From fab0f4f3375eff7ace3306034410a9af46a83798 Mon Sep 17 00:00:00 2001 From: Priyansh Shah <108476017+priyanshshah2442@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:55:48 +0530 Subject: [PATCH] fix: Multiple Issues in Pick List to Delivery Note Flow (#48206) * fix: get items from Pick List to DN even if not linked to Sales Order * refactor: consistently return dn; better place to convert json to doc * fix: update DN if already created instead of creating new DN when SO is not present in pick list location * fix: set correct warehouse,batch no and serial no in packed items and allow multiple customer in a pick list * fix: return 0 for minimum possible bundles if none exist * fix: test cases * test: add tests for product bundle items in pick list and handling pick lists with and without sales orders * fix: minor change to test case * refactor: simplify pick list creation by using create_pick_list function * fix: update delivery note creation logic and remove unused function * test: update pick list test for packed items * fix: add conditional check for sales_order before setting customer in delivery note * test: add test case for packed item multiple times in so --------- Co-authored-by: Smit Vora --- .../stock/doctype/packed_item/packed_item.py | 26 ++++ erpnext/stock/doctype/pick_list/pick_list.py | 93 +++++++------ .../stock/doctype/pick_list/test_pick_list.py | 126 ++++++++++++++++-- 3 files changed, 187 insertions(+), 58 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 23aa5bf6247..8c31f9156d6 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -89,6 +89,10 @@ def make_packing_list(doc): update_packed_item_basic_data(item_row, pi_row, bundle_item, item_data) update_packed_item_stock_data(item_row, pi_row, bundle_item, item_data, doc) update_packed_item_price_data(pi_row, item_data, doc) + + if item_row.get("against_pick_list"): + update_packed_item_with_pick_list_info(item_row, pi_row) + update_packed_item_from_cancelled_doc(item_row, bundle_item, pi_row, doc) if set_price_from_children: # create/update bundle item wise price dict @@ -237,6 +241,28 @@ def update_packed_item_stock_data(main_item_row, pi_row, packing_item, item_data pi_row.use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields") +def update_packed_item_with_pick_list_info(main_item_row, pi_row): + pl_row = frappe.db.get_value( + "Pick List Item", + { + "item_code": pi_row.item_code, + "sales_order": main_item_row.get("against_sales_order"), + "sales_order_item": main_item_row.get("so_detail"), + "parent": main_item_row.against_pick_list, + }, + ["warehouse", "batch_no", "serial_no"], + as_dict=True, + order_by="qty desc", + ) + + if not pl_row: + return + + pi_row.warehouse = pl_row.warehouse + pi_row.batch_no = pl_row.batch_no + pi_row.serial_no = pl_row.serial_no + + def update_packed_item_price_data(pi_row, item_data, doc): "Set price as per price list or from the Item master." if pi_row.rate: diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 36f29cbf929..83f7d4b3370 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -161,7 +161,6 @@ class PickList(TransactionBase): "Sales Order": { "ref_dn_field": "sales_order", "compare_fields": [ - ["customer", "="], ["company", "="], ], }, @@ -776,16 +775,16 @@ class PickList(TransactionBase): def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int: """Compute how many full bundles can be created from picked items.""" precision = frappe.get_precision("Stock Ledger Entry", "qty_after_transaction") - possible_bundles = [] + possible_bundles = {} for item in self.locations: if item.sales_order_item != bundle_row: continue if qty_in_bundle := bundle_items.get(item.item_code): - possible_bundles.append(item.picked_qty / qty_in_bundle) - else: - possible_bundles.append(0) - return int(flt(min(possible_bundles), precision or 6)) + possible_bundles.setdefault(item.product_bundle_item, 0) + possible_bundles[item.product_bundle_item] += item.picked_qty / qty_in_bundle + + return int(flt(min(possible_bundles.values()), precision or 6)) if possible_bundles else 0 def has_unreserved_stock(self): if self.purpose == "Delivery": @@ -1221,8 +1220,10 @@ def create_delivery_note(source_name, target_doc=None): return delivery_note -def create_dn_wo_so(pick_list): - delivery_note = frappe.new_doc("Delivery Note") +def create_dn_wo_so(pick_list, delivery_note=None): + if not delivery_note: + delivery_note = frappe.new_doc("Delivery Note") + delivery_note.company = pick_list.company item_table_mapper_without_so = { @@ -1234,6 +1235,8 @@ def create_dn_wo_so(pick_list): }, } map_pl_locations(pick_list, item_table_mapper_without_so, delivery_note) + delivery_note.flags.ignore_mandatory = True + delivery_note.save() return delivery_note @@ -1244,22 +1247,30 @@ def create_dn_for_pick_lists(source_name, target_doc=None, kwargs=None): pick_list = frappe.get_doc("Pick List", source_name) validate_item_locations(pick_list) - if kwargs and (order := kwargs.get("sales_order")): - sales_orders = {order} + sales_order_arg = kwargs.get("sales_order") if kwargs else None + customer_arg = kwargs.get("customer") if kwargs else None + + if sales_order_arg: + sales_orders = {sales_order_arg} else: sales_orders = {row.sales_order for row in pick_list.locations if row.sales_order} - if kwargs and (customer := kwargs.get("customer")): + if customer_arg: sales_orders = frappe.get_all( "Sales Order", - filters={"customer": customer, "name": ["in", list(sales_orders)]}, + filters={"customer": customer_arg, "name": ["in", list(sales_orders)]}, pluck="name", ) - if not sales_orders: - return + delivery_note = create_dn_from_so(pick_list, sales_orders, delivery_note=target_doc) - return create_dn_from_so(pick_list, sales_orders, delivery_note=target_doc) + if not sales_order_arg and not all(item.sales_order for item in pick_list.locations): + if isinstance(delivery_note, str): + delivery_note = frappe.get_doc(frappe.parse_json(delivery_note)) + + delivery_note = create_dn_wo_so(pick_list, delivery_note) + + return delivery_note def create_dn_with_so(sales_dict, pick_list): @@ -1268,11 +1279,19 @@ def create_dn_with_so(sales_dict, pick_list): for customer in sales_dict: delivery_note = create_dn_from_so(pick_list, sales_dict[customer], None) + if delivery_note: + delivery_note.flags.ignore_mandatory = True + # updates packed_items on save + # save as multiple customers are possible + delivery_note.save() return delivery_note def create_dn_from_so(pick_list, sales_order_list, delivery_note=None): + if not sales_order_list: + return delivery_note + item_table_mapper = { "doctype": "Delivery Note Item", "field_map": { @@ -1284,6 +1303,7 @@ def create_dn_from_so(pick_list, sales_order_list, delivery_note=None): } kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule} + delivery_note = create_delivery_note_from_sales_order( next(iter(sales_order_list)), delivery_note, kwargs=kwargs ) @@ -1291,11 +1311,8 @@ def create_dn_from_so(pick_list, sales_order_list, delivery_note=None): if not delivery_note: return - if delivery_note: - for so in sales_order_list: - map_pl_locations(pick_list, item_table_mapper, delivery_note, so) - - update_packed_item_details(pick_list, delivery_note) + for so in sales_order_list: + map_pl_locations(pick_list, item_table_mapper, delivery_note, so) return delivery_note @@ -1331,7 +1348,8 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): set_delivery_note_missing_values(delivery_note) delivery_note.company = pick_list.company - delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") + if sales_order: + delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") def add_product_bundles_to_delivery_note( @@ -1353,34 +1371,10 @@ def add_product_bundles_to_delivery_note( dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( so_row, product_bundle_qty_map[item_code] ) + dn_bundle_item.against_pick_list = pick_list.name update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note) -def update_packed_item_details(pick_list: "PickList", delivery_note) -> None: - """Update stock details on packed items table of delivery note.""" - - def _find_so_row(packed_item): - for item in delivery_note.items: - if packed_item.parent_detail_docname == item.name: - return item.so_detail - - def _find_pick_list_location(bundle_row, packed_item): - if not bundle_row: - return - for loc in pick_list.locations: - if loc.product_bundle_item == bundle_row and loc.item_code == packed_item.item_code: - return loc - - for packed_item in delivery_note.packed_items: - so_row = _find_so_row(packed_item) - location = _find_pick_list_location(so_row, packed_item) - if not location: - continue - packed_item.warehouse = location.warehouse - packed_item.batch_no = location.batch_no - packed_item.serial_no = location.serial_no - - @frappe.whitelist() def create_stock_entry(pick_list): pick_list = frappe.get_doc(json.loads(pick_list)) @@ -1558,20 +1552,23 @@ def get_pick_list_query(doctype, txt, searchfield, start, page_len, filters): PICK_LIST = frappe.qb.DocType("Pick List") PICK_LIST_ITEM = frappe.qb.DocType("Pick List Item") + SALES_ORDER = frappe.qb.DocType("Sales Order") query = ( frappe.qb.from_(PICK_LIST) .join(PICK_LIST_ITEM) .on(PICK_LIST.name == PICK_LIST_ITEM.parent) + .join(SALES_ORDER) + .on(PICK_LIST_ITEM.sales_order == SALES_ORDER.name) .select( PICK_LIST.name, - PICK_LIST.customer, + SALES_ORDER.customer, Replace(GROUP_CONCAT(PICK_LIST_ITEM.sales_order).distinct(), ",", "
").as_("sales_order"), ) .where(PICK_LIST.docstatus == 1) .where(PICK_LIST.status.isin(["Open", "Partly Delivered"])) .where(PICK_LIST.company == filters.get("company")) - .where(PICK_LIST.customer == filters.get("customer")) + .where(SALES_ORDER.customer == filters.get("customer")) .groupby(PICK_LIST.name) ) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index fba222410d8..d24d241e6ab 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -5,11 +5,12 @@ import frappe from frappe import _dict from frappe.tests import IntegrationTestCase +from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.selling.doctype.sales_order.sales_order import create_pick_list from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle -from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note +from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note, create_dn_for_pick_lists from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( get_batch_from_bundle, @@ -401,7 +402,7 @@ class TestPickList(IntegrationTestCase): item_code = make_item( uoms=[ {"uom": "Nos", "conversion_factor": 1}, - {"uom": "Box", "conversion_factor": 5}, + {"uom": "Hand", "conversion_factor": 5}, {"uom": "Unit", "conversion_factor": 0.5}, ] ).name @@ -417,7 +418,7 @@ class TestPickList(IntegrationTestCase): { "item_code": item_code, "qty": 1, - "uom": "Box", + "uom": "Hand", "delivery_date": frappe.utils.today(), "warehouse": "_Test Warehouse - _TC", }, @@ -546,7 +547,7 @@ class TestPickList(IntegrationTestCase): sales_order_2 = frappe.get_doc( { "doctype": "Sales Order", - "customer": "_Test Customer", + "customer": "_Test Customer 1", "company": "_Test Company", "items": [ { @@ -565,11 +566,10 @@ class TestPickList(IntegrationTestCase): "company": "_Test Company", "items_based_on": "Sales Order", "purpose": "Delivery", - "picker": "P001", "customer": "_Test Customer", "locations": [ { - "item_code": "_Test Item ", + "item_code": "_Test Item", "qty": 1, "stock_qty": 1, "conversion_factor": 1, @@ -599,7 +599,7 @@ class TestPickList(IntegrationTestCase): self.assertEqual(dn_item.item_code, "_Test Item") self.assertEqual(dn_item.against_sales_order, sales_order_1.name) self.assertEqual(dn_item.against_pick_list, pick_list.name) - self.assertEqual(dn_item.pick_list_item, pick_list.locations[dn_item.idx - 1].name) + self.assertEqual(dn_item.pick_list_item, pick_list.locations[0].name) for dn in frappe.get_all( "Delivery Note", @@ -610,17 +610,16 @@ class TestPickList(IntegrationTestCase): self.assertEqual(dn_item.item_code, "_Test Item 2") self.assertEqual(dn_item.against_sales_order, sales_order_2.name) self.assertEqual(dn_item.against_pick_list, pick_list.name) - self.assertEqual(dn_item.pick_list_item, pick_list.locations[dn_item.idx - 1].name) + self.assertEqual(dn_item.pick_list_item, pick_list.locations[1].name) # test DN creation without so pick_list_1 = frappe.get_doc( { "doctype": "Pick List", "company": "_Test Company", "purpose": "Delivery", - "picker": "P001", "locations": [ { - "item_code": "_Test Item ", + "item_code": "_Test Item", "qty": 1, "stock_qty": 1, "conversion_factor": 1, @@ -1379,3 +1378,110 @@ class TestPickList(IntegrationTestCase): sales_order.reload() sales_order.cancel() stock_entry.cancel() + + def test_packed_item_in_pick_list(self): + warehouse_1 = "RJ Warehouse - _TC" + warehouse_2 = "_Test Warehouse 2 - _TC" + item_1 = make_item(properties={"is_stock_item": 0}).name + item_2 = make_item().name + item_3 = make_item().name + + make_product_bundle(item_1, items=[item_2, item_3]) + + stock_entry_1 = make_stock_entry(item=item_2, to_warehouse=warehouse_1, qty=10, basic_rate=100) + stock_entry_2 = make_stock_entry(item=item_3, to_warehouse=warehouse_1, qty=4, basic_rate=100) + stock_entry_3 = make_stock_entry(item=item_3, to_warehouse=warehouse_2, qty=6, basic_rate=100) + + sales_order = make_sales_order(item_code=item_1, qty=10, rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + self.assertEqual(len(pick_list.locations), 3) + delivery_note = create_delivery_note(pick_list.name) + + self.assertEqual(delivery_note.items[0].qty, 10) + self.assertEqual(delivery_note.packed_items[0].warehouse, warehouse_1) + self.assertEqual(delivery_note.packed_items[1].warehouse, warehouse_2) + + pick_list.cancel() + sales_order.cancel() + stock_entry_1.cancel() + stock_entry_2.cancel() + stock_entry_3.cancel() + + def test_packed_item_multiple_times_in_so(self): + frappe.db.delete("Item Price") + warehouse_1 = "RJ Warehouse - _TC" + warehouse_2 = "_Test Warehouse 2 - _TC" + warehouse = "_Test Warehouse - _TC" + item_1 = make_item(properties={"is_stock_item": 0}).name + item_2 = make_item().name + item_3 = make_item().name + + make_product_bundle(item_1, items=[item_2, item_3]) + + stock_entry_1 = make_stock_entry(item=item_2, to_warehouse=warehouse_1, qty=20, basic_rate=100) + stock_entry_2 = make_stock_entry(item=item_3, to_warehouse=warehouse_1, qty=8, basic_rate=100) + stock_entry_3 = make_stock_entry(item=item_3, to_warehouse=warehouse_2, qty=12, basic_rate=100) + + sales_order = make_sales_order( + item_list=[ + {"item_code": item_1, "qty": 8, "rate": 100, "warehouse": warehouse}, + {"item_code": item_1, "qty": 12, "rate": 100, "warehouse": warehouse}, + ] + ) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + self.assertEqual(len(pick_list.locations), 4) + delivery_note = create_delivery_note(pick_list.name) + + self.assertEqual(delivery_note.items[0].qty, 8) + self.assertEqual(delivery_note.items[1].qty, 12) + + self.assertEqual(delivery_note.packed_items[0].qty, 8) + self.assertEqual(delivery_note.packed_items[2].qty, 12) + + self.assertEqual(delivery_note.packed_items[0].warehouse, warehouse_1) + self.assertEqual(delivery_note.packed_items[1].warehouse, warehouse_1) + self.assertEqual(delivery_note.packed_items[2].warehouse, warehouse_1) + self.assertEqual(delivery_note.packed_items[3].warehouse, warehouse_2) + + pick_list.cancel() + sales_order.cancel() + stock_entry_1.cancel() + stock_entry_2.cancel() + stock_entry_3.cancel() + + def test_pick_list_with_and_without_so(self): + warehouse = "_Test Warehouse - _TC" + item = make_item().name + + sales_order = make_sales_order(item_code=item, qty=20, rate=100) + stock_entry = make_stock_entry(item=item, to_warehouse=warehouse, qty=500, basic_rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.append( + "locations", + { + "item_code": item, + "qty": 10, + "stock_qty": 10, + "warehouse": warehouse, + "picked_qty": 0, + }, + ) + pick_list.submit() + + delivery_note = create_dn_for_pick_lists(pick_list.name) + + self.assertEqual(delivery_note.items[0].against_pick_list, pick_list.name) + self.assertEqual(delivery_note.items[0].against_sales_order, sales_order.name) + self.assertEqual(delivery_note.items[0].qty, 20) + + self.assertEqual(delivery_note.items[1].against_pick_list, pick_list.name) + self.assertEqual(delivery_note.items[1].qty, 10) + + pick_list.cancel() + sales_order.cancel() + stock_entry.cancel()