Bug review fixes (found by code review + live QWeb error):
- report_fp_sale.xml: product_uom → product_uom_id (Odoo 19 renamed;
was raising KeyError during PDF render, blocking all sale-order prints)
- mrp_production.button_mark_done: add idempotency guard on delivery
auto-create (was duplicating on every re-close)
- fp.certificate._compute_batch_ids: use empty recordset instead of
False for Many2many computed fields
- fp_notification_template._collect_attachments: collapse attach_quotation
+ attach_sale_order into a single render so email doesn't double-attach
the same PDF
- fp.operator.certification: SQL unique on computed state was unreliable;
added explicit `revoked` boolean, made state pure-compute, replaced
SQL constraint with @api.constrains that checks active-only uniqueness;
has_active_cert now reads revoked + expires_date directly (no stale
stored state between nightly recomputes)
Two missing invoice strategies implemented + 1 pre-existing deposit bug fix:
- Progress Billing: new x_fc_progress_initial_percent field on sale.order;
_create_progress_initial_invoice bills the configured % on SO confirm
via down-payment wizard, _create_final_balance_invoice bills the
remainder on delivery
- Net Terms: no invoice on confirm; full invoice auto-created when
fusion.plating.delivery.action_mark_delivered fires
- Fix for deposit (pre-existing, silent): sale.advance.payment.inv
reads active_ids at wizard-create time, not on create_invoices();
context was being set on the wrong call, so every deposit attempt
raised "Expected singleton" and message-posted to chatter instead
of actually invoicing
- New fusion_plating_invoicing/models/fp_delivery.py hooks
action_mark_delivered to dispatch final invoice for progress/net_terms
- fp.direct.order.wizard + SO form surface the progress_initial_percent
field (conditional on strategy)
Report styling cleanup:
- Hide DISCOUNT column from sale + invoice landscape reports unless at
least one line has a non-zero discount; colspan auto-adjusts
- Replace hardcoded #0066a1 in all reports with company.primary_color
driven by doc.company_id → company → user.company_id fallback chain,
with #1d1f1e as ultimate fallback; new .fp-header-primary class
exposes the colour for inline section headers (CARGO DESCRIPTION,
PAYMENT DETAILS, OPERATOR SIGN-OFF, etc.) so they retint with the
company theme without template edits
Certificate of Conformance — formal ENTECH-style rebuild:
- New res.company fields: x_fc_owner_user_id (default signer, sig from
hr.employee.signature), x_fc_coc_signature_override (manual upload),
x_fc_{nadcap,as9100,cgp}_logo + _active toggles for accreditation
badges
- New res.config.settings section "Fusion Plating" exposing the above
as configurable blocks; manager-only menu under Configuration →
Fusion Plating Settings
- New fp.certificate fields: nc_quantity, customer_job_no,
contact_partner_id (child contact for Name / Email / Phone block)
- New report_coc_en + report_coc_fr templates (primary): custom header
(company contact | accreditations | company logo), bilingual labels
per variant, customer info block with customer logo, 3-column cert
info table, 6-column line-item table (Part # | Process | Customer
PO | Shipped | NC Qty | Customer Job No.), signature image + bordered
certification statement, footer "Fusion Plating by Nexa Systems"
- Legacy report_coc + report_coc_portrait kept for existing portal-job
bindings (no behaviour change)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
8.9 KiB
Python
226 lines
8.9 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
|
|
|
|
# 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)
|
|
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
|