diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index b109ae77..f030d50d 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.1.3.0', + 'version': '19.0.1.4.0', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'description': """ @@ -32,6 +32,7 @@ full design rationale and §6.2 of the implementation plan for task list. ], 'data': [ 'security/ir.model.access.csv', + 'views/res_config_settings_views.xml', ], 'installable': True, 'application': False, diff --git a/fusion_plating/fusion_plating_jobs/models/__init__.py b/fusion_plating/fusion_plating_jobs/models/__init__.py index 789b1929..f245dd30 100644 --- a/fusion_plating/fusion_plating_jobs/models/__init__.py +++ b/fusion_plating/fusion_plating_jobs/models/__init__.py @@ -7,3 +7,5 @@ from . import fp_job from . import fp_job_node_override +from . import res_config_settings +from . import sale_order diff --git a/fusion_plating/fusion_plating_jobs/models/res_config_settings.py b/fusion_plating/fusion_plating_jobs/models/res_config_settings.py new file mode 100644 index 00000000..df30d2c6 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/models/res_config_settings.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# x_fc_use_native_jobs — company-level setting that controls whether +# SO confirmation creates a native fp.job record (this module) or +# the legacy mrp.production / mrp.workorder records (bridge_mrp). +# +# Default: False (legacy MO flow). Phase 9 cutover flips this to True +# on entech. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + x_fc_use_native_jobs = fields.Boolean( + string='Use Native Plating Jobs', + config_parameter='fusion_plating_jobs.use_native_jobs', + help='When enabled, SO confirmation creates fp.job records ' + 'instead of mrp.production. Phase-2 migration toggle.', + ) diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py new file mode 100644 index 00000000..edf22be1 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# sale.order.action_confirm hook — creates fp.job records when the +# x_fc_use_native_jobs setting is True. Mirrors bridge_mrp's +# _fp_auto_create_mo but creates fp.job instead of mrp.production. +# +# When the setting is False (default), this hook is a no-op and +# bridge_mrp's MO-creation hook handles the flow. + +import logging +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def action_confirm(self): + result = super().action_confirm() + # Only run when the native flag is on + ICP = self.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True': + for so in self: + so._fp_auto_create_job() + return result + + def _fp_auto_create_job(self): + """Create fp.job(s) from the SO's plating lines. + + Lines that share a `x_fc_wo_group_tag` collapse into one job; + untagged lines get one job per line. Mirrors bridge_mrp's + _fp_auto_create_mo grouping logic. + """ + self.ensure_one() + Job = self.env['fp.job'].sudo() + + # Idempotency: skip if a job already references this SO + existing = Job.search([('sale_order_id', '=', self.id)], limit=1) + if existing: + return + + # Find plating lines (those with a part_catalog_id or coating_config_id) + plating_lines = self.order_line.filtered( + lambda l: ( + ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id) + or ('x_fc_coating_config_id' in l._fields and l.x_fc_coating_config_id) + ) + ) + if not plating_lines: + _logger.info('SO %s: no plating lines, skipping job creation.', self.name) + return + + # Group by x_fc_wo_group_tag (untagged → distinct group per line) + groups = {} # tag → recordset of lines + untagged_idx = 0 + for line in plating_lines: + tag = ( + 'x_fc_wo_group_tag' in line._fields and line.x_fc_wo_group_tag + ) or False + if not tag: + untagged_idx += 1 + tag = '__untagged_%d' % untagged_idx + groups[tag] = groups.get(tag, self.env['sale.order.line']) | line + + # Create a job per group + for tag, lines in groups.items(): + first_line = lines[0] + qty = sum(lines.mapped('product_uom_qty')) + part = ( + 'x_fc_part_catalog_id' in first_line._fields + and first_line.x_fc_part_catalog_id + or False + ) + coating = ( + 'x_fc_coating_config_id' in first_line._fields + and first_line.x_fc_coating_config_id + or False + ) + # Recipe lookup: from coating, fallback to part + recipe = False + if coating and 'recipe_id' in coating._fields and coating.recipe_id: + recipe = coating.recipe_id + if not recipe and part and 'default_process_id' in part._fields and part.default_process_id: + recipe = part.default_process_id + if not recipe and part and 'recipe_id' in part._fields and part.recipe_id: + recipe = part.recipe_id + + vals = { + 'partner_id': self.partner_id.id, + 'product_id': first_line.product_id.id if first_line.product_id else False, + 'qty': qty, + 'origin': self.name, + 'sale_order_id': self.id, + 'sale_order_line_ids': [(6, 0, lines.ids)], + 'date_deadline': self.commitment_date or self.date_order, + } + if part: + vals['part_catalog_id'] = part.id + if coating: + vals['coating_config_id'] = coating.id + if recipe: + vals['recipe_id'] = recipe.id + + # Customer spec / facility / manager — copy from SO if present + if 'x_fc_customer_spec_id' in self._fields and self.x_fc_customer_spec_id: + vals['customer_spec_id'] = self.x_fc_customer_spec_id.id + if 'x_fc_facility_id' in self._fields and self.x_fc_facility_id: + vals['facility_id'] = self.x_fc_facility_id.id + if 'x_fc_manager_id' in self._fields and self.x_fc_manager_id: + vals['manager_id'] = self.x_fc_manager_id.id + + # Quoted revenue: sum line totals + vals['quoted_revenue'] = sum(lines.mapped('price_subtotal')) + + job = Job.create(vals) + _logger.info( + 'SO %s: created fp.job %s (qty=%s, recipe=%s)', + self.name, job.name, qty, (recipe.name if recipe else '-'), + ) + return True diff --git a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py index e0dc2346..52b7943c 100644 --- a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py +++ b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py @@ -260,3 +260,75 @@ class TestFpJobStepsGenerator(TransactionCase): self.assertEqual(first_step.duration_expected, 30.0) # Work centre resolved by code from legacy model self.assertEqual(first_step.work_centre_id, self.wc) + + +class TestSoConfirmHook(TransactionCase): + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'C'}) + self.product = self.env['product.product'].create({'name': 'P'}) + self.ICP = self.env['ir.config_parameter'].sudo() + + def _make_so_with_plating_line(self, **line_vals): + # client_order_ref satisfies the fusion_plating_invoicing PO# gate. + so_vals = { + 'partner_id': self.partner.id, + 'client_order_ref': 'TEST-PO-001', + } + so = self.env['sale.order'].create(so_vals) + line_defaults = { + 'order_id': so.id, + 'product_id': self.product.id, + 'product_uom_qty': 5.0, + 'price_unit': 10.0, + } + line_defaults.update(line_vals) + self.env['sale.order.line'].create(line_defaults) + return so + + def test_flag_off_no_job_created(self): + # Default flag is False + self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'False') + so = self._make_so_with_plating_line() + so.action_confirm() + jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)]) + self.assertFalse(jobs) + + def test_flag_on_creates_job(self): + self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'True') + # Need a plating line — add x_fc_part_catalog_id if available + if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields: + partner_for_part = self.env['res.partner'].create({'name': 'PartOwner'}) + part = self.env['fp.part.catalog'].create({ + 'name': 'TPart', 'part_number': 'TP-1', + 'partner_id': partner_for_part.id, + }) + so = self._make_so_with_plating_line(x_fc_part_catalog_id=part.id) + so.action_confirm() + jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)]) + self.assertEqual(len(jobs), 1) + self.assertEqual(jobs.qty, 5.0) + self.assertEqual(jobs.part_catalog_id, part) + self.assertEqual(jobs.origin, so.name) + else: + self.skipTest('x_fc_part_catalog_id field not present on sale.order.line') + + def test_flag_on_idempotent(self): + self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'True') + if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields: + partner_for_part = self.env['res.partner'].create({'name': 'PO'}) + part = self.env['fp.part.catalog'].create({ + 'name': 'IdemPart', 'part_number': 'IP-1', + 'partner_id': partner_for_part.id, + }) + so = self._make_so_with_plating_line(x_fc_part_catalog_id=part.id) + so.action_confirm() + count_after_first = self.env['fp.job'].search_count( + [('sale_order_id', '=', so.id)]) + # Calling action_confirm again should NOT create a duplicate + so._fp_auto_create_job() + count_after_second = self.env['fp.job'].search_count( + [('sale_order_id', '=', so.id)]) + self.assertEqual(count_after_first, count_after_second) + else: + self.skipTest('x_fc_part_catalog_id field not present') diff --git a/fusion_plating/fusion_plating_jobs/views/res_config_settings_views.xml b/fusion_plating/fusion_plating_jobs/views/res_config_settings_views.xml new file mode 100644 index 00000000..c9a4bb81 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/views/res_config_settings_views.xml @@ -0,0 +1,21 @@ + + + + res.config.settings.fp.jobs + res.config.settings + + + + + + + + + + + + + +