From f1bf5b214cac6318848bfda71f5889aad9ebca99 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 3 Jun 2026 22:31:27 -0400 Subject: [PATCH] feat(fusion_plating_jobs): multi-part cert creation + requirement union Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fusion_plating_jobs/models/fp_job.py | 120 +++++++++++++----- .../fusion_plating_jobs/tests/__init__.py | 1 + .../tests/test_combined_cert_creation.py | 59 +++++++++ 3 files changed, 151 insertions(+), 29 deletions(-) create mode 100644 fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index 31883e0f..309ba9a0 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -609,38 +609,47 @@ class FpJob(models.Model): matches the defensive pattern used elsewhere in this file. """ self.ensure_one() - # ---- Step 1 — partner + part baseline ---- - req = ( - self.part_catalog_id - and self.part_catalog_id.certificate_requirement - ) or 'inherit' - if req == 'inherit': - wanted = set() + # ---- Step 1 — partner + part baseline (union across all parts) ---- + def _partner_inherit_set(): + s = set() p = self.partner_id if p: if p.x_fc_send_coc: - wanted.add('coc') + s.add('coc') if p.x_fc_send_thickness_report: - wanted.add('thickness_report') - # Three aerospace/defence partner toggles. Field guards - # let this module load even if fusion_plating_certificates - # is at an older version that pre-dates the new fields. - if ('x_fc_send_nadcap_cert' in p._fields - and p.x_fc_send_nadcap_cert): - wanted.add('nadcap_cert') - if ('x_fc_send_mill_test' in p._fields - and p.x_fc_send_mill_test): - wanted.add('mill_test') - if ('x_fc_send_customer_specific' in p._fields - and p.x_fc_send_customer_specific): - wanted.add('customer_specific') - else: - wanted = { - 'none': set(), - 'coc': {'coc'}, + s.add('thickness_report') + if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert: + s.add('nadcap_cert') + if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test: + s.add('mill_test') + if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific: + s.add('customer_specific') + return s + + def _explicit_set(req): + return { + 'none': set(), 'coc': {'coc'}, 'coc_thickness': {'coc', 'thickness_report'}, }.get(req, {'coc'}) + parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id') + if not parts and self.part_catalog_id: + parts = self.part_catalog_id + if not parts: + parts = [False] + wanted = set() + inherit = None + for part in parts: + req = (part.certificate_requirement + if part and 'certificate_requirement' in part._fields + else 'inherit') or 'inherit' + if req == 'inherit': + if inherit is None: + inherit = _partner_inherit_set() + wanted |= inherit + else: + wanted |= _explicit_set(req) + # ---- Step 2 — recipe suppression (suppress-only) ---- recipe = self.recipe_id if recipe: @@ -2655,6 +2664,58 @@ class FpJob(models.Model): self.name, e, ) + def _fp_cert_source_lines(self): + """Plating SO lines this job covers (one cert part-line each).""" + self.ensure_one() + lines = self.sale_order_line_ids + if not lines and self.sale_order_id: + lines = self.sale_order_id.order_line + return lines.filtered( + lambda l: not l.display_type + and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) + + def _fp_format_spec_ref(self, spec): + """Format 'CODE Rev X' from a customer spec (or '').""" + if not spec: + return '' + ref = spec.code or '' + if 'revision' in spec._fields and spec.revision: + ref = (f'{ref} Rev {spec.revision}' if ref + else f'Rev {spec.revision}') + return ref + + def _fp_build_cert_part_commands(self): + """O2M create commands for fp.certificate.part — one per line.""" + self.ensure_one() + cmds, seq = [], 10 + for sol in self._fp_cert_source_lines(): + part = sol.x_fc_part_catalog_id + spec = (sol.x_fc_customer_spec_id + if 'x_fc_customer_spec_id' in sol._fields else False) + serials = '' + if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids: + serials = ', '.join(sol.x_fc_serial_ids.mapped('name')) + # fp_customer_description() is a method (configurator), not a + # field — use hasattr, not a _fields check. + desc = (sol.fp_customer_description() + if hasattr(sol, 'fp_customer_description') + else (sol.name or '')) + cmds.append((0, 0, { + 'sequence': seq, + 'sale_order_line_id': sol.id, + 'part_catalog_id': part.id if part else False, + 'part_number': (part.part_number if part else '') or '', + 'part_name': (part.name if part else '') or '', + 'description': desc, + 'serial': serials, + 'customer_spec_id': spec.id if spec else False, + 'spec_reference': self._fp_format_spec_ref(spec), + 'quantity_shipped': int(sol.product_uom_qty or 0), + 'nc_quantity': 0, + })) + seq += 10 + return cmds + def _fp_create_certificates(self): """Auto-create one draft fp.certificate per type returned by _resolve_required_cert_types. Idempotent per type — re-running @@ -2742,10 +2803,7 @@ class FpJob(models.Model): # spec_reference is what action_issue blocks on. # Format spec.code + revision for the cert text. if spec and 'spec_reference' in Cert._fields: - ref = spec.code or '' - if spec.revision: - ref = (f'{ref} Rev {spec.revision}' - if ref else f'Rev {spec.revision}') + ref = self._fp_format_spec_ref(spec) if ref: vals['spec_reference'] = ref if 'customer_spec_id' in Cert._fields: @@ -2781,6 +2839,10 @@ class FpJob(models.Model): vals['contact_partner_id'] = contact.id if 'entech_wo_number' in Cert._fields: vals['entech_wo_number'] = self.name or '' + if 'part_line_ids' in Cert._fields: + part_cmds = self._fp_build_cert_part_commands() + if part_cmds: + vals['part_line_ids'] = part_cmds cert = Cert.create(vals) self.message_post(body=Markup(_( '%(t)s %(n)s auto-created (draft). Issuer ' diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py index 970f2ee2..404fccef 100644 --- a/fusion_plating/fusion_plating_jobs/tests/__init__.py +++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py @@ -10,3 +10,4 @@ from . import test_autopause_cron from . import test_post_shop_states from . import test_recipe_cert_suppression from . import test_order_ship_state +from . import test_combined_cert_creation diff --git a/fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py b/fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py new file mode 100644 index 00000000..f43afee4 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase + + +class TestCombinedCertCreation(TransactionCase): + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({ + 'name': 'CertCust', + 'x_fc_send_coc': True, # drives the coc requirement + }) + self.product = self.env['product.product'].create({'name': 'W'}) + self.part_a = self.env['fp.part.catalog'].create({ + 'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'}) + self.part_b = self.env['fp.part.catalog'].create({ + 'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'}) + self.so = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'order_line': [ + (0, 0, {'product_id': self.product.id, 'product_uom_qty': 3, + 'x_fc_part_catalog_id': self.part_a.id}), + (0, 0, {'product_id': self.product.id, 'product_uom_qty': 2, + 'x_fc_part_catalog_id': self.part_b.id}), + ], + }) + + def test_combined_cert_has_one_line_per_so_line(self): + job = self.env['fp.job'].create({ + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 5.0, + 'sale_order_id': self.so.id, + 'part_catalog_id': self.part_a.id, + 'sale_order_line_ids': [(6, 0, self.so.order_line.ids)], + }) + job._fp_create_certificates() + cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)]) + self.assertEqual(len(cert), 1, 'one combined CoC') + self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line') + self.assertEqual( + set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'}) + a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1') + self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line') + + def test_part_lines_fall_back_to_so_order_line(self): + # Job without an explicit sale_order_line_ids M2M still builds + # one part-line per plating line via the SO order_line fallback. + job = self.env['fp.job'].create({ + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 5.0, + 'sale_order_id': self.so.id, + 'part_catalog_id': self.part_a.id, + }) + job._fp_create_certificates() + cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)]) + self.assertEqual(len(cert), 1) + self.assertEqual(len(cert.part_line_ids), 2, + 'falls back to SO order_line when no M2M lines set')