Merge pull request #30762 from ankush/bunle_pickng
feat: support product bundles in picklist
This commit is contained in:
commit
2fffc68938
@ -152,7 +152,9 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -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",
|
||||
|
@ -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:
|
||||
@ -1232,9 +1242,30 @@ 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):
|
||||
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 @@ 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",
|
||||
},
|
||||
"field_no_map": ["picked_qty"],
|
||||
"postprocess": update_packed_item_qty,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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:
|
||||
|
@ -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.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, 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 @@ 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"]
|
||||
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 @@ 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)
|
||||
|
@ -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
|
||||
}
|
@ -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 @@ 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:
|
||||
@ -45,7 +47,8 @@ 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)
|
||||
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 @@ class PickList(Document):
|
||||
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)
|
||||
|
||||
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(
|
||||
"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 @@ 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)
|
||||
|
||||
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 @@ 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,7 +200,6 @@ 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()
|
||||
|
||||
def group_similar_items(self):
|
||||
@ -217,6 +226,57 @@ class PickList(Document):
|
||||
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 @@ 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)
|
||||
|
||||
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,11 +548,6 @@ def create_dn_wo_so(pick_list):
|
||||
def create_dn_with_so(sales_dict, pick_list):
|
||||
delivery_note = None
|
||||
|
||||
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": {
|
||||
@ -507,12 +558,20 @@ def create_dn_with_so(sales_dict, pick_list):
|
||||
"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)
|
||||
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,19 +579,17 @@ 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:
|
||||
sales_order_item = frappe.get_cached_doc(
|
||||
"Sales Order Item", {"name": location.sales_order_item}
|
||||
)
|
||||
sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item)
|
||||
else:
|
||||
sales_order_item = None
|
||||
|
||||
source_doc, table_mapper = (
|
||||
[sales_order_item, item_mapper] if sales_order_item else [location, item_mapper]
|
||||
)
|
||||
source_doc = sales_order_item or location
|
||||
|
||||
dn_item = map_child_doc(source_doc, delivery_note, table_mapper)
|
||||
dn_item = map_child_doc(source_doc, delivery_note, item_mapper)
|
||||
|
||||
if dn_item:
|
||||
dn_item.pick_list_item = location.name
|
||||
@ -542,6 +599,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
|
||||
@ -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")
|
||||
|
||||
|
||||
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))
|
||||
|
@ -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 @@ class TestPickList(FrappeTestCase):
|
||||
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)
|
||||
|
@ -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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user