diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 1c5ec88759f..8b8cc4c250e 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -552,40 +552,21 @@ class PurchaseOrderLine(models.Model): def create(self, values): line = super(PurchaseOrderLine, self).create(values) if line.order_id.state == 'purchase': - line.order_id._create_picking() + line._create_or_update_picking(values) msg = _("Extra line with %s ") % (line.product_id.display_name,) line.order_id.message_post(body=msg) return line @api.multi def write(self, values): - orders = False if 'product_qty' in values: - changed_lines = self.filtered(lambda x: x.order_id.state == 'purchase') - if changed_lines: - orders = changed_lines.mapped('order_id') - for order in orders: - order_lines = changed_lines.filtered(lambda x: x.order_id == order) - msg = "" - if any([values['product_qty'] < x.product_qty for x in order_lines]): - msg += "" + _('The ordered quantity has been decreased. Do not forget to take it into account on your bills and receipts.') + '
' - msg += "" - order.message_post(body=msg) + self.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking(values) + result = super(PurchaseOrderLine, self).write(values) # Update expected date of corresponding moves if 'date_planned' in values: self.env['stock.move'].search([ ('purchase_line_id', 'in', self.ids), ('state', '!=', 'done') ]).write({'date_expected': values['date_planned']}) - result = super(PurchaseOrderLine, self).write(values) - if orders: - orders._create_picking() return result name = fields.Text(string='Description', required=True) @@ -619,6 +600,59 @@ class PurchaseOrderLine(models.Model): date_order = fields.Datetime(related='order_id.date_order', string='Order Date', readonly=True) procurement_ids = fields.One2many('procurement.order', 'purchase_line_id', string='Associated Procurements', copy=False) + @api.multi + def _create_or_update_picking(self, vals): + for line in self: + if line.product_id.type in ('product', 'consu'): + if float_compare(vals['product_qty'], line.qty_invoiced, line.product_uom.rounding) == -1: + # If the quantity is now below the invoiced quantity, create an activity on the vendor bill + # inviting the user to create a refund. + activity = self.env['mail.activity'].sudo().create({ + 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, + 'note': _('The quantities on your purchase order indicate less than billed. You should ask for a refund. '), + 'res_id': line.invoice_lines[0].invoice_id.id, + 'res_model_id': self.env.ref('account.model_account_invoice').id, + }) + activity._onchange_activity_type_id() + diff_purchase_uom_qty = vals['product_qty'] - line.product_qty + diff_qty = line.product_uom._compute_quantity(diff_purchase_uom_qty, line.product_id.uom_id) + if diff_purchase_uom_qty > 0 or not line.move_ids: + # If the user increased quantity of existing line or created a new line + pickings = line.order_id.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel') and x.location_dest_id.usage in ('internal', 'transit')) + picking = pickings and pickings[0] or False + if not picking: + res = line.order_id._prepare_picking() + picking = self.env['stock.picking'].create(res) + move_vals = line._get_move_template(picking) + if not line.move_ids: + move_vals['product_uom_qty'] = vals['product_qty'] + else: + move_vals['product_uom_qty'] = diff_purchase_uom_qty + self.env['stock.move']\ + .create(move_vals)\ + .action_confirm()\ + .action_assign() + else: + # Prevent decreasing below received quantity + if float_compare(vals['product_qty'], line.qty_received, line.product_uom.rounding) >= 0: + open_moves = line.move_ids.filtered(lambda m: m.state not in ('done', 'cancel')).sorted(lambda m: m.procurement_id.id) + # We sort by procurement id to decrease first the moves without procurement id, + # the idea is to get rid of the extra moves in priority + for move in open_moves: + if float_compare(move.product_qty, -diff_qty, line.product_id.uom_id.rounding) > 0: + qty_for_move = line.product_uom._compute_quantity(diff_purchase_uom_qty, move.product_uom) + move.product_uom_qty += qty_for_move + move.action_assign() + break + else: + diff_qty += move.product_qty + move.do_unreserve() + move.state = 'draft' + move.unlink() + else: + raise UserError('You cannot decrease the ordered quantity below your receipt.\n' + 'Create a return first.') + @api.multi def _get_stock_move_price_unit(self): self.ensure_one() @@ -634,18 +668,8 @@ class PurchaseOrderLine(models.Model): return price_unit @api.multi - def _prepare_stock_moves(self, picking): - """ Prepare the stock moves data for one order line. This function returns a list of - dictionary ready to be used in stock.move's create() - """ + def _get_move_template(self, picking): self.ensure_one() - res = [] - if self.product_id.type not in ['product', 'consu']: - return res - qty = 0.0 - price_unit = self._get_stock_move_price_unit() - for move in self.move_ids.filtered(lambda x: x.state != 'cancel' and not x.location_dest_id.usage == "supplier"): - qty += move.product_qty template = { 'name': self.name or '', 'product_id': self.product_id.id, @@ -660,14 +684,30 @@ class PurchaseOrderLine(models.Model): 'state': 'draft', 'purchase_line_id': self.id, 'company_id': self.order_id.company_id.id, - 'price_unit': price_unit, + 'price_unit': self._get_stock_move_price_unit(), 'picking_type_id': self.order_id.picking_type_id.id, 'group_id': self.order_id.group_id.id, 'procurement_id': False, 'origin': self.order_id.name, - 'route_ids': self.order_id.picking_type_id.warehouse_id and [(6, 0, [x.id for x in self.order_id.picking_type_id.warehouse_id.route_ids])] or [], + 'route_ids': self.order_id.picking_type_id.warehouse_id and [ + (6, 0, [x.id for x in self.order_id.picking_type_id.warehouse_id.route_ids])] or [], 'warehouse_id': self.order_id.picking_type_id.warehouse_id.id, } + return template + + @api.multi + def _prepare_stock_moves(self, picking): + """ Prepare the stock moves data for one order line. This function returns a list of + dictionary ready to be used in stock.move's create() + """ + self.ensure_one() + res = [] + if self.product_id.type not in ['product', 'consu']: + return res + qty = 0.0 + for move in self.move_ids.filtered(lambda x: x.state != 'cancel' and not x.location_dest_id.usage == "supplier"): + qty += move.product_qty + template = self._get_move_template(picking) # Fullfill all related procurements with this po line diff_quantity = self.product_qty - qty for procurement in self.procurement_ids.filtered(lambda p: p.state != 'cancel'): @@ -806,15 +846,6 @@ class PurchaseOrderLine(models.Model): self.price_unit = price_unit - @api.onchange('product_qty') - def _onchange_product_qty(self): - if (self.state == 'purchase' or self.state == 'to approve') and self.product_id.type in ['product', 'consu'] and self.product_qty < self._origin.product_qty: - warning_mess = { - 'title': _('Ordered quantity decreased!'), - 'message' : _('You are decreasing the ordered quantity!\nYou must update the quantities on the reception and/or bills.'), - } - return {'warning': warning_mess} - def _suggest_quantity(self): ''' Suggest a minimal quantity based on the seller diff --git a/addons/purchase/models/stock.py b/addons/purchase/models/stock.py index 3bfc0877f74..83db902f1ac 100644 --- a/addons/purchase/models/stock.py +++ b/addons/purchase/models/stock.py @@ -11,15 +11,6 @@ class StockPicking(models.Model): purchase_id = fields.Many2one('purchase.order', related='move_lines.purchase_line_id.order_id', string="Purchase Orders", readonly=True) - @api.model - def _prepare_values_extra_move(self, op, product, remaining_qty): - res = super(StockPicking, self)._prepare_values_extra_move(op, product, remaining_qty) - for m in op.linked_move_operation_ids: - if m.move_id.purchase_line_id and m.move_id.product_id == product: - res['purchase_line_id'] = m.move_id.purchase_line_id.id - break - return res - @api.model def _create_backorder(self, backorder_moves=[]): res = super(StockPicking, self)._create_backorder(backorder_moves) @@ -36,7 +27,7 @@ class StockMove(models.Model): _inherit = 'stock.move' purchase_line_id = fields.Many2one('purchase.order.line', - 'Purchase Order Line', ondelete='set null', index=True, readonly=True) + 'Purchase Order Line', ondelete='set null', index=True, readonly=True, copy=False) @api.multi def get_price_unit(self): @@ -56,17 +47,15 @@ class StockMove(models.Model): return self.price_unit return super(StockMove, self).get_price_unit() - @api.multi - def copy(self, default=None): - self.ensure_one() - default = default or {} - # we don't want to propagate the link to the purchase order line on the move copied, - # except when it's a split or a returned move - - if not default.get('split_from') and not default.get('origin_returned_move_id'): - default['purchase_line_id'] = False - return super(StockMove, self).copy(default) + def _prepare_extra_move_vals(self, qty): + vals = super(StockMove, self)._prepare_extra_move_vals(qty) + vals['purchase_line_id'] = self.purchase_line_id.id + return vals + def _prepare_move_split_vals(self, uom_qty): + vals = super(StockMove, self)._prepare_move_split_vals(uom_qty) + vals['purchase_line_id'] = self.purchase_line_id.id + return vals class StockWarehouse(models.Model): _inherit = 'stock.warehouse' @@ -141,3 +130,11 @@ class StockWarehouse(models.Model): if warehouse.in_type_id.default_location_dest_id != warehouse.buy_pull_id.location_id: warehouse.buy_pull_id.write({'location_id': warehouse.in_type_id.default_location_dest_id.id}) return res + +class ReturnPicking(models.TransientModel): + _inherit = "stock.return.picking" + + def _prepare_move_default_values(self, return_line, new_picking): + vals = super(ReturnPicking, self)._prepare_move_default_values(return_line, new_picking) + vals['purchase_line_id'] = return_line.move_id.purchase_line_id.id + return vals diff --git a/addons/purchase/tests/test_create_picking.py b/addons/purchase/tests/test_create_picking.py index 383b250907a..0d8de789006 100644 --- a/addons/purchase/tests/test_create_picking.py +++ b/addons/purchase/tests/test_create_picking.py @@ -47,7 +47,6 @@ class TestCreatePicking(common.TestProductCommon): self.assertEqual(self.po.state, 'purchase', 'Purchase: PO state should be "Purchase') self.assertEqual(self.po.picking_count, 1, 'Purchase: one picking should be created') self.assertEqual(len(self.po.order_line.move_ids), 1, 'One move should be created') - # Change purchase order line product quantity self.po.order_line.write({'product_qty': 7.0}) self.assertEqual(len(self.po.order_line.move_ids), 2, 'Two move should be created') @@ -55,9 +54,11 @@ class TestCreatePicking(common.TestProductCommon): # Validate first shipment self.picking = self.po.picking_ids[0] self.picking.force_assign() - self.picking.pack_operation_product_ids.write({'qty_done': 7.0}) - self.picking.do_new_transfer() + for ml in self.picking.pack_operation_ids: + ml.qty_done = ml.product_uom_qty + self.picking.action_done() self.assertEqual(self.po.order_line.mapped('qty_received'), [7.0], 'Purchase: all products should be received') + # create new order line self.po.write({'order_line': [ @@ -71,7 +72,7 @@ class TestCreatePicking(common.TestProductCommon): })]}) self.assertEqual(self.po.picking_count, 2, 'New picking should be created') moves = self.po.order_line.mapped('move_ids').filtered(lambda x: x.state not in ('done', 'cancel')) - self.assertEqual(len(moves), 1, 'One move should be created') + self.assertEqual(len(moves), 1, 'One moves should have been created') def test_01_check_double_validation(self): diff --git a/addons/purchase/tests/test_purchase_order.py b/addons/purchase/tests/test_purchase_order.py index 42c67a62b63..6d63545b138 100644 --- a/addons/purchase/tests/test_purchase_order.py +++ b/addons/purchase/tests/test_purchase_order.py @@ -75,8 +75,8 @@ class TestPurchaseOrder(AccountingTestCase): self.assertEqual(self.po.picking_count, 1, 'Purchase: one picking should be created"') self.picking = self.po.picking_ids[0] self.picking.force_assign() - self.picking.pack_operation_product_ids.write({'qty_done': 5.0}) - self.picking.do_new_transfer() + self.picking.pack_operation_ids.write({'qty_done': 5.0}) + self.picking.button_validate() self.assertEqual(self.po.order_line.mapped('qty_received'), [5.0, 5.0], 'Purchase: all products should be received"') self.invoice = self.AccountInvoice.create({ @@ -109,8 +109,8 @@ class TestPurchaseOrder(AccountingTestCase): self.assertEqual(self.po.picking_count, 1, 'Purchase: one picking should be created"') self.picking = self.po.picking_ids[0] self.picking.force_assign() - self.picking.pack_operation_product_ids.write({'qty_done': 5.0}) - self.picking.do_new_transfer() + self.picking.pack_operation_ids.write({'qty_done': 5.0}) + self.picking.button_validate() self.assertEqual(self.po.order_line.mapped('qty_received'), [5.0, 5.0], 'Purchase: all products should be received"') #After Receiving all products create vendor bill. @@ -139,8 +139,9 @@ class TestPurchaseOrder(AccountingTestCase): # Validate picking return_pick.force_assign() - return_pick.pack_operation_product_ids.write({'qty_done': 2}) - return_pick.do_new_transfer() + return_pick.pack_operation_ids.write({'qty_done': 2}) + + return_pick.button_validate() # Check Received quantity self.assertEqual(self.po.order_line[0].qty_received, 3.0, 'Purchase: delivered quantity should be 3.0 instead of "%s" after picking return' % self.po.order_line[0].qty_received) diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 27d44b3d81a..28b527cd672 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -811,6 +811,13 @@ class StockMove(models.Model): self.mapped('procurement_id').check() return True + def _prepare_extra_move_vals(self, qty): + vals = { + 'product_uom_qty': qty, + 'picking_id': self.picking_id.id + } + return vals + @api.multi def _create_extra_move(self): """ If the quantity done on a move exceeds its quantity todo, this method will create an @@ -830,9 +837,7 @@ class StockMove(models.Model): self.quantity_done - self.product_uom_qty, precision_rounding=self.product_uom.rounding, rounding_method ='UP') - extra_move_vals = { - 'product_uom_qty': extra_move_quantity, - 'picking_id': self.picking_id.id} + extra_move_vals = self._prepare_extra_move_vals(extra_move_quantity) extra_move = self.copy(default=extra_move_vals).action_confirm() # link it to some move lines @@ -928,6 +933,17 @@ class StockMove(models.Model): raise UserError(_('You can only delete draft moves.')) return super(StockMove, self).unlink() + def _prepare_move_split_vals(self, uom_qty): + vals = { + 'product_uom_qty': uom_qty, + 'procure_method': 'make_to_stock', + 'procurement_id': self.procurement_id.id, + 'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')], + 'move_orig_ids': [(4, x.id) for x in self.move_orig_ids], + 'origin_returned_move_id': self.origin_returned_move_id.id, + } + return vals + @api.multi def split(self, qty, restrict_lot_id=False, restrict_partner_id=False): """ Splits qty from move move into a new move @@ -948,15 +964,7 @@ class StockMove(models.Model): return self.id # HALF-UP rounding as only rounding errors will be because of propagation of error from default UoM uom_qty = self.product_id.uom_id._compute_quantity(qty, self.product_uom, rounding_method='HALF-UP') - defaults = { - 'product_uom_qty': uom_qty, - 'procure_method': 'make_to_stock', - 'restrict_lot_id': restrict_lot_id, - 'procurement_id': self.procurement_id.id, - 'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')], - 'move_orig_ids': [(4, x.id) for x in self.move_orig_ids], - 'origin_returned_move_id': self.origin_returned_move_id.id, - } + defaults = self._prepare_move_split_vals(uom_qty) if restrict_partner_id: defaults['restrict_partner_id'] = restrict_partner_id diff --git a/addons/stock/wizard/stock_picking_return.py b/addons/stock/wizard/stock_picking_return.py index 19d9343f615..adb520f68f8 100644 --- a/addons/stock/wizard/stock_picking_return.py +++ b/addons/stock/wizard/stock_picking_return.py @@ -68,6 +68,21 @@ class ReturnPicking(models.TransientModel): res['location_id'] = location_id return res + def _prepare_move_default_values(self, return_line, new_picking): + vals = { + 'product_id': return_line.product_id.id, + 'product_uom_qty': return_line.quantity, + 'picking_id': new_picking.id, + 'state': 'draft', + 'location_id': return_line.move_id.location_dest_id.id, + 'location_dest_id': self.location_id.id or return_line.move_id.location_id.id, + 'picking_type_id': new_picking.picking_type_id.id, + 'warehouse_id': self.picking_id.picking_type_id.warehouse_id.id, + 'origin_returned_move_id': return_line.move_id.id, + 'procure_method': 'make_to_stock', + } + return vals + @api.multi def _create_returns(self): # TODO sle: the unreserve of the next moves could be less brutal @@ -90,22 +105,11 @@ class ReturnPicking(models.TransientModel): for return_line in self.product_return_moves: if not return_line.move_id: raise UserError(_("You have manually created product lines, please delete them to proceed")) - new_qty = return_line.quantity # TODO sle: float_is_zero? - if new_qty: + if return_line.quantity: returned_lines += 1 - r = return_line.move_id.copy({ - 'product_id': return_line.product_id.id, - 'product_uom_qty': new_qty, - 'picking_id': new_picking.id, - 'state': 'draft', - 'location_id': return_line.move_id.location_dest_id.id, - 'location_dest_id': self.location_id.id or return_line.move_id.location_id.id, - 'picking_type_id': picking_type_id, - 'warehouse_id': self.picking_id.picking_type_id.warehouse_id.id, - 'origin_returned_move_id': return_line.move_id.id, - 'procure_method': 'make_to_stock', - }) + vals = self._prepare_move_default_values(return_line, new_picking) + r = return_line.move_id.copy(vals) r.write({'move_orig_ids': [(4, return_line.move_id.id, False)]}) if not returned_lines: