feat(certificates): fp.certificate model + views + menu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-12 19:44:00 -04:00
parent ec8b26f8c8
commit 54540d5b1e
3 changed files with 361 additions and 3 deletions

View File

@@ -3,4 +3,126 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
# Placeholder — fp.certificate model will be implemented in a subsequent task.
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')
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',
)
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',
)
mean_nip_mils = fields.Float(
string='Mean NiP (mils)', compute='_compute_reading_stats', digits=(10, 4),
)
@api.depends('thickness_reading_ids', 'thickness_reading_ids.nip_mils')
def _compute_reading_stats(self):
for rec in self:
readings = rec.thickness_reading_ids
rec.reading_count = len(readings)
if readings:
nip_values = readings.mapped('nip_mils')
rec.mean_nip_mils = sum(nip_values) / len(nip_values) if nip_values else 0
else:
rec.mean_nip_mils = 0
# ----- Sequence ---------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('fp.certificate') or 'New'
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_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,
}

View File

@@ -1,2 +1,196 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo></odoo>
<odoo>
<!-- ============================================================ -->
<!-- LIST VIEW -->
<!-- ============================================================ -->
<record id="fp_certificate_view_list" model="ir.ui.view">
<field name="name">fp.certificate.list</field>
<field name="model">fp.certificate</field>
<field name="arch" type="xml">
<list default_order="issue_date desc"
decoration-muted="state == 'draft'"
decoration-success="state == 'issued'"
decoration-danger="state == 'voided'">
<field name="issue_date"/>
<field name="name"/>
<field name="certificate_type" widget="badge"/>
<field name="partner_id"/>
<field name="part_number"/>
<field name="po_number"/>
<field name="entech_wo_number"/>
<field name="process_description"/>
<field name="quantity_shipped"/>
<field name="issued_by_id"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-success="state == 'issued'"
decoration-danger="state == 'voided'"/>
</list>
</field>
</record>
<!-- ============================================================ -->
<!-- FORM VIEW -->
<!-- ============================================================ -->
<record id="fp_certificate_view_form" model="ir.ui.view">
<field name="name">fp.certificate.form</field>
<field name="model">fp.certificate</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_issue" string="Issue"
type="object" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_void" string="Void"
type="object" class="btn-danger"
invisible="state != 'issued'"/>
<button name="action_send_to_customer" string="Send to Customer"
type="object"
invisible="state != 'issued'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,issued"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="certificate_type"/>
<field name="partner_id"/>
<field name="sale_order_id"/>
<field name="production_id"/>
<field name="portal_job_id"/>
<field name="issue_date"/>
</group>
<group>
<field name="part_number"/>
<field name="po_number"/>
<field name="entech_wo_number"/>
<field name="process_description"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
</group>
</group>
<group>
<group>
<field name="issued_by_id"/>
<field name="certified_by_id"/>
</group>
<group>
<field name="mean_nip_mils" readonly="1"/>
<field name="reading_count" readonly="1"/>
</group>
</group>
<notebook>
<page string="Thickness Readings" name="readings">
<field name="thickness_reading_ids">
<list editable="bottom">
<field name="reading_number"/>
<field name="nip_mils"/>
<field name="ni_percent"/>
<field name="p_percent"/>
<field name="position_label"/>
<field name="equipment_model"/>
<field name="operator_id"/>
<field name="reading_datetime"/>
</list>
</field>
</page>
<page string="Certificate PDF" name="pdf">
<group>
<field name="attachment_id"/>
</group>
</page>
<page string="Void" name="void"
invisible="state != 'voided'">
<group>
<field name="void_reason"/>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
<chatter>
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</chatter>
</form>
</field>
</record>
<!-- ============================================================ -->
<!-- SEARCH VIEW -->
<!-- ============================================================ -->
<record id="fp_certificate_view_search" model="ir.ui.view">
<field name="name">fp.certificate.search</field>
<field name="model">fp.certificate</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<field name="part_number"/>
<field name="po_number"/>
<field name="entech_wo_number"/>
<separator/>
<filter name="filter_coc" string="CoC"
domain="[('certificate_type', '=', 'coc')]"/>
<filter name="filter_thickness" string="Thickness Report"
domain="[('certificate_type', '=', 'thickness_report')]"/>
<filter name="filter_mill_test" string="Mill Test"
domain="[('certificate_type', '=', 'mill_test')]"/>
<filter name="filter_nadcap" string="Nadcap"
domain="[('certificate_type', '=', 'nadcap_cert')]"/>
<separator/>
<filter name="filter_draft" string="Draft"
domain="[('state', '=', 'draft')]"/>
<filter name="filter_issued" string="Issued"
domain="[('state', '=', 'issued')]"/>
<filter name="filter_voided" string="Voided"
domain="[('state', '=', 'voided')]"/>
<separator/>
<filter name="filter_this_week" string="This Week"
domain="[('issue_date', '>=', (context_today() - datetime.timedelta(weeks=1)).strftime('%Y-%m-%d'))]"/>
<filter name="filter_this_month" string="This Month"
domain="[('issue_date', '>=', time.strftime('%Y-%m-01'))]"/>
<separator/>
<group expand="1" string="Group By">
<filter name="group_customer" string="Customer"
context="{'group_by': 'partner_id'}"/>
<filter name="group_type" string="Type"
context="{'group_by': 'certificate_type'}"/>
<filter name="group_issued_by" string="Issued By"
context="{'group_by': 'issued_by_id'}"/>
<filter name="group_month" string="Month"
context="{'group_by': 'issue_date:month'}"/>
</group>
</search>
</field>
</record>
<!-- ============================================================ -->
<!-- WINDOW ACTION -->
<!-- ============================================================ -->
<record id="action_fp_certificate" model="ir.actions.act_window">
<field name="name">Certificates</field>
<field name="res_model">fp.certificate</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="fp_certificate_view_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first certificate
</p>
<p>
Certificates of Conformance, thickness reports, and other quality
documents are tracked here.
</p>
</field>
</record>
</odoo>

View File

@@ -1,2 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo></odoo>
<odoo>
<!-- Actions BEFORE menus -->
<record id="action_fp_certificate_coc" model="ir.actions.act_window">
<field name="name">Certificates of Conformance</field>
<field name="res_model">fp.certificate</field>
<field name="view_mode">list,form</field>
<field name="domain">[('certificate_type', '=', 'coc')]</field>
</record>
<record id="action_fp_certificate_thickness" model="ir.actions.act_window">
<field name="name">Thickness Reports</field>
<field name="res_model">fp.certificate</field>
<field name="view_mode">list,form</field>
<field name="domain">[('certificate_type', '=', 'thickness_report')]</field>
</record>
<!-- Menu under Fusion Plating root -->
<menuitem id="menu_fp_certificates"
name="Certificates"
parent="fusion_plating.menu_fp_root"
sequence="15"
groups="fusion_plating.group_fusion_plating_supervisor"/>
<menuitem id="menu_fp_certificates_all"
name="All Certificates"
parent="menu_fp_certificates"
action="action_fp_certificate"
sequence="10"/>
<menuitem id="menu_fp_certificates_coc"
name="Certificates of Conformance"
parent="menu_fp_certificates"
action="action_fp_certificate_coc"
sequence="20"/>
<menuitem id="menu_fp_certificates_thickness"
name="Thickness Reports"
parent="menu_fp_certificates"
action="action_fp_certificate_thickness"
sequence="30"/>
</odoo>