From ba6aeaaca9f438e66b2c800e540efb38a466c66c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 17:31:17 -0400 Subject: [PATCH] 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) --- .../__manifest__.py | 2 +- .../migrations/19.0.10.2.0/post-migrate.py | 57 +++++++++++++++++++ .../models/fp_certificate.py | 26 ++++++--- .../models/res_partner.py | 27 +++++---- .../views/res_partner_views.xml | 14 +++-- .../fusion_plating_jobs/models/fp_job.py | 8 ++- .../tests/test_fp_job_milestone_cascade.py | 2 +- 7 files changed, 108 insertions(+), 28 deletions(-) create mode 100644 fusion_plating/fusion_plating_certificates/migrations/19.0.10.2.0/post-migrate.py diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index 356f2810..fd7a00b2 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating_certificates/migrations/19.0.10.2.0/post-migrate.py b/fusion_plating/fusion_plating_certificates/migrations/19.0.10.2.0/post-migrate.py new file mode 100644 index 00000000..3e007852 --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/migrations/19.0.10.2.0/post-migrate.py @@ -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) diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py index e177417b..9370ac5a 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py @@ -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] diff --git a/fusion_plating/fusion_plating_certificates/models/res_partner.py b/fusion_plating/fusion_plating_certificates/models/res_partner.py index 5bdbd0a4..9e203999 100644 --- a/fusion_plating/fusion_plating_certificates/models/res_partner.py +++ b/fusion_plating/fusion_plating_certificates/models/res_partner.py @@ -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.', ) diff --git a/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml b/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml index 2c85fe74..d4de2d39 100644 --- a/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml +++ b/fusion_plating/fusion_plating_certificates/views/res_partner_views.xml @@ -44,15 +44,17 @@ - +

- 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.

- diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index 9958b8d7..88936e74 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -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) diff --git a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py index aaf28e9d..ea503847 100644 --- a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py +++ b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py @@ -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({