Files
Odoo-Modules/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
gsinghpal a623c6684d fix(fusion_plating): bug review fixes + progress/net-terms invoicing + formal CoC rebuild
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>
2026-04-17 01:18:22 -04:00

314 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 api, fields, models, _
from odoo.exceptions import UserError
class FpCertificate(models.Model):
"""Unified certificate registry.
Logs every quality document issued to customers: CoC, thickness
reports, mill test reports, Nadcap certs, and customer-specific
formats. Auto-created when reports are generated.
"""
_name = 'fp.certificate'
_description = 'Fusion Plating — Certificate'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'issue_date desc, id desc'
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
certificate_type = fields.Selection(
[
('coc', 'Certificate of Conformance'),
('thickness_report', 'Thickness Report'),
('mill_test', 'Mill Test Report'),
('nadcap_cert', 'Nadcap Certificate'),
('customer_specific', 'Customer-Specific'),
],
string='Type', required=True, default='coc', tracking=True,
)
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True, tracking=True,
domain="[('customer_rank', '>', 0)]",
)
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
production_id = fields.Many2one('mrp.production', string='Manufacturing Order')
portal_job_id = fields.Many2one('fusion.plating.portal.job', string='Portal Job')
part_number = fields.Char(string='Part Number', help='Denormalized for fast search.')
process_description = fields.Char(
string='Process', help='e.g. "ELECTROLESS NICKEL PLATING PER AMS 2404"',
)
spec_reference = fields.Char(string='Spec Reference')
po_number = fields.Char(string='Customer PO #')
entech_wo_number = fields.Char(string='Entech WO #')
quantity_shipped = fields.Integer(string='Qty Shipped')
nc_quantity = fields.Integer(
string='NC Qty',
help='Non-conforming quantity — parts that failed inspection / rework.',
)
customer_job_no = fields.Char(
string='Customer Job No.',
help="Customer's internal job / traveler reference.",
)
contact_partner_id = fields.Many2one(
'res.partner', string='Customer Contact',
domain="[('parent_id', '=', partner_id)]",
help="Specific contact person at the customer for this certificate. "
'Their name, email, and phone are printed on the CoC.',
)
issued_by_id = fields.Many2one(
'res.users', string='Issued By', default=lambda self: self.env.user,
)
certified_by_id = fields.Many2one(
'res.users', string='Certified By', help='Signing authority (e.g. Quality Manager).',
)
issue_date = fields.Date(string='Issue Date', default=fields.Date.today, tracking=True)
attachment_id = fields.Many2one('ir.attachment', string='Certificate PDF')
thickness_reading_ids = fields.One2many(
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
)
# ---- Material traceability (T2.3) ----
batch_ids = fields.Many2many(
'fusion.plating.batch', compute='_compute_batch_ids',
string='Batches', help='All batches used for this MO.',
)
batch_count = fields.Integer(
string='Batches', compute='_compute_batch_ids',
)
bath_ids = fields.Many2many(
'fusion.plating.bath', compute='_compute_batch_ids',
string='Baths Used',
)
@api.depends('production_id')
def _compute_batch_ids(self):
Batch = self.env.get('fusion.plating.batch')
Bath = self.env['fusion.plating.bath']
empty_batch = self.env['fusion.plating.batch']
for rec in self:
if Batch is not None and rec.production_id:
batches = Batch.search([
('production_id', '=', rec.production_id.id),
])
rec.batch_ids = batches
rec.batch_count = len(batches)
rec.bath_ids = batches.mapped('bath_id')
else:
rec.batch_ids = empty_batch
rec.batch_count = 0
rec.bath_ids = Bath
state = fields.Selection(
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
string='Status', default='draft', tracking=True, required=True,
)
void_reason = fields.Text(string='Void Reason')
notes = fields.Html(string='Notes')
# ----- Computed stats from readings -------------------------------------
reading_count = fields.Integer(
string='Readings', compute='_compute_reading_stats', store=True,
)
mean_nip_mils = fields.Float(
string='Mean NiP (mils)', compute='_compute_reading_stats',
store=True, digits=(10, 4),
)
# ---- SPC (T2.1) ----
spec_min_mils = fields.Float(
string='Spec Min (mils)', digits=(10, 4),
help='Lower specification limit pulled from the coating config. '
'Override per certificate if needed.',
)
spec_max_mils = fields.Float(
string='Spec Max (mils)', digits=(10, 4),
help='Upper specification limit pulled from the coating config.',
)
std_dev_mils = fields.Float(
string='Std Dev (mils)', compute='_compute_reading_stats',
store=True, digits=(10, 4),
)
min_reading_mils = fields.Float(
string='Min Reading', compute='_compute_reading_stats',
store=True, digits=(10, 4),
)
max_reading_mils = fields.Float(
string='Max Reading', compute='_compute_reading_stats',
store=True, digits=(10, 4),
)
cpk = fields.Float(
string='Cpk', compute='_compute_reading_stats', store=True, digits=(6, 3),
help='Process capability index. <1.0 = incapable · 1.0-1.33 = marginal · '
'≥1.33 = capable · ≥1.67 = excellent.',
)
cpk_status = fields.Selection(
[('incapable', 'Incapable'),
('marginal', 'Marginal'),
('capable', 'Capable'),
('excellent', 'Excellent'),
('insufficient', 'Insufficient Data')],
string='Cpk Status', compute='_compute_reading_stats', store=True,
)
trend_alert = fields.Selection(
[('ok', 'OK'),
('warning', 'Trend Detected'),
('alert', 'Out of Control')],
string='Trend Alert', compute='_compute_reading_stats', store=True,
help='Western Electric rule 1 (any point beyond 3σ) or rule 4 '
'(8 consecutive points on one side of centre).',
)
trend_explanation = fields.Char(
string='Trend Note', compute='_compute_reading_stats', store=True,
)
@api.depends('thickness_reading_ids', 'thickness_reading_ids.nip_mils',
'spec_min_mils', 'spec_max_mils')
def _compute_reading_stats(self):
for rec in self:
readings = rec.thickness_reading_ids
rec.reading_count = len(readings)
values = [r.nip_mils for r in readings if r.nip_mils]
n = len(values)
if n == 0:
rec.mean_nip_mils = 0.0
rec.std_dev_mils = 0.0
rec.min_reading_mils = 0.0
rec.max_reading_mils = 0.0
rec.cpk = 0.0
rec.cpk_status = 'insufficient'
rec.trend_alert = 'ok'
rec.trend_explanation = ''
continue
mean = sum(values) / n
rec.mean_nip_mils = round(mean, 4)
rec.min_reading_mils = round(min(values), 4)
rec.max_reading_mils = round(max(values), 4)
# Sample standard deviation (Bessel's correction)
if n >= 2:
sq_diff = sum((v - mean) ** 2 for v in values)
sigma = (sq_diff / (n - 1)) ** 0.5
else:
sigma = 0.0
rec.std_dev_mils = round(sigma, 4)
# Cpk — requires spec limits + non-zero sigma
usl = rec.spec_max_mils or 0.0
lsl = rec.spec_min_mils or 0.0
if n < 5 or sigma == 0 or (usl == 0 and lsl == 0):
rec.cpk = 0.0
rec.cpk_status = 'insufficient'
else:
if usl and lsl:
cpu = (usl - mean) / (3.0 * sigma)
cpl = (mean - lsl) / (3.0 * sigma)
cpk = min(cpu, cpl)
elif usl:
cpk = (usl - mean) / (3.0 * sigma)
else:
cpk = (mean - lsl) / (3.0 * sigma)
rec.cpk = round(cpk, 3)
if cpk < 1.0:
rec.cpk_status = 'incapable'
elif cpk < 1.33:
rec.cpk_status = 'marginal'
elif cpk < 1.67:
rec.cpk_status = 'capable'
else:
rec.cpk_status = 'excellent'
# Trend detection (Western Electric rules)
# Rule 1: any point outside 3σ from mean
# Rule 4: 8+ consecutive on one side of mean
alert = 'ok'
explanation = ''
if sigma > 0:
three_sigma = 3.0 * sigma
for v in values:
if abs(v - mean) > three_sigma:
alert = 'alert'
explanation = 'Rule 1: reading beyond 3σ from mean'
break
if alert == 'ok' and n >= 8:
# Check last 8 readings for all-above or all-below
last_eight = values[-8:]
if all(v > mean for v in last_eight):
alert = 'warning'
explanation = 'Rule 4: 8 consecutive readings above mean'
elif all(v < mean for v in last_eight):
alert = 'warning'
explanation = 'Rule 4: 8 consecutive readings below mean'
rec.trend_alert = alert
rec.trend_explanation = explanation
# ----- Sequence + spec-limit auto-fill ---------------------------------
@api.model_create_multi
def create(self, vals_list):
SaleOrder = self.env['sale.order']
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
# Pull thickness spec limits from coating config if not set
already_set = vals.get('spec_min_mils') or vals.get('spec_max_mils')
if not already_set and vals.get('sale_order_id'):
so = SaleOrder.browse(vals['sale_order_id'])
cfg = getattr(so, 'x_fc_coating_config_id', False)
if cfg and cfg.thickness_uom == 'mils':
vals.setdefault('spec_min_mils', cfg.thickness_min or 0.0)
vals.setdefault('spec_max_mils', cfg.thickness_max or 0.0)
return super().create(vals_list)
# ----- State actions ----------------------------------------------------
def action_issue(self):
for rec in self:
if rec.state != 'draft':
raise UserError(_('Only draft certificates can be issued.'))
rec.state = 'issued'
rec.message_post(body=_('Certificate issued.'))
def action_void(self):
for rec in self:
if rec.state != 'issued':
raise UserError(_('Only issued certificates can be voided.'))
if not rec.void_reason:
raise UserError(_('Please enter a void reason before voiding.'))
rec.state = 'voided'
rec.message_post(body=_('Certificate voided. Reason: %s') % rec.void_reason)
def action_view_traceability(self):
"""Show the batches (and their chemistry logs) that produced
these parts — auditor's dream, customer's RMA friend."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Traceability — %s') % self.name,
'res_model': 'fusion.plating.batch',
'view_mode': 'list,form',
'domain': [('id', 'in', self.batch_ids.ids)],
'target': 'current',
}
def action_send_to_customer(self):
"""Open email composer with the certificate PDF attached."""
self.ensure_one()
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False)
ctx = {
'default_model': 'fp.certificate',
'default_res_ids': self.ids,
'default_composition_mode': 'comment',
'default_partner_ids': [self.partner_id.id] if self.partner_id else [],
}
if self.attachment_id:
ctx['default_attachment_ids'] = [self.attachment_id.id]
return {
'type': 'ir.actions.act_window',
'res_model': 'mail.compose.message',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}