From 1e606ecdfdb7ed6413eef79e4d64d5c8556d4f10 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Wed, 18 Sep 2024 13:14:34 +0530 Subject: [PATCH] feat: stock reservation for Work Order --- .../doctype/work_order/test_work_order.py | 182 +++++++++ .../doctype/work_order/work_order.js | 54 +++ .../doctype/work_order/work_order.json | 14 +- .../doctype/work_order/work_order.py | 367 ++++++++++++++++-- .../work_order/work_order_dashboard.py | 6 +- .../doctype/work_order/work_order_list.js | 2 + .../work_order_item/work_order_item.json | 24 +- .../work_order_item/work_order_item.py | 1 + erpnext/public/js/erpnext.bundle.js | 1 + erpnext/public/js/stock_reservation.js | 339 ++++++++++++++++ .../doctype/sales_order/sales_order.py | 5 + .../stock/doctype/stock_entry/stock_entry.py | 97 ++++- .../stock_entry/stock_entry_dashboard.py | 3 +- .../stock_reservation_entry.json | 67 +++- .../stock_reservation_entry.py | 200 +++++++++- .../stock_reservation_entry_list.js | 1 + .../stock_settings/stock_settings.json | 10 +- .../doctype/stock_settings/stock_settings.py | 4 + .../report/reserved_stock/reserved_stock.js | 2 +- erpnext/stock/stock_ledger.py | 2 +- 20 files changed, 1311 insertions(+), 70 deletions(-) create mode 100644 erpnext/public/js/stock_reservation.js diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index e19277475387..e3f3565ce4a1 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2467,6 +2467,187 @@ def test_components_as_per_bom_for_manufacture_entry(self): frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0) + def test_stock_reservation_for_serialized_raw_material(self): + from erpnext.stock.doctype.stock_entry.stock_entry_utils import ( + make_stock_entry as make_stock_entry_test_record, + ) + + production_item = "Test Stock Reservation FG 1" + rm_item = "Test Stock Reservation RM 1" + source_warehouse = "Stores - _TC" + + make_item(production_item, {"is_stock_item": 1}) + make_item(rm_item, {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TST-SER-RES-.###"}) + + bom = make_bom( + item=production_item, + source_warehouse=source_warehouse, + raw_materials=[rm_item], + operating_cost_per_bom_quantity=100, + do_not_submit=True, + ) + + for row in bom.exploded_items: + make_stock_entry_test_record( + item_code=row.item_code, + target=source_warehouse, + qty=10, + basic_rate=100, + ) + + wo = make_wo_order_test_record( + item=production_item, + qty=10, + reserve_stock=1, + source_warehouse=source_warehouse, + ) + + self.assertTrue(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name})) + + wo1 = make_wo_order_test_record( + item=production_item, + qty=10, + reserve_stock=1, + source_warehouse=source_warehouse, + ) + + self.assertFalse(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo1.name})) + + transfer_entry = frappe.get_doc(make_stock_entry(wo1.name, "Material Transfer for Manufacture", 10)) + transfer_entry.save() + + self.assertRaises(frappe.ValidationError, transfer_entry.submit) + + def test_stock_reservation_for_batched_raw_material(self): + from erpnext.stock.doctype.stock_entry.stock_entry_utils import ( + make_stock_entry as make_stock_entry_test_record, + ) + + production_item = "Test Stock Reservation FG 2" + rm_item = "Test Stock Reservation RM 2" + source_warehouse = "Stores - _TC" + + make_item(production_item, {"is_stock_item": 1}) + make_item( + rm_item, + { + "is_stock_item": 1, + "has_batch_no": 1, + "batch_number_series": "TST-BATCH-RES-.###", + "create_new_batch": 1, + }, + ) + + bom = make_bom( + item=production_item, + source_warehouse=source_warehouse, + raw_materials=[rm_item], + operating_cost_per_bom_quantity=100, + do_not_submit=True, + ) + + for row in bom.exploded_items: + make_stock_entry_test_record( + item_code=row.item_code, + target=source_warehouse, + qty=10, + basic_rate=100, + ) + + wo = make_wo_order_test_record( + item=production_item, + qty=10, + reserve_stock=1, + source_warehouse=source_warehouse, + ) + + self.assertTrue(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name})) + + wo1 = make_wo_order_test_record( + item=production_item, + qty=10, + reserve_stock=1, + source_warehouse=source_warehouse, + ) + + self.assertFalse(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo1.name})) + + transfer_entry = frappe.get_doc(make_stock_entry(wo1.name, "Material Transfer for Manufacture", 10)) + transfer_entry.save() + + self.assertRaises(frappe.ValidationError, transfer_entry.submit) + + def test_auto_stock_reservation_for_batched_raw_material(self): + from erpnext.stock.doctype.stock_entry.stock_entry_utils import ( + make_stock_entry as make_stock_entry_test_record, + ) + + frappe.db.set_single_value("Stock Settings", "auto_reserve_serial_and_batch", 1) + + production_item = "Test Stock Reservation FG 3" + rm_item = "Test Stock Reservation RM 3" + source_warehouse = "Stores - _TC" + + make_item(production_item, {"is_stock_item": 1}) + make_item( + rm_item, + { + "is_stock_item": 1, + "has_batch_no": 1, + "batch_number_series": "TST-BATCH-RES-.###", + "create_new_batch": 1, + }, + ) + + bom = make_bom( + item=production_item, + source_warehouse=source_warehouse, + raw_materials=[rm_item], + operating_cost_per_bom_quantity=100, + do_not_submit=True, + ) + + itemwise_batches = frappe._dict() + for row in bom.exploded_items: + se = make_stock_entry_test_record( + item_code=row.item_code, + target=source_warehouse, + qty=10, + basic_rate=100, + ) + + itemwise_batches[row.item_code] = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + wo = make_wo_order_test_record( + item=production_item, + qty=10, + reserve_stock=1, + source_warehouse=source_warehouse, + ) + + self.assertTrue(frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name})) + + for row in frappe.get_all("Stock Reservation Entry", filters={"voucher_no": wo.name}): + reservation_entry = frappe.get_doc("Stock Reservation Entry", row.name) + self.assertTrue(reservation_entry.has_batch_no) + self.assertTrue(reservation_entry.sb_entries) + + for row in bom.exploded_items: + make_stock_entry_test_record( + item_code=row.item_code, + target=source_warehouse, + qty=10, + basic_rate=100, + ) + + transfer_entry = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 10)) + transfer_entry.save() + transfer_entry.submit() + + for row in transfer_entry.items: + batch_no = get_batch_from_bundle(row.serial_and_batch_bundle) + self.assertEqual(batch_no, itemwise_batches[row.item_code]) + def make_operation(**kwargs): kwargs = frappe._dict(kwargs) @@ -2792,6 +2973,7 @@ def make_wo_order_test_record(**args): "BOM", {"item": wo_order.production_item, "is_active": 1, "is_default": 1} ) wo_order.qty = args.qty or 10 + wo_order.reserve_stock = args.reserve_stock or 0 wo_order.wip_warehouse = args.wip_warehouse or "_Test Warehouse - _TC" wo_order.fg_warehouse = args.fg_warehouse or "_Test Warehouse 1 - _TC" wo_order.scrap_warehouse = args.fg_warehouse or "_Test Scrap Warehouse - _TC" 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 7b5d796e50d3..c1c8473ab16f 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,6 +181,8 @@ 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() if self.source_warehouse: self.set_warehouses() @@ -184,6 +190,31 @@ def validate(self): 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 set_warehouses(self): for row in self.required_items: @@ -374,6 +405,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): @@ -490,6 +532,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") @@ -508,6 +553,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 @@ -1085,6 +1137,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: @@ -1178,9 +1232,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") @@ -1215,30 +1288,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( @@ -1265,6 +1374,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 c296da5a8fef..f74f60574de5 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -23,8 +23,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": [ { @@ -152,13 +155,28 @@ "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", - "options": "UOM", + "options": "UOM" + }, + { + "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-11-19 15:48:16.823384", + "modified": "2024-11-20 15:48:16.823384", "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 04f78eb1c3b2..9bc62a8a8323 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.py +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.py @@ -34,6 +34,7 @@ class WorkOrderItem(Document): returned_qty: DF.Float source_warehouse: DF.Link | None stock_uom: 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 0483feae2d8e..b5bca9736315 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -239,6 +239,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 a4e22a041a6d..9d9563a27264 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() @@ -1612,6 +1615,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: ItemDetailsCtx = None, for_update=False): item = frappe.db.sql( @@ -1886,11 +1915,77 @@ def get_items(self): self.set_process_loss_qty() self.load_items_from_bom() + self.set_serial_batch_from_reserved_entry() self.set_scrap_items() self.set_actual_qty() self.validate_customer_provided_item() self.calculate_rate_and_amount(raise_error_if_no_rate=False) + def set_serial_batch_from_reserved_entry(self): + if not self.work_order: + return + + if self.purpose not in ["Material Transfer for Manufacture"]: + return + + reservation_entries = self.get_reserved_entries() + for d in self.items: + key = (d.item_code, d.s_warehouse) + if details := reservation_entries.get(key): + if details.get("serial_no"): + d.serial_no = details.get("serial_no") + + if batches := details.get("batch_no"): + for batch_no, qty in batches.items(): + if qty > 0: + d.batch_no = batch_no + batches[batch_no] -= d.qty + + d.use_serial_batch_fields = 1 + + def get_reserved_entries(self): + doctype = frappe.qb.DocType("Stock Reservation Entry") + serial_batch_doc = frappe.qb.DocType("Serial and Batch Entry") + + query = ( + frappe.qb.from_(doctype) + .inner_join(serial_batch_doc) + .on(doctype.name == serial_batch_doc.parent) + .select( + serial_batch_doc.serial_no, + serial_batch_doc.batch_no, + serial_batch_doc.qty, + doctype.item_code, + doctype.warehouse, + doctype.transferred_qty, + doctype.consumed_qty, + doctype.reserved_qty.as_("qty"), + ) + .where((doctype.docstatus == 1) & (doctype.voucher_no == self.work_order)) + ) + + data = query.run(as_dict=True) + + itemwise_serial_batch_qty = frappe._dict() + + for d in data: + key = (d.item_code, d.warehouse) + if key not in itemwise_serial_batch_qty: + itemwise_serial_batch_qty[key] = frappe._dict( + { + "serial_no": [], + "batch_no": defaultdict(float), + } + ) + + details = itemwise_serial_batch_qty[key] + if d.serial_no: + details.serial_no.append(d.serial_no) + if d.batch_no: + details.batch_no[d.batch_no] += d.qty + + return itemwise_serial_batch_qty + def set_scrap_items(self): if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]: scrap_item_dict = self.get_bom_scrap_material(self.fg_completed_qty) 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..18df9d6f3242 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,150 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st return False +class StockReservation: + def __init__(self, doc, items=None, notify=True): + 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 15e36d78fb34..c6ce6b29124f 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", @@ -467,6 +468,13 @@ "fieldname": "allow_existing_serial_no", "fieldtype": "Check", "label": "Allow existing Serial No to be Manufactured/Received again" + }, + { + "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", @@ -474,7 +482,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-12-09 17:52:36.030456", + "modified": "2024-12-10 17:52:36.030456", "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 b7a317cd66a3..b7a9602594f4 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -37,6 +37,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 @@ -166,6 +167,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 997922c638ac..dbcaf5aa6671 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1507,7 +1507,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)