From 5c3f9019cc4dda9e0c58a7bf9a824e64db995a21 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 21 Apr 2022 17:18:19 +0530 Subject: [PATCH 01/19] refactor: misc pick list refactors - make tracking fields read only and no-copy :facepalm: - collapse print settings section, most users configure it once and forget about it, not need to show this. - call pick list grouping function directly - use get_descendants_of instead of obscure db function --- erpnext/selling/doctype/sales_order/sales_order.json | 3 ++- .../selling/doctype/sales_order_item/sales_order_item.json | 6 ++++-- erpnext/stock/doctype/pick_list/pick_list.json | 4 +++- erpnext/stock/doctype/pick_list/pick_list.py | 6 +++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index fe2f14e19a6..1d0432bddbe 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1520,6 +1520,7 @@ "fieldname": "per_picked", "fieldtype": "Percent", "label": "% Picked", + "no_copy": 1, "read_only": 1 } ], @@ -1527,7 +1528,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2022-03-15 21:38:31.437586", + "modified": "2022-04-21 08:16:48.316074", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 195e96486b3..8a6a0bae548 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -803,13 +803,15 @@ { "fieldname": "picked_qty", "fieldtype": "Float", - "label": "Picked Qty" + "label": "Picked Qty", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2022-03-15 20:17:33.984799", + "modified": "2022-04-21 08:15:14.010319", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index c604c711ef5..e984c082d48 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -114,6 +114,7 @@ "set_only_once": 1 }, { + "collapsible": 1, "fieldname": "print_settings_section", "fieldtype": "Section Break", "label": "Print Settings" @@ -129,7 +130,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-10-05 15:08:40.369957", + "modified": "2022-04-21 07:56:40.646473", "modified_by": "Administrator", "module": "Stock", "name": "Pick List", @@ -199,5 +200,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 33d7745c628..72524f036ec 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -11,6 +11,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.mapper import map_child_doc from frappe.utils import cint, floor, flt, today +from frappe.utils.nestedset import get_descendants_of from erpnext.selling.doctype.sales_order.sales_order import ( make_delivery_note as create_delivery_note_from_sales_order, @@ -109,7 +110,7 @@ class PickList(Document): from_warehouses = None if self.parent_warehouse: - from_warehouses = frappe.db.get_descendants("Warehouse", self.parent_warehouse) + from_warehouses = get_descendants_of("Warehouse", self.parent_warehouse) # Create replica before resetting, to handle empty table on update after submit. locations_replica = self.get("locations") @@ -190,8 +191,7 @@ class PickList(Document): frappe.throw(_("Qty of Finished Goods Item should be greater than 0.")) def before_print(self, settings=None): - if self.get("group_same_items"): - self.group_similar_items() + self.group_similar_items() def group_similar_items(self): group_item_qty = defaultdict(float) From 7d5682020ad59853ebec83431219671a3648d4b9 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 20 Apr 2022 18:57:33 +0530 Subject: [PATCH 02/19] test: bundles in picklist --- .../stock/doctype/pick_list/test_pick_list.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 27b06d2dd93..1ba46f74148 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -3,11 +3,10 @@ import frappe from frappe import _dict - -test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"] - from frappe.tests.utils import FrappeTestCase +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.pick_list.pick_list import create_delivery_note from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -15,6 +14,8 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( EmptyStockReconciliationItemsError, ) +test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"] + class TestPickList(FrappeTestCase): def test_pick_list_picks_warehouse_for_each_item(self): @@ -579,6 +580,19 @@ class TestPickList(FrappeTestCase): if dn_item.item_code == "_Test Item 2": self.assertEqual(dn_item.qty, 2) + def test_picklist_with_bundles(self): + # from test_records.json + bundle = "_Test Product Bundle Item" + bundle_items = {"_Test Item": 5, "_Test Item Home Desktop 100": 2} + + so = make_sales_order(item_code=bundle, qty=1) + + pl = create_pick_list(so.name) + pl.save() + self.assertEqual(len(pl.locations), 2) + for item in pl.locations: + self.assertEqual(item.stock_qty, bundle_items[item.item_code]) + # def test_pick_list_skips_items_in_expired_batch(self): # pass From 36c5e8a14fd9cebac5c5f22cf062ca3a47a416f0 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 21 Apr 2022 12:33:38 +0530 Subject: [PATCH 03/19] feat: Pick list from SO with Product Bundle --- .../doctype/sales_order/sales_order.py | 31 +++++++++++++++++-- .../doctype/packed_item/packed_item.json | 9 +++++- .../stock/doctype/packed_item/packed_item.py | 6 +++- .../stock/doctype/pick_list/test_pick_list.py | 8 +++-- .../pick_list_item/pick_list_item.json | 13 +++++++- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index d3b4286be54..1d172ad0bf3 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1232,10 +1232,27 @@ def make_inter_company_purchase_order(source_name, target_doc=None): @frappe.whitelist() def create_pick_list(source_name, target_doc=None): - def update_item_quantity(source, target, source_parent): + from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle + + def update_item_quantity(source, target, source_parent) -> None: target.qty = flt(source.qty) - flt(source.delivered_qty) target.stock_qty = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.conversion_factor) + def update_packed_item_qty(source, target, source_parent) -> None: + qty = flt(source.qty) + for item in source_parent.items: + if source.parent_detail_docname == item.name: + pending_percent = (item.qty - item.delivered_qty) / item.qty + target.qty = target.stock_qty = qty * pending_percent + return + + def should_pick_order_item(item) -> bool: + return ( + abs(item.delivered_qty) < abs(item.qty) + and item.delivered_by_supplier != 1 + and not is_product_bundle(item.item_code) + ) + doc = get_mapped_doc( "Sales Order", source_name, @@ -1245,8 +1262,16 @@ def create_pick_list(source_name, target_doc=None): "doctype": "Pick List Item", "field_map": {"parent": "sales_order", "name": "sales_order_item"}, "postprocess": update_item_quantity, - "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) - and doc.delivered_by_supplier != 1, + "condition": should_pick_order_item, + }, + "Packed Item": { + "doctype": "Pick List Item", + "field_map": { + "parent": "sales_order", + "name": "sales_order_item", + "parent_detail_docname": "product_bundle_item", + }, + "postprocess": update_packed_item_qty, }, }, target_doc, diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index e94c34d7adc..4e67c84a0bc 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -29,6 +29,7 @@ "ordered_qty", "column_break_16", "incoming_rate", + "picked_qty", "page_break", "prevdoc_doctype", "parent_detail_docname" @@ -234,13 +235,19 @@ "label": "Ordered Qty", "no_copy": 1, "read_only": 1 + }, + { + "depends_on": "picked_qty", + "fieldname": "picked_qty", + "fieldtype": "Float", + "label": "Picked Qty" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-03-10 15:42:00.265915", + "modified": "2022-04-21 08:05:29.785362", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 026dd4e122a..4d05d7a345c 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -32,7 +32,7 @@ def make_packing_list(doc): reset = reset_packing_list(doc) for item_row in doc.get("items"): - if frappe.db.exists("Product Bundle", {"new_item_code": item_row.item_code}): + if is_product_bundle(item_row.item_code): for bundle_item in get_product_bundle_items(item_row.item_code): pi_row = add_packed_item_row( doc=doc, @@ -54,6 +54,10 @@ def make_packing_list(doc): set_product_bundle_rate_amount(doc, parent_items_price) # set price in bundle item +def is_product_bundle(item_code: str) -> bool: + return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code})) + + def get_indexed_packed_items_table(doc): """ Create dict from stale packed items table like: diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 1ba46f74148..d1a9472f1f6 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -10,6 +10,7 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( EmptyStockReconciliationItemsError, ) @@ -582,16 +583,19 @@ class TestPickList(FrappeTestCase): def test_picklist_with_bundles(self): # from test_records.json + warehouse = "_Test Warehouse - _TC" bundle = "_Test Product Bundle Item" bundle_items = {"_Test Item": 5, "_Test Item Home Desktop 100": 2} + for item in bundle_items: + make_stock_entry(item=item, to_warehouse=warehouse, qty=10, rate=10) - so = make_sales_order(item_code=bundle, qty=1) + so = make_sales_order(item_code=bundle, qty=3) pl = create_pick_list(so.name) pl.save() self.assertEqual(len(pl.locations), 2) for item in pl.locations: - self.assertEqual(item.stock_qty, bundle_items[item.item_code]) + self.assertEqual(item.stock_qty, bundle_items[item.item_code] * 3) # def test_pick_list_skips_items_in_expired_batch(self): # pass diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json index 805286ddcc0..a96ebfcdee6 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -27,6 +27,7 @@ "column_break_15", "sales_order", "sales_order_item", + "product_bundle_item", "material_request", "material_request_item" ], @@ -146,6 +147,7 @@ { "fieldname": "sales_order_item", "fieldtype": "Data", + "hidden": 1, "label": "Sales Order Item", "read_only": 1 }, @@ -177,11 +179,19 @@ "fieldtype": "Data", "label": "Item Group", "read_only": 1 + }, + { + "description": "product bundle item row's name in sales order. Also indicates that picked item is to be used for a product bundle", + "fieldname": "product_bundle_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Product Bundle Item", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2021-09-28 12:02:16.923056", + "modified": "2022-04-22 05:27:38.497997", "modified_by": "Administrator", "module": "Stock", "name": "Pick List Item", @@ -190,5 +200,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file From e64cc66df74dd1015b3b015adfacc4f665806f7c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 22 Apr 2022 15:22:36 +0530 Subject: [PATCH 04/19] refactor: sales order status update - rename badly named variables - support updated packed items --- erpnext/stock/doctype/pick_list/pick_list.py | 27 ++++++++++---------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 72524f036ec..01448be6008 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -46,7 +46,7 @@ class PickList(Document): if item.sales_order_item: # update the picked_qty in SO Item - self.update_so(item.sales_order_item, item.picked_qty, item.item_code) + self.update_sales_order_item(item, item.picked_qty, item.item_code) if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"): continue @@ -70,14 +70,13 @@ class PickList(Document): # update picked_qty in SO Item on cancel of PL for item in self.get("locations"): if item.sales_order_item: - self.update_so(item.sales_order_item, -1 * item.picked_qty, item.item_code) + self.update_sales_order_item(item, -1 * item.picked_qty, item.item_code) + + def update_sales_order_item(self, item, picked_qty, item_code): + item_table = "Sales Order Item" if not item.product_bundle_item else "Packed Item" - def update_so(self, so_item, picked_qty, item_code): - so_doc = frappe.get_doc( - "Sales Order", frappe.db.get_value("Sales Order Item", so_item, "parent") - ) already_picked, actual_qty = frappe.db.get_value( - "Sales Order Item", so_item, ["picked_qty", "qty"] + item_table, item.sales_order_item, ["picked_qty", "qty"] ) if self.docstatus == 1: @@ -87,20 +86,22 @@ class PickList(Document): frappe.throw( _( "You are picking more than required quantity for {}. Check if there is any other pick list created for {}" - ).format(item_code, so_doc.name) + ).format(item_code, item.sales_order) ) - frappe.db.set_value("Sales Order Item", so_item, "picked_qty", already_picked + picked_qty) + frappe.db.set_value(item_table, item.sales_order_item, "picked_qty", already_picked + picked_qty) + # TODO: only do this once after all items + sales_order = frappe.get_doc("Sales Order", item.sales_order) total_picked_qty = 0 total_so_qty = 0 - for item in so_doc.get("items"): - total_picked_qty += flt(item.picked_qty) - total_so_qty += flt(item.stock_qty) + for so_item in sales_order.get("items"): + total_picked_qty += flt(so_item.picked_qty) + total_so_qty += flt(so_item.stock_qty) total_picked_qty = total_picked_qty + picked_qty per_picked = total_picked_qty / total_so_qty * 100 - so_doc.db_set("per_picked", flt(per_picked), update_modified=False) + sales_order.db_set("per_picked", flt(per_picked), update_modified=False) @frappe.whitelist() def set_item_locations(self, save=False): From c3fc0a4f55789ee273c7acbc0fb711def4af3190 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 22 Apr 2022 15:31:41 +0530 Subject: [PATCH 05/19] perf: single update per Sales Order. For each SO item the sales order picking status was being updated, this isn't required and wasteful. --- .../doctype/sales_order/sales_order.py | 10 +++++++ erpnext/stock/doctype/pick_list/pick_list.py | 29 +++++++++++-------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 1d172ad0bf3..a35e16f2c2f 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -385,6 +385,16 @@ class SalesOrder(SellingController): if tot_qty != 0: self.db_set("per_delivered", flt(delivered_qty / tot_qty) * 100, update_modified=False) + def update_picking_status(self): + total_picked_qty = 0.0 + total_qty = 0.0 + for so_item in self.items: + total_picked_qty += flt(so_item.picked_qty) + total_qty += flt(so_item.stock_qty) + per_picked = total_picked_qty / total_qty * 100 + + self.db_set("per_picked", flt(per_picked), update_modified=False) + def set_indicator(self): """Set indicator for portal""" if self.per_billed < 100 and self.per_delivered < 100: diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 01448be6008..3191f157828 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -5,6 +5,7 @@ import json from collections import OrderedDict, defaultdict from itertools import groupby from operator import itemgetter +from typing import Set import frappe from frappe import _ @@ -39,6 +40,8 @@ class PickList(Document): ) def before_submit(self): + + update_sales_orders = set() for item in self.locations: # if the user has not entered any picked qty, set it to stock_qty, before submit if item.picked_qty == 0: @@ -47,6 +50,7 @@ class PickList(Document): if item.sales_order_item: # update the picked_qty in SO Item self.update_sales_order_item(item, item.picked_qty, item.item_code) + update_sales_orders.add(item.sales_order) if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"): continue @@ -66,11 +70,18 @@ class PickList(Document): title=_("Quantity Mismatch"), ) + self.update_sales_order_picking_status(update_sales_orders) + def before_cancel(self): - # update picked_qty in SO Item on cancel of PL + """Deduct picked qty on cancelling pick list""" + updated_sales_orders = set() + for item in self.get("locations"): if item.sales_order_item: self.update_sales_order_item(item, -1 * item.picked_qty, item.item_code) + updated_sales_orders.add(item.sales_order) + + self.update_sales_order_picking_status(updated_sales_orders) def update_sales_order_item(self, item, picked_qty, item_code): item_table = "Sales Order Item" if not item.product_bundle_item else "Packed Item" @@ -91,17 +102,11 @@ class PickList(Document): frappe.db.set_value(item_table, item.sales_order_item, "picked_qty", already_picked + picked_qty) - # TODO: only do this once after all items - sales_order = frappe.get_doc("Sales Order", item.sales_order) - total_picked_qty = 0 - total_so_qty = 0 - for so_item in sales_order.get("items"): - total_picked_qty += flt(so_item.picked_qty) - total_so_qty += flt(so_item.stock_qty) - total_picked_qty = total_picked_qty + picked_qty - per_picked = total_picked_qty / total_so_qty * 100 - - sales_order.db_set("per_picked", flt(per_picked), update_modified=False) + @staticmethod + def update_sales_order_picking_status(sales_orders: Set[str]) -> None: + for sales_order in sales_orders: + if sales_order: + frappe.get_doc("Sales Order", sales_order).update_picking_status() @frappe.whitelist() def set_item_locations(self, save=False): From 60bc26fdbe9540cbb40a01732d469f3579314be2 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 13:42:42 +0530 Subject: [PATCH 06/19] feat: back-update min picked qty for a bundle --- erpnext/stock/doctype/pick_list/pick_list.py | 55 +++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 3191f157828..7564e8fd150 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -5,7 +5,7 @@ import json from collections import OrderedDict, defaultdict from itertools import groupby from operator import itemgetter -from typing import Set +from typing import Dict, List, Set import frappe from frappe import _ @@ -40,7 +40,6 @@ class PickList(Document): ) def before_submit(self): - update_sales_orders = set() for item in self.locations: # if the user has not entered any picked qty, set it to stock_qty, before submit @@ -70,6 +69,7 @@ class PickList(Document): title=_("Quantity Mismatch"), ) + self.update_bundle_picked_qty() self.update_sales_order_picking_status(update_sales_orders) def before_cancel(self): @@ -81,6 +81,7 @@ class PickList(Document): self.update_sales_order_item(item, -1 * item.picked_qty, item.item_code) updated_sales_orders.add(item.sales_order) + self.update_bundle_picked_qty() self.update_sales_order_picking_status(updated_sales_orders) def update_sales_order_item(self, item, picked_qty, item_code): @@ -223,6 +224,56 @@ class PickList(Document): for idx, item in enumerate(self.locations, start=1): item.idx = idx + def update_bundle_picked_qty(self): + """Ensure that picked quantity is sufficient for fulfilling a whole number of.""" + + product_bundles = self._get_product_bundles() + product_bundle_qty_map = self._get_product_bundle_qty_map(product_bundles.values()) + + for so_row, item_code in product_bundles.items(): + picked_qty = self._compute_picked_qty_for_bundle(so_row, product_bundle_qty_map[item_code]) + item_table = "Sales Order Item" + already_picked = frappe.db.get_value(item_table, so_row, "picked_qty") + frappe.db.set_value( + item_table, + so_row, + "picked_qty", + already_picked + (picked_qty * (1 if self.docstatus == 1 else -1)), + ) + + def _get_product_bundles(self) -> Dict[str, str]: + # Dict[so_item_row: item_code] + product_bundles = {} + for item in self.locations: + if not item.product_bundle_item: + continue + bundle_item_code = frappe.db.get_value( + "Sales Order Item", item.product_bundle_item, "item_code" + ) + product_bundles[item.product_bundle_item] = bundle_item_code + return product_bundles + + def _get_product_bundle_qty_map(self, bundles: List[str]) -> Dict[str, Dict[str, float]]: + # bundle_item_code: Dict[component, qty] + product_bundle_qty_map = {} + for bundle_item_code in bundles: + bundle = frappe.get_last_doc("Product Bundle", {"new_item_code": bundle_item_code}) + product_bundle_qty_map[bundle_item_code] = {item.item_code: item.qty for item in bundle.items} + return product_bundle_qty_map + + def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> float: + """Compute how many full bundles can be created from picked items.""" + possible_bundles = [] + for item in self.locations: + if item.product_bundle_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 min(possible_bundles) + def validate_item_locations(pick_list): if not pick_list.locations: From 3ddad6891ae6184e7bdcd12abb1cfa3f6993344d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 14:22:29 +0530 Subject: [PATCH 07/19] refactor: simplify needlessly complicated code --- erpnext/stock/doctype/pick_list/pick_list.py | 56 ++++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 7564e8fd150..3703e855025 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -549,21 +549,21 @@ def create_dn_wo_so(pick_list): def create_dn_with_so(sales_dict, pick_list): delivery_note = None + item_table_mapper = { + "doctype": "Delivery Note Item", + "field_map": { + "rate": "rate", + "name": "so_detail", + "parent": "against_sales_order", + }, + "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) + and doc.delivered_by_supplier != 1, + } + for customer in sales_dict: for so in sales_dict[customer]: delivery_note = None delivery_note = create_delivery_note_from_sales_order(so, delivery_note, skip_item_mapping=True) - - item_table_mapper = { - "doctype": "Delivery Note Item", - "field_map": { - "rate": "rate", - "name": "so_detail", - "parent": "against_sales_order", - }, - "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) - and doc.delivered_by_supplier != 1, - } break if delivery_note: # map all items of all sales orders of that customer @@ -577,28 +577,26 @@ def create_dn_with_so(sales_dict, pick_list): def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): for location in pick_list.locations: - if location.sales_order == sales_order: - if location.sales_order_item: - sales_order_item = frappe.get_cached_doc( - "Sales Order Item", {"name": location.sales_order_item} - ) - else: - sales_order_item = None + if location.sales_order != sales_order: + continue - source_doc, table_mapper = ( - [sales_order_item, item_mapper] if sales_order_item else [location, item_mapper] - ) + if location.sales_order_item: + sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item) + else: + sales_order_item = None - dn_item = map_child_doc(source_doc, delivery_note, table_mapper) + source_doc = sales_order_item or location - if dn_item: - dn_item.pick_list_item = location.name - dn_item.warehouse = location.warehouse - dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) - dn_item.batch_no = location.batch_no - dn_item.serial_no = location.serial_no + dn_item = map_child_doc(source_doc, delivery_note, item_mapper) - update_delivery_note_item(source_doc, dn_item, delivery_note) + if dn_item: + dn_item.pick_list_item = location.name + dn_item.warehouse = location.warehouse + dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) + dn_item.batch_no = location.batch_no + dn_item.serial_no = location.serial_no + + update_delivery_note_item(source_doc, dn_item, delivery_note) set_delivery_note_missing_values(delivery_note) delivery_note.pick_list = pick_list.name From 277b51b40482447639d2c0b9d5440e758ea0b76f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 15:02:08 +0530 Subject: [PATCH 08/19] refactor: groupby using keys instead of int index --- erpnext/stock/doctype/pick_list/pick_list.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 3703e855025..a94745677f5 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -4,7 +4,6 @@ import json from collections import OrderedDict, defaultdict from itertools import groupby -from operator import itemgetter from typing import Dict, List, Set import frappe @@ -507,11 +506,13 @@ def create_delivery_note(source_name, target_doc=None): for location in pick_list.locations: if location.sales_order: sales_orders.append( - [frappe.db.get_value("Sales Order", location.sales_order, "customer"), location.sales_order] + frappe.db.get_value( + "Sales Order", location.sales_order, ["customer", "name as sales_order"], as_dict=True + ) ) - # Group sales orders by customer - for key, keydata in groupby(sales_orders, key=itemgetter(0)): - sales_dict[key] = set([d[1] for d in keydata]) + + for customer, rows in groupby(sales_orders, key=lambda so: so["customer"]): + sales_dict[customer] = {row.sales_order for row in rows} if sales_dict: delivery_note = create_dn_with_so(sales_dict, pick_list) From f574121741800425e9eb8b34a78e26d8ed1d877a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 15:05:10 +0530 Subject: [PATCH 09/19] refactor: simpler check for non-SO items --- erpnext/stock/doctype/pick_list/pick_list.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index a94745677f5..9daf4f11c40 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -517,13 +517,7 @@ def create_delivery_note(source_name, target_doc=None): if sales_dict: delivery_note = create_dn_with_so(sales_dict, pick_list) - is_item_wo_so = 0 - for location in pick_list.locations: - if not location.sales_order: - is_item_wo_so = 1 - break - if is_item_wo_so == 1: - # Create a DN for items without sales orders as well + if not all(item.sales_order for item in pick_list.locations): delivery_note = create_dn_wo_so(pick_list) frappe.msgprint(_("Delivery Note(s) created for the Pick List")) From 23cb0d684d5e10fd12688b55caddc0911ff4e927 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 17:06:26 +0530 Subject: [PATCH 10/19] feat: create DN from pick list with bundle items --- erpnext/stock/doctype/pick_list/pick_list.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 9daf4f11c40..46c858878a3 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -572,7 +572,7 @@ def create_dn_with_so(sales_dict, pick_list): def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): for location in pick_list.locations: - if location.sales_order != sales_order: + if location.sales_order != sales_order or location.product_bundle_item: continue if location.sales_order_item: @@ -592,6 +592,8 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): dn_item.serial_no = location.serial_no update_delivery_note_item(source_doc, dn_item, delivery_note) + + add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper) set_delivery_note_missing_values(delivery_note) delivery_note.pick_list = pick_list.name @@ -599,6 +601,22 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") +def add_product_bundles_to_delivery_note( + pick_list: "PickList", delivery_note, item_mapper +) -> None: + product_bundles = pick_list._get_product_bundles() + product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values()) + + for so_row, item_code in product_bundles.items(): + sales_order_item = frappe.get_doc("Sales Order Item", so_row) + dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper) + # TODO: post process packed items and update stock details + dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( + so_row, product_bundle_qty_map[item_code] + ) + update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note) + + @frappe.whitelist() def create_stock_entry(pick_list): pick_list = frappe.get_doc(json.loads(pick_list)) From 25485edfd966301966a8ccc366da1fa0fd38db1f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 17:12:37 +0530 Subject: [PATCH 11/19] refactor: remove unnecssary vars also remove misleading docstring --- erpnext/stock/doctype/pick_list/pick_list.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 46c858878a3..60f5e34efbb 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -224,8 +224,6 @@ class PickList(Document): item.idx = idx def update_bundle_picked_qty(self): - """Ensure that picked quantity is sufficient for fulfilling a whole number of.""" - product_bundles = self._get_product_bundles() product_bundle_qty_map = self._get_product_bundle_qty_map(product_bundles.values()) @@ -246,10 +244,11 @@ class PickList(Document): for item in self.locations: if not item.product_bundle_item: continue - bundle_item_code = frappe.db.get_value( - "Sales Order Item", item.product_bundle_item, "item_code" + product_bundles[item.product_bundle_item] = frappe.db.get_value( + "Sales Order Item", + item.product_bundle_item, + "item_code", ) - product_bundles[item.product_bundle_item] = bundle_item_code return product_bundles def _get_product_bundle_qty_map(self, bundles: List[str]) -> Dict[str, Dict[str, float]]: From 41aa4b352407190c4f2ed6bc223cb4510555206c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 17:16:44 +0530 Subject: [PATCH 12/19] fix: round off bundle qty This is to accomodate bundles that might allow floating point qty. --- erpnext/stock/doctype/pick_list/pick_list.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 60f5e34efbb..aa015755fe3 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -261,6 +261,8 @@ class PickList(Document): def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> float: """Compute how many full bundles can be created from picked items.""" + precision = frappe.get_precision("Stock Ledger Entry", "qty_after_transaction") + possible_bundles = [] for item in self.locations: if item.product_bundle_item != bundle_row: @@ -270,7 +272,7 @@ class PickList(Document): possible_bundles.append(item.picked_qty / qty_in_bundle) else: possible_bundles.append(0) - return min(possible_bundles) + return flt(min(possible_bundles), precision or 6) def validate_item_locations(pick_list): From 1ac275ce61d0e113aac43a8b5ad81ba7c53b8f65 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 17:32:48 +0530 Subject: [PATCH 13/19] feat: transfer picklist stock info to packing list --- erpnext/stock/doctype/pick_list/pick_list.py | 34 +++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index aa015755fe3..94e9e53423b 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -565,7 +565,10 @@ def create_dn_with_so(sales_dict, pick_list): # map all items of all sales orders of that customer for so in sales_dict[customer]: map_pl_locations(pick_list, item_table_mapper, delivery_note, so) - delivery_note.insert(ignore_mandatory=True) + delivery_note.flags.ignore_mandatory = True + delivery_note.insert() + update_packed_item_details(pick_list, delivery_note) + delivery_note.save() return delivery_note @@ -605,6 +608,10 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): def add_product_bundles_to_delivery_note( pick_list: "PickList", delivery_note, item_mapper ) -> None: + """Add product bundles found in pick list to delivery note. + + When mapping pick list items, the bundle item itself isn't part of the + locations. Dynamically fetch and add parent bundle item into DN.""" product_bundles = pick_list._get_product_bundles() product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values()) @@ -618,6 +625,31 @@ def add_product_bundles_to_delivery_note( 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)) From ee54ece8fd7dd70d154e3483d372f43681fbc514 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 19:24:33 +0530 Subject: [PATCH 14/19] test: product bundle fixture --- .../doctype/packed_item/test_packed_item.py | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index fe1b0d9f792..c928b57d58f 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -1,10 +1,12 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from typing import List, Optional, Tuple + +import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_to_date, nowdate -from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item @@ -12,6 +14,25 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +def create_product_bundle(quantities: Optional[List[int]] = None) -> Tuple[str, List[str]]: + """Get a new product_bundle for use in tests""" + if not quantities: + quantities = [2, 2] + + bundle = make_item(properties={"is_stock_item": 0}).name + + bundle_doc = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": bundle}) + + components = [] + for qty in quantities: + compoenent = make_item().name + components.append(compoenent) + bundle_doc.append("items", {"item_code": compoenent, "qty": qty}) + + bundle_doc.insert() + return bundle, components + + class TestPackedItem(FrappeTestCase): "Test impact on Packed Items table in various scenarios." @@ -19,22 +40,11 @@ class TestPackedItem(FrappeTestCase): def setUpClass(cls) -> None: super().setUpClass() cls.warehouse = "_Test Warehouse - _TC" - cls.bundle = "_Test Product Bundle X" - cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"] - cls.bundle2 = "_Test Product Bundle Y" - cls.bundle2_items = ["_Test Bundle Item 3", "_Test Bundle Item 4"] - - make_item(cls.bundle, {"is_stock_item": 0}) - make_item(cls.bundle2, {"is_stock_item": 0}) - for item in cls.bundle_items + cls.bundle2_items: - make_item(item, {"is_stock_item": 1}) - - make_item("_Test Normal Stock Item", {"is_stock_item": 1}) - - make_product_bundle(cls.bundle, cls.bundle_items, qty=2) - make_product_bundle(cls.bundle2, cls.bundle2_items, qty=2) + cls.bundle, cls.bundle_items = create_product_bundle() + cls.bundle2, cls.bundle2_items = create_product_bundle() + cls.normal_item = make_item().name for item in cls.bundle_items + cls.bundle2_items: make_stock_entry(item_code=item, to_warehouse=cls.warehouse, qty=100, rate=100) @@ -58,7 +68,7 @@ class TestPackedItem(FrappeTestCase): self.assertEqual(so.packed_items[1].qty, 4) # change item code to non bundle item - so.items[0].item_code = "_Test Normal Stock Item" + so.items[0].item_code = self.normal_item so.save() self.assertEqual(len(so.packed_items), 0) From 9e60acdf56d15334f55a71c80f3c2e580dc46024 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 25 Apr 2022 19:30:42 +0530 Subject: [PATCH 15/19] test: test bundle - picklist behaviour --- .../doctype/packed_item/test_packed_item.py | 18 ++++-- erpnext/stock/doctype/pick_list/pick_list.py | 1 - .../stock/doctype/pick_list/test_pick_list.py | 61 +++++++++++++++---- 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index c928b57d58f..ad7fd9a6976 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -14,8 +14,13 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -def create_product_bundle(quantities: Optional[List[int]] = None) -> Tuple[str, List[str]]: - """Get a new product_bundle for use in tests""" +def create_product_bundle( + quantities: Optional[List[int]] = None, warehouse: Optional[str] = None +) -> Tuple[str, List[str]]: + """Get a new product_bundle for use in tests. + + Create 10x required stock if warehouse is specified. + """ if not quantities: quantities = [2, 2] @@ -28,8 +33,11 @@ def create_product_bundle(quantities: Optional[List[int]] = None) -> Tuple[str, compoenent = make_item().name components.append(compoenent) bundle_doc.append("items", {"item_code": compoenent, "qty": qty}) + if warehouse: + make_stock_entry(item=compoenent, to_warehouse=warehouse, qty=10 * qty, rate=100) bundle_doc.insert() + return bundle, components @@ -41,12 +49,10 @@ class TestPackedItem(FrappeTestCase): super().setUpClass() cls.warehouse = "_Test Warehouse - _TC" - cls.bundle, cls.bundle_items = create_product_bundle() - cls.bundle2, cls.bundle2_items = create_product_bundle() + cls.bundle, cls.bundle_items = create_product_bundle(warehouse=cls.warehouse) + cls.bundle2, cls.bundle2_items = create_product_bundle(warehouse=cls.warehouse) cls.normal_item = make_item().name - for item in cls.bundle_items + cls.bundle2_items: - make_stock_entry(item_code=item, to_warehouse=cls.warehouse, qty=100, rate=100) def test_adding_bundle_item(self): "Test impact on packed items if bundle item row is added." diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 94e9e53423b..53584f5079e 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -618,7 +618,6 @@ def add_product_bundles_to_delivery_note( for so_row, item_code in product_bundles.items(): sales_order_item = frappe.get_doc("Sales Order Item", so_row) dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper) - # TODO: post process packed items and update stock details dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( so_row, product_bundle_qty_map[item_code] ) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index d1a9472f1f6..8ce05f15f7d 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -8,6 +8,7 @@ from frappe.tests.utils import FrappeTestCase 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.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry @@ -584,12 +585,12 @@ class TestPickList(FrappeTestCase): def test_picklist_with_bundles(self): # from test_records.json warehouse = "_Test Warehouse - _TC" - bundle = "_Test Product Bundle Item" - bundle_items = {"_Test Item": 5, "_Test Item Home Desktop 100": 2} - for item in bundle_items: - make_stock_entry(item=item, to_warehouse=warehouse, qty=10, rate=10) - so = make_sales_order(item_code=bundle, qty=3) + quantities = [5, 2] + bundle, components = create_product_bundle(quantities, warehouse=warehouse) + bundle_items = dict(zip(components, quantities)) + + so = make_sales_order(item_code=bundle, qty=3, rate=42) pl = create_pick_list(so.name) pl.save() @@ -597,14 +598,48 @@ class TestPickList(FrappeTestCase): for item in pl.locations: self.assertEqual(item.stock_qty, bundle_items[item.item_code] * 3) - # def test_pick_list_skips_items_in_expired_batch(self): - # pass + # check picking status on sales order + pl.submit() + so.reload() + self.assertEqual(so.per_picked, 100) - # def test_pick_list_from_sales_order(self): - # pass + # deliver + dn = create_delivery_note(pl.name).submit() + self.assertEqual(dn.items[0].rate, 42) + self.assertEqual(dn.packed_items[0].warehouse, warehouse) + so.reload() + self.assertEqual(so.per_delivered, 100) - # def test_pick_list_from_work_order(self): - # pass + def test_picklist_with_partial_bundles(self): + # from test_records.json + warehouse = "_Test Warehouse - _TC" - # def test_pick_list_from_material_request(self): - # pass + quantities = [5, 2] + bundle, components = create_product_bundle(quantities, warehouse=warehouse) + + so = make_sales_order(item_code=bundle, qty=4, rate=42) + + pl = create_pick_list(so.name) + for loc in pl.locations: + loc.picked_qty = loc.qty / 2 + + pl.save().submit() + so.reload() + self.assertEqual(so.per_picked, 50) + + # deliver half qty + dn = create_delivery_note(pl.name).submit() + self.assertEqual(dn.items[0].rate, 42) + so.reload() + self.assertEqual(so.per_delivered, 50) + + pl = create_pick_list(so.name) + pl.save().submit() + so.reload() + self.assertEqual(so.per_picked, 100) + + # deliver remaining + dn = create_delivery_note(pl.name).submit() + self.assertEqual(dn.items[0].rate, 42) + so.reload() + self.assertEqual(so.per_delivered, 100) From 8207697e43a8baa3353c0c984e8e99b14fcb5b55 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 27 Apr 2022 12:15:26 +0530 Subject: [PATCH 16/19] fix: compare against stock qty while validating Other changes: - only allow whole number of bundles to get picked --- .../sales_order_item/sales_order_item.json | 4 ++-- erpnext/stock/doctype/pick_list/pick_list.py | 9 ++++++--- .../stock/doctype/pick_list/test_pick_list.py | 17 ++++++++++++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 8a6a0bae548..3797856db2f 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -803,7 +803,7 @@ { "fieldname": "picked_qty", "fieldtype": "Float", - "label": "Picked Qty", + "label": "Picked Qty (in Stock UOM)", "no_copy": 1, "read_only": 1 } @@ -811,7 +811,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-04-21 08:15:14.010319", + "modified": "2022-04-27 03:15:34.366563", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 53584f5079e..70d2f23070c 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -85,9 +85,12 @@ class PickList(Document): def update_sales_order_item(self, item, picked_qty, item_code): item_table = "Sales Order Item" if not item.product_bundle_item else "Packed Item" + stock_qty_field = "stock_qty" if not item.product_bundle_item else "qty" already_picked, actual_qty = frappe.db.get_value( - item_table, item.sales_order_item, ["picked_qty", "qty"] + item_table, + item.sales_order_item, + ["picked_qty", stock_qty_field], ) if self.docstatus == 1: @@ -259,7 +262,7 @@ class PickList(Document): product_bundle_qty_map[bundle_item_code] = {item.item_code: item.qty for item in bundle.items} return product_bundle_qty_map - def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> float: + 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") @@ -272,7 +275,7 @@ class PickList(Document): possible_bundles.append(item.picked_qty / qty_in_bundle) else: possible_bundles.append(0) - return flt(min(possible_bundles), precision or 6) + return int(flt(min(possible_bundles), precision or 6)) def validate_item_locations(pick_list): diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 8ce05f15f7d..f552299806c 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -582,8 +582,23 @@ class TestPickList(FrappeTestCase): if dn_item.item_code == "_Test Item 2": self.assertEqual(dn_item.qty, 2) + def test_picklist_with_multi_uom(self): + warehouse = "_Test Warehouse - _TC" + item = make_item(properties={"uoms": [dict(uom="Box", conversion_factor=24)]}).name + make_stock_entry(item=item, to_warehouse=warehouse, qty=1000) + + so = make_sales_order(item_code=item, qty=10, rate=42, uom="Box") + pl = create_pick_list(so.name) + # pick half the qty + for loc in pl.locations: + loc.picked_qty = loc.stock_qty / 2 + pl.save() + pl.submit() + + so.reload() + self.assertEqual(so.per_picked, 50) + def test_picklist_with_bundles(self): - # from test_records.json warehouse = "_Test Warehouse - _TC" quantities = [5, 2] From 47e1a0104c83824a315a666c9a21956265c2b7d2 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 27 Apr 2022 13:31:23 +0530 Subject: [PATCH 17/19] fix: dont map picked qty and consider pick qty for new PL Co-Authored-By: marination --- erpnext/selling/doctype/sales_order/sales_order.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index a35e16f2c2f..b463213f50d 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1245,14 +1245,18 @@ def create_pick_list(source_name, target_doc=None): from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle def update_item_quantity(source, target, source_parent) -> None: - target.qty = flt(source.qty) - flt(source.delivered_qty) - target.stock_qty = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.conversion_factor) + picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1) + qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty)) + + target.qty = qty_to_be_picked + target.stock_qty = qty_to_be_picked * flt(source.conversion_factor) def update_packed_item_qty(source, target, source_parent) -> None: qty = flt(source.qty) for item in source_parent.items: if source.parent_detail_docname == item.name: - pending_percent = (item.qty - item.delivered_qty) / item.qty + picked_qty = flt(item.picked_qty) / (flt(item.conversion_factor) or 1) + pending_percent = (item.qty - max(picked_qty, item.delivered_qty)) / item.qty target.qty = target.stock_qty = qty * pending_percent return @@ -1281,6 +1285,7 @@ def create_pick_list(source_name, target_doc=None): "name": "sales_order_item", "parent_detail_docname": "product_bundle_item", }, + "field_no_map": ["picked_qty"], "postprocess": update_packed_item_qty, }, }, From 9a8e3ef2356ac080d8922d5072a4e8a58c5eb676 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 27 Apr 2022 14:41:50 +0530 Subject: [PATCH 18/19] fix(UX): only show pick list when picking is pending [skip ci] --- erpnext/selling/doctype/sales_order/sales_order.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 0b48f70eab6..26c9996dba9 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -152,7 +152,9 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } } - this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create')); + if (flt(doc.per_picked, 6) < 100 && flt(doc.per_delivered, 6) < 100) { + this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create')); + } const order_is_a_sale = ["Sales", "Shopping Cart"].indexOf(doc.order_type) !== -1; const order_is_maintenance = ["Maintenance"].indexOf(doc.order_type) !== -1; From ebd5f0b1bb4a2792bb768b720b800ca5d8724085 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 27 Apr 2022 14:53:20 +0530 Subject: [PATCH 19/19] chore: make picked qty read only --- erpnext/stock/doctype/packed_item/packed_item.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index 4e67c84a0bc..cb8eb30cb30 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -237,17 +237,18 @@ "read_only": 1 }, { - "depends_on": "picked_qty", "fieldname": "picked_qty", "fieldtype": "Float", - "label": "Picked Qty" + "label": "Picked Qty", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-04-21 08:05:29.785362", + "modified": "2022-04-27 05:23:08.683245", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item",