diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9dcb9c1bcae..54957cd2228 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -326,3 +326,4 @@ erpnext.patches.v14_0.create_accounting_dimensions_for_payment_request erpnext.patches.v14_0.update_entry_type_for_journal_entry erpnext.patches.v14_0.change_autoname_for_tax_withheld_vouchers erpnext.patches.v14_0.update_asset_value_for_manual_depr_entries +erpnext.patches.v14_0.set_pick_list_status \ No newline at end of file diff --git a/erpnext/patches/v14_0/set_pick_list_status.py b/erpnext/patches/v14_0/set_pick_list_status.py new file mode 100644 index 00000000000..eea5745c23a --- /dev/null +++ b/erpnext/patches/v14_0/set_pick_list_status.py @@ -0,0 +1,40 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + + +import frappe +from pypika.terms import ExistsCriterion + + +def execute(): + pl = frappe.qb.DocType("Pick List") + se = frappe.qb.DocType("Stock Entry") + dn = frappe.qb.DocType("Delivery Note") + + ( + frappe.qb.update(pl).set( + pl.status, + ( + frappe.qb.terms.Case() + .when(pl.docstatus == 0, "Draft") + .when(pl.docstatus == 2, "Cancelled") + .else_("Completed") + ), + ) + ).run() + + ( + frappe.qb.update(pl) + .set(pl.status, "Open") + .where( + ( + ExistsCriterion( + frappe.qb.from_(se).select(se.name).where((se.docstatus == 1) & (se.pick_list == pl.name)) + ) + | ExistsCriterion( + frappe.qb.from_(dn).select(dn.name).where((dn.docstatus == 1) & (dn.pick_list == pl.name)) + ) + ).negate() + & (pl.docstatus == 1) + ) + ).run() diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index a1df764ea9d..9f9f5cbe2a4 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -228,6 +228,7 @@ class DeliveryNote(SellingController): def on_submit(self): self.validate_packed_qty() + self.update_pick_list_status() # Check for Approving Authority frappe.get_doc("Authorization Control").validate_approving_authority( @@ -313,6 +314,11 @@ class DeliveryNote(SellingController): if has_error: raise frappe.ValidationError + def update_pick_list_status(self): + from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status + + update_pick_list_status(self.pick_list) + def check_next_docstatus(self): submit_rv = frappe.db.sql( """select t1.name diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index e1c3f0f5061..7259dc00a81 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -26,7 +26,8 @@ "locations", "amended_from", "print_settings_section", - "group_same_items" + "group_same_items", + "status" ], "fields": [ { @@ -168,11 +169,26 @@ "fieldtype": "Data", "label": "Customer Name", "read_only": 1 + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "in_standard_filter": 1, + "label": "Status", + "no_copy": 1, + "options": "Draft\nOpen\nCompleted\nCancelled", + "print_hide": 1, + "read_only": 1, + "report_hide": 1, + "reqd": 1, + "search_index": 1 } ], "is_submittable": 1, "links": [], - "modified": "2022-07-19 11:03:04.442174", + "modified": "2023-01-24 10:33:43.244476", "modified_by": "Administrator", "module": "Stock", "name": "Pick List", @@ -244,4 +260,4 @@ "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 808f19e2740..bf3b5ddc54a 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -11,7 +11,8 @@ from frappe import _ from frappe.model.document import Document from frappe.model.mapper import map_child_doc from frappe.query_builder import Case -from frappe.query_builder.functions import IfNull, Locate, Sum +from frappe.query_builder.custom import GROUP_CONCAT +from frappe.query_builder.functions import Coalesce, IfNull, Locate, Replace, Sum from frappe.utils import cint, floor, flt, today from frappe.utils.nestedset import get_descendants_of @@ -77,15 +78,32 @@ class PickList(Document): ) def on_submit(self): + self.update_status() self.update_bundle_picked_qty() self.update_reference_qty() self.update_sales_order_picking_status() def on_cancel(self): + self.update_status() self.update_bundle_picked_qty() self.update_reference_qty() self.update_sales_order_picking_status() + def update_status(self, status=None, update_modified=True): + if not status: + if self.docstatus == 0: + status = "Draft" + elif self.docstatus == 1: + if self.status == "Draft": + status = "Open" + elif target_document_exists(self.name, self.purpose): + status = "Completed" + elif self.docstatus == 2: + status = "Cancelled" + + if status: + frappe.db.set_value("Pick List", self.name, "status", status, update_modified=update_modified) + def update_reference_qty(self): packed_items = [] so_items = [] @@ -162,6 +180,7 @@ class PickList(Document): def set_item_locations(self, save=False): self.validate_for_qty() items = self.aggregate_item_qty() + picked_items_details = self.get_picked_items_details(items) self.item_location_map = frappe._dict() from_warehouses = None @@ -180,7 +199,11 @@ class PickList(Document): self.item_location_map.setdefault( item_code, get_available_item_locations( - item_code, from_warehouses, self.item_count_map.get(item_code), self.company + item_code, + from_warehouses, + self.item_count_map.get(item_code), + self.company, + picked_item_details=picked_items_details.get(item_code), ), ) @@ -309,6 +332,56 @@ class PickList(Document): already_picked + (picked_qty * (1 if self.docstatus == 1 else -1)), ) + def get_picked_items_details(self, items): + picked_items = frappe._dict() + + if items: + pi = frappe.qb.DocType("Pick List") + pi_item = frappe.qb.DocType("Pick List Item") + query = ( + frappe.qb.from_(pi) + .inner_join(pi_item) + .on(pi.name == pi_item.parent) + .select( + pi_item.item_code, + pi_item.warehouse, + pi_item.batch_no, + Sum(Case().when(pi_item.picked_qty > 0, pi_item.picked_qty).else_(pi_item.stock_qty)).as_( + "picked_qty" + ), + Replace(GROUP_CONCAT(pi_item.serial_no), ",", "\n").as_("serial_no"), + ) + .where( + (pi_item.item_code.isin([x.item_code for x in items])) + & ((pi_item.picked_qty > 0) | (pi_item.stock_qty > 0)) + & (pi.status != "Completed") + & (pi_item.docstatus != 2) + ) + .groupby( + pi_item.item_code, + pi_item.warehouse, + pi_item.batch_no, + ) + ) + + if self.name: + query = query.where(pi_item.parent != self.name) + + items_data = query.run(as_dict=True) + + for item_data in items_data: + key = (item_data.warehouse, item_data.batch_no) if item_data.batch_no else item_data.warehouse + serial_no = [x for x in item_data.serial_no.split("\n") if x] if item_data.serial_no else None + data = {"picked_qty": item_data.picked_qty} + if serial_no: + data["serial_no"] = serial_no + if item_data.item_code not in picked_items: + picked_items[item_data.item_code] = {key: data} + else: + picked_items[item_data.item_code][key] = data + + return picked_items + def _get_product_bundles(self) -> Dict[str, str]: # Dict[so_item_row: item_code] product_bundles = {} @@ -346,29 +419,30 @@ class PickList(Document): return int(flt(min(possible_bundles), precision or 6)) +def update_pick_list_status(pick_list): + if pick_list: + doc = frappe.get_doc("Pick List", pick_list) + doc.run_method("update_status") + + def get_picked_items_qty(items) -> List[Dict]: - return frappe.db.sql( - f""" - SELECT - sales_order_item, - item_code, - sales_order, - SUM(stock_qty) AS stock_qty, - SUM(picked_qty) AS picked_qty - FROM - `tabPick List Item` - WHERE - sales_order_item IN ( - {", ".join(frappe.db.escape(d) for d in items)} - ) - AND docstatus = 1 - GROUP BY - sales_order_item, - sales_order - FOR UPDATE - """, - as_dict=1, - ) + pi_item = frappe.qb.DocType("Pick List Item") + return ( + frappe.qb.from_(pi_item) + .select( + pi_item.sales_order_item, + pi_item.item_code, + pi_item.sales_order, + Sum(pi_item.stock_qty).as_("stock_qty"), + Sum(pi_item.picked_qty).as_("picked_qty"), + ) + .where((pi_item.docstatus == 1) & (pi_item.sales_order_item.isin(items))) + .groupby( + pi_item.sales_order_item, + pi_item.sales_order, + ) + .for_update() + ).run(as_dict=True) def validate_item_locations(pick_list): @@ -434,31 +508,38 @@ def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus) def get_available_item_locations( - item_code, from_warehouses, required_qty, company, ignore_validation=False + item_code, + from_warehouses, + required_qty, + company, + ignore_validation=False, + picked_item_details=None, ): locations = [] + total_picked_qty = ( + sum([v.get("picked_qty") for k, v in picked_item_details.items()]) if picked_item_details else 0 + ) has_serial_no = frappe.get_cached_value("Item", item_code, "has_serial_no") has_batch_no = frappe.get_cached_value("Item", item_code, "has_batch_no") if has_batch_no and has_serial_no: locations = get_available_item_locations_for_serial_and_batched_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty ) elif has_serial_no: locations = get_available_item_locations_for_serialized_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty ) elif has_batch_no: locations = get_available_item_locations_for_batched_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty ) else: locations = get_available_item_locations_for_other_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty ) total_qty_available = sum(location.get("qty") for location in locations) - remaining_qty = required_qty - total_qty_available if remaining_qty > 0 and not ignore_validation: @@ -469,25 +550,60 @@ def get_available_item_locations( title=_("Insufficient Stock"), ) + if picked_item_details: + for location in list(locations): + key = ( + (location["warehouse"], location["batch_no"]) + if location.get("batch_no") + else location["warehouse"] + ) + + if key in picked_item_details: + picked_detail = picked_item_details[key] + + if picked_detail.get("serial_no") and location.get("serial_no"): + location["serial_no"] = list( + set(location["serial_no"]).difference(set(picked_detail["serial_no"])) + ) + location["qty"] = len(location["serial_no"]) + else: + location["qty"] -= picked_detail.get("picked_qty") + + if location["qty"] < 1: + locations.remove(location) + + total_qty_available = sum(location.get("qty") for location in locations) + remaining_qty = required_qty - total_qty_available + + if remaining_qty > 0 and not ignore_validation: + frappe.msgprint( + _("{0} units of Item {1} is picked in another Pick List.").format( + remaining_qty, frappe.get_desk_link("Item", item_code) + ), + title=_("Already Picked"), + ) + return locations def get_available_item_locations_for_serialized_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty=0 ): - filters = frappe._dict({"item_code": item_code, "company": company, "warehouse": ["!=", ""]}) + sn = frappe.qb.DocType("Serial No") + query = ( + frappe.qb.from_(sn) + .select(sn.name, sn.warehouse) + .where((sn.item_code == item_code) & (sn.company == company)) + .orderby(sn.purchase_date) + .limit(cint(required_qty + total_picked_qty)) + ) if from_warehouses: - filters.warehouse = ["in", from_warehouses] + query = query.where(sn.warehouse.isin(from_warehouses)) + else: + query = query.where(Coalesce(sn.warehouse, "") != "") - serial_nos = frappe.get_all( - "Serial No", - fields=["name", "warehouse"], - filters=filters, - limit=required_qty, - order_by="purchase_date", - as_list=1, - ) + serial_nos = query.run(as_list=True) warehouse_serial_nos_map = frappe._dict() for serial_no, warehouse in serial_nos: @@ -501,7 +617,7 @@ def get_available_item_locations_for_serialized_item( def get_available_item_locations_for_batched_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty=0 ): sle = frappe.qb.DocType("Stock Ledger Entry") batch = frappe.qb.DocType("Batch") @@ -521,6 +637,7 @@ def get_available_item_locations_for_batched_item( .groupby(sle.warehouse, sle.batch_no, sle.item_code) .having(Sum(sle.actual_qty) > 0) .orderby(IfNull(batch.expiry_date, "2200-01-01"), batch.creation, sle.batch_no, sle.warehouse) + .limit(cint(required_qty + total_picked_qty)) ) if from_warehouses: @@ -530,53 +647,58 @@ def get_available_item_locations_for_batched_item( def get_available_item_locations_for_serial_and_batched_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty=0 ): # Get batch nos by FIFO locations = get_available_item_locations_for_batched_item( item_code, from_warehouses, required_qty, company ) - filters = frappe._dict( - {"item_code": item_code, "company": company, "warehouse": ["!=", ""], "batch_no": ""} - ) + if locations: + sn = frappe.qb.DocType("Serial No") + conditions = (sn.item_code == item_code) & (sn.company == company) - # Get Serial Nos by FIFO for Batch No - for location in locations: - filters.batch_no = location.batch_no - filters.warehouse = location.warehouse - location.qty = ( - required_qty if location.qty > required_qty else location.qty - ) # if extra qty in batch + for location in locations: + location.qty = ( + required_qty if location.qty > required_qty else location.qty + ) # if extra qty in batch - serial_nos = frappe.get_list( - "Serial No", fields=["name"], filters=filters, limit=location.qty, order_by="purchase_date" - ) + serial_nos = ( + frappe.qb.from_(sn) + .select(sn.name) + .where( + (conditions) & (sn.batch_no == location.batch_no) & (sn.warehouse == location.warehouse) + ) + .orderby(sn.purchase_date) + .limit(cint(location.qty + total_picked_qty)) + ).run(as_dict=True) - serial_nos = [sn.name for sn in serial_nos] - location.serial_no = serial_nos + serial_nos = [sn.name for sn in serial_nos] + location.serial_no = serial_nos + location.qty = len(serial_nos) return locations -def get_available_item_locations_for_other_item(item_code, from_warehouses, required_qty, company): - # gets all items available in different warehouses - warehouses = [x.get("name") for x in frappe.get_list("Warehouse", {"company": company}, "name")] - - filters = frappe._dict( - {"item_code": item_code, "warehouse": ["in", warehouses], "actual_qty": [">", 0]} +def get_available_item_locations_for_other_item( + item_code, from_warehouses, required_qty, company, total_picked_qty=0 +): + bin = frappe.qb.DocType("Bin") + query = ( + frappe.qb.from_(bin) + .select(bin.warehouse, bin.actual_qty.as_("qty")) + .where((bin.item_code == item_code) & (bin.actual_qty > 0)) + .orderby(bin.creation) + .limit(cint(required_qty + total_picked_qty)) ) if from_warehouses: - filters.warehouse = ["in", from_warehouses] + query = query.where(bin.warehouse.isin(from_warehouses)) + else: + wh = frappe.qb.DocType("Warehouse") + query = query.from_(wh).where((bin.warehouse == wh.name) & (wh.company == company)) - item_locations = frappe.get_all( - "Bin", - fields=["warehouse", "actual_qty as qty"], - filters=filters, - limit=required_qty, - order_by="creation", - ) + item_locations = query.run(as_dict=True) return item_locations diff --git a/erpnext/stock/doctype/pick_list/pick_list_list.js b/erpnext/stock/doctype/pick_list/pick_list_list.js new file mode 100644 index 00000000000..ad88b0a682f --- /dev/null +++ b/erpnext/stock/doctype/pick_list/pick_list_list.js @@ -0,0 +1,14 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.listview_settings['Pick List'] = { + get_indicator: function (doc) { + const status_colors = { + "Draft": "grey", + "Open": "orange", + "Completed": "green", + "Cancelled": "red", + }; + return [__(doc.status), status_colors[doc.status], "status,=," + doc.status]; + }, +}; \ No newline at end of file diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 43acdf08360..1254fe3927f 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -414,6 +414,7 @@ class TestPickList(FrappeTestCase): pick_list.submit() delivery_note = create_delivery_note(pick_list.name) + pick_list.load_from_db() self.assertEqual(pick_list.locations[0].qty, delivery_note.items[0].qty) self.assertEqual(pick_list.locations[1].qty, delivery_note.items[1].qty) @@ -663,3 +664,147 @@ class TestPickList(FrappeTestCase): self.assertEqual(dn.items[0].rate, 42) so.reload() self.assertEqual(so.per_delivered, 100) + + def test_pick_list_status(self): + warehouse = "_Test Warehouse - _TC" + item = make_item(properties={"maintain_stock": 1}).name + make_stock_entry(item=item, to_warehouse=warehouse, qty=10) + + so = make_sales_order(item_code=item, qty=10, rate=100) + + pl = create_pick_list(so.name) + pl.save() + pl.reload() + self.assertEqual(pl.status, "Draft") + + pl.submit() + pl.reload() + self.assertEqual(pl.status, "Open") + + dn = create_delivery_note(pl.name) + dn.save() + pl.reload() + self.assertEqual(pl.status, "Open") + + dn.submit() + pl.reload() + self.assertEqual(pl.status, "Completed") + + dn.cancel() + pl.reload() + self.assertEqual(pl.status, "Completed") + + pl.cancel() + pl.reload() + self.assertEqual(pl.status, "Cancelled") + + def test_consider_existing_pick_list(self): + def create_items(items_properties): + items = [] + + for properties in items_properties: + properties.update({"maintain_stock": 1}) + item_code = make_item(properties=properties).name + properties.update({"item_code": item_code}) + items.append(properties) + + return items + + def create_stock_entries(items): + warehouses = ["Stores - _TC", "Finished Goods - _TC"] + + for item in items: + for warehouse in warehouses: + se = make_stock_entry( + item=item.get("item_code"), + to_warehouse=warehouse, + qty=5, + ) + + def get_item_list(items, qty, warehouse="All Warehouses - _TC"): + return [ + { + "item_code": item.get("item_code"), + "qty": qty, + "warehouse": warehouse, + } + for item in items + ] + + def get_picked_items_details(pick_list_doc): + items_data = {} + + for location in pick_list_doc.locations: + key = (location.warehouse, location.batch_no) if location.batch_no else location.warehouse + serial_no = [x for x in location.serial_no.split("\n") if x] if location.serial_no else None + data = {"picked_qty": location.picked_qty} + if serial_no: + data["serial_no"] = serial_no + if location.item_code not in items_data: + items_data[location.item_code] = {key: data} + else: + items_data[location.item_code][key] = data + + return items_data + + # Step - 1: Setup - Create Items and Stock Entries + items_properties = [ + { + "valuation_rate": 100, + }, + { + "valuation_rate": 200, + "has_batch_no": 1, + "create_new_batch": 1, + }, + { + "valuation_rate": 300, + "has_serial_no": 1, + "serial_no_series": "SNO.###", + }, + { + "valuation_rate": 400, + "has_batch_no": 1, + "create_new_batch": 1, + "has_serial_no": 1, + "serial_no_series": "SNO.###", + }, + ] + + items = create_items(items_properties) + create_stock_entries(items) + + # Step - 2: Create Sales Order [1] + so1 = make_sales_order(item_list=get_item_list(items, qty=6)) + + # Step - 3: Create and Submit Pick List [1] for Sales Order [1] + pl1 = create_pick_list(so1.name) + pl1.submit() + + # Step - 4: Create Sales Order [2] with same Item(s) as Sales Order [1] + so2 = make_sales_order(item_list=get_item_list(items, qty=4)) + + # Step - 5: Create Pick List [2] for Sales Order [2] + pl2 = create_pick_list(so2.name) + pl2.save() + + # Step - 6: Assert + picked_items_details = get_picked_items_details(pl1) + + for location in pl2.locations: + key = (location.warehouse, location.batch_no) if location.batch_no else location.warehouse + item_data = picked_items_details.get(location.item_code, {}).get(key, {}) + picked_qty = item_data.get("picked_qty", 0) + picked_serial_no = picked_items_details.get("serial_no", []) + bin_actual_qty = frappe.db.get_value( + "Bin", {"item_code": location.item_code, "warehouse": location.warehouse}, "actual_qty" + ) + + # Available Qty to pick should be equal to [Actual Qty - Picked Qty] + self.assertEqual(location.stock_qty, bin_actual_qty - picked_qty) + + # Serial No should not be in the Picked Serial No list + if location.serial_no: + a = set(picked_serial_no) + b = set([x for x in location.serial_no.split("\n") if x]) + self.assertSetEqual(b, b.difference(a)) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index d90a74f7b4a..9a247317045 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -161,6 +161,7 @@ class StockEntry(StockController): self.validate_subcontract_order() self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() + self.update_pick_list_status() self.make_gl_entries() @@ -2279,6 +2280,11 @@ class StockEntry(StockController): update_subcontracting_order_status(self.subcontracting_order) + def update_pick_list_status(self): + from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status + + update_pick_list_status(self.pick_list) + def set_missing_values(self): "Updates rate and availability of all the items of mapped doc." self.set_transfer_qty()