Repository: ParaLogicTech/erpnext
Pull request #401: [feature] Purchase Order: (26) Add new fields Instock and Projected qty in purchase order
Author: login: Rakeshsingu
Created: 2025-04-18T06:18:39Z | Merged: 2025-04-21T18:25:13Z

🔧 Backend Implementation
---
- Added two fields, _In stock_ and _Projected Qty_, to the `Purchase Order Item` doctype. These fields were also included in `purchase_order_item.json`.

- Updated the onload method in the buying controller to populate each item with bin details and batch-level quantity for actual_batch_qty if applicable.

- Added `setup_projected_qty_formatter` calls in `buying.js` for actual_qty and projected_qty to enable custom field formatting and linking to the Stock Projected Qty report.

Please review and confirm: [https://github.com/ParaLogicTech/automotive/issues/26#issuecomment-2814690621](https://github.com/ParaLogicTech/automotive/issues/26#issuecomment-2814690621 )

---
Closes [issue link 26](https://github.com/ParaLogicTech/automotive/issues/26)


Diff:
diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
index 79fb09b319b5..4eaabc01846e 100644
--- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
+++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
@@ -135,12 +135,14 @@
   "item_group",
   "brand",
   "section_break_56",
-  "last_purchase_rate",
+  "actual_qty",
+  "projected_qty",
   "column_break_60",
-  "total_returned_qty",
-  "returned_qty",
+  "last_purchase_rate",
   "column_break_119",
   "received_qty",
+  "total_returned_qty",
+  "returned_qty",
   "column_break_121",
   "billed_qty",
   "billed_amt",
@@ -1239,12 +1241,32 @@
    "options": "Work Order",
    "read_only": 1,
    "search_index": 1
+  },
+  {
+   "allow_on_submit": 1,
+   "fieldname": "actual_qty",
+   "fieldtype": "Float",
+   "label": " In Stock",
+   "no_copy": 1,
+   "print_width": "70px",
+   "read_only": 1,
+   "width": "70px"
+  },
+  {
+   "allow_on_submit": 1,
+   "fieldname": "projected_qty",
+   "fieldtype": "Float",
+   "label": "Projected Qty",
+   "no_copy": 1,
+   "print_width": "70px",
+   "read_only": 1,
+   "width": "70px"
   }
  ],
  "idx": 1,
  "istable": 1,
  "links": [],
- "modified": "2025-01-24 17:37:54.933060",
+ "modified": "2025-04-21 22:18:21.517891",
  "modified_by": "Administrator",
  "module": "Buying",
  "name": "Purchase Order Item",
diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py
index 18a3c36f2cf4..ee6aee46789b 100644
--- a/erpnext/controllers/selling_controller.py
+++ b/erpnext/controllers/selling_controller.py
@@ -4,10 +4,9 @@
 import frappe
 from frappe.utils import cint, flt, cstr
 from frappe import _
-from erpnext.stock.get_item_details import get_bin_details
 from erpnext.stock.utils import get_incoming_rate, has_valuation_read_permission
 from erpnext.stock.get_item_details import get_target_warehouse_validation, item_has_product_bundle
-from erpnext.stock.doctype.batch.batch import get_batch_qty, auto_select_and_split_batches
+from erpnext.stock.doctype.batch.batch import auto_select_and_split_batches
 from erpnext.overrides.sales_person.sales_person_hooks import get_sales_person_commission_details
 from erpnext.overrides.campaign.campaign_hooks import validate_campaign_voucher_code
 from erpnext.controllers.transaction_controller import TransactionController
@@ -39,14 +38,6 @@ def onload(self):
 			self.set_onload("is_internal_customer",
 				frappe.get_cached_value("Customer", self.get("bill_to") or self.customer, "is_internal_customer"))
 
-			for item in self.get("items"):
-				item.update(get_bin_details(item.item_code, item.warehouse))
-				if item.meta.has_field('actual_batch_qty'):
-					if item.get('batch_no'):
-						item.actual_batch_qty = get_batch_qty(item.batch_no, item.warehouse, item.item_code)
-					else:
-						item.actual_batch_qty = 0
-
 		if self.docstatus == 0 and self.meta.get_field("currency"):
 			self.calculate_taxes_and_totals()
 
diff --git a/erpnext/controllers/transaction_controller.py b/erpnext/controllers/transaction_controller.py
index 50025ac21695..045aa048b033 100644
--- a/erpnext/controllers/transaction_controller.py
+++ b/erpnext/controllers/transaction_controller.py
@@ -2,7 +2,13 @@
 from frappe import _
 from frappe.utils import flt, cstr, cint
 from erpnext.controllers.stock_controller import StockController
-from erpnext.stock.get_item_details import get_item_details, get_applies_to_details, get_force_applies_to_fields
+from erpnext.stock.get_item_details import (
+	get_item_details,
+	get_applies_to_details,
+	get_force_applies_to_fields,
+	get_bin_details,
+)
+from erpnext.stock.doctype.batch.batch import get_batch_qty
 from erpnext.accounts.doctype.pricing_rule.utils import (
 	apply_pricing_rule_for_free_items, get_applied_pricing_rules,
 	apply_pricing_rule_on_transaction, update_pricing_rule_table
@@ -95,8 +101,23 @@ def __init__(self, *args, **kwargs):
 
 	def onload(self):
 		super().onload()
+
 		self.set_onload("enable_dynamic_bundling", self.dynamic_bundling_enabled())
 
+		for item in self.get("items") or []:
+			if (
+				item.meta.has_field("item_code")
+				and item.meta.has_field("warehouse")
+				and (item.meta.has_field('actual_qty') or item.meta.has_field('projected_qty'))
+			):
+				item.update(get_bin_details(item.item_code, item.warehouse))
+
+			if item.meta.has_field('actual_batch_qty'):
+				if item.get('batch_no'):
+					item.actual_batch_qty = get_batch_qty(item.batch_no, item.warehouse, item.item_code)
+				else:
+					item.actual_batch_qty = 0
+
 	def before_print(self, print_settings=None):
 		super().before_print(print_settings)
 
diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js
index 6414cde57bc9..6cff024daad0 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -13,6 +13,9 @@ erpnext.buying.BuyingController = class BuyingController extends erpnext.Transac
 	setup() {
 		super.setup();
 
+		erpnext.utils.setup_projected_qty_formatter(this.frm.doc.doctype + " Item", "actual_qty");
+		erpnext.utils.setup_projected_qty_formatter(this.frm.doc.doctype + " Item", "projected_qty");
+
 		frappe.ui.form.on(this.frm.doctype + " Item", {
 			items_add: function(frm, cdt, cdn) {
 				var item = frappe.get_doc(cdt, cdn);
diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py
index a0e8902cce75..53acf2263b63 100644
--- a/erpnext/stock/doctype/material_request/material_request.py
+++ b/erpnext/stock/doctype/material_request/material_request.py
@@ -15,7 +15,6 @@
 from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
 from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items
 from erpnext.stock.get_item_details import get_default_supplier
-from erpnext.stock.get_item_details import get_bin_details
 
 
 form_grid_templates = {
@@ -40,11 +39,6 @@ def __init__(self, *args, **kwargs):
 	def get_feed(self):
 		return _("{0}: {1}").format(self.status, self.material_request_type)
 
-	def onload(self):
-		super().onload()
-		for item in self.get("items"):
-			item.update(get_bin_details(item.item_code, item.warehouse))
-
 	def before_print(self, print_settings=None):
 		super().before_print(print_settings)
 		self.from_warehouse_name = frappe.get_cached_value("Warehouse", self.from_warehouse, "warehouse_name")
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index c303a80a7c19..4c58e8550c00 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -1214,15 +1214,20 @@ def get_projected_qty(item_code, warehouse):
 
 @frappe.whitelist()
 def get_bin_details(item_code, warehouse):
+	empty = frappe._dict({"projected_qty": 0, "actual_qty": 0, "reserved_qty": 0})
+
 	def generator():
 		return frappe.db.get_value(
 			"Bin",
 			{"item_code": item_code, "warehouse": warehouse},
 			["projected_qty", "actual_qty", "reserved_qty"],
 			as_dict=True
-		) or {"projected_qty": 0, "actual_qty": 0, "reserved_qty": 0}
+		) or empty
 
-	return frappe.local_cache("get_bin_details", (item_code, warehouse), generator)
+	if not item_code or not warehouse:
+		return empty
+	else:
+		return frappe.local_cache("get_bin_details", (item_code, warehouse), generator)
 
 
 @frappe.whitelist()


Reviews:
user: login: SaiFi0102
body: Looks good, indentation issue, can be commonified: same logic is used in SellingController and MaterialRequest... if we commonify we can move this part into TransactionController and update the bin details as long as there is `actual_qty` in the line item DocType
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-18T07:37:32Z
user: login: SaiFi0102
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-18T09:38:17Z
user: login: SaiFi0102
body: - Add the `item.update(get_bin_details(item.item_code, item.warehouse))` in `TransactionController.onload`
- Move the actual_batch_qty in ``TransactionController.onload` as well
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-21T11:43:47Z
user: login: SaiFi0102
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-21T18:24:52Z

Review comments:
diff_hunk: @@ -44,6 +46,14 @@ def onload(self):
 			if self.doctype in ("Supplier Quotation", "Purchase Order", "Purchase Receipt", "Purchase Invoice"):
 				self.calculate_taxes_and_totals()
 
+		for item in self.get("items"):
+				item.update(get_bin_details(item.item_code, item.warehouse))
+				if item.meta.has_field('actual_batch_qty'):
+					if item.get('batch_no'):
+						item.actual_batch_qty = get_batch_qty(item.batch_no, item.warehouse, item.item_code)
+					else:
+						item.actual_batch_qty = 0
+
path: erpnext/controllers/buying_controller.py
user: login: SaiFi0102
body: - fix indentation
- can be commonified between Selling Controller and Material Request (optional)
created_at: 2025-04-18T07:34:07Z
author_association: MEMBER
diff_hunk: @@ -44,6 +46,14 @@ def onload(self):
 			if self.doctype in ("Supplier Quotation", "Purchase Order", "Purchase Receipt", "Purchase Invoice"):
 				self.calculate_taxes_and_totals()
 
+		for item in self.get("items"):
+				item.update(get_bin_details(item.item_code, item.warehouse))
+				if item.meta.has_field('actual_batch_qty'):
+					if item.get('batch_no'):
+						item.actual_batch_qty = get_batch_qty(item.batch_no, item.warehouse, item.item_code)
+					else:
+						item.actual_batch_qty = 0
+
path: erpnext/controllers/buying_controller.py
user: login: SaiFi0102
body: - remove from Material Request, Selling Controller, Buying Controller
- move to Transaction Controller
- add additional condition `if item.meta.has_field('actual_qty')` for `get_bin_details`
created_at: 2025-04-18T09:36:37Z
author_association: MEMBER

PR comments:
user: login: Rakeshsingu
created_at: 2025-04-21T05:40:00Z
body: While debugging and analysing the code, I observed that the `get_item_details `method in `get_item_details.py` is responsible for calling the `get_bin_details `method, which in turn fetches the `actual_qty `and `projected_qty `using the **item code** and **warehouse** in sales order, purchase order, material request. However, I was unable to trace where the get_item_details method is being invoked from. I'm sure that the `onload `methods in the Selling Controller, Buying Controller, and Material Request are not responsible for fetching the actual_qty and projected_qty details.

As a cleanup, from the **Onload** method, we can remove `get_bin_details` calls from the Selling Controller, Buying Controller, and Material Request, since these parts of the code are not responsible for fetching the quantity details.

[https://github.com/ParaLogicTech/erpnext/blob/7e83855084f8fbcc84391da9a58d8cb664b821a7/erpnext/stock/get_item_details.py#L73-L74](https://github.com/ParaLogicTech/erpnext/blob/7e83855084f8fbcc84391da9a58d8cb664b821a7/erpnext/stock/get_item_details.py#L73-L74)
author_association: NONE
user: login: SaiFi0102
created_at: 2025-04-21T08:12:27Z
body: @Rakeshsingu `get_item_details` is called from:

- Frontend: `item_code` event handler in `transaction.js`, whenever you select an item in the items table, it'll call `get_item_details` which includes `get_bin_details`, and fills in the values on the client
- Backend: `SellingController.set_missing_values` and `BuyingController.set_missing_values` calls `set_missing_item_details`, `set_missing_item_details` calls `get_item_details` to fill in the missing field values and some fields are forced set based on `TransactionController.force_item_fields`

They both work together so that the way item details are filled in client side and backend are similar.
author_association: MEMBER