Merge pull request #30762 from ankush/bunle_pickng

feat: support product bundles in picklist
This commit is contained in:
Marica 2022-04-27 15:23:16 +05:30 committed by GitHub
commit 2fffc68938
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 363 additions and 106 deletions

View File

@ -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_a_sale = ["Sales", "Shopping Cart"].indexOf(doc.order_type) !== -1;
const order_is_maintenance = ["Maintenance"].indexOf(doc.order_type) !== -1; const order_is_maintenance = ["Maintenance"].indexOf(doc.order_type) !== -1;

View File

@ -1520,6 +1520,7 @@
"fieldname": "per_picked", "fieldname": "per_picked",
"fieldtype": "Percent", "fieldtype": "Percent",
"label": "% Picked", "label": "% Picked",
"no_copy": 1,
"read_only": 1 "read_only": 1
} }
], ],
@ -1527,7 +1528,7 @@
"idx": 105, "idx": 105,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-03-15 21:38:31.437586", "modified": "2022-04-21 08:16:48.316074",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order", "name": "Sales Order",

View File

@ -385,6 +385,16 @@ class SalesOrder(SellingController):
if tot_qty != 0: if tot_qty != 0:
self.db_set("per_delivered", flt(delivered_qty / tot_qty) * 100, update_modified=False) 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): def set_indicator(self):
"""Set indicator for portal""" """Set indicator for portal"""
if self.per_billed < 100 and self.per_delivered < 100: if self.per_billed < 100 and self.per_delivered < 100:
@ -1232,9 +1242,30 @@ def make_inter_company_purchase_order(source_name, target_doc=None):
@frappe.whitelist() @frappe.whitelist()
def create_pick_list(source_name, target_doc=None): 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
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_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( doc = get_mapped_doc(
"Sales Order", "Sales Order",
@ -1245,8 +1276,17 @@ def create_pick_list(source_name, target_doc=None):
"doctype": "Pick List Item", "doctype": "Pick List Item",
"field_map": {"parent": "sales_order", "name": "sales_order_item"}, "field_map": {"parent": "sales_order", "name": "sales_order_item"},
"postprocess": update_item_quantity, "postprocess": update_item_quantity,
"condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) "condition": should_pick_order_item,
and doc.delivered_by_supplier != 1, },
"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, target_doc,

View File

@ -803,13 +803,15 @@
{ {
"fieldname": "picked_qty", "fieldname": "picked_qty",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Picked Qty" "label": "Picked Qty (in Stock UOM)",
"no_copy": 1,
"read_only": 1
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-03-15 20:17:33.984799", "modified": "2022-04-27 03:15:34.366563",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order Item", "name": "Sales Order Item",

View File

@ -29,6 +29,7 @@
"ordered_qty", "ordered_qty",
"column_break_16", "column_break_16",
"incoming_rate", "incoming_rate",
"picked_qty",
"page_break", "page_break",
"prevdoc_doctype", "prevdoc_doctype",
"parent_detail_docname" "parent_detail_docname"
@ -234,13 +235,20 @@
"label": "Ordered Qty", "label": "Ordered Qty",
"no_copy": 1, "no_copy": 1,
"read_only": 1 "read_only": 1
},
{
"fieldname": "picked_qty",
"fieldtype": "Float",
"label": "Picked Qty",
"no_copy": 1,
"read_only": 1
} }
], ],
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-03-10 15:42:00.265915", "modified": "2022-04-27 05:23:08.683245",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Packed Item", "name": "Packed Item",

View File

@ -32,7 +32,7 @@ def make_packing_list(doc):
reset = reset_packing_list(doc) reset = reset_packing_list(doc)
for item_row in doc.get("items"): 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): for bundle_item in get_product_bundle_items(item_row.item_code):
pi_row = add_packed_item_row( pi_row = add_packed_item_row(
doc=doc, 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 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): def get_indexed_packed_items_table(doc):
""" """
Create dict from stale packed items table like: Create dict from stale packed items table like:

View File

@ -1,10 +1,12 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # 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.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_to_date, nowdate 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.sales_order import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
@ -12,6 +14,33 @@ 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 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): class TestPackedItem(FrappeTestCase):
"Test impact on Packed Items table in various scenarios." "Test impact on Packed Items table in various scenarios."
@ -19,24 +48,11 @@ class TestPackedItem(FrappeTestCase):
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
super().setUpClass() super().setUpClass()
cls.warehouse = "_Test Warehouse - _TC" 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.bundle, cls.bundle_items = create_product_bundle(warehouse=cls.warehouse)
cls.bundle2_items = ["_Test Bundle Item 3", "_Test Bundle Item 4"] cls.bundle2, cls.bundle2_items = create_product_bundle(warehouse=cls.warehouse)
make_item(cls.bundle, {"is_stock_item": 0}) cls.normal_item = make_item().name
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)
def test_adding_bundle_item(self): def test_adding_bundle_item(self):
"Test impact on packed items if bundle item row is added." "Test impact on packed items if bundle item row is added."
@ -58,7 +74,7 @@ class TestPackedItem(FrappeTestCase):
self.assertEqual(so.packed_items[1].qty, 4) self.assertEqual(so.packed_items[1].qty, 4)
# change item code to non bundle item # 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() so.save()
self.assertEqual(len(so.packed_items), 0) self.assertEqual(len(so.packed_items), 0)

View File

@ -114,6 +114,7 @@
"set_only_once": 1 "set_only_once": 1
}, },
{ {
"collapsible": 1,
"fieldname": "print_settings_section", "fieldname": "print_settings_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Print Settings" "label": "Print Settings"
@ -129,7 +130,7 @@
], ],
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2021-10-05 15:08:40.369957", "modified": "2022-04-21 07:56:40.646473",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Pick List", "name": "Pick List",
@ -199,5 +200,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -4,13 +4,14 @@
import json import json
from collections import OrderedDict, defaultdict from collections import OrderedDict, defaultdict
from itertools import groupby from itertools import groupby
from operator import itemgetter from typing import Dict, List, Set
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.mapper import map_child_doc from frappe.model.mapper import map_child_doc
from frappe.utils import cint, floor, flt, today 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 ( from erpnext.selling.doctype.sales_order.sales_order import (
make_delivery_note as create_delivery_note_from_sales_order, make_delivery_note as create_delivery_note_from_sales_order,
@ -38,6 +39,7 @@ class PickList(Document):
) )
def before_submit(self): def before_submit(self):
update_sales_orders = set()
for item in self.locations: for item in self.locations:
# if the user has not entered any picked qty, set it to stock_qty, before submit # if the user has not entered any picked qty, set it to stock_qty, before submit
if item.picked_qty == 0: if item.picked_qty == 0:
@ -45,7 +47,8 @@ class PickList(Document):
if item.sales_order_item: if item.sales_order_item:
# update the picked_qty in SO 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"): if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
continue continue
@ -65,18 +68,29 @@ class PickList(Document):
title=_("Quantity Mismatch"), title=_("Quantity Mismatch"),
) )
self.update_bundle_picked_qty()
self.update_sales_order_picking_status(update_sales_orders)
def before_cancel(self): 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"): for item in self.get("locations"):
if item.sales_order_item: 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)
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"
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( 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: if self.docstatus == 1:
@ -86,20 +100,16 @@ class PickList(Document):
frappe.throw( frappe.throw(
_( _(
"You are picking more than required quantity for {}. Check if there is any other pick list created for {}" "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 @staticmethod
total_so_qty = 0 def update_sales_order_picking_status(sales_orders: Set[str]) -> None:
for item in so_doc.get("items"): for sales_order in sales_orders:
total_picked_qty += flt(item.picked_qty) if sales_order:
total_so_qty += flt(item.stock_qty) frappe.get_doc("Sales Order", sales_order).update_picking_status()
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)
@frappe.whitelist() @frappe.whitelist()
def set_item_locations(self, save=False): def set_item_locations(self, save=False):
@ -109,7 +119,7 @@ class PickList(Document):
from_warehouses = None from_warehouses = None
if self.parent_warehouse: 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. # Create replica before resetting, to handle empty table on update after submit.
locations_replica = self.get("locations") locations_replica = self.get("locations")
@ -190,8 +200,7 @@ class PickList(Document):
frappe.throw(_("Qty of Finished Goods Item should be greater than 0.")) frappe.throw(_("Qty of Finished Goods Item should be greater than 0."))
def before_print(self, settings=None): 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): def group_similar_items(self):
group_item_qty = defaultdict(float) group_item_qty = defaultdict(float)
@ -217,6 +226,57 @@ class PickList(Document):
for idx, item in enumerate(self.locations, start=1): for idx, item in enumerate(self.locations, start=1):
item.idx = idx 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): def validate_item_locations(pick_list):
if not pick_list.locations: if not pick_list.locations:
@ -450,22 +510,18 @@ def create_delivery_note(source_name, target_doc=None):
for location in pick_list.locations: for location in pick_list.locations:
if location.sales_order: if location.sales_order:
sales_orders.append( 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)): for customer, rows in groupby(sales_orders, key=lambda so: so["customer"]):
sales_dict[key] = set([d[1] for d in keydata]) sales_dict[customer] = {row.sales_order for row in rows}
if sales_dict: if sales_dict:
delivery_note = create_dn_with_so(sales_dict, pick_list) delivery_note = create_dn_with_so(sales_dict, pick_list)
is_item_wo_so = 0 if not all(item.sales_order for item in pick_list.locations):
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
delivery_note = create_dn_wo_so(pick_list) delivery_note = create_dn_wo_so(pick_list)
frappe.msgprint(_("Delivery Note(s) created for the Pick List")) frappe.msgprint(_("Delivery Note(s) created for the Pick List"))
@ -492,27 +548,30 @@ def create_dn_wo_so(pick_list):
def create_dn_with_so(sales_dict, pick_list): def create_dn_with_so(sales_dict, pick_list):
delivery_note = None 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 customer in sales_dict:
for so in sales_dict[customer]: for so in sales_dict[customer]:
delivery_note = None delivery_note = None
delivery_note = create_delivery_note_from_sales_order(so, delivery_note, skip_item_mapping=True) 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 break
if delivery_note: if delivery_note:
# map all items of all sales orders of that customer # map all items of all sales orders of that customer
for so in sales_dict[customer]: for so in sales_dict[customer]:
map_pl_locations(pick_list, item_table_mapper, delivery_note, so) 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 return delivery_note
@ -520,28 +579,28 @@ def create_dn_with_so(sales_dict, pick_list):
def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None):
for location in pick_list.locations: for location in pick_list.locations:
if location.sales_order == sales_order: if location.sales_order != sales_order or location.product_bundle_item:
if location.sales_order_item: continue
sales_order_item = frappe.get_cached_doc(
"Sales Order Item", {"name": location.sales_order_item}
)
else:
sales_order_item = None
source_doc, table_mapper = ( if location.sales_order_item:
[sales_order_item, item_mapper] if sales_order_item else [location, item_mapper] 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 = map_child_doc(source_doc, delivery_note, item_mapper)
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) 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) set_delivery_note_missing_values(delivery_note)
delivery_note.pick_list = pick_list.name delivery_note.pick_list = pick_list.name
@ -549,6 +608,50 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None):
delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") 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() @frappe.whitelist()
def create_stock_entry(pick_list): def create_stock_entry(pick_list):
pick_list = frappe.get_doc(json.loads(pick_list)) pick_list = frappe.get_doc(json.loads(pick_list))

View File

@ -3,18 +3,21 @@
import frappe import frappe
from frappe import _dict from frappe import _dict
test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"]
from frappe.tests.utils import FrappeTestCase 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.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
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt 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 ( from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
EmptyStockReconciliationItemsError, EmptyStockReconciliationItemsError,
) )
test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"]
class TestPickList(FrappeTestCase): class TestPickList(FrappeTestCase):
def test_pick_list_picks_warehouse_for_each_item(self): def test_pick_list_picks_warehouse_for_each_item(self):
@ -579,14 +582,79 @@ class TestPickList(FrappeTestCase):
if dn_item.item_code == "_Test Item 2": if dn_item.item_code == "_Test Item 2":
self.assertEqual(dn_item.qty, 2) self.assertEqual(dn_item.qty, 2)
# def test_pick_list_skips_items_in_expired_batch(self): def test_picklist_with_multi_uom(self):
# pass 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): so = make_sales_order(item_code=item, qty=10, rate=42, uom="Box")
# pass 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): so.reload()
# pass self.assertEqual(so.per_picked, 50)
# def test_pick_list_from_material_request(self): def test_picklist_with_bundles(self):
# pass 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)

View File

@ -27,6 +27,7 @@
"column_break_15", "column_break_15",
"sales_order", "sales_order",
"sales_order_item", "sales_order_item",
"product_bundle_item",
"material_request", "material_request",
"material_request_item" "material_request_item"
], ],
@ -146,6 +147,7 @@
{ {
"fieldname": "sales_order_item", "fieldname": "sales_order_item",
"fieldtype": "Data", "fieldtype": "Data",
"hidden": 1,
"label": "Sales Order Item", "label": "Sales Order Item",
"read_only": 1 "read_only": 1
}, },
@ -177,11 +179,19 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Item Group", "label": "Item Group",
"read_only": 1 "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, "istable": 1,
"links": [], "links": [],
"modified": "2021-09-28 12:02:16.923056", "modified": "2022-04-22 05:27:38.497997",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Pick List Item", "name": "Pick List Item",
@ -190,5 +200,6 @@
"quick_entry": 1, "quick_entry": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }