[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:
committed by
Simon Lejeune
parent
0a76e158e1
commit
8248f0e153
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user