Merge pull request #30762 from ankush/bunle_pickng
feat: support product bundles in picklist
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 0b48f70..26c9996 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -152,7 +152,9 @@
}
}
- 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;
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index fe2f14e..1d0432b 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/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index d3b4286..b463213 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -385,6 +385,16 @@
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:
@@ -1232,9 +1242,30 @@
@frappe.whitelist()
def create_pick_list(source_name, target_doc=None):
- def update_item_quantity(source, target, source_parent):
- target.qty = flt(source.qty) - flt(source.delivered_qty)
- target.stock_qty = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.conversion_factor)
+ from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle
+
+ def update_item_quantity(source, target, source_parent) -> None:
+ 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:
+ 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
+
+ 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",
@@ -1245,8 +1276,17 @@
"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",
+ },
+ "field_no_map": ["picked_qty"],
+ "postprocess": update_packed_item_qty,
},
},
target_doc,
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 195e964..3797856 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 (in Stock UOM)",
+ "no_copy": 1,
+ "read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-03-15 20:17:33.984799",
+ "modified": "2022-04-27 03:15:34.366563",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",
diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json
index e94c34d..cb8eb30 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,20 @@
"label": "Ordered Qty",
"no_copy": 1,
"read_only": 1
+ },
+ {
+ "fieldname": "picked_qty",
+ "fieldtype": "Float",
+ "label": "Picked Qty",
+ "no_copy": 1,
+ "read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-03-10 15:42:00.265915",
+ "modified": "2022-04-27 05:23:08.683245",
"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 026dd4e..4d05d7a 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.py
+++ b/erpnext/stock/doctype/packed_item/packed_item.py
@@ -32,7 +32,7 @@
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 @@
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/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py
index fe1b0d9..ad7fd9a 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,33 @@
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+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]
+
+ 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})
+ if warehouse:
+ make_stock_entry(item=compoenent, to_warehouse=warehouse, qty=10 * qty, rate=100)
+
+ bundle_doc.insert()
+
+ return bundle, components
+
+
class TestPackedItem(FrappeTestCase):
"Test impact on Packed Items table in various scenarios."
@@ -19,24 +48,11 @@
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"]
+ cls.bundle, cls.bundle_items = create_product_bundle(warehouse=cls.warehouse)
+ cls.bundle2, cls.bundle2_items = create_product_bundle(warehouse=cls.warehouse)
- 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)
-
- for item in cls.bundle_items + cls.bundle2_items:
- make_stock_entry(item_code=item, to_warehouse=cls.warehouse, qty=100, rate=100)
+ cls.normal_item = make_item().name
def test_adding_bundle_item(self):
"Test impact on packed items if bundle item row is added."
@@ -58,7 +74,7 @@
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)
diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json
index c604c71..e984c08 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 33d7745..70d2f23 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -4,13 +4,14 @@
import json
from collections import OrderedDict, defaultdict
from itertools import groupby
-from operator import itemgetter
+from typing import Dict, List, Set
import frappe
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,
@@ -38,6 +39,7 @@
)
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:
@@ -45,7 +47,8 @@
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)
+ update_sales_orders.add(item.sales_order)
if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
continue
@@ -65,18 +68,29 @@
title=_("Quantity Mismatch"),
)
+ self.update_bundle_picked_qty()
+ 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_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)
+ updated_sales_orders.add(item.sales_order)
- 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")
- )
+ 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):
+ 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(
- "Sales Order Item", so_item, ["picked_qty", "qty"]
+ item_table,
+ item.sales_order_item,
+ ["picked_qty", stock_qty_field],
)
if self.docstatus == 1:
@@ -86,20 +100,16 @@
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)
- 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)
- 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)
+ @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):
@@ -109,7 +119,7 @@
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 +200,7 @@
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)
@@ -217,6 +226,57 @@
for idx, item in enumerate(self.locations, start=1):
item.idx = idx
+ def update_bundle_picked_qty(self):
+ 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
+ product_bundles[item.product_bundle_item] = frappe.db.get_value(
+ "Sales Order Item",
+ item.product_bundle_item,
+ "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) -> int:
+ """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:
+ 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))
+
def validate_item_locations(pick_list):
if not pick_list.locations:
@@ -450,22 +510,18 @@
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)
- 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"))
@@ -492,27 +548,30 @@
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
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
@@ -520,28 +579,28 @@
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 or location.product_bundle_item:
+ 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)
+
+ 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
@@ -549,6 +608,50 @@
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:
+ """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())
+
+ 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)
+ 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)
+
+
+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))
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index 27b06d2..f552299 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -3,18 +3,21 @@
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.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
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,14 +582,79 @@
if dn_item.item_code == "_Test Item 2":
self.assertEqual(dn_item.qty, 2)
- # def test_pick_list_skips_items_in_expired_batch(self):
- # pass
+ 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)
- # def test_pick_list_from_sales_order(self):
- # pass
+ 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()
- # def test_pick_list_from_work_order(self):
- # pass
+ so.reload()
+ self.assertEqual(so.per_picked, 50)
- # def test_pick_list_from_material_request(self):
- # pass
+ def test_picklist_with_bundles(self):
+ warehouse = "_Test Warehouse - _TC"
+
+ 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()
+ self.assertEqual(len(pl.locations), 2)
+ for item in pl.locations:
+ self.assertEqual(item.stock_qty, bundle_items[item.item_code] * 3)
+
+ # check picking status on sales order
+ pl.submit()
+ so.reload()
+ self.assertEqual(so.per_picked, 100)
+
+ # 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_picklist_with_partial_bundles(self):
+ # from test_records.json
+ warehouse = "_Test Warehouse - _TC"
+
+ 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)
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 805286d..a96ebfc 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