From a315ed42689ffc69309845795ed512022653db34 Mon Sep 17 00:00:00 2001 From: clesgow <quwo@odoo.com> Date: Thu, 23 Sep 2021 14:15:05 +0000 Subject: [PATCH] [IMP] mrp,{purchase_,sale_}stock,stock: decrease qty cascade on MTO Allow the quantity decrease of Sale Order line of a MTO product. When increasing de quantities, the related pickings, RFQ / PO are increased as well, but it wasn't the case if decreasing the quantity. It should allow the cascade of the decrease in the related pickings as it would work for the increase. Also modifies a related Purchase Order if it wasn't yet validated (and thus still a RFQ). merge_moves was modified so it would allow the merge of negative moves with positive ones, while trying to "deplete" as much quantity as possible for each mergeable move. But negative moves don't always have all required properties to be compared to the positive ones (some keys might be missing, such as 'created_production_id'). That means we will merge strictly the positive moves at first as it was done before. But then we try to merge them "less strictly", using less keys to compare. Let's say we have those moves (and all other relevant keys matches) : - move_1 : {qty : 5, created_production_id: 1} - move_2 : {qty : 3, created_production_id: 2} - move_3 : {qty : -6, created_production_id: False} move_1 and move_2 cannot be merged as they don't share the same created_production_id. But to merge move_3, we'll need to merge it into move_1 and move_2. It will then deplete move_1 and decrease move_2. Which will leave us with : - move_1 : {qty : 0, created_production_id: 1} - move_2 : {qty : 2, created_production_id: 2} move_3 will be unliked as its purpose is done. Task-2513592 Part-of: odoo/odoo#78070 --- addons/mrp/models/stock_move.py | 4 + addons/mrp/models/stock_rule.py | 5 +- addons/mrp/tests/test_procurement.py | 76 +++++++++++++++++ addons/purchase_stock/models/purchase.py | 4 +- addons/purchase_stock/models/stock.py | 6 +- addons/purchase_stock/models/stock_rule.py | 24 ++++-- .../tests/test_create_picking.py | 84 +++++++++++++++++++ addons/sale_stock/models/sale_order.py | 28 +------ addons/sale_stock/tests/test_sale_stock.py | 39 +++++++++ addons/stock/models/stock_move.py | 73 ++++++++++++---- addons/stock/models/stock_rule.py | 12 ++- 11 files changed, 297 insertions(+), 58 deletions(-) diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index bfbf572762a3..d08f8fb3a286 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -394,6 +394,10 @@ class StockMove(models.Model): def _prepare_merge_moves_distinct_fields(self): return super()._prepare_merge_moves_distinct_fields() + ['created_production_id', 'cost_share'] + @api.model + def _prepare_merge_negative_moves_excluded_distinct_fields(self): + return super()._prepare_merge_negative_moves_excluded_distinct_fields() + ['created_production_id'] + def _merge_moves_fields(self): res = super()._merge_moves_fields() res['cost_share'] = sum(self.mapped('cost_share')) diff --git a/addons/mrp/models/stock_rule.py b/addons/mrp/models/stock_rule.py index 641f7dcdf43e..999bd61e1788 100644 --- a/addons/mrp/models/stock_rule.py +++ b/addons/mrp/models/stock_rule.py @@ -7,7 +7,7 @@ from dateutil.relativedelta import relativedelta from odoo import api, fields, models, SUPERUSER_ID, _ from odoo.osv import expression from odoo.addons.stock.models.stock_rule import ProcurementException -from odoo.tools import OrderedSet +from odoo.tools import float_compare, OrderedSet class StockRule(models.Model): @@ -42,6 +42,9 @@ class StockRule(models.Model): productions_values_by_company = defaultdict(list) errors = [] for procurement, rule in procurements: + if float_compare(procurement.product_qty, 0, precision_rounding=procurement.product_uom.rounding) <= 0: + # If procurement contains negative quantity, don't create a MO that would be for a negative value. + continue bom = rule._get_matching_bom(procurement.product_id, procurement.company_id, procurement.values) productions_values_by_company[procurement.company_id.id].append(rule._prepare_mo_vals(*procurement, bom)) diff --git a/addons/mrp/tests/test_procurement.py b/addons/mrp/tests/test_procurement.py index 15a0d4ce3b94..14f14614cfc4 100644 --- a/addons/mrp/tests/test_procurement.py +++ b/addons/mrp/tests/test_procurement.py @@ -520,3 +520,79 @@ class TestProcurement(TestMrpCommon): mo_assign_at_confirm.action_confirm() self.assertEqual(mo_assign_at_confirm.move_raw_ids.reserved_availability, 5, "Components should have been auto-reserved") + + def test_check_update_qty_mto_chain(self): + """ Simulate a mto chain with a manufacturing order. Updating the + initial demand should also impact the initial move but not the + linked manufacturing order. + """ + def create_run_procurement(product, product_qty, values=None): + if not values: + values = { + 'warehouse_id': picking_type_out.warehouse_id, + 'action': 'pull_push', + 'group_id': procurement_group, + } + return self.env['procurement.group'].run([self.env['procurement.group'].Procurement( + product, product_qty, self.uom_unit, vendor.property_stock_customer, + product.name, '/', self.env.company, values) + ]) + + picking_type_out = self.env.ref('stock.picking_type_out') + vendor = self.env['res.partner'].create({ + 'name': 'Roger' + }) + # This needs to be tried with MTO route activated + self.env['stock.location.route'].browse(self.ref('stock.route_warehouse0_mto')).action_unarchive() + # Define products requested for this BoM. + product = self.env['product.product'].create({ + 'name': 'product', + 'type': 'product', + 'route_ids': [(4, self.ref('stock.route_warehouse0_mto')), (4, self.ref('mrp.route_warehouse0_manufacture'))], + 'categ_id': self.env.ref('product.product_category_all').id + }) + component = self.env['product.product'].create({ + 'name': 'component', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id + }) + self.env['mrp.bom'].create({ + 'product_id': product.id, + 'product_tmpl_id': product.product_tmpl_id.id, + 'product_uom_id': product.uom_id.id, + 'product_qty': 1.0, + 'consumption': 'flexible', + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': component.id, 'product_qty': 1}), + ] + }) + + procurement_group = self.env['procurement.group'].create({ + 'move_type': 'direct', + 'partner_id': vendor.id + }) + # Create initial procurement that will generate the initial move and its picking. + create_run_procurement(product, 10, { + 'group_id': procurement_group, + 'warehouse_id': picking_type_out.warehouse_id, + 'partner_id': vendor + }) + customer_move = self.env['stock.move'].search([('group_id', '=', procurement_group.id)]) + manufacturing_order = self.env['mrp.production'].search([('product_id', '=', product.id)]) + self.assertTrue(manufacturing_order, 'No manufacturing order created.') + + # Check manufacturing order data. + self.assertEqual(manufacturing_order.product_qty, 10, 'The manufacturing order qty should be the same as the move.') + + # Create procurement to decrease quantity in the initial move but not in the related MO. + create_run_procurement(product, -5.00) + self.assertEqual(customer_move.product_uom_qty, 5, 'The demand on the initial move should have been decreased when merged with the procurement.') + self.assertEqual(manufacturing_order.product_qty, 10, 'The demand on the manufacturing order should not have been decreased.') + + # Create procurement to increase quantity on the initial move and should create a new MO for the missing qty. + create_run_procurement(product, 2.00) + self.assertEqual(customer_move.product_uom_qty, 5, 'The demand on the initial move should not have been increased since it should be a new move.') + self.assertEqual(manufacturing_order.product_qty, 10, 'The demand on the initial manufacturing order should not have been increased.') + manufacturing_orders = self.env['mrp.production'].search([('product_id', '=', product.id)]) + self.assertEqual(len(manufacturing_orders), 2, 'A new MO should have been created for missing demand.') diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py index d078af03ef43..913a3932853b 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -4,7 +4,7 @@ from markupsafe import Markup from dateutil.relativedelta import relativedelta from odoo import api, fields, models, SUPERUSER_ID, _ -from odoo.tools.float_utils import float_compare, float_round +from odoo.tools.float_utils import float_compare, float_is_zero, float_round from odoo.exceptions import UserError from odoo.addons.purchase.models.purchase import PurchaseOrder as Purchase @@ -462,7 +462,7 @@ class PurchaseOrderLine(models.Model): if float_compare(qty_to_attach, 0.0, precision_rounding=self.product_uom.rounding) > 0: product_uom_qty, product_uom = self.product_uom._adjust_uom_quantities(qty_to_attach, self.product_id.uom_id) res.append(self._prepare_stock_move_vals(picking, price_unit, product_uom_qty, product_uom)) - if float_compare(qty_to_push, 0.0, precision_rounding=self.product_uom.rounding) > 0: + if not float_is_zero(qty_to_push, precision_rounding=self.product_uom.rounding): product_uom_qty, product_uom = self.product_uom._adjust_uom_quantities(qty_to_push, self.product_id.uom_id) extra_move_vals = self._prepare_stock_move_vals(picking, price_unit, product_uom_qty, product_uom) extra_move_vals['move_dest_ids'] = False # don't attach diff --git a/addons/purchase_stock/models/stock.py b/addons/purchase_stock/models/stock.py index 6081c840eac6..f4bcc0b9b301 100644 --- a/addons/purchase_stock/models/stock.py +++ b/addons/purchase_stock/models/stock.py @@ -26,6 +26,10 @@ class StockMove(models.Model): distinct_fields += ['purchase_line_id', 'created_purchase_line_id'] return distinct_fields + @api.model + def _prepare_merge_negative_moves_excluded_distinct_fields(self): + return super()._prepare_merge_negative_moves_excluded_distinct_fields() + ['created_purchase_line_id'] + def _get_price_unit(self): """ Returns the unit price for the move""" self.ensure_one() @@ -88,7 +92,7 @@ class StockMove(models.Model): self.write({'created_purchase_line_id': False}) def _get_upstream_documents_and_responsibles(self, visited): - if self.created_purchase_line_id and self.created_purchase_line_id.state not in ('done', 'cancel'): + if self.created_purchase_line_id and self.created_purchase_line_id.state not in ('draft', 'done', 'cancel'): return [(self.created_purchase_line_id.order_id, self.created_purchase_line_id.order_id.user_id, visited)] elif self.purchase_line_id and self.purchase_line_id.state not in ('done', 'cancel'): return[(self.purchase_line_id.order_id, self.purchase_line_id.order_id.user_id, visited)] diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py index f39e2fe07dd6..7c1bb0a96be0 100644 --- a/addons/purchase_stock/models/stock_rule.py +++ b/addons/purchase_stock/models/stock_rule.py @@ -4,6 +4,7 @@ from collections import defaultdict from datetime import datetime from dateutil.relativedelta import relativedelta +from odoo.tools import float_compare from odoo import api, fields, models, SUPERUSER_ID, _ from odoo.addons.stock.models.stock_rule import ProcurementException @@ -93,15 +94,17 @@ class StockRule(models.Model): po = self.env['purchase.order'].sudo().search([dom for dom in domain], limit=1) company_id = procurements[0].company_id if not po: - # We need a rule to generate the PO. However the rule generated - # the same domain for PO and the _prepare_purchase_order method - # should only uses the common rules's fields. - vals = rules[0]._prepare_purchase_order(company_id, origins, [p.values for p in procurements]) - # The company_id is the same for all procurements since - # _make_po_get_domain add the company in the domain. - # We use SUPERUSER_ID since we don't want the current user to be follower of the PO. - # Indeed, the current user may be a user without access to Purchase, or even be a portal user. - po = self.env['purchase.order'].with_company(company_id).with_user(SUPERUSER_ID).create(vals) + positive_values = [p.values for p in procurements if float_compare(p.product_qty, 0.0, precision_rounding=p.product_uom.rounding) >= 0] + if positive_values: + # We need a rule to generate the PO. However the rule generated + # the same domain for PO and the _prepare_purchase_order method + # should only uses the common rules's fields. + vals = rules[0]._prepare_purchase_order(company_id, origins, positive_values) + # The company_id is the same for all procurements since + # _make_po_get_domain add the company in the domain. + # We use SUPERUSER_ID since we don't want the current user to be follower of the PO. + # Indeed, the current user may be a user without access to Purchase, or even be a portal user. + po = self.env['purchase.order'].with_company(company_id).with_user(SUPERUSER_ID).create(vals) else: # If a purchase order is found, adapt its `origin` field. if po.origin: @@ -131,6 +134,9 @@ class StockRule(models.Model): procurement.values, po_line) po_line.write(vals) else: + if float_compare(procurement.product_qty, 0, precision_rounding=procurement.product_uom.rounding) <= 0: + # If procurement contains negative quantity, don't create a new line that would contain negative qty + continue # If it does not exist a PO line for current procurement. # Generate the create values for it and add it to a list in # order to create it in batch. diff --git a/addons/purchase_stock/tests/test_create_picking.py b/addons/purchase_stock/tests/test_create_picking.py index 3ad4048fdc58..7e7d9f779f6a 100644 --- a/addons/purchase_stock/tests/test_create_picking.py +++ b/addons/purchase_stock/tests/test_create_picking.py @@ -515,3 +515,87 @@ class TestCreatePicking(common.TestProductCommon): po.order_line.product_qty += 2 backorder = po.picking_ids.filtered(lambda picking: picking.state == 'assigned') self.assertEqual(backorder.move_lines.product_uom_qty, 9) + + def test_08_check_update_qty_mto_chain(self): + """ Simulate a mto chain with a purchase order. Updating the + initial demand should also impact the initial move and the + purchase order if it wasn't yet confirmed. + """ + def create_run_procurement(product, product_qty, values=None): + if not values: + values = { + 'warehouse_id': picking_type_out.warehouse_id, + 'action': 'pull_push', + 'group_id': procurement_group, + } + return self.env['procurement.group'].run([self.env['procurement.group'].Procurement( + product, product_qty, self.uom_unit, vendor.property_stock_customer, + product.name, '/', self.env.company, values) + ]) + + # Prepare procurement that replicates a sale order. + picking_type_out = self.env.ref('stock.picking_type_out') + partner = self.env['res.partner'].create({ + 'name': 'Jhon' + }) + seller = self.env['product.supplierinfo'].create({ + 'name': partner.id, + 'price': 12.0, + }) + vendor = self.env['res.partner'].create({ + 'name': 'Roger' + }) + # This needs to be tried with MTO route activated + self.env['stock.location.route'].browse(self.ref('stock.route_warehouse0_mto')).action_unarchive() + product = self.env['product.product'].create({ + 'name': 'product', + 'type': 'product', + 'route_ids': [(4, self.ref('stock.route_warehouse0_mto')), (4, self.ref('purchase_stock.route_warehouse0_buy'))], + 'seller_ids': [(6, 0, [seller.id])], + 'categ_id': self.env.ref('product.product_category_all').id, + 'supplier_taxes_id': [(6, 0, [])], + }) + + procurement_group = self.env['procurement.group'].create({ + 'move_type': 'direct', + 'partner_id': vendor.id + }) + # Create initial procurement that will generate the initial move and its picking. + create_run_procurement(product, 50, { + 'group_id': procurement_group, + 'warehouse_id': picking_type_out.warehouse_id, + 'partner_id': vendor + }) + customer_move = self.env['stock.move'].search([('group_id', '=', procurement_group.id)]) + purchase_order = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertTrue(purchase_order, 'No purchase order created.') + + # Check purchase order line data. + purchase_order_line = purchase_order.order_line + self.assertEqual(purchase_order_line.product_id, product, 'The product on the purchase order line is not correct.') + self.assertEqual(purchase_order_line.product_qty, 50, 'The purchase order line qty should be the same as the move.') + + # Create procurement to decrease quantity in the initial move and the related RFQ. + create_run_procurement(product, -10.00) + self.assertEqual(customer_move.product_uom_qty, 40, 'The demand on the initial move should have been decreased when merged with the procurement.') + self.assertEqual(purchase_order_line.product_qty, 40, 'The demand on the Purchase Order should have been decreased since it is still a RFQ.') + + # Create procurement to increase quantity on the initial move and the related RFQ. + create_run_procurement(product, 5.00) + self.assertEqual(customer_move.product_uom_qty, 45, 'The demand on the initial move should have been increased when merged with the procurement.') + self.assertEqual(purchase_order_line.product_qty, 45, 'The demand on the Purchase Order should have been increased since it is still a RFQ.') + + purchase_order.button_confirm() + # Create procurement to decrease quantity in the initial move but not the confirmed PO. + create_run_procurement(product, -10.00) + self.assertEqual(customer_move.product_uom_qty, 35, 'The demand on the initial move should have been decreased when merged with the procurement.') + self.assertEqual(purchase_order_line.product_qty, 45, 'The demand on the Purchase Order should not have been decreased since it is has been confirmed.') + purchase_orders = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertEqual(len(purchase_orders), 1, 'No RFQ should have been created for a negative demand') + + # Create procurement to increase quantity on the initial move that will create a new move and a new RFQ for missing demand. + create_run_procurement(product, 5.00) + self.assertEqual(customer_move.product_uom_qty, 35, 'The demand on the initial move should not have been increased since it should be a new move.') + self.assertEqual(purchase_order_line.product_qty, 45, 'The demand on the Purchase Order should not have been increased since it is has been confirmed.') + purchase_orders = self.env['purchase.order'].search([('partner_id', '=', partner.id)]) + self.assertEqual(len(purchase_orders), 2, 'A new RFQ should have been created for missing demand.') diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index d6c3f484f994..9800080f35ba 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -425,9 +425,8 @@ class SaleOrderLine(models.Model): def write(self, values): lines = self.env['sale.order.line'] if 'product_uom_qty' in values: - precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - lines = self.filtered( - lambda r: r.state == 'sale' and not r.is_expense and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1) + lines = self.filtered(lambda r: r.state == 'sale' and not r.is_expense) + previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines} res = super(SaleOrderLine, self).write(values) if lines: @@ -473,27 +472,6 @@ class SaleOrderLine(models.Model): def _onchange_product_id_set_customer_lead(self): self.customer_lead = self.product_id.sale_delay - @api.onchange('product_uom_qty') - def _onchange_product_uom_qty(self): - # When modifying a one2many, _origin doesn't guarantee that its values will be the ones - # in database. Hence, we need to explicitly read them from there. - if self._origin: - product_uom_qty_origin = self._origin.read(["product_uom_qty"])[0]["product_uom_qty"] - else: - product_uom_qty_origin = 0 - - if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < product_uom_qty_origin: - # Do not display this warning if the new quantity is below the delivered - # one; the `write` will raise an `UserError` anyway. - if self.product_uom_qty < self.qty_delivered: - return {} - warning_mess = { - 'title': _('Ordered quantity decreased!'), - 'message' : _('You are decreasing the ordered quantity! Do not forget to manually update the delivery order if needed.'), - } - return {'warning': warning_mess} - return {} - def _prepare_procurement_values(self, group_id=False): """ Prepare specific key for moves or other components that will be created from a stock rule comming from a sale order line. This method could be override in order to add other custom key that could @@ -569,7 +547,7 @@ class SaleOrderLine(models.Model): if line.state != 'sale' or not line.product_id.type in ('consu','product'): continue qty = line._get_qty_procurement(previous_product_uom_qty) - if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0: + if float_compare(qty, line.product_uom_qty, precision_digits=precision) == 0: continue group_id = line._get_procurement_group() diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py index 2dce01c4087b..93cbf77790b4 100644 --- a/addons/sale_stock/tests/test_sale_stock.py +++ b/addons/sale_stock/tests/test_sale_stock.py @@ -1119,3 +1119,42 @@ class TestSaleStock(TestSaleCommon, ValuationReconciliationTestCommon): picking.move_lines.write({'quantity_done': 3.66}) picking.button_validate() self.assertEqual(so.order_line.mapped('qty_delivered'), [4.0], 'Sale: no conversion error on delivery in different uom"') + + def test_17_qty_update_propagation(self): + """ Creates a sale order, then modifies the sale order lines qty and verifies + that quantity changes are correctly propagated to the delivery picking. + """ + # Sell a product. + product = self.company_data['product_delivery_no'] # storable + product.type = 'product' # storable + + self.env['stock.quant']._update_available_quantity(product, self.company_data['default_warehouse'].lot_stock_id, 50) + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'order_line': [ + (0, 0, {'name': product.name, 'product_id': product.id, 'product_uom_qty': 50, 'product_uom': product.uom_id.id, 'price_unit': product.list_price}), + ], + }) + sale_order.action_confirm() + + # Check picking created + self.assertEqual(len(sale_order.picking_ids), 1, 'A delivery picking should have been created.') + move_out = sale_order.picking_ids.move_lines + self.assertEqual(len(move_out), 1, 'Only one move should be created for a single product.') + self.assertEqual(move_out.product_uom_qty, 50, 'The move quantity should be the same as the quantity sold.') + + # Decrease the quantity in the sale order and check the move has been updated. + sale_order.write({ + 'order_line': [ + (1, sale_order.order_line.id, {'product_uom_qty': 30}), + ] + }) + self.assertEqual(move_out.product_uom_qty, 30, 'The move quantity should have been decreased as the sale order line was.') + + # Increase the quantity in the sale order and check the move has been updated. + sale_order.write({ + 'order_line': [ + (1, sale_order.order_line.id, {'product_uom_qty': 40}) + ] + }) + self.assertEqual(move_out.product_uom_qty, 40, 'The move quantity should have been increased as the sale order line was.') diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 011c055ae3f2..82ccc14ca60b 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -807,6 +807,10 @@ class StockMove(models.Model): 'product_packaging_id', ] + @api.model + def _prepare_merge_negative_moves_excluded_distinct_fields(self): + return [] + def _clean_merged(self): """Cleanup hook used when merging moves""" self.write({'propagate_cancel': False}) @@ -831,31 +835,51 @@ class StockMove(models.Model): # Move removed after merge moves_to_unlink = self.env['stock.move'] - moves_to_merge = [] + # Moves successfully merged + merged_moves = self.env['stock.move'] + + moves_by_neg_key = defaultdict(lambda: self.env['stock.move']) + # Need to check less fields for negative moves as some might not be set. + neg_qty_moves = self.filtered(lambda m: float_compare(m.product_qty, 0.0, precision_rounding=m.product_uom.rounding) < 0) + excluded_fields = self._prepare_merge_negative_moves_excluded_distinct_fields() + neg_key = itemgetter(*[field for field in distinct_fields if field not in excluded_fields]) + for candidate_moves in candidate_moves_list: # First step find move to merge. - candidate_moves = candidate_moves.with_context(prefetch_fields=False) + candidate_moves = candidate_moves.filtered(lambda m: m.state not in ('done', 'cancel', 'draft')) - neg_qty_moves for __, g in groupby(candidate_moves, key=itemgetter(*distinct_fields)): - moves = self.env['stock.move'].concat(*g).filtered(lambda m: m.state not in ('done', 'cancel', 'draft')) - # If we have multiple records we will merge then in a single one. + moves = self.env['stock.move'].concat(*g) + # Merge all positive moves together if len(moves) > 1: - moves_to_merge.append(moves) - - # second step merge its move lines, initial demand, ... - for moves in moves_to_merge: - # link all move lines to record 0 (the one we will keep). - moves.mapped('move_line_ids').write({'move_id': moves[0].id}) - # merge move data - moves[0].write(moves._merge_moves_fields()) - # update merged moves dicts - moves_to_unlink |= moves[1:] + # link all move lines to record 0 (the one we will keep). + moves.mapped('move_line_ids').write({'move_id': moves[0].id}) + # merge move data + moves[0].write(moves._merge_moves_fields()) + # update merged moves dicts + moves_to_unlink |= moves[1:] + merged_moves |= moves[0] + # Add the now single positive move to its limited key record + moves_by_neg_key[neg_key(moves[0])] |= moves[0] + + for neg_move in neg_qty_moves: + # Check all the candidates that matches the same limited key, and adjust their quantites to absorb negative moves + for pos_move in moves_by_neg_key.get(neg_key(neg_move), []): + # If quantity can be fully absorbed by a single move, update its quantity and remove the negative move + if float_compare(pos_move.product_uom_qty, abs(neg_move.product_uom_qty), precision_rounding=pos_move.product_uom.rounding) >= 0: + pos_move.product_uom_qty += neg_move.product_uom_qty + merged_moves |= pos_move + moves_to_unlink |= neg_move + break + neg_move.product_uom_qty += pos_move.product_uom_qty + pos_move.product_uom_qty = 0 if moves_to_unlink: # We are using propagate to False in order to not cancel destination moves merged in moves[0] moves_to_unlink._clean_merged() moves_to_unlink._action_cancel() moves_to_unlink.sudo().unlink() - return (self | self.env['stock.move'].concat(*moves_to_merge)) - moves_to_unlink + + return (self | merged_moves) - moves_to_unlink def _get_relevant_state_among_moves(self): # We sort our moves by importance of state: @@ -1035,6 +1059,11 @@ class StockMove(models.Model): 'origin': False, }) else: + # Don't create picking for negative moves since they will be + # reverse and assign to another picking + moves = moves.filtered(lambda m: float_compare(m.product_uom_qty, 0.0, precision_rounding=m.product_uom.rounding) >= 0) + if not moves: + continue new_picking = True picking = Picking.create(moves._get_new_picking_values()) @@ -1186,6 +1215,18 @@ class StockMove(models.Model): if merge: moves = self._merge_moves(merge_into=merge_into) + # Transform remaining move in return in case of negative initial demand + neg_r_moves = moves.filtered(lambda move: float_compare( + move.product_uom_qty, 0, precision_rounding=move.product_uom.rounding) < 0) + for move in neg_r_moves: + move.location_id, move.location_dest_id = move.location_dest_id, move.location_id + move.product_uom_qty *= -1 + if move.picking_type_id.return_picking_type_id: + move.picking_type_id = move.picking_type_id.return_picking_type_id + # detach their picking as we inverted the location and potentially picking type + neg_r_moves.picking_id = False + neg_r_moves._assign_picking() + # call `_action_assign` on every confirmed move which location_id bypasses the reservation + those expected to be auto-assigned moves.filtered(lambda move: not move.picking_id.immediate_transfer and move.state == 'confirmed' @@ -1733,7 +1774,7 @@ class StockMove(models.Model): result.add((document, responsible, visited)) return result else: - return [(self.picking_id, self.product_id.responsible_id, visited)] + return [] def _set_quantity_done_prepare_vals(self, qty): res = [] diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py index fb45b054b2f1..f79b8588a67f 100644 --- a/addons/stock/models/stock_rule.py +++ b/addons/stock/models/stock_rule.py @@ -238,16 +238,20 @@ class StockRule(models.Model): forecasted_qties_by_loc[location] = {product.id: product.free_qty for product in products} # Prepare the move values, adapt the `procure_method` if needed. + procurements = sorted(procurements, key=lambda proc: float_compare(proc[0].product_qty, 0.0, precision_rounding=proc[0].product_uom.rounding) > 0) for procurement, rule in procurements: procure_method = rule.procure_method if rule.procure_method == 'mts_else_mto': qty_needed = procurement.product_uom._compute_quantity(procurement.product_qty, procurement.product_id.uom_id) - qty_available = forecasted_qties_by_loc[rule.location_src_id][procurement.product_id.id] - if float_compare(qty_needed, qty_available, precision_rounding=procurement.product_id.uom_id.rounding) <= 0: - procure_method = 'make_to_stock' + if float_compare(qty_needed, 0, precision_rounding=procurement.product_id.uom_id.rounding) <= 0: forecasted_qties_by_loc[rule.location_src_id][procurement.product_id.id] -= qty_needed - else: procure_method = 'make_to_order' + elif float_compare(qty_needed, forecasted_qties_by_loc[rule.location_src_id][procurement.product_id.id], + precision_rounding=procurement.product_id.uom_id.rounding) > 0: + procure_method = 'make_to_order' + else: + forecasted_qties_by_loc[rule.location_src_id][procurement.product_id.id] -= qty_needed + procure_method = 'make_to_stock' move_values = rule._get_stock_move_values(*procurement) move_values['procure_method'] = procure_method -- GitLab