[IMP] *purchase*: Allow multiple created_purchase_line on a single move

Problem case :
- Create a product with MTO and Buy route activated.
- Create a Sale Order containing this product.
- Confirm the Sale Order, this will generate a Purchase Order for this
product.
- Go to Alternatives -> Create Alternative -> Select another vendor &
select "Copy products".

The alternative PO won't be linked to the Sale Order, as this link is
done through the relation `created_purchase_line_id` <-> `move_dest_ids`
respectively on the `stock.move` and the `purchase.order.line`.
The only way to enable multiple Purchase Order to be linked to a single
move is to extend the number of `created_purchase_line_id` linked to a
move, hence changing it to a Many2Many relation.

Then, when an alternative PO is created, we link the original PO lines
`move_dest_ids` to the newly created PO lines, which correctly links the
SO and the alternative PO.

closes odoo/odoo#106411

Related: odoo/upgrade#4116
Signed-off-by: Arnold Moyaux (arm) <arm@odoo.com>
This commit is contained in:
clesgow
2023-01-13 16:27:26 +01:00
parent 3449a6d7c7
commit 706f9286f4
6 changed files with 116 additions and 24 deletions
+13 -8
View File
@@ -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
@@ -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.")
@@ -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
+12 -3
View File
@@ -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)
+10 -10
View File
@@ -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:
@@ -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