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

@@ -523,6 +523,9 @@ class PurchaseOrder(BuyingController):
if self.is_against_so():
self.update_status_updater()
if self.is_against_pp():
self.update_status_updater_if_from_pp()
if self.has_drop_ship_item():
self.update_delivered_qty_in_sales_order()
@@ -1007,6 +1010,13 @@ def get_mapped_subcontracting_order(source_name, target_doc=None):
"Job Card", item.job_card, "wip_warehouse"
)
production_plan = set([item.production_plan for item in source_doc.items if item.production_plan])
if production_plan:
target_doc.production_plan = production_plan.pop()
target_doc.reserve_stock = frappe.get_single_value(
"Stock Settings", "auto_reserve_stock"
) or frappe.get_value("Production Plan", target_doc.production_plan, "reserve_stock")
if target_doc and isinstance(target_doc, str):
target_doc = json.loads(target_doc)
for key in ["service_items", "items", "supplied_items"]:

View File

@@ -830,6 +830,7 @@
"fieldname": "production_plan",
"fieldtype": "Link",
"label": "Production Plan",
"no_copy": 1,
"options": "Production Plan",
"print_hide": 1,
"read_only": 1
@@ -948,7 +949,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-12 10:57:31.552812",
"modified": "2025-10-30 16:51:56.761673",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -1645,6 +1645,128 @@ class StockController(AccountsController):
gl_entries.append(self.get_gl_dict(gl_entry, item=item))
def update_stock_reservation_entries(self):
def get_sre_list():
table = frappe.qb.DocType("Stock Reservation Entry")
query = (
frappe.qb.from_(table)
.select(table.name)
.where(
(table.docstatus == 1)
& (table.voucher_type == data_map[purpose or self.doctype]["voucher_type"])
& (
table.voucher_no
== data_map[purpose or self.doctype].get(
"voucher_no", item.get("subcontracting_order")
)
)
)
.orderby(table.creation)
)
if reference_field := data_map[purpose or self.doctype].get("voucher_detail_no_field"):
query = query.where(table.voucher_detail_no == item.get(reference_field))
else:
query = query.where(
(table.item_code == item.rm_item_code) & (table.warehouse == self.supplier_warehouse)
)
return query.run(pluck="name")
def get_data_map():
return {
"Subcontracting Delivery": {
"table_name": "items",
"voucher_type": "Subcontracting Inward Order",
"voucher_no": self.get("subcontracting_inward_order"),
"voucher_detail_no_field": "scio_detail",
"field": "delivered_qty",
},
"Send to Subcontractor": {
"table_name": "items",
"voucher_type": "Subcontracting Order",
"voucher_no": self.get("subcontracting_order"),
"voucher_detail_no_field": "sco_rm_detail",
"field": "transferred_qty",
},
"Subcontracting Receipt": {
"table_name": "supplied_items",
"voucher_type": "Subcontracting Order",
"field": "consumed_qty",
},
}
purpose = self.get("purpose")
if (
purpose == "Subcontracting Delivery"
or (
purpose == "Send to Subcontractor"
and frappe.get_value("Subcontracting Order", self.subcontracting_order, "reserve_stock")
)
or (self.doctype == "Subcontracting Receipt" and self.has_reserved_stock() and not self.is_return)
):
data_map = get_data_map()
field = data_map[purpose or self.doctype]["field"]
for item in self.get(data_map[purpose or self.doctype]["table_name"]):
sre_list = get_sre_list()
if not sre_list:
continue
qty = item.get("transfer_qty", item.get("consumed_qty"))
for sre in sre_list:
if qty <= 0:
break
sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
working_qty = 0
if sre_doc.reservation_based_on == "Serial and Batch":
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
if sre_doc.has_serial_no:
serial_nos = [d.serial_no for d in sbb.entries]
for entry in sre_doc.sb_entries:
if entry.serial_no in serial_nos:
entry.delivered_qty = 1 if self._action == "submit" else 0
entry.db_update()
working_qty += 1
serial_nos.remove(entry.serial_no)
else:
batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries}
for entry in sre_doc.sb_entries:
if entry.batch_no in batch_qty:
delivered_qty = min(
(entry.qty - entry.delivered_qty)
if self._action == "submit"
else entry.delivered_qty,
batch_qty[entry.batch_no],
)
entry.delivered_qty += (
delivered_qty if self._action == "submit" else (-1 * delivered_qty)
)
entry.db_update()
working_qty += delivered_qty
batch_qty[entry.batch_no] -= delivered_qty
else:
working_qty = min(
(sre_doc.reserved_qty - sre_doc.get(field))
if self._action == "submit"
else sre_doc.get(field),
qty,
)
sre_doc.set(
field,
sre_doc.get(field)
+ (working_qty if self._action == "submit" else (-1 * working_qty)),
)
sre_doc.db_update()
sre_doc.update_reserved_qty_in_voucher()
sre_doc.update_status()
sre_doc.update_reserved_stock_in_bin()
qty -= working_qty
@frappe.whitelist()
def show_accounting_ledger_preview(company, doctype, docname):

View File

@@ -497,11 +497,10 @@ class SubcontractingController(StockController):
if row.serial_no:
details.serial_no.extend(get_serial_nos(row.serial_no))
elif row.batch_no:
if row.batch_no:
details.batch_no[row.batch_no] += row.qty
elif voucher_bundle_data:
if not row.serial_no and not row.batch_no and voucher_bundle_data:
bundle_key = (row.rm_item_code, row.main_item_code, row.t_warehouse, row.voucher_no)
bundle_data = voucher_bundle_data.get(bundle_key, frappe._dict())

View File

@@ -556,131 +556,6 @@ class SubcontractingInwardController:
item.basic_rate + (item.additional_cost / item.transfer_qty), item.precision("basic_rate")
)
def update_sre_for_subcontracting_delivery(self) -> None:
if self.purpose == "Subcontracting Delivery":
if self._action == "submit":
self.update_sre_for_subcontracting_delivery_submit()
elif self._action == "cancel":
self.update_sre_for_subcontracting_delivery_cancel()
def update_sre_for_subcontracting_delivery_submit(self):
for item in self.get("items"):
table = frappe.qb.DocType("Stock Reservation Entry")
query = (
frappe.qb.from_(table)
.select(table.name)
.where(
(table.docstatus == 1)
& (table.voucher_type == "Subcontracting Inward Order")
& (table.voucher_no == self.subcontracting_inward_order)
& (table.voucher_detail_no == item.scio_detail)
)
.orderby(table.creation)
)
sre_list = query.run(pluck="name")
if not sre_list:
continue
qty_to_deliver = item.transfer_qty
for sre in sre_list:
if qty_to_deliver <= 0:
break
sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
qty_can_be_deliver = 0
if sre_doc.reservation_based_on == "Serial and Batch":
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
if sre_doc.has_serial_no:
delivered_serial_nos = [d.serial_no for d in sbb.entries]
for entry in sre_doc.sb_entries:
if entry.serial_no in delivered_serial_nos:
entry.delivered_qty = 1
entry.db_update()
qty_can_be_deliver += 1
delivered_serial_nos.remove(entry.serial_no)
else:
delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries}
for entry in sre_doc.sb_entries:
if entry.batch_no in delivered_batch_qty:
delivered_qty = min(
(entry.qty - entry.delivered_qty),
delivered_batch_qty[entry.batch_no],
)
entry.delivered_qty += delivered_qty
entry.db_update()
qty_can_be_deliver += delivered_qty
delivered_batch_qty[entry.batch_no] -= delivered_qty
else:
qty_can_be_deliver = min((sre_doc.reserved_qty - sre_doc.delivered_qty), qty_to_deliver)
sre_doc.delivered_qty += qty_can_be_deliver
sre_doc.db_update()
sre_doc.update_status()
sre_doc.update_reserved_stock_in_bin()
qty_to_deliver -= qty_can_be_deliver
def update_sre_for_subcontracting_delivery_cancel(self):
for item in self.get("items"):
table = frappe.qb.DocType("Stock Reservation Entry")
query = (
frappe.qb.from_(table)
.select(table.name)
.where(
(table.docstatus == 1)
& (table.voucher_type == "Subcontracting Inward Order")
& (table.voucher_no == self.subcontracting_inward_order)
& (table.voucher_detail_no == item.scio_detail)
& (table.warehouse == item.s_warehouse)
)
.orderby(table.creation)
)
sre_list = query.run(pluck="name")
if not sre_list:
continue
qty_to_undelivered = item.transfer_qty
for sre in sre_list:
if qty_to_undelivered <= 0:
break
sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
qty_can_be_undelivered = 0
if sre_doc.reservation_based_on == "Serial and Batch":
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
if sre_doc.has_serial_no:
serial_nos_to_undelivered = [d.serial_no for d in sbb.entries]
for entry in sre_doc.sb_entries:
if entry.serial_no in serial_nos_to_undelivered:
entry.delivered_qty = 0
entry.db_update()
qty_can_be_undelivered += 1
serial_nos_to_undelivered.remove(entry.serial_no)
else:
batch_qty_to_undelivered = {d.batch_no: -1 * d.qty for d in sbb.entries}
for entry in sre_doc.sb_entries:
if entry.batch_no in batch_qty_to_undelivered:
undelivered_qty = min(
entry.delivered_qty, batch_qty_to_undelivered[entry.batch_no]
)
entry.delivered_qty -= undelivered_qty
entry.db_update()
qty_can_be_undelivered += undelivered_qty
batch_qty_to_undelivered[entry.batch_no] -= undelivered_qty
else:
qty_can_be_undelivered = min(sre_doc.delivered_qty, qty_to_undelivered)
sre_doc.delivered_qty -= qty_can_be_undelivered
sre_doc.db_update()
sre_doc.update_status()
sre_doc.update_reserved_stock_in_bin()
qty_to_undelivered -= qty_can_be_undelivered
def validate_receive_from_customer_cancel(self):
if self.purpose == "Receive from Customer":
for item in self.items:

View File

@@ -1308,7 +1308,11 @@ def make_subcontracted_items():
"Subcontracted Item SA7": {},
"Subcontracted Item SA8": {},
"Subcontracted Item SA9": {"stock_uom": "Litre"},
"Subcontracted Item SA10": {},
"Subcontracted Item SA10": {
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "SBAT.####",
},
}
for item, properties in sub_contracted_items.items():

View File

@@ -8,12 +8,16 @@
"item_code",
"from_warehouse",
"warehouse",
"item_name",
"material_request_type",
"column_break_4",
"item_name",
"uom",
"conversion_factor",
"section_break_azee",
"from_bom",
"column_break_scnz",
"main_item_code",
"section_break_qnpt",
"required_bom_qty",
"projected_qty",
"column_break_wack",
@@ -25,6 +29,7 @@
"min_order_qty",
"section_break_8",
"sales_order",
"sub_assembly_item_reference",
"bin_qty_section",
"actual_qty",
"requested_qty",
@@ -220,12 +225,48 @@
"label": "Stock Reserved Qty",
"no_copy": 1,
"read_only": 1
},
{
"depends_on": "from_bom",
"fieldname": "from_bom",
"fieldtype": "Link",
"label": "From BOM",
"mandatory_depends_on": "eval:parent.reserve_stock",
"no_copy": 1,
"options": "BOM",
"read_only": 1
},
{
"fieldname": "sub_assembly_item_reference",
"fieldtype": "Data",
"hidden": 1,
"label": "Sub Assembly Item Reference",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "section_break_qnpt",
"fieldtype": "Section Break"
},
{
"depends_on": "main_item_code",
"fieldname": "main_item_code",
"fieldtype": "Link",
"label": "Main Item Code",
"mandatory_depends_on": "eval:parent.reserve_stock",
"no_copy": 1,
"options": "Item",
"read_only": 1
},
{
"fieldname": "column_break_scnz",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2025-05-01 14:50:55.805442",
"modified": "2025-10-30 17:01:25.996352",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Material Request Plan Item",

View File

@@ -17,9 +17,11 @@ class MaterialRequestPlanItem(Document):
actual_qty: DF.Float
conversion_factor: DF.Float
description: DF.TextEditor | None
from_bom: DF.Link | None
from_warehouse: DF.Link | None
item_code: DF.Link
item_name: DF.Data | None
main_item_code: DF.Link | None
material_request_type: DF.Literal[
"",
"Purchase",
@@ -43,6 +45,7 @@ class MaterialRequestPlanItem(Document):
sales_order: DF.Link | None
schedule_date: DF.Date | None
stock_reserved_qty: DF.Float
sub_assembly_item_reference: DF.Data | None
uom: DF.Link | None
warehouse: DF.Link
# end: auto-generated types

View File

@@ -568,6 +568,7 @@ class ProductionPlan(Document):
def on_submit(self):
self.update_bin_qty()
self.update_sales_order()
self.add_reference_to_raw_materials()
self.update_stock_reservation()
def on_cancel(self):
@@ -583,6 +584,24 @@ class ProductionPlan(Document):
make_stock_reservation_entries(self)
def add_reference_to_raw_materials(self):
for item in self.mr_items:
if reference := next(
(
sa_item.name
for sa_item in self.sub_assembly_items
if sa_item.production_item == item.main_item_code and sa_item.bom_no == item.from_bom
),
None,
):
item.db_set("sub_assembly_item_reference", reference)
elif self.reserve_stock and item.main_item_code and item.from_bom:
frappe.throw(
_(
"Sub assembly item references are missing. Please fetch the sub assemblies and raw materials again."
)
)
def update_sales_order(self):
sales_orders = [row.sales_order for row in self.po_items if row.sales_order]
if sales_orders:
@@ -1382,14 +1401,14 @@ def get_material_request_items(
include_safety_stock,
warehouse,
bin_dict,
total_qty,
):
total_qty = row["qty"]
required_qty = 0
if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
required_qty = total_qty
elif total_qty > bin_dict.get("projected_qty", 0):
required_qty = total_qty - bin_dict.get("projected_qty", 0)
required_qty = total_qty[row.get("item_code")]
elif total_qty[row.get("item_code")] > bin_dict.get("projected_qty", 0):
required_qty = total_qty[row.get("item_code")] - bin_dict.get("projected_qty", 0)
total_qty[row.get("item_code")] -= required_qty
if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]:
required_qty = row["min_order_qty"]
@@ -1432,7 +1451,7 @@ def get_material_request_items(
"item_name": row.item_name,
"quantity": required_qty / conversion_factor,
"conversion_factor": conversion_factor,
"required_bom_qty": total_qty,
"required_bom_qty": row.get("qty"),
"stock_uom": row.get("stock_uom"),
"warehouse": warehouse
or row.get("source_warehouse")
@@ -1448,7 +1467,8 @@ def get_material_request_items(
"sales_order": sales_order,
"description": row.get("description"),
"uom": row.get("purchase_uom") or row.get("stock_uom"),
"main_bom_item": row.get("main_bom_item"),
"main_item_code": row.get("main_bom_item"),
"from_bom": row.get("main_bom"),
}
@@ -1629,7 +1649,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
sub_assembly_items = defaultdict(int)
if doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items"):
for d in doc.get("sub_assembly_items"):
sub_assembly_items[(d.get("production_item"), d.get("bom_no"))] += d.get("qty")
sub_assembly_items[
(d.get("production_item"), d.get("bom_no"), d.get("type_of_manufacturing"))
] += d.get("qty")
sub_assembly_items = {k[:2]: v for k, v in sub_assembly_items.items()}
for data in po_items:
if not data.get("include_exploded_items") and doc.get("sub_assembly_items"):
@@ -1718,19 +1741,21 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
sales_order = data.get("sales_order")
for item_code, details in item_details.items():
for key, details in item_details.items():
so_item_details.setdefault(sales_order, frappe._dict())
if item_code in so_item_details.get(sales_order, {}):
so_item_details[sales_order][item_code]["qty"] = so_item_details[sales_order][item_code].get(
if key in so_item_details.get(sales_order, {}):
so_item_details[sales_order][key]["qty"] = so_item_details[sales_order][key].get(
"qty", 0
) + flt(details.qty)
else:
so_item_details[sales_order][item_code] = details
so_item_details[sales_order][key] = details
mr_items = []
for sales_order in so_item_details:
item_dict = so_item_details[sales_order]
total_qty = defaultdict(float)
for details in item_dict.values():
total_qty[details.item_code] += flt(details.qty)
bin_dict = get_bin_details(details, doc.company, warehouse)
bin_dict = bin_dict[0] if bin_dict else {}
@@ -1744,6 +1769,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
include_safety_stock,
warehouse,
bin_dict,
total_qty,
)
if items:
mr_items.append(items)
@@ -1998,7 +2024,7 @@ def get_raw_materials_of_sub_assembly_items(
item_default = frappe.qb.DocType("Item Default")
item_uom = frappe.qb.DocType("UOM Conversion Detail")
items = (
query = (
frappe.qb.from_(bei)
.join(bom)
.on(bom.name == bei.parent)
@@ -2024,6 +2050,7 @@ def get_raw_materials_of_sub_assembly_items(
item_uom.conversion_factor,
item.safety_stock,
bom.item.as_("main_bom_item"),
bom.name.as_("main_bom"),
)
.where(
(bei.docstatus == 1)
@@ -2032,11 +2059,13 @@ def get_raw_materials_of_sub_assembly_items(
& (item.is_stock_item.isin([0, 1]) if include_non_stock_items else item.is_stock_item == 1)
)
.groupby(bei.item_code, bei.stock_uom)
).run(as_dict=True)
)
for item in items:
for item in query.run(as_dict=True):
key = (item.item_code, item.bom_no)
if (item.bom_no and key not in sub_assembly_items) or (item.item_code in existing_sub_assembly_items):
if (item.bom_no and key not in sub_assembly_items) or (
(item.item_code, item.bom_no or item.main_bom) in existing_sub_assembly_items
):
continue
if item.bom_no:
@@ -2050,15 +2079,15 @@ def get_raw_materials_of_sub_assembly_items(
sub_assembly_items,
planned_qty=planned_qty,
)
existing_sub_assembly_items.add(item.item_code)
existing_sub_assembly_items.add((item.item_code, item.bom_no or item.main_bom))
else:
if not item.conversion_factor and item.purchase_uom:
item.conversion_factor = get_uom_conversion_factor(item.item_code, item.purchase_uom)
if details := item_details.get(item.get("item_code")):
if details := item_details.get((item.get("item_code"), item.get("main_bom"))):
details.qty += item.get("qty")
else:
item_details.setdefault(item.get("item_code"), item)
item_details.setdefault((item.get("item_code"), item.get("main_bom")), item)
return item_details

View File

@@ -1944,11 +1944,17 @@ class TestProductionPlan(IntegrationTestCase):
mr_items = get_items_for_material_requests(plan.as_dict())
from collections import defaultdict
mr_items_dict = defaultdict(float)
for item in mr_items:
mr_items_dict[item.get("item_code")] += item.get("quantity")
# RM Item 1 (FG1 (100 + 100) + FG2 (50) + FG3 (10) - 90 in stock - 80 sub assembly stock)
self.assertEqual(mr_items[0].get("quantity"), 90)
self.assertEqual(mr_items_dict["RM Item 1"], 90)
# RM Item 2 (FG1 (100) + FG2 (50) + FG4 (10) - 80 sub assembly stock)
self.assertEqual(mr_items[1].get("quantity"), 80)
self.assertEqual(mr_items_dict["RM Item 2"], 80)
def test_stock_reservation_against_production_plan(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
@@ -2364,9 +2370,6 @@ class TestProductionPlan(IntegrationTestCase):
def test_production_plan_for_partial_sub_assembly_items(self):
from erpnext.controllers.status_updater import OverAllowanceError
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.subcontracting.doctype.subcontracting_bom.test_subcontracting_bom import (
create_subcontracting_bom,
)
frappe.flags.test_print = False
@@ -2440,6 +2443,7 @@ def create_production_plan(**args):
"skip_available_sub_assembly_item": args.skip_available_sub_assembly_item or 0,
"sub_assembly_warehouse": args.sub_assembly_warehouse,
"reserve_stock": args.reserve_stock or 0,
"for_warehouse": args.for_warehouse or None,
}
)

View File

@@ -79,13 +79,14 @@
"fieldname": "received_qty",
"fieldtype": "Float",
"label": "Received Qty",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "bom_no",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Bom No",
"label": "BOM No",
"options": "BOM"
},
{
@@ -245,7 +246,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-10 13:36:24.759101",
"modified": "2025-11-03 14:33:50.677717",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan Sub Assembly Item",

View File

@@ -2502,18 +2502,11 @@ def get_auto_batch_nos(kwargs):
def get_batch_nos_from_sre(kwargs):
from frappe.query_builder.functions import Max, Min, Sum
from frappe.query_builder.functions import Sum
table = frappe.qb.DocType("Stock Reservation Entry")
child_table = frappe.qb.DocType("Serial and Batch Entry")
if kwargs.based_on == "LIFO":
creation_field = Max(child_table.creation).as_("sort_creation")
order = frappe.query_builder.Order.desc
else:
creation_field = Min(child_table.creation).as_("sort_creation")
order = frappe.query_builder.Order.asc
query = (
frappe.qb.from_(table)
.join(child_table)
@@ -2522,7 +2515,6 @@ def get_batch_nos_from_sre(kwargs):
child_table.batch_no,
child_table.warehouse,
Sum(child_table.qty - child_table.delivered_qty).as_("qty"),
creation_field,
)
.where(
(table.docstatus == 1)
@@ -2530,7 +2522,6 @@ def get_batch_nos_from_sre(kwargs):
& (child_table.qty != child_table.delivered_qty)
)
.groupby(child_table.batch_no, child_table.warehouse)
.orderby("sort_creation", order=order)
.orderby(child_table.batch_no, order=frappe.query_builder.Order.asc)
)

View File

@@ -202,6 +202,12 @@ class StockEntry(StockController, SubcontractingInwardController):
for item in self.get("items"):
item.update(get_bin_details(item.item_code, item.s_warehouse))
def before_insert(self):
if self.subcontracting_order and frappe.get_cached_value(
"Subcontracting Order", self.subcontracting_order, "reserve_stock"
):
self.set_serial_batch_from_reserved_entry()
def before_validate(self):
from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule
@@ -274,9 +280,10 @@ class StockEntry(StockController, SubcontractingInwardController):
self.update_work_order()
self.update_disassembled_order()
self.adjust_stock_reservation_entries_for_return()
self.update_sre_for_subcontracting_delivery()
self.update_stock_reservation_entries()
self.update_stock_ledger()
self.make_stock_reserve_for_wip_and_fg()
self.reserve_stock_for_subcontracting()
self.update_subcontract_order_supplied_items()
self.update_subcontracting_order_status()
@@ -324,7 +331,7 @@ class StockEntry(StockController, SubcontractingInwardController):
self.update_transferred_qty()
self.update_quality_inspection()
self.adjust_stock_reservation_entries_for_return()
self.update_sre_for_subcontracting_delivery()
self.update_stock_reservation_entries()
self.delete_auto_created_batches()
self.delete_linked_stock_entry()
@@ -1889,6 +1896,30 @@ class StockEntry(StockController, SubcontractingInwardController):
pro_doc.set_reserved_qty_for_wip_and_fg(self)
def reserve_stock_for_subcontracting(self):
if self.purpose == "Send to Subcontractor" and frappe.get_value(
"Subcontracting Order", self.subcontracting_order, "reserve_stock"
):
items = {}
for item in self.items:
if item.sco_rm_detail in items:
items[item.sco_rm_detail].qty_to_reserve += item.transfer_qty
items[item.sco_rm_detail].serial_and_batch_bundles.append(item.serial_and_batch_bundle)
else:
items[item.sco_rm_detail] = frappe._dict(
{
"name": item.sco_rm_detail,
"qty_to_reserve": item.transfer_qty,
"warehouse": item.t_warehouse,
"reference_voucher_detail_no": item.name,
"serial_and_batch_bundles": [item.serial_and_batch_bundle],
}
)
frappe.get_doc("Subcontracting Order", self.subcontracting_order).reserve_raw_materials(
items=items.values(), stock_entry=self.name
)
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)
@@ -2230,21 +2261,16 @@ class StockEntry(StockController, SubcontractingInwardController):
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.work_order and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock"):
skip_transfer = frappe.get_cached_value("Work Order", self.work_order, "skip_transfer")
if not frappe.get_cached_value("Work Order", self.work_order, "reserve_stock"):
return
skip_transfer = frappe.get_cached_value("Work Order", self.work_order, "skip_transfer")
if (
self.purpose not in ["Material Transfer for Manufacture"]
and frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on")
!= "BOM"
and not skip_transfer
):
return
if (
self.purpose not in ["Material Transfer for Manufacture"]
and frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on")
!= "BOM"
and not skip_transfer
):
return
reservation_entries = self.get_available_reserved_materials()
if not reservation_entries:
@@ -2252,6 +2278,9 @@ class StockEntry(StockController, SubcontractingInwardController):
new_items_to_add = []
for d in self.items:
if d.serial_and_batch_bundle or d.serial_no or d.batch_no:
continue
key = (d.item_code, d.s_warehouse)
if details := reservation_entries.get(key):
original_qty = d.qty
@@ -2363,7 +2392,7 @@ class StockEntry(StockController, SubcontractingInwardController):
)
.where(
(doctype.docstatus == 1)
& (doctype.voucher_no == self.work_order)
& (doctype.voucher_no == (self.work_order or self.subcontracting_order))
& (serial_batch_doc.delivered_qty < serial_batch_doc.qty)
)
.orderby(serial_batch_doc.idx)

View File

@@ -84,7 +84,7 @@
"no_copy": 1,
"oldfieldname": "voucher_type",
"oldfieldtype": "Data",
"options": "\nSales Order\nWork Order\nSubcontracting Inward Order\nProduction Plan",
"options": "\nSales Order\nWork Order\nSubcontracting Inward Order\nProduction Plan\nSubcontracting Order",
"print_width": "150px",
"read_only": 1,
"width": "150px"
@@ -315,8 +315,7 @@
},
{
"fieldname": "production_section",
"fieldtype": "Section Break",
"label": "Production"
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_qdwj",
@@ -335,7 +334,7 @@
{
"fieldname": "transferred_qty",
"fieldtype": "Float",
"label": "Qty in WIP Warehouse"
"label": "Transferred Qty"
}
],
"grid_page_length": 50,
@@ -344,7 +343,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-10-12 19:48:33.170835",
"modified": "2025-11-10 16:09:10.380024",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reservation Entry",

View File

@@ -64,7 +64,12 @@ class StockReservationEntry(Document):
voucher_no: DF.DynamicLink | None
voucher_qty: DF.Float
voucher_type: DF.Literal[
"", "Sales Order", "Work Order", "Subcontracting Inward Order", "Production Plan"
"",
"Sales Order",
"Work Order",
"Subcontracting Inward Order",
"Production Plan",
"Subcontracting Order",
]
warehouse: DF.Link | None
# end: auto-generated types
@@ -338,7 +343,7 @@ 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":
if self.voucher_type in ["Work Order", "Subcontracting Order"]:
return
if self.reservation_based_on == "Serial and Batch":
@@ -460,13 +465,14 @@ class StockReservationEntry(Document):
"Sales Order": "Sales Order Item",
"Work Order": "Work Order Item",
"Production Plan": "Production Plan Sub Assembly Item",
"Subcontracting Order": "Subcontracting Order Supplied Item",
}.get(self.voucher_type, None)
if item_doctype:
sre = frappe.qb.DocType("Stock Reservation Entry")
reserved_qty = (
frappe.qb.from_(sre)
.select(Sum(sre.reserved_qty))
.select(Sum(sre.reserved_qty - sre.delivered_qty - sre.transferred_qty - sre.consumed_qty))
.where(
(sre.docstatus == 1)
& (sre.voucher_type == self.voucher_type)
@@ -574,7 +580,7 @@ class StockReservationEntry(Document):
)
from_voucher_detail_no = None
if self.from_voucher_type and self.from_voucher_type == "Stock Entry":
if self.from_voucher_type and self.from_voucher_type in ["Stock Entry", "Production Plan"]:
from_voucher_detail_no = self.from_voucher_detail_no
total_reserved_qty = get_sre_reserved_qty_for_voucher_detail_no(
@@ -1276,7 +1282,7 @@ class StockReservation:
if not reservation_entries:
return
entries_to_reserve = frappe._dict({})
entries_to_reserve = frappe._dict()
for row in reservation_entries:
reserved_qty_field = "reserved_qty" if row.reservation_based_on == "Qty" else "sabb_qty"
delivered_qty_field = (
@@ -1293,7 +1299,7 @@ class StockReservation:
if available_qty <= 0:
continue
key = (row.item_code, row.warehouse)
key = (row.item_code, row.warehouse, entry.voucher_detail_no)
if key not in entries_to_reserve:
entries_to_reserve.setdefault(
@@ -1303,7 +1309,7 @@ class StockReservation:
"qty_to_reserve": 0.0,
"item_code": row.item_code,
"warehouse": row.warehouse,
"voucher_type": entry.voucher_type,
"voucher_type": entry.voucher_type or to_doctype,
"voucher_no": entry.voucher_no,
"voucher_detail_no": entry.voucher_detail_no,
"serial_nos": [],
@@ -1475,6 +1481,9 @@ class StockReservation:
.orderby(sabb_entry.idx)
)
if self.items and (data := [item.from_voucher_detail_no for item in self.items]):
query = query.where(sre.voucher_detail_no.isin(data))
if against_fg_item:
query = query.where(
sre.voucher_detail_no.isin(
@@ -1490,9 +1499,14 @@ class StockReservation:
def get_items_to_reserve(self, docnames, from_doctype, to_doctype):
field = frappe.scrub(from_doctype)
item_code_fieldname, child_table_suffix = (
("rm_item_code", " Supplied Item")
if to_doctype == "Subcontracting Order"
else ("item_code", " Item")
)
doctype = frappe.qb.DocType(to_doctype)
child_doctype = frappe.qb.DocType(to_doctype + " Item")
child_doctype = frappe.qb.DocType(to_doctype + child_table_suffix)
query = (
frappe.qb.from_(doctype)
@@ -1501,11 +1515,12 @@ class StockReservation:
.select(
doctype.name.as_("voucher_no"),
child_doctype.name.as_("voucher_detail_no"),
child_doctype.item_code,
child_doctype[item_code_fieldname].as_("item_code"),
doctype.company,
child_doctype.stock_uom,
)
.where((doctype.docstatus == 1) & (doctype[field].isin(docnames)))
.groupby(child_doctype.name)
)
if to_doctype == "Work Order":
@@ -1523,6 +1538,15 @@ class StockReservation:
(doctype.qty > doctype.material_transferred_for_manufacturing)
& (doctype.status != "Completed")
)
elif to_doctype == "Subcontracting Order":
query = query.select(
child_doctype.stock_reserved_qty,
child_doctype.required_qty.as_("qty"),
child_doctype.reserve_warehouse.as_("source_warehouse"),
)
if self.items and (data := [item.voucher_detail_no for item in self.items]):
query = query.where(child_doctype.name.isin(data))
data = query.run(as_dict=True)
items = []

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):