Merge pull request #50235 from mihir-kandoi/sre-sco

feat: stock reservation for subcontracting order
This commit is contained in:
Mihir Kandoi
2025-11-16 13:06:21 +05:30
committed by GitHub
parent 4bd3b00e5f
commit 9b303a2272
25 changed files with 895 additions and 209 deletions

View File

@@ -172,11 +172,279 @@ frappe.ui.form.on("Subcontracting Order", {
__("Status")
);
}
if (frm.doc.reserve_stock) {
if (frm.doc.status !== "Closed") {
if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) {
frm.add_custom_button(
__("Reserve"),
() => frm.events.create_stock_reservation_entries(frm),
__("Stock Reservation")
);
}
}
if (
frm.doc.__onload &&
frm.doc.__onload.has_reserved_stock &&
frappe.model.can_cancel("Stock Reservation Entry")
) {
frm.add_custom_button(
__("Unreserve"),
() => frm.events.cancel_stock_reservation_entries(frm),
__("Stock Reservation")
);
}
frm.doc.supplied_items.forEach((item) => {
if (
flt(item.stock_reserved_qty) > 0 &&
frappe.model.can_read("Stock Reservation Entry")
) {
frm.add_custom_button(
__("Reserved Stock"),
() => frm.events.show_reserved_stock(frm),
__("Stock Reservation")
);
return;
}
});
}
}
frm.trigger("get_materials_from_supplier");
},
create_stock_reservation_entries(frm) {
const dialog = new frappe.ui.Dialog({
title: __("Stock Reservation"),
size: "extra-large",
fields: [
{
fieldname: "items",
fieldtype: "Table",
label: __("Items to Reserve"),
allow_bulk_edit: false,
cannot_add_rows: true,
cannot_delete_rows: true,
data: [],
fields: [
{
fieldname: "subcontracting_order_supplied_item",
fieldtype: "Link",
label: __("Subcontracting Order Supplied Item"),
options: "Subcontracting Order Supplied Item",
reqd: 1,
in_list_view: 1,
read_only: 1,
get_query: () => {
return {
query: "erpnext.controllers.queries.get_filtered_child_rows",
filters: {
parenttype: frm.doc.doctype,
parent: frm.doc.name,
},
};
},
},
{
fieldname: "rm_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,
read_only: 1,
},
{
fieldname: "qty_to_reserve",
fieldtype: "Float",
label: __("Qty"),
reqd: 1,
in_list_view: 1,
},
],
},
],
primary_action_label: __("Reserve Stock"),
primary_action: () => {
var data = { items: dialog.fields_dict.items.grid.get_selected_children() };
if (data.items && data.items.length > 0) {
frappe.call({
doc: frm.doc,
method: "reserve_raw_materials",
args: {
items: data.items.map((item) => ({
name: item.subcontracting_order_supplied_item,
qty_to_reserve: item.qty_to_reserve,
})),
},
freeze: true,
freeze_message: __("Reserving Stock..."),
callback: (_) => {
frm.reload_doc();
},
});
dialog.hide();
} else {
frappe.msgprint(__("Please select items to reserve."));
}
},
});
frm.doc.supplied_items.forEach((item) => {
let unreserved_qty =
flt(item.required_qty) - flt(item.supplied_qty) - flt(item.stock_reserved_qty);
if (unreserved_qty > 0) {
dialog.fields_dict.items.df.data.push({
__checked: 1,
subcontracting_order_supplied_item: item.name,
rm_item_code: item.rm_item_code,
warehouse: item.reserve_warehouse,
qty_to_reserve: unreserved_qty,
});
}
});
dialog.fields_dict.items.grid.refresh();
dialog.show();
},
cancel_stock_reservation_entries(frm) {
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: [
{
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,
},
],
},
],
primary_action_label: __("Unreserve Stock"),
primary_action: () => {
var data = { sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children() };
if (data.sr_entries && data.sr_entries.length > 0) {
frappe.call({
doc: frm.doc,
method: "cancel_stock_reservation_entries",
args: {
sre_list: data.sr_entries.map((item) => item.sre),
},
freeze: true,
freeze_message: __("Unreserving Stock..."),
callback: (_) => {
frm.doc.__onload.has_reserved_stock = false;
frm.reload_doc();
},
});
dialog.hide();
} else {
frappe.msgprint(__("Please select items to unreserve."));
}
},
});
frappe
.call({
method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.get_stock_reservation_entries_for_voucher",
args: {
voucher_type: frm.doctype,
voucher_no: frm.doc.name,
},
callback: (r) => {
if (!r.exc && r.message) {
r.message.forEach((sre) => {
if (flt(sre.reserved_qty) > flt(sre.delivered_qty)) {
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),
});
}
});
}
},
})
.then((r) => {
dialog.fields_dict.sr_entries.grid.refresh();
dialog.show();
});
},
show_reserved_stock(frm) {
// Get the latest modified date from the items table.
var to_date = moment(new Date(Math.max(...frm.doc.items.map((e) => new Date(e.modified))))).format(
"YYYY-MM-DD"
);
frappe.route_options = {
company: frm.doc.company,
from_date: frm.doc.transaction_date,
to_date: to_date,
voucher_type: frm.doc.doctype,
voucher_no: frm.doc.name,
};
frappe.set_route("query-report", "Reserved Stock");
},
update_subcontracting_order_status(frm, status) {
frappe.call({
method: "erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order.update_subcontracting_order_status",

View File

@@ -36,6 +36,7 @@
"service_items",
"raw_materials_supplied_section",
"set_reserve_warehouse",
"reserve_stock",
"supplied_items",
"tab_address_and_contact",
"supplier_address",
@@ -62,7 +63,8 @@
"select_print_heading",
"column_break_43",
"letter_head",
"tab_connections"
"tab_connections",
"production_plan"
],
"fields": [
{
@@ -471,6 +473,22 @@
"no_copy": 1,
"options": "Currency",
"read_only": 1
},
{
"default": "0",
"fieldname": "reserve_stock",
"fieldtype": "Check",
"label": "Reserve Stock",
"no_copy": 1,
"show_on_timeline": 1
},
{
"fieldname": "production_plan",
"fieldtype": "Data",
"hidden": 1,
"label": "Production Plan",
"no_copy": 1,
"read_only": 1
}
],
"icon": "fa fa-file-text",

View File

@@ -8,6 +8,10 @@ from frappe.utils import flt
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
StockReservation,
has_reserved_stock,
)
from erpnext.stock.stock_balance import update_bin_qty
from erpnext.stock.utils import get_bin
@@ -50,8 +54,10 @@ class SubcontractingOrder(SubcontractingController):
letter_head: DF.Link | None
naming_series: DF.Literal["SC-ORD-.YYYY.-"]
per_received: DF.Percent
production_plan: DF.Data | None
project: DF.Link | None
purchase_order: DF.Link
reserve_stock: DF.Check
schedule_date: DF.Date | None
select_print_heading: DF.Link | None
service_items: DF.Table[SubcontractingOrderServiceItem]
@@ -105,6 +111,13 @@ class SubcontractingOrder(SubcontractingController):
frappe.db.get_single_value("Buying Settings", "over_transfer_allowance"),
)
if self.reserve_stock:
if self.has_unreserved_stock():
self.set_onload("has_unreserved_stock", True)
if has_reserved_stock(self.doctype, self.name):
self.set_onload("has_reserved_stock", True)
def before_validate(self):
super().before_validate()
@@ -121,6 +134,7 @@ class SubcontractingOrder(SubcontractingController):
self.update_prevdoc_status()
self.update_status()
self.update_subcontracted_quantity_in_po()
self.reserve_raw_materials()
def on_cancel(self):
self.update_prevdoc_status()
@@ -253,10 +267,10 @@ class SubcontractingOrder(SubcontractingController):
if si.fg_item:
item = frappe.get_doc("Item", si.fg_item)
qty, subcontracted_qty, fg_item_qty = frappe.db.get_value(
qty, subcontracted_qty, fg_item_qty, production_plan_sub_assembly_item = frappe.db.get_value(
"Purchase Order Item",
si.purchase_order_item,
["qty", "subcontracted_qty", "fg_item_qty"],
["qty", "subcontracted_qty", "fg_item_qty", "production_plan_sub_assembly_item"],
)
available_qty = flt(qty) - flt(subcontracted_qty)
@@ -292,6 +306,7 @@ class SubcontractingOrder(SubcontractingController):
"purchase_order_item": si.purchase_order_item,
"material_request": si.material_request,
"material_request_item": si.material_request_item,
"production_plan_sub_assembly_item": production_plan_sub_assembly_item,
}
)
else:
@@ -362,6 +377,90 @@ class SubcontractingOrder(SubcontractingController):
subcontracted_qty,
)
@frappe.whitelist()
def reserve_raw_materials(self, items=None, stock_entry=None):
if self.reserve_stock:
item_dict = {}
if items:
item_dict = {d["name"]: d for d in items}
items = [item for item in self.supplied_items if item.name in item_dict]
reservation_items = []
is_transfer = False
for item in items or self.supplied_items:
data = frappe._dict(
{
"voucher_no": self.name,
"voucher_type": self.doctype,
"voucher_detail_no": item.name,
"item_code": item.rm_item_code,
"warehouse": item_dict.get(item.name, {}).get("warehouse", item.reserve_warehouse),
"stock_qty": item_dict.get(item.name, {}).get("qty_to_reserve", item.required_qty),
}
)
if stock_entry:
data.update(
{
"from_voucher_no": stock_entry,
"from_voucher_type": "Stock Entry",
"from_voucher_detail_no": item_dict[item.name]["reference_voucher_detail_no"],
"serial_and_batch_bundles": item_dict[item.name]["serial_and_batch_bundles"],
}
)
elif self.production_plan:
fg_item = next(i for i in self.items if i.name == item.reference_name)
if production_plan_sub_assembly_item := fg_item.production_plan_sub_assembly_item:
from_voucher_detail_no, reserved_qty = frappe.get_value(
"Material Request Plan Item",
{
"parent": self.production_plan,
"item_code": item.rm_item_code,
"warehouse": item.reserve_warehouse,
"sub_assembly_item_reference": production_plan_sub_assembly_item,
"docstatus": 1,
},
["name", "stock_reserved_qty"],
)
if flt(item.stock_reserved_qty) < reserved_qty:
is_transfer = True
data.update(
{
"from_voucher_no": self.production_plan,
"from_voucher_type": "Production Plan",
"from_voucher_detail_no": from_voucher_detail_no,
}
)
reservation_items.append(data)
sre = StockReservation(self, items=reservation_items, notify=True)
if is_transfer:
sre.transfer_reservation_entries_to(
self.production_plan, from_doctype="Production Plan", to_doctype="Subcontracting Order"
)
else:
if sre.make_stock_reservation_entries():
frappe.msgprint(_("Stock Reservation Entries created"), alert=True, indicator="blue")
def has_unreserved_stock(self) -> bool:
for item in self.get("supplied_items"):
if item.required_qty - flt(item.supplied_qty) - flt(item.stock_reserved_qty) > 0:
return True
return False
@frappe.whitelist()
def cancel_stock_reservation_entries(self, sre_list=None, notify=True) -> None:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
cancel_stock_reservation_entries,
)
cancel_stock_reservation_entries(
voucher_type=self.doctype, voucher_no=self.name, sre_list=sre_list, notify=notify
)
@frappe.whitelist()
def make_subcontracting_receipt(source_name, target_doc=None):

View File

@@ -4,5 +4,15 @@ from frappe import _
def get_data():
return {
"fieldname": "subcontracting_order",
"transactions": [{"label": _("Reference"), "items": ["Subcontracting Receipt", "Stock Entry"]}],
"non_standard_fieldnames": {"Stock Reservation Entry": "voucher_no"},
"transactions": [
{
"label": _("Reference"),
"items": ["Subcontracting Receipt", "Stock Entry"],
},
{
"label": _("Stock Reservation"),
"items": ["Stock Reservation Entry"],
},
],
}

View File

@@ -700,6 +700,126 @@ class TestSubcontractingOrder(IntegrationTestCase):
self.assertEqual(sco.supplied_items[0].required_qty, 210.149)
def test_stock_reservation(self):
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_details_for_voucher,
)
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 4",
"qty": 10,
"rate": 100,
"fg_item": "Subcontracted Item SA4",
"fg_item_qty": 10,
}
]
sco = get_subcontracting_order(service_items=service_items, do_not_submit=1)
sco.reserve_stock = 1
rm_items = get_rm_items(sco.supplied_items)
make_stock_in_entry(rm_items=rm_items)
sco.submit()
sre_list = get_sre_details_for_voucher("Subcontracting Order", sco.name)
self.assertTrue(len(sre_list) > 0)
se_dict = make_rm_stock_entry(sco.name)
se = frappe.get_doc(se_dict)
se.items[-1].use_serial_batch_fields = 1
se.save()
se.submit()
sco.reload()
for sre in sre_list:
self.assertEqual(frappe.get_value("Stock Reservation Entry", sre.name, "status"), "Closed")
make_subcontracting_receipt(sco.name).submit()
for status in frappe.get_all(
"Stock Reservation Entry", filters={"voucher_no": sco.name, "docstatus": 1}, pluck="status"
)[:3]:
self.assertEqual(status, "Delivered")
def test_stock_reservation_transfer(self):
from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_items_for_material_requests,
)
from erpnext.manufacturing.doctype.production_plan.test_production_plan import create_production_plan
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_serial_batch_entries_for_voucher,
get_sre_details_for_voucher,
)
parent_fg = make_item()
make_bom(
item=parent_fg.name, raw_materials=["Subcontracted Item SA10"], rate=100, rm_qty=1, currency="INR"
)
plan = create_production_plan(
item_code=parent_fg.name,
planned_qty=10,
do_not_submit=True,
reserve_stock=True,
skip_available_sub_assembly_item=True,
for_warehouse="_Test Warehouse - _TC",
sub_assembly_warehouse="_Test Warehouse - _TC",
skip_getting_mr_items=True,
)
plan.get_sub_assembly_items()
plan.sub_assembly_items[0].supplier = "_Test Supplier"
mr_items = get_items_for_material_requests(plan.as_dict())
for d in mr_items:
plan.append("mr_items", d)
make_stock_entry(
target="_Test Warehouse - _TC", item_code="Subcontracted SRM Item 1", qty=10, basic_rate=100
)
make_stock_entry(
target="_Test Warehouse - _TC", item_code="Subcontracted SRM Item 2", qty=10, basic_rate=100
)
make_stock_entry(
target="_Test Warehouse - _TC", item_code="Subcontracted SRM Item 3", qty=10, basic_rate=100
)
plan.submit()
sre_against_plan = get_sre_details_for_voucher("Production Plan", plan.name)
sbe_pp_list = []
for sre in sre_against_plan:
sbe_pp_list.append(
sorted(
get_serial_batch_entries_for_voucher(sre.name),
key=lambda x: x.get("serial_no") or x.get("batch_no") or "",
)
)
plan.make_work_order()
po = frappe.get_doc(
"Purchase Order",
frappe.get_value("Purchase Order Item", {"production_plan": plan.name}, "parent"),
)
po.items[0].item_code = "Subcontracted Service Item 4"
po.items[0].qty = 10
po.submit()
so = create_subcontracting_order(po_name=po.name, do_not_save=1)
so.supplier_warehouse = "_Test Warehouse 1 - _TC"
so.reserve_stock = True
so.submit()
so.reload()
sre_against_so = get_sre_details_for_voucher("Subcontracting Order", so.name)
sbe_so_list = []
for sre in sre_against_so:
sbe_so_list.append(
sorted(
get_serial_batch_entries_for_voucher(sre.name),
key=lambda x: x.get("serial_no") or x.get("batch_no") or "",
)
)
self.assertEqual(sbe_pp_list, sbe_so_list)
def create_subcontracting_order(**args):
args = frappe._dict(args)

View File

@@ -55,7 +55,8 @@
"section_break_34",
"purchase_order_item",
"page_break",
"subcontracting_conversion_factor"
"subcontracting_conversion_factor",
"production_plan_sub_assembly_item"
],
"fields": [
{
@@ -407,6 +408,16 @@
"hidden": 1,
"label": "Subcontracting Conversion Factor",
"read_only": 1
},
{
"fieldname": "production_plan_sub_assembly_item",
"fieldtype": "Data",
"hidden": 1,
"label": "Production Plan Sub Assembly Item",
"no_copy": 1,
"print_hide": 1,
"read_only": 1,
"report_hide": 1
}
],
"grid_page_length": 50,
@@ -414,7 +425,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-08-10 22:37:39.863628",
"modified": "2025-11-03 12:29:45.156101",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Order Item",

View File

@@ -35,6 +35,7 @@ class SubcontractingOrderItem(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
production_plan_sub_assembly_item: DF.Data | None
project: DF.Link | None
purchase_order_item: DF.Data | None
qty: DF.Float

View File

@@ -21,6 +21,7 @@
"section_break_13",
"required_qty",
"supplied_qty",
"stock_reserved_qty",
"column_break_16",
"consumed_qty",
"returned_qty",
@@ -52,7 +53,7 @@
{
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock Uom",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
@@ -160,18 +161,29 @@
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:parent.reserve_stock",
"fieldname": "stock_reserved_qty",
"fieldtype": "Float",
"label": "Reserved Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
}
],
"hide_toolbar": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:46.680164",
"modified": "2025-10-30 16:00:43.379828",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Order Supplied Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -28,6 +28,7 @@ class SubcontractingOrderSuppliedItem(Document):
reserve_warehouse: DF.Link | None
returned_qty: DF.Float
rm_item_code: DF.Link | None
stock_reserved_qty: DF.Float
stock_uom: DF.Link | None
supplied_qty: DF.Float
total_supplied_qty: DF.Float

View File

@@ -164,6 +164,8 @@ class SubcontractingReceipt(SubcontractingController):
for table_name in ["items", "supplied_items"]:
self.make_bundle_using_old_serial_batch_fields(table_name)
self.update_stock_reservation_entries()
self.update_stock_ledger()
self.make_gl_entries()
self.repost_future_sle_and_gle()
@@ -189,6 +191,7 @@ class SubcontractingReceipt(SubcontractingController):
self.set_consumed_qty_in_subcontract_order()
self.set_subcontracting_order_status(update_bin=False)
self.update_stock_ledger()
self.update_stock_reservation_entries()
self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle()
self.update_status()
@@ -199,7 +202,7 @@ class SubcontractingReceipt(SubcontractingController):
def reset_raw_materials(self):
self.supplied_items = []
self.flags.reset_raw_materials = True
self.create_raw_materials_supplied()
self.create_raw_materials_supplied_or_received()
def validate_closed_subcontracting_order(self):
for item in self.items:
@@ -853,6 +856,17 @@ class SubcontractingReceipt(SubcontractingController):
if frappe.db.get_single_value("Buying Settings", "auto_create_purchase_receipt"):
make_purchase_receipt(self, save=True, notify=True)
def has_reserved_stock(self):
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_details_for_voucher,
)
for item in self.supplied_items:
if get_sre_details_for_voucher("Subcontracting Order", item.subcontracting_order):
return True
return False
@frappe.whitelist()
def make_subcontract_return_against_rejected_warehouse(source_name):