diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 5445c6bb287f..a96f0ce2f930 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -206,6 +206,20 @@ frappe.ui.form.on("Work Order", { frm.trigger("add_custom_button_to_return_components"); }, + has_unreserved_stock(frm) { + let has_unreserved_stock = frm.doc.required_items.some( + (item) => flt(item.required_qty) > flt(item.stock_reserved_qty) + ); + + return has_unreserved_stock; + }, + + has_reserved_stock(frm) { + let has_reserved_stock = frm.doc.required_items.some((item) => flt(item.stock_reserved_qty) > 0); + + return has_reserved_stock; + }, + add_custom_button_to_return_components: function (frm) { if (frm.doc.docstatus === 1 && ["Closed", "Completed"].includes(frm.doc.status)) { let non_consumed_items = frm.doc.required_items.filter((d) => { @@ -545,6 +559,12 @@ frappe.ui.form.on("Work Order", { erpnext.work_order.calculate_cost(frm.doc); erpnext.work_order.calculate_total_cost(frm); }, + + on_submit() { + frappe.route_hooks.after_submit = (frm) => { + frm.reload_doc(); + }; + }, }); frappe.ui.form.on("Work Order Item", { @@ -657,6 +677,8 @@ erpnext.work_order = { ); } + erpnext.work_order.setup_stock_reservation(frm); + if (!frm.doc.track_semi_finished_goods) { const show_start_btn = frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1; @@ -750,6 +772,38 @@ erpnext.work_order = { } } }, + + setup_stock_reservation(frm) { + if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) { + if ( + frm.events.has_unreserved_stock(frm) && + (frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty) + ) { + frm.add_custom_button( + __("Reserve"), + () => erpnext.stock_reservation.make_entries(frm, "required_items"), + __("Stock Reservation") + ); + } + + if (frm.events.has_reserved_stock(frm)) { + if (frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty) { + frm.add_custom_button( + __("Unreserve"), + () => erpnext.stock_reservation.unreserve_stock(frm), + __("Stock Reservation") + ); + } + + frm.add_custom_button( + __("Reserved Stock"), + () => erpnext.stock_reservation.show_reserved_stock(frm, "required_items"), + __("Stock Reservation") + ); + } + } + }, + calculate_cost: function (doc) { if (doc.operations) { var op = doc.operations; diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index e564af27d95d..3171bbd27418 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -23,6 +23,7 @@ "process_loss_qty", "project", "track_semi_finished_goods", + "reserve_stock", "warehouses", "source_warehouse", "wip_warehouse", @@ -102,7 +103,7 @@ "no_copy": 1, "oldfieldname": "status", "oldfieldtype": "Select", - "options": "\nDraft\nSubmitted\nNot Started\nIn Process\nCompleted\nStopped\nClosed\nCancelled", + "options": "\nDraft\nSubmitted\nNot Started\nIn Process\nStock Reserved\nStock Partially Reserved\nCompleted\nStopped\nClosed\nCancelled", "read_only": 1, "reqd": 1, "search_index": 1 @@ -214,7 +215,6 @@ "read_only": 1 }, { - "allow_on_submit": 1, "fieldname": "sales_order", "fieldtype": "Link", "in_global_search": 1, @@ -322,6 +322,8 @@ "label": "Expected Delivery Date" }, { + "collapsible": 1, + "collapsible_depends_on": "eval:!doc.operations", "fieldname": "operations_section", "fieldtype": "Section Break", "label": "Operations", @@ -584,6 +586,12 @@ "fieldtype": "Check", "label": "Track Semi Finished Goods", "read_only": 1 + }, + { + "default": "0", + "fieldname": "reserve_stock", + "fieldtype": "Check", + "label": " Reserve Stock" } ], "icon": "fa fa-cogs", @@ -591,7 +599,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:13:00.129434", + "modified": "2024-09-23 16:56:00.483027", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index b9957af46b7d..14b4f793fb84 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -19,6 +19,7 @@ getdate, now, nowdate, + parse_json, time_diff_in_hours, ) from pypika import functions as fn @@ -34,6 +35,7 @@ from erpnext.stock.doctype.batch.batch import make_batch from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos, get_serial_nos +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty from erpnext.stock.utils import get_bin, get_latest_stock_qty, validate_warehouse_company from erpnext.utilities.transaction_base import validate_uom_is_integer @@ -73,9 +75,7 @@ class WorkOrder(Document): from frappe.types import DF from erpnext.manufacturing.doctype.work_order_item.work_order_item import WorkOrderItem - from erpnext.manufacturing.doctype.work_order_operation.work_order_operation import ( - WorkOrderOperation, - ) + from erpnext.manufacturing.doctype.work_order_operation.work_order_operation import WorkOrderOperation actual_end_date: DF.Datetime | None actual_operating_cost: DF.Currency @@ -89,7 +89,7 @@ class WorkOrder(Document): corrective_operation_cost: DF.Currency description: DF.SmallText | None expected_delivery_date: DF.Date | None - fg_warehouse: DF.Link + fg_warehouse: DF.Link | None from_wip_warehouse: DF.Check has_batch_no: DF.Check has_serial_no: DF.Check @@ -114,6 +114,7 @@ class WorkOrder(Document): project: DF.Link | None qty: DF.Float required_items: DF.Table[WorkOrderItem] + reserve_stock: DF.Check sales_order: DF.Link | None sales_order_item: DF.Data | None scrap_warehouse: DF.Link | None @@ -125,6 +126,8 @@ class WorkOrder(Document): "Submitted", "Not Started", "In Process", + "Stock Reserved", + "Stock Partially Reserved", "Completed", "Stopped", "Closed", @@ -132,6 +135,7 @@ class WorkOrder(Document): ] stock_uom: DF.Link | None total_operating_cost: DF.Currency + track_semi_finished_goods: DF.Check transfer_material_against: DF.Literal["", "Work Order", "Job Card"] update_consumed_material_cost_in_project: DF.Check use_multi_level_bom: DF.Check @@ -177,10 +181,37 @@ def validate(self): self.status = self.get_status() self.validate_workstation_type() self.reset_use_multi_level_bom() + self.set_reserve_stock() + self.validate_fg_warehouse_for_reservation() validate_uom_is_integer(self, "stock_uom", ["qty", "produced_qty"]) self.set_required_items(reset_only_qty=len(self.get("required_items"))) + self.enable_auto_reserve_stock() + + def validate_fg_warehouse_for_reservation(self): + if self.reserve_stock and self.sales_order: + warehouses = frappe.get_all( + "Sales Order Item", + filters={"parent": self.sales_order, "item_code": self.production_item}, + pluck="warehouse", + ) + + if self.fg_warehouse not in warehouses: + frappe.throw( + _("Warehouse {0} is not allowed for Sales Order {1}, it should be {2}").format( + self.fg_warehouse, self.sales_order, warehouses[0] + ), + title=_("Target Warehouse Reservation Error"), + ) + + def set_reserve_stock(self): + for row in self.required_items: + row.reserve_stock = self.reserve_stock + + def enable_auto_reserve_stock(self): + if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"): + self.reserve_stock = 1 def reset_use_multi_level_bom(self): if self.is_new(): @@ -366,6 +397,17 @@ def get_status(self, status=None): ): status = "In Process" + if status == "Not Started" and self.reserve_stock: + for row in self.required_items: + if not row.stock_reserved_qty: + continue + + if row.stock_reserved_qty >= row.required_qty: + status = "Stock Reserved" + else: + status = "Stock Partially Reserved" + break + return status def update_work_order_qty(self): @@ -482,6 +524,9 @@ def on_submit(self): self.update_planned_qty() self.create_job_card() + if self.reserve_stock: + self.update_stock_reservation() + def on_cancel(self): self.validate_cancel() self.db_set("status", "Cancelled") @@ -500,6 +545,13 @@ def on_cancel(self): self.update_reserved_qty_for_production() self.delete_auto_created_batch_and_serial_no() + if self.reserve_stock: + self.update_stock_reservation() + + def update_stock_reservation(self): + make_stock_reservation_entries(self) + self.db_set("status", self.get_status()) + def create_serial_no_batch_no(self): if not (self.has_serial_no or self.has_batch_no): return @@ -1077,6 +1129,8 @@ def update_required_items(self): # update in bin self.update_reserved_qty_for_production() + self.validate_reserved_qty() + def update_reserved_qty_for_production(self, items=None): """update reserved_qty_for_production in bins""" for d in self.required_items: @@ -1170,9 +1224,28 @@ def update_transferred_qty_for_required_items(self): transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data}) for row in self.required_items: - row.db_set( - "transferred_qty", (transferred_items.get(row.item_code) or 0.0), update_modified=False - ) + transferred_qty = transferred_items.get(row.item_code) or 0.0 + row.db_set("transferred_qty", transferred_qty, update_modified=False) + + if not self.reserve_stock: + continue + + warehouse = row.source_warehouse + + if name := frappe.db.get_value( + "Stock Reservation Entry", + { + "voucher_no": self.name, + "item_code": row.item_code, + "voucher_detail_no": row.name, + "warehouse": warehouse, + }, + "name", + ): + doc = frappe.get_doc("Stock Reservation Entry", name) + doc.db_set("transferred_qty", flt(transferred_qty), update_modified=False) + doc.update_status() + doc.update_reserved_stock_in_bin() def update_returned_qty(self): ste = frappe.qb.DocType("Stock Entry") @@ -1207,30 +1280,66 @@ def update_consumed_qty_for_required_items(self): Update consumed qty from submitted stock entries against a work order for each stock item """ + wip_warehouse = self.wip_warehouse + if self.skip_transfer and not self.from_wip_warehouse: + wip_warehouse = None for item in self.required_items: - consumed_qty = frappe.db.sql( - """ - SELECT - SUM(detail.qty) - FROM - `tabStock Entry` entry, - `tabStock Entry Detail` detail - WHERE - entry.work_order = %(name)s - AND (entry.purpose = "Material Consumption for Manufacture" - OR entry.purpose = "Manufacture") - AND entry.docstatus = 1 - AND detail.parent = entry.name - AND detail.s_warehouse IS NOT null - AND (detail.item_code = %(item)s - OR detail.original_item = %(item)s) - """, - {"name": self.name, "item": item.item_code}, - )[0][0] - + consumed_qty = get_consumed_qty(self.name, item.item_code) item.db_set("consumed_qty", flt(consumed_qty), update_modified=False) + if not self.reserve_stock: + continue + + wip_warehouse = wip_warehouse or item.source_warehouse + self.update_consumed_qty_in_stock_reservation(item, consumed_qty, wip_warehouse) + + def update_consumed_qty_in_stock_reservation(self, item, consumed_qty, wip_warehouse): + filters = { + "voucher_no": self.name, + "item_code": item.item_code, + "voucher_detail_no": item.name, + "warehouse": wip_warehouse, + "docstatus": 1, + "status": ("in", ["Reserved", "Partially Transferred"]), + } + if not self.skip_transfer: + filters["from_voucher_no"] = ("is", "set") + + if names := frappe.get_all( + "Stock Reservation Entry", filters=filters, pluck="name", order_by="creation" + ): + for name in names: + if consumed_qty < 0: + consumed_qty = 0 + + doc = frappe.get_doc("Stock Reservation Entry", name) + reserved_qty = doc.reserved_qty + qty_to_update = consumed_qty if consumed_qty < reserved_qty else reserved_qty + if qty_to_update >= 0: + doc.db_set("consumed_qty", flt(qty_to_update), update_modified=False) + consumed_qty -= qty_to_update + doc.update_status() + doc.update_reserved_stock_in_bin() + + def validate_reserved_qty(self): + sre_details = get_sre_details(self.name) + for item in self.required_items: + if details := sre_details.get(item.name): + if details.reserved_qty < details.consumed_qty: + frappe.throw( + _("Consumed Qty cannot be greater than Reserved Qty for item {0}").format( + details.consumed_qty, details.reserved_qty, item.item_code + ) + ) + + if details.reserved_qty < details.transferred_qty: + frappe.throw( + _("Transferred Qty {0} cannot be greater than Reserved Qty {1} for item {2}").format( + details.transferred_qty, details.reserved_qty, item.item_code + ) + ) + @frappe.whitelist() def make_bom(self): data = frappe.db.sql( @@ -1257,6 +1366,210 @@ def make_bom(self): bom.set_bom_material_details() return bom + def set_reserved_qty_for_wip_and_fg(self, stock_entry): + items = frappe._dict() + if stock_entry.purpose == "Manufacture" and self.sales_order: + items = self.get_finished_goods_for_reservation(stock_entry) + elif stock_entry.purpose == "Material Transfer for Manufacture": + items = self.get_list_of_materials_for_reservation(stock_entry) + + if not items: + return + + item_list = list(items.values()) + make_stock_reservation_entries(self, item_list, notify=True) + + def get_list_of_materials_for_reservation(self, stock_entry): + items = frappe._dict() + vocher_detail_no = {d.item_code: d.name for d in self.required_items} + + for row in stock_entry.items: + if row.item_code not in items: + items[row.item_code] = frappe._dict( + { + "voucher_no": self.name, + "voucher_type": self.doctype, + "voucher_detail_no": vocher_detail_no.get(row.item_code), + "item_code": row.item_code, + "warehouse": row.t_warehouse, + "stock_qty": row.transfer_qty, + "from_voucher_no": stock_entry.name, + "from_voucher_type": stock_entry.doctype, + "from_voucher_detail_no": row.name, + } + ) + else: + items[row.item_code]["stock_qty"] += row.transfer_qty + + return items + + def get_finished_goods_for_reservation(self, stock_entry): + items = frappe._dict() + + so_details = self.get_so_details() + qty = so_details.stock_qty - so_details.stock_reserved_qty + if not qty: + return items + + for row in stock_entry.items: + if not row.t_warehouse or not row.is_finished_item: + continue + + if qty > row.transfer_qty: + qty = row.transfer_qty + + if row.item_code not in items: + items[row.item_code] = frappe._dict( + { + "voucher_no": self.sales_order, + "voucher_type": "Sales Order", + "voucher_detail_no": so_details.name, + "item_code": row.item_code, + "warehouse": row.t_warehouse, + "stock_qty": qty, + "from_voucher_no": stock_entry.name, + "from_voucher_type": stock_entry.doctype, + "from_voucher_detail_no": row.name, + } + ) + else: + items[row.item_code]["stock_qty"] += qty + + return items + + def get_so_details(self): + return frappe.db.get_value( + "Sales Order Item", + { + "parent": self.sales_order, + "item_code": self.production_item, + "docstatus": 1, + "stock_reserved_qty": 0, + }, + ["name", "stock_qty", "stock_reserved_qty"], + as_dict=1, + ) + + def get_voucher_details(self, stock_entry): + vocher_detail_no = {} + + if stock_entry.purpose == "Manufacture" and self.sales_order: + so_details = frappe.db.get_value( + "Sales Order Item", + { + "parent": self.sales_order, + "item_code": self.production_item, + "docstatus": 1, + "stock_reserved_qty": 0, + }, + ["name", "stock_qty", "stock_reserved_qty"], + as_dict=1, + ) + + vocher_detail_no = {self.production_item: so_details} + else: + vocher_detail_no = {d.item_code: d.name for d in self.required_items} + + return frappe._dict(vocher_detail_no) + + def cancel_reserved_qty_for_wip_and_fg(self, ste_doc): + for row in ste_doc.items: + sre_list = frappe.get_all( + "Stock Reservation Entry", + filters={ + "from_voucher_no": ste_doc.name, + "from_voucher_detail_no": row.name, + "docstatus": 1, + }, + pluck="name", + ) + + if sre_list: + cancel_stock_reservation_entries(self, sre_list) + + +@frappe.whitelist() +def make_stock_reservation_entries(doc, items=None, notify=False): + if isinstance(doc, str): + doc = parse_json(doc) + doc = frappe.get_doc("Work Order", doc.get("name")) + + if items and isinstance(items, str): + items = parse_json(items) + + sre = StockReservation(doc, items=items, notify=notify) + if doc.docstatus == 1: + sre.make_stock_reservation_entries() + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True) + elif doc.docstatus == 2: + sre.cancel_stock_reservation_entries() + + doc.reload() + doc.db_set("status", doc.get_status()) + + +@frappe.whitelist() +def cancel_stock_reservation_entries(doc, sre_list): + if isinstance(doc, str): + doc = parse_json(doc) + doc = frappe.get_doc("Work Order", doc.get("name")) + + sre = StockReservation(doc) + sre.cancel_stock_reservation_entries(sre_list) + + doc.reload() + doc.db_set("status", doc.get_status()) + + +def get_sre_details(work_order): + sre_details = frappe._dict() + + data = frappe.get_all( + "Stock Reservation Entry", + filters={"voucher_no": work_order, "docstatus": 1}, + fields=[ + "item_code", + "warehouse", + "reserved_qty", + "transferred_qty", + "consumed_qty", + "voucher_detail_no", + ], + ) + + for row in data: + if row.voucher_detail_no not in sre_details: + sre_details.setdefault(row.voucher_detail_no, row) + else: + sre_details[row.voucher_detail_no].reserved_qty += row.reserved_qty + sre_details[row.voucher_detail_no].transferred_qty += row.transferred_qty + sre_details[row.voucher_detail_no].consumed_qty += row.consumed_qty + + return sre_details + + +def get_consumed_qty(work_order, item_code): + stock_entry = frappe.qb.DocType("Stock Entry") + stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") + + query = ( + frappe.qb.from_(stock_entry) + .inner_join(stock_entry_detail) + .on(stock_entry_detail.parent == stock_entry.name) + .select(fn.Sum(stock_entry_detail.qty).as_("qty")) + .where( + (stock_entry.work_order == work_order) + & (stock_entry.purpose.isin(["Manufacture", "Material Consumption for Manufacture"])) + & (stock_entry.docstatus == 1) + & (stock_entry_detail.s_warehouse.isnotnull()) + & ((stock_entry_detail.item_code == item_code) | (stock_entry_detail.original_item == item_code)) + ) + ) + + result = query.run() + + return flt(result[0][0]) if result else 0 + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs diff --git a/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py b/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py index d0dcc5593241..67e5ccf55a4e 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py +++ b/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py @@ -4,9 +4,13 @@ def get_data(): return { "fieldname": "work_order", - "non_standard_fieldnames": {"Batch": "reference_name"}, + "non_standard_fieldnames": { + "Batch": "reference_name", + "Stock Reservation Entry": "voucher_no", + }, "transactions": [ {"label": _("Transactions"), "items": ["Stock Entry", "Job Card", "Pick List"]}, {"label": _("Reference"), "items": ["Serial No", "Batch", "Material Request"]}, + {"label": _("Stock Reservation"), "items": ["Stock Reservation Entry"]}, ], } diff --git a/erpnext/manufacturing/doctype/work_order/work_order_list.js b/erpnext/manufacturing/doctype/work_order/work_order_list.js index 1e1e5661fe58..578d970990f9 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order_list.js +++ b/erpnext/manufacturing/doctype/work_order/work_order_list.js @@ -22,6 +22,8 @@ frappe.listview_settings["Work Order"] = { "Not Started": "red", "In Process": "orange", Completed: "green", + "Stock Reserved": "blue", + "Stock Partially Reserved": "orange", Cancelled: "gray", }[doc.status], "status,=," + doc.status, diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json index abb73f0ccc80..e4f912db6b37 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -22,8 +22,11 @@ "transferred_qty", "consumed_qty", "returned_qty", + "section_break_idhr", "available_qty_at_source_warehouse", - "available_qty_at_wip_warehouse" + "available_qty_at_wip_warehouse", + "column_break_jash", + "stock_reserved_qty" ], "fields": [ { @@ -145,11 +148,27 @@ "fieldtype": "Int", "label": "Operation Row Id", "read_only": 1 + }, + { + "fieldname": "section_break_idhr", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_jash", + "fieldtype": "Column Break" + }, + { + "fieldname": "stock_reserved_qty", + "fieldtype": "Float", + "label": "Stock Reserved Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2024-03-27 13:12:00.429838", + "modified": "2024-09-15 15:22:43.687847", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Item", diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.py b/erpnext/manufacturing/doctype/work_order_item/work_order_item.py index 267ca5d21dee..926890f8adee 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.py +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.py @@ -25,6 +25,7 @@ class WorkOrderItem(Document): item_code: DF.Link | None item_name: DF.Data | None operation: DF.Link | None + operation_row_id: DF.Int parent: DF.Data parentfield: DF.Data parenttype: DF.Data @@ -32,6 +33,7 @@ class WorkOrderItem(Document): required_qty: DF.Float returned_qty: DF.Float source_warehouse: DF.Link | None + stock_reserved_qty: DF.Float transferred_qty: DF.Float # end: auto-generated types diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 6e1097072fa8..221e3a62c6a4 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -1,5 +1,6 @@ import "./conf"; import "./utils"; +import "./stock_reservation"; import "./queries"; import "./sms_manager"; import "./utils/party"; diff --git a/erpnext/public/js/stock_reservation.js b/erpnext/public/js/stock_reservation.js new file mode 100644 index 000000000000..58d57aff4990 --- /dev/null +++ b/erpnext/public/js/stock_reservation.js @@ -0,0 +1,339 @@ +frappe.provide("erpnext.stock_reservation"); + +$.extend(erpnext.stock_reservation, { + make_entries(frm, table_name) { + erpnext.stock_reservation.setup(frm, table_name); + }, + + setup(frm, table_name) { + let parms = erpnext.stock_reservation.get_parms(frm, table_name); + + erpnext.stock_reservation.dialog = new frappe.ui.Dialog({ + title: __("Stock Reservation"), + size: "extra-large", + fields: erpnext.stock_reservation.get_dialog_fields(frm, parms), + primary_action_label: __("Reserve Stock"), + primary_action: () => { + erpnext.stock_reservation.reserve_stock(frm, parms); + }, + }); + + erpnext.stock_reservation.render_items(frm, parms); + }, + + get_parms(frm, table_name) { + let params = { + table_name: table_name || "items", + child_doctype: frm.doc.doctype + " Item", + }; + + params["qty_field"] = { + "Sales Order": "stock_qty", + "Work Order": "required_qty", + }[frm.doc.doctype]; + + params["dispatch_qty_field"] = { + "Sales Order": "delivered_qty", + "Work Order": "transferred_qty", + }[frm.doc.doctype]; + + params["method"] = { + "Sales Order": "delivered_qty", + "Work Order": + "erpnext.manufacturing.doctype.work_order.work_order.make_stock_reservation_entries", + }[frm.doc.doctype]; + + return params; + }, + + get_dialog_fields(frm, parms) { + let fields = erpnext.stock_reservation.fields || []; + let qty_field = parms.qty_field; + let dialog = erpnext.stock_reservation.dialog; + + let table_fields = [ + { fieldtype: "Section Break" }, + { + fieldname: "items", + fieldtype: "Table", + label: __("Items to Reserve"), + allow_bulk_edit: false, + cannot_add_rows: true, + cannot_delete_rows: true, + data: [], + fields: [ + { + fieldname: frappe.scrub(parms.child_doctype), + fieldtype: "Link", + label: __(parms.child_doctype), + options: parms.child_doctype, + reqd: 1, + in_list_view: 1, + get_query: () => { + return { + query: "erpnext.controllers.queries.get_filtered_child_rows", + filters: { + parenttype: frm.doc.doctype, + parent: frm.doc.name, + reserve_stock: 1, + }, + }; + }, + onchange: (event) => { + if (event) { + let name = $(event.currentTarget).closest(".grid-row").attr("data-name"); + let item_row = dialog.fields_dict.items.grid.grid_rows_by_docname[name].doc; + + frm.doc.items.forEach((item) => { + if (item.name === item_row.sales_order_item) { + item_row.item_code = item.item_code; + } + }); + dialog.fields_dict.items.grid.refresh(); + } + }, + }, + { + fieldname: "item_code", + fieldtype: "Link", + label: __("Item Code"), + options: "Item", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "warehouse", + fieldtype: "Link", + label: __("Warehouse"), + options: "Warehouse", + reqd: 1, + in_list_view: 1, + get_query: () => { + return { + filters: [["Warehouse", "is_group", "!=", 1]], + }; + }, + }, + { + fieldname: qty_field, + fieldtype: "Float", + label: __("Qty"), + reqd: 1, + in_list_view: 1, + }, + ], + }, + ]; + + return fields.concat(table_fields); + }, + + render_items(frm, parms) { + let dialog = erpnext.stock_reservation.dialog; + let field = frappe.scrub(parms.child_doctype); + + let qty_field = parms.qty_field; + let dispatch_qty_field = parms.dispatch_qty_field; + + if (frm.doc.doctype === "Work Order" && frm.doc.skip_transfer) { + dispatch_qty_field = "consumed_qty"; + } + + frm.doc[parms.table_name].forEach((item) => { + if (frm.doc.reserve_stock) { + let unreserved_qty = + (flt(item[qty_field]) - + (item.stock_reserved_qty + ? flt(item.stock_reserved_qty) + : flt(item[dispatch_qty_field]) * flt(item.conversion_factor || 1))) / + flt(item.conversion_factor || 1); + + if (unreserved_qty > 0) { + let args = { + __checked: 1, + item_code: item.item_code, + warehouse: item.warehouse || item.source_warehouse, + }; + + args[field] = item.name; + args[qty_field] = unreserved_qty; + dialog.fields_dict.items.df.data.push(args); + } + } + }); + + dialog.fields_dict.items.grid.refresh(); + dialog.show(); + }, + + reserve_stock(frm, parms) { + let dialog = erpnext.stock_reservation.dialog; + var data = { items: dialog.fields_dict.items.grid.get_selected_children() }; + + if (data.items && data.items.length > 0) { + frappe.call({ + method: parms.method, + args: { + doc: frm.doc, + items: data.items, + notify: true, + }, + freeze: true, + freeze_message: __("Reserving Stock..."), + callback: (r) => { + frm.doc.__onload.has_unreserved_stock = false; + frm.reload_doc(); + }, + }); + + dialog.hide(); + } else { + frappe.msgprint(__("Please select items to reserve.")); + } + }, + + unreserve_stock(frm) { + erpnext.stock_reservation.get_stock_reservation_entries(frm.doctype, frm.docname).then((r) => { + if (!r.exc && r.message) { + if (r.message.length > 0) { + erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, r.message); + } else { + frappe.msgprint(__("No reserved stock to unreserve.")); + } + } + }); + }, + + prepare_for_cancel_sre_entries(frm, sre_entries) { + const dialog = new frappe.ui.Dialog({ + title: __("Stock Unreservation"), + size: "extra-large", + fields: [ + { + fieldname: "sr_entries", + fieldtype: "Table", + label: __("Reserved Stock"), + allow_bulk_edit: false, + cannot_add_rows: true, + cannot_delete_rows: true, + in_place_edit: true, + data: [], + fields: erpnext.stock_reservation.get_fields_for_cancel(), + }, + ], + primary_action_label: __("Unreserve Stock"), + primary_action: () => { + erpnext.stock_reservation.cancel_stock_reservation(dialog, frm); + }, + }); + + sre_entries.forEach((sre) => { + dialog.fields_dict.sr_entries.df.data.push({ + sre: sre.name, + item_code: sre.item_code, + warehouse: sre.warehouse, + qty: flt(sre.reserved_qty) - flt(sre.delivered_qty), + }); + }); + + dialog.fields_dict.sr_entries.grid.refresh(); + dialog.show(); + }, + + cancel_stock_reservation(dialog, frm) { + var data = { sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children() }; + + if (data.sr_entries?.length > 0) { + frappe.call({ + method: "erpnext.manufacturing.doctype.work_order.work_order.cancel_stock_reservation_entries", + args: { + doc: frm.doc, + sre_list: data.sr_entries.map((item) => item.sre), + }, + freeze: true, + freeze_message: __("Unreserving Stock..."), + callback: (r) => { + frm.doc.__onload.has_reserved_stock = false; + frm.reload_doc(); + }, + }); + + dialog.hide(); + } else { + frappe.msgprint(__("Please select items to unreserve.")); + } + }, + + get_stock_reservation_entries(voucher_type, voucher_no) { + return frappe.call({ + method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.get_stock_reservation_entries_for_voucher", + args: { + voucher_type: voucher_type, + voucher_no: voucher_no, + }, + }); + }, + + get_fields_for_cancel() { + return [ + { + fieldname: "sre", + fieldtype: "Link", + label: __("Stock Reservation Entry"), + options: "Stock Reservation Entry", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "item_code", + fieldtype: "Link", + label: __("Item Code"), + options: "Item", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "warehouse", + fieldtype: "Link", + label: __("Warehouse"), + options: "Warehouse", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "qty", + fieldtype: "Float", + label: __("Qty"), + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + ]; + }, + + show_reserved_stock(frm, table_name) { + if (!table_name) { + table_name = "items"; + } + + // Get the latest modified date from the items table. + var to_date = moment( + new Date(Math.max(...frm.doc[table_name].map((e) => new Date(e.modified)))) + ).format("YYYY-MM-DD"); + + let from_date = frm.doc.transaction_date || new Date(frm.doc.creation); + + frappe.route_options = { + company: frm.doc.company, + from_date: from_date, + to_date: to_date, + voucher_type: frm.doc.doctype, + voucher_no: frm.doc.name, + }; + frappe.set_route("query-report", "Reserved Stock"); + }, +}); diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 9e7d94f18856..0a70924d0ef6 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -233,6 +233,11 @@ def validate(self): self.advance_payment_status = "Not Requested" self.reset_default_field_value("set_warehouse", "items", "warehouse") + self.enable_auto_reserve_stock() + + def enable_auto_reserve_stock(self): + if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"): + self.reserve_stock = 1 def validate_po(self): # validate p.o date v/s delivery date diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index fde469356081..411d3d109239 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -246,8 +246,10 @@ def validate(self): def on_submit(self): self.validate_closed_subcontracting_order() self.make_bundle_using_old_serial_batch_fields() - self.update_stock_ledger() self.update_work_order() + self.update_stock_ledger() + self.make_stock_reserve_for_wip_and_fg() + self.validate_subcontract_order() self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() @@ -269,6 +271,7 @@ def on_cancel(self): self.validate_closed_subcontracting_order() self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() + self.cancel_stock_reserve_for_wip_and_fg() if self.work_order and self.purpose == "Material Consumption for Manufacture": self.validate_work_order_status() @@ -1635,6 +1638,32 @@ def _validate_work_order(pro_doc): if not pro_doc.operations: pro_doc.set_actual_dates() + def make_stock_reserve_for_wip_and_fg(self): + if self.is_stock_reserve_for_work_order(): + pro_doc = frappe.get_doc("Work Order", self.work_order) + if self.purpose == "Manufacture" and not pro_doc.sales_order: + return + + pro_doc.set_reserved_qty_for_wip_and_fg(self) + + def cancel_stock_reserve_for_wip_and_fg(self): + if self.is_stock_reserve_for_work_order(): + pro_doc = frappe.get_doc("Work Order", self.work_order) + if self.purpose == "Manufacture" and not pro_doc.sales_order: + return + + pro_doc.cancel_reserved_qty_for_wip_and_fg(self) + + def is_stock_reserve_for_work_order(self): + if ( + self.work_order + and self.stock_entry_type in ["Material Transfer for Manufacture", "Manufacture"] + and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock") + ): + return True + + return False + @frappe.whitelist() def get_item_details(self, args=None, for_update=False): item = frappe.db.sql( diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_dashboard.py b/erpnext/stock/doctype/stock_entry/stock_entry_dashboard.py index c1141fe43ec6..4c410212c8b0 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_dashboard.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_dashboard.py @@ -6,7 +6,7 @@ def get_data(): return { "fieldname": "stock_entry", "non_standard_fieldnames": { - # "DocType Name": "Reference field name", + "Stock Reservation Entry": "from_voucher_no", }, "internal_links": { "Purchase Order": ["items", "purchase_order"], @@ -22,5 +22,6 @@ def get_data(): "Subcontracting Receipt", ], }, + {"label": _("Stock Reservation"), "items": ["Stock Reservation Entry"]}, ], } diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index 3de76faa4d76..daae6218fed9 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -8,26 +8,31 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "item_code", - "warehouse", - "has_serial_no", - "has_batch_no", - "column_break_elik", + "section_break_xt4m", "voucher_type", "voucher_no", "voucher_detail_no", - "column_break_7dxj", - "from_voucher_type", - "from_voucher_no", - "from_voucher_detail_no", - "section_break_xt4m", - "stock_uom", "column_break_grdt", - "available_qty", "voucher_qty", + "available_qty", "column_break_o6ex", "reserved_qty", "delivered_qty", + "item_information_section", + "item_code", + "warehouse", + "column_break_elik", + "stock_uom", + "has_serial_no", + "has_batch_no", + "column_break_7dxj", + "from_voucher_type", + "from_voucher_no", + "from_voucher_detail_no", + "production_section", + "transferred_qty", + "column_break_qdwj", + "consumed_qty", "serial_and_batch_reservation_section", "reservation_based_on", "sb_entries", @@ -79,7 +84,7 @@ "no_copy": 1, "oldfieldname": "voucher_type", "oldfieldtype": "Data", - "options": "\nSales Order", + "options": "\nSales Order\nWork Order", "print_width": "150px", "read_only": 1, "width": "150px" @@ -213,16 +218,16 @@ }, { "fieldname": "section_break_xt4m", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Transaction Information" }, { "fieldname": "column_break_o6ex", "fieldtype": "Column Break" }, { - "collapsible": 1, "fieldname": "section_break_3vb3", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "More Information" }, { @@ -257,7 +262,7 @@ }, { "fieldname": "serial_and_batch_reservation_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Serial and Batch Reservation" }, { @@ -284,7 +289,7 @@ "fieldtype": "Select", "label": "From Voucher Type", "no_copy": 1, - "options": "\nPick List\nPurchase Receipt", + "options": "\nPick List\nPurchase Receipt\nStock Entry\nWork Order", "print_hide": 1, "read_only": 1, "report_hide": 1 @@ -308,6 +313,30 @@ "read_only": 1, "report_hide": 1, "search_index": 1 + }, + { + "fieldname": "production_section", + "fieldtype": "Section Break", + "label": "Production" + }, + { + "fieldname": "column_break_qdwj", + "fieldtype": "Column Break" + }, + { + "fieldname": "consumed_qty", + "fieldtype": "Float", + "label": "Consumed Qty" + }, + { + "fieldname": "item_information_section", + "fieldtype": "Section Break", + "label": "Item Information" + }, + { + "fieldname": "transferred_qty", + "fieldtype": "Float", + "label": "Qty in WIP Warehouse" } ], "hide_toolbar": 1, @@ -315,7 +344,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:10:45.186573", + "modified": "2024-09-19 15:28:24.726283", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index 6fd45ed5671b..80ff32f1d02e 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -7,7 +7,7 @@ from frappe import _ from frappe.model.document import Document from frappe.query_builder.functions import Sum -from frappe.utils import cint, flt, nowdate, nowtime +from frappe.utils import cint, flt, nowdate, nowtime, parse_json from erpnext.stock.utils import get_or_make_bin, get_stock_balance @@ -21,17 +21,16 @@ class StockReservationEntry(Document): if TYPE_CHECKING: from frappe.types import DF - from erpnext.stock.doctype.serial_and_batch_entry.serial_and_batch_entry import ( - SerialandBatchEntry, - ) + from erpnext.stock.doctype.serial_and_batch_entry.serial_and_batch_entry import SerialandBatchEntry amended_from: DF.Link | None available_qty: DF.Float company: DF.Link | None + consumed_qty: DF.Float delivered_qty: DF.Float from_voucher_detail_no: DF.Data | None from_voucher_no: DF.DynamicLink | None - from_voucher_type: DF.Literal["", "Pick List", "Purchase Receipt"] + from_voucher_type: DF.Literal["", "Pick List", "Purchase Receipt", "Stock Entry", "Work Order"] has_batch_no: DF.Check has_serial_no: DF.Check item_code: DF.Link | None @@ -43,10 +42,11 @@ class StockReservationEntry(Document): "Draft", "Partially Reserved", "Reserved", "Partially Delivered", "Delivered", "Cancelled" ] stock_uom: DF.Link | None + transferred_qty: DF.Float voucher_detail_no: DF.Data | None voucher_no: DF.DynamicLink | None voucher_qty: DF.Float - voucher_type: DF.Literal["", "Sales Order"] + voucher_type: DF.Literal["", "Sales Order", "Work Order"] warehouse: DF.Link | None # end: auto-generated types @@ -330,7 +330,10 @@ def update_reserved_qty_in_voucher( ) -> None: """Updates total reserved qty in the voucher.""" - item_doctype = "Sales Order Item" if self.voucher_type == "Sales Order" else None + item_doctype = { + "Sales Order": "Sales Order Item", + "Work Order": "Work Order Item", + }.get(self.voucher_type, None) if item_doctype: sre = frappe.qb.DocType("Stock Reservation Entry") @@ -393,7 +396,7 @@ def update_status(self, status: str | None = None, update_modified: bool = True) if self.docstatus == 2: status = "Cancelled" elif self.docstatus == 1: - if self.reserved_qty == self.delivered_qty: + if self.reserved_qty == (self.delivered_qty or self.transferred_qty or self.consumed_qty): status = "Delivered" elif self.delivered_qty and self.delivered_qty < self.reserved_qty: status = "Partially Delivered" @@ -433,8 +436,17 @@ def validate_with_allowed_qty(self, qty_to_be_reserved: float) -> None: get_available_qty_to_reserve(self.item_code, self.warehouse, ignore_sre=self.name), ) + from_voucher_detail_no = None + if self.from_voucher_type and self.from_voucher_type == "Stock Entry": + from_voucher_detail_no = self.from_voucher_detail_no + total_reserved_qty = get_sre_reserved_qty_for_voucher_detail_no( - self.voucher_type, self.voucher_no, self.voucher_detail_no, ignore_sre=self.name + self.voucher_type, + self.voucher_no, + self.voucher_detail_no, + ignore_sre=self.name, + warehouse=self.warehouse, + from_voucher_detail_no=from_voucher_detail_no, ) voucher_delivered_qty = 0 @@ -610,7 +622,11 @@ def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str | sre = frappe.qb.DocType("Stock Reservation Entry") query = ( frappe.qb.from_(sre) - .select(Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty")) + .select( + Sum(sre.reserved_qty - sre.delivered_qty - sre.transferred_qty - sre.consumed_qty).as_( + "reserved_qty" + ) + ) .where( (sre.docstatus == 1) & (sre.item_code == item_code) @@ -709,7 +725,12 @@ def get_sre_reserved_warehouses_for_voucher( def get_sre_reserved_qty_for_voucher_detail_no( - voucher_type: str, voucher_no: str, voucher_detail_no: str, ignore_sre=None + voucher_type: str, + voucher_no: str, + voucher_detail_no: str, + ignore_sre=None, + warehouse=None, + from_voucher_detail_no=None, ) -> float: """Returns `Reserved Qty` against the Voucher.""" @@ -717,7 +738,12 @@ def get_sre_reserved_qty_for_voucher_detail_no( query = ( frappe.qb.from_(sre) .select( - (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)), + ( + Sum(sre.reserved_qty) + - Sum(sre.delivered_qty) + - Sum(sre.transferred_qty) + - Sum(sre.consumed_qty) + ), ) .where( (sre.docstatus == 1) @@ -731,6 +757,12 @@ def get_sre_reserved_qty_for_voucher_detail_no( if ignore_sre: query = query.where(sre.name != ignore_sre) + if warehouse: + query = query.where(sre.warehouse == warehouse) + + if from_voucher_detail_no: + query = query.where(sre.from_voucher_detail_no == from_voucher_detail_no) + reserved_qty = query.run(as_list=True) return flt(reserved_qty[0][0]) @@ -880,6 +912,151 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st return False +class StockReservation: + def __init__(self, doc: object, items: list = None, notify: bool = True) -> None: + if isinstance(doc, str): + doc = parse_json(doc) + doc = frappe.get_doc("Work Order", doc.get("name")) + + self.doc = doc + self.items = items + self.initialize_fields() + + def initialize_fields(self) -> None: + self.table_name = "items" + self.qty_field = "stock_qty" + self.warehouse_field = "warehouse" + self.warehouse = None + + if self.doc.doctype == "Work Order": + self.table_name = "required_items" + self.qty_field = "required_qty" + self.warehouse_field = "source_warehouse" + if self.doc.skip_transfer and self.doc.from_wip_warehouse: + self.warehouse = self.doc.wip_warehouse + + def cancel_stock_reservation_entries(self, names=None) -> None: + """Cancels Stock Reservation Entries for the Voucher.""" + + if isinstance(names, str): + names = parse_json(names) + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(sre.name) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == self.doc.doctype) + & (sre.voucher_no == self.doc.name) + ) + ) + + if names: + query = query.where(sre.name.isin(names)) + elif self.warehouse: + query = query.where(sre.warehouse == self.warehouse) + + sre_names = query.run(as_dict=True) + + if sre_names: + for sre_name in sre_names: + sre_doc = frappe.get_doc("Stock Reservation Entry", sre_name.name) + sre_doc.cancel() + + if sre_names and names: + frappe.msgprint( + _("Stock has been unreserved for work order {0}.").format(frappe.bold(self.doc.name)), + alert=True, + ) + + def make_stock_reservation_entries(self): + items = self.items + if not items: + items = self.doc.get(self.table_name) + + child_doctype = frappe.scrub(self.doc.doctype + " Item") + + for item in items: + sre = frappe.new_doc("Stock Reservation Entry") + if isinstance(item, dict): + item = frappe._dict(item) + + item_details = frappe.get_cached_value( + "Item", item.item_code, ["has_serial_no", "has_batch_no", "stock_uom"], as_dict=True + ) + + warehouse = self.warehouse or item.get(self.warehouse_field) or item.warehouse + qty = item.get(self.qty_field) or item.get("stock_qty") + + self.available_qty_to_reserve = self.get_available_qty_to_reserve(item.item_code, warehouse) + if not self.available_qty_to_reserve: + self.throw_stock_not_exists_error(item, warehouse) + + self.qty_to_be_reserved = ( + qty if self.available_qty_to_reserve >= qty else self.available_qty_to_reserve + ) + + if not self.qty_to_be_reserved: + continue + + sre.item_code = item.item_code + sre.warehouse = warehouse + sre.has_serial_no = item_details.has_serial_no + sre.has_batch_no = item_details.has_batch_no + sre.voucher_type = item.get("voucher_type") or self.doc.doctype + sre.voucher_no = item.get("voucher_no") or self.doc.name + sre.voucher_detail_no = item.get(child_doctype) or item.name or item.voucher_detail_no + sre.available_qty = self.available_qty_to_reserve + sre.voucher_qty = qty + sre.reserved_qty = self.qty_to_be_reserved + sre.company = self.doc.company + sre.stock_uom = item_details.stock_uom + sre.project = self.doc.project + sre.from_voucher_no = item.get("from_voucher_no") + sre.from_voucher_detail_no = item.get("from_voucher_detail_no") + sre.from_voucher_type = item.get("from_voucher_type") + + sre.save() + sre.submit() + + def throw_stock_not_exists_error(self, item, warehouse): + frappe.msgprint( + _("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format( + item.idx, frappe.bold(item.item_code), frappe.bold(warehouse) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + + def get_available_qty_to_reserve(self, item_code, warehouse, ignore_sre=None): + available_qty = get_stock_balance(item_code, warehouse) + + if available_qty: + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(Sum(sre.reserved_qty - sre.delivered_qty - sre.transferred_qty - sre.consumed_qty)) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & (sre.reserved_qty >= sre.delivered_qty) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + ) + + if ignore_sre: + query = query.where(sre.name != ignore_sre) + + reserved_qty = query.run()[0][0] or 0.0 + + if reserved_qty: + return available_qty - reserved_qty + + return available_qty + + def create_stock_reservation_entries_for_so_items( sales_order: object, items_details: list[dict] | None = None, 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 index d2194a486c31..323d488e433f 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js @@ -2,6 +2,7 @@ // For license information, please see license.txt frappe.listview_settings["Stock Reservation Entry"] = { + filters: [["status", "!=", "Cancelled"]], get_indicator: function (doc) { const status_colors = { Draft: "red", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index a09215cc13f3..602375ecdc74 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -39,6 +39,7 @@ "action_if_quality_inspection_is_rejected", "stock_reservation_tab", "enable_stock_reservation", + "auto_reserve_stock", "column_break_rx3e", "allow_partial_reservation", "auto_reserve_stock_for_sales_order_on_purchase", @@ -460,6 +461,13 @@ "fieldname": "over_picking_allowance", "fieldtype": "Percent", "label": "Over Picking Allowance" + }, + { + "default": "0", + "description": "Upon submission of the Sales Order, Work Order, or Production Plan, the system will automatically reserve the stock.", + "fieldname": "auto_reserve_stock", + "fieldtype": "Check", + "label": "Auto Reserve Stock" } ], "icon": "icon-cog", @@ -467,7 +475,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-07-29 14:55:19.093508", + "modified": "2024-09-13 14:32:01.356841", "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 229ff9447507..d0d015774875 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -36,6 +36,7 @@ class StockSettings(Document): auto_indent: DF.Check auto_insert_price_list_rate_if_missing: DF.Check auto_reserve_serial_and_batch: DF.Check + auto_reserve_stock: DF.Check auto_reserve_stock_for_sales_order_on_purchase: DF.Check clean_description_html: DF.Check default_warehouse: DF.Link | None @@ -165,6 +166,9 @@ def validate_pending_reposts(self): 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`.""" + if not self.enable_stock_reservation and self.auto_reserve_stock: + self.auto_reserve_stock = 0 + # Skip validation for tests if frappe.flags.in_test: return diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.js b/erpnext/stock/report/reserved_stock/reserved_stock.js index 29a97ad9cb4a..f5aa3fb18564 100644 --- a/erpnext/stock/report/reserved_stock/reserved_stock.js +++ b/erpnext/stock/report/reserved_stock/reserved_stock.js @@ -68,7 +68,7 @@ frappe.query_reports["Reserved Stock"] = { default: "Sales Order", get_query: () => ({ filters: { - name: ["in", ["Sales Order"]], + name: ["in", ["Sales Order", "Work Order", "Production Plan"]], }, }), }, diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 52577312d8f0..0591568b34f4 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1503,7 +1503,7 @@ def raise_exceptions(self): msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty) ) else: - msg = f"{msg} As the full stock is reserved for other sales orders, you're not allowed to consume the stock." + msg = f"{msg} As the full stock is reserved for other transactions, you're not allowed to consume the stock." msg_list.append(msg)