feat(certificates): cert Customer Contact is multi-contact, auto-filled, sends to all
fp.certificate.contact_partner_id (single 'Customer Contact') becomes contact_partner_ids (Many2many) — same shape as the partner's Default CoC Contacts, as requested. - Auto-populate: at job creation (fp.job cert resolution) + lazy-fill at issue, contact_partner_ids = the customer's x_fc_default_coc_contact_ids (ALL). - Send: action_send_to_customer pre-fills the composer with exactly the cert's contact_partner_ids, so the CoC goes to all the defined clients (fallback: company). - Primary: the FIRST contact prints on the CoC + is gated for email; report uses contact_partner_ids[:1]. - Gate: requires >=1 Customer Contact + the primary has an email. - View: many2many_tags. - Migration 19.0.10.3.0: copies each cert's old single contact into the new M2m, drops the orphaned column. Deployed + verified on entech: migration copied 16 certs, old column dropped, field is M2m, send pre-fills the cert contacts, CoC report renders. entech-only part_line_ids 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',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.10.2.0',
|
'version': '19.0.10.3.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': """
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
"""Migrate the cert's single Customer Contact (Many2one) to a Many2many.
|
||||||
|
|
||||||
|
fp.certificate.contact_partner_id (single Many2one column) was renamed to
|
||||||
|
contact_partner_ids (Many2many -> res.partner, rel
|
||||||
|
fp_certificate_contact_partner_rel) so a cert can carry every contact who
|
||||||
|
receives the CoC. Odoo creates the new rel table during the schema-update
|
||||||
|
phase but leaves the old column orphaned. Copy each cert's single contact
|
||||||
|
into the new M2m (becomes the primary / printed addressee), then drop the
|
||||||
|
dead column. Guarded + idempotent.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
cr.execute("""
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'fp_certificate'
|
||||||
|
AND column_name = 'contact_partner_id'
|
||||||
|
""")
|
||||||
|
if not cr.fetchone():
|
||||||
|
return
|
||||||
|
cr.execute("""
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'fp_certificate_contact_partner_rel'
|
||||||
|
""")
|
||||||
|
if not cr.fetchone():
|
||||||
|
_logger.warning(
|
||||||
|
'fp_certificate_contact_partner_rel missing — skipping cert '
|
||||||
|
'customer-contact migration (rel table not created yet).')
|
||||||
|
return
|
||||||
|
cr.execute("""
|
||||||
|
INSERT INTO fp_certificate_contact_partner_rel (cert_id, partner_id)
|
||||||
|
SELECT c.id, c.contact_partner_id
|
||||||
|
FROM fp_certificate c
|
||||||
|
WHERE c.contact_partner_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM fp_certificate_contact_partner_rel r
|
||||||
|
WHERE r.cert_id = c.id
|
||||||
|
AND r.partner_id = c.contact_partner_id)
|
||||||
|
""")
|
||||||
|
moved = cr.rowcount
|
||||||
|
cr.execute(
|
||||||
|
"ALTER TABLE fp_certificate DROP COLUMN IF EXISTS contact_partner_id")
|
||||||
|
_logger.info(
|
||||||
|
'Cert customer-contact migration: copied %s single contact_partner_id '
|
||||||
|
'value(s) into the new contact_partner_ids M2m, dropped old column.',
|
||||||
|
moved)
|
||||||
@@ -58,11 +58,18 @@ class FpCertificate(models.Model):
|
|||||||
string='Customer Job No.',
|
string='Customer Job No.',
|
||||||
help="Customer's internal job / traveler reference.",
|
help="Customer's internal job / traveler reference.",
|
||||||
)
|
)
|
||||||
contact_partner_id = fields.Many2one(
|
contact_partner_ids = fields.Many2many(
|
||||||
'res.partner', string='Customer Contact',
|
'res.partner',
|
||||||
|
relation='fp_certificate_contact_partner_rel',
|
||||||
|
column1='cert_id', column2='partner_id',
|
||||||
|
string='Customer Contact',
|
||||||
domain="[('parent_id', '=', partner_id)]",
|
domain="[('parent_id', '=', partner_id)]",
|
||||||
help="Specific contact person at the customer for this certificate. "
|
help="Contacts at the customer who receive this certificate. "
|
||||||
'Their name, email, and phone are printed on the CoC.',
|
"Auto-filled from the customer's Default CoC Contacts when a "
|
||||||
|
'job ships. The first is the primary (its name, email and '
|
||||||
|
'phone print on the CoC); ALL of them are emailed when the '
|
||||||
|
'cert is sent to the customer. (Renamed from the single '
|
||||||
|
'contact_partner_id — see migration 19.0.10.3.0.)',
|
||||||
)
|
)
|
||||||
issued_by_id = fields.Many2one(
|
issued_by_id = fields.Many2one(
|
||||||
'res.users', string='Issued By', default=lambda self: self.env.user,
|
'res.users', string='Issued By', default=lambda self: self.env.user,
|
||||||
@@ -519,14 +526,15 @@ class FpCertificate(models.Model):
|
|||||||
# was configured would still trip the gate even after sales
|
# was configured would still trip the gate even after sales
|
||||||
# set the default. Robust-by-construction: the defaults take
|
# set the default. Robust-by-construction: the defaults take
|
||||||
# effect retroactively at issue time.
|
# effect retroactively at issue time.
|
||||||
if (not rec.contact_partner_id
|
if (not rec.contact_partner_ids
|
||||||
and rec.partner_id
|
and rec.partner_id
|
||||||
and 'x_fc_default_coc_contact_ids' 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_ids):
|
and rec.partner_id.x_fc_default_coc_contact_ids):
|
||||||
# Primary = first of the customer's CoC contacts; the rest
|
# Auto-fill ALL the customer's CoC contacts. The first is
|
||||||
# are CC'd at send (action_send_to_customer).
|
# the primary (printed on the CoC); every contact is emailed
|
||||||
rec.contact_partner_id = (
|
# when the cert is sent (action_send_to_customer).
|
||||||
rec.partner_id.x_fc_default_coc_contact_ids[:1]
|
rec.contact_partner_ids = (
|
||||||
|
rec.partner_id.x_fc_default_coc_contact_ids
|
||||||
)
|
)
|
||||||
# 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
|
||||||
@@ -582,25 +590,25 @@ class FpCertificate(models.Model):
|
|||||||
# 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 the FIRST
|
# cert and emailed when it ships. Auto-filled from the FIRST
|
||||||
# of partner.x_fc_default_coc_contact_ids when set.
|
# of partner.x_fc_default_coc_contact_ids when set.
|
||||||
if not rec.contact_partner_id:
|
if not rec.contact_partner_ids:
|
||||||
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(s), '
|
||||||
'or configure Default CoC Contacts 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,
|
||||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||||
})
|
})
|
||||||
if not (rec.contact_partner_id.email or '').strip():
|
if not (rec.contact_partner_ids[:1].email or '').strip():
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
'Cannot issue certificate "%(name)s" — contact '
|
'Cannot issue certificate "%(name)s" — primary contact '
|
||||||
'"%(c)s" has no email address.\n\nAdd an email '
|
'"%(c)s" has no email address.\n\nAdd an email to the '
|
||||||
'to the contact before issuing (the cert is sent '
|
'contact before issuing (the cert is sent by email '
|
||||||
'by email post-issue).'
|
'post-issue).'
|
||||||
) % {
|
) % {
|
||||||
'name': rec.name or rec.display_name,
|
'name': rec.name or rec.display_name,
|
||||||
'c': rec.contact_partner_id.name,
|
'c': rec.contact_partner_ids[:1].name,
|
||||||
})
|
})
|
||||||
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
|
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
|
||||||
# are manual-attach only — operator uploads supplier doc /
|
# are manual-attach only — operator uploads supplier doc /
|
||||||
@@ -1116,14 +1124,11 @@ 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
|
# Send to ALL the Customer Contacts defined on this cert (auto-filled
|
||||||
# contact + the rest of partner.x_fc_default_coc_contact_ids),
|
# from the customer's Default CoC Contacts at job creation), falling
|
||||||
# falling back to the company. Every contact who needs the cert is
|
# back to the company. The CoC goes to exactly the contacts shown on
|
||||||
# pre-filled on the composer.
|
# the cert's Customer Contact field.
|
||||||
recipients = self.contact_partner_id
|
recipients = self.contact_partner_ids
|
||||||
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 (
|
partner_ids = recipients.ids or (
|
||||||
[self.partner_id.id] if self.partner_id else [])
|
[self.partner_id.id] if self.partner_id else [])
|
||||||
ctx = {
|
ctx = {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class TestActionIssueGates(TransactionCase):
|
|||||||
'spec_reference': 'AMS 2404',
|
'spec_reference': 'AMS 2404',
|
||||||
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
||||||
'certified_by_id': self.signer.id,
|
'certified_by_id': self.signer.id,
|
||||||
'contact_partner_id': self.contact_with_email.id,
|
'contact_partner_ids': [(6, 0, [self.contact_with_email.id])],
|
||||||
}
|
}
|
||||||
vals.update(kw)
|
vals.update(kw)
|
||||||
return self.env['fp.certificate'].create(vals)
|
return self.env['fp.certificate'].create(vals)
|
||||||
@@ -85,13 +85,13 @@ class TestActionIssueGates(TransactionCase):
|
|||||||
# ---- new gate: contact_partner_id ----
|
# ---- new gate: contact_partner_id ----
|
||||||
|
|
||||||
def test_blocks_on_missing_contact(self):
|
def test_blocks_on_missing_contact(self):
|
||||||
cert = self._make_cert(contact_partner_id=False)
|
cert = self._make_cert(contact_partner_ids=False)
|
||||||
with self.assertRaises(UserError) as exc:
|
with self.assertRaises(UserError) as exc:
|
||||||
cert.action_issue()
|
cert.action_issue()
|
||||||
self.assertIn('Customer Contact', str(exc.exception))
|
self.assertIn('Customer Contact', str(exc.exception))
|
||||||
|
|
||||||
def test_blocks_on_contact_without_email(self):
|
def test_blocks_on_contact_without_email(self):
|
||||||
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
|
cert = self._make_cert(contact_partner_ids=[(6, 0, [self.contact_no_email.id])])
|
||||||
with self.assertRaises(UserError) as exc:
|
with self.assertRaises(UserError) as exc:
|
||||||
cert.action_issue()
|
cert.action_issue()
|
||||||
self.assertIn('no email', str(exc.exception))
|
self.assertIn('no email', str(exc.exception))
|
||||||
@@ -113,7 +113,7 @@ class TestActionIssueGates(TransactionCase):
|
|||||||
spec_reference=False,
|
spec_reference=False,
|
||||||
process_description=False,
|
process_description=False,
|
||||||
certified_by_id=False,
|
certified_by_id=False,
|
||||||
contact_partner_id=False,
|
contact_partner_ids=False,
|
||||||
)
|
)
|
||||||
with self.assertRaises(UserError) as exc:
|
with self.assertRaises(UserError) as exc:
|
||||||
cert.action_issue()
|
cert.action_issue()
|
||||||
|
|||||||
@@ -101,7 +101,8 @@
|
|||||||
<group>
|
<group>
|
||||||
<field name="certificate_type"/>
|
<field name="certificate_type"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="contact_partner_id"
|
<field name="contact_partner_ids"
|
||||||
|
widget="many2many_tags"
|
||||||
options="{'no_create': True}"
|
options="{'no_create': True}"
|
||||||
invisible="not partner_id"/>
|
invisible="not partner_id"/>
|
||||||
<field name="sale_order_id"/>
|
<field name="sale_order_id"/>
|
||||||
|
|||||||
@@ -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_ids[:1]
|
- contact_partner_ids ← partner.x_fc_default_coc_contact_ids (all)
|
||||||
- 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 /
|
||||||
@@ -2714,9 +2714,10 @@ class FpJob(models.Model):
|
|||||||
# 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_ids' in self.partner_id._fields:
|
if 'x_fc_default_coc_contact_ids' in self.partner_id._fields:
|
||||||
# Primary = first of the customer's CoC contacts; the rest are
|
# ALL the customer's CoC contacts -> the cert's Customer Contact
|
||||||
# CC'd at send (fp.certificate.action_send_to_customer).
|
# M2m. First is the primary (printed on the CoC); every contact
|
||||||
contact = self.partner_id.x_fc_default_coc_contact_ids[:1]
|
# is emailed at send (fp.certificate.action_send_to_customer).
|
||||||
|
contact = self.partner_id.x_fc_default_coc_contact_ids
|
||||||
# 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)
|
||||||
@@ -2786,8 +2787,8 @@ class FpJob(models.Model):
|
|||||||
vals['process_description'] = recipe.name or ''
|
vals['process_description'] = recipe.name or ''
|
||||||
if 'certified_by_id' in Cert._fields and signer:
|
if 'certified_by_id' in Cert._fields and signer:
|
||||||
vals['certified_by_id'] = signer.id
|
vals['certified_by_id'] = signer.id
|
||||||
if 'contact_partner_id' in Cert._fields and contact:
|
if 'contact_partner_ids' in Cert._fields and contact:
|
||||||
vals['contact_partner_id'] = contact.id
|
vals['contact_partner_ids'] = [(6, 0, contact.ids)]
|
||||||
if 'entech_wo_number' in Cert._fields:
|
if 'entech_wo_number' in Cert._fields:
|
||||||
vals['entech_wo_number'] = self.name or ''
|
vals['entech_wo_number'] = self.name or ''
|
||||||
cert = Cert.create(vals)
|
cert = Cert.create(vals)
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ class TestCertCreationAndGates(TransactionCase):
|
|||||||
cert = self.env['fp.certificate'].search([
|
cert = self.env['fp.certificate'].search([
|
||||||
('x_fc_job_id', '=', job.id),
|
('x_fc_job_id', '=', job.id),
|
||||||
])
|
])
|
||||||
self.assertEqual(cert.contact_partner_id, self.contact)
|
self.assertEqual(cert.contact_partner_ids, self.contact)
|
||||||
|
|
||||||
def test_create_cert_computes_nc_quantity(self):
|
def test_create_cert_computes_nc_quantity(self):
|
||||||
job = self._make_job(
|
job = self._make_job(
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class TestRecipeCertSuppression(TransactionCase):
|
|||||||
'certificate_type': 'nadcap_cert',
|
'certificate_type': 'nadcap_cert',
|
||||||
'state': 'draft',
|
'state': 'draft',
|
||||||
'partner_id': self.partner.id,
|
'partner_id': self.partner.id,
|
||||||
'contact_partner_id': self.partner.id,
|
'contact_partner_ids': [(6, 0, [self.partner.id])],
|
||||||
'spec_reference': 'AMS 2404',
|
'spec_reference': 'AMS 2404',
|
||||||
'process_description': 'TEST PROCESS',
|
'process_description': 'TEST PROCESS',
|
||||||
'certified_by_id': self.env.user.id,
|
'certified_by_id': self.env.user.id,
|
||||||
|
|||||||
@@ -216,7 +216,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="width: 50%; vertical-align: top;">
|
<td style="width: 50%; vertical-align: top;">
|
||||||
<t t-set="contact" t-value="doc.contact_partner_id or doc.partner_id"/>
|
<t t-set="contact" t-value="doc.contact_partner_ids[:1] or doc.partner_id"/>
|
||||||
<div>
|
<div>
|
||||||
<span class="fp-bl-en">Contact Name</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Nom du contact</span>:
|
<span class="fp-bl-en">Contact Name</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Nom du contact</span>:
|
||||||
<t t-esc="contact.name or ''"/>
|
<t t-esc="contact.name or ''"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user