From f33de37da05a2df52e85fdc05c6c118914e73a54 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 7 Jun 2026 09:55:15 +0530 Subject: [PATCH] refactor(stock): extract Purchase Receipt StockReservationService Move stock reservation on PR submission into stock/doctype/purchase_receipt/services/stock_reservation.py: - reserve_stock, reserve_stock_for_sales_order, reserve_stock_for_production_plan and get_production_plan_references move into the service (internal-only; on_submit repointed) - delegates to the Sales Order controller contract (create_stock_reservation_entries) and the shared StockReservation class No behaviour change. --- .../purchase_receipt/purchase_receipt.py | 110 +--------------- .../services/stock_reservation.py | 121 ++++++++++++++++++ 2 files changed, 124 insertions(+), 107 deletions(-) create mode 100644 erpnext/stock/doctype/purchase_receipt/services/stock_reservation.py diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 1078a750e92..669e7ae47c8 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -6,7 +6,7 @@ import frappe from frappe import _, throw from frappe.desk.notifications import clear_doctype_notifications from frappe.model.document import Document -from frappe.utils import cint, flt, get_datetime, getdate, nowdate +from frappe.utils import cint, flt, getdate, nowdate import erpnext from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled @@ -15,7 +15,7 @@ from erpnext.stock.doctype.purchase_receipt.services.billing_status import Billi from erpnext.stock.doctype.purchase_receipt.services.provisional_accounting import ( ProvisionalAccountingService, ) -from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation +from erpnext.stock.doctype.purchase_receipt.services.stock_reservation import StockReservationService form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -374,7 +374,7 @@ class PurchaseReceipt(BuyingController): self.make_gl_entries() self.repost_future_sle_and_gle() self.set_consumed_qty_in_subcontract_order() - self.reserve_stock() + StockReservationService(self).reserve_stock() self.update_received_qty_if_from_pp() def update_received_qty_if_from_pp(self): @@ -513,110 +513,6 @@ class PurchaseReceipt(BuyingController): def update_billing_status(self, update_modified=True): BillingStatusService(self).update_billing_status(update_modified) - def reserve_stock(self): - self.reserve_stock_for_sales_order() - self.reserve_stock_for_production_plan() - - def reserve_stock_for_sales_order(self): - if ( - self.is_return - or not frappe.get_single_value("Stock Settings", "enable_stock_reservation") - or not frappe.get_single_value("Stock Settings", "auto_reserve_stock_for_sales_order_on_purchase") - ): - return - - self.reload() # reload to get the Serial and Batch Bundle Details - - so_items_details_map = {} - for item in self.items: - if item.sales_order and item.sales_order_item: - item_details = { - "sales_order_item": item.sales_order_item, - "item_code": item.item_code, - "warehouse": item.warehouse, - "qty_to_reserve": item.stock_qty, - "from_voucher_no": item.parent, - "from_voucher_detail_no": item.name, - "serial_and_batch_bundle": item.serial_and_batch_bundle, - } - so_items_details_map.setdefault(item.sales_order, []).append(item_details) - - if so_items_details_map: - if get_datetime(f"{self.posting_date} {self.posting_time}") > get_datetime(): - return frappe.msgprint( - _("Cannot create Stock Reservation Entries for future dated Purchase Receipts.") - ) - - for so, items_details in so_items_details_map.items(): - so_doc = frappe.get_lazy_doc("Sales Order", so) - so_doc.create_stock_reservation_entries( - items_details=items_details, - from_voucher_type="Purchase Receipt", - notify=True, - ) - - def reserve_stock_for_production_plan(self): - if self.is_return or not frappe.get_single_value("Stock Settings", "enable_stock_reservation"): - return - - production_plan_references = self.get_production_plan_references() - production_plan_items = [] - self.reload() - - docnames = [] - for row in self.items: - if row.material_request_item and row.material_request_item in production_plan_references: - _ref = production_plan_references[row.material_request_item] - docnames.append(_ref.production_plan) - row.update( - { - "voucher_type": "Production Plan", - "voucher_no": _ref.production_plan, - "voucher_detail_no": _ref.material_request_plan_item, - "from_voucher_no": self.name, - "from_voucher_detail_no": row.name, - "from_voucher_type": self.doctype, - "serial_and_batch_bundles": [row.serial_and_batch_bundle], - } - ) - - production_plan_items.append(row) - - if not production_plan_items: - return - - sre = StockReservation(doc=self, items=production_plan_items) - sre.make_stock_reservation_entries() - if docnames: - sre.transfer_reservation_entries_to( - docnames, from_doctype="Production Plan", to_doctype="Work Order" - ) - - def get_production_plan_references(self): - production_plan_references = frappe._dict() - material_request_items = [] - - for row in self.items: - if row.material_request_item: - material_request_items.append(row.material_request_item) - - if not material_request_items: - return frappe._dict() - - items = frappe.get_all( - "Material Request Item", - fields=["material_request_plan_item", "production_plan", "name"], - filters={"name": ["in", material_request_items], "docstatus": 1}, - ) - - for item in items: - if not item.production_plan: - continue - - production_plan_references.setdefault(item.name, item) - - return production_plan_references - def enable_recalculate_rate_in_sles(self): rejected_warehouses = frappe.get_all( "Purchase Receipt Item", filters={"parent": self.name}, pluck="rejected_warehouse" diff --git a/erpnext/stock/doctype/purchase_receipt/services/stock_reservation.py b/erpnext/stock/doctype/purchase_receipt/services/stock_reservation.py new file mode 100644 index 00000000000..c9cf00d9d0a --- /dev/null +++ b/erpnext/stock/doctype/purchase_receipt/services/stock_reservation.py @@ -0,0 +1,121 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Stock reservation on Purchase Receipt submission (Sales Order / Production Plan).""" + +import frappe +from frappe import _ +from frappe.utils import get_datetime + +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation + + +class StockReservationService: + def __init__(self, doc): + self.doc = doc + + def reserve_stock(self) -> None: + self.reserve_stock_for_sales_order() + self.reserve_stock_for_production_plan() + + def reserve_stock_for_sales_order(self) -> None: + doc = self.doc + if ( + doc.is_return + or not frappe.get_single_value("Stock Settings", "enable_stock_reservation") + or not frappe.get_single_value("Stock Settings", "auto_reserve_stock_for_sales_order_on_purchase") + ): + return + + doc.reload() # reload to get the Serial and Batch Bundle Details + + so_items_details_map = {} + for item in doc.items: + if item.sales_order and item.sales_order_item: + item_details = { + "sales_order_item": item.sales_order_item, + "item_code": item.item_code, + "warehouse": item.warehouse, + "qty_to_reserve": item.stock_qty, + "from_voucher_no": item.parent, + "from_voucher_detail_no": item.name, + "serial_and_batch_bundle": item.serial_and_batch_bundle, + } + so_items_details_map.setdefault(item.sales_order, []).append(item_details) + + if so_items_details_map: + if get_datetime(f"{doc.posting_date} {doc.posting_time}") > get_datetime(): + return frappe.msgprint( + _("Cannot create Stock Reservation Entries for future dated Purchase Receipts.") + ) + + for so, items_details in so_items_details_map.items(): + so_doc = frappe.get_lazy_doc("Sales Order", so) + so_doc.create_stock_reservation_entries( + items_details=items_details, + from_voucher_type="Purchase Receipt", + notify=True, + ) + + def reserve_stock_for_production_plan(self) -> None: + doc = self.doc + if doc.is_return or not frappe.get_single_value("Stock Settings", "enable_stock_reservation"): + return + + production_plan_references = self.get_production_plan_references() + production_plan_items = [] + doc.reload() + + docnames = [] + for row in doc.items: + if row.material_request_item and row.material_request_item in production_plan_references: + _ref = production_plan_references[row.material_request_item] + docnames.append(_ref.production_plan) + row.update( + { + "voucher_type": "Production Plan", + "voucher_no": _ref.production_plan, + "voucher_detail_no": _ref.material_request_plan_item, + "from_voucher_no": doc.name, + "from_voucher_detail_no": row.name, + "from_voucher_type": doc.doctype, + "serial_and_batch_bundles": [row.serial_and_batch_bundle], + } + ) + + production_plan_items.append(row) + + if not production_plan_items: + return + + sre = StockReservation(doc=doc, items=production_plan_items) + sre.make_stock_reservation_entries() + if docnames: + sre.transfer_reservation_entries_to( + docnames, from_doctype="Production Plan", to_doctype="Work Order" + ) + + def get_production_plan_references(self) -> frappe._dict: + production_plan_references = frappe._dict() + material_request_items = [] + + for row in self.doc.items: + if row.material_request_item: + material_request_items.append(row.material_request_item) + + if not material_request_items: + return frappe._dict() + + items = frappe.get_all( + "Material Request Item", + fields=["material_request_plan_item", "production_plan", "name"], + filters={"name": ["in", material_request_items], "docstatus": 1}, + ) + + for item in items: + if not item.production_plan: + continue + + production_plan_references.setdefault(item.name, item) + + return production_plan_references