[IMP] purchase,stock: edit PO line quantity

This commit adapt purchase to be able to edit purchase line ordered quantity
and propagate it to the related picking after the request for quotation
validation.

These are the supported use case

- When you increase the quantity on a purchase line, an extra move (and
an extra picking if they are all done or canceled) is created with the
difference between old and new value and the push rules are applied on
it.

- When you decrease the quantity of a purchase line, different behaviors
can occurs:
  * If you plan to decrese the ordered quantity under the received
    quantity, you get a user error saying tht you have to make some
    return before.
  * Else the moves not done or cancelled are decreased by starting with
    move having no procurement_id. The decrease is not propagated
    through push or pull rules.
  * If you decreased the quantity under the invoiced quantity, a next
    activity is created to remind you to do a refund.
This commit is contained in:
Pierre Masereel
2017-07-14 17:08:21 +02:00
committed by Simon Lejeune
parent 0a76e158e1
commit 8248f0e153
6 changed files with 142 additions and 100 deletions
+75 -44
View File
@@ -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 += "<b>" + _('The ordered quantity has been decreased. Do not forget to take it into account on your bills and receipts.') + '</b><br/>'
msg += "<ul>"
for line in order_lines:
msg += "<li> %s:" % (line.product_id.display_name,)
msg += "<br/>" + _("Ordered Quantity") + ": %s -> %s <br/>" % (line.product_qty, float(values['product_qty']),)
if line.product_id.type in ('product', 'consu'):
msg += _("Received Quantity") + ": %s <br/>" % (line.qty_received,)
msg += _("Billed Quantity") + ": %s <br/></li>" % (line.qty_invoiced,)
msg += "</ul>"
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
+17 -20
View File
@@ -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
+5 -4
View File
@@ -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):
+7 -6
View File
@@ -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)
+20 -12
View File
@@ -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
+18 -14
View File
@@ -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: