diff --git a/addons/mrp_subcontracting/__init__.py b/addons/mrp_subcontracting/__init__.py index dfbd96001d5..880e67434db 100644 --- a/addons/mrp_subcontracting/__init__.py +++ b/addons/mrp_subcontracting/__init__.py @@ -13,6 +13,10 @@ def uninstall_hook(cr, registry): warehouses = env["stock.warehouse"].search([]) subcontracting_routes = warehouses.mapped("subcontracting_route_id") warehouses.write({"subcontracting_route_id": False}) + companies = env["res.company"].search([]) + subcontracting_locations = companies.mapped("subcontracting_location_id") + subcontracting_locations.active = False + companies.write({"subcontracting_location_id": False}) operations_type_to_remove = (warehouses.subcontracting_resupply_type_id | warehouses.subcontracting_type_id) operations_type_to_remove.active = False # Fail unlink means that the route is used somewhere (e.g. route_id on stock.rule). In this case diff --git a/addons/mrp_subcontracting/__manifest__.py b/addons/mrp_subcontracting/__manifest__.py index 3813dc5f5ab..c5ec547e89d 100644 --- a/addons/mrp_subcontracting/__manifest__.py +++ b/addons/mrp_subcontracting/__manifest__.py @@ -23,6 +23,7 @@ 'views/mrp_production_views.xml', 'views/subcontracting_portal_views.xml', 'views/subcontracting_portal_templates.xml', + 'views/stock_location_views.xml', 'wizard/stock_picking_return_views.xml', 'report/mrp_report_bom_structure.xml', ], diff --git a/addons/mrp_subcontracting/models/res_company.py b/addons/mrp_subcontracting/models/res_company.py index 583d2413916..58eb602fc9c 100644 --- a/addons/mrp_subcontracting/models/res_company.py +++ b/addons/mrp_subcontracting/models/res_company.py @@ -27,6 +27,7 @@ class ResCompany(models.Model): 'usage': 'internal', 'location_id': parent_location.id, 'company_id': company.id, + 'is_subcontracting_location': True, }) self.env['ir.property']._set_default( "property_stock_subcontractor", diff --git a/addons/mrp_subcontracting/models/res_partner.py b/addons/mrp_subcontracting/models/res_partner.py index e2aa3f1278f..7c46acb5072 100644 --- a/addons/mrp_subcontracting/models/res_partner.py +++ b/addons/mrp_subcontracting/models/res_partner.py @@ -9,6 +9,7 @@ class ResPartner(models.Model): property_stock_subcontractor = fields.Many2one( 'stock.location', string="Subcontractor Location", company_dependent=True, + domain="[('is_subcontracting_location', '=', True)]", help="The stock location used as source and destination when sending\ goods to this contact during a subcontracting process.") is_subcontractor = fields.Boolean( diff --git a/addons/mrp_subcontracting/models/stock_location.py b/addons/mrp_subcontracting/models/stock_location.py index c012885b9a0..f1dac53a052 100644 --- a/addons/mrp_subcontracting/models/stock_location.py +++ b/addons/mrp_subcontracting/models/stock_location.py @@ -1,15 +1,107 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import models +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError class StockLocation(models.Model): _inherit = 'stock.location' + is_subcontracting_location = fields.Boolean( + "Is a Subcontracting Location?", + help="Check this box to create a new dedicated subcontracting location for this company. Note that standard subcontracting routes will be adapted so as to take these into account automatically." + ) + + @api.constrains('is_subcontracting_location', 'usage', 'location_id') + def _check_subcontracting_location(self): + for location in self: + if location == location.company_id.subcontracting_location_id: + raise ValidationError(_("You cannot alter the company's subcontracting location")) + if location.is_subcontracting_location and (location.usage != 'internal' or location.warehouse_id): + raise ValidationError(_("In order to manage stock accurately, subcontracting locations must be type Internal, linked to the appropriate company and not specific to a warehouse.")) + + @api.constrains('is_subcontracting_location') + def _check_is_subcontracting_location(self): + for location in self: + if not location.is_subcontracting_location and self.env['res.partner'].search([('property_stock_subcontractor', '=', location.id)]): + raise ValidationError(_("You cannot change the subcontracting location as it is still linked to a subcontractor partner")) + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + new_subcontracting_locations = res.filtered(lambda l: l.is_subcontracting_location) + new_subcontracting_locations._activate_subcontracting_location_rules() + return res + + def write(self, values): + res = super().write(values) + if 'is_subcontracting_location' in values: + if values['is_subcontracting_location']: + self._activate_subcontracting_location_rules() + else: + self._archive_subcontracting_location_rules() + return res + def _check_access_putaway(self): """ Use sudo mode for subcontractor """ if self.env.user.partner_id.is_subcontractor: return self.sudo() else: return super()._check_access_putaway() + + def _activate_subcontracting_location_rules(self): + """ Create or unarchive rules for the 'custom' subcontracting location(s). + The subcontracting location defined on the company is considered as the 'reference' one. + All rules defined on this 'reference' location will be replicated on 'custom' subcontracting locations. + """ + locations_per_company = {} + for location in self: + if location.is_subcontracting_location and location != location.company_id.subcontracting_location_id: + locations_per_company.setdefault(location.company_id, []).extend(location) + new_rules_vals = [] + rules_to_unarchive = self.env['stock.rule'] + for company, locations in locations_per_company.items(): + reference_location_id = company.subcontracting_location_id + if reference_location_id: + reference_rules_from = self.env['stock.rule'].search([('location_src_id', '=', reference_location_id.id)]) + reference_rules_to = self.env['stock.rule'].search([('location_dest_id', '=', reference_location_id.id)]) + for location in locations: + existing_rules = { + (rule.route_id, rule.picking_type_id, rule.action, rule.location_src_id): rule + for rule in self.env['stock.rule'].with_context(active_test=False).search([('location_src_id', '=', location.id)]) + } + for rule in reference_rules_from: + if (rule.route_id, rule.picking_type_id, rule.action, location) not in existing_rules: + new_rules_vals.append(rule.copy_data({ + 'location_src_id': location.id, + 'name': rule.name.replace(reference_location_id.name, location.name) + })[0]) + else: + existing_rule = existing_rules[(rule.route_id, rule.picking_type_id, rule.action, location)] + if not existing_rule.active: + rules_to_unarchive += existing_rule + existing_rules = { + (rule.route_id, rule.picking_type_id, rule.action, rule.location_dest_id): rule + for rule in self.env['stock.rule'].with_context(active_test=False).search([('location_dest_id', '=', location.id)]) + } + for rule in reference_rules_to: + if (rule.route_id, rule.picking_type_id, rule.action, location) not in existing_rules: + new_rules_vals.append(rule.copy_data({ + 'location_dest_id': location.id, + 'name': rule.name.replace(reference_location_id.name, location.name) + })[0]) + else: + existing_rule = existing_rules[(rule.route_id, rule.picking_type_id, rule.action, location)] + if not existing_rule.active: + rules_to_unarchive += existing_rule + self.env['stock.rule'].create(new_rules_vals) + rules_to_unarchive.action_unarchive() + + def _archive_subcontracting_location_rules(self): + """ Archive subcontracting rules for locations that are no longer 'custom' subcontracting locations.""" + reference_location_ids = self.company_id.subcontracting_location_id + reference_rules = self.env['stock.rule'].search(['|', ('location_src_id', 'in', reference_location_ids.ids), ('location_dest_id', 'in', reference_location_ids.ids)]) + reference_routes = reference_rules.route_id + rules_to_archive = self.env['stock.rule'].search(['&', ('route_id', 'in', reference_routes.ids), '|', ('location_src_id', 'in', self.ids), ('location_dest_id', 'in', self.ids)]) + rules_to_archive.action_archive() diff --git a/addons/mrp_subcontracting/models/stock_move_line.py b/addons/mrp_subcontracting/models/stock_move_line.py index 91def171cf0..9042796d0e3 100644 --- a/addons/mrp_subcontracting/models/stock_move_line.py +++ b/addons/mrp_subcontracting/models/stock_move_line.py @@ -12,7 +12,7 @@ class StockMoveLine(models.Model): def _onchange_serial_number(self): current_location_id = self.location_id res = super()._onchange_serial_number() - if res and not self.lot_name and self.company_id.subcontracting_location_id == current_location_id: + if res and not self.lot_name and current_location_id.is_subcontracting_location: # we want to avoid auto-updating source location in this case + change the warning message self.location_id = current_location_id res['warning']['message'] = res['warning']['message'].split("\n\n", 1)[0] + "\n\n" + \ diff --git a/addons/mrp_subcontracting/models/stock_quant.py b/addons/mrp_subcontracting/models/stock_quant.py index c2b48b2c7fa..05ed58c21bf 100644 --- a/addons/mrp_subcontracting/models/stock_quant.py +++ b/addons/mrp_subcontracting/models/stock_quant.py @@ -14,7 +14,7 @@ class StockQuant(models.Model): if operator not in ['=', '!='] or not isinstance(value, bool): raise UserError(_('Operation not supported')) - subcontract_locations = self.env['res.company'].search([]).subcontracting_location_id + subcontract_locations = self.env['stock.location'].search([('is_subcontracting_location', '=', 'True')]) quant_ids = self.env['stock.quant'].search([ ('location_id', 'in', subcontract_locations.ids), diff --git a/addons/mrp_subcontracting/models/stock_warehouse.py b/addons/mrp_subcontracting/models/stock_warehouse.py index e7477271257..3611636f8dc 100644 --- a/addons/mrp_subcontracting/models/stock_warehouse.py +++ b/addons/mrp_subcontracting/models/stock_warehouse.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models, _ +from odoo import api, fields, models, _ class StockWarehouse(models.Model): @@ -24,6 +24,12 @@ class StockWarehouse(models.Model): 'stock.picking.type', 'Subcontracting Resupply Operation Type', domain=[('code', '=', 'outgoing')]) + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + res._update_subcontracting_locations_rules() + return res + def get_rules_dict(self): result = super(StockWarehouse, self).get_rules_dict() subcontract_location_id = self._get_subcontracting_location() @@ -90,7 +96,7 @@ class StockWarehouse(models.Model): 'auto': 'manual', 'route_id': self._find_global_route('mrp_subcontracting.route_resupply_subcontractor_mto', _('Resupply Subcontractor on Order')).id, - 'name': self._format_rulename(self.lot_stock_id, subcontract_location_id, False), + 'name': self._format_rulename(subcontract_location_id, production_location_id, False), 'location_dest_id': production_location_id.id, 'location_src_id': subcontract_location_id.id, 'picking_type_id': self.subcontracting_resupply_type_id.id @@ -129,16 +135,17 @@ class StockWarehouse(models.Model): def _get_sequence_values(self): values = super(StockWarehouse, self)._get_sequence_values() + count = self.env['ir.sequence'].search_count([('prefix', 'like', self.code + '/SBC%/%')]) values.update({ 'subcontracting_type_id': { 'name': self.name + ' ' + _('Sequence subcontracting'), - 'prefix': self.code + '/SBC/', + 'prefix': self.code + (('/SBC' + str(count) + '/') if count else '/SBC/'), 'padding': 5, 'company_id': self.company_id.id }, 'subcontracting_resupply_type_id': { 'name': self.name + ' ' + _('Sequence Resupply Subcontractor'), - 'prefix': self.code + '/RES/', + 'prefix': self.code + (('/RES' + str(count) + '/') if count else '/RES/'), 'padding': 5, 'company_id': self.company_id.id }, @@ -165,3 +172,10 @@ class StockWarehouse(models.Model): def _get_subcontracting_location(self): return self.company_id.subcontracting_location_id + + def _update_subcontracting_locations_rules(self): + subcontracting_locations = self.env['stock.location'].search([ + ('company_id', 'in', self.company_id.ids), + ('is_subcontracting_location', '=', 'True'), + ]) + subcontracting_locations._activate_subcontracting_location_rules() diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py index 657223c81e8..7ab0ff6c24a 100644 --- a/addons/mrp_subcontracting/tests/test_subcontracting.py +++ b/addons/mrp_subcontracting/tests/test_subcontracting.py @@ -22,6 +22,7 @@ class TestSubcontractingBasic(TransactionCase): self.assertTrue(self.env.company.subcontracting_location_id != company2.subcontracting_location_id) +@tagged('post_install', '-at_install') class TestSubcontractingFlows(TestMrpSubcontractingCommon): def test_flow_1(self): """ Don't tick any route on the components and trigger the creation of the subcontracting @@ -98,22 +99,18 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon): # Tick "resupply subconractor on order" resupply_sub_on_order_route = self.env['stock.route'].search([('name', '=', 'Resupply Subcontractor on Order')]) (self.comp1 + self.comp2).write({'route_ids': [(4, resupply_sub_on_order_route.id, None)]}) - # Create a different subcontract location + # Create a different subcontract location & check rules replication + reference_location_rules_count = self.env['stock.rule'].search_count(['|', ('location_src_id', '=', self.env.company.subcontracting_location_id.id), ('location_dest_id', '=', self.env.company.subcontracting_location_id.id)]) partner_subcontract_location = self.env['stock.location'].create({ 'name': 'Specific partner location', 'location_id': self.env.ref('stock.stock_location_locations_partner').id, 'usage': 'internal', 'company_id': self.env.company.id, + 'is_subcontracting_location': True, }) + custom_location_rules_count = self.env['stock.rule'].search_count(['|', ('location_src_id', '=', partner_subcontract_location.id), ('location_dest_id', '=', partner_subcontract_location.id)]) + self.assertEqual(reference_location_rules_count, custom_location_rules_count) self.subcontractor_partner1.property_stock_subcontractor = partner_subcontract_location.id - resupply_rule = resupply_sub_on_order_route.rule_ids.filtered(lambda r: - r.location_dest_id == self.comp1.property_stock_production and - r.location_src_id == self.env.company.subcontracting_location_id) - resupply_rule.copy({'location_src_id': partner_subcontract_location.id}) - resupply_warehouse_rule = self.warehouse.route_ids.rule_ids.filtered(lambda r: - r.location_dest_id == self.env.company.subcontracting_location_id and - r.location_src_id == self.warehouse.lot_stock_id) - resupply_warehouse_rule.copy({'location_dest_id': partner_subcontract_location.id}) # Add a manufacturing lead time to check that the resupply delivery is correctly planned 2 days # before the subcontracting receipt self.finished.produce_delay = 2 @@ -177,7 +174,7 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon): """ # Tick "resupply subconractor on order" resupply_sub_on_order_route = self.env['stock.route'].search([('name', '=', 'Resupply Subcontractor on Order')]) - (self.comp1 + self.comp2).write({'route_ids': [(4, resupply_sub_on_order_route.id, None)]}) + (self.comp1 + self.comp2).write({'route_ids': [(6, None, [resupply_sub_on_order_route.id])]}) # Tick "manufacture" and MTO on self.comp2 mto_route = self.env.ref('stock.route_warehouse0_mto') @@ -243,8 +240,7 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon): mto_route = self.env.ref('stock.route_warehouse0_mto') mto_route.active = True manufacture_route = self.env['stock.route'].search([('name', '=', 'Manufacture')]) - self.comp2.write({'route_ids': [(4, manufacture_route.id, None)]}) - self.comp2.write({'route_ids': [(4, mto_route.id, None)]}) + self.comp2.write({'route_ids': [(6, None, [manufacture_route.id, mto_route.id])]}) orderpoint_form = Form(self.env['stock.warehouse.orderpoint']) orderpoint_form.product_id = self.comp2 @@ -667,7 +663,35 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon): self.assertEqual(self.env['mrp.production'].search_count([('bom_id', '=', bom.id)]), 3) + def test_subcontracting_rules_replication(self): + """ Test activate/archive subcontracting location rules.""" + reference_location_rules = self.env['stock.rule'].search(['|', ('location_src_id', '=', self.env.company.subcontracting_location_id.id), ('location_dest_id', '=', self.env.company.subcontracting_location_id.id)]) + warehouse_related_rules = reference_location_rules.filtered(lambda r: r.warehouse_id) + company_rules = reference_location_rules - warehouse_related_rules + # Create a custom subcontracting location + custom_subcontracting_location = self.env['stock.location'].create({ + 'name': 'Custom Subcontracting Location', + 'location_id': self.env.ref('stock.stock_location_locations').id, + 'usage': 'internal', + 'company_id': self.env.company.id, + 'is_subcontracting_location': True, + }) + custom_location_rules_count = self.env['stock.rule'].search_count(['|', ('location_src_id', '=', custom_subcontracting_location.id), ('location_dest_id', '=', custom_subcontracting_location.id)]) + self.assertEqual(len(reference_location_rules), custom_location_rules_count) + # Add a new warehouse + warehouse = self.env['stock.warehouse'].create({ + 'name': 'Additional Warehouse', + 'code': 'ADD' + }) + company_subcontracting_locations_rules_count = self.env['stock.rule'].search_count(['&', ('company_id', '=', warehouse.company_id.id), '|', ('location_src_id.is_subcontracting_location', '=', 'True'), ('location_dest_id.is_subcontracting_location', '=', 'True')]) + self.assertEqual(len(warehouse_related_rules) * 4 + len(company_rules) * 2, company_subcontracting_locations_rules_count) + # Custom location no longer a subcontracting one + custom_subcontracting_location.is_subcontracting_location = False + custom_location_rules_count = self.env['stock.rule'].search_count(['|', ('location_src_id', '=', custom_subcontracting_location.id), ('location_dest_id', '=', custom_subcontracting_location.id)]) + self.assertEqual(custom_location_rules_count, 0) + +@tagged('post_install', '-at_install') class TestSubcontractingTracking(TransactionCase): @classmethod @@ -1056,6 +1080,7 @@ class TestSubcontractingTracking(TransactionCase): self.assertEqual(picking_receipt.state, 'done') +@tagged('post_install', '-at_install') class TestSubcontractingPortal(TransactionCase): @classmethod diff --git a/addons/mrp_subcontracting/views/res_partner_views.xml b/addons/mrp_subcontracting/views/res_partner_views.xml index ee3c6ba6c7d..c218a4db95f 100644 --- a/addons/mrp_subcontracting/views/res_partner_views.xml +++ b/addons/mrp_subcontracting/views/res_partner_views.xml @@ -6,7 +6,7 @@ - + diff --git a/addons/mrp_subcontracting/views/stock_location_views.xml b/addons/mrp_subcontracting/views/stock_location_views.xml new file mode 100644 index 00000000000..f04b8c06464 --- /dev/null +++ b/addons/mrp_subcontracting/views/stock_location_views.xml @@ -0,0 +1,13 @@ + + + + stock.location.form + stock.location + + + + + + + + diff --git a/addons/mrp_subcontracting_dropshipping/models/stock_rule.py b/addons/mrp_subcontracting_dropshipping/models/stock_rule.py index 21090a7ee88..906b60f73f8 100644 --- a/addons/mrp_subcontracting_dropshipping/models/stock_rule.py +++ b/addons/mrp_subcontracting_dropshipping/models/stock_rule.py @@ -8,6 +8,6 @@ class StockRule(models.Model): _inherit = 'stock.rule' def _prepare_purchase_order(self, company_id, origins, values): - if 'partner_id' not in values[0] and company_id.subcontracting_location_id.parent_path in self.location_dest_id.parent_path: + if 'partner_id' not in values[0] and self.location_dest_id.is_subcontracting_location: values[0]['partner_id'] = values[0]['group_id'].partner_id.id return super()._prepare_purchase_order(company_id, origins, values) diff --git a/addons/mrp_subcontracting_dropshipping/tests/test_purchase_subcontracting.py b/addons/mrp_subcontracting_dropshipping/tests/test_purchase_subcontracting.py index eeb3d9c68c9..23cf0958cff 100644 --- a/addons/mrp_subcontracting_dropshipping/tests/test_purchase_subcontracting.py +++ b/addons/mrp_subcontracting_dropshipping/tests/test_purchase_subcontracting.py @@ -147,11 +147,10 @@ class TestSubcontractingDropshippingFlows(TestMrpSubcontractingCommon): sub_location = self.env['stock.location'].create({ 'name': 'Super Location', 'location_id': subcontract_location.id, + 'is_subcontracting_location': True, }) dropship_subcontractor_route = self.env['stock.route'].search([('name', '=', 'Dropship Subcontractor on Order')]) - dropship_subcontractor_route.rule_ids.filtered(lambda rule: rule.location_dest_id == subcontract_location).copy(default={'location_dest_id': sub_location.id}) - dropship_subcontractor_route.rule_ids.filtered(lambda rule: rule.location_src_id == subcontract_location).copy(default={'location_src_id': sub_location.id}) subcontractor, vendor = self.env['res.partner'].create([ {'name': 'SuperSubcontractor', 'property_stock_subcontractor': sub_location.id},