diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 5753eba8cc1..7345a5ef78d 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -460,6 +460,8 @@ class SalesInvoice(SellingController): self.make_bundle_for_sales_purchase_return(table_name) self.make_bundle_using_old_serial_batch_fields(table_name) + + self.update_stock_reservation_entries() self.update_stock_ledger() # this sequence because outstanding may get -ve @@ -561,6 +563,7 @@ class SalesInvoice(SellingController): self.make_gl_entries_on_cancel() if self.update_stock == 1: + self.update_stock_reservation_entries() self.repost_future_sle_and_gle() self.db_set("status", "Cancelled") diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index f79d83d5b09..4b5b28c05fa 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -791,6 +791,151 @@ class SellingController(StockController): validate_item_type(self, "is_sales_item", "sales") + def update_stock_reservation_entries(self) -> None: + """Updates Delivered Qty in Stock Reservation Entries.""" + + # Don't update Delivered Qty on Return. + if self.is_return: + return + + so_field = "sales_order" if self.doctype == "Sales Invoice" else "against_sales_order" + + if self._action == "submit": + for item in self.get("items"): + # Skip if `Sales Order` or `Sales Order Item` reference is not set. + if not item.get(so_field) or not item.so_detail: + continue + + sre_list = frappe.db.get_all( + "Stock Reservation Entry", + { + "docstatus": 1, + "voucher_type": "Sales Order", + "voucher_no": item.get(so_field), + "voucher_detail_no": item.so_detail, + "warehouse": item.warehouse, + "status": ["not in", ["Delivered", "Cancelled"]], + }, + order_by="creation", + ) + + # Skip if no Stock Reservation Entries. + if not sre_list: + continue + + qty_to_deliver = item.stock_qty + for sre in sre_list: + if qty_to_deliver <= 0: + break + + sre_doc = frappe.get_doc("Stock Reservation Entry", sre) + + qty_can_be_deliver = 0 + if sre_doc.reservation_based_on == "Serial and Batch": + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + if sre_doc.has_serial_no: + delivered_serial_nos = [d.serial_no for d in sbb.entries] + for entry in sre_doc.sb_entries: + if entry.serial_no in delivered_serial_nos: + entry.delivered_qty = 1 # Qty will always be 0 or 1 for Serial No. + entry.db_update() + qty_can_be_deliver += 1 + delivered_serial_nos.remove(entry.serial_no) + else: + delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries} + for entry in sre_doc.sb_entries: + if entry.batch_no in delivered_batch_qty: + delivered_qty = min( + (entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no] + ) + entry.delivered_qty += delivered_qty + entry.db_update() + qty_can_be_deliver += delivered_qty + delivered_batch_qty[entry.batch_no] -= delivered_qty + else: + # `Delivered Qty` should be less than or equal to `Reserved Qty`. + qty_can_be_deliver = min( + (sre_doc.reserved_qty - sre_doc.delivered_qty), qty_to_deliver + ) + + sre_doc.delivered_qty += qty_can_be_deliver + sre_doc.db_update() + + # Update Stock Reservation Entry `Status` based on `Delivered Qty`. + sre_doc.update_status() + + # Update Reserved Stock in Bin. + sre_doc.update_reserved_stock_in_bin() + + qty_to_deliver -= qty_can_be_deliver + + if self._action == "cancel": + for item in self.get("items"): + # Skip if `Sales Order` or `Sales Order Item` reference is not set. + if not item.get(so_field) or not item.so_detail: + continue + + sre_list = frappe.db.get_all( + "Stock Reservation Entry", + { + "docstatus": 1, + "voucher_type": "Sales Order", + "voucher_no": item.get(so_field), + "voucher_detail_no": item.so_detail, + "warehouse": item.warehouse, + "status": ["in", ["Partially Delivered", "Delivered"]], + }, + order_by="creation", + ) + + # Skip if no Stock Reservation Entries. + if not sre_list: + continue + + qty_to_undelivered = item.stock_qty + for sre in sre_list: + if qty_to_undelivered <= 0: + break + + sre_doc = frappe.get_doc("Stock Reservation Entry", sre) + + qty_can_be_undelivered = 0 + if sre_doc.reservation_based_on == "Serial and Batch": + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + if sre_doc.has_serial_no: + serial_nos_to_undelivered = [d.serial_no for d in sbb.entries] + for entry in sre_doc.sb_entries: + if entry.serial_no in serial_nos_to_undelivered: + entry.delivered_qty = 0 # Qty will always be 0 or 1 for Serial No. + entry.db_update() + qty_can_be_undelivered += 1 + serial_nos_to_undelivered.remove(entry.serial_no) + else: + batch_qty_to_undelivered = {d.batch_no: -1 * d.qty for d in sbb.entries} + for entry in sre_doc.sb_entries: + if entry.batch_no in batch_qty_to_undelivered: + undelivered_qty = min( + entry.delivered_qty, batch_qty_to_undelivered[entry.batch_no] + ) + entry.delivered_qty -= undelivered_qty + entry.db_update() + qty_can_be_undelivered += undelivered_qty + batch_qty_to_undelivered[entry.batch_no] -= undelivered_qty + else: + # `Qty to Undelivered` should be less than or equal to `Delivered Qty`. + qty_can_be_undelivered = min(sre_doc.delivered_qty, qty_to_undelivered) + + sre_doc.delivered_qty -= qty_can_be_undelivered + sre_doc.db_update() + + # Update Stock Reservation Entry `Status` based on `Delivered Qty`. + sre_doc.update_status() + + # Update Reserved Stock in Bin. + sre_doc.update_reserved_stock_in_bin() + + qty_to_undelivered -= qty_can_be_undelivered + def set_default_income_account_for_item(obj): for d in obj.get("items"): diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 47d42b0a9d5..e4fc1b6fe53 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -2119,6 +2119,123 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertRaises(frappe.ValidationError, so1.update_status, "Draft") +<<<<<<< HEAD +======= + @IntegrationTestCase.change_settings("Stock Settings", {"enable_stock_reservation": True}) + def test_warehouse_mapping_based_on_stock_reservation(self): + self.create_company(company_name="Glass Ceiling", abbr="GC") + self.create_item("Lamy Safari 2", True, self.warehouse_stores, self.company, 2000) + self.create_customer() + self.clear_old_entries() + + so = frappe.new_doc("Sales Order") + so.company = self.company + so.customer = self.customer + so.transaction_date = today() + so.append( + "items", + { + "item_code": self.item, + "qty": 10, + "rate": 2000, + "warehouse": self.warehouse_stores, + "delivery_date": today(), + }, + ) + so.submit() + + # Create stock + se = frappe.get_doc( + { + "doctype": "Stock Entry", + "company": self.company, + "stock_entry_type": "Material Receipt", + "posting_date": today(), + "items": [ + {"item_code": self.item, "t_warehouse": self.warehouse_stores, "qty": 5}, + {"item_code": self.item, "t_warehouse": self.warehouse_finished_goods, "qty": 5}, + ], + } + ) + se.submit() + + # Reserve stock on 2 different warehouses + itm = so.items[0] + so.create_stock_reservation_entries( + [ + { + "sales_order_item": itm.name, + "item_code": itm.item_code, + "warehouse": self.warehouse_stores, + "qty_to_reserve": 2, + } + ] + ) + so.create_stock_reservation_entries( + [ + { + "sales_order_item": itm.name, + "item_code": itm.item_code, + "warehouse": self.warehouse_finished_goods, + "qty_to_reserve": 3, + } + ] + ) + + # Delivery note should auto-select warehouse based on reservation + dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + self.assertEqual(2, len(dn.items)) + self.assertEqual(dn.items[0].qty, 2) + self.assertEqual(dn.items[0].warehouse, self.warehouse_stores) + self.assertEqual(dn.items[1].qty, 3) + self.assertEqual(dn.items[1].warehouse, self.warehouse_finished_goods) + + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + warehouse = create_warehouse("Test Warehouse 1", company=self.company) + + make_stock_entry( + item_code=self.item, + target=warehouse, + qty=5, + company=self.company, + ) + + so = frappe.new_doc("Sales Order") + so.reserve_stock = 1 + so.company = self.company + so.customer = self.customer + so.transaction_date = today() + so.currency = "INR" + so.append( + "items", + { + "item_code": self.item, + "qty": 5, + "rate": 2000, + "warehouse": warehouse, + "delivery_date": today(), + }, + ) + so.submit() + + sres = frappe.get_all( + "Stock Reservation Entry", + filters={"voucher_no": so.name}, + fields=["name"], + ) + + self.assertEqual(len(sres), 1) + sre_doc = frappe.get_doc("Stock Reservation Entry", sres[0].name) + self.assertFalse(sre_doc.status == "Delivered") + + si = make_sales_invoice(so.name) + si.update_stock = 1 + si.submit() + sre_doc.reload() + self.assertTrue(sre_doc.status == "Delivered") + +>>>>>>> 0c9d0ea1f4 (fix: stock reservation not working for sales invoice with update stock) def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 39527c9ec47..ba04abce8f3 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -491,149 +491,6 @@ class DeliveryNote(SellingController): self.delete_auto_created_batches() - def update_stock_reservation_entries(self) -> None: - """Updates Delivered Qty in Stock Reservation Entries.""" - - # Don't update Delivered Qty on Return. - if self.is_return: - return - - if self._action == "submit": - for item in self.get("items"): - # Skip if `Sales Order` or `Sales Order Item` reference is not set. - if not item.against_sales_order or not item.so_detail: - continue - - sre_list = frappe.db.get_all( - "Stock Reservation Entry", - { - "docstatus": 1, - "voucher_type": "Sales Order", - "voucher_no": item.against_sales_order, - "voucher_detail_no": item.so_detail, - "warehouse": item.warehouse, - "status": ["not in", ["Delivered", "Cancelled"]], - }, - order_by="creation", - ) - - # Skip if no Stock Reservation Entries. - if not sre_list: - continue - - qty_to_deliver = item.stock_qty - for sre in sre_list: - if qty_to_deliver <= 0: - break - - sre_doc = frappe.get_doc("Stock Reservation Entry", sre) - - qty_can_be_deliver = 0 - if sre_doc.reservation_based_on == "Serial and Batch": - sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) - if sre_doc.has_serial_no: - delivered_serial_nos = [d.serial_no for d in sbb.entries] - for entry in sre_doc.sb_entries: - if entry.serial_no in delivered_serial_nos: - entry.delivered_qty = 1 # Qty will always be 0 or 1 for Serial No. - entry.db_update() - qty_can_be_deliver += 1 - delivered_serial_nos.remove(entry.serial_no) - else: - delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries} - for entry in sre_doc.sb_entries: - if entry.batch_no in delivered_batch_qty: - delivered_qty = min( - (entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no] - ) - entry.delivered_qty += delivered_qty - entry.db_update() - qty_can_be_deliver += delivered_qty - delivered_batch_qty[entry.batch_no] -= delivered_qty - else: - # `Delivered Qty` should be less than or equal to `Reserved Qty`. - qty_can_be_deliver = min( - (sre_doc.reserved_qty - sre_doc.delivered_qty), qty_to_deliver - ) - - sre_doc.delivered_qty += qty_can_be_deliver - sre_doc.db_update() - - # Update Stock Reservation Entry `Status` based on `Delivered Qty`. - sre_doc.update_status() - - # Update Reserved Stock in Bin. - sre_doc.update_reserved_stock_in_bin() - - qty_to_deliver -= qty_can_be_deliver - - if self._action == "cancel": - for item in self.get("items"): - # Skip if `Sales Order` or `Sales Order Item` reference is not set. - if not item.against_sales_order or not item.so_detail: - continue - - sre_list = frappe.db.get_all( - "Stock Reservation Entry", - { - "docstatus": 1, - "voucher_type": "Sales Order", - "voucher_no": item.against_sales_order, - "voucher_detail_no": item.so_detail, - "warehouse": item.warehouse, - "status": ["in", ["Partially Delivered", "Delivered"]], - }, - order_by="creation", - ) - - # Skip if no Stock Reservation Entries. - if not sre_list: - continue - - qty_to_undelivered = item.stock_qty - for sre in sre_list: - if qty_to_undelivered <= 0: - break - - sre_doc = frappe.get_doc("Stock Reservation Entry", sre) - - qty_can_be_undelivered = 0 - if sre_doc.reservation_based_on == "Serial and Batch": - sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) - if sre_doc.has_serial_no: - serial_nos_to_undelivered = [d.serial_no for d in sbb.entries] - for entry in sre_doc.sb_entries: - if entry.serial_no in serial_nos_to_undelivered: - entry.delivered_qty = 0 # Qty will always be 0 or 1 for Serial No. - entry.db_update() - qty_can_be_undelivered += 1 - serial_nos_to_undelivered.remove(entry.serial_no) - else: - batch_qty_to_undelivered = {d.batch_no: -1 * d.qty for d in sbb.entries} - for entry in sre_doc.sb_entries: - if entry.batch_no in batch_qty_to_undelivered: - undelivered_qty = min( - entry.delivered_qty, batch_qty_to_undelivered[entry.batch_no] - ) - entry.delivered_qty -= undelivered_qty - entry.db_update() - qty_can_be_undelivered += undelivered_qty - batch_qty_to_undelivered[entry.batch_no] -= undelivered_qty - else: - # `Qty to Undelivered` should be less than or equal to `Delivered Qty`. - qty_can_be_undelivered = min(sre_doc.delivered_qty, qty_to_undelivered) - - sre_doc.delivered_qty -= qty_can_be_undelivered - sre_doc.db_update() - - # Update Stock Reservation Entry `Status` based on `Delivered Qty`. - sre_doc.update_status() - - # Update Reserved Stock in Bin. - sre_doc.update_reserved_stock_in_bin() - - qty_to_undelivered -= qty_can_be_undelivered - def validate_against_stock_reservation_entries(self): """Validates if Stock Reservation Entries are available for the Sales Order Item reference."""