diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py
index de3d57d095a8..f5a980019bd8 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/gross_profit.py
@@ -965,7 +965,7 @@ def get_stock_ledger_entries(self, item_code, warehouse):
& (sle.is_cancelled == 0)
)
.orderby(sle.item_code)
- .orderby(sle.warehouse, sle.posting_date, sle.posting_time, sle.creation, order=Order.desc)
+ .orderby(sle.warehouse, sle.posting_datetime, sle.creation, order=Order.desc)
.run(as_dict=True)
)
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 4f1f967f202e..b6520d3c82d7 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -931,46 +931,6 @@ def get_currency_precision():
return precision
-def get_stock_rbnb_difference(posting_date, company):
- stock_items = frappe.db.sql_list(
- """select distinct item_code
- from `tabStock Ledger Entry` where company=%s""",
- company,
- )
-
- pr_valuation_amount = frappe.db.sql(
- """
- select sum(pr_item.valuation_rate * pr_item.qty * pr_item.conversion_factor)
- from `tabPurchase Receipt Item` pr_item, `tabPurchase Receipt` pr
- where pr.name = pr_item.parent and pr.docstatus=1 and pr.company=%s
- and pr.posting_date <= %s and pr_item.item_code in (%s)"""
- % ("%s", "%s", ", ".join(["%s"] * len(stock_items))),
- tuple([company, posting_date] + stock_items),
- )[0][0]
-
- pi_valuation_amount = frappe.db.sql(
- """
- select sum(pi_item.valuation_rate * pi_item.qty * pi_item.conversion_factor)
- from `tabPurchase Invoice Item` pi_item, `tabPurchase Invoice` pi
- where pi.name = pi_item.parent and pi.docstatus=1 and pi.company=%s
- and pi.posting_date <= %s and pi_item.item_code in (%s)"""
- % ("%s", "%s", ", ".join(["%s"] * len(stock_items))),
- tuple([company, posting_date] + stock_items),
- )[0][0]
-
- # Balance should be
- stock_rbnb = flt(pr_valuation_amount, 2) - flt(pi_valuation_amount, 2)
-
- # Balance as per system
- stock_rbnb_account = "Stock Received But Not Billed - " + frappe.get_cached_value(
- "Company", company, "abbr"
- )
- sys_bal = get_balance_on(stock_rbnb_account, posting_date, in_account_currency=False)
-
- # Amount should be credited
- return flt(stock_rbnb) + flt(sys_bal)
-
-
def get_held_invoices(party_type, party):
"""
Returns a list of names Purchase Invoices for the given party that are on hold
@@ -1372,8 +1332,7 @@ def sort_stock_vouchers_by_posting_date(
.select(sle.voucher_type, sle.voucher_no, sle.posting_date, sle.posting_time, sle.creation)
.where((sle.is_cancelled == 0) & (sle.voucher_no.isin(voucher_nos)))
.groupby(sle.voucher_type, sle.voucher_no)
- .orderby(sle.posting_date)
- .orderby(sle.posting_time)
+ .orderby(sle.posting_datetime)
.orderby(sle.creation)
).run(as_dict=True)
sorted_vouchers = [(sle.voucher_type, sle.voucher_no) for sle in sles]
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index e281fbc1ec99..03190e7b965a 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -978,8 +978,7 @@ def get_valuation_rate(data):
frappe.qb.from_(sle)
.select(sle.valuation_rate)
.where((sle.item_code == item_code) & (sle.valuation_rate > 0) & (sle.is_cancelled == 0))
- .orderby(sle.posting_date, order=frappe.qb.desc)
- .orderby(sle.posting_time, order=frappe.qb.desc)
+ .orderby(sle.posting_datetime, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
.limit(1)
).run(as_dict=True)
diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
index 97f30ef62e9d..8d3770805e61 100644
--- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
+++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
@@ -58,7 +58,7 @@ def get_data(filters):
query_filters["creation"] = ("between", [filters.get("from_date"), filters.get("to_date")])
data = frappe.get_all(
- "Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc", debug=1
+ "Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc"
)
res = []
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 9b3cf5d7bc70..e67dad0e8ba3 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -274,6 +274,7 @@ erpnext.patches.v14_0.clear_reconciliation_values_from_singles
[post_model_sync]
execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings')
+erpnext.patches.v14_0.update_posting_datetime_and_dropped_indexes
erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents
erpnext.patches.v14_0.delete_shopify_doctypes
erpnext.patches.v14_0.delete_healthcare_doctypes
@@ -361,4 +362,4 @@ erpnext.stock.doctype.delivery_note.patches.drop_unused_return_against_index # 2
erpnext.patches.v14_0.set_maintain_stock_for_bom_item
execute:frappe.db.set_single_value('E Commerce Settings', 'show_actual_qty', 1)
erpnext.patches.v14_0.delete_orphaned_asset_movement_item_records
-erpnext.patches.v14_0.remove_cancelled_asset_capitalization_from_asset
+erpnext.patches.v14_0.remove_cancelled_asset_capitalization_from_asset
\ No newline at end of file
diff --git a/erpnext/patches/v14_0/update_posting_datetime_and_dropped_indexes.py b/erpnext/patches/v14_0/update_posting_datetime_and_dropped_indexes.py
new file mode 100644
index 000000000000..6ec3f842007e
--- /dev/null
+++ b/erpnext/patches/v14_0/update_posting_datetime_and_dropped_indexes.py
@@ -0,0 +1,19 @@
+import frappe
+
+
+def execute():
+ frappe.db.sql(
+ """
+ UPDATE `tabStock Ledger Entry`
+ SET posting_datetime = timestamp(posting_date, posting_time)
+ """
+ )
+
+ drop_indexes()
+
+
+def drop_indexes():
+ if not frappe.db.has_index("tabStock Ledger Entry", "posting_sort_index"):
+ return
+
+ frappe.db.sql_ddl("ALTER TABLE `tabStock Ledger Entry` DROP INDEX `posting_sort_index`")
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 7defbc5bcdfa..0d244da1fdc2 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -3,7 +3,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
-from frappe.utils import add_days, cint, cstr, flt, today
+from frappe.utils import add_days, cint, cstr, flt, today, nowtime
from pypika import functions as fn
import erpnext
@@ -2224,6 +2224,95 @@ def test_pr_billed_amount_against_return_entry(self):
pr.reload()
self.assertEqual(pr.per_billed, 100)
+ def test_sle_qty_after_transaction(self):
+ item = make_item(
+ "_Test Item Qty After Transaction",
+ properties={"is_stock_item": 1, "valuation_method": "FIFO"},
+ ).name
+
+ posting_date = today()
+ posting_time = nowtime()
+
+ # Step 1: Create Purchase Receipt
+ pr = make_purchase_receipt(
+ item_code=item,
+ qty=1,
+ rate=100,
+ posting_date=posting_date,
+ posting_time=posting_time,
+ do_not_save=1,
+ )
+
+ for i in range(9):
+ pr.append(
+ "items",
+ {
+ "item_code": item,
+ "qty": 1,
+ "rate": 100,
+ "warehouse": pr.items[0].warehouse,
+ "cost_center": pr.items[0].cost_center,
+ "expense_account": pr.items[0].expense_account,
+ "uom": pr.items[0].uom,
+ "stock_uom": pr.items[0].stock_uom,
+ "conversion_factor": pr.items[0].conversion_factor,
+ },
+ )
+
+ self.assertEqual(len(pr.items), 10)
+ pr.save()
+ pr.submit()
+
+ data = frappe.get_all(
+ "Stock Ledger Entry",
+ fields=["qty_after_transaction", "creation", "posting_datetime"],
+ filters={"voucher_no": pr.name, "is_cancelled": 0},
+ order_by="creation",
+ )
+
+ for index, d in enumerate(data):
+ self.assertEqual(d.qty_after_transaction, 1 + index)
+
+ # Step 2: Create Purchase Receipt
+ pr = make_purchase_receipt(
+ item_code=item,
+ qty=1,
+ rate=100,
+ posting_date=posting_date,
+ posting_time=posting_time,
+ do_not_save=1,
+ )
+
+ for i in range(9):
+ pr.append(
+ "items",
+ {
+ "item_code": item,
+ "qty": 1,
+ "rate": 100,
+ "warehouse": pr.items[0].warehouse,
+ "cost_center": pr.items[0].cost_center,
+ "expense_account": pr.items[0].expense_account,
+ "uom": pr.items[0].uom,
+ "stock_uom": pr.items[0].stock_uom,
+ "conversion_factor": pr.items[0].conversion_factor,
+ },
+ )
+
+ self.assertEqual(len(pr.items), 10)
+ pr.save()
+ pr.submit()
+
+ data = frappe.get_all(
+ "Stock Ledger Entry",
+ fields=["qty_after_transaction", "creation", "posting_datetime"],
+ filters={"voucher_no": pr.name, "is_cancelled": 0},
+ order_by="creation",
+ )
+
+ for index, d in enumerate(data):
+ self.assertEqual(d.qty_after_transaction, 11 + index)
+
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
new file mode 100644
index 000000000000..b932c1371d6e
--- /dev/null
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
@@ -0,0 +1,590 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import json
+
+import frappe
+from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils import flt, nowtime, today
+
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
+ add_serial_batch_ledgers,
+ make_batch_nos,
+ make_serial_nos,
+)
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+
+class TestSerialandBatchBundle(FrappeTestCase):
+ def test_inward_outward_serial_valuation(self):
+ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ serial_item_code = "New Serial No Valuation 1"
+ make_item(
+ serial_item_code,
+ {
+ "has_serial_no": 1,
+ "serial_no_series": "TEST-SER-VAL-.#####",
+ "is_stock_item": 1,
+ },
+ )
+
+ pr = make_purchase_receipt(
+ item_code=serial_item_code, warehouse="_Test Warehouse - _TC", qty=1, rate=500
+ )
+
+ serial_no1 = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)[0]
+
+ pr = make_purchase_receipt(
+ item_code=serial_item_code, warehouse="_Test Warehouse - _TC", qty=1, rate=300
+ )
+
+ serial_no2 = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)[0]
+
+ dn = create_delivery_note(
+ item_code=serial_item_code,
+ warehouse="_Test Warehouse - _TC",
+ qty=1,
+ rate=1500,
+ serial_no=[serial_no2],
+ )
+
+ stock_value_difference = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": dn.name, "is_cancelled": 0, "voucher_type": "Delivery Note"},
+ "stock_value_difference",
+ )
+
+ self.assertEqual(flt(stock_value_difference, 2), -300)
+
+ dn = create_delivery_note(
+ item_code=serial_item_code,
+ warehouse="_Test Warehouse - _TC",
+ qty=1,
+ rate=1500,
+ serial_no=[serial_no1],
+ )
+
+ stock_value_difference = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": dn.name, "is_cancelled": 0, "voucher_type": "Delivery Note"},
+ "stock_value_difference",
+ )
+
+ self.assertEqual(flt(stock_value_difference, 2), -500)
+
+ def test_inward_outward_batch_valuation(self):
+ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ batch_item_code = "New Batch No Valuation 1"
+ make_item(
+ batch_item_code,
+ {
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TEST-BATTCCH-VAL-.#####",
+ "is_stock_item": 1,
+ },
+ )
+
+ pr = make_purchase_receipt(
+ item_code=batch_item_code, warehouse="_Test Warehouse - _TC", qty=10, rate=500
+ )
+
+ batch_no1 = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
+
+ pr = make_purchase_receipt(
+ item_code=batch_item_code, warehouse="_Test Warehouse - _TC", qty=10, rate=300
+ )
+
+ batch_no2 = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
+
+ dn = create_delivery_note(
+ item_code=batch_item_code,
+ warehouse="_Test Warehouse - _TC",
+ qty=10,
+ rate=1500,
+ batch_no=batch_no2,
+ )
+
+ stock_value_difference = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": dn.name, "is_cancelled": 0, "voucher_type": "Delivery Note"},
+ "stock_value_difference",
+ )
+
+ self.assertEqual(flt(stock_value_difference, 2), -3000)
+
+ dn = create_delivery_note(
+ item_code=batch_item_code,
+ warehouse="_Test Warehouse - _TC",
+ qty=10,
+ rate=1500,
+ batch_no=batch_no1,
+ )
+
+ stock_value_difference = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": dn.name, "is_cancelled": 0, "voucher_type": "Delivery Note"},
+ "stock_value_difference",
+ )
+
+ self.assertEqual(flt(stock_value_difference, 2), -5000)
+
+ def test_old_batch_valuation(self):
+ frappe.flags.ignore_serial_batch_bundle_validation = True
+ frappe.flags.use_serial_and_batch_fields = True
+ batch_item_code = "Old Batch Item Valuation 1"
+ make_item(
+ batch_item_code,
+ {
+ "has_batch_no": 1,
+ "is_stock_item": 1,
+ },
+ )
+
+ batch_id = "Old Batch 1"
+ if not frappe.db.exists("Batch", batch_id):
+ batch_doc = frappe.get_doc(
+ {
+ "doctype": "Batch",
+ "batch_id": batch_id,
+ "item": batch_item_code,
+ "use_batchwise_valuation": 0,
+ }
+ ).insert(ignore_permissions=True)
+
+ self.assertTrue(batch_doc.use_batchwise_valuation)
+ batch_doc.db_set("use_batchwise_valuation", 0)
+
+ stock_queue = []
+ qty_after_transaction = 0
+ balance_value = 0
+ for qty, valuation in {10: 100, 20: 200}.items():
+ stock_queue.append([qty, valuation])
+ qty_after_transaction += qty
+ balance_value += qty_after_transaction * valuation
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Stock Ledger Entry",
+ "posting_date": today(),
+ "posting_time": nowtime(),
+ "batch_no": batch_id,
+ "incoming_rate": valuation,
+ "qty_after_transaction": qty_after_transaction,
+ "stock_value_difference": valuation * qty,
+ "balance_value": balance_value,
+ "valuation_rate": balance_value / qty_after_transaction,
+ "actual_qty": qty,
+ "item_code": batch_item_code,
+ "warehouse": "_Test Warehouse - _TC",
+ "stock_queue": json.dumps(stock_queue),
+ }
+ )
+
+ doc.flags.ignore_permissions = True
+ doc.flags.ignore_mandatory = True
+ doc.flags.ignore_links = True
+ doc.flags.ignore_validate = True
+ doc.submit()
+ doc.reload()
+
+ bundle_doc = make_serial_batch_bundle(
+ {
+ "item_code": batch_item_code,
+ "warehouse": "_Test Warehouse - _TC",
+ "voucher_type": "Stock Entry",
+ "posting_date": today(),
+ "posting_time": nowtime(),
+ "qty": -10,
+ "batches": frappe._dict({batch_id: 10}),
+ "type_of_transaction": "Outward",
+ "do_not_submit": True,
+ }
+ )
+
+ bundle_doc.reload()
+ for row in bundle_doc.entries:
+ self.assertEqual(flt(row.stock_value_difference, 2), -1666.67)
+
+ bundle_doc.flags.ignore_permissions = True
+ bundle_doc.flags.ignore_mandatory = True
+ bundle_doc.flags.ignore_links = True
+ bundle_doc.flags.ignore_validate = True
+ bundle_doc.submit()
+
+ bundle_doc = make_serial_batch_bundle(
+ {
+ "item_code": batch_item_code,
+ "warehouse": "_Test Warehouse - _TC",
+ "voucher_type": "Stock Entry",
+ "posting_date": today(),
+ "posting_time": nowtime(),
+ "qty": -20,
+ "batches": frappe._dict({batch_id: 20}),
+ "type_of_transaction": "Outward",
+ "do_not_submit": True,
+ }
+ )
+
+ bundle_doc.reload()
+ for row in bundle_doc.entries:
+ self.assertEqual(flt(row.stock_value_difference, 2), -3333.33)
+
+ bundle_doc.flags.ignore_permissions = True
+ bundle_doc.flags.ignore_mandatory = True
+ bundle_doc.flags.ignore_links = True
+ bundle_doc.flags.ignore_validate = True
+ bundle_doc.submit()
+
+ frappe.flags.ignore_serial_batch_bundle_validation = False
+ frappe.flags.use_serial_and_batch_fields = False
+
+ def test_old_serial_no_valuation(self):
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ serial_no_item_code = "Old Serial No Item Valuation 1"
+ make_item(
+ serial_no_item_code,
+ {
+ "has_serial_no": 1,
+ "serial_no_series": "TEST-SER-VALL-.#####",
+ "is_stock_item": 1,
+ },
+ )
+
+ make_purchase_receipt(
+ item_code=serial_no_item_code, warehouse="_Test Warehouse - _TC", qty=1, rate=500
+ )
+
+ frappe.flags.ignore_serial_batch_bundle_validation = True
+ frappe.flags.use_serial_and_batch_fields = True
+
+ serial_no_id = "Old Serial No 1"
+ if not frappe.db.exists("Serial No", serial_no_id):
+ sn_doc = frappe.get_doc(
+ {
+ "doctype": "Serial No",
+ "serial_no": serial_no_id,
+ "item_code": serial_no_item_code,
+ "company": "_Test Company",
+ }
+ ).insert(ignore_permissions=True)
+
+ sn_doc.db_set(
+ {
+ "warehouse": "_Test Warehouse - _TC",
+ "purchase_rate": 100,
+ }
+ )
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Stock Ledger Entry",
+ "posting_date": today(),
+ "posting_time": nowtime(),
+ "serial_no": serial_no_id,
+ "incoming_rate": 100,
+ "qty_after_transaction": 1,
+ "stock_value_difference": 100,
+ "balance_value": 100,
+ "valuation_rate": 100,
+ "actual_qty": 1,
+ "item_code": serial_no_item_code,
+ "warehouse": "_Test Warehouse - _TC",
+ "company": "_Test Company",
+ }
+ )
+
+ doc.flags.ignore_permissions = True
+ doc.flags.ignore_mandatory = True
+ doc.flags.ignore_links = True
+ doc.flags.ignore_validate = True
+ doc.submit()
+
+ bundle_doc = make_serial_batch_bundle(
+ {
+ "item_code": serial_no_item_code,
+ "warehouse": "_Test Warehouse - _TC",
+ "voucher_type": "Stock Entry",
+ "posting_date": today(),
+ "posting_time": nowtime(),
+ "qty": -1,
+ "serial_nos": [serial_no_id],
+ "type_of_transaction": "Outward",
+ "do_not_submit": True,
+ }
+ )
+
+ bundle_doc.reload()
+ for row in bundle_doc.entries:
+ self.assertEqual(flt(row.stock_value_difference, 2), -100.00)
+
+ frappe.flags.ignore_serial_batch_bundle_validation = False
+ frappe.flags.use_serial_and_batch_fields = False
+
+ def test_batch_not_belong_to_serial_no(self):
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ serial_and_batch_code = "New Serial No Valuation 1"
+ make_item(
+ serial_and_batch_code,
+ {
+ "has_serial_no": 1,
+ "serial_no_series": "TEST-SER-VALL-.#####",
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TEST-SNBAT-VAL-.#####",
+ },
+ )
+
+ pr = make_purchase_receipt(
+ item_code=serial_and_batch_code, warehouse="_Test Warehouse - _TC", qty=1, rate=500
+ )
+
+ serial_no = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)[0]
+
+ pr = make_purchase_receipt(
+ item_code=serial_and_batch_code, warehouse="_Test Warehouse - _TC", qty=1, rate=300
+ )
+
+ batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Serial and Batch Bundle",
+ "item_code": serial_and_batch_code,
+ "warehouse": "_Test Warehouse - _TC",
+ "voucher_type": "Stock Entry",
+ "posting_date": today(),
+ "posting_time": nowtime(),
+ "qty": -1,
+ "type_of_transaction": "Outward",
+ }
+ )
+
+ doc.append(
+ "entries",
+ {
+ "batch_no": batch_no,
+ "serial_no": serial_no,
+ "qty": -1,
+ },
+ )
+
+ # Batch does not belong to serial no
+ self.assertRaises(frappe.exceptions.ValidationError, doc.save)
+
+ def test_auto_delete_draft_serial_and_batch_bundle(self):
+ serial_and_batch_code = "New Serial No Auto Delete 1"
+ make_item(
+ serial_and_batch_code,
+ {
+ "has_serial_no": 1,
+ "serial_no_series": "TEST-SER-VALL-.#####",
+ "is_stock_item": 1,
+ },
+ )
+
+ ste = make_stock_entry(
+ item_code=serial_and_batch_code,
+ target="_Test Warehouse - _TC",
+ qty=1,
+ rate=500,
+ do_not_submit=True,
+ )
+
+ serial_no = "SN-TEST-AUTO-DEL"
+ if not frappe.db.exists("Serial No", serial_no):
+ frappe.get_doc(
+ {
+ "doctype": "Serial No",
+ "serial_no": serial_no,
+ "item_code": serial_and_batch_code,
+ "company": "_Test Company",
+ }
+ ).insert(ignore_permissions=True)
+
+ bundle_doc = make_serial_batch_bundle(
+ {
+ "item_code": serial_and_batch_code,
+ "warehouse": "_Test Warehouse - _TC",
+ "voucher_type": "Stock Entry",
+ "posting_date": ste.posting_date,
+ "posting_time": ste.posting_time,
+ "qty": 1,
+ "serial_nos": [serial_no],
+ "type_of_transaction": "Inward",
+ "do_not_submit": True,
+ }
+ )
+
+ bundle_doc.reload()
+ ste.items[0].serial_and_batch_bundle = bundle_doc.name
+ ste.save()
+ ste.reload()
+
+ ste.delete()
+ self.assertFalse(frappe.db.exists("Serial and Batch Bundle", bundle_doc.name))
+
+ def test_serial_and_batch_bundle_company(self):
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ item = make_item(
+ "Test Serial and Batch Bundle Company Item",
+ properties={
+ "has_serial_no": 1,
+ "serial_no_series": "TT-SER-VAL-.#####",
+ },
+ ).name
+
+ pr = make_purchase_receipt(
+ item_code=item,
+ warehouse="_Test Warehouse - _TC",
+ qty=3,
+ rate=500,
+ do_not_submit=True,
+ )
+
+ entries = []
+ for serial_no in ["TT-SER-VAL-00001", "TT-SER-VAL-00002", "TT-SER-VAL-00003"]:
+ entries.append(frappe._dict({"serial_no": serial_no, "qty": 1}))
+
+ if not frappe.db.exists("Serial No", serial_no):
+ frappe.get_doc(
+ {
+ "doctype": "Serial No",
+ "serial_no": serial_no,
+ "item_code": item,
+ }
+ ).insert(ignore_permissions=True)
+
+ item_row = pr.items[0]
+ item_row.type_of_transaction = "Inward"
+ item_row.is_rejected = 0
+ sn_doc = add_serial_batch_ledgers(entries, item_row, pr, "_Test Warehouse - _TC")
+ self.assertEqual(sn_doc.company, "_Test Company")
+
+ def test_auto_cancel_serial_and_batch(self):
+ item_code = make_item(
+ properties={"has_serial_no": 1, "serial_no_series": "ATC-TT-SER-VAL-.#####"}
+ ).name
+
+ se = make_stock_entry(
+ item_code=item_code,
+ target="_Test Warehouse - _TC",
+ qty=5,
+ rate=500,
+ )
+
+ bundle = se.items[0].serial_and_batch_bundle
+ docstatus = frappe.db.get_value("Serial and Batch Bundle", bundle, "docstatus")
+ self.assertEqual(docstatus, 1)
+
+ se.cancel()
+ docstatus = frappe.db.get_value("Serial and Batch Bundle", bundle, "docstatus")
+ self.assertEqual(docstatus, 2)
+
+ def test_batch_duplicate_entry(self):
+ item_code = make_item(properties={"has_batch_no": 1}).name
+
+ batch_id = "TEST-BATTCCH-VAL-00001"
+ batch_nos = [{"batch_no": batch_id, "qty": 1}]
+
+ make_batch_nos(item_code, batch_nos)
+ self.assertTrue(frappe.db.exists("Batch", batch_id))
+
+ batch_id = "TEST-BATTCCH-VAL-00001"
+ batch_nos = [{"batch_no": batch_id, "qty": 1}]
+
+ # Shouldn't throw duplicate entry error
+ make_batch_nos(item_code, batch_nos)
+ self.assertTrue(frappe.db.exists("Batch", batch_id))
+
+ def test_serial_no_duplicate_entry(self):
+ item_code = make_item(properties={"has_serial_no": 1}).name
+
+ serial_no_id = "TEST-SNID-VAL-00001"
+ serial_nos = [{"serial_no": serial_no_id, "qty": 1}]
+
+ make_serial_nos(item_code, serial_nos)
+ self.assertTrue(frappe.db.exists("Serial No", serial_no_id))
+
+ serial_no_id = "TEST-SNID-VAL-00001"
+ serial_nos = [{"batch_no": serial_no_id, "qty": 1}]
+
+ # Shouldn't throw duplicate entry error
+ make_serial_nos(item_code, serial_nos)
+ self.assertTrue(frappe.db.exists("Serial No", serial_no_id))
+
+ @change_settings("Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1})
+ def test_duplicate_serial_and_batch_bundle(self):
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+
+ item_code = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
+
+ serial_no = f"{item_code}-001"
+ serial_nos = [{"serial_no": serial_no, "qty": 1}]
+ make_serial_nos(item_code, serial_nos)
+
+ pr1 = make_purchase_receipt(item=item_code, qty=1, rate=500, serial_no=[serial_no])
+ pr2 = make_purchase_receipt(item=item_code, qty=1, rate=500, do_not_save=True)
+
+ pr1.reload()
+ pr2.items[0].serial_and_batch_bundle = pr1.items[0].serial_and_batch_bundle
+
+ self.assertRaises(frappe.exceptions.ValidationError, pr2.save)
+
+
+def get_batch_from_bundle(bundle):
+ from erpnext.stock.serial_batch_bundle import get_batch_nos
+
+ batches = get_batch_nos(bundle)
+
+ return list(batches.keys())[0]
+
+
+def get_serial_nos_from_bundle(bundle):
+ from erpnext.stock.serial_batch_bundle import get_serial_nos
+
+ serial_nos = get_serial_nos(bundle)
+ return sorted(serial_nos) if serial_nos else []
+
+
+def make_serial_batch_bundle(kwargs):
+ from erpnext.stock.serial_batch_bundle import SerialBatchCreation
+
+ if isinstance(kwargs, dict):
+ kwargs = frappe._dict(kwargs)
+
+ type_of_transaction = "Inward" if kwargs.qty > 0 else "Outward"
+ if kwargs.get("type_of_transaction"):
+ type_of_transaction = kwargs.get("type_of_transaction")
+
+ sb = SerialBatchCreation(
+ {
+ "item_code": kwargs.item_code,
+ "warehouse": kwargs.warehouse,
+ "voucher_type": kwargs.voucher_type,
+ "voucher_no": kwargs.voucher_no,
+ "posting_date": kwargs.posting_date,
+ "posting_time": kwargs.posting_time,
+ "qty": kwargs.qty,
+ "avg_rate": kwargs.rate,
+ "batches": kwargs.batches,
+ "serial_nos": kwargs.serial_nos,
+ "type_of_transaction": type_of_transaction,
+ "company": kwargs.company or "_Test Company",
+ "do_not_submit": kwargs.do_not_submit,
+ }
+ )
+
+ if not kwargs.get("do_not_save"):
+ return sb.make_serial_and_batch_bundle()
+
+ return sb
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index ca31a9a3d31c..408aeb12be17 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -1721,9 +1721,22 @@ def add_batchwise_finished_good(self, data, args, item):
if qty <= 0:
break
+<<<<<<< HEAD
fg_qty = batch_qty
if batch_qty >= qty:
fg_qty = qty
+=======
+ id = create_serial_and_batch_bundle(
+ self,
+ row,
+ frappe._dict(
+ {
+ "item_code": self.pro_doc.production_item,
+ "warehouse": args.get("to_warehouse"),
+ }
+ ),
+ )
+>>>>>>> d80ca523a4 (perf: new column posting datetime in SLE to optimize stock ledger related queries)
qty -= batch_qty
args["qty"] = fg_qty
@@ -1969,7 +1982,11 @@ def update_item_in_stock_entry_detail(self, row, item, qty) -> None:
"to_warehouse": "",
"qty": qty,
"item_name": item.item_name,
+<<<<<<< HEAD
"batch_no": item.batch_no,
+=======
+ "serial_and_batch_bundle": create_serial_and_batch_bundle(self, row, item, "Outward"),
+>>>>>>> d80ca523a4 (perf: new column posting datetime in SLE to optimize stock ledger related queries)
"description": item.description,
"stock_uom": item.stock_uom,
"expense_account": item.expense_account,
@@ -2377,6 +2394,39 @@ def set_material_request_transfer_status(self, status):
frappe.db.set_value("Material Request", material_request, "transfer_status", status)
def set_serial_no_batch_for_finished_good(self):
+<<<<<<< HEAD
+=======
+ if not (
+ (self.pro_doc.has_serial_no or self.pro_doc.has_batch_no)
+ and frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order")
+ ):
+ return
+
+ for d in self.items:
+ if (
+ d.is_finished_item
+ and d.item_code == self.pro_doc.production_item
+ and not d.serial_and_batch_bundle
+ ):
+ serial_nos = self.get_available_serial_nos()
+ if serial_nos:
+ row = frappe._dict({"serial_nos": serial_nos[0 : cint(d.qty)]})
+
+ id = create_serial_and_batch_bundle(
+ self,
+ row,
+ frappe._dict(
+ {
+ "item_code": d.item_code,
+ "warehouse": d.t_warehouse,
+ }
+ ),
+ )
+
+ d.serial_and_batch_bundle = id
+
+ def get_available_serial_nos(self) -> List[str]:
+>>>>>>> d80ca523a4 (perf: new column posting datetime in SLE to optimize stock ledger related queries)
serial_nos = []
if self.pro_doc.serial_no:
serial_nos = self.get_serial_nos_for_fg() or []
@@ -2855,3 +2905,91 @@ def get_stock_entry_data(work_order):
)
.orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx)
).run(as_dict=1)
+<<<<<<< HEAD
+=======
+
+ if not data:
+ return []
+
+ voucher_nos = [row.get("name") for row in data if row.get("name")]
+ if voucher_nos:
+ bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=voucher_nos)
+ for row in data:
+ key = (row.item_code, row.warehouse, row.name)
+ if row.purpose != "Material Transfer for Manufacture":
+ key = (row.item_code, row.s_warehouse, row.name)
+
+ if bundle_data.get(key):
+ row.update(bundle_data.get(key))
+
+ return data
+
+
+def create_serial_and_batch_bundle(parent_doc, row, child, type_of_transaction=None):
+ item_details = frappe.get_cached_value(
+ "Item", child.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
+ )
+
+ if not (item_details.has_serial_no or item_details.has_batch_no):
+ return
+
+ if not type_of_transaction:
+ type_of_transaction = "Inward"
+
+ doc = frappe.get_doc(
+ {
+ "doctype": "Serial and Batch Bundle",
+ "voucher_type": "Stock Entry",
+ "item_code": child.item_code,
+ "warehouse": child.warehouse,
+ "type_of_transaction": type_of_transaction,
+ "posting_date": parent_doc.posting_date,
+ "posting_time": parent_doc.posting_time,
+ }
+ )
+
+ if row.serial_nos and row.batches_to_be_consume:
+ doc.has_serial_no = 1
+ doc.has_batch_no = 1
+ batchwise_serial_nos = get_batchwise_serial_nos(child.item_code, row)
+ for batch_no, qty in row.batches_to_be_consume.items():
+
+ while qty > 0:
+ qty -= 1
+ doc.append(
+ "entries",
+ {
+ "batch_no": batch_no,
+ "serial_no": batchwise_serial_nos.get(batch_no).pop(0),
+ "warehouse": row.warehouse,
+ "qty": -1,
+ },
+ )
+
+ elif row.serial_nos:
+ doc.has_serial_no = 1
+ for serial_no in row.serial_nos:
+ doc.append("entries", {"serial_no": serial_no, "warehouse": row.warehouse, "qty": -1})
+
+ elif row.batches_to_be_consume:
+ doc.has_batch_no = 1
+ for batch_no, qty in row.batches_to_be_consume.items():
+ doc.append("entries", {"batch_no": batch_no, "warehouse": row.warehouse, "qty": qty * -1})
+
+ return doc.insert(ignore_permissions=True).name
+
+
+def get_batchwise_serial_nos(item_code, row):
+ batchwise_serial_nos = {}
+
+ for batch_no in row.batches_to_be_consume:
+ serial_nos = frappe.get_all(
+ "Serial No",
+ filters={"item_code": item_code, "batch_no": batch_no, "name": ("in", row.serial_nos)},
+ )
+
+ if serial_nos:
+ batchwise_serial_nos[batch_no] = sorted([serial_no.name for serial_no in serial_nos])
+
+ return batchwise_serial_nos
+>>>>>>> d80ca523a4 (perf: new column posting datetime in SLE to optimize stock ledger related queries)
diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json
index 0a666b44fbd2..835002f0e16a 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json
@@ -11,6 +11,7 @@
"warehouse",
"posting_date",
"posting_time",
+ "posting_datetime",
"is_adjustment_entry",
"column_break_6",
"voucher_type",
@@ -96,7 +97,6 @@
"oldfieldtype": "Date",
"print_width": "100px",
"read_only": 1,
- "search_index": 1,
"width": "100px"
},
{
@@ -249,7 +249,6 @@
"options": "Company",
"print_width": "150px",
"read_only": 1,
- "search_index": 1,
"width": "150px"
},
{
@@ -316,6 +315,11 @@
"fieldname": "is_adjustment_entry",
"fieldtype": "Check",
"label": "Is Adjustment Entry"
+ },
+ {
+ "fieldname": "posting_datetime",
+ "fieldtype": "Datetime",
+ "label": "Posting Datetime"
}
],
"hide_toolbar": 1,
@@ -324,7 +328,7 @@
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2024-03-13 09:56:13.021696",
+ "modified": "2024-02-07 09:18:13.999231",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Ledger Entry",
diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
index 9580e83ed956..8864ef4b2f29 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
@@ -28,6 +28,50 @@ class BackDatedStockTransaction(frappe.ValidationError):
class StockLedgerEntry(Document):
+<<<<<<< HEAD
+=======
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ actual_qty: DF.Float
+ auto_created_serial_and_batch_bundle: DF.Check
+ batch_no: DF.Data | None
+ company: DF.Link | None
+ dependant_sle_voucher_detail_no: DF.Data | None
+ fiscal_year: DF.Data | None
+ has_batch_no: DF.Check
+ has_serial_no: DF.Check
+ incoming_rate: DF.Currency
+ is_adjustment_entry: DF.Check
+ is_cancelled: DF.Check
+ item_code: DF.Link | None
+ outgoing_rate: DF.Currency
+ posting_date: DF.Date | None
+ posting_datetime: DF.Datetime | None
+ posting_time: DF.Time | None
+ project: DF.Link | None
+ qty_after_transaction: DF.Float
+ recalculate_rate: DF.Check
+ serial_and_batch_bundle: DF.Link | None
+ serial_no: DF.LongText | None
+ stock_queue: DF.Text | None
+ stock_uom: DF.Link | None
+ stock_value: DF.Currency
+ stock_value_difference: DF.Currency
+ to_rename: DF.Check
+ valuation_rate: DF.Currency
+ voucher_detail_no: DF.Data | None
+ voucher_no: DF.DynamicLink | None
+ voucher_type: DF.Link | None
+ warehouse: DF.Link | None
+ # end: auto-generated types
+
+>>>>>>> d80ca523a4 (perf: new column posting datetime in SLE to optimize stock ledger related queries)
def autoname(self):
"""
Temporarily name doc for fast insertion
@@ -52,6 +96,12 @@ def validate(self):
self.validate_with_last_transaction_posting_time()
self.validate_inventory_dimension_negative_stock()
+ def set_posting_datetime(self):
+ from erpnext.stock.utils import get_combine_datetime
+
+ self.posting_datetime = get_combine_datetime(self.posting_date, self.posting_time)
+ self.db_set("posting_datetime", self.posting_datetime)
+
def validate_inventory_dimension_negative_stock(self):
if self.is_cancelled:
return
@@ -122,6 +172,7 @@ def _get_inventory_dimensions(self):
return inv_dimension_dict
def on_submit(self):
+ self.set_posting_datetime()
self.check_stock_frozen_date()
self.calculate_batch_qty()
@@ -293,9 +344,7 @@ def on_cancel(self):
def on_doctype_update():
- frappe.db.add_index(
- "Stock Ledger Entry", fields=["posting_date", "posting_time"], index_name="posting_sort_index"
- )
frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"])
frappe.db.add_index("Stock Ledger Entry", ["batch_no", "item_code", "warehouse"])
frappe.db.add_index("Stock Ledger Entry", ["warehouse", "item_code"], "item_warehouse")
+ frappe.db.add_index("Stock Ledger Entry", ["posting_datetime", "creation"])
diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
index 6c341d9e9ec2..44ebcda1ed20 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
@@ -1066,7 +1066,7 @@ def ordered_qty_after_transaction():
frappe.qb.from_(sle)
.select("qty_after_transaction")
.where((sle.item_code == item) & (sle.warehouse == warehouse) & (sle.is_cancelled == 0))
- .orderby(CombineDatetime(sle.posting_date, sle.posting_time))
+ .orderby(sle.posting_datetime)
.orderby(sle.creation)
).run(pluck=True)
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
new file mode 100644
index 000000000000..26fe8e1787c2
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
@@ -0,0 +1,1189 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from typing import Literal
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.query_builder.functions import Sum
+from frappe.utils import cint, flt, nowdate, nowtime
+
+from erpnext.stock.utils import get_or_make_bin, get_stock_balance
+
+
+class StockReservationEntry(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from erpnext.stock.doctype.serial_and_batch_entry.serial_and_batch_entry import (
+ SerialandBatchEntry,
+ )
+
+ amended_from: DF.Link | None
+ available_qty: DF.Float
+ company: DF.Link | None
+ delivered_qty: DF.Float
+ from_voucher_detail_no: DF.Data | None
+ from_voucher_no: DF.DynamicLink | None
+ from_voucher_type: DF.Literal["", "Pick List", "Purchase Receipt"]
+ has_batch_no: DF.Check
+ has_serial_no: DF.Check
+ item_code: DF.Link | None
+ project: DF.Link | None
+ reservation_based_on: DF.Literal["Qty", "Serial and Batch"]
+ reserved_qty: DF.Float
+ sb_entries: DF.Table[SerialandBatchEntry]
+ status: DF.Literal[
+ "Draft", "Partially Reserved", "Reserved", "Partially Delivered", "Delivered", "Cancelled"
+ ]
+ stock_uom: DF.Link | None
+ voucher_detail_no: DF.Data | None
+ voucher_no: DF.DynamicLink | None
+ voucher_qty: DF.Float
+ voucher_type: DF.Literal["", "Sales Order"]
+ warehouse: DF.Link | None
+ # end: auto-generated types
+
+ def validate(self) -> None:
+ from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
+
+ self.validate_amended_doc()
+ self.validate_mandatory()
+ self.validate_group_warehouse()
+ validate_disabled_warehouse(self.warehouse)
+ validate_warehouse_company(self.warehouse, self.company)
+ self.validate_uom_is_integer()
+
+ def before_submit(self) -> None:
+ self.set_reservation_based_on()
+ self.validate_reservation_based_on_qty()
+ self.auto_reserve_serial_and_batch()
+ self.validate_reservation_based_on_serial_and_batch()
+
+ def on_submit(self) -> None:
+ self.update_reserved_qty_in_voucher()
+ self.update_reserved_qty_in_pick_list()
+ self.update_status()
+ self.update_reserved_stock_in_bin()
+
+ def on_update_after_submit(self) -> None:
+ self.can_be_updated()
+ self.validate_uom_is_integer()
+ self.set_reservation_based_on()
+ self.validate_reservation_based_on_qty()
+ self.validate_reservation_based_on_serial_and_batch()
+ self.update_reserved_qty_in_voucher()
+ self.update_status()
+ self.update_reserved_stock_in_bin()
+ self.reload()
+
+ def on_cancel(self) -> None:
+ self.update_reserved_qty_in_voucher()
+ self.update_reserved_qty_in_pick_list()
+ self.update_status()
+ self.update_reserved_stock_in_bin()
+
+ def validate_amended_doc(self) -> None:
+ """Raises an exception if document is amended."""
+
+ if self.amended_from:
+ msg = _("Cannot amend {0} {1}, please create a new one instead.").format(
+ self.doctype, frappe.bold(self.amended_from)
+ )
+ frappe.throw(msg)
+
+ def validate_mandatory(self) -> None:
+ """Raises an exception if mandatory fields are not set."""
+
+ mandatory = [
+ "item_code",
+ "warehouse",
+ "voucher_type",
+ "voucher_no",
+ "voucher_detail_no",
+ "available_qty",
+ "voucher_qty",
+ "stock_uom",
+ "reserved_qty",
+ "company",
+ ]
+ for d in mandatory:
+ if not self.get(d):
+ msg = _("{0} is required").format(self.meta.get_label(d))
+ frappe.throw(msg)
+
+ def validate_group_warehouse(self) -> None:
+ """Raises an exception if `Warehouse` is a Group Warehouse."""
+
+ if frappe.get_cached_value("Warehouse", self.warehouse, "is_group"):
+ msg = _("Stock cannot be reserved in group warehouse {0}.").format(frappe.bold(self.warehouse))
+ frappe.throw(msg, title=_("Invalid Warehouse"))
+
+ def validate_uom_is_integer(self) -> None:
+ """Validates `Reserved Qty` with Stock UOM."""
+
+ if cint(frappe.db.get_value("UOM", self.stock_uom, "must_be_whole_number", cache=True)):
+ if cint(self.reserved_qty) != flt(self.reserved_qty, self.precision("reserved_qty")):
+ msg = _(
+ "Reserved Qty ({0}) cannot be a fraction. To allow this, disable '{1}' in UOM {3}."
+ ).format(
+ flt(self.reserved_qty, self.precision("reserved_qty")),
+ frappe.bold(_("Must be Whole Number")),
+ frappe.bold(self.stock_uom),
+ )
+ frappe.throw(msg)
+
+ def set_reservation_based_on(self) -> None:
+ """Sets `Reservation Based On` based on `Has Serial No` and `Has Batch No`."""
+
+ if (self.reservation_based_on == "Serial and Batch") and (
+ not self.has_serial_no and not self.has_batch_no
+ ):
+ self.db_set("reservation_based_on", "Qty")
+
+ def validate_reservation_based_on_qty(self) -> None:
+ """Validates `Reserved Qty` when `Reservation Based On` is `Qty`."""
+
+ if self.reservation_based_on == "Qty":
+ self.validate_with_allowed_qty(self.reserved_qty)
+
+ def auto_reserve_serial_and_batch(self, based_on: str = None) -> None:
+ """Auto pick Serial and Batch Nos to reserve when `Reservation Based On` is `Serial and Batch`."""
+
+ if (
+ not self.from_voucher_type
+ and (self.get("_action") == "submit")
+ and (self.has_serial_no or self.has_batch_no)
+ and cint(frappe.db.get_single_value("Stock Settings", "auto_reserve_serial_and_batch"))
+ ):
+ from erpnext.stock.doctype.batch.batch import get_available_batches
+ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos_for_outward
+ from erpnext.stock.serial_batch_bundle import get_serial_nos_batch
+
+ self.reservation_based_on = "Serial and Batch"
+ self.sb_entries.clear()
+ kwargs = frappe._dict(
+ {
+ "item_code": self.item_code,
+ "warehouse": self.warehouse,
+ "qty": abs(self.reserved_qty) or 0,
+ "based_on": based_on
+ or frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"),
+ }
+ )
+
+ serial_nos, batch_nos = [], []
+ if self.has_serial_no:
+ serial_nos = get_serial_nos_for_outward(kwargs)
+ if self.has_batch_no:
+ batch_nos = get_available_batches(kwargs)
+
+ if serial_nos:
+ serial_no_wise_batch = frappe._dict({})
+
+ if self.has_batch_no:
+ serial_no_wise_batch = get_serial_nos_batch(serial_nos)
+
+ for serial_no in serial_nos:
+ self.append(
+ "sb_entries",
+ {
+ "serial_no": serial_no,
+ "qty": 1,
+ "batch_no": serial_no_wise_batch.get(serial_no),
+ "warehouse": self.warehouse,
+ },
+ )
+ elif batch_nos:
+ for batch_no, batch_qty in batch_nos.items():
+ self.append(
+ "sb_entries",
+ {
+ "batch_no": batch_no,
+ "qty": batch_qty,
+ "warehouse": self.warehouse,
+ },
+ )
+
+ def validate_reservation_based_on_serial_and_batch(self) -> None:
+ """Validates `Reserved Qty`, `Serial and Batch Nos` when `Reservation Based On` is `Serial and Batch`."""
+
+ if self.reservation_based_on == "Serial and Batch":
+ allow_partial_reservation = frappe.db.get_single_value(
+ "Stock Settings", "allow_partial_reservation"
+ )
+
+ available_serial_nos = []
+ if self.has_serial_no:
+ available_serial_nos = get_available_serial_nos_to_reserve(
+ self.item_code, self.warehouse, self.has_batch_no, ignore_sre=self.name
+ )
+
+ if not available_serial_nos:
+ msg = _("Stock not available for Item {0} in Warehouse {1}.").format(
+ frappe.bold(self.item_code), frappe.bold(self.warehouse)
+ )
+ frappe.throw(msg)
+
+ qty_to_be_reserved = 0
+ selected_batch_nos, selected_serial_nos = [], []
+ for entry in self.sb_entries:
+ entry.warehouse = self.warehouse
+
+ if self.has_serial_no:
+ entry.qty = 1
+
+ key = (
+ (entry.serial_no, self.warehouse, entry.batch_no)
+ if self.has_batch_no
+ else (entry.serial_no, self.warehouse)
+ )
+ if key not in available_serial_nos:
+ msg = _(
+ "Row #{0}: Serial No {1} for Item {2} is not available in {3} {4} or might be reserved in another {5}."
+ ).format(
+ entry.idx,
+ frappe.bold(entry.serial_no),
+ frappe.bold(self.item_code),
+ _("Batch {0} and Warehouse").format(frappe.bold(entry.batch_no))
+ if self.has_batch_no
+ else _("Warehouse"),
+ frappe.bold(self.warehouse),
+ frappe.bold("Stock Reservation Entry"),
+ )
+
+ frappe.throw(msg)
+
+ if entry.serial_no in selected_serial_nos:
+ msg = _("Row #{0}: Serial No {1} is already selected.").format(
+ entry.idx, frappe.bold(entry.serial_no)
+ )
+ frappe.throw(msg)
+ else:
+ selected_serial_nos.append(entry.serial_no)
+
+ elif self.has_batch_no:
+ if cint(frappe.db.get_value("Batch", entry.batch_no, "disabled")):
+ msg = _(
+ "Row #{0}: Stock cannot be reserved for Item {1} against a disabled Batch {2}."
+ ).format(
+ entry.idx, frappe.bold(self.item_code), frappe.bold(entry.batch_no)
+ )
+ frappe.throw(msg)
+
+ available_qty_to_reserve = get_available_qty_to_reserve(
+ self.item_code, self.warehouse, entry.batch_no, ignore_sre=self.name
+ )
+
+ if available_qty_to_reserve <= 0:
+ msg = _(
+ "Row #{0}: Stock not available to reserve for Item {1} against Batch {2} in Warehouse {3}."
+ ).format(
+ entry.idx,
+ frappe.bold(self.item_code),
+ frappe.bold(entry.batch_no),
+ frappe.bold(self.warehouse),
+ )
+ frappe.throw(msg)
+
+ if entry.qty > available_qty_to_reserve:
+ if allow_partial_reservation:
+ entry.qty = available_qty_to_reserve
+ if self.get("_action") == "update_after_submit":
+ entry.db_update()
+ else:
+ msg = _(
+ "Row #{0}: Qty should be less than or equal to Available Qty to Reserve (Actual Qty - Reserved Qty) {1} for Iem {2} against Batch {3} in Warehouse {4}."
+ ).format(
+ entry.idx,
+ frappe.bold(available_qty_to_reserve),
+ frappe.bold(self.item_code),
+ frappe.bold(entry.batch_no),
+ frappe.bold(self.warehouse),
+ )
+ frappe.throw(msg)
+
+ if entry.batch_no in selected_batch_nos:
+ msg = _("Row #{0}: Batch No {1} is already selected.").format(
+ entry.idx, frappe.bold(entry.batch_no)
+ )
+ frappe.throw(msg)
+ else:
+ selected_batch_nos.append(entry.batch_no)
+
+ qty_to_be_reserved += entry.qty
+
+ if not qty_to_be_reserved:
+ msg = _("Please select Serial/Batch Nos to reserve or change Reservation Based On to Qty.")
+ frappe.throw(msg)
+
+ # Should be called after validating Serial and Batch Nos.
+ self.validate_with_allowed_qty(qty_to_be_reserved)
+ self.db_set("reserved_qty", qty_to_be_reserved)
+
+ def update_reserved_qty_in_voucher(
+ self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True
+ ) -> None:
+ """Updates total reserved qty in the voucher."""
+
+ item_doctype = "Sales Order Item" if self.voucher_type == "Sales Order" else None
+
+ if item_doctype:
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ reserved_qty = (
+ frappe.qb.from_(sre)
+ .select(Sum(sre.reserved_qty))
+ .where(
+ (sre.docstatus == 1)
+ & (sre.voucher_type == self.voucher_type)
+ & (sre.voucher_no == self.voucher_no)
+ & (sre.voucher_detail_no == self.voucher_detail_no)
+ )
+ ).run(as_list=True)[0][0] or 0
+
+ frappe.db.set_value(
+ item_doctype,
+ self.voucher_detail_no,
+ reserved_qty_field,
+ reserved_qty,
+ update_modified=update_modified,
+ )
+
+ def update_reserved_qty_in_pick_list(
+ self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True
+ ) -> None:
+ """Updates total reserved qty in the Pick List."""
+
+ if (
+ self.from_voucher_type == "Pick List" and self.from_voucher_no and self.from_voucher_detail_no
+ ):
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ reserved_qty = (
+ frappe.qb.from_(sre)
+ .select(Sum(sre.reserved_qty))
+ .where(
+ (sre.docstatus == 1)
+ & (sre.from_voucher_type == "Pick List")
+ & (sre.from_voucher_no == self.from_voucher_no)
+ & (sre.from_voucher_detail_no == self.from_voucher_detail_no)
+ )
+ ).run(as_list=True)[0][0] or 0
+
+ frappe.db.set_value(
+ "Pick List Item",
+ self.from_voucher_detail_no,
+ reserved_qty_field,
+ reserved_qty,
+ update_modified=update_modified,
+ )
+
+ def update_reserved_stock_in_bin(self) -> None:
+ """Updates `Reserved Stock` in Bin."""
+
+ bin_name = get_or_make_bin(self.item_code, self.warehouse)
+ bin_doc = frappe.get_cached_doc("Bin", bin_name)
+ bin_doc.update_reserved_stock()
+
+ def update_status(self, status: str = None, update_modified: bool = True) -> None:
+ """Updates status based on Voucher Qty, Reserved Qty and Delivered Qty."""
+
+ if not status:
+ if self.docstatus == 2:
+ status = "Cancelled"
+ elif self.docstatus == 1:
+ if self.reserved_qty == self.delivered_qty:
+ status = "Delivered"
+ elif self.delivered_qty and self.delivered_qty < self.reserved_qty:
+ status = "Partially Delivered"
+ elif self.reserved_qty == self.voucher_qty:
+ status = "Reserved"
+ else:
+ status = "Partially Reserved"
+ else:
+ status = "Draft"
+
+ frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified)
+
+ def can_be_updated(self) -> None:
+ """Raises an exception if `Stock Reservation Entry` is not allowed to be updated."""
+
+ if self.status in ("Partially Delivered", "Delivered"):
+ msg = _(
+ "{0} {1} cannot be updated. If you need to make changes, we recommend canceling the existing entry and creating a new one."
+ ).format(self.status, self.doctype)
+ frappe.throw(msg)
+
+ if self.from_voucher_type == "Pick List":
+ msg = _(
+ "Stock Reservation Entry created against a Pick List cannot be updated. If you need to make changes, we recommend canceling the existing entry and creating a new one."
+ )
+ frappe.throw(msg)
+
+ if self.delivered_qty > 0:
+ msg = _("Stock Reservation Entry cannot be updated as it has been delivered.")
+ frappe.throw(msg)
+
+ def validate_with_allowed_qty(self, qty_to_be_reserved: float) -> None:
+ """Validates `Reserved Qty` with `Max Reserved Qty`."""
+
+ self.db_set(
+ "available_qty",
+ get_available_qty_to_reserve(self.item_code, self.warehouse, ignore_sre=self.name),
+ )
+
+ total_reserved_qty = get_sre_reserved_qty_for_voucher_detail_no(
+ self.voucher_type, self.voucher_no, self.voucher_detail_no, ignore_sre=self.name
+ )
+
+ voucher_delivered_qty = 0
+ if self.voucher_type == "Sales Order":
+ delivered_qty, conversion_factor = frappe.db.get_value(
+ "Sales Order Item", self.voucher_detail_no, ["delivered_qty", "conversion_factor"]
+ )
+ voucher_delivered_qty = flt(delivered_qty) * flt(conversion_factor)
+
+ allowed_qty = min(
+ self.available_qty, (self.voucher_qty - voucher_delivered_qty - total_reserved_qty)
+ )
+
+ if self.get("_action") != "submit" and self.voucher_type == "Sales Order" and allowed_qty <= 0:
+ msg = _("Item {0} is already reserved/delivered against Sales Order {1}.").format(
+ frappe.bold(self.item_code), frappe.bold(self.voucher_no)
+ )
+
+ if self.docstatus == 1:
+ self.cancel()
+ return frappe.msgprint(msg)
+ else:
+ frappe.throw(msg)
+
+ if qty_to_be_reserved > allowed_qty:
+ actual_qty = get_stock_balance(self.item_code, self.warehouse)
+ msg = """
+ Cannot reserve more than Allowed Qty {0} {1} for Item {2} against {3} {4}.
+ The Allowed Qty is calculated as follows:
+