From 743afc972da24d5e784acee2e8f62c595d8f7d6e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 4 Jun 2026 22:47:26 +0530 Subject: [PATCH] feat: create sales invoice from pick list (backport #55594) (#55635) --- .../doctype/sales_invoice/sales_invoice.py | 71 +++++-- .../sales_invoice_item.json | 23 +- .../sales_invoice_item/sales_invoice_item.py | 2 + erpnext/controllers/selling_controller.py | 38 ++++ .../doctype/sales_order/sales_order.py | 3 +- .../doctype/delivery_note/delivery_note.py | 38 ---- erpnext/stock/doctype/pick_list/pick_list.js | 14 +- erpnext/stock/doctype/pick_list/pick_list.py | 166 ++++++++++----- .../doctype/pick_list/pick_list_dashboard.py | 3 +- .../stock/doctype/pick_list/test_pick_list.py | 198 +++++++++++++++++- 10 files changed, 432 insertions(+), 124 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 64a1d3530f2..862cdcd70b6 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -370,6 +370,8 @@ class SalesInvoice(SellingController): if row.billing_amount: row.billing_amount = -abs(row.billing_amount) + self.validate_update_stock_for_pick_list_reference() + self.set_serial_and_batch_bundle_from_pick_list() self.update_packing_list() self.set_billing_hours_and_amount() self.update_timesheet_billing_for_project() @@ -389,6 +391,18 @@ class SalesInvoice(SellingController): self.validate_subcontracted_sales_order() self.validate_scio_self_rm_qty() + def validate_update_stock_for_pick_list_reference(self): + if self.update_stock or self.is_return: + return + + for row in self.items: + if row.get("against_pick_list"): + frappe.throw( + _( + "Row {0}: Update Stock must be checked for item {1} because it is against Pick List {2}." + ).format(row.idx, frappe.bold(row.item_code), frappe.bold(row.against_pick_list)) + ) + def validate_accounts(self): self.validate_write_off_account() self.validate_account_for_change_amount() @@ -491,6 +505,7 @@ class SalesInvoice(SellingController): if self.update_stock == 1: self.repost_future_sle_and_gle() + self.update_pick_list_status() if not self.is_return: self.update_billing_status_for_zero_amount_refdoc("Delivery Note") @@ -614,6 +629,7 @@ class SalesInvoice(SellingController): if self.update_stock == 1: self.update_stock_reservation_entries() self.repost_future_sle_and_gle() + self.update_pick_list_status() self.db_set("status", "Cancelled") @@ -665,26 +681,41 @@ class SalesInvoice(SellingController): if not cint(self.update_stock): return - self.status_updater.append( - { - "source_dt": "Sales Invoice Item", - "target_dt": "Sales Order Item", - "target_parent_dt": "Sales Order", - "target_parent_field": "per_delivered", - "target_field": "delivered_qty", - "target_ref_field": "qty", - "source_field": "qty", - "join_field": "so_detail", - "percent_join_field": "sales_order", - "status_field": "delivery_status", - "keyword": "Delivered", - "second_source_dt": "Delivery Note Item", - "second_source_field": "qty", - "second_join_field": "so_detail", - "overflow_type": "delivery", - "extra_cond": """ and exists(select name from `tabSales Invoice` - where name=`tabSales Invoice Item`.parent and update_stock = 1)""", - } + self.status_updater.extend( + [ + { + "source_dt": "Sales Invoice Item", + "target_dt": "Sales Order Item", + "target_parent_dt": "Sales Order", + "target_parent_field": "per_delivered", + "target_field": "delivered_qty", + "target_ref_field": "qty", + "source_field": "qty", + "join_field": "so_detail", + "percent_join_field": "sales_order", + "status_field": "delivery_status", + "keyword": "Delivered", + "second_source_dt": "Delivery Note Item", + "second_source_field": "qty", + "second_join_field": "so_detail", + "overflow_type": "delivery", + "extra_cond": """ and exists(select name from `tabSales Invoice` + where name=`tabSales Invoice Item`.parent and update_stock = 1)""", + }, + { + "source_dt": "Sales Invoice 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 not cint(self.is_return): diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index c9ed9d3f15d..5f1e0b1444b 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -104,6 +104,7 @@ "sales_order", "so_detail", "sales_invoice_item", + "pick_list_item", "column_break_74", "delivery_note", "dn_detail", @@ -112,6 +113,7 @@ "pos_invoice", "pos_invoice_item", "scio_detail", + "against_pick_list", "internal_transfer_section", "purchase_order", "column_break_92", @@ -855,8 +857,8 @@ "fieldtype": "Currency", "label": "Rate of Stock UOM", "no_copy": 1, - "print_hide": 1, "options": "currency", + "print_hide": 1, "read_only": 1 }, { @@ -1011,13 +1013,30 @@ "label": "Consider for Tax Withholding", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "against_pick_list", + "fieldtype": "Link", + "hidden": 1, + "label": "Against Pick List", + "no_copy": 1, + "options": "Pick List", + "read_only": 1 + }, + { + "fieldname": "pick_list_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Pick List Item", + "no_copy": 1, + "read_only": 1 } ], "grid_page_length": 50, "idx": 1, "istable": 1, "links": [], - "modified": "2026-05-29 12:23:28.259905", + "modified": "2026-06-03 13:17:36.145788", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py index 2b444d760b7..12bf334af25 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py @@ -22,6 +22,7 @@ class SalesInvoiceItem(Document): actual_batch_qty: DF.Float actual_qty: DF.Float + against_pick_list: DF.Link | None allow_zero_valuation_rate: DF.Check amount: DF.Currency apply_tds: DF.Check @@ -72,6 +73,7 @@ class SalesInvoiceItem(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + pick_list_item: DF.Data | None pos_invoice: DF.Link | None pos_invoice_item: DF.Data | None price_list_rate: DF.Currency diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index a3940aacb6b..6cdcb7090d2 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -1060,6 +1060,44 @@ class SellingController(StockController): qty_to_undelivered -= qty_can_be_undelivered + def set_serial_and_batch_bundle_from_pick_list(self): + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + for item in self.items: + if item.use_serial_batch_fields or not item.against_pick_list or not self.get("update_stock", 1): + 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": item.against_pick_list, + "voucher_detail_no": item.pick_list_item, + } + + bundle_id = frappe.db.get_value("Serial and Batch Bundle", filters, "name") + + if bundle_id: + cls_obj = SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": bundle_id, + "item_code": item.get("item_code"), + "warehouse": item.get("warehouse"), + } + ) + + cls_obj.duplicate_package() + + item.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle + + def update_pick_list_status(self): + from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status + + 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 set_default_income_account_for_item(obj): """Set income account as default for items in the transaction. diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 2aff7d44327..71df3d8d081 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1466,7 +1466,8 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a if is_unit_price_row(doc) else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))) ) - and select_item(doc), + and select_item(doc) + and not args.get("skip_item_mapping"), }, "Sales Taxes and Charges": { "doctype": "Sales Taxes and Charges", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index f0e44ed368d..0be8c8f864d 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -356,37 +356,6 @@ class DeliveryNote(SellingController): ] ) - def set_serial_and_batch_bundle_from_pick_list(self): - from erpnext.stock.serial_batch_bundle import SerialBatchCreation - - for item in self.items: - 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": item.against_pick_list, - "voucher_detail_no": item.pick_list_item, - } - - bundle_id = frappe.db.get_value("Serial and Batch Bundle", filters, "name") - - if bundle_id: - cls_obj = SerialBatchCreation( - { - "type_of_transaction": "Outward", - "serial_and_batch_bundle": bundle_id, - "item_code": item.get("item_code"), - "warehouse": item.get("warehouse"), - } - ) - - cls_obj.duplicate_package() - - item.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle - def validate_references(self): self.validate_sales_order_references() self.validate_sales_invoice_references() @@ -617,13 +586,6 @@ class DeliveryNote(SellingController): ) ) - def update_pick_list_status(self): - from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status - - 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( """select t1.name diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 750466a4a40..bcb194bd23c 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -135,7 +135,12 @@ frappe.ui.form.on("Pick List", { if (frm.doc.purpose === "Delivery") { frm.add_custom_button( __("Delivery Note"), - () => frm.trigger("create_delivery_note"), + () => frm.events.create_delivery(frm, "Delivery Note"), + __("Create") + ); + frm.add_custom_button( + __("Sales Invoice"), + () => frm.events.create_delivery(frm, "Sales Invoice"), __("Create") ); } else { @@ -232,9 +237,12 @@ frappe.ui.form.on("Pick List", { frm.clear_table("locations"); frm.trigger("add_get_items_button"); }, - create_delivery_note: (frm) => { + create_delivery(frm, doctype) { frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.pick_list.pick_list.create_delivery_note", + method: "erpnext.stock.doctype.pick_list.pick_list.create_delivery", + args: { + target: doctype, + }, frm: frm, }); }, diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index ad26b73c119..7d5baa663d1 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -17,6 +17,9 @@ 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, ) +from erpnext.selling.doctype.sales_order.sales_order import ( + make_sales_invoice as create_sales_invoice_from_sales_order, +) from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( get_auto_batch_nos, ) @@ -1284,11 +1287,17 @@ def get_available_item_locations_for_other_item( @frappe.whitelist() def create_delivery_note(source_name, target_doc=None): + return create_delivery(source_name, target_doc, "Delivery Note") + + +@frappe.whitelist() +def create_delivery(source_name, target_doc=None, target=None): pick_list = frappe.get_doc("Pick List", source_name) + target = target or (frappe.flags.args or {}).get("target") or "Delivery Note" validate_item_locations(pick_list) sales_dict = dict() sales_orders = [] - delivery_notes = [] + documents = [] for location in pick_list.locations: if location.sales_order: sales_orders.append( @@ -1318,39 +1327,45 @@ def create_delivery_note(source_name, target_doc=None): sales_dict[key] = {row.sales_order for row in rows} if sales_dict: - delivery_notes.extend(create_dn_with_so(sales_dict, pick_list)) + documents.extend(create_delivery_with_so(sales_dict, pick_list, target)) if not all(item.sales_order for item in pick_list.locations): - delivery_notes.append(create_dn_wo_so(pick_list)) + documents.append(create_delivery_wo_so(pick_list, target, target_doc)) - if len(delivery_notes) == 1: - return delivery_notes[0] + if len(documents) == 1: + return documents[0] else: from frappe.utils import comma_and - doc_list = [get_link_to_form("Delivery Note", p.name) for p in delivery_notes] + doc_list = [get_link_to_form(target, p.name) for p in documents] frappe.msgprint(_("{0} created").format(comma_and(doc_list))) def create_dn_wo_so(pick_list, delivery_note=None): - if not delivery_note: - delivery_note = frappe.new_doc("Delivery Note") + return create_delivery_wo_so(pick_list, "Delivery Note", delivery_note) - delivery_note.company = pick_list.company + +def create_delivery_wo_so(pick_list, target, target_doc=None): + if not target_doc: + target_doc = frappe.new_doc(target) + + target_doc.company = pick_list.company item_table_mapper_without_so = { - "doctype": "Delivery Note Item", + "doctype": f"{target} Item", "field_map": { "rate": "rate", "name": "name", "parent": "", }, } - map_pl_locations(pick_list, item_table_mapper_without_so, delivery_note) - delivery_note.flags.ignore_mandatory = True - delivery_note.save() + map_pl_locations(pick_list, item_table_mapper_without_so, target_doc) + target_doc.flags.ignore_mandatory = True + if target == "Sales Invoice": + target_doc.update_stock = 1 + target_doc.save() - return delivery_note + return target_doc @frappe.whitelist() @@ -1379,36 +1394,53 @@ def create_dn_for_pick_lists(source_name, target_doc=None, kwargs=None): pluck="name", ) - delivery_note = create_dn_from_so(pick_list, sales_orders, delivery_note=target_doc, kwargs=kwargs) + delivery_note = create_delivery_from_so( + pick_list, sales_orders, "Delivery Note", target_doc=target_doc, kwargs=kwargs + ) if not sales_order_arg and not all(item.sales_order for item in pick_list.locations): if isinstance(delivery_note, str): delivery_note = frappe.get_doc(frappe.parse_json(delivery_note)) - delivery_note = create_dn_wo_so(pick_list, delivery_note) + delivery_note = create_delivery_wo_so(pick_list, "Delivery Note", delivery_note) return delivery_note def create_dn_with_so(sales_dict, pick_list): - """Create Delivery Note for each customer (based on SO) in a Pick List.""" - delivery_notes = [] + return create_delivery_with_so(sales_dict, pick_list, "Delivery Note") + + +def create_delivery_with_so(sales_dict, pick_list, target): + """Create target document for each customer (based on SO) in a Pick List.""" + documents = [] for key in sales_dict: - delivery_note = create_dn_from_so(pick_list, sales_dict[key], None) - if delivery_note: - delivery_note.flags.ignore_mandatory = True + document = create_delivery_from_so(pick_list, sales_dict[key], target) + if document: + document.flags.ignore_mandatory = True # updates packed_items on save # save as multiple customers are possible - delivery_note.save() - delivery_notes.append(delivery_note) + if target == "Sales Invoice": + document.update_stock = 1 + document.save() + documents.append(document) - return delivery_notes + return documents def create_dn_from_so(pick_list, sales_order_list, delivery_note=None, kwargs=None): + return create_delivery_from_so( + pick_list, sales_order_list, "Delivery Note", target_doc=delivery_note, kwargs=kwargs + ) + + +def create_delivery_from_so(pick_list, sales_order_list, target, target_doc=None, kwargs=None): if not sales_order_list: - return delivery_note + return target_doc + + if kwargs is None: + kwargs = {} def select_item(d): filtered_items = kwargs.get("filtered_children", []) @@ -1416,11 +1448,11 @@ def create_dn_from_so(pick_list, sales_order_list, delivery_note=None, kwargs=No return child_filter item_table_mapper = { - "doctype": "Delivery Note Item", + "doctype": f"{target} Item", "field_map": { "rate": "rate", "name": "so_detail", - "parent": "against_sales_order", + "parent": "against_sales_order" if target == "Delivery Note" else "sales_order", }, "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1 @@ -1429,20 +1461,22 @@ def create_dn_from_so(pick_list, sales_order_list, delivery_note=None, kwargs=No 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 + target_doc = ( + create_delivery_note_from_sales_order(next(iter(sales_order_list)), target_doc, kwargs=kwargs) + if target == "Delivery Note" + else create_sales_invoice_from_sales_order(next(iter(sales_order_list)), target_doc, args=kwargs) ) - if not delivery_note: + if not target_doc: return for so in sales_order_list: - map_pl_locations(pick_list, item_table_mapper, delivery_note, so) + map_pl_locations(pick_list, item_table_mapper, target_doc, so) - return delivery_note + return target_doc -def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): +def map_pl_locations(pick_list, item_mapper, target_doc, sales_order=None): for location in pick_list.locations: if location.sales_order != sales_order or location.product_bundle_item: continue @@ -1454,36 +1488,44 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): source_doc = sales_order_item or location - dn_item = map_child_doc(source_doc, delivery_note, item_mapper) + child_item = map_child_doc(source_doc, target_doc, 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 - location.delivered_qty) / ( - flt(dn_item.conversion_factor) or 1 + if child_item: + child_item.against_pick_list = pick_list.name + child_item.pick_list_item = location.name + child_item.warehouse = location.warehouse + child_item.qty = flt(location.picked_qty - location.delivered_qty) / ( + flt(child_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 + child_item.batch_no = location.batch_no + child_item.serial_no = location.serial_no + child_item.use_serial_batch_fields = location.use_serial_batch_fields - update_delivery_note_item(source_doc, dn_item, delivery_note) + if not child_item.qty: + target_doc.items.remove(child_item) + continue - add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order) - set_delivery_note_missing_values(delivery_note) + update_child_item(source_doc, child_item, target_doc) - delivery_note.company = pick_list.company + add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_order) + set_target_missing_values(target_doc) + + target_doc.company = pick_list.company if sales_order: - delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") + target_doc.customer = frappe.get_value("Sales Order", sales_order, "customer") 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. + return add_product_bundles_to_target(pick_list, delivery_note, item_mapper, sales_order) + + +def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_order=None) -> None: + """Add product bundles found in pick list to target document. When mapping pick list items, the bundle item itself isn't part of the - locations. Dynamically fetch and add parent bundle item into DN.""" + locations. Dynamically fetch and add parent bundle item into target document.""" product_bundles = pick_list._get_product_bundles() product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values()) @@ -1492,13 +1534,13 @@ def add_product_bundles_to_delivery_note( 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( + target_bundle_item = map_child_doc(sales_order_item, target_doc, item_mapper) + target_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( so_row, product_bundle_qty_map[value.item_code] ) - dn_bundle_item.pick_list_item = value.pick_list_item - dn_bundle_item.against_pick_list = pick_list.name - update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note) + target_bundle_item.pick_list_item = value.pick_list_item + target_bundle_item.against_pick_list = pick_list.name + update_child_item(sales_order_item, target_bundle_item, target_doc) @frappe.whitelist() @@ -1572,12 +1614,16 @@ def get_actual_qty(item_code, warehouse): def update_delivery_note_item(source, target, delivery_note): - cost_center = frappe.db.get_value("Project", delivery_note.project, "cost_center") + return update_child_item(source, target, delivery_note) + + +def update_child_item(source, target, target_doc): + cost_center = frappe.db.get_value("Project", target_doc.project, "cost_center") if not cost_center: - cost_center = get_cost_center(source.item_code, "Item", delivery_note.company) + cost_center = get_cost_center(source.item_code, "Item", target_doc.company) if not cost_center: - cost_center = get_cost_center(source.item_group, "Item Group", delivery_note.company) + cost_center = get_cost_center(source.item_group, "Item Group", target_doc.company) target.cost_center = cost_center @@ -1592,6 +1638,10 @@ def get_cost_center(for_item, from_doctype, company): def set_delivery_note_missing_values(target): + return set_target_missing_values(target) + + +def set_target_missing_values(target): target.run_method("set_missing_values") target.run_method("set_po_nos") target.run_method("calculate_taxes_and_totals") diff --git a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py index b6159d2d70c..c722db33d37 100644 --- a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py +++ b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py @@ -7,6 +7,7 @@ def get_data(): "non_standard_fieldnames": { "Stock Reservation Entry": "from_voucher_no", "Delivery Note": "against_pick_list", + "Sales Invoice": "against_pick_list", }, "internal_links": { "Sales Order": ["locations", "sales_order"], @@ -14,7 +15,7 @@ def get_data(): "transactions": [ { "label": _("Sales"), - "items": ["Sales Order", "Delivery Note"], + "items": ["Sales Order", "Delivery Note", "Sales Invoice"], }, { "label": _("Manufacturing"), diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 85a45f1686b..ad6081dbb6b 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -9,7 +9,11 @@ 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, create_dn_for_pick_lists +from erpnext.stock.doctype.pick_list.pick_list import ( + create_delivery, + create_delivery_note, + create_dn_for_pick_lists, +) from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( get_batch_from_bundle, @@ -768,6 +772,198 @@ class TestPickList(ERPNextTestSuite): if dn_item.item_code == "_Test Item 2": self.assertEqual(dn_item.qty, 2) + @ERPNextTestSuite.change_settings("Stock Settings", {"use_serial_batch_fields": 1}) + def test_sales_invoice_from_pick_list_copies_old_batch_serial_fields(self): + warehouse = "_Test Warehouse - _TC" + item = make_item( + f"_Test PLSI Old Fields {frappe.generate_hash(length=8)}", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "has_serial_no": 1, + "batch_number_series": f"PLSI-OLD-B-{frappe.generate_hash(length=6)}-.#####", + "serial_no_series": f"PLSI-OLD-S-{frappe.generate_hash(length=6)}-.#####", + }, + ).name + + make_stock_entry(item=item, to_warehouse=warehouse, qty=2, basic_rate=100) + sales_order = make_sales_order(item_code=item, warehouse=warehouse, qty=2, rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + pick_list_item = pick_list.locations[0] + + self.assertTrue(pick_list_item.use_serial_batch_fields) + self.assertTrue(pick_list_item.batch_no) + self.assertTrue(pick_list_item.serial_no) + + sales_invoice = create_delivery(pick_list.name, target="Sales Invoice") + sales_invoice_item = sales_invoice.items[0] + + self.assertEqual(sales_invoice.update_stock, 1) + self.assertEqual(sales_invoice_item.against_pick_list, pick_list.name) + self.assertEqual(sales_invoice_item.pick_list_item, pick_list_item.name) + self.assertEqual(sales_invoice_item.use_serial_batch_fields, 1) + self.assertEqual(sales_invoice_item.batch_no, pick_list_item.batch_no) + self.assertEqual( + set(sales_invoice_item.serial_no.split("\n")), set(pick_list_item.serial_no.split("\n")) + ) + + @ERPNextTestSuite.change_settings("Stock Settings", {"use_serial_batch_fields": 0}) + def test_sales_invoice_from_pick_list_copies_serial_and_batch_bundle(self): + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) + + warehouse = "_Test Warehouse - _TC" + item = make_item( + f"_Test PLSI Bundle {frappe.generate_hash(length=8)}", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "has_serial_no": 1, + "batch_number_series": f"PLSI-BND-B-{frappe.generate_hash(length=6)}-.#####", + "serial_no_series": f"PLSI-BND-S-{frappe.generate_hash(length=6)}-.#####", + }, + ).name + + stock_entry = make_stock_entry(item=item, to_warehouse=warehouse, qty=2, basic_rate=100) + batch_no = get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle) + serial_nos = get_serial_nos_from_bundle(stock_entry.items[0].serial_and_batch_bundle) + sales_order = make_sales_order(item_code=item, warehouse=warehouse, qty=2, rate=100) + + pick_list = frappe.get_doc( + { + "doctype": "Pick List", + "company": "_Test Company", + "customer": "_Test Customer", + "items_based_on": "Sales Order", + "purpose": "Delivery", + "pick_manually": 1, + "locations": [ + { + "item_code": item, + "warehouse": warehouse, + "qty": 2, + "stock_qty": 2, + "picked_qty": 2, + "conversion_factor": 1, + "sales_order": sales_order.name, + "sales_order_item": sales_order.items[0].name, + "use_serial_batch_fields": 0, + } + ], + } + ).insert() + + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + pick_list.locations[0].serial_and_batch_bundle = ( + SerialBatchCreation( + { + "item_code": item, + "warehouse": warehouse, + "voucher_type": "Pick List", + "voucher_no": pick_list.name, + "voucher_detail_no": pick_list.locations[0].name, + "qty": -2, + "batches": frappe._dict({batch_no: 2}), + "serial_nos": serial_nos, + "type_of_transaction": "Outward", + "company": "_Test Company", + "do_not_submit": True, + } + ) + .make_serial_and_batch_bundle() + .name + ) + pick_list.locations[0].db_set( + { + "use_serial_batch_fields": 0, + "batch_no": None, + "serial_no": None, + "serial_and_batch_bundle": pick_list.locations[0].serial_and_batch_bundle, + } + ) + pick_list.reload() + pick_list_item = pick_list.locations[0] + + self.assertFalse(pick_list_item.use_serial_batch_fields) + self.assertTrue(pick_list_item.serial_and_batch_bundle) + + sales_invoice = create_delivery(pick_list.name, target="Sales Invoice") + sales_invoice_item = sales_invoice.items[0] + + self.assertEqual(sales_invoice_item.against_pick_list, pick_list.name) + self.assertEqual(sales_invoice_item.pick_list_item, pick_list_item.name) + self.assertFalse(sales_invoice_item.use_serial_batch_fields) + self.assertTrue(sales_invoice_item.serial_and_batch_bundle) + self.assertNotEqual( + sales_invoice_item.serial_and_batch_bundle, pick_list_item.serial_and_batch_bundle + ) + self.assertEqual( + get_batch_from_bundle(sales_invoice_item.serial_and_batch_bundle), + get_batch_from_bundle(pick_list_item.serial_and_batch_bundle), + ) + self.assertEqual( + set(get_serial_nos_from_bundle(sales_invoice_item.serial_and_batch_bundle)), + set(get_serial_nos_from_bundle(pick_list_item.serial_and_batch_bundle)), + ) + + def test_sales_invoice_from_sales_order_pick_list_updates_sales_order(self): + warehouse = "_Test Warehouse - _TC" + item = make_item().name + + make_stock_entry(item=item, to_warehouse=warehouse, qty=5, basic_rate=100) + sales_order = make_sales_order(item_code=item, warehouse=warehouse, qty=5, rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + + sales_invoice = create_delivery(pick_list.name, target="Sales Invoice") + sales_invoice_item = sales_invoice.items[0] + + self.assertEqual(sales_invoice_item.sales_order, sales_order.name) + self.assertEqual(sales_invoice_item.so_detail, sales_order.items[0].name) + self.assertEqual(sales_invoice_item.against_pick_list, pick_list.name) + self.assertEqual(sales_invoice_item.pick_list_item, pick_list.locations[0].name) + + sales_invoice.submit() + pick_list.reload() + sales_order.reload() + + self.assertEqual(pick_list.locations[0].delivered_qty, pick_list.locations[0].picked_qty) + self.assertEqual(pick_list.per_delivered, 100) + self.assertEqual(pick_list.delivery_status, "Fully Delivered") + self.assertEqual(pick_list.status, "Completed") + + self.assertEqual(sales_order.items[0].picked_qty, 5) + self.assertEqual(sales_order.items[0].delivered_qty, 5) + self.assertEqual(sales_order.per_delivered, 100) + self.assertEqual(sales_order.delivery_status, "Fully Delivered") + self.assertEqual(sales_order.per_billed, 100) + self.assertEqual(sales_order.billing_status, "Fully Billed") + self.assertEqual(sales_order.status, "Completed") + + def test_sales_invoice_against_pick_list_requires_update_stock(self): + warehouse = "_Test Warehouse - _TC" + item = make_item().name + + make_stock_entry(item=item, to_warehouse=warehouse, qty=5, basic_rate=100) + sales_order = make_sales_order(item_code=item, warehouse=warehouse, qty=5, rate=100) + + pick_list = create_pick_list(sales_order.name) + pick_list.submit() + + sales_invoice = create_delivery(pick_list.name, target="Sales Invoice") + sales_invoice.update_stock = 0 + + self.assertRaisesRegex( + frappe.ValidationError, + "Update Stock.*Pick List", + sales_invoice.save, + ) + def test_picklist_reserved_qty_validation(self): from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order