diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index e9649e50d44..0cdf16b2298 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -164,6 +164,17 @@ status_map = { ["Draft", None], ["Completed", "eval:self.docstatus == 1"], ], + "Pick List": [ + ["Draft", None], + ["Open", "eval:self.docstatus == 1"], + ["Completed", "stock_entry_exists"], + [ + "Partly Delivered", + "eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'", + ], + ["Completed", "eval:self.purpose == 'Delivery' and self.delivery_status == 'Fully Delivered'"], + ["Cancelled", "eval:self.docstatus == 2"], + ], } diff --git a/erpnext/patches.txt b/erpnext/patches.txt index a86a5dda20c..69f5ec159fa 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -419,6 +419,7 @@ erpnext.patches.v15_0.rename_group_by_to_categorize_by_in_custom_reports erpnext.patches.v15_0.remove_agriculture_roles erpnext.patches.v14_0.update_full_name_in_contract erpnext.patches.v15_0.drop_sle_indexes +erpnext.patches.v15_0.update_pick_list_fields execute:frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", 1) erpnext.patches.v15_0.rename_pos_closing_entry_fields #2025-06-13 erpnext.patches.v15_0.update_pegged_currencies diff --git a/erpnext/patches/v15_0/update_pick_list_fields.py b/erpnext/patches/v15_0/update_pick_list_fields.py new file mode 100644 index 00000000000..9a7a1f5f463 --- /dev/null +++ b/erpnext/patches/v15_0/update_pick_list_fields.py @@ -0,0 +1,28 @@ +import frappe +from frappe.query_builder.functions import IfNull + + +def execute(): + update_delivery_note() + update_pick_list_items() + + +def update_delivery_note(): + DN = frappe.qb.DocType("Delivery Note") + DNI = frappe.qb.DocType("Delivery Note Item") + + frappe.qb.update(DNI).join(DN).on(DN.name == DNI.parent).set(DNI.against_pick_list, DN.pick_list).where( + IfNull(DN.pick_list, "") != "" + ).run() + + +def update_pick_list_items(): + PL = frappe.qb.DocType("Pick List") + PLI = frappe.qb.DocType("Pick List Item") + + pick_lists = frappe.qb.from_(PL).select(PL.name).where(PL.status == "Completed").run(pluck="name") + + if not pick_lists: + return + + frappe.qb.update(PLI).set(PLI.delivered_qty, PLI.picked_qty).where(PLI.parent.isin(pick_lists)).run() diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 9ec15c77cd9..56164e14b21 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -1031,7 +1031,7 @@ erpnext.utils.map_current_doc = function (opts) { if ( opts.allow_child_item_selection || - ["Purchase Receipt", "Delivery Note"].includes(opts.source_doctype) + ["Purchase Receipt", "Delivery Note", "Pick List"].includes(opts.source_doctype) ) { // args contains filtered child docnames opts.args = args; diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index f0e9d33f700..cced2a57a2e 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1774,8 +1774,8 @@ def create_pick_list(source_name, target_doc=None): "doctype": "Pick List Item", "field_map": { "parent": "sales_order", - "name": "sales_order_item", - "parent_detail_docname": "product_bundle_item", + "parent_detail_docname": "sales_order_item", + "name": "product_bundle_item", }, "field_no_map": ["picked_qty"], "postprocess": update_packed_item_qty, diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index 1f6816e3fed..440e104abb6 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -188,6 +188,55 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( ); } + if ( + !doc.is_return && + doc.status != "Closed" && + this.frm.has_perm("write") && + frappe.model.can_read("Pick List") && + this.frm.doc.docstatus === 0 + ) { + this.frm.add_custom_button( + __("Pick List"), + function () { + if (!me.frm.doc.customer) { + frappe.throw({ + title: __("Mandatory"), + message: __("Please Select a Customer"), + }); + } + erpnext.utils.map_current_doc({ + method: "erpnext.stock.doctype.pick_list.pick_list.create_dn_for_pick_lists", + source_doctype: "Pick List", + target: me.frm, + setters: [ + { + fieldname: "customer", + default: me.frm.doc.customer, + label: __("Customer"), + fieldtype: "Link", + options: "Customer", + reqd: 1, + read_only: 1, + }, + { + fieldname: "sales_order", + label: __("Sales Order"), + fieldtype: "Link", + options: "Sales Order", + link_filters: `[["Sales Order","customer","=","${me.frm.doc.customer}"],["Sales Order","docstatus","=","1"],["Sales Order","delivery_status","not in",["Closed","Fully Delivered"]]]`, + }, + ], + get_query_filters: { + company: me.frm.doc.company, + }, + get_query_method: "erpnext.stock.doctype.pick_list.pick_list.get_pick_list_query", + size: "extra-large", + }); + }, + __("Get Items From") + ); + } + if (!doc.is_return && doc.status != "Closed") { if (doc.docstatus == 1 && frappe.model.can_create("Shipment")) { this.frm.add_custom_button( diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index cbb3feb9f88..5574519c9dd 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -37,7 +37,6 @@ "ignore_pricing_rule", "items_section", "scan_barcode", - "pick_list", "col_break_warehouse", "set_warehouse", "set_target_warehouse", @@ -1196,15 +1195,6 @@ "options": "Sales Team", "print_hide": 1 }, - { - "fieldname": "pick_list", - "fieldtype": "Link", - "hidden": 1, - "label": "Pick List", - "options": "Pick List", - "read_only": 1, - "search_index": 1 - }, { "default": "0", "fetch_from": "customer.is_internal_customer", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index fceca989fbb..32f80c41ecc 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -176,6 +176,19 @@ class DeliveryNote(SellingController): "overflow_type": "delivery", "no_allowance": 1, }, + { + "source_dt": "Delivery Note Item", + "target_dt": "Pick List Item", + "join_field": "pick_list_item", + "target_field": "delivered_qty", + "target_parent_dt": "Pick List", + "target_parent_field": "per_delivered", + "target_ref_field": "picked_qty", + "source_field": "stock_qty", + "percent_join_field": "against_pick_list", + "status_field": "delivery_status", + "keyword": "Delivered", + }, ] if cint(self.is_return): self.status_updater.extend( @@ -328,18 +341,15 @@ class DeliveryNote(SellingController): def set_serial_and_batch_bundle_from_pick_list(self): from erpnext.stock.serial_batch_bundle import SerialBatchCreation - if not self.pick_list: - return - for item in self.items: - if item.use_serial_batch_fields: + if item.use_serial_batch_fields or not item.against_pick_list: continue if item.pick_list_item and not item.serial_and_batch_bundle: filters = { "item_code": item.item_code, "voucher_type": "Pick List", - "voucher_no": self.pick_list, + "voucher_no": item.against_pick_list, "voucher_detail_no": item.pick_list_item, } @@ -588,7 +598,9 @@ class DeliveryNote(SellingController): 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) + pick_lists = {row.against_pick_list for row in self.items if row.against_pick_list} + for pick_list in pick_lists: + update_pick_list_status(pick_list) def check_next_docstatus(self): submit_rv = frappe.db.sql( diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index e2b9bbede40..4be44d2ee55 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -77,6 +77,7 @@ "against_sales_invoice", "si_detail", "dn_detail", + "against_pick_list", "pick_list_item", "section_break_40", "pick_serial_and_batch", @@ -935,6 +936,16 @@ { "fieldname": "column_break_fguf", "fieldtype": "Column Break" + }, + { + "fieldname": "against_pick_list", + "fieldtype": "Link", + "label": "Against Pick List", + "no_copy": 1, + "options": "Pick List", + "print_hide": 1, + "read_only": 1, + "search_index": 1 } ], "grid_page_length": 50, @@ -942,7 +953,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-03-07 12:33:40.868499", + "modified": "2025-05-31 18:51:32.651562", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", @@ -953,4 +964,4 @@ "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py index 7fb0e24be0b..62a7691009e 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.py @@ -16,6 +16,7 @@ class DeliveryNoteItem(Document): actual_batch_qty: DF.Float actual_qty: DF.Float + against_pick_list: DF.Link | None against_sales_invoice: DF.Link | None against_sales_order: DF.Link | None allow_zero_valuation_rate: DF.Check diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 6a6bb226a9e..dea83560494 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -98,34 +98,28 @@ frappe.ui.form.on("Pick List", { refresh: (frm) => { frm.trigger("add_get_items_button"); if (frm.doc.docstatus === 1) { - frappe - .xcall("erpnext.stock.doctype.pick_list.pick_list.target_document_exists", { - pick_list_name: frm.doc.name, - purpose: frm.doc.purpose, - }) - .then((target_document_exists) => { - frm.set_df_property("locations", "allow_on_submit", target_document_exists ? 0 : 1); + const status_completed = frm.doc.status === "Completed"; + frm.set_df_property("locations", "allow_on_submit", status_completed ? 0 : 1); - if (target_document_exists) return; + if (!status_completed) { + frm.add_custom_button(__("Update Current Stock"), () => + frm.trigger("update_pick_list_stock") + ); - frm.add_custom_button(__("Update Current Stock"), () => - frm.trigger("update_pick_list_stock") + if (frm.doc.purpose === "Delivery") { + frm.add_custom_button( + __("Create Delivery Note"), + () => frm.trigger("create_delivery_note"), + __("Create") ); - - if (frm.doc.purpose === "Delivery") { - frm.add_custom_button( - __("Delivery Note"), - () => frm.trigger("create_delivery_note"), - __("Create") - ); - } else { - frm.add_custom_button( - __("Stock Entry"), - () => frm.trigger("create_stock_entry"), - __("Create") - ); - } - }); + } else { + frm.add_custom_button( + __("Create Stock Entry"), + () => frm.trigger("create_stock_entry"), + __("Create") + ); + } + } if (frm.doc.purpose === "Delivery" && frm.doc.status === "Open") { if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) { diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index 88ba11aef0f..e6449476971 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -30,7 +30,11 @@ "amended_from", "print_settings_section", "group_same_items", - "status" + "status_section", + "status", + "column_break_qyam", + "delivery_status", + "per_delivered" ], "fields": [ { @@ -181,7 +185,7 @@ "in_standard_filter": 1, "label": "Status", "no_copy": 1, - "options": "Draft\nOpen\nCompleted\nCancelled", + "options": "Draft\nOpen\nPartly Delivered\nCompleted\nCancelled", "print_hide": 1, "read_only": 1, "report_hide": 1, @@ -208,11 +212,42 @@ "fieldname": "ignore_pricing_rule", "fieldtype": "Check", "label": "Ignore Pricing Rule" + }, + { + "collapsible": 1, + "fieldname": "status_section", + "fieldtype": "Section Break", + "label": "Status", + "print_hide": 1 + }, + { + "fieldname": "delivery_status", + "fieldtype": "Select", + "hidden": 1, + "in_standard_filter": 1, + "label": "Delivery Status", + "no_copy": 1, + "options": "Not Delivered\nFully Delivered\nPartly Delivered", + "print_hide": 1 + }, + { + "depends_on": "eval:!doc.__islocal && doc.purpose === \"Delivery\"", + "description": "% of materials delivered against this Pick List", + "fieldname": "per_delivered", + "fieldtype": "Percent", + "label": "% Delivered", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_qyam", + "fieldtype": "Column Break" } ], "is_submittable": 1, "links": [], - "modified": "2024-08-14 13:20:42.168827", + "modified": "2025-05-31 19:18:30.860044", "modified_by": "Administrator", "module": "Stock", "name": "Pick List", @@ -280,8 +315,9 @@ "write": 1 } ], + "row_format": "Dynamic", "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 9530444c6e8..36f29cbf929 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -7,8 +7,7 @@ from itertools import groupby import frappe from frappe import _, bold -from frappe.model.document import Document -from frappe.model.mapper import map_child_doc +from frappe.model.mapper import get_mapped_doc, map_child_doc from frappe.query_builder import Case from frappe.query_builder.custom import GROUP_CONCAT from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum @@ -28,11 +27,12 @@ from erpnext.stock.serial_batch_bundle import ( get_batches_from_bundle, get_serial_nos_from_bundle, ) +from erpnext.utilities.transaction_base import TransactionBase # TODO: Prioritize SO or WO group warehouse -class PickList(Document): +class PickList(TransactionBase): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -48,6 +48,7 @@ class PickList(Document): consider_rejected_warehouses: DF.Check customer: DF.Link | None customer_name: DF.Data | None + delivery_status: DF.Literal["Not Delivered", "Fully Delivered", "Partly Delivered"] for_qty: DF.Float group_same_items: DF.Check ignore_pricing_rule: DF.Check @@ -55,12 +56,13 @@ class PickList(Document): material_request: DF.Link | None naming_series: DF.Literal["STO-PICK-.YYYY.-"] parent_warehouse: DF.Link | None + per_delivered: DF.Percent pick_manually: DF.Check prompt_qty: DF.Check purpose: DF.Literal["Material Transfer for Manufacture", "Material Transfer", "Delivery"] scan_barcode: DF.Data | None scan_mode: DF.Check - status: DF.Literal["Draft", "Open", "Completed", "Cancelled"] + status: DF.Literal["Draft", "Open", "Partly Delivered", "Completed", "Cancelled"] work_order: DF.Link | None # end: auto-generated types @@ -77,6 +79,7 @@ class PickList(Document): self.validate_for_qty() self.validate_stock_qty() self.check_serial_no_status() + self.validate_with_previous_doc() def before_save(self): self.update_status() @@ -152,6 +155,19 @@ class PickList(Document): title=_("Incorrect Warehouse"), ) + def validate_with_previous_doc(self): + super().validate_with_previous_doc( + { + "Sales Order": { + "ref_dn_field": "sales_order", + "compare_fields": [ + ["customer", "="], + ["company", "="], + ], + }, + } + ) + def validate_sales_order_percentage(self): # set percentage picked in SO for location in self.get("locations"): @@ -329,26 +345,27 @@ class PickList(Document): def update_status(self, status=None, update_modified=True): if not status: - if self.docstatus == 0: - status = "Draft" - elif self.docstatus == 1: - if target_document_exists(self.name, self.purpose): - status = "Completed" - else: - status = "Open" - elif self.docstatus == 2: - status = "Cancelled" + status = self.get_status().get("status") if status: self.db_set("status", status) + def stock_entry_exists(self): + if self.docstatus != 1: + return False + + if self.purpose == "Delivery": + return False + + return stock_entry_exists(self.name) + def update_reference_qty(self): packed_items = [] so_items = [] for item in self.locations: if item.product_bundle_item: - packed_items.append(item.sales_order_item) + packed_items.append(item.product_bundle_item) elif item.sales_order_item: so_items.append(item.sales_order_item) @@ -359,12 +376,12 @@ class PickList(Document): self.update_sales_order_item_qty(so_items) def update_packed_items_qty(self, packed_items): - picked_items = get_picked_items_qty(packed_items) + picked_items = get_picked_items_qty(packed_items, contains_packed_items=True) self.validate_picked_qty(picked_items) picked_qty = frappe._dict() for d in picked_items: - picked_qty[d.sales_order_item] = d.picked_qty + picked_qty[d.product_bundle_item] = d.picked_qty for packed_item in packed_items: frappe.db.set_value( @@ -577,7 +594,6 @@ class PickList(Document): # maintain count of each item (useful to limit get query) self.item_count_map.setdefault(item_code, 0) self.item_count_map[item_code] += flt(item.stock_qty, item.precision("stock_qty")) - return item_map.values() def validate_for_qty(self): @@ -741,9 +757,10 @@ class PickList(Document): for item in self.locations: if not item.product_bundle_item: continue - product_bundles[item.product_bundle_item] = frappe.db.get_value( + + product_bundles[item.sales_order_item] = frappe.db.get_value( "Sales Order Item", - item.product_bundle_item, + item.sales_order_item, "item_code", ) return product_bundles @@ -759,10 +776,9 @@ class PickList(Document): 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: + if item.sales_order_item != bundle_row: continue if qty_in_bundle := bundle_items.get(item.item_code): @@ -802,24 +818,35 @@ def update_pick_list_status(pick_list): doc.run_method("update_status") -def get_picked_items_qty(items) -> list[dict]: +def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]: pi_item = frappe.qb.DocType("Pick List Item") - return ( + + query = ( frappe.qb.from_(pi_item) .select( pi_item.sales_order_item, + pi_item.product_bundle_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( + .where(pi_item.docstatus == 1) + .for_update() + ) + + if contains_packed_items: + query = query.groupby( + pi_item.product_bundle_item, + pi_item.sales_order, + ).where(pi_item.product_bundle_item.isin(items)) + else: + query = query.groupby( pi_item.sales_order_item, pi_item.sales_order, - ) - .for_update() - ).run(as_dict=True) + ).where(pi_item.sales_order_item.isin(items)) + + return query.run(as_dict=True) def validate_item_locations(pick_list): @@ -1211,9 +1238,41 @@ def create_dn_wo_so(pick_list): return delivery_note +@frappe.whitelist() +def create_dn_for_pick_lists(source_name, target_doc=None, kwargs=None): + """Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer""" + pick_list = frappe.get_doc("Pick List", source_name) + validate_item_locations(pick_list) + + if kwargs and (order := kwargs.get("sales_order")): + sales_orders = {order} + else: + sales_orders = {row.sales_order for row in pick_list.locations if row.sales_order} + + if kwargs and (customer := kwargs.get("customer")): + sales_orders = frappe.get_all( + "Sales Order", + filters={"customer": customer, "name": ["in", list(sales_orders)]}, + pluck="name", + ) + + if not sales_orders: + return + + return create_dn_from_so(pick_list, sales_orders, delivery_note=target_doc) + + def create_dn_with_so(sales_dict, pick_list): + """Create Delivery Note for each customer (based on SO) in a Pick List.""" delivery_note = None + for customer in sales_dict: + delivery_note = create_dn_from_so(pick_list, sales_dict[customer], None) + + return delivery_note + + +def create_dn_from_so(pick_list, sales_order_list, delivery_note=None): item_table_mapper = { "doctype": "Delivery Note Item", "field_map": { @@ -1224,17 +1283,19 @@ 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 - kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule} - delivery_note = create_delivery_note_from_sales_order(so, delivery_note, kwargs=kwargs) - 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) - update_packed_item_details(pick_list, delivery_note) + kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule} + delivery_note = create_delivery_note_from_sales_order( + next(iter(sales_order_list)), delivery_note, kwargs=kwargs + ) + + if not delivery_note: + return + + if delivery_note: + for so in sales_order_list: + map_pl_locations(pick_list, item_table_mapper, delivery_note, so) + + update_packed_item_details(pick_list, delivery_note) return delivery_note @@ -1254,24 +1315,28 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): dn_item = map_child_doc(source_doc, delivery_note, item_mapper) if dn_item: + dn_item.against_pick_list = pick_list.name 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.qty = flt(location.picked_qty - location.delivered_qty) / ( + flt(dn_item.conversion_factor) or 1 + ) dn_item.batch_no = location.batch_no dn_item.serial_no = location.serial_no dn_item.use_serial_batch_fields = location.use_serial_batch_fields update_delivery_note_item(source_doc, dn_item, delivery_note) - add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper) + add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order) set_delivery_note_missing_values(delivery_note) - delivery_note.pick_list = pick_list.name delivery_note.company = pick_list.company 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: +def add_product_bundles_to_delivery_note( + pick_list: "PickList", delivery_note, item_mapper, sales_order=None +) -> 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 @@ -1281,6 +1346,9 @@ def add_product_bundles_to_delivery_note(pick_list: "PickList", delivery_note, i for so_row, item_code in product_bundles.items(): sales_order_item = frappe.get_doc("Sales Order Item", so_row) + if sales_order and sales_order_item.parent != sales_order: + continue + 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] @@ -1359,14 +1427,6 @@ def get_pending_work_orders(doctype, txt, searchfield, start, page_length, filte ).run(as_dict=as_dict) -@frappe.whitelist() -def target_document_exists(pick_list_name, purpose): - if purpose == "Delivery": - return frappe.db.exists("Delivery Note", {"pick_list": pick_list_name, "docstatus": 1}) - - return stock_entry_exists(pick_list_name) - - @frappe.whitelist() def get_item_details(item_code, uom=None): details = frappe.db.get_value("Item", item_code, ["stock_uom", "name"], as_dict=1) @@ -1487,3 +1547,47 @@ def get_rejected_warehouses(): ) return frappe.local.rejected_warehouses + + +@frappe.whitelist() +def get_pick_list_query(doctype, txt, searchfield, start, page_len, filters): + frappe.has_permission("Pick List", throw=True) + + if not filters.get("company"): + frappe.throw(_("Please select a Company")) + + PICK_LIST = frappe.qb.DocType("Pick List") + PICK_LIST_ITEM = frappe.qb.DocType("Pick List Item") + + query = ( + frappe.qb.from_(PICK_LIST) + .join(PICK_LIST_ITEM) + .on(PICK_LIST.name == PICK_LIST_ITEM.parent) + .select( + PICK_LIST.name, + PICK_LIST.customer, + Replace(GROUP_CONCAT(PICK_LIST_ITEM.sales_order).distinct(), ",", "
").as_("sales_order"), + ) + .where(PICK_LIST.docstatus == 1) + .where(PICK_LIST.status.isin(["Open", "Partly Delivered"])) + .where(PICK_LIST.company == filters.get("company")) + .where(PICK_LIST.customer == filters.get("customer")) + .groupby(PICK_LIST.name) + ) + + if filters.get("sales_order"): + query = query.where(PICK_LIST_ITEM.sales_order == filters.get("sales_order")) + + if txt: + meta = frappe.get_meta("Pick List") + search_fields = meta.get_search_fields() + + txt = f"%{txt}%" + txt_condition = PICK_LIST[search_fields[-1]].like(txt) + + for field in search_fields[:-1]: + txt_condition |= PICK_LIST[field].like(txt) + + query = query.where(txt_condition) + + return query.run(as_dict=True) diff --git a/erpnext/stock/doctype/pick_list/pick_list_list.js b/erpnext/stock/doctype/pick_list/pick_list_list.js index eca6eece785..a675c95f973 100644 --- a/erpnext/stock/doctype/pick_list/pick_list_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list_list.js @@ -6,6 +6,7 @@ frappe.listview_settings["Pick List"] = { const status_colors = { Draft: "red", Open: "orange", + "Partly Delivered": "orange", Completed: "green", Cancelled: "red", }; diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 910f9c3b400..fba222410d8 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -398,7 +398,13 @@ class TestPickList(IntegrationTestCase): self.assertEqual(pick_list.locations[1].sales_order_item, sales_order.items[0].name) def test_pick_list_for_items_with_multiple_UOM(self): - item_code = make_item().name + item_code = make_item( + uoms=[ + {"uom": "Nos", "conversion_factor": 1}, + {"uom": "Box", "conversion_factor": 5}, + {"uom": "Unit", "conversion_factor": 0.5}, + ] + ).name purchase_receipt = make_purchase_receipt(item_code=item_code, qty=10) purchase_receipt.submit() @@ -411,8 +417,7 @@ class TestPickList(IntegrationTestCase): { "item_code": item_code, "qty": 1, - "conversion_factor": 5, - "stock_qty": 5, + "uom": "Box", "delivery_date": frappe.utils.today(), "warehouse": "_Test Warehouse - _TC", }, @@ -426,6 +431,7 @@ class TestPickList(IntegrationTestCase): ], } ).insert() + sales_order.submit() pick_list = frappe.get_doc( @@ -440,6 +446,7 @@ class TestPickList(IntegrationTestCase): "item_code": item_code, "qty": 2, "stock_qty": 1, + "uom": "Unit", "conversion_factor": 0.5, "sales_order": sales_order.name, "sales_order_item": sales_order.items[0].name, @@ -461,7 +468,11 @@ class TestPickList(IntegrationTestCase): 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) + # pick list stk_qty / dn conversion_factor = dn qty (1/5 = 0.2) + self.assertEqual( + pick_list.locations[0].picked_qty, + delivery_note.items[0].qty * delivery_note.items[0].conversion_factor, + ) self.assertEqual(pick_list.locations[1].qty, delivery_note.items[1].qty) self.assertEqual(sales_order.items[0].conversion_factor, delivery_note.items[0].conversion_factor) @@ -535,7 +546,7 @@ class TestPickList(IntegrationTestCase): sales_order_2 = frappe.get_doc( { "doctype": "Sales Order", - "customer": "_Test Customer 1", + "customer": "_Test Customer", "company": "_Test Company", "items": [ { @@ -555,6 +566,7 @@ class TestPickList(IntegrationTestCase): "items_based_on": "Sales Order", "purpose": "Delivery", "picker": "P001", + "customer": "_Test Customer", "locations": [ { "item_code": "_Test Item ", @@ -580,22 +592,25 @@ class TestPickList(IntegrationTestCase): create_delivery_note(pick_list.name) for dn in frappe.get_all( "Delivery Note", - filters={"pick_list": pick_list.name, "customer": "_Test Customer"}, + filters={"against_pick_list": pick_list.name, "customer": "_Test Customer"}, fields={"name"}, ): for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"): self.assertEqual(dn_item.item_code, "_Test Item") self.assertEqual(dn_item.against_sales_order, sales_order_1.name) + self.assertEqual(dn_item.against_pick_list, pick_list.name) self.assertEqual(dn_item.pick_list_item, pick_list.locations[dn_item.idx - 1].name) for dn in frappe.get_all( "Delivery Note", - filters={"pick_list": pick_list.name, "customer": "_Test Customer 1"}, + filters={"against_pick_list": pick_list.name, "customer": "_Test Customer 1"}, fields={"name"}, ): for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"): self.assertEqual(dn_item.item_code, "_Test Item 2") self.assertEqual(dn_item.against_sales_order, sales_order_2.name) + self.assertEqual(dn_item.against_pick_list, pick_list.name) + self.assertEqual(dn_item.pick_list_item, pick_list.locations[dn_item.idx - 1].name) # test DN creation without so pick_list_1 = frappe.get_doc( { @@ -622,7 +637,9 @@ class TestPickList(IntegrationTestCase): pick_list_1.set_item_locations() pick_list_1.submit() create_delivery_note(pick_list_1.name) - for dn in frappe.get_all("Delivery Note", filters={"pick_list": pick_list_1.name}, fields={"name"}): + for dn in frappe.get_all( + "Delivery Note", filters={"against_pick_list": pick_list_1.name}, fields={"name"} + ): for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"): if dn_item.item_code == "_Test Item": self.assertEqual(dn_item.qty, 1) @@ -759,7 +776,6 @@ class TestPickList(IntegrationTestCase): quantities = [5, 2] bundle, components = create_product_bundle(quantities, warehouse=warehouse) bundle_items = dict(zip(components, quantities, strict=False)) - so = make_sales_order(item_code=bundle, qty=3, rate=42) pl = create_pick_list(so.name) @@ -1307,3 +1323,59 @@ class TestPickList(IntegrationTestCase): for loc in pl.locations: self.assertEqual(loc.batch_no, batch2) + + def test_multiple_pick_lists_delivery_note(self): + from erpnext.stock.doctype.pick_list.pick_list import create_dn_for_pick_lists + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + + stock_entry = make_stock_entry(item=item_code, to_warehouse=warehouse, qty=500, basic_rate=100) + + def create_pick_list(qty): + pick_list = frappe.get_doc( + { + "doctype": "Pick List", + "company": "_Test Company", + "customer": "_Test Customer", + "purpose": "Delivery", + "locations": [ + { + "item_code": item_code, + "warehouse": warehouse, + "qty": qty, + "stock_qty": qty, + "picked_qty": 0, + "sales_order": sales_order.name, + "sales_order_item": sales_order.items[0].name, + }, + ], + } + ) + pick_list.submit() + return pick_list + + sales_order = make_sales_order(item_code=item_code, qty=50, rate=100) + pick_list_1 = create_pick_list(10) + pick_list_2 = create_pick_list(20) + + delivery_note = create_dn_for_pick_lists(pick_list_1.name) + delivery_note = create_dn_for_pick_lists(pick_list_2.name, delivery_note) + delivery_note.items[0].qty = 5 + delivery_note.submit() + + sales_order.reload() + pick_list_1.reload() + pick_list_2.reload() + + self.assertEqual(sales_order.items[0].picked_qty, 30) + self.assertEqual(pick_list_1.locations[0].delivered_qty, delivery_note.items[0].qty) + self.assertEqual(pick_list_1.status, "Partly Delivered") + self.assertEqual(pick_list_2.status, "Completed") + + pick_list_1.cancel() + pick_list_2.cancel() + delivery_note.cancel() + sales_order.reload() + sales_order.cancel() + stock_entry.cancel() 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 9eaf83f63b0..08310999b85 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -21,6 +21,7 @@ "uom", "conversion_factor", "stock_uom", + "delivered_qty", "serial_no_and_batch_section", "pick_serial_and_batch", "serial_and_batch_bundle", @@ -237,19 +238,30 @@ { "fieldname": "column_break_belw", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "delivered_qty", + "fieldtype": "Float", + "label": "Delivered Qty (in Stock UOM)", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "report_hide": 1 } ], "istable": 1, "links": [], - "modified": "2024-05-07 15:32:42.905446", + "modified": "2025-05-31 19:57:43.531298", "modified_by": "Administrator", "module": "Stock", "name": "Pick List Item", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.py b/erpnext/stock/doctype/pick_list_item/pick_list_item.py index f3f6298a305..af23a424949 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.py +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.py @@ -17,6 +17,7 @@ class PickListItem(Document): batch_no: DF.Link | None conversion_factor: DF.Float + delivered_qty: DF.Float description: DF.Text | None item_code: DF.Link item_group: DF.Data | None