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:
+ + """.format( + frappe.bold(allowed_qty), + self.stock_uom, + frappe.bold(self.item_code), + self.voucher_type, + frappe.bold(self.voucher_no), + actual_qty, + actual_qty - self.available_qty, + self.available_qty, + self.voucher_qty, + voucher_delivered_qty, + total_reserved_qty, + allowed_qty, + ) + frappe.throw(msg) + + if qty_to_be_reserved <= self.delivered_qty: + msg = _("Reserved Qty should be greater than Delivered Qty.") + frappe.throw(msg) + + +def validate_stock_reservation_settings(voucher: object) -> None: + """Raises an exception if `Stock Reservation` is not enabled or `Voucher Type` is not allowed.""" + + if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): + msg = _("Please enable {0} in the {1}.").format( + frappe.bold("Stock Reservation"), frappe.bold("Stock Settings") + ) + frappe.throw(msg) + + # Voucher types allowed for stock reservation + allowed_voucher_types = ["Sales Order"] + + if voucher.doctype not in allowed_voucher_types: + msg = _("Stock Reservation can only be created against {0}.").format( + ", ".join(allowed_voucher_types) + ) + frappe.throw(msg) + + +def get_available_qty_to_reserve( + item_code: str, warehouse: str, batch_no: str = None, ignore_sre=None +) -> float: + """Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item, Warehouse and Batch combination.""" + + from erpnext.stock.doctype.batch.batch import get_batch_qty + + if batch_no: + return get_batch_qty( + item_code=item_code, warehouse=warehouse, batch_no=batch_no, ignore_voucher_nos=[ignore_sre] + ) + + available_qty = get_stock_balance(item_code, warehouse) + + if available_qty: + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(Sum(sre.reserved_qty - sre.delivered_qty)) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & (sre.reserved_qty >= sre.delivered_qty) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + ) + + if ignore_sre: + query = query.where(sre.name != ignore_sre) + + reserved_qty = query.run()[0][0] or 0.0 + + if reserved_qty: + return available_qty - reserved_qty + + return available_qty + + +def get_available_serial_nos_to_reserve( + item_code: str, warehouse: str, has_batch_no: bool = False, ignore_sre=None +) -> list[tuple]: + """Returns Available Serial Nos to Reserve (Available Serial Nos - Reserved Serial Nos)` for Item, Warehouse and Batch combination.""" + + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_available_serial_nos, + ) + + available_serial_nos = get_available_serial_nos( + frappe._dict( + { + "item_code": item_code, + "warehouse": warehouse, + "has_batch_no": has_batch_no, + "ignore_voucher_nos": [ignore_sre], + } + ) + ) + + available_serial_nos_list = [] + if available_serial_nos: + available_serial_nos_list = [tuple(d.values()) for d in available_serial_nos] + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(sre) + .left_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sb_entry.serial_no, sre.warehouse) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & (sre.reserved_qty >= sre.delivered_qty) + & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.reservation_based_on == "Serial and Batch") + ) + ) + + if has_batch_no: + query = query.select(sb_entry.batch_no) + + if ignore_sre: + query = query.where(sre.name != ignore_sre) + + reserved_serial_nos = query.run() + + if reserved_serial_nos: + return list(set(available_serial_nos_list) - set(reserved_serial_nos)) + + return available_serial_nos_list + + +def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str = None) -> float: + """Returns current `Reserved Qty` for Item and Warehouse combination.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty")) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .groupby(sre.item_code, sre.warehouse) + ) + + if warehouse: + query = query.where(sre.warehouse == warehouse) + + reserved_qty = query.run(as_list=True) + + return flt(reserved_qty[0][0]) if reserved_qty else 0.0 + + +def get_sre_reserved_qty_for_items_and_warehouses( + item_code_list: list, warehouse_list: list = None +) -> dict: + """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }.""" + + if not item_code_list: + return {} + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select( + sre.item_code, + sre.warehouse, + Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"), + ) + .where( + (sre.docstatus == 1) + & sre.item_code.isin(item_code_list) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .groupby(sre.item_code, sre.warehouse) + ) + + if warehouse_list: + query = query.where(sre.warehouse.isin(warehouse_list)) + + data = query.run(as_dict=True) + + return {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in data} if data else {} + + +def get_sre_reserved_qty_details_for_voucher(voucher_type: str, voucher_no: str) -> dict: + """Returns a dict like {"voucher_detail_no": "reserved_qty", ... }.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + data = ( + frappe.qb.from_(sre) + .select( + sre.voucher_detail_no, + (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty"), + ) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .groupby(sre.voucher_detail_no) + ).run(as_list=True) + + return frappe._dict(data) + + +def get_sre_reserved_warehouses_for_voucher( + voucher_type: str, voucher_no: str, voucher_detail_no: str = None +) -> list: + """Returns a list of warehouses where the stock is reserved for the provided voucher.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(sre.warehouse) + .distinct() + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .orderby(sre.creation) + ) + + if voucher_detail_no: + query = query.where(sre.voucher_detail_no == voucher_detail_no) + + warehouses = query.run(as_list=True) + + return [d[0] for d in warehouses] if warehouses else [] + + +def get_sre_reserved_qty_for_voucher_detail_no( + voucher_type: str, voucher_no: str, voucher_detail_no: str, ignore_sre=None +) -> float: + """Returns `Reserved Qty` against the Voucher.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select( + (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)), + ) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.voucher_detail_no == voucher_detail_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + ) + + if ignore_sre: + query = query.where(sre.name != ignore_sre) + + reserved_qty = query.run(as_list=True) + + return flt(reserved_qty[0][0]) + + +def get_sre_reserved_serial_nos_details( + item_code: str, warehouse: str, serial_nos: list = None +) -> dict: + """Returns a dict of `Serial No` reserved in Stock Reservation Entry. The dict is like {serial_no: sre_name, ...}""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sb_entry.serial_no, sre.name) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & (sre.reserved_qty > sre.delivered_qty) + & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.reservation_based_on == "Serial and Batch") + ) + .orderby(sb_entry.creation) + ) + + if serial_nos: + query = query.where(sb_entry.serial_no.isin(serial_nos)) + + return frappe._dict(query.run()) + + +def get_sre_reserved_batch_nos_details( + item_code: str, warehouse: str, batch_nos: list = None +) -> dict: + """Returns a dict of `Batch Qty` reserved in Stock Reservation Entry. The dict is like {batch_no: qty, ...}""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select( + sb_entry.batch_no, + Sum(sb_entry.qty - sb_entry.delivered_qty), + ) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & ((sre.reserved_qty - sre.delivered_qty) > 0) + & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.reservation_based_on == "Serial and Batch") + ) + .groupby(sb_entry.batch_no) + .orderby(sb_entry.creation) + ) + + if batch_nos: + query = query.where(sb_entry.batch_no.isin(batch_nos)) + + return frappe._dict(query.run()) + + +def get_sre_details_for_voucher(voucher_type: str, voucher_no: str) -> list[dict]: + """Returns a list of SREs for the provided voucher.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + return ( + frappe.qb.from_(sre) + .select( + sre.name, + sre.item_code, + sre.warehouse, + sre.voucher_type, + sre.voucher_no, + sre.voucher_detail_no, + (sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"), + sre.has_serial_no, + sre.has_batch_no, + sre.reservation_based_on, + ) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.reserved_qty > sre.delivered_qty) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .orderby(sre.creation) + ).run(as_dict=True) + + +def get_serial_batch_entries_for_voucher(sre_name: str) -> list[dict]: + """Returns a list of `Serial and Batch Entries` for the provided voucher.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + + return ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select( + sb_entry.serial_no, + sb_entry.batch_no, + (sb_entry.qty - sb_entry.delivered_qty).as_("qty"), + ) + .where( + (sre.docstatus == 1) & (sre.name == sre_name) & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .where(sb_entry.qty > sb_entry.delivered_qty) + .orderby(sb_entry.creation) + ).run(as_dict=True) + + +def get_ssb_bundle_for_voucher(sre: dict) -> object: + """Returns a new `Serial and Batch Bundle` against the provided SRE.""" + + sb_entries = get_serial_batch_entries_for_voucher(sre["name"]) + + if sb_entries: + bundle = frappe.new_doc("Serial and Batch Bundle") + bundle.type_of_transaction = "Outward" + bundle.voucher_type = "Delivery Note" + bundle.posting_date = nowdate() + bundle.posting_time = nowtime() + + for field in ("item_code", "warehouse", "has_serial_no", "has_batch_no"): + setattr(bundle, field, sre[field]) + + for sb_entry in sb_entries: + bundle.append("entries", sb_entry) + + bundle.save() + + return bundle.name + + +def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool: + """Returns True if there is any Stock Reservation Entry for the given voucher.""" + + if get_stock_reservation_entries_for_voucher( + voucher_type, voucher_no, voucher_detail_no, fields=["name"], ignore_status=True + ): + return True + + return False + + +def create_stock_reservation_entries_for_so_items( + sales_order: object, + items_details: list[dict] = None, + from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None, + notify=True, +) -> None: + """Creates Stock Reservation Entries for Sales Order Items.""" + + from erpnext.selling.doctype.sales_order.sales_order import get_unreserved_qty + + if not from_voucher_type and ( + sales_order.get("_action") == "submit" + and sales_order.set_warehouse + and cint(frappe.get_cached_value("Warehouse", sales_order.set_warehouse, "is_group")) + ): + return frappe.msgprint( + _("Stock cannot be reserved in the group warehouse {0}.").format( + frappe.bold(sales_order.set_warehouse) + ) + ) + + validate_stock_reservation_settings(sales_order) + + allow_partial_reservation = frappe.db.get_single_value( + "Stock Settings", "allow_partial_reservation" + ) + + items = [] + if items_details: + for item in items_details: + so_item = frappe.get_doc("Sales Order Item", item.get("sales_order_item")) + so_item.warehouse = item.get("warehouse") + so_item.qty_to_reserve = ( + flt(item.get("qty_to_reserve")) + if from_voucher_type in ["Pick List", "Purchase Receipt"] + else ( + flt(item.get("qty_to_reserve")) + * (flt(item.get("conversion_factor")) or flt(so_item.conversion_factor) or 1) + ) + ) + so_item.from_voucher_no = item.get("from_voucher_no") + so_item.from_voucher_detail_no = item.get("from_voucher_detail_no") + so_item.serial_and_batch_bundle = item.get("serial_and_batch_bundle") + + items.append(so_item) + + sre_count = 0 + reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", sales_order.name) + + for item in items if items_details else sales_order.get("items"): + # Skip if `Reserved Stock` is not checked for the item. + if not item.get("reserve_stock"): + continue + + # Stock should be reserved from the Pick List if has Picked Qty. + if from_voucher_type != "Pick List" and flt(item.picked_qty) > 0: + frappe.throw( + _("Row #{0}: Item {1} has been picked, please reserve stock from the Pick List.").format( + item.idx, frappe.bold(item.item_code) + ) + ) + + is_stock_item, has_serial_no, has_batch_no = frappe.get_cached_value( + "Item", item.item_code, ["is_stock_item", "has_serial_no", "has_batch_no"] + ) + + # Skip if Non-Stock Item. + if not is_stock_item: + if not from_voucher_type: + frappe.msgprint( + _("Row #{0}: Stock cannot be reserved for a non-stock Item {1}").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="yellow", + ) + + item.db_set("reserve_stock", 0) + continue + + # Skip if Group Warehouse. + if frappe.get_cached_value("Warehouse", item.warehouse, "is_group"): + frappe.msgprint( + _("Row #{0}: Stock cannot be reserved in group warehouse {1}.").format( + item.idx, frappe.bold(item.warehouse) + ), + title=_("Stock Reservation"), + indicator="yellow", + ) + continue + + unreserved_qty = get_unreserved_qty(item, reserved_qty_details) + + # Stock is already reserved for the item, notify the user and skip the item. + if unreserved_qty <= 0: + if not from_voucher_type: + frappe.msgprint( + _("Row #{0}: Stock is already reserved for the Item {1}.").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="yellow", + ) + + continue + + available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse) + + # No stock available to reserve, notify the user and skip the item. + if available_qty_to_reserve <= 0: + frappe.msgprint( + _("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format( + item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + continue + + # The quantity which can be reserved. + qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve) + + if hasattr(item, "qty_to_reserve"): + if item.qty_to_reserve <= 0: + frappe.msgprint( + _("Row #{0}: Quantity to reserve for the Item {1} should be greater than 0.").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + continue + else: + qty_to_be_reserved = min(qty_to_be_reserved, item.qty_to_reserve) + + # Partial Reservation + if qty_to_be_reserved < unreserved_qty: + if not from_voucher_type and ( + not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")) + ): + msg = _("Row #{0}: Only {1} available to reserve for the Item {2}").format( + item.idx, + frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom), + frappe.bold(item.item_code), + ) + frappe.msgprint(msg, title=_("Stock Reservation"), indicator="orange") + + # Skip the item if `Partial Reservation` is disabled in the Stock Settings. + if not allow_partial_reservation: + if qty_to_be_reserved == flt(item.get("qty_to_reserve")): + msg = _("Enable Allow Partial Reservation in the Stock Settings to reserve partial stock.") + frappe.msgprint(msg, title=_("Partial Stock Reservation"), indicator="yellow") + + continue + + sre = frappe.new_doc("Stock Reservation Entry") + + sre.item_code = item.item_code + sre.warehouse = item.warehouse + sre.has_serial_no = has_serial_no + sre.has_batch_no = has_batch_no + sre.voucher_type = sales_order.doctype + sre.voucher_no = sales_order.name + sre.voucher_detail_no = item.name + sre.available_qty = available_qty_to_reserve + sre.voucher_qty = item.stock_qty + sre.reserved_qty = qty_to_be_reserved + sre.company = sales_order.company + sre.stock_uom = item.stock_uom + sre.project = sales_order.project + + if from_voucher_type: + sre.from_voucher_type = from_voucher_type + sre.from_voucher_no = item.from_voucher_no + sre.from_voucher_detail_no = item.from_voucher_detail_no + + if item.get("serial_and_batch_bundle"): + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + sre.reservation_based_on = "Serial and Batch" + + index, picked_qty = 0, 0 + while index < len(sbb.entries) and picked_qty < qty_to_be_reserved: + entry = sbb.entries[index] + qty = 1 if has_serial_no else min(abs(entry.qty), qty_to_be_reserved - picked_qty) + + sre.append( + "sb_entries", + { + "serial_no": entry.serial_no, + "batch_no": entry.batch_no, + "qty": qty, + "warehouse": entry.warehouse, + }, + ) + + index += 1 + picked_qty += qty + + sre.save() + sre.submit() + + sre_count += 1 + + if sre_count and notify: + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green") + + +def cancel_stock_reservation_entries( + voucher_type: str = None, + voucher_no: str = None, + voucher_detail_no: str = None, + from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None, + from_voucher_no: str = None, + from_voucher_detail_no: str = None, + sre_list: list = None, + notify: bool = True, +) -> None: + """Cancel Stock Reservation Entries.""" + + if not sre_list: + sre_list = {} + + if voucher_type and voucher_no: + sre_list = get_stock_reservation_entries_for_voucher( + voucher_type, voucher_no, voucher_detail_no, fields=["name"] + ) + elif from_voucher_type and from_voucher_no: + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(sre.name) + .where( + (sre.docstatus == 1) + & (sre.from_voucher_type == from_voucher_type) + & (sre.from_voucher_no == from_voucher_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .orderby(sre.creation) + ) + + if from_voucher_detail_no: + query = query.where(sre.from_voucher_detail_no == from_voucher_detail_no) + + sre_list = query.run(as_dict=True) + + sre_list = [d.name for d in sre_list] + + if sre_list: + for sre in sre_list: + frappe.get_doc("Stock Reservation Entry", sre).cancel() + + if notify: + msg = _("Stock Reservation Entries Cancelled") + frappe.msgprint(msg, alert=True, indicator="red") + + +@frappe.whitelist() +def get_stock_reservation_entries_for_voucher( + voucher_type: str, + voucher_no: str, + voucher_detail_no: str = None, + fields: list[str] = None, + ignore_status: bool = False, +) -> list[dict]: + """Returns list of Stock Reservation Entries against a Voucher.""" + + if not fields or not isinstance(fields, list): + fields = [ + "name", + "item_code", + "warehouse", + "voucher_detail_no", + "reserved_qty", + "delivered_qty", + "stock_uom", + ] + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .where( + (sre.docstatus == 1) & (sre.voucher_type == voucher_type) & (sre.voucher_no == voucher_no) + ) + .orderby(sre.creation) + ) + + for field in fields: + query = query.select(sre[field]) + + if voucher_detail_no: + query = query.where(sre.voucher_detail_no == voucher_detail_no) + + if ignore_status: + query = query.where(sre.status.notin(["Delivered", "Cancelled"])) + + return query.run(as_dict=True) diff --git a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py index e4f657ca7070..da958a8b0f12 100644 --- a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py +++ b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.query_builder import Field -from frappe.query_builder.functions import CombineDatetime, Min +from frappe.query_builder.functions import Min from frappe.utils import add_days, getdate, today import erpnext @@ -75,7 +75,7 @@ def get_data(report_filters): & (sle.company == report_filters.company) & (sle.is_cancelled == 0) ) - .orderby(CombineDatetime(sle.posting_date, sle.posting_time), sle.creation) + .orderby(sle.posting_datetime, sle.creation) ).run(as_dict=True) for d in data: diff --git a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py index 9e75201bd141..dd79e7fcaf58 100644 --- a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py +++ b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py @@ -213,13 +213,11 @@ def get_stock_ledger_entries(filters, items): query = ( frappe.qb.from_(sle) - .force_index("posting_sort_index") .left_join(sle2) .on( (sle.item_code == sle2.item_code) & (sle.warehouse == sle2.warehouse) - & (sle.posting_date < sle2.posting_date) - & (sle.posting_time < sle2.posting_time) + & (sle.posting_datetime < sle2.posting_datetime) & (sle.name < sle2.name) ) .select(sle.item_code, sle.warehouse, sle.qty_after_transaction, sle.company) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 32a2b302d7b2..6b0bbf3f44dc 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -8,7 +8,7 @@ import frappe from frappe import _ from frappe.query_builder import Order -from frappe.query_builder.functions import Coalesce, CombineDatetime +from frappe.query_builder.functions import Coalesce from frappe.utils import add_days, cint, date_diff, flt, getdate from frappe.utils.nestedset import get_descendants_of @@ -283,7 +283,7 @@ def prepare_stock_ledger_entries(self): item_table.item_name, ) .where((sle.docstatus < 2) & (sle.is_cancelled == 0)) - .orderby(CombineDatetime(sle.posting_date, sle.posting_time)) + .orderby(sle.posting_datetime) .orderby(sle.creation) .orderby(sle.actual_qty) ) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index eeef39641b01..21b90c4b026e 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -276,7 +276,7 @@ def get_stock_ledger_entries(filters, items): frappe.qb.from_(sle) .select( sle.item_code, - CombineDatetime(sle.posting_date, sle.posting_time).as_("date"), + sle.posting_datetime.as_("date"), sle.warehouse, sle.posting_date, sle.posting_time, diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ef1b0cda4ff7..ee06dcecd522 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -7,13 +7,30 @@ import frappe from frappe import _ from frappe.model.meta import get_field_precision +<<<<<<< HEAD from frappe.query_builder.functions import CombineDatetime, Sum from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate +======= +from frappe.query_builder.functions import Sum +from frappe.utils import ( + add_to_date, + cint, + cstr, + flt, + get_link_to_form, + getdate, + now, + nowdate, + nowtime, + parse_json, +) +>>>>>>> d80ca523a4 (perf: new column posting datetime in SLE to optimize stock ledger related queries) import erpnext from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.utils import ( + get_combine_datetime, get_incoming_outgoing_rate_for_cancel, get_incoming_rate, get_or_make_bin, @@ -68,7 +85,11 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher) args = sle_doc.as_dict() +<<<<<<< HEAD args["allow_zero_valuation_rate"] = sle.get("allow_zero_valuation_rate") or False +======= + args["posting_datetime"] = get_combine_datetime(args.posting_date, args.posting_time) +>>>>>>> d80ca523a4 (perf: new column posting datetime in SLE to optimize stock ledger related queries) if sle.get("voucher_type") == "Stock Reconciliation": # preserve previous_qty_after_transaction for qty reposting @@ -431,12 +452,14 @@ def process_sle_against_current_timestamp(self): self.process_sle(sle) def get_sle_against_current_voucher(self): - self.args["time_format"] = "%H:%i:%s" + self.args["posting_datetime"] = get_combine_datetime( + self.args.posting_date, self.args.posting_time + ) return frappe.db.sql( """ select - *, timestamp(posting_date, posting_time) as "timestamp" + *, posting_datetime as "timestamp" from `tabStock Ledger Entry` where @@ -444,11 +467,10 @@ def get_sle_against_current_voucher(self): and warehouse = %(warehouse)s and is_cancelled = 0 and ( - posting_date = %(posting_date)s and - time_format(posting_time, %(time_format)s) = time_format(%(posting_time)s, %(time_format)s) + posting_datetime = %(posting_datetime)s ) order by - creation ASC + posting_datetime ASC, creation ASC for update """, self.args, @@ -1188,9 +1210,14 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc args["time_format"] = "%H:%i:%s" if not args.get("posting_date"): - args["posting_date"] = "1900-01-01" - if not args.get("posting_time"): - args["posting_time"] = "00:00" + args["posting_datetime"] = "1900-01-01 00:00:00" + + if not args.get("posting_datetime"): + args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"]) + + if operator == "<=": + # Add 1 second to handle millisecond for less than and equal to condition + args["posting_datetime"] = add_to_date(args["posting_datetime"], seconds=1) voucher_condition = "" if exclude_current_voucher: @@ -1199,23 +1226,20 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc sle = frappe.db.sql( """ - select *, timestamp(posting_date, posting_time) as "timestamp" + select *, posting_datetime as "timestamp" from `tabStock Ledger Entry` where item_code = %(item_code)s and warehouse = %(warehouse)s and is_cancelled = 0 {voucher_condition} and ( - posting_date < %(posting_date)s or - ( - posting_date = %(posting_date)s and - time_format(posting_time, %(time_format)s) {operator} time_format(%(posting_time)s, %(time_format)s) - ) + posting_datetime {operator} %(posting_datetime)s ) - order by timestamp(posting_date, posting_time) desc, creation desc + order by posting_datetime desc, creation desc limit 1 for update""".format( - operator=operator, voucher_condition=voucher_condition + operator=operator, + voucher_condition=voucher_condition, ), args, as_dict=1, @@ -1256,9 +1280,7 @@ def get_stock_ledger_entries( extra_cond=None, ): """get stock ledger entries filtered by specific posting datetime conditions""" - conditions = " and timestamp(posting_date, posting_time) {0} timestamp(%(posting_date)s, %(posting_time)s)".format( - operator - ) + conditions = " and posting_datetime {0} %(posting_datetime)s".format(operator) if previous_sle.get("warehouse"): conditions += " and warehouse = %(warehouse)s" elif previous_sle.get("warehouse_condition"): @@ -1284,9 +1306,11 @@ def get_stock_ledger_entries( ) if not previous_sle.get("posting_date"): - previous_sle["posting_date"] = "1900-01-01" - if not previous_sle.get("posting_time"): - previous_sle["posting_time"] = "00:00" + previous_sle["posting_datetime"] = "1900-01-01 00:00:00" + else: + previous_sle["posting_datetime"] = get_combine_datetime( + previous_sle["posting_date"], previous_sle["posting_time"] + ) if operator in (">", "<=") and previous_sle.get("name"): conditions += " and name!=%(name)s" @@ -1299,12 +1323,12 @@ def get_stock_ledger_entries( return frappe.db.sql( """ - select *, timestamp(posting_date, posting_time) as "timestamp" + select *, posting_datetime as "timestamp" from `tabStock Ledger Entry` where item_code = %%(item_code)s and is_cancelled = 0 %(conditions)s - order by timestamp(posting_date, posting_time) %(order)s, creation %(order)s + order by posting_datetime %(order)s, creation %(order)s %(limit)s %(for_update)s""" % { "conditions": conditions, @@ -1330,7 +1354,7 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): "posting_date", "posting_time", "voucher_detail_no", - "timestamp(posting_date, posting_time) as timestamp", + "posting_datetime as timestamp", ], as_dict=1, ) @@ -1342,13 +1366,10 @@ def get_batch_incoming_rate( sle = frappe.qb.DocType("Stock Ledger Entry") - timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime( - posting_date, posting_time - ) + timestamp_condition = sle.posting_datetime < get_combine_datetime(posting_date, posting_time) if creation: timestamp_condition |= ( - CombineDatetime(sle.posting_date, sle.posting_time) - == CombineDatetime(posting_date, posting_time) + sle.posting_datetime == get_combine_datetime(posting_date, posting_time) ) & (sle.creation < creation) batch_details = ( @@ -1401,6 +1422,7 @@ def get_valuation_rate( ) # Get valuation rate from last sle for the same item and warehouse +<<<<<<< HEAD if not last_valuation_rate or last_valuation_rate[0][0] is None: last_valuation_rate = frappe.db.sql( """select valuation_rate @@ -1416,6 +1438,20 @@ def get_valuation_rate( ) if last_valuation_rate: +======= + if last_valuation_rate := frappe.db.sql( + """select valuation_rate + from `tabStock Ledger Entry` force index (item_warehouse) + where + item_code = %s + AND warehouse = %s + AND valuation_rate >= 0 + AND is_cancelled = 0 + AND NOT (voucher_no = %s AND voucher_type = %s) + order by posting_datetime desc, name desc limit 1""", + (item_code, warehouse, voucher_no, voucher_type), + ): +>>>>>>> d80ca523a4 (perf: new column posting datetime in SLE to optimize stock ledger related queries) return flt(last_valuation_rate[0][0]) # If negative stock allowed, and item delivered without any incoming entry, @@ -1473,6 +1509,7 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): qty_shift = args.actual_qty args["time_format"] = "%H:%i:%s" + args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"]) # find difference/shift in qty caused by stock reconciliation if args.voucher_type == "Stock Reconciliation": @@ -1482,8 +1519,6 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): next_stock_reco_detail = get_next_stock_reco(args) if next_stock_reco_detail: detail = next_stock_reco_detail[0] - - # add condition to update SLEs before this date & time datetime_limit_condition = get_datetime_limit_condition(detail) frappe.db.sql( @@ -1496,13 +1531,9 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): and voucher_no != %(voucher_no)s and is_cancelled = 0 and ( - posting_date > %(posting_date)s or - ( - posting_date = %(posting_date)s and - time_format(posting_time, %(time_format)s) > time_format(%(posting_time)s, %(time_format)s) - ) + posting_datetime > %(posting_datetime)s ) - {datetime_limit_condition} + {datetime_limit_condition} """, args, ) @@ -1557,20 +1588,11 @@ def get_next_stock_reco(kwargs): & (sle.voucher_no != kwargs.get("voucher_no")) & (sle.is_cancelled == 0) & ( - ( - CombineDatetime(sle.posting_date, sle.posting_time) - > CombineDatetime(kwargs.get("posting_date"), kwargs.get("posting_time")) - ) - | ( - ( - CombineDatetime(sle.posting_date, sle.posting_time) - == CombineDatetime(kwargs.get("posting_date"), kwargs.get("posting_time")) - ) - & (sle.creation > kwargs.get("creation")) - ) + sle.posting_datetime + >= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) ) ) - .orderby(CombineDatetime(sle.posting_date, sle.posting_time)) + .orderby(sle.posting_datetime) .orderby(sle.creation) .limit(1) ) @@ -1582,11 +1604,13 @@ def get_next_stock_reco(kwargs): def get_datetime_limit_condition(detail): + posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time) + return f""" and - (timestamp(posting_date, posting_time) < timestamp('{detail.posting_date}', '{detail.posting_time}') + (posting_datetime < '{posting_datetime}' or ( - timestamp(posting_date, posting_time) = timestamp('{detail.posting_date}', '{detail.posting_time}') + posting_datetime = '{posting_datetime}' and creation < '{detail.creation}' ) )""" @@ -1648,6 +1672,7 @@ def is_negative_with_precision(neg_sle, is_batch=False): return qty_deficit < 0 and abs(qty_deficit) > 0.0001 +<<<<<<< HEAD def get_future_sle_with_negative_qty(sle): SLE = frappe.qb.DocType("Stock Ledger Entry") query = ( @@ -1668,6 +1693,27 @@ def get_future_sle_with_negative_qty(sle): ) .orderby(CombineDatetime(SLE.posting_date, SLE.posting_time)) .limit(1) +======= +def get_future_sle_with_negative_qty(args): + return frappe.db.sql( + """ + select + qty_after_transaction, posting_date, posting_time, + voucher_type, voucher_no + from `tabStock Ledger Entry` + where + item_code = %(item_code)s + and warehouse = %(warehouse)s + and voucher_no != %(voucher_no)s + and posting_datetime >= %(posting_datetime)s + and is_cancelled = 0 + and qty_after_transaction < 0 + order by posting_datetime asc + limit 1 + """, + args, + as_dict=1, +>>>>>>> d80ca523a4 (perf: new column posting datetime in SLE to optimize stock ledger related queries) ) if sle.voucher_type == "Stock Reconciliation" and sle.batch_no: @@ -1681,20 +1727,20 @@ def get_future_sle_with_negative_batch_qty(args): """ with batch_ledger as ( select - posting_date, posting_time, voucher_type, voucher_no, - sum(actual_qty) over (order by posting_date, posting_time, creation) as cumulative_total + posting_date, posting_time, posting_datetime, voucher_type, voucher_no, + sum(actual_qty) over (order by posting_datetime, creation) as cumulative_total from `tabStock Ledger Entry` where item_code = %(item_code)s and warehouse = %(warehouse)s and batch_no=%(batch_no)s and is_cancelled = 0 - order by posting_date, posting_time, creation + order by posting_datetime, creation ) select * from batch_ledger where cumulative_total < 0.0 - and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) + and posting_datetime >= %(posting_datetime)s limit 1 """, args, @@ -1746,6 +1792,7 @@ def is_internal_transfer(sle): def get_stock_value_difference(item_code, warehouse, posting_date, posting_time, voucher_no=None): table = frappe.qb.DocType("Stock Ledger Entry") + posting_datetime = get_combine_datetime(posting_date, posting_time) query = ( frappe.qb.from_(table) @@ -1754,10 +1801,7 @@ def get_stock_value_difference(item_code, warehouse, posting_date, posting_time, (table.is_cancelled == 0) & (table.item_code == item_code) & (table.warehouse == warehouse) - & ( - (table.posting_date < posting_date) - | ((table.posting_date == posting_date) & (table.posting_time <= posting_time)) - ) + & (table.posting_datetime <= posting_datetime) ) ) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 2b57a1be8fad..c6ee01d3df15 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -8,7 +8,7 @@ import frappe from frappe import _ from frappe.query_builder.functions import CombineDatetime, IfNull, Sum -from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime +from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime import erpnext from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses @@ -619,3 +619,18 @@ def _update_item_info(scan_result: Dict[str, Optional[str]]) -> Dict[str, Option ): scan_result.update(item_info) return scan_result + + +def get_combine_datetime(posting_date, posting_time): + import datetime + + if isinstance(posting_date, str): + posting_date = getdate(posting_date) + + if isinstance(posting_time, str): + posting_time = get_time(posting_time) + + if isinstance(posting_time, datetime.timedelta): + posting_time = (datetime.datetime.min + posting_time).time() + + return datetime.datetime.combine(posting_date, posting_time)