feat: batch split operation to produce child batches per piece (#58530)

* feat: batch split operation to produce child batches per piece

* fix: single input validation, weight conserving lineage, cancel cleanup and naming race for batch split

* fix: delete cancelled batch split bundle along with unused child batches

* fix: retain all child batches when any sibling of a split bundle is in use

* fix: run batch split cancel cleanup only for batch split entries

* fix: make batch split flag read only on stock entry type

* fix: restrict cancel cleanup to child batches minted by the cancelled entry

* refactor: name child batches from item batch series and retain them on cancel

* feat: batch split tree report for parent to child batch traceability

* refactor: source each piece wholly from a single parent batch

* fix: weight per piece sizes the child batches instead of scaling raw material consumption

* fix: apportion child batch lineage proportionally to parent batch quantities

* fix: cap child batch lineage at the whole piece capacity of each parent batch

* fix: exclude batches of cancelled split entries from the batch split tree
This commit is contained in:
rohitwaghchaure
2026-08-28 17:11:02 +05:30
committed by GitHub
parent ca49de633f
commit 0223223385
27 changed files with 926 additions and 12 deletions

View File

@@ -1116,13 +1116,6 @@ frappe.ui.form.on("BOM", {
doc.qty = 1.0;
this.grid.set_value("qty", 1.0, doc);
},
get_query() {
return {
filters: {
name: ["!=", row.finished_good],
},
};
},
},
{
label: __("Qty"),

View File

@@ -339,6 +339,7 @@ class BOM(WebsiteGenerator):
self.validate_uoms()
self.set_default_uom()
self.validate_semi_finished_goods()
self.validate_batch_split_operations()
self.validate_secondary_items()
self.set_fg_cost_allocation()
self.validate_total_cost_allocation()
@@ -395,6 +396,36 @@ class BOM(WebsiteGenerator):
),
)
def validate_batch_split_operations(self):
for row in self.operations:
if not row.get("batch_split"):
continue
if not self.track_semi_finished_goods:
frappe.throw(
_(
"Row #{0}: Batch Split is only supported when 'Track Semi Finished Goods' is enabled."
).format(row.idx)
)
if flt(row.weight_per_piece) <= 0:
frappe.throw(
_("Row #{0}: Weight Per Piece is required for the Batch Split operation {1}.").format(
row.idx, bold(row.operation)
)
)
if row.finished_good:
item_details = frappe.get_cached_value(
"Item", row.finished_good, ["has_batch_no", "create_new_batch"], as_dict=1
)
if not item_details.has_batch_no or not item_details.create_new_batch:
frappe.throw(
_(
"Row #{0}: The item {1} must have 'Has Batch No' and 'Automatically Create New Batch' enabled as the operation {2} is marked as Batch Split."
).format(row.idx, bold(row.finished_good), bold(row.operation))
)
def validate_secondary_items(self):
for item in self.secondary_items:
if not item.is_legacy and item.item_code == self.item:

View File

@@ -22,6 +22,8 @@
"is_final_finished_good",
"set_cost_based_on_bom_qty",
"quality_inspection_required",
"batch_split",
"weight_per_piece",
"warehouse_section",
"skip_material_transfer",
"backflush_from_wip_warehouse",
@@ -302,13 +304,30 @@
"fieldname": "quality_inspection_required",
"fieldtype": "Check",
"label": "Quality Inspection Required"
},
{
"default": "0",
"depends_on": "eval:parent.track_semi_finished_goods === 1",
"description": "On completion of the Job Card, split the consumed batch into one child batch per finished piece",
"fieldname": "batch_split",
"fieldtype": "Check",
"label": "Batch Split"
},
{
"depends_on": "eval:doc.batch_split",
"description": "Produced quantity is split into one batch per this many units of the finished good",
"fieldname": "weight_per_piece",
"fieldtype": "Float",
"label": "Weight Per Piece",
"mandatory_depends_on": "eval:doc.batch_split",
"non_negative": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-08-08 12:00:00.000000",
"modified": "2026-08-28 18:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",

View File

@@ -19,6 +19,7 @@ class BOMOperation(Document):
base_hour_rate: DF.Currency
base_operating_cost: DF.Currency
batch_size: DF.Float
batch_split: DF.Check
bom_no: DF.Link | None
cost_per_unit: DF.Float
description: DF.TextEditor | None
@@ -41,6 +42,7 @@ class BOMOperation(Document):
skip_material_transfer: DF.Check
source_warehouse: DF.Link | None
time_in_mins: DF.Float
weight_per_piece: DF.Float
wip_warehouse: DF.Link | None
workstation: DF.Link | None
workstation_type: DF.Link | None

View File

@@ -27,6 +27,8 @@
"finished_good",
"column_break_mcnb",
"semi_fg_bom",
"batch_split",
"weight_per_piece",
"section_break_folk",
"pending_qty",
"column_break_cyjw",
@@ -551,6 +553,20 @@
"options": "BOM",
"read_only": 1
},
{
"default": "0",
"fieldname": "batch_split",
"fieldtype": "Check",
"label": "Batch Split",
"read_only": 1
},
{
"depends_on": "eval:doc.batch_split",
"fieldname": "weight_per_piece",
"fieldtype": "Float",
"label": "Weight Per Piece",
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:!doc.is_corrective_job_card",
@@ -700,7 +716,7 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2026-08-12 15:28:19.126628",
"modified": "2026-08-28 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -85,6 +85,7 @@ class JobCard(Document):
amended_from: DF.Link | None
backflush_from_wip_warehouse: DF.Check
barcode: DF.Barcode | None
batch_split: DF.Check
batch_no: DF.Link | None
bom_no: DF.Link | None
company: DF.Link
@@ -141,6 +142,7 @@ class JobCard(Document):
time_required: DF.Float
total_completed_qty: DF.Float
total_time_in_mins: DF.Float
weight_per_piece: DF.Float
track_semi_finished_goods: DF.Check
transferred_qty: DF.Float
wip_warehouse: DF.Link | None

View File

@@ -1796,6 +1796,133 @@ class TestJobCard(ERPNextTestSuite):
8,
)
def test_batch_split_operation_creates_child_batches(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
)
original_value = frappe.db.get_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
)
frappe.db.set_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward", 1)
self.addCleanup(
frappe.db.set_single_value,
"Stock Settings",
"auto_create_serial_and_batch_bundle_for_outward",
original_value,
)
warehouse = "Stores - _TC"
rm = make_item(
"Batch Split Rod KG",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BS-ROD-KG-.####",
},
).name
fg = make_item(
"Batch Split Rod PC",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BS-ROD-PC-.####",
},
).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
operation = {
"operation": "Batch Split Op A",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
"batch_split": 1,
"weight_per_piece": 10,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=50,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.save()
work_order.submit()
self.assertEqual(work_order.operations[0].batch_split, 1)
self.assertEqual(flt(work_order.operations[0].weight_per_piece), 10.0)
source_entry = make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
parent_batch = get_batch_from_bundle(source_entry.items[0].serial_and_batch_bundle)
job_card = frappe.get_doc(
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
)
self.assertEqual(job_card.batch_split, 1)
job_card.append(
"time_logs",
{"from_time": "2024-03-01 08:00:00", "to_time": "2024-03-01 09:00:00", "completed_qty": 50},
)
job_card.save()
job_card.submit()
manufacture_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
manufacture_entry.submit()
rm_row = next(row for row in manufacture_entry.items if row.item_code == rm)
self.assertEqual(flt(rm_row.transfer_qty), 50.0)
fg_row = next(row for row in manufacture_entry.items if row.item_code == fg)
self.assertEqual(flt(fg_row.transfer_qty), 50.0)
entries = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": fg_row.serial_and_batch_bundle},
fields=["batch_no", "qty"],
)
self.assertEqual(len(entries), 5)
for entry in entries:
self.assertEqual(flt(entry.qty), 10.0)
self.assertTrue(entry.batch_no.startswith("BS-ROD-PC-"))
self.assertEqual(frappe.db.get_value("Batch", entry.batch_no, "parent_batch"), parent_batch)
manufacture_entry.reload()
manufacture_entry.cancel()
for entry in entries:
self.assertTrue(frappe.db.exists("Batch", entry.batch_no))
self.assertTrue(frappe.db.exists("Batch", parent_batch))
def test_semi_fg_pending_qty_is_left_to_another_job_card(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -525,6 +525,8 @@ def _job_card_warehouse_values(work_order, row, qty):
"finished_good": row.get("finished_good"),
"semi_fg_bom": row.get("bom_no"),
"is_subcontracted": row.get("is_subcontracted"),
"batch_split": row.get("batch_split"),
"weight_per_piece": row.get("weight_per_piece"),
}

View File

@@ -52,6 +52,8 @@ _BOM_OPERATION_FIELDS = [
"backflush_from_wip_warehouse",
"set_cost_based_on_bom_qty",
"quality_inspection_required",
"batch_split",
"weight_per_piece",
]

View File

@@ -24,6 +24,8 @@
"is_subcontracted",
"skip_material_transfer",
"backflush_from_wip_warehouse",
"batch_split",
"weight_per_piece",
"column_break_vjih",
"source_warehouse",
"wip_warehouse",
@@ -299,6 +301,20 @@
"label": "Backflush Materials From WIP Warehouse",
"read_only": 1
},
{
"default": "0",
"fieldname": "batch_split",
"fieldtype": "Check",
"label": "Batch Split",
"read_only": 1
},
{
"depends_on": "eval:doc.batch_split",
"fieldname": "weight_per_piece",
"fieldtype": "Float",
"label": "Weight Per Piece",
"read_only": 1
},
{
"default": "0",
"fieldname": "quality_inspection_required",
@@ -317,7 +333,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-05-25 17:15:12.038470",
"modified": "2026-08-28 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Operation",

View File

@@ -20,6 +20,7 @@ class WorkOrderOperation(Document):
actual_start_time: DF.Datetime | None
backflush_from_wip_warehouse: DF.Check
batch_size: DF.Float
batch_split: DF.Check
bom: DF.Link | None
bom_no: DF.Link | None
completed_qty: DF.Float
@@ -43,6 +44,7 @@ class WorkOrderOperation(Document):
source_warehouse: DF.Link | None
status: DF.Literal["Pending", "Work in Progress", "Completed"]
time_in_mins: DF.Float
weight_per_piece: DF.Float
wip_warehouse: DF.Link | None
workstation: DF.Link | None
workstation_type: DF.Link | None

View File

@@ -515,3 +515,4 @@ erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status
erpnext.patches.v16_0.recalculate_mixed_purchase_receipt_billing_status
erpnext.patches.v16_0.repair_work_order_material_transfer
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
erpnext.patches.v16_0.add_batch_split_stock_entry_type

View File

@@ -0,0 +1,8 @@
import frappe
def execute():
if not frappe.db.exists("Stock Entry Type", "Batch Split"):
frappe.new_doc("Stock Entry Type", purpose="Repack", batch_split=1).insert(
set_name="Batch Split", ignore_permissions=True
)

View File

@@ -99,6 +99,12 @@ def get_preset_records(country=None):
"purpose": "Repack",
"is_standard": 1,
},
{
"doctype": "Stock Entry Type",
"name": _("Batch Split"),
"purpose": "Repack",
"batch_split": 1,
},
{"doctype": "Stock Entry Type", "name": "Disassemble", "purpose": "Disassemble", "is_standard": 1},
{
"doctype": "Stock Entry Type",

View File

@@ -21,6 +21,7 @@ frappe.ui.form.on("Batch", {
};
frappe.set_route("query-report", "Stock Ledger");
});
frm.trigger("add_batch_split_tree_button");
frm.trigger("make_dashboard");
frm.add_custom_button(__("Recalculate Batch Qty"), () => {
@@ -35,6 +36,31 @@ frappe.ui.form.on("Batch", {
});
}
},
add_batch_split_tree_button: (frm) => {
if (frm.doc.parent_batch) {
frm.trigger("show_batch_split_tree_button");
return;
}
frappe.db.get_value(
"Batch",
{ parent_batch: frm.doc.name, reference_name: ["is", "set"] },
"name",
(r) => {
if (r && r.name) {
frm.trigger("show_batch_split_tree_button");
}
}
);
},
show_batch_split_tree_button: (frm) => {
frm.add_custom_button(__("Batch Split Tree"), () => {
frappe.route_options = {
batch: frm.doc.parent_batch || frm.doc.name,
};
frappe.set_route("query-report", "Batch Split Tree");
});
},
item: (frm) => {
// frappe.db.get_value('Item', {name: frm.doc.item}, 'has_expiry_date', (r) => {
// frm.toggle_reqd('expiry_date', r.has_expiry_date);

View File

@@ -0,0 +1,241 @@
import frappe
from frappe import _
from frappe.utils import cint, flt
from erpnext.stock.doctype.batch.batch import get_available_batches, make_batch
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
from erpnext.stock.utils import get_combine_datetime
class BatchSplitFinishedGood:
def __init__(self, doc):
self.doc = doc
def process(self):
if not self.is_applicable():
return
fg_row = self.get_finished_good_row()
pieces = self.get_pieces(fg_row)
input_batches = self.get_input_batches()
parent_batches = self.get_parent_batches(input_batches, pieces)
child_batches = self.make_child_batches(fg_row, parent_batches)
self.attach_bundle(fg_row, child_batches)
def is_applicable(self):
self.weight_per_piece = 0.0
if self.doc.purpose == "Repack":
if not self.doc.stock_entry_type or not cint(
frappe.get_cached_value("Stock Entry Type", self.doc.stock_entry_type, "batch_split")
):
return False
self.weight_per_piece = flt(self.doc.weight_per_piece)
return True
if self.doc.purpose != "Manufacture" or not self.doc.job_card:
return False
details = frappe.db.get_value(
"Job Card", self.doc.job_card, ["batch_split", "weight_per_piece"], as_dict=1
)
self.weight_per_piece = flt(details.weight_per_piece)
return cint(details.batch_split) and self.weight_per_piece > 0
def get_finished_good_row(self):
fg_rows = [
row
for row in self.doc.items
if row.is_finished_item and not row.secondary_item_type and not row.is_legacy_scrap_item
]
if len(fg_rows) != 1:
frappe.throw(
_("The Batch Split entry {0} must have exactly one finished good row.").format(self.doc.name)
)
row = fg_rows[0]
if row.serial_and_batch_bundle:
frappe.throw(
_(
"Row #{0}: Remove the Serial and Batch Bundle as the batches for the Batch Split item {1} are created automatically."
).format(row.idx, row.item_code)
)
item_details = frappe.get_cached_value(
"Item", row.item_code, ["has_batch_no", "create_new_batch"], as_dict=1
)
if not item_details.has_batch_no or not item_details.create_new_batch:
frappe.throw(
_(
"Row #{0}: The item {1} must have 'Has Batch No' and 'Automatically Create New Batch' enabled for the Batch Split operation."
).format(row.idx, row.item_code)
)
return row
def get_pieces(self, fg_row):
if self.weight_per_piece <= 0:
frappe.throw(
_(
"Please set the Weight Per Piece to split the produced quantity into batches in the Stock Entry {0}."
).format(self.doc.name)
)
pieces = flt(fg_row.transfer_qty) / self.weight_per_piece
if pieces < 1 or pieces != cint(pieces):
frappe.throw(
_(
"Row #{0}: The quantity {1} of the Batch Split item {2} must be a multiple of the Weight Per Piece {3}."
).format(fg_row.idx, fg_row.transfer_qty, fg_row.item_code, self.weight_per_piece)
)
return cint(pieces)
def get_input_batches(self):
input_rows = [row for row in self.doc.items if self.is_batch_input_row(row)]
if not input_rows:
frappe.throw(
_(
"The Batch Split operation requires a batch tracked raw material to be consumed in the Stock Entry {0}."
).format(self.doc.name)
)
item_codes = {row.item_code for row in input_rows}
if len(item_codes) > 1:
frappe.throw(
_(
"The Batch Split entry {0} must consume exactly one batch tracked raw material, found {1} ({2})."
).format(self.doc.name, len(item_codes), ", ".join(sorted(item_codes)))
)
batches = []
for row in input_rows:
batches.extend(self.get_row_batches(row))
if not batches:
frappe.throw(
_(
"The Batch Split operation requires a batch tracked raw material to be consumed in the Stock Entry {0}."
).format(self.doc.name)
)
return batches
def is_batch_input_row(self, row):
if row.is_finished_item or not row.s_warehouse:
return False
if row.secondary_item_type or row.is_legacy_scrap_item:
return False
return bool(frappe.get_cached_value("Item", row.item_code, "has_batch_no"))
def get_row_batches(self, row):
if row.serial_and_batch_bundle:
entries = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle, "batch_no": ("is", "set")},
fields=["batch_no", "qty"],
order_by="idx",
)
return [(d.batch_no, abs(flt(d.qty))) for d in entries]
if row.batch_no:
return [(row.batch_no, flt(row.transfer_qty))]
return self.get_available_row_batches(row)
def get_available_row_batches(self, row):
available = get_available_batches(
frappe._dict(
{
"item_code": row.item_code,
"warehouse": row.s_warehouse,
"posting_datetime": get_combine_datetime(self.doc.posting_date, self.doc.posting_time),
"based_on": frappe.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"),
}
)
)
batches = []
remaining = flt(row.transfer_qty)
for batch_no, qty in available.items():
if remaining <= 0:
break
if flt(qty) <= 0:
continue
taken = min(flt(qty), remaining)
batches.append((batch_no, taken))
remaining -= taken
return batches
def get_parent_batches(self, input_batches, pieces):
pool = [(batch_no, flt(qty)) for batch_no, qty in input_batches if flt(qty) > 0]
capacities = [int(flt(qty / self.weight_per_piece, 6)) for _batch_no, qty in pool]
if sum(capacities) < pieces:
frappe.throw(
_(
"The batches consumed in the Stock Entry {0} can supply only {1} whole pieces of {2} units each, but {3} pieces are required. Reduce the finished quantity or consume larger batches."
).format(self.doc.name, sum(capacities), self.weight_per_piece, pieces)
)
total_qty = sum(qty for _batch_no, qty in pool)
shares = [pieces * qty / total_qty for _batch_no, qty in pool]
counts = [min(int(share), capacity) for share, capacity in zip(shares, capacities, strict=False)]
while sum(counts) < pieces:
eligible = [i for i in range(len(pool)) if counts[i] < capacities[i]]
index = min(eligible, key=lambda i: (counts[i] - shares[i], i))
counts[index] += 1
parents = []
for (batch_no, _qty), count in zip(pool, counts, strict=False):
parents.extend([batch_no] * count)
return parents
def make_child_batches(self, fg_row, parent_batches):
batches = frappe._dict()
for parent_batch in parent_batches:
batch_no = make_batch(
frappe._dict(
{
"item": fg_row.item_code,
"parent_batch": parent_batch,
"reference_doctype": self.doc.doctype,
"reference_name": self.doc.name,
}
)
)
batches[batch_no] = self.weight_per_piece
return batches
def attach_bundle(self, fg_row, batches):
bundle = SerialBatchCreation(
{
"item_code": fg_row.item_code,
"warehouse": fg_row.t_warehouse,
"posting_datetime": get_combine_datetime(self.doc.posting_date, self.doc.posting_time),
"voucher_type": self.doc.doctype,
"voucher_detail_no": fg_row.name,
"qty": sum(batches.values()),
"batches": batches,
"type_of_transaction": "Inward",
"company": self.doc.company,
"do_not_submit": True,
}
).make_serial_and_batch_bundle()
fg_row.serial_and_batch_bundle = bundle.name
fg_row.use_serial_batch_fields = 0
fg_row.batch_no = None

View File

@@ -283,6 +283,11 @@ class ManufactureStockEntry(BaseManufactureStockEntry):
self.set_default_warehouse()
self.set_job_card_data()
def before_submit(self):
from .batch_split import BatchSplitFinishedGood
BatchSplitFinishedGood(self.doc).process()
def validate(self):
self.validate_warehouse()
self.validate_raw_materials_exists()
@@ -978,6 +983,11 @@ class RepackStockEntry(BaseManufactureStockEntry):
def before_validate(self):
self.set_default_warehouse()
def before_submit(self):
from .batch_split import BatchSplitFinishedGood
BatchSplitFinishedGood(self.doc).process()
def validate(self):
self.validate_raw_materials_exists()
self.validate_repack_entry()

View File

@@ -288,6 +288,7 @@ frappe.ui.form.on("Stock Entry", {
refresh: function (frm) {
frm.trigger("get_items_from_transit_entry");
frm.trigger("toggle_warehouse_fields");
frm.trigger("toggle_weight_per_piece");
erpnext.toggle_serial_batch_fields(frm);
if (!frm.doc.docstatus && !frm.doc.subcontracting_inward_order) {
@@ -608,6 +609,7 @@ frappe.ui.form.on("Stock Entry", {
frm.events.show_bom_custom_button(frm);
frm.trigger("add_to_transit");
frm.trigger("toggle_warehouse_fields");
frm.trigger("toggle_weight_per_piece");
frm.fields_dict.items.grid.update_docfield_property(
"basic_rate",
@@ -616,6 +618,18 @@ frappe.ui.form.on("Stock Entry", {
);
},
toggle_weight_per_piece(frm) {
if (!frm.doc.stock_entry_type || frm.doc.purpose !== "Repack") {
frm.toggle_display("weight_per_piece", false);
return;
}
frappe.db.get_value("Stock Entry Type", frm.doc.stock_entry_type, "batch_split", (r) => {
frm.toggle_display("weight_per_piece", cint(r.batch_split));
frm.toggle_reqd("weight_per_piece", cint(r.batch_split));
});
},
toggle_warehouse_fields(frm) {
frm.fields_dict["items"].grid.update_docfield_property(
"s_warehouse",

View File

@@ -11,6 +11,7 @@
"company",
"naming_series",
"stock_entry_type",
"weight_per_piece",
"purpose",
"col2",
"set_posting_time",
@@ -120,6 +121,16 @@
"reqd": 1,
"search_index": 1
},
{
"depends_on": "eval:doc.purpose == 'Repack'",
"description": "Splits the produced quantity into one batch per this many units",
"fieldname": "weight_per_piece",
"fieldtype": "Float",
"hidden": 1,
"label": "Weight Per Piece",
"no_copy": 1,
"non_negative": 1
},
{
"depends_on": "eval:doc.purpose == 'Material Transfer'",
"fieldname": "outgoing_stock_entry",
@@ -784,7 +795,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-08-27 10:00:00.000000",
"modified": "2026-08-28 18:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry",

View File

@@ -170,6 +170,7 @@ class StockEntry(StockController, SubcontractingInwardController):
total_outgoing_value: DF.Currency
use_multi_level_bom: DF.Check
value_difference: DF.Currency
weight_per_piece: DF.Float
work_order: DF.Link | None
# end: auto-generated types
@@ -357,6 +358,9 @@ class StockEntry(StockController, SubcontractingInwardController):
def before_submit(self):
StockEntrySABB(self).make_serial_and_batch_bundle_for_outward()
if self.purpose_cls and hasattr(self.purpose_cls, "before_submit"):
self.purpose_cls(self).before_submit()
def on_submit(self):
if self.purpose_cls and hasattr(self.purpose_cls, "on_submit"):
self.purpose_cls(self).on_submit()

View File

@@ -514,6 +514,159 @@ class TestStockEntry(ERPNextTestSuite):
frappe.db.exists("GL Entry", {"voucher_type": "Stock Entry", "voucher_no": repack.name})
)
def test_batch_split_stock_entry_type(self):
original_value = frappe.db.get_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
)
frappe.db.set_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward", 1)
self.addCleanup(
frappe.db.set_single_value,
"Stock Settings",
"auto_create_serial_and_batch_bundle_for_outward",
original_value,
)
if not frappe.db.exists("Stock Entry Type", "Batch Split"):
frappe.new_doc("Stock Entry Type", purpose="Repack", batch_split=1).insert(
set_name="Batch Split", ignore_permissions=True
)
warehouse = "_Test Warehouse - _TC"
rm = make_item(
"Batch Split Repack RM",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BS-RP-RM-.####",
},
).name
fg = make_item(
"Batch Split Repack FG",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "BS-RP-FG-.####",
},
).name
first_receipt = make_stock_entry(item_code=rm, target=warehouse, qty=30, basic_rate=200)
first_parent = get_batch_from_bundle(first_receipt.items[0].serial_and_batch_bundle)
second_receipt = make_stock_entry(item_code=rm, target=warehouse, qty=20, basic_rate=200)
second_parent = get_batch_from_bundle(second_receipt.items[0].serial_and_batch_bundle)
repack = frappe.new_doc("Stock Entry")
repack.stock_entry_type = "Batch Split"
repack.company = "_Test Company"
repack.weight_per_piece = 10
repack.append("items", {"item_code": rm, "qty": 50, "s_warehouse": warehouse})
repack.append("items", {"item_code": fg, "qty": 50, "t_warehouse": warehouse, "is_finished_item": 1})
repack.insert()
repack.submit()
fg_row = next(row for row in repack.items if row.item_code == fg)
entries = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": fg_row.serial_and_batch_bundle},
fields=["batch_no", "qty"],
)
self.assertEqual(len(entries), 5)
parent_wise_pieces = {}
for entry in entries:
self.assertEqual(flt(entry.qty), 10.0)
parent = frappe.db.get_value("Batch", entry.batch_no, "parent_batch")
parent_wise_pieces[parent] = parent_wise_pieces.get(parent, 0) + 1
self.assertEqual(parent_wise_pieces, {first_parent: 3, second_parent: 2})
repack.reload()
repack.cancel()
for entry in entries:
self.assertTrue(frappe.db.exists("Batch", entry.batch_no))
self.assertTrue(frappe.db.get_value("Batch", entry.batch_no, "parent_batch"))
def test_batch_split_requires_single_batch_input(self):
if not frappe.db.exists("Stock Entry Type", "Batch Split"):
frappe.new_doc("Stock Entry Type", purpose="Repack", batch_split=1).insert(
set_name="Batch Split", ignore_permissions=True
)
warehouse = "_Test Warehouse - _TC"
items = {}
for suffix in ("RM A", "RM B", "FG C"):
items[suffix] = make_item(
f"Batch Split Multi {suffix}",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": f"BS-M-{suffix[-1]}-.####",
},
).name
if suffix != "FG C":
make_stock_entry(item_code=items[suffix], target=warehouse, qty=10, basic_rate=100)
repack = frappe.new_doc("Stock Entry")
repack.stock_entry_type = "Batch Split"
repack.company = "_Test Company"
repack.weight_per_piece = 10
repack.append("items", {"item_code": items["RM A"], "qty": 10, "s_warehouse": warehouse})
repack.append("items", {"item_code": items["RM B"], "qty": 10, "s_warehouse": warehouse})
repack.append(
"items", {"item_code": items["FG C"], "qty": 20, "t_warehouse": warehouse, "is_finished_item": 1}
)
repack.insert()
self.assertRaises(frappe.ValidationError, repack.submit)
def test_batch_split_requires_whole_piece_capacity(self):
original_value = frappe.db.get_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
)
frappe.db.set_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward", 1)
self.addCleanup(
frappe.db.set_single_value,
"Stock Settings",
"auto_create_serial_and_batch_bundle_for_outward",
original_value,
)
if not frappe.db.exists("Stock Entry Type", "Batch Split"):
frappe.new_doc("Stock Entry Type", purpose="Repack", batch_split=1).insert(
set_name="Batch Split", ignore_permissions=True
)
warehouse = "_Test Warehouse - _TC"
items = {}
for suffix in ("RM", "FG"):
items[suffix] = make_item(
f"Batch Split Capacity {suffix}",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": f"BS-CAP-{suffix}-.####",
},
).name
make_stock_entry(item_code=items["RM"], target=warehouse, qty=25, basic_rate=200)
make_stock_entry(item_code=items["RM"], target=warehouse, qty=25, basic_rate=200)
repack = frappe.new_doc("Stock Entry")
repack.stock_entry_type = "Batch Split"
repack.company = "_Test Company"
repack.weight_per_piece = 10
repack.append("items", {"item_code": items["RM"], "qty": 50, "s_warehouse": warehouse})
repack.append(
"items", {"item_code": items["FG"], "qty": 50, "t_warehouse": warehouse, "is_finished_item": 1}
)
repack.insert()
self.assertRaises(frappe.ValidationError, repack.submit)
def test_repack_with_additional_costs(self):
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")

View File

@@ -8,6 +8,7 @@
"field_order": [
"purpose",
"add_to_transit",
"batch_split",
"is_standard"
],
"fields": [
@@ -28,6 +29,15 @@
"fieldtype": "Check",
"label": "Add to Transit"
},
{
"default": "0",
"depends_on": "eval: doc.purpose == 'Repack'",
"description": "On submission of the stock entry, the consumed batch is split into one child batch per finished piece",
"fieldname": "batch_split",
"fieldtype": "Check",
"label": "Batch Split",
"read_only": 1
},
{
"default": "0",
"fieldname": "is_standard",
@@ -38,7 +48,7 @@
],
"grid_page_length": 50,
"links": [],
"modified": "2025-09-04 13:03:31.283348",
"modified": "2026-08-28 14:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Type",

View File

@@ -23,6 +23,7 @@ class StockEntryType(Document):
from frappe.types import DF
add_to_transit: DF.Check
batch_split: DF.Check
is_standard: DF.Check
purpose: DF.Literal[
"Material Issue",
@@ -46,6 +47,9 @@ class StockEntryType(Document):
if self.add_to_transit and self.purpose != "Material Transfer":
self.add_to_transit = 0
if self.batch_split and self.purpose != "Repack":
self.batch_split = 0
def validate_standard_type(self):
if self.is_standard and self.name not in [
"Material Issue",

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.query_reports["Batch Split Tree"] = {
filters: [
{
fieldname: "batch",
label: __("Parent Batch"),
fieldtype: "Link",
options: "Batch",
},
{
fieldname: "item_code",
label: __("Item Code"),
fieldtype: "Link",
options: "Item",
},
],
initial_depth: 5,
};

View File

@@ -0,0 +1,33 @@
{
"add_total_row": 0,
"creation": "2026-08-28 15:00:00.000000",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"modified": "2026-08-28 15:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Batch Split Tree",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Batch",
"report_name": "Batch Split Tree",
"report_type": "Script Report",
"roles": [
{
"role": "Stock User"
},
{
"role": "Stock Manager"
},
{
"role": "Manufacturing User"
},
{
"role": "Manufacturing Manager"
}
]
}

View File

@@ -0,0 +1,161 @@
from collections import defaultdict
import frappe
from frappe import _
def execute(filters=None):
filters = frappe._dict(filters or {})
return get_columns(), get_data(filters)
def get_data(filters):
roots = get_root_batches(filters)
if not roots:
return []
children_map = get_children_map(roots)
data = []
for batch_no in roots:
add_rows(batch_no, children_map, data, 0)
return data
def get_root_batches(filters):
batch = frappe.qb.DocType("Batch")
child = frappe.qb.DocType("Batch").as_("child")
if filters.batch:
return [filters.batch]
query = (
frappe.qb.from_(batch)
.inner_join(child)
.on(child.parent_batch == batch.name)
.select(batch.name)
.distinct()
.where(batch.parent_batch.isnull())
.where(child.reference_name.isnotnull() & (child.reference_name != ""))
.orderby(batch.name)
)
if filters.item_code:
query = query.where(batch.item == filters.item_code)
return query.run(pluck=True)
def get_children_map(roots):
batch = frappe.qb.DocType("Batch")
tree = frappe.qb.Table("batch_split_tree")
fields = [
batch.name,
batch.parent_batch,
batch.item,
batch.item_name,
batch.batch_qty,
batch.stock_uom,
batch.reference_doctype,
batch.reference_name,
batch.manufacturing_date,
batch.creation,
]
seed = frappe.qb.from_(batch).select(*fields).where(batch.name.isin(roots))
recursion = (
frappe.qb.from_(batch)
.inner_join(tree)
.on(batch.parent_batch == tree.name)
.select(*fields)
.where(batch.reference_name.isnotnull() & (batch.reference_name != ""))
)
rows = (
frappe.qb.with_(seed + recursion, "batch_split_tree", recursive=True).from_(tree).select(tree.star)
).run(as_dict=True)
children_map = defaultdict(dict)
for row in rows:
children_map[row.parent_batch][row.name] = row
return children_map
def add_rows(batch_no, children_map, data, indent, batch_details=None):
if batch_details is None:
batch_details = get_batch_row(batch_no)
batch_details.batch_no = batch_no
batch_details.indent = indent
data.append(batch_details)
children = sorted(children_map.get(batch_no, {}).values(), key=lambda row: row.creation)
for child in children:
add_rows(child.name, children_map, data, indent + 1, batch_details=child)
def get_batch_row(batch_no):
return frappe.db.get_value(
"Batch",
batch_no,
[
"item",
"item_name",
"batch_qty",
"stock_uom",
"reference_doctype",
"reference_name",
"manufacturing_date",
],
as_dict=1,
)
def get_columns():
return [
{
"label": _("Batch"),
"fieldname": "batch_no",
"fieldtype": "Link",
"options": "Batch",
"width": 260,
},
{
"label": _("Item Code"),
"fieldname": "item",
"fieldtype": "Link",
"options": "Item",
"width": 160,
},
{"label": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 160},
{"label": _("Batch Qty"), "fieldname": "batch_qty", "fieldtype": "Float", "width": 110},
{
"label": _("Stock UOM"),
"fieldname": "stock_uom",
"fieldtype": "Link",
"options": "UOM",
"width": 100,
},
{
"label": _("Created Via"),
"fieldname": "reference_doctype",
"fieldtype": "Link",
"options": "DocType",
"width": 120,
},
{
"label": _("Reference"),
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"options": "reference_doctype",
"width": 160,
},
{
"label": _("Manufacturing Date"),
"fieldname": "manufacturing_date",
"fieldtype": "Date",
"width": 130,
},
]