feat: stock reservation for Work Order

This commit is contained in:
Rohit Waghchaure
2024-09-18 13:14:34 +05:30
parent 8e77b26641
commit 4d050441b3
23 changed files with 1534 additions and 76 deletions

View File

@@ -2553,6 +2553,187 @@ class TestWorkOrder(IntegrationTestCase):
status = frappe.db.get_value("Serial No", row, "status")
self.assertEqual(status, "Consumed")
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)
@@ -2878,6 +3059,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"

View File

@@ -211,6 +211,28 @@ frappe.ui.form.on("Work Order", {
frm.trigger("add_custom_button_to_return_components");
frm.trigger("allow_alternative_item");
frm.trigger("hide_reserve_stock_button");
},
hide_reserve_stock_button(frm) {
frm.toggle_display("reserve_stock", false);
if (frm.doc.__onload?.enable_stock_reservation) {
frm.toggle_display("reserve_stock", true);
}
},
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) {
@@ -552,6 +574,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", {
@@ -667,6 +695,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;
@@ -759,6 +789,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;

View File

@@ -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",

View File

@@ -2,6 +2,7 @@
# License: GNU General Public License v3. See license.txt
import json
from collections import defaultdict
import frappe
from dateutil.relativedelta import relativedelta
@@ -19,6 +20,7 @@ from frappe.utils import (
getdate,
now,
nowdate,
parse_json,
time_diff_in_hours,
)
from pypika import functions as fn
@@ -34,6 +36,7 @@ from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings
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 +76,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 +90,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 +115,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 +127,8 @@ class WorkOrder(Document):
"Submitted",
"Not Started",
"In Process",
"Stock Reserved",
"Stock Partially Reserved",
"Completed",
"Stopped",
"Closed",
@@ -132,6 +136,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
@@ -144,6 +149,10 @@ class WorkOrder(Document):
self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on)
self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order)
self.set_onload("show_create_job_card_button", self.show_create_job_card_button())
self.set_onload(
"enable_stock_reservation",
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"),
)
def show_create_job_card_button(self):
operation_details = frappe._dict(
@@ -178,6 +187,8 @@ class WorkOrder(Document):
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()
@@ -185,6 +196,31 @@ class WorkOrder(Document):
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:
@@ -380,6 +416,17 @@ class WorkOrder(Document):
):
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):
@@ -496,6 +543,9 @@ class WorkOrder(Document):
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")
@@ -514,6 +564,13 @@ class WorkOrder(Document):
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
@@ -1091,6 +1148,8 @@ class WorkOrder(Document):
# 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:
@@ -1184,9 +1243,33 @@ class WorkOrder(Document):
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:
return
row_wise_serial_batch = get_row_wise_serial_batch(self.name)
for row in self.required_items:
self.update_qty_in_stock_reservation(row, transferred_qty, row_wise_serial_batch)
def update_qty_in_stock_reservation(self, row, transferred_qty, row_wise_serial_batch):
if name := frappe.db.get_value(
"Stock Reservation Entry",
{
"voucher_no": self.name,
"item_code": row.item_code,
"voucher_detail_no": row.name,
"warehouse": row.source_warehouse,
},
"name",
):
doc = frappe.get_doc("Stock Reservation Entry", name)
doc.db_set("transferred_qty", flt(transferred_qty), update_modified=False)
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
doc.update_status()
doc.update_reserved_stock_in_bin()
def update_returned_qty(self):
ste = frappe.qb.DocType("Stock Entry")
@@ -1221,30 +1304,71 @@ class WorkOrder(Document):
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,
}
if not self.skip_transfer:
filters["from_voucher_no"] = ("is", "set")
row_wise_serial_batch = get_row_wise_serial_batch(self.name, "Manufacture")
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
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
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(
@@ -1271,6 +1395,213 @@ class WorkOrder(Document):
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,
"serial_and_batch_bundles": [row.serial_and_batch_bundle],
}
)
else:
items[row.item_code]["stock_qty"] += row.transfer_qty
if row.serial_and_batch_bundle:
items[row.item_code]["serial_and_batch_bundles"].append(row.serial_and_batch_bundle)
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
@@ -1826,3 +2157,57 @@ def make_stock_return_entry(work_order):
stock_entry.set_stock_entry_type()
return stock_entry
def get_row_wise_serial_batch(work_order, purpose=None):
if not purpose:
purpose = "Material Transfer for Manufacture"
stock_entries = frappe.get_all(
"Stock Entry",
filters={
"work_order": work_order,
"purpose": purpose,
"docstatus": 1,
},
pluck="name",
)
serial_batch_entries = frappe.get_all(
"Serial and Batch Bundle",
fields=[
"`tabSerial and Batch Entry`.`serial_no`",
"`tabSerial and Batch Entry`.`batch_no`",
"`tabSerial and Batch Entry`.`qty`",
"`tabSerial and Batch Bundle`.`warehouse`",
"`tabSerial and Batch Bundle`.`item_code`",
"`tabSerial and Batch Bundle`.`voucher_detail_no`",
],
filters=[
["Serial and Batch Bundle", "voucher_type", "=", "Stock Entry"],
["Serial and Batch Bundle", "voucher_no", "in", stock_entries],
["Serial and Batch Bundle", "voucher_detail_no", "is", "set"],
["Serial and Batch Bundle", "docstatus", "<", 2],
["Serial and Batch Bundle", "is_cancelled", "=", 0],
["Serial and Batch Entry", "qty", "<", 0],
],
)
row_wise_serial_batch = {}
for entry in serial_batch_entries:
key = (entry.item_code, entry.warehouse, entry.voucher_detail_no)
if key not in row_wise_serial_batch:
row_wise_serial_batch[key] = frappe._dict(
{
"serial_nos": [],
"batch_nos": defaultdict(float),
}
)
details = row_wise_serial_batch[key]
if entry.serial_no:
details.serial_nos.append(entry.serial_no)
if entry.batch_no:
details.batch_nos[entry.batch_no] += abs(entry.qty)
return row_wise_serial_batch

View File

@@ -4,9 +4,13 @@ from frappe import _
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"]},
],
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -1,5 +1,6 @@
import "./conf";
import "./utils";
import "./stock_reservation";
import "./queries";
import "./sms_manager";
import "./utils/party";

View File

@@ -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");
},
});

View File

@@ -238,6 +238,11 @@ class SalesOrder(SellingController):
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

View File

@@ -1826,8 +1826,22 @@ def get_reserved_serial_nos(kwargs) -> list:
# Extend the list by serial nos reserved in POS Invoice
ignore_serial_nos.extend(get_reserved_serial_nos_for_pos(kwargs))
reserved_entries = get_reserved_serial_nos_for_sre(kwargs)
serial_nos = []
for entry in reserved_entries:
if kwargs.get("serial_nos") and entry.serial_no in kwargs.get("serial_nos"):
frappe.throw(
_(
"The Serial No {0} is reserved against the {1} {2} and cannot be used for any other transaction."
).format(bold(entry.serial_no), entry.voucher_type, bold(entry.voucher_no)),
title=_("Serial No Reserved"),
)
serial_nos.append(entry.serial_no)
# Extend the list by serial nos reserved via SRE
ignore_serial_nos.extend(get_reserved_serial_nos_for_sre(kwargs))
ignore_serial_nos.extend(serial_nos)
return ignore_serial_nos
@@ -1912,7 +1926,11 @@ def get_reserved_serial_nos_for_sre(kwargs) -> list:
frappe.qb.from_(sre)
.inner_join(sb_entry)
.on(sre.name == sb_entry.parent)
.select(sb_entry.serial_no)
.select(
sb_entry.serial_no,
sre.voucher_no,
sre.voucher_type,
)
.where(
(sre.docstatus == 1)
& (sre.item_code == kwargs.item_code)
@@ -1928,7 +1946,7 @@ def get_reserved_serial_nos_for_sre(kwargs) -> list:
if kwargs.ignore_voucher_nos:
query = query.where(sre.name.notin(kwargs.ignore_voucher_nos))
return [row[0] for row in query.run()]
return query.run(as_dict=True)
def get_reserved_batches_for_pos(kwargs) -> dict:

View File

@@ -17,7 +17,9 @@
"outgoing_rate",
"stock_value_difference",
"is_outward",
"stock_queue"
"stock_queue",
"section_break_gmim",
"reference_for_reservation"
],
"fields": [
{
@@ -66,6 +68,7 @@
"label": "Rate Section"
},
{
"allow_on_submit": 1,
"fieldname": "incoming_rate",
"fieldtype": "Float",
"label": "Valuation Rate",
@@ -86,6 +89,7 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "stock_value_difference",
"fieldtype": "Float",
"label": "Change in Stock Value",
@@ -117,12 +121,25 @@
"print_hide": 1,
"read_only": 1,
"report_hide": 1
},
{
"fieldname": "section_break_gmim",
"fieldtype": "Section Break"
},
{
"fieldname": "reference_for_reservation",
"fieldtype": "Data",
"hidden": 1,
"label": "Reference for Reservation",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:39.060322",
"modified": "2025-01-02 21:51:52.528916",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial and Batch Entry",

View File

@@ -23,6 +23,7 @@ class SerialandBatchEntry(Document):
parentfield: DF.Data
parenttype: DF.Data
qty: DF.Float
reference_for_reservation: DF.Data | None
serial_no: DF.Link | None
stock_queue: DF.SmallText | None
stock_value_difference: DF.Float

View File

@@ -246,8 +246,10 @@ class StockEntry(StockController):
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 @@ class StockEntry(StockController):
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()
@@ -1614,6 +1617,32 @@ class StockEntry(StockController):
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(
@@ -1888,11 +1917,93 @@ class StockEntry(StockController):
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 not frappe.get_cached_value("Work Order", self.work_order, "reserve_stock"):
return
if self.purpose not in ["Material Transfer for Manufacture", "Manufacture"]:
return
reservation_entries = self.get_available_reserved_materials()
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 = "\n".join(details.get("serial_no"))
if batches := details.get("batch_no"):
for batch_no, qty in batches.items():
if qty <= 0:
continue
if qty >= d.qty:
d.batch_no = batch_no
batches[batch_no] -= d.qty
else:
d.batch_no = batch_no
d.qty = qty
batches[batch_no] = 0
d.use_serial_batch_fields = 1
def get_available_reserved_materials(self):
reserved_entries = self.get_reserved_materials()
if not reserved_entries:
return {}
itemwise_serial_batch_qty = frappe._dict()
for d in reserved_entries:
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 get_reserved_materials(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.name,
doctype.transferred_qty,
doctype.consumed_qty,
)
.where((doctype.docstatus == 1) & (doctype.voucher_no == self.work_order))
)
return query.run(as_dict=True)
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)

View File

@@ -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"]},
],
}

View File

@@ -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_grdt",
"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",
"section_break_xt4m",
"stock_uom",
"column_break_grdt",
"available_qty",
"voucher_qty",
"column_break_o6ex",
"reserved_qty",
"delivered_qty",
"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",

View File

@@ -7,7 +7,7 @@ import frappe
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
@@ -213,6 +213,8 @@ class StockReservationEntry(Document):
def validate_reservation_based_on_serial_and_batch(self) -> None:
"""Validates `Reserved Qty`, `Serial and Batch Nos` when `Reservation Based On` is `Serial and Batch`."""
if self.voucher_type == "Work Order":
return
if self.reservation_based_on == "Serial and Batch":
allow_partial_reservation = frappe.db.get_single_value(
@@ -330,7 +332,10 @@ class StockReservationEntry(Document):
) -> 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 +398,7 @@ class StockReservationEntry(Document):
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 +438,17 @@ class StockReservationEntry(Document):
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
@@ -491,6 +505,57 @@ class StockReservationEntry(Document):
msg = _("Reserved Qty should be greater than Delivered Qty.")
frappe.throw(msg)
def consume_serial_batch_for_material_transfer(self, row_wise_serial_batch):
for entry in self.sb_entries:
if entry.reference_for_reservation:
entry.delete()
qty_to_consume = self.reserved_qty
for (item_code, warehouse, reference_for_reservation), data in row_wise_serial_batch.items():
if item_code != self.item_code or warehouse != self.warehouse:
continue
remove_serial_nos = []
for serial_no in data.serial_nos:
if qty_to_consume <= 0:
break
new_row = self.append(
"sb_entries",
{
"serial_no": serial_no,
"qty": -1,
"warehouse": self.warehouse,
"reference_for_reservation": reference_for_reservation,
},
)
new_row.insert()
qty_to_consume -= 1
remove_serial_nos.append(serial_no)
for sn in remove_serial_nos:
data.serial_nos.remove(sn)
for batch_no, batch_qty in data.batch_nos.items():
if qty_to_consume <= 0:
break
qty = batch_qty if batch_qty <= qty_to_consume else (qty_to_consume)
new_row = self.append(
"sb_entries",
{
"batch_no": batch_no,
"qty": qty * -1,
"warehouse": self.warehouse,
"reference_for_reservation": reference_for_reservation,
},
)
new_row.insert()
qty_to_consume -= qty
data.batch_nos[batch_no] -= qty
def validate_stock_reservation_settings(voucher: object) -> None:
"""Raises an exception if `Stock Reservation` is not enabled or `Voucher Type` is not allowed."""
@@ -610,7 +675,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 +778,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 +791,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 +810,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 +965,182 @@ 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.get("warehouse")
if (
not warehouse
and self.doc.doctype == "Work Order"
and (not self.doc.skip_transfer or not self.doc.from_wip_warehouse)
):
frappe.throw(
_("Source Warehouse is mandatory for the Item {0}.").format(frappe.bold(item.item_code))
)
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()
if item.get("serial_and_batch_bundles"):
sre.reservation_based_on = "Serial and Batch"
self.set_serial_batch(sre, item.serial_and_batch_bundles)
sre.submit()
def set_serial_batch(self, sre, serial_batch_bundles):
bundle_details = frappe.get_all(
"Serial and Batch Entry",
fields=["serial_no", "batch_no", "qty"],
filters={"parent": ("in", serial_batch_bundles)},
)
for detail in bundle_details:
sre.append(
"sb_entries",
{
"serial_no": detail.serial_no,
"batch_no": detail.batch_no,
"qty": abs(detail.qty),
"warehouse": sre.warehouse,
},
)
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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 @@ class StockSettings(Document):
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

View File

@@ -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"]],
},
}),
},

View File

@@ -1512,7 +1512,7 @@ class update_entries_after:
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)