Merge pull request #35426 from rohitwaghchaure/fixed-incorrect-actual-qty-bin
fix: incorrect available quantity in BIN
diff --git a/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py b/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py
index cd5f366..f0ca405 100644
--- a/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py
+++ b/erpnext/accounts/report/gross_and_net_profit_report/gross_and_net_profit_report.py
@@ -125,12 +125,14 @@
data_to_be_removed = True
while data_to_be_removed:
- revenue, data_to_be_removed = remove_parent_with_no_child(revenue, period_list)
- revenue = adjust_account(revenue, period_list)
+ revenue, data_to_be_removed = remove_parent_with_no_child(revenue)
+
+ adjust_account_totals(revenue, period_list)
+
return copy.deepcopy(revenue)
-def remove_parent_with_no_child(data, period_list):
+def remove_parent_with_no_child(data):
data_to_be_removed = False
for parent in data:
if "is_group" in parent and parent.get("is_group") == 1:
@@ -147,16 +149,19 @@
return data, data_to_be_removed
-def adjust_account(data, period_list, consolidated=False):
- leaf_nodes = [item for item in data if item["is_group"] == 0]
+def adjust_account_totals(data, period_list):
totals = {}
- for node in leaf_nodes:
- set_total(node, node["total"], data, totals)
- for d in data:
- for period in period_list:
- key = period if consolidated else period.key
- d["total"] = totals[d["account"]]
- return data
+ for d in reversed(data):
+ if d.get("is_group"):
+ for period in period_list:
+ # reset totals for group accounts as totals set by get_data doesn't consider include_in_gross check
+ d[period.key] = sum(
+ item[period.key] for item in data if item.get("parent_account") == d.get("account")
+ )
+ else:
+ set_total(d, d["total"], data, totals)
+
+ d["total"] = totals[d["account"]]
def set_total(node, value, complete_list, totals):
@@ -191,6 +196,9 @@
if profit_loss[key]:
has_value = True
+ if not profit_loss.get("total"):
+ profit_loss["total"] = 0
+ profit_loss["total"] += profit_loss[key]
if has_value:
return profit_loss
@@ -229,6 +237,9 @@
if profit_loss[key]:
has_value = True
+ if not profit_loss.get("total"):
+ profit_loss["total"] = 0
+ profit_loss["total"] += profit_loss[key]
if has_value:
return profit_loss
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 78bb056..20b332e 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -2826,6 +2826,17 @@
parent.update_billing_percentage()
parent.set_status()
+ # Cancel and Recreate Stock Reservation Entries.
+ if parent_doctype == "Sales Order":
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ has_reserved_stock,
+ )
+
+ if has_reserved_stock(parent.doctype, parent.name):
+ cancel_stock_reservation_entries(parent.doctype, parent.name)
+ parent.create_stock_reservation_entries()
+
@erpnext.allow_regional
def validate_regional(doc):
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 7e68ec1..3a59d3c 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -333,3 +333,4 @@
execute:frappe.delete_doc_if_exists("Report", "Tax Detail")
erpnext.patches.v15_0.enable_all_leads
erpnext.patches.v14_0.update_company_in_ldc
+erpnext.patches.v14_0.set_packed_qty_in_draft_delivery_notes
diff --git a/erpnext/patches/v14_0/set_packed_qty_in_draft_delivery_notes.py b/erpnext/patches/v14_0/set_packed_qty_in_draft_delivery_notes.py
new file mode 100644
index 0000000..1aeb2e6
--- /dev/null
+++ b/erpnext/patches/v14_0/set_packed_qty_in_draft_delivery_notes.py
@@ -0,0 +1,60 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.query_builder.functions import Sum
+
+
+def execute():
+ ps = frappe.qb.DocType("Packing Slip")
+ dn = frappe.qb.DocType("Delivery Note")
+ ps_item = frappe.qb.DocType("Packing Slip Item")
+
+ ps_details = (
+ frappe.qb.from_(ps)
+ .join(ps_item)
+ .on(ps.name == ps_item.parent)
+ .join(dn)
+ .on(ps.delivery_note == dn.name)
+ .select(
+ dn.name.as_("delivery_note"),
+ ps_item.item_code.as_("item_code"),
+ Sum(ps_item.qty).as_("packed_qty"),
+ )
+ .where((ps.docstatus == 1) & (dn.docstatus == 0))
+ .groupby(dn.name, ps_item.item_code)
+ ).run(as_dict=True)
+
+ if ps_details:
+ dn_list = set()
+ item_code_list = set()
+ for ps_detail in ps_details:
+ dn_list.add(ps_detail.delivery_note)
+ item_code_list.add(ps_detail.item_code)
+
+ dn_item = frappe.qb.DocType("Delivery Note Item")
+ dn_item_query = (
+ frappe.qb.from_(dn_item)
+ .select(
+ dn.parent.as_("delivery_note"),
+ dn_item.name,
+ dn_item.item_code,
+ dn_item.qty,
+ )
+ .where((dn_item.parent.isin(dn_list)) & (dn_item.item_code.isin(item_code_list)))
+ )
+
+ dn_details = frappe._dict()
+ for r in dn_item_query.run(as_dict=True):
+ dn_details.setdefault((r.delivery_note, r.item_code), frappe._dict()).setdefault(r.name, r.qty)
+
+ for ps_detail in ps_details:
+ dn_items = dn_details.get((ps_detail.delivery_note, ps_detail.item_code))
+
+ if dn_items:
+ remaining_qty = ps_detail.packed_qty
+ for name, qty in dn_items.items():
+ if remaining_qty > 0:
+ row_packed_qty = min(qty, remaining_qty)
+ frappe.db.set_value("Delivery Note Item", name, "packed_qty", row_packed_qty)
+ remaining_qty -= row_packed_qty
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index e9a6cc3..5d43a07 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -47,21 +47,50 @@
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'
- && flt(frm.doc.per_delivered, 6) < 100 && flt(frm.doc.per_billed, 6) < 100) {
- frm.add_custom_button(__('Update Items'), () => {
- erpnext.utils.update_child_items({
- frm: frm,
- child_docname: "items",
- child_doctype: "Sales Order Detail",
- cannot_add_row: false,
- })
- });
+ if(frm.doc.docstatus === 1) {
+ if (frm.doc.status !== 'Closed' && flt(frm.doc.per_delivered, 6) < 100 && flt(frm.doc.per_billed, 6) < 100) {
+ frm.add_custom_button(__('Update Items'), () => {
+ erpnext.utils.update_child_items({
+ frm: frm,
+ child_docname: "items",
+ child_doctype: "Sales Order Detail",
+ cannot_add_row: false,
+ })
+ });
+
+ // Stock Reservation > Reserve button will be only visible if the SO has unreserved stock.
+ if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) {
+ frm.add_custom_button(__('Reserve'), () => frm.events.create_stock_reservation_entries(frm), __('Stock Reservation'));
+ }
+ }
+
+ // Stock Reservation > Unreserve button will be only visible if the SO has reserved stock.
+ if (frm.doc.__onload && frm.doc.__onload.has_reserved_stock) {
+ frm.add_custom_button(__('Unreserve'), () => frm.events.cancel_stock_reservation_entries(frm), __('Stock Reservation'));
+ }
}
- 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 `Reserve Stock on Sales Order Submission` is enabled in Stock Settings, set Reserve Stock to 1 else 0.
+ frm.set_value("reserve_stock", value ? 1 : 0);
+ })
+ } else {
+ // If `Stock Reservation` is disabled in Stock Settings, set Reserve Stock to 0 and read only.
+ frm.set_value("reserve_stock", 0);
+ frm.set_df_property("reserve_stock", "read_only", 1);
+ }
+ })
+ }
}
},
@@ -137,6 +166,108 @@
if(!d.delivery_date) d.delivery_date = frm.doc.delivery_date;
});
refresh_field("items");
+ },
+
+ create_stock_reservation_entries(frm) {
+ let items_data = [];
+
+ const dialog = frappe.prompt({fieldname: 'items', fieldtype: 'Table', label: __('Items to Reserve'),
+ fields: [
+ {
+ fieldtype: 'Data',
+ fieldname: 'name',
+ label: __('Name'),
+ reqd: 1,
+ read_only: 1,
+ },
+ {
+ fieldtype: 'Link',
+ fieldname: 'item_code',
+ label: __('Item Code'),
+ options: 'Item',
+ reqd: 1,
+ read_only: 1,
+ in_list_view: 1,
+ },
+ {
+ fieldtype: 'Link',
+ fieldname: 'warehouse',
+ label: __('Warehouse'),
+ options: 'Warehouse',
+ reqd: 1,
+ in_list_view: 1,
+ get_query: function () {
+ return {
+ filters: [
+ ["Warehouse", "is_group", "!=", 1]
+ ]
+ };
+ },
+ },
+ {
+ fieldtype: 'Float',
+ fieldname: 'qty_to_reserve',
+ label: __('Qty'),
+ reqd: 1,
+ in_list_view: 1
+ }
+ ],
+ data: items_data,
+ in_place_edit: true,
+ get_data: function() {
+ return items_data;
+ }
+ }, function(data) {
+ if (data.items.length > 0) {
+ frappe.call({
+ doc: frm.doc,
+ method: 'create_stock_reservation_entries',
+ args: {
+ items_details: data.items,
+ notify: true
+ },
+ freeze: true,
+ freeze_message: __('Reserving Stock...'),
+ callback: (r) => {
+ frm.doc.__onload.has_unreserved_stock = false;
+ frm.reload_doc();
+ }
+ });
+ }
+ }, __("Stock Reservation"), __("Reserve Stock"));
+
+ frm.doc.items.forEach(item => {
+ if (item.reserve_stock) {
+ let unreserved_qty = (flt(item.stock_qty) - (flt(item.delivered_qty) * flt(item.conversion_factor)) - flt(item.stock_reserved_qty))
+
+ if (unreserved_qty > 0) {
+ dialog.fields_dict.items.df.data.push({
+ 'name': item.name,
+ 'item_code': item.item_code,
+ 'warehouse': item.warehouse,
+ 'qty_to_reserve': (unreserved_qty / flt(item.conversion_factor))
+ });
+ }
+ }
+ });
+
+ dialog.fields_dict.items.grid.refresh();
+ },
+
+ cancel_stock_reservation_entries(frm) {
+ frappe.call({
+ method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries',
+ args: {
+ voucher_type: frm.doctype,
+ voucher_no: frm.docname
+ },
+ freeze: true,
+ freeze_message: __('Unreserving Stock...'),
+ callback: (r) => {
+ frm.doc.__onload.has_reserved_stock = false;
+ frm.reload_doc();
+ }
+ })
}
});
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index 4f498fb..f7143d7 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -42,6 +42,7 @@
"scan_barcode",
"column_break_28",
"set_warehouse",
+ "reserve_stock",
"items_section",
"items",
"section_break_31",
@@ -1625,13 +1626,24 @@
"fieldname": "named_place",
"fieldtype": "Data",
"label": "Named Place"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: (doc.docstatus == 0 || doc.reserve_stock)",
+ "description": "If checked, Stock Reservation Entries will be created on <b>Submit</b>",
+ "fieldname": "reserve_stock",
+ "fieldtype": "Check",
+ "label": "Reserve Stock",
+ "no_copy": 1,
+ "print_hide": 1,
+ "report_hide": 1
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2023-04-20 11:14:01.036202",
+ "modified": "2023-04-22 09:55:37.008190",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
@@ -1664,7 +1676,6 @@
"read": 1,
"report": 1,
"role": "Sales Manager",
- "set_user_permissions": 1,
"share": 1,
"submit": 1,
"write": 1
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 06467e5..353fa9b 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -30,6 +30,11 @@
from erpnext.selling.doctype.customer.customer import check_credit_limit
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_item_defaults
+from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ get_sre_reserved_qty_details_for_voucher,
+ has_reserved_stock,
+)
from erpnext.stock.get_item_details import get_default_bom, get_price_list_rate
from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty
@@ -44,6 +49,14 @@
def __init__(self, *args, **kwargs):
super(SalesOrder, self).__init__(*args, **kwargs)
+ def onload(self) -> None:
+ if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"):
+ if self.has_unreserved_stock():
+ self.set_onload("has_unreserved_stock", True)
+
+ if has_reserved_stock(self.doctype, self.name):
+ self.set_onload("has_reserved_stock", True)
+
def validate(self):
super(SalesOrder, self).validate()
self.validate_delivery_date()
@@ -241,6 +254,9 @@
update_coupon_code_count(self.coupon_code, "used")
+ if self.get("reserve_stock"):
+ self.create_stock_reservation_entries()
+
def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
super(SalesOrder, self).on_cancel()
@@ -257,6 +273,7 @@
self.db_set("status", "Cancelled")
self.update_blanket_order()
+ cancel_stock_reservation_entries("Sales Order", self.name)
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_order_reference)
if self.coupon_code:
@@ -485,6 +502,166 @@
).format(item.item_code)
)
+ def has_unreserved_stock(self) -> bool:
+ """Returns True if there is any unreserved item in the Sales Order."""
+
+ reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
+
+ for item in self.get("items"):
+ if not item.get("reserve_stock"):
+ continue
+
+ unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
+ if unreserved_qty > 0:
+ return True
+
+ return False
+
+ @frappe.whitelist()
+ def create_stock_reservation_entries(self, items_details=None, notify=True):
+ """Creates Stock Reservation Entries for Sales Order Items."""
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_available_qty_to_reserve,
+ validate_stock_reservation_settings,
+ )
+
+ validate_stock_reservation_settings(self)
+
+ 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["name"])
+ so_item.reserve_stock = 1
+ so_item.warehouse = item["warehouse"]
+ so_item.qty_to_reserve = flt(item["qty_to_reserve"]) * flt(so_item.conversion_factor)
+ items.append(so_item)
+
+ sre_count = 0
+ reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
+ for item in items or self.get("items"):
+ # Skip if `Reserved Stock` is not checked for the item.
+ if not item.get("reserve_stock"):
+ continue
+
+ # Skip if Non-Stock Item.
+ if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
+ 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:
+ 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}: No available stock 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 item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")):
+ frappe.msgprint(
+ _("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),
+ ),
+ title=_("Stock Reservation"),
+ indicator="orange",
+ )
+
+ # Skip the item if `Partial Reservation` is disabled in the Stock Settings.
+ if not allow_partial_reservation:
+ continue
+
+ # Create and Submit Stock Reservation Entry
+ 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_to_reserve
+ sre.voucher_qty = item.stock_qty
+ sre.reserved_qty = qty_to_be_reserved
+ sre.company = self.company
+ sre.stock_uom = item.stock_uom
+ sre.project = self.project
+ sre.save()
+ sre.submit()
+
+ sre_count += 1
+
+ if sre_count and notify:
+ frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green")
+
+
+def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:
+ """Returns the unreserved quantity for the Sales Order Item."""
+
+ existing_reserved_qty = reserved_qty_details.get(item.name, 0)
+ return (
+ item.stock_qty
+ - flt(item.delivered_qty) * item.get("conversion_factor", 1)
+ - existing_reserved_qty
+ )
+
def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context
@@ -680,7 +857,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/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 9854f15..88bc4bd 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -1878,6 +1878,139 @@
self.assertEqual(pe.references[1].reference_name, so.name)
self.assertEqual(pe.references[1].allocated_amount, 300)
+ @change_settings("Stock Settings", {"enable_stock_reservation": 1})
+ def test_stock_reservation_against_sales_order(self) -> None:
+ from random import randint, uniform
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ get_sre_reserved_qty_details_for_voucher,
+ get_stock_reservation_entries_for_voucher,
+ has_reserved_stock,
+ )
+ from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import (
+ create_items,
+ create_material_receipts,
+ )
+
+ items_details, warehouse = create_items(), "_Test Warehouse - _TC"
+ create_material_receipts(items_details, warehouse, qty=10)
+
+ item_list = []
+ for item_code, properties in items_details.items():
+ stock_uom = properties.stock_uom
+ item_list.append(
+ {
+ "item_code": item_code,
+ "warehouse": warehouse,
+ "qty": flt(uniform(11, 100), 0 if stock_uom == "Nos" else 3),
+ "uom": stock_uom,
+ "rate": randint(10, 200),
+ }
+ )
+
+ so = make_sales_order(
+ item_list=item_list,
+ warehouse="_Test Warehouse - _TC",
+ )
+
+ # Test - 1: Stock should not be reserved if the Available Qty to Reserve is less than the Ordered Qty and Partial Reservation is disabled in Stock Settings.
+ with change_settings("Stock Settings", {"allow_partial_reservation": 0}):
+ so.create_stock_reservation_entries()
+ self.assertFalse(has_reserved_stock("Sales Order", so.name))
+
+ # Test - 2: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings.
+ with change_settings("Stock Settings", {"allow_partial_reservation": 1}):
+ so.create_stock_reservation_entries()
+ so.load_from_db()
+ self.assertTrue(has_reserved_stock("Sales Order", so.name))
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order", so.name, item.name, fields=["reserved_qty", "status"]
+ )
+ self.assertEqual(item.stock_reserved_qty, sre_details[0].reserved_qty)
+ self.assertEqual(sre_details[0].status, "Partially Reserved")
+
+ # Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty.
+ create_material_receipts(items_details, warehouse, qty=100)
+ so.create_stock_reservation_entries()
+ so.load_from_db()
+
+ reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
+ for item in so.items:
+ reserved_qty = reserved_qty_details[item.name]
+ self.assertEqual(item.stock_reserved_qty, reserved_qty)
+ self.assertEqual(item.stock_qty, item.stock_reserved_qty)
+
+ # Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries.
+ cancel_stock_reservation_entries("Sales Order", so.name)
+ so.load_from_db()
+ self.assertFalse(has_reserved_stock("Sales Order", so.name))
+
+ for item in so.items:
+ self.assertEqual(item.stock_reserved_qty, 0)
+
+ # Test - 5: Re-reserve the stock.
+ so.create_stock_reservation_entries()
+ self.assertTrue(has_reserved_stock("Sales Order", so.name))
+
+ # Test - 6: Stock should get unreserved on cancellation of Sales Order.
+ so.cancel()
+ so.load_from_db()
+ self.assertFalse(has_reserved_stock("Sales Order", so.name))
+
+ for item in so.items:
+ self.assertEqual(item.stock_reserved_qty, 0)
+
+ # Create Sales Order and Reserve Stock.
+ so = make_sales_order(
+ item_list=item_list,
+ warehouse="_Test Warehouse - _TC",
+ )
+ so.create_stock_reservation_entries()
+
+ # Test - 7: Partial Delivery against Sales Order.
+ dn1 = make_delivery_note(so.name)
+
+ for item in dn1.items:
+ item.qty = flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
+
+ dn1.save()
+ dn1.submit()
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order", so.name, item.name, fields=["delivered_qty", "status"]
+ )
+ self.assertGreater(sre_details[0].delivered_qty, 0)
+ self.assertEqual(sre_details[0].status, "Partially Delivered")
+
+ # Test - 8: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty.
+ with change_settings("Stock Settings", {"over_delivery_receipt_allowance": 100}):
+ dn2 = make_delivery_note(so.name)
+
+ for item in dn2.items:
+ item.qty += flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
+
+ dn2.save()
+ dn2.submit()
+
+ for item in so.items:
+ sre_details = frappe.db.get_all(
+ "Stock Reservation Entry",
+ filters={
+ "voucher_type": "Sales Order",
+ "voucher_no": so.name,
+ "voucher_detail_no": item.name,
+ },
+ fields=["status", "reserved_qty", "delivered_qty"],
+ )
+
+ for sre_detail in sre_details:
+ self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty)
+ self.assertEqual(sre_detail.status, "Delivered")
+
def test_delivered_item_material_request(self):
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
from erpnext.manufacturing.doctype.work_order.work_order import (
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..5c7e10a 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,33 @@
"fieldname": "material_request_item",
"fieldtype": "Data",
"label": "Material Request Item"
+ },
+ {
+ "allow_on_submit": 1,
+ "default": "1",
+ "fieldname": "reserve_stock",
+ "fieldtype": "Check",
+ "label": "Reserve Stock",
+ "print_hide": 1,
+ "report_hide": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.stock_reserved_qty",
+ "fieldname": "stock_reserved_qty",
+ "fieldtype": "Float",
+ "label": "Stock Reserved Qty (in Stock UOM)",
+ "no_copy": 1,
+ "non_negative": 1,
+ "print_hide": 1,
+ "read_only": 1,
+ "report_hide": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-12-25 02:51:10.247569",
+ "modified": "2023-04-04 10:44:05.707488",
"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..77545e0 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.js
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.js
@@ -185,11 +185,14 @@
}
if(doc.docstatus==0 && !doc.__islocal) {
- this.frm.add_custom_button(__('Packing Slip'), function() {
- frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip",
- frm: me.frm
- }) }, __('Create'));
+ if (doc.__onload && doc.__onload.has_unpacked_items) {
+ this.frm.add_custom_button(__('Packing Slip'), function() {
+ frappe.model.open_mapped_doc({
+ method: "erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip",
+ frm: me.frm
+ }) }, __('Create')
+ );
+ }
}
if (!doc.__islocal && doc.docstatus==1) {
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index c18e851..2ee372e 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -86,6 +86,10 @@
]
)
+ def onload(self):
+ if self.docstatus == 0:
+ self.set_onload("has_unpacked_items", self.has_unpacked_items())
+
def before_print(self, settings=None):
def toggle_print_hide(meta, fieldname):
df = meta.get_field(fieldname)
@@ -147,6 +151,8 @@
if not self.installation_status:
self.installation_status = "Not Installed"
+
+ self.validate_against_stock_reservation_entries()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
def validate_with_previous_doc(self):
@@ -239,6 +245,8 @@
self.update_prevdoc_status()
self.update_billing_status()
+ self.update_stock_reservation_entries()
+
if not self.is_return:
self.check_credit_limit()
elif self.issue_credit_note:
@@ -268,6 +276,90 @@
self.repost_future_sle_and_gle()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
+ def update_stock_reservation_entries(self) -> None:
+ """Updates Delivered Qty in Stock Reservation Entries."""
+
+ # Don't update Delivered Qty on Return or Cancellation.
+ if self.is_return or self._action == "cancel":
+ return
+
+ for item in self.get("items"):
+ # Skip if `Sales Order` or `Sales Order Item` reference is not set.
+ if not item.against_sales_order or not item.so_detail:
+ continue
+
+ sre_list = frappe.db.get_all(
+ "Stock Reservation Entry",
+ {
+ "docstatus": 1,
+ "voucher_type": "Sales Order",
+ "voucher_no": item.against_sales_order,
+ "voucher_detail_no": item.so_detail,
+ "warehouse": item.warehouse,
+ "status": ["not in", ["Delivered", "Cancelled"]],
+ },
+ order_by="creation",
+ )
+
+ # Skip if no Stock Reservation Entries.
+ if not sre_list:
+ continue
+
+ available_qty_to_deliver = item.stock_qty
+ for sre in sre_list:
+ if available_qty_to_deliver <= 0:
+ break
+
+ sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
+
+ # `Delivered Qty` should be less than or equal to `Reserved Qty`.
+ qty_to_be_deliver = min(sre_doc.reserved_qty - sre_doc.delivered_qty, available_qty_to_deliver)
+
+ sre_doc.delivered_qty += qty_to_be_deliver
+ sre_doc.db_update()
+
+ # Update Stock Reservation Entry `Status` based on `Delivered Qty`.
+ sre_doc.update_status()
+
+ available_qty_to_deliver -= qty_to_be_deliver
+
+ def validate_against_stock_reservation_entries(self):
+ """Validates if Stock Reservation Entries are available for the Sales Order Item reference."""
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_details_for_voucher_detail_no,
+ )
+
+ # Don't validate if Return
+ if self.is_return:
+ return
+
+ for item in self.get("items"):
+ # Skip if `Sales Order` or `Sales Order Item` reference is not set.
+ if not item.against_sales_order or not item.so_detail:
+ continue
+
+ sre_data = get_sre_reserved_qty_details_for_voucher_detail_no(
+ "Sales Order", item.against_sales_order, item.so_detail
+ )
+
+ # Skip if stock is not reserved.
+ if not sre_data:
+ continue
+
+ # Set `Warehouse` from SRE if not set.
+ if not item.warehouse:
+ item.warehouse = sre_data[0]
+ else:
+ # Throw if `Warehouse` is different from SRE.
+ if item.warehouse != sre_data[0]:
+ frappe.throw(
+ _("Row #{0}: Stock is reserved for Item {1} in Warehouse {2}.").format(
+ item.idx, frappe.bold(item.item_code), frappe.bold(sre_data[0])
+ ),
+ title=_("Stock Reservation Warehouse Mismatch"),
+ )
+
def check_credit_limit(self):
from erpnext.selling.doctype.customer.customer import check_credit_limit
@@ -302,20 +394,21 @@
)
def validate_packed_qty(self):
- """
- Validate that if packed qty exists, it should be equal to qty
- """
- if not any(flt(d.get("packed_qty")) for d in self.get("items")):
- return
- has_error = False
- for d in self.get("items"):
- if flt(d.get("qty")) != flt(d.get("packed_qty")):
- frappe.msgprint(
- _("Packed quantity must equal quantity for Item {0} in row {1}").format(d.item_code, d.idx)
- )
- has_error = True
- if has_error:
- raise frappe.ValidationError
+ """Validate that if packed qty exists, it should be equal to qty"""
+
+ if frappe.db.exists("Packing Slip", {"docstatus": 1, "delivery_note": self.name}):
+ product_bundle_list = self.get_product_bundle_list()
+ for item in self.items + self.packed_items:
+ if (
+ item.item_code not in product_bundle_list
+ and flt(item.packed_qty)
+ and flt(item.packed_qty) != flt(item.qty)
+ ):
+ frappe.throw(
+ _("Row {0}: Packed Qty must be equal to {1} Qty.").format(
+ item.idx, frappe.bold(item.doctype)
+ )
+ )
def update_pick_list_status(self):
from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status
@@ -393,6 +486,23 @@
)
)
+ def has_unpacked_items(self):
+ product_bundle_list = self.get_product_bundle_list()
+
+ for item in self.items + self.packed_items:
+ if item.item_code not in product_bundle_list and flt(item.packed_qty) < flt(item.qty):
+ return True
+
+ return False
+
+ def get_product_bundle_list(self):
+ items_list = [item.item_code for item in self.items]
+ return frappe.db.get_all(
+ "Product Bundle",
+ filters={"new_item_code": ["in", items_list]},
+ pluck="name",
+ )
+
def update_billed_amount_based_on_so(so_detail, update_modified=True):
from frappe.query_builder.functions import Sum
@@ -684,6 +794,12 @@
@frappe.whitelist()
def make_packing_slip(source_name, target_doc=None):
+ def set_missing_values(source, target):
+ target.run_method("set_missing_values")
+
+ def update_item(obj, target, source_parent):
+ target.qty = flt(obj.qty) - flt(obj.packed_qty)
+
doclist = get_mapped_doc(
"Delivery Note",
source_name,
@@ -698,12 +814,34 @@
"field_map": {
"item_code": "item_code",
"item_name": "item_name",
+ "batch_no": "batch_no",
"description": "description",
"qty": "qty",
+ "stock_uom": "stock_uom",
+ "name": "dn_detail",
},
+ "postprocess": update_item,
+ "condition": lambda item: (
+ not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code})
+ and flt(item.packed_qty) < flt(item.qty)
+ ),
+ },
+ "Packed Item": {
+ "doctype": "Packing Slip Item",
+ "field_map": {
+ "item_code": "item_code",
+ "item_name": "item_name",
+ "batch_no": "batch_no",
+ "description": "description",
+ "qty": "qty",
+ "name": "pi_detail",
+ },
+ "postprocess": update_item,
+ "condition": lambda item: (flt(item.packed_qty) < flt(item.qty)),
},
},
target_doc,
+ set_missing_values,
)
return doclist
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 e46cab0..3853bd1 100644
--- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
+++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
@@ -84,6 +84,7 @@
"installed_qty",
"item_tax_rate",
"column_break_atna",
+ "packed_qty",
"received_qty",
"accounting_details_section",
"expense_account",
@@ -850,6 +851,16 @@
"print_hide": 1,
"read_only": 1,
"report_hide": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.packed_qty",
+ "fieldname": "packed_qty",
+ "fieldtype": "Float",
+ "label": "Packed Qty",
+ "no_copy": 1,
+ "non_negative": 1,
+ "read_only": 1
}
],
"idx": 1,
diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json
index cb8eb30..c5fb241 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.json
+++ b/erpnext/stock/doctype/packed_item/packed_item.json
@@ -27,6 +27,7 @@
"actual_qty",
"projected_qty",
"ordered_qty",
+ "packed_qty",
"column_break_16",
"incoming_rate",
"picked_qty",
@@ -242,13 +243,23 @@
"label": "Picked Qty",
"no_copy": 1,
"read_only": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.packed_qty",
+ "fieldname": "packed_qty",
+ "fieldtype": "Float",
+ "label": "Packed Qty",
+ "no_copy": 1,
+ "non_negative": 1,
+ "read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-04-27 05:23:08.683245",
+ "modified": "2023-04-28 13:16:38.460806",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packed Item",
diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.js b/erpnext/stock/doctype/packing_slip/packing_slip.js
index 40d4685..95e5ea3 100644
--- a/erpnext/stock/doctype/packing_slip/packing_slip.js
+++ b/erpnext/stock/doctype/packing_slip/packing_slip.js
@@ -1,113 +1,46 @@
-// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-// License: GNU General Public License v3. See license.txt
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
-cur_frm.fields_dict['delivery_note'].get_query = function(doc, cdt, cdn) {
- return{
- filters:{ 'docstatus': 0}
- }
-}
+frappe.ui.form.on('Packing Slip', {
+ setup: (frm) => {
+ frm.set_query('delivery_note', () => {
+ return {
+ filters: {
+ docstatus: 0,
+ }
+ }
+ });
+ frm.set_query('item_code', 'items', (doc, cdt, cdn) => {
+ if (!doc.delivery_note) {
+ frappe.throw(__('Please select a Delivery Note'));
+ } else {
+ let d = locals[cdt][cdn];
+ return {
+ query: 'erpnext.stock.doctype.packing_slip.packing_slip.item_details',
+ filters: {
+ delivery_note: doc.delivery_note,
+ }
+ }
+ }
+ });
+ },
-cur_frm.fields_dict['items'].grid.get_field('item_code').get_query = function(doc, cdt, cdn) {
- if(!doc.delivery_note) {
- frappe.throw(__("Please select a Delivery Note"));
- } else {
- return {
- query: "erpnext.stock.doctype.packing_slip.packing_slip.item_details",
- filters:{ 'delivery_note': doc.delivery_note}
+ refresh: (frm) => {
+ frm.toggle_display('misc_details', frm.doc.amended_from);
+ },
+
+ delivery_note: (frm) => {
+ frm.set_value('items', null);
+
+ if (frm.doc.delivery_note) {
+ erpnext.utils.map_current_doc({
+ method: 'erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip',
+ source_name: frm.doc.delivery_note,
+ target_doc: frm,
+ freeze: true,
+ freeze_message: __('Creating Packing Slip ...'),
+ });
}
- }
-}
-
-cur_frm.cscript.onload_post_render = function(doc, cdt, cdn) {
- if(doc.delivery_note && doc.__islocal) {
- cur_frm.cscript.get_items(doc, cdt, cdn);
- }
-}
-
-cur_frm.cscript.get_items = function(doc, cdt, cdn) {
- return this.frm.call({
- doc: this.frm.doc,
- method: "get_items",
- callback: function(r) {
- if(!r.exc) cur_frm.refresh();
- }
- });
-}
-
-cur_frm.cscript.refresh = function(doc, dt, dn) {
- cur_frm.toggle_display("misc_details", doc.amended_from);
-}
-
-cur_frm.cscript.validate = function(doc, cdt, cdn) {
- cur_frm.cscript.validate_case_nos(doc);
- cur_frm.cscript.validate_calculate_item_details(doc);
-}
-
-// To Case No. cannot be less than From Case No.
-cur_frm.cscript.validate_case_nos = function(doc) {
- doc = locals[doc.doctype][doc.name];
- if(cint(doc.from_case_no)==0) {
- frappe.msgprint(__("The 'From Package No.' field must neither be empty nor it's value less than 1."));
- frappe.validated = false;
- } else if(!cint(doc.to_case_no)) {
- doc.to_case_no = doc.from_case_no;
- refresh_field('to_case_no');
- } else if(cint(doc.to_case_no) < cint(doc.from_case_no)) {
- frappe.msgprint(__("'To Case No.' cannot be less than 'From Case No.'"));
- frappe.validated = false;
- }
-}
-
-
-cur_frm.cscript.validate_calculate_item_details = function(doc) {
- doc = locals[doc.doctype][doc.name];
- var ps_detail = doc.items || [];
-
- cur_frm.cscript.validate_duplicate_items(doc, ps_detail);
- cur_frm.cscript.calc_net_total_pkg(doc, ps_detail);
-}
-
-
-// Do not allow duplicate items i.e. items with same item_code
-// Also check for 0 qty
-cur_frm.cscript.validate_duplicate_items = function(doc, ps_detail) {
- for(var i=0; i<ps_detail.length; i++) {
- for(var j=0; j<ps_detail.length; j++) {
- if(i!=j && ps_detail[i].item_code && ps_detail[i].item_code==ps_detail[j].item_code) {
- frappe.msgprint(__("You have entered duplicate items. Please rectify and try again."));
- frappe.validated = false;
- return;
- }
- }
- if(flt(ps_detail[i].qty)<=0) {
- frappe.msgprint(__("Invalid quantity specified for item {0}. Quantity should be greater than 0.", [ps_detail[i].item_code]));
- frappe.validated = false;
- }
- }
-}
-
-
-// Calculate Net Weight of Package
-cur_frm.cscript.calc_net_total_pkg = function(doc, ps_detail) {
- var net_weight_pkg = 0;
- doc.net_weight_uom = (ps_detail && ps_detail.length) ? ps_detail[0].weight_uom : '';
- doc.gross_weight_uom = doc.net_weight_uom;
-
- for(var i=0; i<ps_detail.length; i++) {
- var item = ps_detail[i];
- if(item.weight_uom != doc.net_weight_uom) {
- frappe.msgprint(__("Different UOM for items will lead to incorrect (Total) Net Weight value. Make sure that Net Weight of each item is in the same UOM."));
- frappe.validated = false;
- }
- net_weight_pkg += flt(item.net_weight) * flt(item.qty);
- }
-
- doc.net_weight_pkg = roundNumber(net_weight_pkg, 2);
- if(!flt(doc.gross_weight_pkg)) {
- doc.gross_weight_pkg = doc.net_weight_pkg;
- }
- refresh_many(['net_weight_pkg', 'net_weight_uom', 'gross_weight_uom', 'gross_weight_pkg']);
-}
-
-// TODO: validate gross weight field
+ },
+});
diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.json b/erpnext/stock/doctype/packing_slip/packing_slip.json
index ec8d57c..86ed794 100644
--- a/erpnext/stock/doctype/packing_slip/packing_slip.json
+++ b/erpnext/stock/doctype/packing_slip/packing_slip.json
@@ -1,264 +1,262 @@
{
- "allow_import": 1,
- "autoname": "MAT-PAC-.YYYY.-.#####",
- "creation": "2013-04-11 15:32:24",
- "description": "Generate packing slips for packages to be delivered. Used to notify package number, package contents and its weight.",
- "doctype": "DocType",
- "document_type": "Document",
- "engine": "InnoDB",
- "field_order": [
- "packing_slip_details",
- "column_break0",
- "delivery_note",
- "column_break1",
- "naming_series",
- "section_break0",
- "column_break2",
- "from_case_no",
- "column_break3",
- "to_case_no",
- "package_item_details",
- "get_items",
- "items",
- "package_weight_details",
- "net_weight_pkg",
- "net_weight_uom",
- "column_break4",
- "gross_weight_pkg",
- "gross_weight_uom",
- "letter_head_details",
- "letter_head",
- "misc_details",
- "amended_from"
- ],
- "fields": [
- {
- "fieldname": "packing_slip_details",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "column_break0",
- "fieldtype": "Column Break"
- },
- {
- "description": "Indicates that the package is a part of this delivery (Only Draft)",
- "fieldname": "delivery_note",
- "fieldtype": "Link",
- "in_global_search": 1,
- "in_list_view": 1,
- "label": "Delivery Note",
- "options": "Delivery Note",
- "reqd": 1
- },
- {
- "fieldname": "column_break1",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "naming_series",
- "fieldtype": "Select",
- "label": "Series",
- "options": "MAT-PAC-.YYYY.-",
- "print_hide": 1,
- "reqd": 1,
- "set_only_once": 1
- },
- {
- "fieldname": "section_break0",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "column_break2",
- "fieldtype": "Column Break"
- },
- {
- "description": "Identification of the package for the delivery (for print)",
- "fieldname": "from_case_no",
- "fieldtype": "Int",
- "in_list_view": 1,
- "label": "From Package No.",
- "no_copy": 1,
- "reqd": 1,
- "width": "50px"
- },
- {
- "fieldname": "column_break3",
- "fieldtype": "Column Break"
- },
- {
- "description": "If more than one package of the same type (for print)",
- "fieldname": "to_case_no",
- "fieldtype": "Int",
- "in_list_view": 1,
- "label": "To Package No.",
- "no_copy": 1,
- "width": "50px"
- },
- {
- "fieldname": "package_item_details",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "get_items",
- "fieldtype": "Button",
- "label": "Get Items"
- },
- {
- "fieldname": "items",
- "fieldtype": "Table",
- "label": "Items",
- "options": "Packing Slip Item",
- "reqd": 1
- },
- {
- "fieldname": "package_weight_details",
- "fieldtype": "Section Break",
- "label": "Package Weight Details"
- },
- {
- "description": "The net weight of this package. (calculated automatically as sum of net weight of items)",
- "fieldname": "net_weight_pkg",
- "fieldtype": "Float",
- "label": "Net Weight",
- "no_copy": 1,
- "read_only": 1
- },
- {
- "fieldname": "net_weight_uom",
- "fieldtype": "Link",
- "label": "Net Weight UOM",
- "no_copy": 1,
- "options": "UOM",
- "read_only": 1
- },
- {
- "fieldname": "column_break4",
- "fieldtype": "Column Break"
- },
- {
- "description": "The gross weight of the package. Usually net weight + packaging material weight. (for print)",
- "fieldname": "gross_weight_pkg",
- "fieldtype": "Float",
- "label": "Gross Weight",
- "no_copy": 1
- },
- {
- "fieldname": "gross_weight_uom",
- "fieldtype": "Link",
- "label": "Gross Weight UOM",
- "no_copy": 1,
- "options": "UOM"
- },
- {
- "fieldname": "letter_head_details",
- "fieldtype": "Section Break",
- "label": "Letter Head"
- },
- {
- "allow_on_submit": 1,
- "fieldname": "letter_head",
- "fieldtype": "Link",
- "label": "Letter Head",
- "options": "Letter Head",
- "print_hide": 1
- },
- {
- "fieldname": "misc_details",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "amended_from",
- "fieldtype": "Link",
- "ignore_user_permissions": 1,
- "label": "Amended From",
- "no_copy": 1,
- "options": "Packing Slip",
- "print_hide": 1,
- "read_only": 1
- }
- ],
- "icon": "fa fa-suitcase",
- "idx": 1,
- "is_submittable": 1,
- "modified": "2019-09-09 04:45:08.082862",
- "modified_by": "Administrator",
- "module": "Stock",
- "name": "Packing Slip",
- "owner": "Administrator",
- "permissions": [
- {
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Stock User",
- "share": 1,
- "submit": 1,
- "write": 1
- },
- {
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Sales User",
- "share": 1,
- "submit": 1,
- "write": 1
- },
- {
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Item Manager",
- "share": 1,
- "submit": 1,
- "write": 1
- },
- {
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Stock Manager",
- "share": 1,
- "submit": 1,
- "write": 1
- },
- {
- "amend": 1,
- "cancel": 1,
- "create": 1,
- "delete": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Sales Manager",
- "share": 1,
- "submit": 1,
- "write": 1
- }
- ],
- "search_fields": "delivery_note",
- "show_name_in_global_search": 1,
- "sort_field": "modified",
- "sort_order": "DESC"
+ "actions": [],
+ "allow_import": 1,
+ "autoname": "MAT-PAC-.YYYY.-.#####",
+ "creation": "2013-04-11 15:32:24",
+ "description": "Generate packing slips for packages to be delivered. Used to notify package number, package contents and its weight.",
+ "doctype": "DocType",
+ "document_type": "Document",
+ "engine": "InnoDB",
+ "field_order": [
+ "packing_slip_details",
+ "column_break0",
+ "delivery_note",
+ "column_break1",
+ "naming_series",
+ "section_break0",
+ "column_break2",
+ "from_case_no",
+ "column_break3",
+ "to_case_no",
+ "package_item_details",
+ "items",
+ "package_weight_details",
+ "net_weight_pkg",
+ "net_weight_uom",
+ "column_break4",
+ "gross_weight_pkg",
+ "gross_weight_uom",
+ "letter_head_details",
+ "letter_head",
+ "misc_details",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "packing_slip_details",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break0",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "Indicates that the package is a part of this delivery (Only Draft)",
+ "fieldname": "delivery_note",
+ "fieldtype": "Link",
+ "in_global_search": 1,
+ "in_list_view": 1,
+ "label": "Delivery Note",
+ "options": "Delivery Note",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break1",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "label": "Series",
+ "options": "MAT-PAC-.YYYY.-",
+ "print_hide": 1,
+ "reqd": 1,
+ "set_only_once": 1
+ },
+ {
+ "fieldname": "section_break0",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "Identification of the package for the delivery (for print)",
+ "fieldname": "from_case_no",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "From Package No.",
+ "no_copy": 1,
+ "reqd": 1,
+ "width": "50px"
+ },
+ {
+ "fieldname": "column_break3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "If more than one package of the same type (for print)",
+ "fieldname": "to_case_no",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "To Package No.",
+ "no_copy": 1,
+ "width": "50px"
+ },
+ {
+ "fieldname": "package_item_details",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "items",
+ "fieldtype": "Table",
+ "label": "Items",
+ "options": "Packing Slip Item",
+ "reqd": 1
+ },
+ {
+ "fieldname": "package_weight_details",
+ "fieldtype": "Section Break",
+ "label": "Package Weight Details"
+ },
+ {
+ "description": "The net weight of this package. (calculated automatically as sum of net weight of items)",
+ "fieldname": "net_weight_pkg",
+ "fieldtype": "Float",
+ "label": "Net Weight",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "net_weight_uom",
+ "fieldtype": "Link",
+ "label": "Net Weight UOM",
+ "no_copy": 1,
+ "options": "UOM",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break4",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "The gross weight of the package. Usually net weight + packaging material weight. (for print)",
+ "fieldname": "gross_weight_pkg",
+ "fieldtype": "Float",
+ "label": "Gross Weight",
+ "no_copy": 1
+ },
+ {
+ "fieldname": "gross_weight_uom",
+ "fieldtype": "Link",
+ "label": "Gross Weight UOM",
+ "no_copy": 1,
+ "options": "UOM"
+ },
+ {
+ "fieldname": "letter_head_details",
+ "fieldtype": "Section Break",
+ "label": "Letter Head"
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "letter_head",
+ "fieldtype": "Link",
+ "label": "Letter Head",
+ "options": "Letter Head",
+ "print_hide": 1
+ },
+ {
+ "fieldname": "misc_details",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "ignore_user_permissions": 1,
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Packing Slip",
+ "print_hide": 1,
+ "read_only": 1
}
+ ],
+ "icon": "fa fa-suitcase",
+ "idx": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2023-04-28 18:01:37.341619",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "Packing Slip",
+ "naming_rule": "Expression (old style)",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Stock User",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Sales User",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Item Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Stock Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Sales Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "search_fields": "delivery_note",
+ "show_name_in_global_search": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.py b/erpnext/stock/doctype/packing_slip/packing_slip.py
index e5b9de8..6ea5938 100644
--- a/erpnext/stock/doctype/packing_slip/packing_slip.py
+++ b/erpnext/stock/doctype/packing_slip/packing_slip.py
@@ -4,193 +4,181 @@
import frappe
from frappe import _
-from frappe.model import no_value_fields
-from frappe.model.document import Document
from frappe.utils import cint, flt
+from erpnext.controllers.status_updater import StatusUpdater
-class PackingSlip(Document):
- def validate(self):
- """
- * Validate existence of submitted Delivery Note
- * Case nos do not overlap
- * Check if packed qty doesn't exceed actual qty of delivery note
- It is necessary to validate case nos before checking quantity
- """
- self.validate_delivery_note()
- self.validate_items_mandatory()
- self.validate_case_nos()
- self.validate_qty()
+class PackingSlip(StatusUpdater):
+ def __init__(self, *args, **kwargs) -> None:
+ super(PackingSlip, self).__init__(*args, **kwargs)
+ self.status_updater = [
+ {
+ "target_dt": "Delivery Note Item",
+ "join_field": "dn_detail",
+ "target_field": "packed_qty",
+ "target_parent_dt": "Delivery Note",
+ "target_ref_field": "qty",
+ "source_dt": "Packing Slip Item",
+ "source_field": "qty",
+ },
+ {
+ "target_dt": "Packed Item",
+ "join_field": "pi_detail",
+ "target_field": "packed_qty",
+ "target_parent_dt": "Delivery Note",
+ "target_ref_field": "qty",
+ "source_dt": "Packing Slip Item",
+ "source_field": "qty",
+ },
+ ]
+ def validate(self) -> None:
from erpnext.utilities.transaction_base import validate_uom_is_integer
+ self.validate_delivery_note()
+ self.validate_case_nos()
+ self.validate_items()
+
validate_uom_is_integer(self, "stock_uom", "qty")
validate_uom_is_integer(self, "weight_uom", "net_weight")
- def validate_delivery_note(self):
- """
- Validates if delivery note has status as draft
- """
- if cint(frappe.db.get_value("Delivery Note", self.delivery_note, "docstatus")) != 0:
- frappe.throw(_("Delivery Note {0} must not be submitted").format(self.delivery_note))
+ self.set_missing_values()
+ self.calculate_net_total_pkg()
- def validate_items_mandatory(self):
- rows = [d.item_code for d in self.get("items")]
- if not rows:
- frappe.msgprint(_("No Items to pack"), raise_exception=1)
+ def on_submit(self):
+ self.update_prevdoc_status()
+
+ def on_cancel(self):
+ self.update_prevdoc_status()
+
+ def validate_delivery_note(self):
+ """Raises an exception if the `Delivery Note` status is not Draft"""
+
+ if cint(frappe.db.get_value("Delivery Note", self.delivery_note, "docstatus")) != 0:
+ frappe.throw(
+ _("A Packing Slip can only be created for Draft Delivery Note.").format(self.delivery_note)
+ )
def validate_case_nos(self):
- """
- Validate if case nos overlap. If they do, recommend next case no.
- """
- if not cint(self.from_case_no):
- frappe.msgprint(_("Please specify a valid 'From Case No.'"), raise_exception=1)
+ """Validate if case nos overlap. If they do, recommend next case no."""
+
+ if cint(self.from_case_no) <= 0:
+ frappe.throw(
+ _("The 'From Package No.' field must neither be empty nor it's value less than 1.")
+ )
elif not self.to_case_no:
self.to_case_no = self.from_case_no
- elif cint(self.from_case_no) > cint(self.to_case_no):
- frappe.msgprint(_("'To Case No.' cannot be less than 'From Case No.'"), raise_exception=1)
+ elif cint(self.to_case_no) < cint(self.from_case_no):
+ frappe.throw(_("'To Package No.' cannot be less than 'From Package No.'"))
+ else:
+ ps = frappe.qb.DocType("Packing Slip")
+ res = (
+ frappe.qb.from_(ps)
+ .select(
+ ps.name,
+ )
+ .where(
+ (ps.delivery_note == self.delivery_note)
+ & (ps.docstatus == 1)
+ & (
+ (ps.from_case_no.between(self.from_case_no, self.to_case_no))
+ | (ps.to_case_no.between(self.from_case_no, self.to_case_no))
+ | ((ps.from_case_no <= self.from_case_no) & (ps.to_case_no >= self.from_case_no))
+ )
+ )
+ ).run()
- res = frappe.db.sql(
- """SELECT name FROM `tabPacking Slip`
- WHERE delivery_note = %(delivery_note)s AND docstatus = 1 AND
- ((from_case_no BETWEEN %(from_case_no)s AND %(to_case_no)s)
- OR (to_case_no BETWEEN %(from_case_no)s AND %(to_case_no)s)
- OR (%(from_case_no)s BETWEEN from_case_no AND to_case_no))
- """,
- {
- "delivery_note": self.delivery_note,
- "from_case_no": self.from_case_no,
- "to_case_no": self.to_case_no,
- },
- )
+ if res:
+ frappe.throw(
+ _("""Package No(s) already in use. Try from Package No {0}""").format(
+ self.get_recommended_case_no()
+ )
+ )
- if res:
- frappe.throw(
- _("""Case No(s) already in use. Try from Case No {0}""").format(self.get_recommended_case_no())
+ def validate_items(self):
+ for item in self.items:
+ if item.qty <= 0:
+ frappe.throw(_("Row {0}: Qty must be greater than 0.").format(item.idx))
+
+ if not item.dn_detail and not item.pi_detail:
+ frappe.throw(
+ _("Row {0}: Either Delivery Note Item or Packed Item reference is mandatory.").format(
+ item.idx
+ )
+ )
+
+ remaining_qty = frappe.db.get_value(
+ "Delivery Note Item" if item.dn_detail else "Packed Item",
+ {"name": item.dn_detail or item.pi_detail, "docstatus": 0},
+ ["sum(qty - packed_qty)"],
)
- def validate_qty(self):
- """Check packed qty across packing slips and delivery note"""
- # Get Delivery Note Items, Item Quantity Dict and No. of Cases for this Packing slip
- dn_details, ps_item_qty, no_of_cases = self.get_details_for_packing()
+ if remaining_qty is None:
+ frappe.throw(
+ _("Row {0}: Please provide a valid Delivery Note Item or Packed Item reference.").format(
+ item.idx
+ )
+ )
+ elif remaining_qty <= 0:
+ frappe.throw(
+ _("Row {0}: Packing Slip is already created for Item {1}.").format(
+ item.idx, frappe.bold(item.item_code)
+ )
+ )
+ elif item.qty > remaining_qty:
+ frappe.throw(
+ _("Row {0}: Qty cannot be greater than {1} for the Item {2}.").format(
+ item.idx, frappe.bold(remaining_qty), frappe.bold(item.item_code)
+ )
+ )
- for item in dn_details:
- new_packed_qty = (flt(ps_item_qty[item["item_code"]]) * no_of_cases) + flt(item["packed_qty"])
- if new_packed_qty > flt(item["qty"]) and no_of_cases:
- self.recommend_new_qty(item, ps_item_qty, no_of_cases)
-
- def get_details_for_packing(self):
- """
- Returns
- * 'Delivery Note Items' query result as a list of dict
- * Item Quantity dict of current packing slip doc
- * No. of Cases of this packing slip
- """
-
- rows = [d.item_code for d in self.get("items")]
-
- # also pick custom fields from delivery note
- custom_fields = ", ".join(
- "dni.`{0}`".format(d.fieldname)
- for d in frappe.get_meta("Delivery Note Item").get_custom_fields()
- if d.fieldtype not in no_value_fields
- )
-
- if custom_fields:
- custom_fields = ", " + custom_fields
-
- condition = ""
- if rows:
- condition = " and item_code in (%s)" % (", ".join(["%s"] * len(rows)))
-
- # gets item code, qty per item code, latest packed qty per item code and stock uom
- res = frappe.db.sql(
- """select item_code, sum(qty) as qty,
- (select sum(psi.qty * (abs(ps.to_case_no - ps.from_case_no) + 1))
- from `tabPacking Slip` ps, `tabPacking Slip Item` psi
- where ps.name = psi.parent and ps.docstatus = 1
- and ps.delivery_note = dni.parent and psi.item_code=dni.item_code) as packed_qty,
- stock_uom, item_name, description, dni.batch_no {custom_fields}
- from `tabDelivery Note Item` dni
- where parent=%s {condition}
- group by item_code""".format(
- condition=condition, custom_fields=custom_fields
- ),
- tuple([self.delivery_note] + rows),
- as_dict=1,
- )
-
- ps_item_qty = dict([[d.item_code, d.qty] for d in self.get("items")])
- no_of_cases = cint(self.to_case_no) - cint(self.from_case_no) + 1
-
- return res, ps_item_qty, no_of_cases
-
- def recommend_new_qty(self, item, ps_item_qty, no_of_cases):
- """
- Recommend a new quantity and raise a validation exception
- """
- item["recommended_qty"] = (flt(item["qty"]) - flt(item["packed_qty"])) / no_of_cases
- item["specified_qty"] = flt(ps_item_qty[item["item_code"]])
- if not item["packed_qty"]:
- item["packed_qty"] = 0
-
- frappe.throw(
- _("Quantity for Item {0} must be less than {1}").format(
- item.get("item_code"), item.get("recommended_qty")
- )
- )
-
- def update_item_details(self):
- """
- Fill empty columns in Packing Slip Item
- """
+ def set_missing_values(self):
if not self.from_case_no:
self.from_case_no = self.get_recommended_case_no()
- for d in self.get("items"):
- res = frappe.db.get_value("Item", d.item_code, ["weight_per_unit", "weight_uom"], as_dict=True)
+ for item in self.items:
+ stock_uom, weight_per_unit, weight_uom = frappe.db.get_value(
+ "Item", item.item_code, ["stock_uom", "weight_per_unit", "weight_uom"]
+ )
- if res and len(res) > 0:
- d.net_weight = res["weight_per_unit"]
- d.weight_uom = res["weight_uom"]
+ item.stock_uom = stock_uom
+ if weight_per_unit and not item.net_weight:
+ item.net_weight = weight_per_unit
+ if weight_uom and not item.weight_uom:
+ item.weight_uom = weight_uom
def get_recommended_case_no(self):
- """
- Returns the next case no. for a new packing slip for a delivery
- note
- """
- recommended_case_no = frappe.db.sql(
- """SELECT MAX(to_case_no) FROM `tabPacking Slip`
- WHERE delivery_note = %s AND docstatus=1""",
- self.delivery_note,
+ """Returns the next case no. for a new packing slip for a delivery note"""
+
+ return (
+ cint(
+ frappe.db.get_value(
+ "Packing Slip", {"delivery_note": self.delivery_note, "docstatus": 1}, ["max(to_case_no)"]
+ )
+ )
+ + 1
)
- return cint(recommended_case_no[0][0]) + 1
+ def calculate_net_total_pkg(self):
+ self.net_weight_uom = self.items[0].weight_uom if self.items else None
+ self.gross_weight_uom = self.net_weight_uom
- @frappe.whitelist()
- def get_items(self):
- self.set("items", [])
+ net_weight_pkg = 0
+ for item in self.items:
+ if item.weight_uom != self.net_weight_uom:
+ frappe.throw(
+ _(
+ "Different UOM for items will lead to incorrect (Total) Net Weight value. Make sure that Net Weight of each item is in the same UOM."
+ )
+ )
- custom_fields = frappe.get_meta("Delivery Note Item").get_custom_fields()
+ net_weight_pkg += flt(item.net_weight) * flt(item.qty)
- dn_details = self.get_details_for_packing()[0]
- for item in dn_details:
- if flt(item.qty) > flt(item.packed_qty):
- ch = self.append("items", {})
- ch.item_code = item.item_code
- ch.item_name = item.item_name
- ch.stock_uom = item.stock_uom
- ch.description = item.description
- ch.batch_no = item.batch_no
- ch.qty = flt(item.qty) - flt(item.packed_qty)
+ self.net_weight_pkg = round(net_weight_pkg, 2)
- # copy custom fields
- for d in custom_fields:
- if item.get(d.fieldname):
- ch.set(d.fieldname, item.get(d.fieldname))
-
- self.update_item_details()
+ if not flt(self.gross_weight_pkg):
+ self.gross_weight_pkg = self.net_weight_pkg
@frappe.whitelist()
diff --git a/erpnext/stock/doctype/packing_slip/test_packing_slip.py b/erpnext/stock/doctype/packing_slip/test_packing_slip.py
index bc405b2..96da23d 100644
--- a/erpnext/stock/doctype/packing_slip/test_packing_slip.py
+++ b/erpnext/stock/doctype/packing_slip/test_packing_slip.py
@@ -3,9 +3,118 @@
import unittest
-# test_records = frappe.get_test_records('Packing Slip')
+import frappe
from frappe.tests.utils import FrappeTestCase
+from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
+from erpnext.stock.doctype.delivery_note.delivery_note import make_packing_slip
+from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+from erpnext.stock.doctype.item.test_item import make_item
-class TestPackingSlip(unittest.TestCase):
- pass
+
+class TestPackingSlip(FrappeTestCase):
+ def test_packing_slip(self):
+ # Step - 1: Create a Product Bundle
+ items = create_items()
+ make_product_bundle(items[0], items[1:], 5)
+
+ # Step - 2: Create a Delivery Note (Draft) with Product Bundle
+ dn = create_delivery_note(
+ item_code=items[0],
+ qty=2,
+ do_not_save=True,
+ )
+ dn.append(
+ "items",
+ {
+ "item_code": items[1],
+ "warehouse": "_Test Warehouse - _TC",
+ "qty": 10,
+ },
+ )
+ dn.save()
+
+ # Step - 3: Make a Packing Slip from Delivery Note for 4 Qty
+ ps1 = make_packing_slip(dn.name)
+ for item in ps1.items:
+ item.qty = 4
+ ps1.save()
+ ps1.submit()
+
+ # Test - 1: `Packed Qty` should be updated to 4 in Delivery Note Items and Packed Items.
+ dn.load_from_db()
+ for item in dn.items:
+ if not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}):
+ self.assertEqual(item.packed_qty, 4)
+
+ for item in dn.packed_items:
+ self.assertEqual(item.packed_qty, 4)
+
+ # Step - 4: Make another Packing Slip from Delivery Note for 6 Qty
+ ps2 = make_packing_slip(dn.name)
+ ps2.save()
+ ps2.submit()
+
+ # Test - 2: `Packed Qty` should be updated to 10 in Delivery Note Items and Packed Items.
+ dn.load_from_db()
+ for item in dn.items:
+ if not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}):
+ self.assertEqual(item.packed_qty, 10)
+
+ for item in dn.packed_items:
+ self.assertEqual(item.packed_qty, 10)
+
+ # Step - 5: Cancel Packing Slip [1]
+ ps1.cancel()
+
+ # Test - 3: `Packed Qty` should be updated to 4 in Delivery Note Items and Packed Items.
+ dn.load_from_db()
+ for item in dn.items:
+ if not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}):
+ self.assertEqual(item.packed_qty, 6)
+
+ for item in dn.packed_items:
+ self.assertEqual(item.packed_qty, 6)
+
+ # Step - 6: Cancel Packing Slip [2]
+ ps2.cancel()
+
+ # Test - 4: `Packed Qty` should be updated to 0 in Delivery Note Items and Packed Items.
+ dn.load_from_db()
+ for item in dn.items:
+ if not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}):
+ self.assertEqual(item.packed_qty, 0)
+
+ for item in dn.packed_items:
+ self.assertEqual(item.packed_qty, 0)
+
+ # Step - 7: Make Packing Slip for more Qty than Delivery Note
+ ps3 = make_packing_slip(dn.name)
+ ps3.items[0].qty = 20
+
+ # Test - 5: Should throw an ValidationError, as Packing Slip Qty is more than Delivery Note Qty
+ self.assertRaises(frappe.exceptions.ValidationError, ps3.save)
+
+ # Step - 8: Make Packing Slip for less Qty than Delivery Note
+ ps4 = make_packing_slip(dn.name)
+ ps4.items[0].qty = 5
+ ps4.save()
+ ps4.submit()
+
+ # Test - 6: Delivery Note should throw a ValidationError on Submit, as Packed Qty and Delivery Note Qty are not the same
+ dn.load_from_db()
+ self.assertRaises(frappe.exceptions.ValidationError, dn.submit)
+
+
+def create_items():
+ items_properties = [
+ {"is_stock_item": 0},
+ {"is_stock_item": 1, "stock_uom": "Nos"},
+ {"is_stock_item": 1, "stock_uom": "Box"},
+ ]
+
+ items = []
+ for properties in items_properties:
+ items.append(make_item(properties=properties).name)
+
+ return items
diff --git a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json
index 4270839..4bd9035 100644
--- a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json
+++ b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json
@@ -20,7 +20,8 @@
"stock_uom",
"weight_uom",
"page_break",
- "dn_detail"
+ "dn_detail",
+ "pi_detail"
],
"fields": [
{
@@ -121,13 +122,23 @@
"fieldtype": "Data",
"hidden": 1,
"in_list_view": 1,
- "label": "DN Detail"
+ "label": "Delivery Note Item",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "pi_detail",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Delivery Note Packed Item",
+ "no_copy": 1,
+ "read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2021-12-14 01:22:00.715935",
+ "modified": "2023-04-28 15:00:14.079306",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packing Slip Item",
@@ -136,5 +147,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index 3fd4cec..8d8b69d 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -47,6 +47,7 @@
self.validate_putaway_capacity()
if self._action == "submit":
+ self.validate_reserved_stock()
self.make_batches("warehouse")
def on_submit(self):
@@ -60,6 +61,7 @@
def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
+ self.validate_reserved_stock()
self.make_sle_on_cancel()
self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle()
@@ -224,6 +226,46 @@
except Exception as e:
self.validation_messages.append(_("Row #") + " " + ("%d: " % (row.idx)) + cstr(e))
+ def validate_reserved_stock(self) -> None:
+ """Raises an exception if there is any reserved stock for the items in the Stock Reconciliation."""
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_details_for_item_and_warehouse as get_sre_reserved_qty_details,
+ )
+
+ item_code_list, warehouse_list = [], []
+ for item in self.items:
+ item_code_list.append(item.item_code)
+ warehouse_list.append(item.warehouse)
+
+ sre_reserved_qty_details = get_sre_reserved_qty_details(item_code_list, warehouse_list)
+
+ if sre_reserved_qty_details:
+ data = []
+ for (item_code, warehouse), reserved_qty in sre_reserved_qty_details.items():
+ data.append([item_code, warehouse, reserved_qty])
+
+ msg = ""
+ if len(data) == 1:
+ msg = _(
+ "{0} units are reserved for Item {1} in Warehouse {2}, please un-reserve the same to {3} the Stock Reconciliation."
+ ).format(bold(data[0][2]), bold(data[0][0]), bold(data[0][1]), self._action)
+ else:
+ items_html = ""
+ for d in data:
+ items_html += "<li>{0} units of Item {1} in Warehouse {2}</li>".format(
+ bold(d[2]), bold(d[0]), bold(d[1])
+ )
+
+ msg = _(
+ "The stock has been reserved for the following Items and Warehouses, un-reserve the same to {0} the Stock Reconciliation: <br /><br /> {1}"
+ ).format(self._action, items_html)
+
+ frappe.throw(
+ msg,
+ title=_("Stock Reservation"),
+ )
+
def update_stock_ledger(self):
"""find difference between current and expected entries
and create stock ledger entries based on the difference"""
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..7c7abac
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
@@ -0,0 +1,234 @@
+{
+ "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",
+ "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",
+ "column_break_jbyr",
+ "project",
+ "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": "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
+ },
+ {
+ "default": "0",
+ "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"
+ }
+ ],
+ "hide_toolbar": 1,
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2023-03-29 18:36:26.752872",
+ "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..5819dd7
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
@@ -0,0 +1,312 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.query_builder.functions import Sum
+
+
+class StockReservationEntry(Document):
+ def validate(self) -> None:
+ from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
+
+ self.validate_mandatory()
+ self.validate_for_group_warehouse()
+ 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:
+ """Raises 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):
+ frappe.throw(_("{0} is required").format(self.meta.get_label(d)))
+
+ def validate_for_group_warehouse(self) -> None:
+ """Raises exception if `Warehouse` is a Group Warehouse."""
+
+ if frappe.get_cached_value("Warehouse", self.warehouse, "is_group"):
+ frappe.throw(
+ _("Stock cannot be reserved in group warehouse {0}.").format(frappe.bold(self.warehouse)),
+ title=_("Invalid Warehouse"),
+ )
+
+ 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 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 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"):
+ frappe.throw(
+ _("Please enable {0} in the {1}.").format(
+ frappe.bold("Stock Reservation"), frappe.bold("Stock Settings")
+ )
+ )
+
+ # Voucher types allowed for stock reservation
+ allowed_voucher_types = ["Sales Order"]
+
+ if voucher.doctype not in allowed_voucher_types:
+ frappe.throw(
+ _("Stock Reservation can only be created against {0}.").format(", ".join(allowed_voucher_types))
+ )
+
+
+def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float:
+ """Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item and Warehouse combination."""
+
+ 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 get_stock_reservation_entries_for_voucher(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str = None, fields: list[str] = None
+) -> 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)
+ & (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 get_sre_reserved_qty_details_for_item_and_warehouse(
+ item_code_list: list, warehouse_list: list
+) -> dict:
+ """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }."""
+
+ 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
+
+
+def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str) -> float:
+ """Returns `Reserved Qty` for Item and Warehouse combination."""
+
+ reserved_qty = 0.0
+
+ if item_code and warehouse:
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ return (
+ 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(as_list=True)[0][0] or 0.0
+
+ return reserved_qty
+
+
+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_qty_details_for_voucher_detail_no(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str
+) -> list:
+ """Returns a list like ["warehouse", "reserved_qty"]."""
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ reserved_qty_details = (
+ frappe.qb.from_(sre)
+ .select(sre.warehouse, (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"]))
+ )
+ .orderby(sre.creation)
+ .groupby(sre.warehouse)
+ ).run(as_list=True)
+
+ if reserved_qty_details:
+ return reserved_qty_details[0]
+
+ return reserved_qty_details
+
+
+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"]
+ ):
+ return True
+
+ return False
+
+
+@frappe.whitelist()
+def cancel_stock_reservation_entries(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str = None, notify: bool = True
+) -> None:
+ """Cancel Stock Reservation Entries for the given voucher."""
+
+ sre_list = get_stock_reservation_entries_for_voucher(
+ voucher_type, voucher_no, voucher_detail_no, fields=["name"]
+ )
+
+ if sre_list:
+ for sre in sre_list:
+ frappe.get_doc("Stock Reservation Entry", sre.name).cancel()
+
+ if notify:
+ frappe.msgprint(_("Stock Reservation Entries Cancelled"), alert=True, indicator="red")
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..5a082dd
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
@@ -0,0 +1,317 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase, change_settings
+
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
+from erpnext.stock.utils import get_stock_balance
+
+
+class TestStockReservationEntry(FrappeTestCase):
+ def setUp(self) -> None:
+ self.items = create_items()
+ create_material_receipts(self.items)
+
+ def tearDown(self) -> None:
+ return super().tearDown()
+
+ def test_validate_stock_reservation_settings(self) -> None:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ validate_stock_reservation_settings,
+ )
+
+ voucher = frappe._dict(
+ {
+ "doctype": "Sales Order",
+ }
+ )
+
+ # Case - 1: When `Stock Reservation` is disabled in `Stock Settings`, throw `ValidationError`
+ with change_settings("Stock Settings", {"enable_stock_reservation": 0}):
+ self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher)
+
+ with change_settings("Stock Settings", {"enable_stock_reservation": 1}):
+ # Case - 2: When `Voucher Type` is not allowed for `Stock Reservation`, throw `ValidationError`
+ voucher.doctype = "NOT ALLOWED"
+ self.assertRaises(frappe.ValidationError, validate_stock_reservation_settings, voucher)
+
+ # Case - 3: When `Voucher Type` is allowed for `Stock Reservation`
+ voucher.doctype = "Sales Order"
+ self.assertIsNone(validate_stock_reservation_settings(voucher), None)
+
+ def test_get_available_qty_to_reserve(self) -> None:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_available_qty_to_reserve,
+ )
+
+ item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
+
+ # Case - 1: When `Reserved Qty` is `0`, Available Qty to Reserve = Actual Qty
+ cancel_all_stock_reservation_entries()
+ available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
+ expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse)
+
+ self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
+
+ # Case - 2: When `Reserved Qty` is `> 0`, Available Qty to Reserve = Actual Qty - Reserved Qty
+ sre = make_stock_reservation_entry(
+ item_code=item_code,
+ warehouse=warehouse,
+ ignore_validate=True,
+ )
+ available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
+ expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse) - sre.reserved_qty
+
+ self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
+
+ def test_update_status(self) -> None:
+ sre = make_stock_reservation_entry(
+ reserved_qty=30,
+ ignore_validate=True,
+ do_not_submit=True,
+ )
+
+ # Draft: When DocStatus is `0`
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Draft")
+
+ # Partially Reserved: When DocStatus is `1` and `Reserved Qty` < `Voucher Qty`
+ sre.submit()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Partially Reserved")
+
+ # Reserved: When DocStatus is `1` and `Reserved Qty` = `Voucher Qty`
+ sre.reserved_qty = sre.voucher_qty
+ sre.db_update()
+ sre.update_status()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Reserved")
+
+ # Partially Delivered: When DocStatus is `1` and (0 < `Delivered Qty` < `Voucher Qty`)
+ sre.delivered_qty = 10
+ sre.db_update()
+ sre.update_status()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Partially Delivered")
+
+ # Delivered: When DocStatus is `1` and `Delivered Qty` = `Voucher Qty`
+ sre.delivered_qty = sre.voucher_qty
+ sre.db_update()
+ sre.update_status()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Delivered")
+
+ # Cancelled: When DocStatus is `2`
+ sre.cancel()
+ sre.load_from_db()
+ self.assertEqual(sre.status, "Cancelled")
+
+ @change_settings("Stock Settings", {"enable_stock_reservation": 1})
+ def test_update_reserved_qty_in_voucher(self) -> None:
+ item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
+
+ # Step - 1: Create a `Sales Order`
+ so = make_sales_order(
+ item_code=item_code,
+ warehouse=warehouse,
+ qty=50,
+ rate=100,
+ do_not_submit=True,
+ )
+ so.reserve_stock = 0 # Stock Reservation Entries won't be created on submit
+ so.items[0].reserve_stock = 1
+ so.save()
+ so.submit()
+
+ # Step - 2: Create a `Stock Reservation Entry[1]` for the `Sales Order Item`
+ sre1 = make_stock_reservation_entry(
+ item_code=item_code,
+ warehouse=warehouse,
+ voucher_type="Sales Order",
+ voucher_no=so.name,
+ voucher_detail_no=so.items[0].name,
+ reserved_qty=30,
+ )
+
+ so.load_from_db()
+ sre1.load_from_db()
+ self.assertEqual(sre1.status, "Partially Reserved")
+ self.assertEqual(so.items[0].stock_reserved_qty, sre1.reserved_qty)
+
+ # Step - 3: Create a `Stock Reservation Entry[2]` for the `Sales Order Item`
+ sre2 = make_stock_reservation_entry(
+ item_code=item_code,
+ warehouse=warehouse,
+ voucher_type="Sales Order",
+ voucher_no=so.name,
+ voucher_detail_no=so.items[0].name,
+ reserved_qty=20,
+ )
+
+ so.load_from_db()
+ sre2.load_from_db()
+ self.assertEqual(sre1.status, "Partially Reserved")
+ self.assertEqual(so.items[0].stock_reserved_qty, sre1.reserved_qty + sre2.reserved_qty)
+
+ # Step - 4: Cancel `Stock Reservation Entry[1]`
+ sre1.cancel()
+ so.load_from_db()
+ sre1.load_from_db()
+ self.assertEqual(sre1.status, "Cancelled")
+ self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty)
+
+ # Step - 5: Cancel `Stock Reservation Entry[2]`
+ sre2.cancel()
+ so.load_from_db()
+ sre2.load_from_db()
+ self.assertEqual(sre1.status, "Cancelled")
+ self.assertEqual(so.items[0].stock_reserved_qty, 0)
+
+ @change_settings("Stock Settings", {"enable_stock_reservation": 1})
+ def test_cant_consume_reserved_stock(self) -> None:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ )
+ from erpnext.stock.stock_ledger import NegativeStockError
+
+ item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
+
+ # Step - 1: Create a `Sales Order`
+ so = make_sales_order(
+ item_code=item_code,
+ warehouse=warehouse,
+ qty=50,
+ rate=100,
+ do_not_submit=True,
+ )
+ so.reserve_stock = 1 # Stock Reservation Entries will be created on submit
+ so.items[0].reserve_stock = 1
+ so.save()
+ so.submit()
+
+ actual_qty = get_stock_balance(item_code, warehouse)
+
+ # Step - 2: Try to consume (Transfer/Issue/Deliver) the Available Qty via Stock Entry or Delivery Note, should throw `NegativeStockError`.
+ se = make_stock_entry(
+ item_code=item_code,
+ qty=actual_qty,
+ from_warehouse=warehouse,
+ rate=100,
+ purpose="Material Issue",
+ do_not_submit=True,
+ )
+ self.assertRaises(NegativeStockError, se.submit)
+ se.cancel()
+
+ # Step - 3: Unreserve the stock and consume the Available Qty via Stock Entry.
+ cancel_stock_reservation_entries(so.doctype, so.name)
+
+ se = make_stock_entry(
+ item_code=item_code,
+ qty=actual_qty,
+ from_warehouse=warehouse,
+ rate=100,
+ purpose="Material Issue",
+ do_not_submit=True,
+ )
+ se.submit()
+ se.cancel()
+
+
+def create_items() -> dict:
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ items_details = {
+ # Stock Items
+ "SR Item 1": {"is_stock_item": 1, "valuation_rate": 100},
+ "SR Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"},
+ # Batch Items
+ "SR Batch Item 1": {
+ "is_stock_item": 1,
+ "valuation_rate": 100,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "SRBI-1-.#####.",
+ },
+ "SR Batch Item 2": {
+ "is_stock_item": 1,
+ "valuation_rate": 200,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "SRBI-2-.#####.",
+ "stock_uom": "Kg",
+ },
+ # Serial Item
+ "SR Serial Item 1": {
+ "is_stock_item": 1,
+ "valuation_rate": 100,
+ "has_serial_no": 1,
+ "serial_no_series": "SRSI-1-.#####",
+ },
+ # Batch and Serial Item
+ "SR Batch and Serial Item 1": {
+ "is_stock_item": 1,
+ "valuation_rate": 100,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "SRBSI-1-.#####.",
+ "has_serial_no": 1,
+ "serial_no_series": "SRBSI-1-.#####",
+ },
+ }
+
+ items = {}
+ for item_code, properties in items_details.items():
+ items[item_code] = make_item(item_code, properties)
+
+ return items
+
+
+def create_material_receipts(
+ items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100
+) -> None:
+ for item in items.values():
+ if item.is_stock_item:
+ make_stock_entry(
+ item_code=item.item_code,
+ qty=qty,
+ to_warehouse=warehouse,
+ rate=item.valuation_rate,
+ purpose="Material Receipt",
+ )
+
+
+def cancel_all_stock_reservation_entries() -> None:
+ sre_list = frappe.db.get_all("Stock Reservation Entry", filters={"docstatus": 1}, pluck="name")
+
+ for sre in sre_list:
+ frappe.get_doc("Stock Reservation Entry", sre).cancel()
+
+
+def make_stock_reservation_entry(**args):
+ doc = frappe.new_doc("Stock Reservation Entry")
+ args = frappe._dict(args)
+
+ doc.item_code = args.item_code or "SR Item 1"
+ doc.warehouse = args.warehouse or "_Test Warehouse - _TC"
+ doc.voucher_type = args.voucher_type
+ doc.voucher_no = args.voucher_no
+ doc.voucher_detail_no = args.voucher_detail_no
+ doc.available_qty = args.available_qty or 100
+ doc.voucher_qty = args.voucher_qty or 50
+ doc.stock_uom = args.stock_uom or "Nos"
+ doc.reserved_qty = args.reserved_qty or 50
+ doc.delivered_qty = args.delivered_qty or 0
+ doc.company = args.company or "_Test Company"
+
+ if args.ignore_validate:
+ doc.flags.ignore_validate = True
+
+ if not args.do_not_save:
+ doc.save()
+ if not args.do_not_submit:
+ doc.submit()
+
+ return doc
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index ec7fb0f..35970b1 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,37 @@
{
"fieldname": "column_break_121",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "stock_reservation_tab",
+ "fieldtype": "Tab Break",
+ "label": "Stock Reservation"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_stock_reservation",
+ "fieldtype": "Check",
+ "label": "Enable Stock Reservation"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.enable_stock_reservation",
+ "description": "If enabled, <b>Stock Reservation Entries</b> will be created on submission of <b>Sales Order</b>",
+ "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",
+ "description": "If enabled, <b>Partial Stock Reservation Entries</b> can be created. For example, If you have a <b>Sales Order</b> of 100 units and the Available Stock is 90 units then a Stock Reservation Entry will be created for 90 units. ",
+ "fieldname": "allow_partial_reservation",
+ "fieldtype": "Check",
+ "label": "Allow Partial Reservation"
}
],
"icon": "icon-cog",
@@ -346,7 +382,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-02-05 15:33:43.692736",
+ "modified": "2023-04-22 08:48:37.767646",
"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..e25c843 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.validate_stock_reservation()
def validate_warehouses(self):
warehouse_fields = ["default_warehouse", "sample_retention_warehouse"]
@@ -99,6 +100,74 @@
if self.stock_frozen_upto:
check_pending_reposting(self.stock_frozen_upto)
+ def validate_stock_reservation(self):
+ """Raises an exception if the user tries to enable/disable `Stock Reservation` with `Negative Stock` or `Open Stock Reservation Entries`."""
+
+ # Skip validation for tests
+ if frappe.flags.in_test:
+ return
+
+ db_allow_negative_stock = frappe.db.get_single_value("Stock Settings", "allow_negative_stock")
+ db_enable_stock_reservation = frappe.db.get_single_value(
+ "Stock Settings", "enable_stock_reservation"
+ )
+
+ # Change in value of `Allow Negative Stock`
+ if db_allow_negative_stock != self.allow_negative_stock:
+
+ # Disable -> Enable: Don't allow if `Stock Reservation` is enabled
+ if self.allow_negative_stock and self.enable_stock_reservation:
+ frappe.throw(
+ _("As {0} is enabled, you can not enable {1}.").format(
+ frappe.bold("Stock Reservation"), frappe.bold("Allow Negative Stock")
+ )
+ )
+
+ # Change in value of `Enable Stock Reservation`
+ if db_enable_stock_reservation != self.enable_stock_reservation:
+
+ # Disable -> Enable
+ if self.enable_stock_reservation:
+
+ # Don't allow if `Allow Negative Stock` is enabled
+ if self.allow_negative_stock:
+ frappe.throw(
+ _("As {0} is enabled, you can not enable {1}.").format(
+ frappe.bold("Allow Negative Stock"), frappe.bold("Stock Reservation")
+ )
+ )
+
+ else:
+ # Don't allow if there are negative stock
+ from frappe.query_builder.functions import Round
+
+ precision = frappe.db.get_single_value("System Settings", "float_precision") or 3
+ bin = frappe.qb.DocType("Bin")
+ bin_with_negative_stock = (
+ frappe.qb.from_(bin).select(bin.name).where(Round(bin.actual_qty, precision) < 0).limit(1)
+ ).run()
+
+ if bin_with_negative_stock:
+ frappe.throw(
+ _("As there are negative stock, you can not enable {0}.").format(
+ frappe.bold("Stock Reservation")
+ )
+ )
+
+ # Enable -> Disable
+ else:
+ # Don't allow if there are open Stock Reservation Entries
+ has_reserved_stock = frappe.db.exists(
+ "Stock Reservation Entry", {"docstatus": 1, "status": ["!=", "Delivered"]}
+ )
+
+ if has_reserved_stock:
+ frappe.throw(
+ _("As there are reserved stock, you cannot disable {0}.").format(
+ frappe.bold("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 68df918..f2c2e27 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -100,6 +100,7 @@
_func = itemgetter(1)
self.item_warehouse_map = self.get_item_warehouse_map()
+ sre_details = self.get_sre_reserved_qty_details()
variant_values = {}
if self.filters.get("show_variant_attributes"):
@@ -133,6 +134,9 @@
report_data.update(stock_ageing_data)
+ report_data.update(
+ {"reserved_stock": sre_details.get((report_data.item_code, report_data.warehouse), 0.0)}
+ )
self.data.append(report_data)
def get_item_warehouse_map(self):
@@ -159,6 +163,18 @@
return item_warehouse_map
+ def get_sre_reserved_qty_details(self) -> dict:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_details_for_item_and_warehouse as get_reserved_qty_details,
+ )
+
+ item_code_list, warehouse_list = [], []
+ for d in self.item_warehouse_map:
+ item_code_list.append(d[1])
+ warehouse_list.append(d[2])
+
+ return get_reserved_qty_details(item_code_list, warehouse_list)
+
def prepare_item_warehouse_map(self, item_warehouse_map, entry, group_by_key):
qty_dict = item_warehouse_map[group_by_key]
for field in self.inventory_dimensions:
@@ -436,6 +452,13 @@
"options": "currency",
},
{
+ "label": _("Reserved Stock"),
+ "fieldname": "reserved_stock",
+ "fieldtype": "Float",
+ "width": 80,
+ "convertible": "qty",
+ },
+ {
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
@@ -573,7 +596,7 @@
"warehouse",
"item_name",
"item_group",
- "projecy",
+ "project",
"stock_uom",
"company",
"opening_fifo_queue",
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 2f64edd..711694b 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -13,6 +13,9 @@
import erpnext
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
+from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock,
+)
from erpnext.stock.utils import (
get_incoming_outgoing_rate_for_cancel,
get_or_make_bin,
@@ -380,6 +383,7 @@
self.new_items_found = False
self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict())
self.affected_transactions: Set[Tuple[str, str]] = set()
+ self.reserved_stock = get_reserved_stock(self.args.item_code, self.args.warehouse)
self.data = frappe._dict()
self.initialize_previous_data(self.args)
@@ -627,7 +631,7 @@
validate negative stock for entries current datetime onwards
will not consider cancelled entries
"""
- diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty)
+ diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty) - flt(self.reserved_stock)
diff = flt(diff, self.flt_precision) # respect system precision
if diff < 0 and abs(diff) > 0.0001:
@@ -1038,7 +1042,7 @@
) in frappe.local.flags.currently_saving:
msg = _("{0} units of {1} needed in {2} to complete this transaction.").format(
- abs(deficiency),
+ frappe.bold(abs(deficiency)),
frappe.get_desk_link("Item", exceptions[0]["item_code"]),
frappe.get_desk_link("Warehouse", warehouse),
)
@@ -1046,7 +1050,7 @@
msg = _(
"{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction."
).format(
- abs(deficiency),
+ frappe.bold(abs(deficiency)),
frappe.get_desk_link("Item", exceptions[0]["item_code"]),
frappe.get_desk_link("Warehouse", warehouse),
exceptions[0]["posting_date"],
@@ -1055,6 +1059,12 @@
)
if msg:
+ if self.reserved_stock:
+ allowed_qty = abs(exceptions[0]["actual_qty"]) - abs(exceptions[0]["diff"])
+ msg = "{0} As {1} units are reserved, you are allowed to consume only {2} units.".format(
+ msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty)
+ )
+
msg_list.append(msg)
if msg_list: