[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:
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user