Merge branch 'develop' into stock-reservation
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 1e4fabe..6e2fb2e 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -783,6 +783,75 @@
gl_entries.append(self.get_gl_dict(gl_entry, item=item))
+ def make_sr_entries(self):
+ if not self.get("reserve_stock"):
+ return
+
+ if self.doctype != "Sales Order":
+ frappe.throw(
+ _("Stock Reservation can only be created against a {0}.").format(frappe.bold("Sales Order"))
+ )
+
+ if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
+ frappe.throw(
+ _("Please enable {0} in the {1}.").format(
+ frappe.bold("Stock Reservation"), frappe.bold("Stock Settings")
+ )
+ )
+
+ if not frappe.db.get_single_value("Stock Settings", "reserve_stock_on_sales_order_submission"):
+ frappe.throw(
+ _("Please enable {0} in the {1}.").format(
+ frappe.bold("Reserve Stock on Sales Order Submission"), frappe.bold("Stock Settings")
+ )
+ )
+
+ for item in self.get("items"):
+ if not item.get("reserve_stock"):
+ continue
+
+ available_qty = get_available_qty_to_reserve(item.item_code, item.warehouse)
+ reserved_qty = min(item.stock_qty, available_qty)
+
+ if not reserved_qty:
+ frappe.msgprint(
+ _("Row {0}: No available stock to reserve for the Item {1}").format(
+ item.idx, frappe.bold(item.item_code)
+ ),
+ title=_("Stock Reservation"),
+ indicator="orange",
+ )
+ continue
+
+ elif reserved_qty < item.stock_qty:
+ frappe.msgprint(
+ _("Row {0}: Only {1} available to reserve for the Item {2}").format(
+ item.idx,
+ frappe.bold(str(reserved_qty / item.conversion_factor) + " " + item.uom),
+ frappe.bold(item.item_code),
+ ),
+ title=_("Stock Reservation"),
+ indicator="orange",
+ )
+
+ if not frappe.db.get_single_value("Stock Settings", "allow_partial_reservation"):
+ continue
+
+ sre = frappe.new_doc("Stock Reservation Entry")
+ sre.item_code = item.item_code
+ sre.warehouse = item.warehouse
+ sre.voucher_type = self.doctype
+ sre.voucher_no = self.name
+ sre.voucher_detail_no = item.name
+ sre.available_qty = available_qty
+ sre.voucher_qty = item.stock_qty
+ sre.reserved_qty = reserved_qty
+ sre.company = self.company
+ sre.stock_uom = item.stock_uom
+ sre.project = self.project
+ sre.save()
+ sre.submit()
+
def repost_required_for_queue(doc: StockController) -> bool:
"""check if stock document contains repeated item-warehouse with queue based valuation.
@@ -952,6 +1021,33 @@
return or_conditions
+@frappe.whitelist()
+def get_available_qty_to_reserve(item_code, warehouse):
+ from frappe.query_builder.functions import Sum
+
+ from erpnext.stock.utils import get_stock_balance
+
+ available_qty = get_stock_balance(item_code, warehouse)
+
+ if available_qty:
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ reserved_qty = (
+ 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.status.notin(["Delivered", "Cancelled"]))
+ )
+ ).run()[0][0] or 0.0
+
+ if reserved_qty:
+ return available_qty - reserved_qty
+
+ return available_qty
+
+
def create_repost_item_valuation_entry(args):
args = frappe._dict(args)
repost_entry = frappe.new_doc("Repost Item Valuation")
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 449d461..d222c3e 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -46,6 +46,8 @@
frm.set_df_property('packed_items', 'cannot_add_rows', true);
frm.set_df_property('packed_items', 'cannot_delete_rows', true);
+
+
},
refresh: function(frm) {
if(frm.doc.docstatus === 1 && frm.doc.status !== 'Closed'
@@ -60,8 +62,27 @@
});
}
- if (frm.doc.docstatus === 0 && frm.doc.is_internal_customer) {
- frm.events.get_items_from_internal_purchase_order(frm);
+ if (frm.doc.docstatus === 0) {
+ if (frm.doc.is_internal_customer) {
+ frm.events.get_items_from_internal_purchase_order(frm);
+ }
+
+ if (frm.is_new()) {
+ frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => {
+ if (value) {
+ frappe.db.get_single_value("Stock Settings", "reserve_stock_on_sales_order_submission").then((value) => {
+ if (value) {
+ frm.set_value("reserve_stock", 1);
+ } else {
+ frm.set_value("reserve_stock", 0);
+ }
+ })
+ } else {
+ frm.set_value("reserve_stock", 0);
+ frm.set_df_property("reserve_stock", "read_only", 1);
+ }
+ })
+ }
}
},
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index ccea840..40cb17d 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -46,6 +46,7 @@
"scan_barcode",
"column_break_28",
"set_warehouse",
+ "reserve_stock",
"items_section",
"items",
"section_break_31",
@@ -1637,13 +1638,20 @@
"fieldname": "named_place",
"fieldtype": "Data",
"label": "Named Place"
+ },
+ {
+ "default": "0",
+ "fieldname": "reserve_stock",
+ "fieldtype": "Check",
+ "label": "Reserve Stock",
+ "no_copy": 1
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2022-12-12 18:34:00.681780",
+ "modified": "2023-03-20 23:51:04.036757",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index ee9161b..5accaf6 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -241,6 +241,8 @@
update_coupon_code_count(self.coupon_code, "used")
+ self.make_sr_entries()
+
def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
super(SalesOrder, self).on_cancel()
@@ -620,7 +622,36 @@
@frappe.whitelist()
def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_stock_reservation_entries_for_voucher,
+ has_reserved_stock,
+ )
+
def set_missing_values(source, target):
+ if not target.items and has_reserved_stock("Sales Order", source_name):
+ sre_list = get_stock_reservation_entries_for_voucher("Sales Order", source_name)
+ sre_dict = {d.pop("voucher_detail_no"): d for d in sre_list}
+
+ for item in source.get("items"):
+ if item.name in sre_dict:
+ qty_to_deliver = (
+ sre_dict[item.name]["reserved_qty"] - sre_dict[item.name]["delivered_qty"]
+ ) / item.conversion_factor
+
+ row = frappe.new_doc("Delivery Note Item")
+ row.against_sales_order = source.name
+ row.against_sre = sre_dict[item.name]["name"]
+ row.so_detail = item.name
+ row.item_code = item.item_code
+ row.item_name = item.item_name
+ row.description = item.description
+ row.qty = qty_to_deliver
+ row.stock_uom = item.stock_uom
+ row.uom = item.uom
+ row.conversion_factor = item.conversion_factor
+
+ target.append("items", row)
+
target.run_method("set_missing_values")
target.run_method("set_po_nos")
target.run_method("calculate_taxes_and_totals")
@@ -649,6 +680,9 @@
or item_group.get("buying_cost_center")
)
+ if has_reserved_stock("Sales Order", source_name):
+ skip_item_mapping = True
+
mapper = {
"Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
@@ -676,7 +710,6 @@
}
target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values)
-
target_doc.set_onload("ignore_price_list", True)
return target_doc
diff --git a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py
index cbc40bb..c840097 100644
--- a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py
+++ b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py
@@ -11,6 +11,7 @@
"Payment Request": "reference_name",
"Auto Repeat": "reference_document",
"Maintenance Visit": "prevdoc_docname",
+ "Stock Reservation Entry": "voucher_no",
},
"internal_links": {
"Quotation": ["items", "prevdoc_docname"],
@@ -23,7 +24,7 @@
{"label": _("Purchasing"), "items": ["Material Request", "Purchase Order"]},
{"label": _("Projects"), "items": ["Project"]},
{"label": _("Manufacturing"), "items": ["Work Order"]},
- {"label": _("Reference"), "items": ["Quotation", "Auto Repeat"]},
+ {"label": _("Reference"), "items": ["Quotation", "Auto Repeat", "Stock Reservation Entry"]},
{"label": _("Payment"), "items": ["Payment Entry", "Payment Request", "Journal Entry"]},
],
}
diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
index d0dabad..be85d9a 100644
--- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json
+++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
@@ -10,6 +10,7 @@
"item_code",
"customer_item_code",
"ensure_delivery_based_on_produced_serial_no",
+ "reserve_stock",
"col_break1",
"delivery_date",
"item_name",
@@ -27,6 +28,7 @@
"uom",
"conversion_factor",
"stock_qty",
+ "stock_reserved_qty",
"section_break_16",
"price_list_rate",
"base_price_list_rate",
@@ -859,12 +861,28 @@
"fieldname": "material_request_item",
"fieldtype": "Data",
"label": "Material Request Item"
+ },
+ {
+ "default": "1",
+ "depends_on": "eval: parent.reserve_stock",
+ "fieldname": "reserve_stock",
+ "fieldtype": "Check",
+ "label": "Reserve Stock"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: (parent.reserve_stock && doc.reserve_stock)",
+ "fieldname": "stock_reserved_qty",
+ "fieldtype": "Float",
+ "label": "Stock Reserved Qty (in Stock UOM)",
+ "no_copy": 1,
+ "read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-12-25 02:51:10.247569",
+ "modified": "2023-03-21 13:14:47.915610",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js
index ae56645..53b3576 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.js
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.js
@@ -77,6 +77,19 @@
}
});
+ frm.set_query("against_sre", "items", (doc, cdt, cdn) => {
+ var row = locals[cdt][cdn];
+ return {
+ filters: {
+ "docstatus": 1,
+ "status": ["not in", ["Delivered", "Cancelled"]],
+ "voucher_type": "Sales Order",
+ "voucher_no": row.against_sales_order,
+ "voucher_detail_no": row.so_detail,
+ }
+ }
+ });
+
frm.set_df_property('packed_items', 'cannot_add_rows', true);
frm.set_df_property('packed_items', 'cannot_delete_rows', true);
},
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 9f9f5cb..77c435e 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -147,6 +147,8 @@
if not self.installation_status:
self.installation_status = "Not Installed"
+
+ self.validate_against_sre()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
def validate_with_previous_doc(self):
@@ -239,6 +241,8 @@
self.update_prevdoc_status()
self.update_billing_status()
+ self.update_stock_reservation_entry()
+
if not self.is_return:
self.check_credit_limit()
elif self.issue_credit_note:
@@ -258,6 +262,8 @@
self.update_prevdoc_status()
self.update_billing_status()
+ self.update_stock_reservation_entry()
+
# Updating stock ledger should always be called after updating prevdoc status,
# because updating reserved qty in bin depends upon updated delivered qty in SO
self.update_stock_ledger()
@@ -268,6 +274,88 @@
self.repost_future_sle_and_gle()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
+ def update_stock_reservation_entry(self):
+ if not self.is_return:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ update_sre_delivered_qty,
+ )
+
+ for item in self.get("items"):
+ if item.against_sre:
+ update_sre_delivered_qty(item.doctype, item.against_sre)
+
+ def validate_against_sre(self):
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_stock_reservation_entries_for_items,
+ has_reserved_stock,
+ )
+
+ sre_details = get_stock_reservation_entries_for_items(self.items)
+
+ for item in self.items:
+ if item.against_sre:
+ sre = sre_details[item.against_sre]
+
+ # SRE `docstatus` should be `1` (submitted)
+ if sre.docstatus == 0:
+ frappe.throw(
+ _("Row #{0}: Stock Reservation Entry {1} is not submitted").format(
+ item.idx, item.against_sre
+ )
+ )
+ elif sre.docstatus == 2:
+ frappe.throw(
+ _("Row #{0}: Stock Reservation Entry {0} is cancelled").format(item.idx, item.against_sre)
+ )
+
+ # SRE `status` should not be `Delivered`
+ if sre.status == "Delivered":
+ frappe.throw(
+ _("Row #{0}: Cannot deliver more against Stock Reservation Entry {1}").format(
+ item.idx, item.against_sre
+ )
+ )
+
+ for field in (
+ "item_code",
+ "warehouse",
+ ("against_sales_order", "voucher_no"),
+ ("so_detail", "voucher_detail_no"),
+ ):
+ item_field = sre_field = None
+
+ if isinstance(field, tuple):
+ item_field, sre_field = field[0], field[1]
+ else:
+ item_field = sre_field = field
+
+ if item.get(item_field) != sre.get(sre_field):
+ frappe.throw(
+ _("Row #{0}: {1} {2} does not match with Stock Reservation Entry {3}").format(
+ item.idx,
+ frappe.get_meta(item.doctype).get_label(item_field),
+ item.get(item_field),
+ item.against_sre,
+ )
+ )
+
+ max_delivered_qty = (sre.reserved_qty - sre.delivered_qty) / item.conversion_factor
+ if item.qty > max_delivered_qty:
+ frappe.throw(
+ _("Row #{0}: Cannot deliver more than {1} {2} against Stock Reservation Entry {3}").format(
+ item.idx, max_delivered_qty, item.uom, item.against_sre
+ )
+ )
+ elif item.against_sales_order:
+ if not item.so_detail:
+ frappe.throw(_("Row #{0}: Sales Order Item reference is required").format(item.idx))
+ elif has_reserved_stock("Sales Order", item.against_sales_order, item.so_detail):
+ frappe.throw(
+ _("Row #{0}: Cannot deliver against Sales Order {1} without Stock Reservation Entry").format(
+ item.idx, item.against_sales_order
+ )
+ )
+
def check_credit_limit(self):
from erpnext.selling.doctype.customer.customer import check_credit_limit
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py
index b6b5ff4..9c64c17 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py
@@ -13,10 +13,14 @@
"Sales Order": ["items", "against_sales_order"],
"Material Request": ["items", "material_request"],
"Purchase Order": ["items", "purchase_order"],
+ "Stock Reservation Entry": ["items", "against_sre"],
},
"transactions": [
{"label": _("Related"), "items": ["Sales Invoice", "Packing Slip", "Delivery Trip"]},
- {"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]},
+ {
+ "label": _("Reference"),
+ "items": ["Sales Order", "Shipment", "Quality Inspection", "Stock Reservation Entry"],
+ },
{"label": _("Returns"), "items": ["Stock Entry"]},
{"label": _("Subscription"), "items": ["Auto Repeat"]},
{"label": _("Internal Transfer"), "items": ["Material Request", "Purchase Order"]},
diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
index 1763269..faa7748 100644
--- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
+++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
@@ -76,6 +76,7 @@
"si_detail",
"dn_detail",
"pick_list_item",
+ "against_sre",
"section_break_40",
"batch_no",
"serial_no",
@@ -832,13 +833,22 @@
"fieldname": "material_request_item",
"fieldtype": "Data",
"label": "Material Request Item"
+ },
+ {
+ "fieldname": "against_sre",
+ "fieldtype": "Link",
+ "label": "Against Stock Reservation Entry",
+ "no_copy": 1,
+ "options": "Stock Reservation Entry",
+ "print_hide": 1,
+ "read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2023-03-20 14:24:10.406746",
+ "modified": "2023-03-26 16:53:08.283469",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",
diff --git a/erpnext/stock/doctype/stock_reservation_entry/__init__.py b/erpnext/stock/doctype/stock_reservation_entry/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/__init__.py
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js
new file mode 100644
index 0000000..666fd24
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Stock Reservation Entry", {
+ refresh(frm) {
+ frm.page.btn_primary.hide()
+ },
+});
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
new file mode 100644
index 0000000..8b1bc43
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
@@ -0,0 +1,272 @@
+{
+ "actions": [],
+ "allow_copy": 1,
+ "autoname": "MAT-SRE-.YYYY.-.#####",
+ "creation": "2023-03-20 10:45:59.258959",
+ "default_view": "List",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "item_code",
+ "warehouse",
+ "posting_date",
+ "posting_time",
+ "column_break_elik",
+ "voucher_type",
+ "voucher_no",
+ "voucher_detail_no",
+ "section_break_xt4m",
+ "available_qty",
+ "voucher_qty",
+ "stock_uom",
+ "column_break_o6ex",
+ "reserved_qty",
+ "delivered_qty",
+ "section_break_3vb3",
+ "company",
+ "project",
+ "column_break_jbyr",
+ "batch_no",
+ "serial_no",
+ "status",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Item Code",
+ "oldfieldname": "item_code",
+ "oldfieldtype": "Link",
+ "options": "Item",
+ "print_width": "100px",
+ "read_only": 1,
+ "search_index": 1,
+ "width": "100px"
+ },
+ {
+ "fieldname": "warehouse",
+ "fieldtype": "Link",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Warehouse",
+ "oldfieldname": "warehouse",
+ "oldfieldtype": "Link",
+ "options": "Warehouse",
+ "print_width": "100px",
+ "read_only": 1,
+ "search_index": 1,
+ "width": "100px"
+ },
+ {
+ "fieldname": "posting_date",
+ "fieldtype": "Date",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "label": "Posting Date",
+ "oldfieldname": "posting_date",
+ "oldfieldtype": "Date",
+ "print_width": "100px",
+ "read_only": 1,
+ "search_index": 1,
+ "width": "100px"
+ },
+ {
+ "fieldname": "posting_time",
+ "fieldtype": "Time",
+ "label": "Posting Time",
+ "oldfieldname": "posting_time",
+ "oldfieldtype": "Time",
+ "print_width": "100px",
+ "read_only": 1,
+ "width": "100px"
+ },
+ {
+ "fieldname": "voucher_type",
+ "fieldtype": "Select",
+ "in_filter": 1,
+ "label": "Voucher Type",
+ "oldfieldname": "voucher_type",
+ "oldfieldtype": "Data",
+ "options": "\nSales Order",
+ "print_width": "150px",
+ "read_only": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "voucher_no",
+ "fieldtype": "Dynamic Link",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Voucher No",
+ "oldfieldname": "voucher_no",
+ "oldfieldtype": "Data",
+ "options": "voucher_type",
+ "print_width": "150px",
+ "read_only": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "voucher_detail_no",
+ "fieldtype": "Data",
+ "label": "Voucher Detail No",
+ "oldfieldname": "voucher_detail_no",
+ "oldfieldtype": "Data",
+ "print_width": "150px",
+ "read_only": 1,
+ "search_index": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "stock_uom",
+ "fieldtype": "Link",
+ "label": "Stock UOM",
+ "oldfieldname": "stock_uom",
+ "oldfieldtype": "Data",
+ "options": "UOM",
+ "print_width": "150px",
+ "read_only": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "project",
+ "fieldtype": "Link",
+ "label": "Project",
+ "options": "Project",
+ "read_only": 1
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "in_filter": 1,
+ "label": "Company",
+ "oldfieldname": "company",
+ "oldfieldtype": "Data",
+ "options": "Company",
+ "print_width": "150px",
+ "read_only": 1,
+ "search_index": 1,
+ "width": "150px"
+ },
+ {
+ "fieldname": "reserved_qty",
+ "fieldtype": "Float",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "label": "Reserved Qty",
+ "oldfieldname": "actual_qty",
+ "oldfieldtype": "Currency",
+ "print_width": "150px",
+ "read_only": 1,
+ "width": "150px"
+ },
+ {
+ "default": "Draft",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "hidden": 1,
+ "label": "Status",
+ "options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled",
+ "read_only": 1
+ },
+ {
+ "fieldname": "delivered_qty",
+ "fieldtype": "Float",
+ "label": "Delivered Qty",
+ "read_only": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Stock Reservation Entry",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "available_qty",
+ "fieldtype": "Float",
+ "label": "Available Qty to Reserve",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "voucher_qty",
+ "fieldtype": "Float",
+ "label": "Voucher Qty",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_elik",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_xt4m",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_o6ex",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_3vb3",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_jbyr",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "batch_no",
+ "fieldtype": "Data",
+ "label": "Batch No",
+ "read_only": 1
+ },
+ {
+ "fieldname": "serial_no",
+ "fieldtype": "Long Text",
+ "label": "Serial No",
+ "read_only": 1
+ }
+ ],
+ "hide_toolbar": 1,
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2023-03-24 16:22:08.859347",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "Stock Reservation Entry",
+ "naming_rule": "Expression (old style)",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
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 0000000..06e14da
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
@@ -0,0 +1,208 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.query_builder.functions import Sum
+
+from erpnext.utilities.transaction_base import TransactionBase
+
+
+class StockReservationEntry(TransactionBase):
+ def validate(self) -> None:
+ from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
+
+ self.validate_posting_time()
+ self.validate_mandatory()
+ validate_disabled_warehouse(self.warehouse)
+ validate_warehouse_company(self.warehouse, self.company)
+
+ def on_submit(self) -> None:
+ self.update_reserved_qty_in_voucher()
+ self.update_status()
+
+ def on_cancel(self) -> None:
+ self.update_reserved_qty_in_voucher()
+ self.update_status()
+
+ def validate_mandatory(self) -> None:
+ mandatory = [
+ "item_code",
+ "warehouse",
+ "posting_date",
+ "posting_time",
+ "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):
+ frappe.throw(_("{0} is required").format(self.meta.get_label(d)))
+
+ def update_status(self, status: str = None, update_modified: bool = True) -> None:
+ 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 update_reserved_qty_in_voucher(
+ self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True
+ ) -> None:
+ 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 get_stock_reservation_entries_for_voucher(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str = None, fields: list[str] = None
+) -> list[dict]:
+ 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)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ .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)
+
+ return query.run(as_dict=True)
+
+
+def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool:
+ if get_stock_reservation_entries_for_voucher(
+ voucher_type, voucher_no, voucher_detail_no, fields=["name"]
+ ):
+ return True
+
+ return False
+
+
+def update_sre_delivered_qty(
+ doctype: str, sre_name: str, sre_field: str = "against_sre", qty_field: str = "stock_qty"
+) -> None:
+ table = frappe.qb.DocType(doctype)
+ delivered_qty = (
+ frappe.qb.from_(table)
+ .select(Sum(table[qty_field]))
+ .where((table.docstatus == 1) & (table[sre_field] == sre_name))
+ ).run(as_list=True)[0][0] or 0.0
+
+ sre_doc = frappe.get_doc("Stock Reservation Entry", sre_name)
+ sre_doc.delivered_qty = delivered_qty
+ sre_doc.db_update()
+ sre_doc.update_status()
+
+
+def get_stock_reservation_entries_for_items(
+ items: list[dict | object], sre_field: str = "against_sre"
+) -> dict[dict]:
+ sre_details = {}
+
+ if items:
+ sre_list = [item.get(sre_field) for item in items if item.get(sre_field)]
+
+ if sre_list:
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ sre_data = (
+ frappe.qb.from_(sre)
+ .select(
+ sre.name,
+ sre.status,
+ sre.docstatus,
+ sre.item_code,
+ sre.warehouse,
+ sre.voucher_type,
+ sre.voucher_no,
+ sre.voucher_detail_no,
+ sre.reserved_qty,
+ sre.delivered_qty,
+ sre.stock_uom,
+ )
+ .where(sre.name.isin(sre_list))
+ .orderby(sre.creation)
+ ).run(as_dict=True)
+
+ sre_details = {d.name: d for d in sre_data}
+
+ return sre_details
+
+
+def get_sre_reserved_qty_details(item_code_list: list, warehouse_list: list) -> dict:
+ sre_details = {}
+
+ if item_code_list and warehouse_list:
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ sre_data = (
+ 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.warehouse.isin(warehouse_list))
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ .groupby(sre.item_code, sre.warehouse)
+ ).run(as_dict=True)
+
+ if sre_data:
+ sre_details = {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in sre_data}
+
+ return sre_details
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js
new file mode 100644
index 0000000..442ac39
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js
@@ -0,0 +1,16 @@
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.listview_settings['Stock Reservation Entry'] = {
+ get_indicator: function (doc) {
+ const status_colors = {
+ 'Draft': 'red',
+ 'Partially Reserved': 'orange',
+ 'Reserved': 'blue',
+ 'Partially Delivered': 'purple',
+ 'Delivered': 'green',
+ 'Cancelled': 'red',
+ };
+ return [__(doc.status), status_colors[doc.status], 'status,=,' + doc.status];
+ },
+};
\ No newline at end of file
diff --git a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
new file mode 100644
index 0000000..e7b829e
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestStockReservationEntry(FrappeTestCase):
+ pass
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index ec7fb0f..02ea381 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -31,6 +31,11 @@
"action_if_quality_inspection_is_not_submitted",
"column_break_23",
"action_if_quality_inspection_is_rejected",
+ "stock_reservation_tab",
+ "enable_stock_reservation",
+ "column_break_rx3e",
+ "reserve_stock_on_sales_order_submission",
+ "allow_partial_reservation",
"serial_and_batch_item_settings_tab",
"section_break_7",
"automatically_set_serial_nos_based_on_fifo",
@@ -339,6 +344,35 @@
{
"fieldname": "column_break_121",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "stock_reservation_tab",
+ "fieldtype": "Tab Break",
+ "label": "Stock Reservation"
+ },
+ {
+ "default": "1",
+ "fieldname": "enable_stock_reservation",
+ "fieldtype": "Check",
+ "label": "Enable Stock Reservation"
+ },
+ {
+ "default": "1",
+ "depends_on": "eval: doc.enable_stock_reservation",
+ "fieldname": "reserve_stock_on_sales_order_submission",
+ "fieldtype": "Check",
+ "label": "Reserve Stock on Sales Order Submission"
+ },
+ {
+ "fieldname": "column_break_rx3e",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "1",
+ "depends_on": "eval: (doc.enable_stock_reservation && doc.reserve_stock_on_sales_order_submission)",
+ "fieldname": "allow_partial_reservation",
+ "fieldtype": "Check",
+ "label": "Allow Partial Reservation"
}
],
"icon": "icon-cog",
@@ -346,7 +380,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-02-05 15:33:43.692736",
+ "modified": "2023-03-23 18:59:11.773360",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py
index 50807a9..d761b66 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.py
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.py
@@ -55,6 +55,7 @@
self.cant_change_valuation_method()
self.validate_clean_description_html()
self.validate_pending_reposts()
+ self.cant_disable_stock_reservation()
def validate_warehouses(self):
warehouse_fields = ["default_warehouse", "sample_retention_warehouse"]
@@ -99,6 +100,19 @@
if self.stock_frozen_upto:
check_pending_reposting(self.stock_frozen_upto)
+ def cant_disable_stock_reservation(self):
+ if not self.enable_stock_reservation:
+ db_enable_stock_reservation = frappe.db.get_single_value(
+ "Stock Settings", "enable_stock_reservation"
+ )
+
+ if db_enable_stock_reservation and frappe.db.count("Stock Reservation Entry"):
+ frappe.throw(
+ _("As there are existing {0}, you can not change the value of {1}.").format(
+ frappe.bold("Stock Reservation Entries"), frappe.bold("Enable Stock Reservation")
+ )
+ )
+
def on_update(self):
self.toggle_warehouse_field_for_inter_warehouse_transfer()
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index 0fc642e..b8d6b6c 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -58,6 +58,7 @@
return columns, []
iwb_map = get_item_warehouse_map(filters, sle)
+ sre_details = get_sre_reserved_qty_details(iwb_map)
item_map = get_item_details(items, sle, filters)
item_reorder_detail_map = get_item_reorder_details(item_map.keys())
@@ -88,6 +89,7 @@
"company": company,
"reorder_level": item_reorder_level,
"reorder_qty": item_reorder_qty,
+ "stock_reservation_qty": sre_details.get((item, warehouse), 0.0),
}
report_data.update(item_map[item])
report_data.update(qty_dict)
@@ -230,6 +232,13 @@
"convertible": "qty",
},
{
+ "label": _("Stock Reservation Qty"),
+ "fieldname": "stock_reservation_qty",
+ "fieldtype": "Float",
+ "width": 80,
+ "convertible": "qty",
+ },
+ {
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
@@ -388,6 +397,19 @@
return iwb_map
+def get_sre_reserved_qty_details(iwb_map: list) -> dict:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_details as get_reserved_qty_details,
+ )
+
+ item_code_list, warehouse_list = [], []
+ for d in iwb_map:
+ item_code_list.append(d[1])
+ warehouse_list.append(d[2])
+
+ return get_reserved_qty_details(item_code_list, warehouse_list)
+
+
def get_group_by_key(row, filters, inventory_dimension_fields) -> tuple:
group_by_key = [row.company, row.item_code, row.warehouse]
diff --git a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py
index f477d8f..d3046d2 100644
--- a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py
+++ b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py
@@ -20,6 +20,7 @@
include_uom = filters.get("include_uom")
columns = get_columns()
bin_list = get_bin_list(filters)
+ sre_details = get_sre_reserved_qty_details(bin_list)
item_map = get_item_map(filters.get("item_code"), include_uom)
warehouse_company = {}
@@ -75,6 +76,7 @@
bin.indented_qty,
bin.ordered_qty,
bin.reserved_qty,
+ sre_details.get((bin.item_code, bin.warehouse), 0.0),
bin.reserved_qty_for_production,
bin.reserved_qty_for_sub_contract,
reserved_qty_for_pos,
@@ -167,6 +169,13 @@
"convertible": "qty",
},
{
+ "label": _("Stock Reservation Qty"),
+ "fieldname": "stock_reservation_qty",
+ "fieldtype": "Float",
+ "width": 100,
+ "convertible": "qty",
+ },
+ {
"label": _("Reserved for Production"),
"fieldname": "reserved_qty_for_production",
"fieldtype": "Float",
@@ -264,6 +273,19 @@
return bin_list
+def get_sre_reserved_qty_details(bin_list: list) -> dict:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_details as get_reserved_qty_details,
+ )
+
+ item_code_list, warehouse_list = [], []
+ for bin in bin_list:
+ item_code_list.append(bin["item_code"])
+ warehouse_list.append(bin["warehouse"])
+
+ return get_reserved_qty_details(item_code_list, warehouse_list)
+
+
def get_item_map(item_code, include_uom):
"""Optimization: get only the item doc and re_order_levels table"""