[IMP] mrp_subcontracting: allow multiple locations

Ease subcontractor stock tracking by using dedicated locations.
Simply declare location as a subcontracting one (rules are
automatically adapted) and set it on vendor.

closes odoo/odoo#88644

Task: 2720393
Related: odoo/upgrade#3755
Signed-off-by: Tiffany Chang <tic@odoo.com>
This commit is contained in:
JF Aubert
2022-08-12 17:05:36 +02:00
parent 77c6b0146f
commit ec4473a381
13 changed files with 173 additions and 23 deletions
+4
View File
@@ -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
@@ -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',
],
@@ -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",
@@ -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(
@@ -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()
@@ -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" + \
@@ -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),
@@ -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()
@@ -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
@@ -6,7 +6,7 @@
<field name="inherit_id" ref="stock.view_partner_stock_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='property_stock_supplier']" position="after">
<field name="property_stock_subcontractor"/>
<field name="property_stock_subcontractor" context="{'default_is_subcontracting_location': True}"/>
<separator/>
</xpath>
</field>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_location_form" model="ir.ui.view">
<field name="name">stock.location.form</field>
<field name="model">stock.location</field>
<field name="inherit_id" ref="stock.view_location_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='return_location']" position='after'>
<field name="is_subcontracting_location" attrs="{'invisible': [('usage', '!=', 'internal')]}" readonly="context.get('default_is_subcontracting_location', False)"/>
</xpath>
</field>
</record>
</odoo>
@@ -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)
@@ -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},