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