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:
gsinghpal
2026-06-04 17:31:17 -04:00
parent dcd4955bb7
commit ba6aeaaca9
7 changed files with 108 additions and 28 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Certificates', 'name': 'Fusion Plating — Certificates',
'version': '19.0.9.4.0', 'version': '19.0.10.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """ 'description': """

View File

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

View File

@@ -521,10 +521,12 @@ class FpCertificate(models.Model):
# effect retroactively at issue time. # effect retroactively at issue time.
if (not rec.contact_partner_id if (not rec.contact_partner_id
and rec.partner_id and rec.partner_id
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields and 'x_fc_default_coc_contact_ids' in rec.partner_id._fields
and rec.partner_id.x_fc_default_coc_contact_id): 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.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 # Lazy-fill the signer from the LIVE company owner (Settings
# "Certificate Owner") when no per-cert / per-spec signer was # "Certificate Owner") when no per-cert / per-spec signer was
@@ -578,13 +580,13 @@ class FpCertificate(models.Model):
'(Settings > Fusion Plating).' '(Settings > Fusion Plating).'
) % {'name': rec.name or rec.display_name}) ) % {'name': rec.name or rec.display_name})
# Customer contact — the named recipient printed on the # Customer contact — the named recipient printed on the
# cert and emailed when it ships. Auto-filled from # cert and emailed when it ships. Auto-filled from the FIRST
# partner.x_fc_default_coc_contact_id when set. # of partner.x_fc_default_coc_contact_ids when set.
if not rec.contact_partner_id: if not rec.contact_partner_id:
raise UserError(_( raise UserError(_(
'Cannot issue certificate "%(name)s" — Customer ' 'Cannot issue certificate "%(name)s" — Customer '
'Contact is not set.\n\nPick the recipient contact, ' '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".' '"%(cust)s".'
) % { ) % {
'name': rec.name or rec.display_name, 'name': rec.name or rec.display_name,
@@ -1114,11 +1116,21 @@ class FpCertificate(models.Model):
"""Open email composer with the certificate PDF attached.""" """Open email composer with the certificate PDF attached."""
self.ensure_one() self.ensure_one()
template = self.env.ref('mail.email_compose_message_wizard_form', raise_if_not_found=False) 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 = { ctx = {
'default_model': 'fp.certificate', 'default_model': 'fp.certificate',
'default_res_ids': self.ids, 'default_res_ids': self.ids,
'default_composition_mode': 'comment', '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: if self.attachment_id:
ctx['default_attachment_ids'] = [self.attachment_id.id] ctx['default_attachment_ids'] = [self.attachment_id.id]

View File

@@ -128,17 +128,24 @@ class ResPartner(models.Model):
'who require specific NIST or DFARS language.', 'who require specific NIST or DFARS language.',
) )
# ---- Default CoC contact (cert addressee + email recipient) ---------- # ---- Default CoC contacts (cert addressees + email recipients) -------
# The single named contact printed on the CoC and used as the email # One or more named contacts who receive this customer's CoC. The first
# default when the cert ships. Sales sets it once per customer. # is the primary (printed on the cert + pre-fills cert.contact_partner_id
# Falls back to manual selection at action_issue time if blank. # when a job ships); the rest are CC'd when the CoC is emailed. Sales
x_fc_default_coc_contact_id = fields.Many2one( # 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', '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)]", domain="[('parent_id', '=', id), ('is_company', '=', False)]",
tracking=True, tracking=True,
help='Default contact the Certificate of Conformance is addressed ' help='Contacts the Certificate of Conformance is addressed to and '
'to and emailed to. Pre-fills cert.contact_partner_id when a ' 'emailed to. The first contact is the primary (printed on the '
'job ships. Leave blank to force the manager to pick at ' 'cert and pre-filled as the cert contact when a job ships); '
'issue time. Must be a child contact of this company.', '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.',
) )

View File

@@ -44,15 +44,17 @@
<field name="x_fc_send_customer_specific" widget="boolean_toggle"/> <field name="x_fc_send_customer_specific" widget="boolean_toggle"/>
</group> </group>
</group> </group>
<separator string="Default CoC Contact"/> <separator string="Default CoC Contacts"/>
<p class="text-muted"> <p class="text-muted">
The named contact this customer's CoC is addressed The contacts this customer's CoC is addressed to and
to and emailed to. Pre-fills cert records when a emailed to. The first is the primary (printed on the
job ships. Leave blank to force the manager to pick cert); the rest are copied (CC) when the CoC is sent.
at issue time. Pre-fills cert records when a job ships. Leave blank
to force the manager to pick at issue time.
</p> </p>
<group> <group>
<field name="x_fc_default_coc_contact_id" <field name="x_fc_default_coc_contact_ids"
widget="many2many_tags"
options="{'no_create': True}"/> options="{'no_create': True}"/>
</group> </group>
<separator string="Cert Statement Override (Sub 12c+)"/> <separator string="Cert Statement Override (Sub 12c+)"/>

View File

@@ -2678,7 +2678,7 @@ class FpJob(models.Model):
(a per-spec override). Left empty (a per-spec override). Left empty
otherwise so the LIVE company owner otherwise so the LIVE company owner
resolves at render / issue time. 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 - nc_quantity ← qty_scrapped + qty_visual_insp_rejects
Honours part.certificate_requirement (coc / coc_thickness / Honours part.certificate_requirement (coc / coc_thickness /
@@ -2713,8 +2713,10 @@ class FpJob(models.Model):
signer = spec.signer_user_id signer = spec.signer_user_id
# Contact: per-customer default; blank means manager picks at issue. # Contact: per-customer default; blank means manager picks at issue.
contact = False contact = False
if 'x_fc_default_coc_contact_id' in self.partner_id._fields: if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
contact = self.partner_id.x_fc_default_coc_contact_id # 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: scrapped + visual rejects. Both NULL-safe.
nc_qty = int( nc_qty = int(
(self.qty_scrapped or 0) (self.qty_scrapped or 0)

View File

@@ -617,7 +617,7 @@ class TestCertCreationAndGates(TransactionCase):
'name': 'CertCust', 'name': 'CertCust',
'is_company': True, 'is_company': True,
'x_fc_send_coc': 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.contact.parent_id = cls.partner.id
cls.product = cls.env['product.product'].create({ cls.product = cls.env['product.product'].create({