diff --git a/addons/point_of_sale/models/stock_picking.py b/addons/point_of_sale/models/stock_picking.py index caf6f1f116f..b3fb1093484 100644 --- a/addons/point_of_sale/models/stock_picking.py +++ b/addons/point_of_sale/models/stock_picking.py @@ -6,6 +6,7 @@ from odoo.exceptions import UserError, ValidationError from odoo.tools import float_is_zero, float_compare from itertools import groupby +from collections import defaultdict class StockPicking(models.Model): _inherit='stock.picking' @@ -86,13 +87,13 @@ class StockPicking(models.Model): def _create_move_from_pos_order_lines(self, lines): self.ensure_one() lines_by_product = groupby(sorted(lines, key=lambda l: l.product_id.id), key=lambda l: l.product_id.id) - for product, lines in lines_by_product: - order_lines = self.env['pos.order.line'].concat(*lines) - current_move = self.env['stock.move'].create( - self._prepare_stock_move_vals(order_lines[0], order_lines) - ) - confirmed_moves = current_move._action_confirm() - confirmed_moves._add_mls_related_to_order(order_lines) + move_vals = [] + for dummy, olines in lines_by_product: + order_lines = self.env['pos.order.line'].concat(*olines) + move_vals.append(self._prepare_stock_move_vals(order_lines[0], order_lines)) + moves = self.env['stock.move'].create(move_vals) + confirmed_moves = moves._action_confirm() + confirmed_moves._add_mls_related_to_order(lines, are_qties_done=True) def _send_confirmation_email(self): # Avoid sending Mail/SMS for POS deliveries @@ -129,71 +130,111 @@ class StockMove(models.Model): keys = super(StockMove, self)._key_assign_picking() return keys + (self.group_id.pos_order_id,) - def _complete_done_qties(self): - self.ensure_one() - # from commit + @api.model + def _prepare_lines_data_dict(self, order_lines): + lines_data = defaultdict(dict) + for product_id, olines in groupby(sorted(order_lines, key=lambda l: l.product_id.id), key=lambda l: l.product_id.id): + lines_data[product_id].update({'order_lines': self.env['pos.order.line'].concat(*olines)}) + return lines_data + + def _complete_done_qties(self, set_quantity_done_on_move=False): + self._action_assign() for move_line in self.move_line_ids: move_line.qty_done = move_line.reserved_uom_qty - if float_compare(self.product_uom_qty, self.quantity_done, precision_rounding=self.product_uom.rounding) > 0: - remaining_qty = self.product_uom_qty - self.quantity_done - ml_vals = self._prepare_move_line_vals() - ml_vals.update({'qty_done': remaining_qty}) - self.env['stock.move.line'].create(ml_vals) + mls_vals = [] + moves_to_set = set() + for move in self: + if float_compare(move.product_uom_qty, move.quantity_done, precision_rounding=move.product_uom.rounding) > 0: + remaining_qty = move.product_uom_qty - move.quantity_done + mls_vals.append(dict(move._prepare_move_line_vals(), qty_done=remaining_qty)) + moves_to_set.add(move.id) + self.env['stock.move.line'].create(mls_vals) + if set_quantity_done_on_move: + for move in self.env['stock.move'].browse(moves_to_set): + move.quantity_done = move.product_uom_qty + + def _create_production_lots_for_pos_order(self, lines): + ''' Search for existing lots and create missing ones. + + :param lines: pos order lines with pack lot ids. + :type lines: pos.order.line recordset. + + :return stock.lot recordset. + ''' + valid_lots = self.env['stock.lot'] + moves = self.filtered(lambda m: m.picking_type_id.use_existing_lots) + # Already called in self._action_confirm() but just to be safe when coming from _launch_stock_rule_from_pos_order_lines. + self._check_company() + if moves: + moves_product_ids = set(moves.mapped('product_id').ids) + lots = lines.pack_lot_ids.filtered(lambda l: l.lot_name and l.product_id.id in moves_product_ids) + lots_data = set(lots.mapped(lambda l: (l.product_id.id, l.lot_name))) + existing_lots = self.env['stock.lot'].search([ + ('company_id', '=', moves[0].picking_type_id.company_id.id), + ('product_id', 'in', lines.product_id.ids), + ('name', 'in', lots.mapped('lot_name')), + ]) + #The previous search may return (product_id.id, lot_name) combinations that have no matching in lines.pack_lot_ids. + for lot in existing_lots: + if (lot.product_id.id, lot.name) in lots_data: + valid_lots |= lot + lots_data.remove((lot.product_id.id, lot.name)) + moves = moves.filtered(lambda m: m.picking_type_id.use_create_lots) + if moves: + moves_product_ids = set(moves.mapped('product_id').ids) + missing_lot_values = [] + for lot_product_id, lot_name in filter(lambda l: l[0] in moves_product_ids, lots_data): + missing_lot_values.append({'company_id': self.company_id.id, 'product_id': lot_product_id, 'name': lot_name}) + valid_lots |= self.env['stock.lot'].create(missing_lot_values) + return valid_lots def _add_mls_related_to_order(self, related_order_lines, are_qties_done=True): + lines_data = self._prepare_lines_data_dict(related_order_lines) qty_fname = 'qty_done' if are_qties_done else 'reserved_uom_qty' - for move in self: - if related_order_lines[0].product_id == move.product_id and related_order_lines[0].product_id.tracking != 'none': - if move.picking_type_id.use_existing_lots or move.picking_type_id.use_create_lots: - for line in related_order_lines: - sum_of_lots = 0 - for lot in line.pack_lot_ids.filtered(lambda l: l.lot_name): - if line.product_id.tracking == 'serial': - qty = 1 - else: - qty = abs(line.qty) - ml_vals = move._prepare_move_line_vals() - if move.picking_type_id.use_existing_lots: - existing_lot = self.env['stock.lot'].search([ - ('company_id', '=', self.company_id.id), - ('product_id', '=', line.product_id.id), - ('name', '=', lot.lot_name) - ]) - if not existing_lot and move.picking_type_id.use_create_lots: - existing_lot = self.env['stock.lot'].create({ - 'company_id': self.company_id.id, - 'product_id': line.product_id.id, - 'name': lot.lot_name, - }) - quant = existing_lot.quant_ids.filtered(lambda q: q.quantity > 0.0 and q.location_id.parent_path.startswith(move.location_id.parent_path))[-1:] - ml_vals.update({ - 'lot_id': existing_lot.id, - 'location_id': quant.location_id.id or move.location_id.id - }) - else: - ml_vals.update({ - 'lot_name': lot.lot_name, - }) - ml = self.env['stock.move.line'].create(ml_vals) - ml.write({qty_fname: qty}) - sum_of_lots += qty - if abs(line.qty) != sum_of_lots: - difference_qty = abs(line.qty) - sum_of_lots - ml_vals = self[0]._prepare_move_line_vals() - if line.product_id.tracking == 'serial': - mls = self.env['stock.move.line'] - for dummy in range(int(difference_qty)): - mls |= self.env['stock.move.line'].create(ml_vals) - mls.write({qty_fname: 1}) - else: - ml = self.env['stock.move.line'].create(ml_vals) - ml.write({qty_fname: difference_qty}) - else: - move._action_assign() - if are_qties_done: - move._complete_done_qties() - else: - move._action_assign() - if are_qties_done: - move._complete_done_qties() - move.quantity_done = move.product_uom_qty + # Moves with product_id not in related_order_lines. This can happend e.g. when product_id has a phantom-type bom. + moves_to_assign = self.filtered(lambda m: m.product_id.id not in lines_data or m.product_id.tracking == 'none' + or (not m.picking_type_id.use_existing_lots and not m.picking_type_id.use_create_lots)) + moves_to_assign._complete_done_qties(set_quantity_done_on_move=True) + moves_remaining = self - moves_to_assign + existing_lots = moves_remaining._create_production_lots_for_pos_order(related_order_lines) + move_lines_to_create = [] + mls_qties = [] + for move in moves_remaining: + for line in lines_data[move.product_id.id]['order_lines']: + sum_of_lots = 0 + for lot in line.pack_lot_ids.filtered(lambda l: l.lot_name): + if line.product_id.tracking == 'serial': + qty = 1 + else: + qty = abs(line.qty) + ml_vals = dict(move._prepare_move_line_vals()) + if existing_lots: + existing_lot = existing_lots.filtered_domain([('product_id', '=', line.product_id.id), ('name', '=', lot.lot_name)]) + quant = self.env['stock.quant'] + if existing_lot: + quant = self.env['stock.quant'].search( + [('lot_id', '=', existing_lot.id), ('quantity', '>', '0.0'), ('location_id', 'child_of', move.location_id.id)], + order='id desc', + limit=1 + ) + ml_vals.update({ + 'lot_id': existing_lot.id, + 'location_id': quant.location_id.id or move.location_id.id + }) + else: + ml_vals.update({'lot_name': lot.lot_name}) + move_lines_to_create.append(ml_vals) + mls_qties.append(qty) + sum_of_lots += qty + if abs(line.qty) != sum_of_lots: + difference_qty = abs(line.qty) - sum_of_lots + ml_vals = self[0]._prepare_move_line_vals() + if line.product_id.tracking == 'serial': + move_lines_to_create.extend([ml_vals for i in range(int(difference_qty))]) + mls_qties.extend([1]*int(difference_qty)) + else: + move_lines_to_create.append(ml_vals) + mls_qties.append(difference_qty) + move_lines = self.env['stock.move.line'].create(move_lines_to_create) + for move_line, qty in zip(move_lines, mls_qties): + move_line.write({qty_fname: qty}) diff --git a/addons/point_of_sale/tests/test_point_of_sale_flow.py b/addons/point_of_sale/tests/test_point_of_sale_flow.py index 07fdaacb070..1db31227f44 100644 --- a/addons/point_of_sale/tests/test_point_of_sale_flow.py +++ b/addons/point_of_sale/tests/test_point_of_sale_flow.py @@ -473,10 +473,12 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): pos_make_payment.with_context(context_payment).check() self.assertEqual(pos_order.state, 'paid') - self.assertEqual(pos_order.picking_ids.move_line_ids[0].lot_id, lot) - self.assertFalse(pos_order.picking_ids.move_line_ids[1].lot_id) - self.assertEqual(pos_order.picking_ids.move_line_ids[0].location_id, shelf1_location) - self.assertEqual(pos_order.picking_ids.move_line_ids[1].location_id, shelf1_location) + tracked_line = pos_order.picking_ids.move_line_ids.filtered(lambda ml: ml.product_id.id == tracked_product.id) + untracked_line = pos_order.picking_ids.move_line_ids - tracked_line + self.assertEqual(tracked_line.lot_id, lot) + self.assertFalse(untracked_line.lot_id) + self.assertEqual(tracked_line.location_id, shelf1_location) + self.assertEqual(untracked_line.location_id, shelf1_location) self.pos_config.current_session_id.action_pos_session_closing_control() @@ -1086,3 +1088,61 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): self.assertEqual(lot02_ml.reserved_qty, 1) self.assertEqual(lot03_ml.reserved_qty, 1) self.assertEqual(lot03_ml.lot_id.name, "Lot03") + + def test_order_multi_step_route(self): + """ Test that orders in sessions with "Ship Later" enabled and "Specific Route" set to a + multi-step (2/3) route can be validated. This config implies multiple picking types + and multiple move_lines. + """ + tracked_product = self.env['product.product'].create({ + 'name': 'SuperProduct Tracked', + 'type': 'product', + 'tracking': 'lot', + 'available_in_pos': True + }) + warehouse_id = self.company_data['default_warehouse'] + warehouse_id.delivery_steps = 'pick_ship' + + self.pos_config.ship_later = True + self.pos_config.warehouse_id = warehouse_id + self.pos_config.route_id = warehouse_id.route_ids[-1] + self.pos_config.open_session_cb() + self.pos_config.current_session_id.update_stock_at_closing = False + + untax, tax = self.compute_tax(tracked_product, 1.15, 1) + + pos_order = self.PosOrder.create({ + 'company_id': self.env.company.id, + 'session_id': self.pos_config.current_session_id.id, + 'pricelist_id': self.partner1.property_product_pricelist.id, + 'partner_id': self.partner1.id, + 'lines': [(0, 0, { + 'name': "OL/0001", + 'product_id': tracked_product.id, + 'price_unit': 1.15, + 'qty': 1.0, + 'price_subtotal': untax, + 'price_subtotal_incl': untax + tax, + })], + 'amount_tax': tax, + 'amount_total': untax+tax, + 'amount_paid': 0, + 'amount_return': 0, + 'to_ship': True, + }) + + context_make_payment = { + "active_ids": [pos_order.id], + "active_id": pos_order.id, + } + pos_make_payment = self.PosMakePayment.with_context(context_make_payment).create({ + 'amount': untax+tax, + }) + context_payment = {'active_id': pos_order.id} + pos_make_payment.with_context(context_payment).check() + + pickings = pos_order.picking_ids + picking_mls = pickings.move_line_ids + self.assertEqual(pos_order.state, 'paid') + self.assertEqual(len(picking_mls), 2) + self.assertEqual(len(pickings.picking_type_id), 2)