diff --git a/addons/purchase_mrp/models/mrp_production.py b/addons/purchase_mrp/models/mrp_production.py index ec09e681de6..ed9dd1878f3 100644 --- a/addons/purchase_mrp/models/mrp_production.py +++ b/addons/purchase_mrp/models/mrp_production.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models, _ +from odoo import api, Command, fields, models, _ class MrpProduction(models.Model): @@ -12,15 +12,15 @@ class MrpProduction(models.Model): compute='_compute_purchase_order_count', groups='purchase.group_purchase_user') - @api.depends('procurement_group_id.stock_move_ids.created_purchase_line_id.order_id', 'procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id') + @api.depends('procurement_group_id.stock_move_ids.created_purchase_line_ids.order_id', 'procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id') def _compute_purchase_order_count(self): for production in self: - production.purchase_order_count = len(production.procurement_group_id.stock_move_ids.created_purchase_line_id.order_id | + production.purchase_order_count = len(production.procurement_group_id.stock_move_ids.created_purchase_line_ids.order_id | production.procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id) def action_view_purchase_orders(self): self.ensure_one() - purchase_order_ids = (self.procurement_group_id.stock_move_ids.created_purchase_line_id.order_id | self.procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id).ids + purchase_order_ids = (self.procurement_group_id.stock_move_ids.created_purchase_line_ids.order_id | self.procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id).ids action = { 'res_model': 'purchase.order', 'type': 'ir.actions.act_window', @@ -40,13 +40,18 @@ class MrpProduction(models.Model): def _get_document_iterate_key(self, move_raw_id): iterate_key = super(MrpProduction, self)._get_document_iterate_key(move_raw_id) - if not iterate_key and move_raw_id.created_purchase_line_id: - iterate_key = 'created_purchase_line_id' + if not iterate_key and move_raw_id.created_purchase_line_ids: + iterate_key = 'created_purchase_line_ids' return iterate_key def _prepare_merge_orig_links(self): origs = super()._prepare_merge_orig_links() for move in self.move_raw_ids: - if move.created_purchase_line_id: - origs[move.bom_line_id.id]['created_purchase_line_id'] = move.created_purchase_line_id + if not move.move_orig_ids: + continue + origs[move.bom_line_id.id].setdefault('created_purchase_line_ids', set()).update(move.created_purchase_line_ids.ids) + for vals in origs.values(): + if not vals.get('created_purchase_line_ids'): + continue + vals['created_purchase_line_ids'] = [Command.set(vals['created_purchase_line_ids'])] return origs diff --git a/addons/purchase_requisition_stock/tests/test_purchase_requisition_stock.py b/addons/purchase_requisition_stock/tests/test_purchase_requisition_stock.py index 15a2de173cc..893c05fde72 100644 --- a/addons/purchase_requisition_stock/tests/test_purchase_requisition_stock.py +++ b/addons/purchase_requisition_stock/tests/test_purchase_requisition_stock.py @@ -3,6 +3,7 @@ from odoo.addons.purchase_requisition.tests.common import TestPurchaseRequisitionCommon from odoo.tests import Form +from odoo import Command class TestPurchaseRequisitionStock(TestPurchaseRequisitionCommon): @@ -243,3 +244,72 @@ class TestPurchaseRequisitionStock(TestPurchaseRequisitionCommon): "POs not in a RFQ status should not be listed as possible to cancel") warning_wiz.action_cancel_alternatives() self.assertEqual(orig_po.state, 'cancel', "Original PO should have been cancelled") + + def test_05_move_dest_links_alternatives(self): + """ Checks if Purchase Order alternatives are correctly linked to the original PO's move_dest_ids. """ + # Prepare test data + wh = self.env.ref('stock.warehouse0') + buy_route_id = self.ref('purchase_stock.route_warehouse0_buy') + vendor_1 = self.env['res.partner'].create({'name': 'Vendor 1'}) + vendor_2 = self.env['res.partner'].create({'name': 'Vendor 2'}) + product = self.env['product.product'].create({ + 'name': 'Test product', + 'type': 'product', + 'seller_ids': [Command.create({ + 'partner_id': vendor_1.id, + 'price': 10.0, + 'delay': 0, + })], + 'route_ids': [Command.set([buy_route_id])], + }) + + # Sets the warehouse to do two-steps receptions + grp_multi_loc = self.env.ref('stock.group_stock_multi_locations') + grp_multi_step_rule = self.env.ref('stock.group_adv_location') + self.env.user.write({'groups_id': [(3, grp_multi_loc.id)]}) + self.env.user.write({'groups_id': [(3, grp_multi_step_rule.id)]}) + wh.reception_steps = 'two_steps' + + # Create a reordering rule for the product and + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'RR for %s' % product.name, + 'warehouse_id': wh.id, + 'location_id': wh.lot_stock_id.id, + 'product_id': product.id, + 'product_min_qty': 1, + 'product_max_qty': 10, + }) + # Run scheduler to create internal transfer from Input -> Stock and generate the Purchase Order + self.env['procurement.group'].run_scheduler() + # Find the internal move (Input -> Stock) that has been generated + int_move = self.env['stock.move'].search([('product_id', '=', product.id)]) + self.assertEqual(int_move.reserved_availability, 0, "No product should be available, as no products were received at this point.") + # Find the Purchase Order generated by the orderpoint + orig_po = self.env['purchase.order'].search([('partner_id', '=', vendor_1.id)]) + self.assertEqual(len(orig_po.ids), 1, "Only one PO should have been generated.") + # Create an alternative RFQ for another vendor + action = orig_po.action_create_alternative() + alt_po_wizard = Form(self.env['purchase.requisition.create.alternative'].with_context(**action['context'])) + alt_po_wizard.partner_id = vendor_2 + alt_po_wizard.copy_products = True + alt_po_wizard = alt_po_wizard.save() + alt_po_wizard.action_create_alternative() + # Find the alternative Purchase Order + alt_po = orig_po.alternative_po_ids.filtered(lambda po: po.id != orig_po.id) + self.assertEqual(len(orig_po.alternative_po_ids), 2, "Base PO should be linked with the alternative PO.") + # Validate it + warning_action = alt_po.button_confirm() + warning_wizard = Form(self.env[warning_action['res_model']].with_context(**warning_action['context'])) + warning_wizard = warning_wizard.save() + # Cancel other alternatives + warning_wizard.action_cancel_alternatives() + self.assertEqual(orig_po.state, 'cancel', "Original PO should have been cancelled.") + self.assertEqual(alt_po.state, 'purchase', "Alternative PO should have been confirmed.") + # Set all qty as done and validate the alternative PO's picking + in_picking = alt_po.picking_ids + self.assertEqual(in_picking.picking_type_id.code, 'incoming', "Must be the reception picking.") + in_picking.move_ids.quantity_done = 10 + in_picking.button_validate() + # Quantity should be reserved in the internal transfer's move + self.assertEqual(int_move.reserved_availability, 10, "Quantity should be reserved in the original internal move.") + self.assertEqual(int_move.move_orig_ids.id, in_picking.move_ids.id, "Both moves should be correctly chained together.") diff --git a/addons/purchase_requisition_stock/wizard/purchase_requisition_create_alternative.py b/addons/purchase_requisition_stock/wizard/purchase_requisition_create_alternative.py index cfb92ff9f44..23fd18b4586 100644 --- a/addons/purchase_requisition_stock/wizard/purchase_requisition_create_alternative.py +++ b/addons/purchase_requisition_stock/wizard/purchase_requisition_create_alternative.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import models +from odoo import api, Command, models class PurchaseRequisitionCreateAlternative(models.TransientModel): @@ -11,3 +11,11 @@ class PurchaseRequisitionCreateAlternative(models.TransientModel): vals = super(PurchaseRequisitionCreateAlternative, self)._get_alternative_values() vals['picking_type_id'] = self.origin_po_id.picking_type_id.id return vals + + @api.model + def _get_alternative_line_value(self, order_line): + res_line = super()._get_alternative_line_value(order_line) + if order_line.move_dest_ids: + res_line['move_dest_ids'] = [Command.set(order_line.move_dest_ids.ids)] + + return res_line diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py index 917ce1adfcd..71b69036781 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -3,7 +3,7 @@ from markupsafe import Markup from dateutil.relativedelta import relativedelta -from odoo import api, fields, models, SUPERUSER_ID, _ +from odoo import api, Command, fields, models, SUPERUSER_ID, _ from odoo.tools.float_utils import float_compare, float_is_zero, float_round from odoo.exceptions import UserError @@ -122,6 +122,9 @@ class PurchaseOrder(models.Model): for order_line in order.order_line: order_line.move_ids._action_cancel() if order_line.move_dest_ids: + moves_to_unlink = order_line.move_dest_ids.filtered(lambda m: len(m.created_purchase_line_ids.ids) > 1) + if moves_to_unlink: + moves_to_unlink.created_purchase_line_ids = [Command.unlink(order_line.id)] move_dest_ids = order_line.move_dest_ids if order_line.propagate_cancel: move_dest_ids._action_cancel() @@ -292,7 +295,7 @@ class PurchaseOrderLine(models.Model): move_ids = fields.One2many('stock.move', 'purchase_line_id', string='Reservation', readonly=True, copy=False) orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Orderpoint', copy=False, index='btree_not_null') - move_dest_ids = fields.One2many('stock.move', 'created_purchase_line_id', 'Downstream Moves') + move_dest_ids = fields.Many2many('stock.move', 'stock_move_created_purchase_line_rel', 'created_purchase_line_id', 'move_id', 'Downstream moves alt') product_description_variants = fields.Char('Custom Description') propagate_cancel = fields.Boolean('Propagate cancellation', default=True) forecasted_issue = fields.Boolean(compute='_compute_forecasted_issue') @@ -386,6 +389,12 @@ class PurchaseOrderLine(models.Model): def unlink(self): self.move_ids._action_cancel() + # Unlink move_dests that have other created_purchase_line_ids instead of cancelling them + for line in self: + moves_to_unlink = line.move_dest_ids.filtered(lambda m: len(m.created_purchase_line_ids.ids) > 1) + if moves_to_unlink: + moves_to_unlink.created_purchase_line_ids = [Command.unlink(line.id)] + ppg_cancel_lines = self.filtered(lambda line: line.propagate_cancel) ppg_cancel_lines.move_dest_ids._action_cancel() @@ -560,7 +569,7 @@ class PurchaseOrderLine(models.Model): for line in self.filtered(lambda l: not l.display_type): for val in line._prepare_stock_moves(picking): values.append(val) - line.move_dest_ids.created_purchase_line_id = False + line.move_dest_ids.created_purchase_line_ids = [Command.clear()] return self.env['stock.move'].create(values) diff --git a/addons/purchase_stock/models/stock_move.py b/addons/purchase_stock/models/stock_move.py index b2ca8785ffc..52b565bbac9 100644 --- a/addons/purchase_stock/models/stock_move.py +++ b/addons/purchase_stock/models/stock_move.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models, _ +from odoo import api, Command, fields, models, _ from odoo.tools.float_utils import float_round, float_is_zero from odoo.exceptions import UserError @@ -12,19 +12,19 @@ class StockMove(models.Model): purchase_line_id = fields.Many2one( 'purchase.order.line', 'Purchase Order Line', ondelete='set null', index='btree_not_null', readonly=True) - created_purchase_line_id = fields.Many2one( - 'purchase.order.line', 'Created Purchase Order Line', - ondelete='set null', index='btree_not_null', readonly=True, copy=False) + created_purchase_line_ids = fields.Many2many( + 'purchase.order.line', 'stock_move_created_purchase_line_rel', + 'move_id', 'created_purchase_line_id', 'Created Purchase Order Lines', copy=False) @api.model def _prepare_merge_moves_distinct_fields(self): distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields() - distinct_fields += ['purchase_line_id', 'created_purchase_line_id'] + distinct_fields += ['purchase_line_id', 'created_purchase_line_ids'] 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'] + return super()._prepare_merge_negative_moves_excluded_distinct_fields() + ['created_purchase_line_ids'] def _compute_partner_id(self): # dropshipped moves should have their partner_ids directly set @@ -151,12 +151,12 @@ class StockMove(models.Model): def _clean_merged(self): super(StockMove, self)._clean_merged() - self.write({'created_purchase_line_id': False}) + self.write({'created_purchase_line_ids': [Command.clear()]}) 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') \ - and (self.created_purchase_line_id.state != 'draft' or self._context.get('include_draft_documents')): - return [(self.created_purchase_line_id.order_id, self.created_purchase_line_id.order_id.user_id, visited)] + created_pl = self.created_purchase_line_ids.filtered(lambda cpl: cpl.state not in ('done', 'cancel') and (cpl.state != 'draft' or self._context.get('include_draft_documents'))) + if created_pl: + return [(pl.order_id, pl.order_id.user_id, visited) for pl in created_pl] 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)] else: diff --git a/addons/sale_purchase_stock/models/sale_order.py b/addons/sale_purchase_stock/models/sale_order.py index 6f74ac76d28..50ff80aa5c3 100644 --- a/addons/sale_purchase_stock/models/sale_order.py +++ b/addons/sale_purchase_stock/models/sale_order.py @@ -7,9 +7,9 @@ from odoo import api, models class SaleOrder(models.Model): _inherit = 'sale.order' - @api.depends('procurement_group_id.stock_move_ids.created_purchase_line_id.order_id', 'procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id') + @api.depends('procurement_group_id.stock_move_ids.created_purchase_line_ids.order_id', 'procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id') def _compute_purchase_order_count(self): super(SaleOrder, self)._compute_purchase_order_count() def _get_purchase_orders(self): - return super(SaleOrder, self)._get_purchase_orders() | self.procurement_group_id.stock_move_ids.created_purchase_line_id.order_id | self.procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id + return super(SaleOrder, self)._get_purchase_orders() | self.procurement_group_id.stock_move_ids.created_purchase_line_ids.order_id | self.procurement_group_id.stock_move_ids.move_orig_ids.purchase_line_id.order_id