From 0223223385765f9299172968927ee209092835b5 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 28 Aug 2026 17:11:02 +0530 Subject: [PATCH] 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 --- erpnext/manufacturing/doctype/bom/bom.js | 7 - erpnext/manufacturing/doctype/bom/bom.py | 31 +++ .../doctype/bom_operation/bom_operation.json | 21 +- .../doctype/bom_operation/bom_operation.py | 2 + .../doctype/job_card/job_card.json | 18 +- .../doctype/job_card/job_card.py | 2 + .../doctype/job_card/test_job_card.py | 127 +++++++++ .../doctype/work_order/mapper.py | 2 + .../doctype/work_order/services/operations.py | 2 + .../work_order_operation.json | 18 +- .../work_order_operation.py | 2 + erpnext/patches.txt | 1 + .../v16_0/add_batch_split_stock_entry_type.py | 8 + .../operations/install_fixtures.py | 6 + erpnext/stock/doctype/batch/batch.js | 26 ++ .../stock_entry/services/batch_split.py | 241 ++++++++++++++++++ .../stock_entry/services/manufacturing.py | 10 + .../stock/doctype/stock_entry/stock_entry.js | 14 + .../doctype/stock_entry/stock_entry.json | 13 +- .../stock/doctype/stock_entry/stock_entry.py | 4 + .../doctype/stock_entry/test_stock_entry.py | 153 +++++++++++ .../stock_entry_type/stock_entry_type.json | 12 +- .../stock_entry_type/stock_entry_type.py | 4 + .../stock/report/batch_split_tree/__init__.py | 0 .../batch_split_tree/batch_split_tree.js | 20 ++ .../batch_split_tree/batch_split_tree.json | 33 +++ .../batch_split_tree/batch_split_tree.py | 161 ++++++++++++ 27 files changed, 926 insertions(+), 12 deletions(-) create mode 100644 erpnext/patches/v16_0/add_batch_split_stock_entry_type.py create mode 100644 erpnext/stock/doctype/stock_entry/services/batch_split.py create mode 100644 erpnext/stock/report/batch_split_tree/__init__.py create mode 100644 erpnext/stock/report/batch_split_tree/batch_split_tree.js create mode 100644 erpnext/stock/report/batch_split_tree/batch_split_tree.json create mode 100644 erpnext/stock/report/batch_split_tree/batch_split_tree.py diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 82297245663..86b8e2e83df 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -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"), diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index c916e2c7b32..811697ce5dc 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -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: diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index e6ac3ee474e..5e14b921387 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -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", diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.py b/erpnext/manufacturing/doctype/bom_operation/bom_operation.py index 71fcd689841..76bc734f45b 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.py +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.py @@ -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 diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index bfd5b1e147c..22bacf24344 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -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", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 47eb7909b5a..8455e64f2ee 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -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 diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 6033765f755..f8330fea5aa 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -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 diff --git a/erpnext/manufacturing/doctype/work_order/mapper.py b/erpnext/manufacturing/doctype/work_order/mapper.py index 016d3eed99c..d7b172daafd 100644 --- a/erpnext/manufacturing/doctype/work_order/mapper.py +++ b/erpnext/manufacturing/doctype/work_order/mapper.py @@ -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"), } diff --git a/erpnext/manufacturing/doctype/work_order/services/operations.py b/erpnext/manufacturing/doctype/work_order/services/operations.py index 905664608f8..5e6f8cdb51f 100644 --- a/erpnext/manufacturing/doctype/work_order/services/operations.py +++ b/erpnext/manufacturing/doctype/work_order/services/operations.py @@ -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", ] diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index d0ef7f257a6..36062ccb2c9 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -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", diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py index 8950fd6b320..a19c601f222 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py @@ -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 diff --git a/erpnext/patches.txt b/erpnext/patches.txt index aff0e29690e..39b0512ba8f 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -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 diff --git a/erpnext/patches/v16_0/add_batch_split_stock_entry_type.py b/erpnext/patches/v16_0/add_batch_split_stock_entry_type.py new file mode 100644 index 00000000000..61469bea72d --- /dev/null +++ b/erpnext/patches/v16_0/add_batch_split_stock_entry_type.py @@ -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 + ) diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 9e1875b2c9c..c3beba293be 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -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", diff --git a/erpnext/stock/doctype/batch/batch.js b/erpnext/stock/doctype/batch/batch.js index da2a083252b..5c884bea64c 100644 --- a/erpnext/stock/doctype/batch/batch.js +++ b/erpnext/stock/doctype/batch/batch.js @@ -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); diff --git a/erpnext/stock/doctype/stock_entry/services/batch_split.py b/erpnext/stock/doctype/stock_entry/services/batch_split.py new file mode 100644 index 00000000000..d8eae6888ae --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/services/batch_split.py @@ -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 diff --git a/erpnext/stock/doctype/stock_entry/services/manufacturing.py b/erpnext/stock/doctype/stock_entry/services/manufacturing.py index 625a56c36a3..25a7a376c8a 100644 --- a/erpnext/stock/doctype/stock_entry/services/manufacturing.py +++ b/erpnext/stock/doctype/stock_entry/services/manufacturing.py @@ -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() diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index d87b9479a96..498c8bf6fe2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -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", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 493df49bf09..646d4516c2d 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -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", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index d16dc1b42c7..44936d4ad94 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -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() diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 3f4407d5045..5bd70a59240 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -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") diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json index 8b52dcd30ca..1689c95de59 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.json @@ -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", diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py index d9ea63a9f82..52baa60a68d 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -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", diff --git a/erpnext/stock/report/batch_split_tree/__init__.py b/erpnext/stock/report/batch_split_tree/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/report/batch_split_tree/batch_split_tree.js b/erpnext/stock/report/batch_split_tree/batch_split_tree.js new file mode 100644 index 00000000000..a1c5680c895 --- /dev/null +++ b/erpnext/stock/report/batch_split_tree/batch_split_tree.js @@ -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, +}; diff --git a/erpnext/stock/report/batch_split_tree/batch_split_tree.json b/erpnext/stock/report/batch_split_tree/batch_split_tree.json new file mode 100644 index 00000000000..104400eabdf --- /dev/null +++ b/erpnext/stock/report/batch_split_tree/batch_split_tree.json @@ -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" + } + ] +} diff --git a/erpnext/stock/report/batch_split_tree/batch_split_tree.py b/erpnext/stock/report/batch_split_tree/batch_split_tree.py new file mode 100644 index 00000000000..5f0b90d7fa0 --- /dev/null +++ b/erpnext/stock/report/batch_split_tree/batch_split_tree.py @@ -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, + }, + ]