mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-28 14:18:24 +00:00
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:
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
241
erpnext/stock/doctype/stock_entry/services/batch_split.py
Normal file
241
erpnext/stock/doctype/stock_entry/services/batch_split.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
0
erpnext/stock/report/batch_split_tree/__init__.py
Normal file
0
erpnext/stock/report/batch_split_tree/__init__.py
Normal file
20
erpnext/stock/report/batch_split_tree/batch_split_tree.js
Normal file
20
erpnext/stock/report/batch_split_tree/batch_split_tree.js
Normal 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,
|
||||
};
|
||||
33
erpnext/stock/report/batch_split_tree/batch_split_tree.json
Normal file
33
erpnext/stock/report/batch_split_tree/batch_split_tree.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
161
erpnext/stock/report/batch_split_tree/batch_split_tree.py
Normal file
161
erpnext/stock/report/batch_split_tree/batch_split_tree.py
Normal 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,
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user