diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index fd7a00b2..3dbc56d4 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.10.2.0', + 'version': '19.0.10.3.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.3.0/post-migrate.py b/fusion_plating/fusion_plating_certificates/migrations/19.0.10.3.0/post-migrate.py new file mode 100644 index 00000000..274e6096 --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/migrations/19.0.10.3.0/post-migrate.py @@ -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) diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py index 9370ac5a..914d547a 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py @@ -58,11 +58,18 @@ class FpCertificate(models.Model): string='Customer Job No.', help="Customer's internal job / traveler reference.", ) - contact_partner_id = fields.Many2one( - 'res.partner', string='Customer Contact', + contact_partner_ids = fields.Many2many( + 'res.partner', + relation='fp_certificate_contact_partner_rel', + column1='cert_id', column2='partner_id', + string='Customer Contact', domain="[('parent_id', '=', partner_id)]", - help="Specific contact person at the customer for this certificate. " - 'Their name, email, and phone are printed on the CoC.', + help="Contacts at the customer who receive this certificate. " + "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( '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 # set the default. Robust-by-construction: the defaults take # effect retroactively at issue time. - if (not rec.contact_partner_id + if (not rec.contact_partner_ids and rec.partner_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_ids[:1] + # Auto-fill ALL the customer's CoC contacts. The first is + # the primary (printed on the CoC); every contact is emailed + # when the cert is sent (action_send_to_customer). + rec.contact_partner_ids = ( + rec.partner_id.x_fc_default_coc_contact_ids ) # Lazy-fill the signer from the LIVE company owner (Settings # "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 # 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: + if not rec.contact_partner_ids: raise UserError(_( '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 ' '"%(cust)s".' ) % { 'name': rec.name or rec.display_name, '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(_( - 'Cannot issue certificate "%(name)s" — contact ' - '"%(c)s" has no email address.\n\nAdd an email ' - 'to the contact before issuing (the cert is sent ' - 'by email post-issue).' + 'Cannot issue certificate "%(name)s" — primary contact ' + '"%(c)s" has no email address.\n\nAdd an email to the ' + 'contact before issuing (the cert is sent by email ' + 'post-issue).' ) % { '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) # are manual-attach only — operator uploads supplier doc / @@ -1116,14 +1124,11 @@ 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 + # Send to ALL the Customer Contacts defined on this cert (auto-filled + # from the customer's Default CoC Contacts at job creation), falling + # back to the company. The CoC goes to exactly the contacts shown on + # the cert's Customer Contact field. + recipients = self.contact_partner_ids partner_ids = recipients.ids or ( [self.partner_id.id] if self.partner_id else []) ctx = { diff --git a/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py b/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py index 016947bb..ce212926 100644 --- a/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py +++ b/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py @@ -50,7 +50,7 @@ class TestActionIssueGates(TransactionCase): 'spec_reference': 'AMS 2404', 'process_description': 'ELECTROLESS NICKEL PER AMS 2404', '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) return self.env['fp.certificate'].create(vals) @@ -85,13 +85,13 @@ class TestActionIssueGates(TransactionCase): # ---- new gate: contact_partner_id ---- 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: cert.action_issue() self.assertIn('Customer Contact', str(exc.exception)) 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: cert.action_issue() self.assertIn('no email', str(exc.exception)) @@ -113,7 +113,7 @@ class TestActionIssueGates(TransactionCase): spec_reference=False, process_description=False, certified_by_id=False, - contact_partner_id=False, + contact_partner_ids=False, ) with self.assertRaises(UserError) as exc: cert.action_issue() diff --git a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml index 8f084d3e..1fdd506b 100644 --- a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml +++ b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml @@ -101,7 +101,8 @@ - diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index 88936e74..77629ff3 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_ids[:1] + - contact_partner_ids ← partner.x_fc_default_coc_contact_ids (all) - nc_quantity ← qty_scrapped + qty_visual_insp_rejects 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 = False 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] + # ALL the customer's CoC contacts -> the cert's Customer Contact + # M2m. First is the primary (printed on the CoC); every contact + # 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 = int( (self.qty_scrapped or 0) @@ -2786,8 +2787,8 @@ class FpJob(models.Model): vals['process_description'] = recipe.name or '' if 'certified_by_id' in Cert._fields and signer: vals['certified_by_id'] = signer.id - if 'contact_partner_id' in Cert._fields and contact: - vals['contact_partner_id'] = contact.id + if 'contact_partner_ids' in Cert._fields and contact: + vals['contact_partner_ids'] = [(6, 0, contact.ids)] if 'entech_wo_number' in Cert._fields: vals['entech_wo_number'] = self.name or '' cert = Cert.create(vals) 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 ea503847..750dcb8b 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 @@ -673,7 +673,7 @@ class TestCertCreationAndGates(TransactionCase): cert = self.env['fp.certificate'].search([ ('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): job = self._make_job( diff --git a/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py b/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py index ee4cc4d8..7bdf2fad 100644 --- a/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py +++ b/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py @@ -130,7 +130,7 @@ class TestRecipeCertSuppression(TransactionCase): 'certificate_type': 'nadcap_cert', 'state': 'draft', 'partner_id': self.partner.id, - 'contact_partner_id': self.partner.id, + 'contact_partner_ids': [(6, 0, [self.partner.id])], 'spec_reference': 'AMS 2404', 'process_description': 'TEST PROCESS', 'certified_by_id': self.env.user.id, diff --git a/fusion_plating/fusion_plating_reports/report/report_coc.xml b/fusion_plating/fusion_plating_reports/report/report_coc.xml index 6af57ce5..c59d6ca6 100644 --- a/fusion_plating/fusion_plating_reports/report/report_coc.xml +++ b/fusion_plating/fusion_plating_reports/report/report_coc.xml @@ -216,7 +216,7 @@ - +
Contact Name/Nom du contact: