feat(jobs): add x_fc_use_native_jobs flag + SO confirm hook (Task 2.5)
Settings flag controls which SO confirm path runs. Default False keeps the legacy bridge_mrp / mrp.production flow on entech. Setting True diverts confirm into fp.job creation. Both hooks coexist — bridge_mrp's _fp_auto_create_mo and the new _fp_auto_create_job — but only one creates records per SO confirm (controlled by the flag). The new _fp_auto_create_job mirrors bridge_mrp's grouping logic (x_fc_wo_group_tag), recipe resolution (coating → part), and traceability fields (origin, sale_order_line_ids). Settings UI shows the flag in a 'Fusion Plating Jobs' app section of the standard Configuration menu. 3 new tests cover: flag off no-op, flag on creates job, idempotency. Manifest 19.0.1.3.0 → 19.0.1.4.0. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -7,3 +7,5 @@
|
||||
|
||||
from . import fp_job
|
||||
from . import fp_job_node_override
|
||||
from . import res_config_settings
|
||||
from . import sale_order
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
123
fusion_plating/fusion_plating_jobs/models/sale_order.py
Normal file
123
fusion_plating/fusion_plating_jobs/models/sale_order.py
Normal file
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_res_config_settings_jobs" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.fp.jobs</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Fusion Plating Jobs" string="Fusion Plating Jobs" name="fusion_plating_jobs">
|
||||
<block title="Native Job Migration" name="fp_jobs_migration">
|
||||
<setting id="fp_use_native_jobs"
|
||||
string="Use Native Plating Jobs"
|
||||
help="When enabled, SO confirmation creates fp.job records instead of mrp.production. Phase-2 migration toggle.">
|
||||
<field name="x_fc_use_native_jobs"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user