feat(certificates): multiple Default CoC Contacts per customer (M2o -> M2m)
res.partner.x_fc_default_coc_contact_id (single Many2one) becomes x_fc_default_coc_contact_ids (self-referential Many2many 'Default CoC Contacts') so a customer can list several contacts who need the CoC. - res.partner: M2m field (rel fp_default_coc_contact_rel) + many2many_tags. - Cert: contact_partner_id (primary addressee printed on the cert) is set to the FIRST CoC contact at job creation + lazy-filled at issue. - Send: action_send_to_customer pre-fills the email composer with ALL the customer's CoC contacts (primary + the rest), falling back to the company. - fp.job cert-default resolution + the action_issue gate wording updated. - Migration 19.0.10.2.0: copies each partner's old single value into the new M2m, then drops the orphaned column. Deployed + verified on entech: migration copied 2 existing values, old column dropped, field is M2m, send pre-fills all contacts. entech-only part_line_ids / multi-part resolver preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.9.4.0',
|
||||
'version': '19.0.10.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Migrate the single Default CoC Contact (Many2one) to the multi-contact
|
||||
Many2many.
|
||||
|
||||
The field x_fc_default_coc_contact_id (Many2one column on res_partner) was
|
||||
renamed to x_fc_default_coc_contact_ids (self-referential Many2many, rel
|
||||
table fp_default_coc_contact_rel). Odoo creates the new rel table during the
|
||||
schema-update phase but leaves the old column orphaned. Copy each partner's
|
||||
single contact into the new M2m so existing per-customer CoC routing carries
|
||||
over, then drop the dead column. Idempotent + guarded on column existence.
|
||||
"""
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# Old column still present (Odoo doesn't drop removed-field columns)?
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'res_partner'
|
||||
AND column_name = 'x_fc_default_coc_contact_id'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
return
|
||||
# New M2m rel table created by the schema update before this runs.
|
||||
cr.execute("""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'fp_default_coc_contact_rel'
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
_logger.warning(
|
||||
'fp_default_coc_contact_rel missing — skipping CoC contact '
|
||||
'migration (rel table not created yet).')
|
||||
return
|
||||
# Copy the single value into the M2m (skip rows already present so a
|
||||
# re-run is harmless).
|
||||
cr.execute("""
|
||||
INSERT INTO fp_default_coc_contact_rel (partner_id, contact_id)
|
||||
SELECT p.id, p.x_fc_default_coc_contact_id
|
||||
FROM res_partner p
|
||||
WHERE p.x_fc_default_coc_contact_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fp_default_coc_contact_rel r
|
||||
WHERE r.partner_id = p.id
|
||||
AND r.contact_id = p.x_fc_default_coc_contact_id)
|
||||
""")
|
||||
moved = cr.rowcount
|
||||
cr.execute(
|
||||
"ALTER TABLE res_partner DROP COLUMN IF EXISTS "
|
||||
"x_fc_default_coc_contact_id")
|
||||
_logger.info(
|
||||
'CoC contact migration: copied %s single Default-CoC-Contact value(s) '
|
||||
'into the new x_fc_default_coc_contact_ids M2m, dropped old column.',
|
||||
moved)
|
||||
@@ -521,10 +521,12 @@ class FpCertificate(models.Model):
|
||||
# effect retroactively at issue time.
|
||||
if (not rec.contact_partner_id
|
||||
and rec.partner_id
|
||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
||||
and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_ids):
|
||||
# Primary = first of the customer's CoC contacts; the rest
|
||||
# are CC'd at send (action_send_to_customer).
|
||||
rec.contact_partner_id = (
|
||||
rec.partner_id.x_fc_default_coc_contact_id
|
||||
rec.partner_id.x_fc_default_coc_contact_ids[:1]
|
||||
)
|
||||
# Lazy-fill the signer from the LIVE company owner (Settings
|
||||
# "Certificate Owner") when no per-cert / per-spec signer was
|
||||
@@ -578,13 +580,13 @@ class FpCertificate(models.Model):
|
||||
'(Settings > Fusion Plating).'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Customer contact — the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from
|
||||
# partner.x_fc_default_coc_contact_id when set.
|
||||
# cert and emailed when it ships. Auto-filled from the FIRST
|
||||
# of partner.x_fc_default_coc_contact_ids when set.
|
||||
if not rec.contact_partner_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact, '
|
||||
'or configure a Default CoC Contact on customer '
|
||||
'or configure Default CoC Contacts on customer '
|
||||
'"%(cust)s".'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
@@ -1114,11 +1116,21 @@ class FpCertificate(models.Model):
|
||||
"""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)
|
||||
# CoC goes to ALL the customer's CoC contacts (the cert's primary
|
||||
# contact + the rest of partner.x_fc_default_coc_contact_ids),
|
||||
# falling back to the company. Every contact who needs the cert is
|
||||
# pre-filled on the composer.
|
||||
recipients = self.contact_partner_id
|
||||
if (self.partner_id
|
||||
and 'x_fc_default_coc_contact_ids' in self.partner_id._fields):
|
||||
recipients |= self.partner_id.x_fc_default_coc_contact_ids
|
||||
partner_ids = recipients.ids or (
|
||||
[self.partner_id.id] if self.partner_id else [])
|
||||
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 [],
|
||||
'default_partner_ids': partner_ids,
|
||||
}
|
||||
if self.attachment_id:
|
||||
ctx['default_attachment_ids'] = [self.attachment_id.id]
|
||||
|
||||
@@ -128,17 +128,24 @@ class ResPartner(models.Model):
|
||||
'who require specific NIST or DFARS language.',
|
||||
)
|
||||
|
||||
# ---- Default CoC contact (cert addressee + email recipient) ----------
|
||||
# The single named contact printed on the CoC and used as the email
|
||||
# default when the cert ships. Sales sets it once per customer.
|
||||
# Falls back to manual selection at action_issue time if blank.
|
||||
x_fc_default_coc_contact_id = fields.Many2one(
|
||||
# ---- Default CoC contacts (cert addressees + email recipients) -------
|
||||
# One or more named contacts who receive this customer's CoC. The first
|
||||
# is the primary (printed on the cert + pre-fills cert.contact_partner_id
|
||||
# when a job ships); the rest are CC'd when the CoC is emailed. Sales
|
||||
# sets the list once per customer. Falls back to manual selection at
|
||||
# action_issue time if blank. Self-referential M2m (renamed from the old
|
||||
# single Many2one x_fc_default_coc_contact_id — see migration 19.0.10.2.0).
|
||||
x_fc_default_coc_contact_ids = fields.Many2many(
|
||||
'res.partner',
|
||||
string='Default CoC Contact',
|
||||
relation='fp_default_coc_contact_rel',
|
||||
column1='partner_id', column2='contact_id',
|
||||
string='Default CoC Contacts',
|
||||
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
||||
tracking=True,
|
||||
help='Default contact the Certificate of Conformance is addressed '
|
||||
'to and emailed to. Pre-fills cert.contact_partner_id when a '
|
||||
'job ships. Leave blank to force the manager to pick at '
|
||||
'issue time. Must be a child contact of this company.',
|
||||
help='Contacts the Certificate of Conformance is addressed to and '
|
||||
'emailed to. The first contact is the primary (printed on the '
|
||||
'cert and pre-filled as the cert contact when a job ships); '
|
||||
'the rest are copied (CC) when the CoC is sent. Leave blank to '
|
||||
'force the manager to pick at issue time. Child contacts of '
|
||||
'this company only.',
|
||||
)
|
||||
|
||||
@@ -44,15 +44,17 @@
|
||||
<field name="x_fc_send_customer_specific" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Default CoC Contact"/>
|
||||
<separator string="Default CoC Contacts"/>
|
||||
<p class="text-muted">
|
||||
The named contact this customer's CoC is addressed
|
||||
to and emailed to. Pre-fills cert records when a
|
||||
job ships. Leave blank to force the manager to pick
|
||||
at issue time.
|
||||
The contacts this customer's CoC is addressed to and
|
||||
emailed to. The first is the primary (printed on the
|
||||
cert); the rest are copied (CC) when the CoC is sent.
|
||||
Pre-fills cert records when a job ships. Leave blank
|
||||
to force the manager to pick at issue time.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_default_coc_contact_id"
|
||||
<field name="x_fc_default_coc_contact_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<separator string="Cert Statement Override (Sub 12c+)"/>
|
||||
|
||||
@@ -2678,7 +2678,7 @@ class FpJob(models.Model):
|
||||
(a per-spec override). Left empty
|
||||
otherwise so the LIVE company owner
|
||||
resolves at render / issue time.
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_ids[:1]
|
||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||
|
||||
Honours part.certificate_requirement (coc / coc_thickness /
|
||||
@@ -2713,8 +2713,10 @@ class FpJob(models.Model):
|
||||
signer = spec.signer_user_id
|
||||
# Contact: per-customer default; blank means manager picks at issue.
|
||||
contact = False
|
||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
||||
if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
|
||||
# Primary = first of the customer's CoC contacts; the rest are
|
||||
# CC'd at send (fp.certificate.action_send_to_customer).
|
||||
contact = self.partner_id.x_fc_default_coc_contact_ids[:1]
|
||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||
nc_qty = int(
|
||||
(self.qty_scrapped or 0)
|
||||
|
||||
@@ -617,7 +617,7 @@ class TestCertCreationAndGates(TransactionCase):
|
||||
'name': 'CertCust',
|
||||
'is_company': True,
|
||||
'x_fc_send_coc': True,
|
||||
'x_fc_default_coc_contact_id': cls.contact.id,
|
||||
'x_fc_default_coc_contact_ids': [(6, 0, [cls.contact.id])],
|
||||
})
|
||||
cls.contact.parent_id = cls.partner.id
|
||||
cls.product = cls.env['product.product'].create({
|
||||
|
||||
Reference in New Issue
Block a user