fix: auto fetch serial no from previous operation output (#56445)

* fix: auto fetch serial no from previous operation output

* fix: order by

* fix: warehouse for operations
This commit is contained in:
rohitwaghchaure
2026-07-03 20:23:33 +05:30
committed by GitHub
parent 341a07dffa
commit 7b0c35caaf
6 changed files with 470 additions and 4 deletions

View File

@@ -86,6 +86,10 @@ def make_material_request(source_name: str, target_doc: Document | str | None =
@frappe.whitelist()
def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
set_previous_operation_serial_batch,
)
def update_item(source, target, source_parent):
target.t_warehouse = source_parent.wip_warehouse
@@ -125,6 +129,7 @@ def make_stock_entry(source_name: str, target_doc: Document | str | None = None)
wo_allows_alternate_item
and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item")
)
set_previous_operation_serial_batch(target, item)
doclist = get_mapped_doc(
"Job Card",

View File

@@ -1061,6 +1061,9 @@ class TestJobCard(ERPNextTestSuite):
job_card.submit()
for row in fg_bom.items:
if row.item_code == sfg.name:
continue
make_stock_entry(
item_code=row.item_code,
target="Stores - _TC",
@@ -1071,9 +1074,301 @@ class TestJobCard(ERPNextTestSuite):
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
manufacturing_entry.submit()
sfg_row = next(row for row in manufacturing_entry.items if row.item_code == sfg.name)
self.assertEqual(flt(sfg_row.basic_rate, 3), 95.0)
self.assertEqual(manufacturing_entry.items[2].item_code, scrap2.name)
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278)
def test_semi_fg_batch_auto_pull_on_manufacture(self):
"""Batch produced by an operation should auto-pull into the next operation's
semi-finished consumption row (skip-transfer Manufacture entry)."""
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
warehouse = "Stores - _TC"
rm1 = make_item("Auto Pull RM 1", {"is_stock_item": 1}).name
rm2 = make_item("Auto Pull RM 2", {"is_stock_item": 1}).name
fg1 = make_item("Auto Pull FG 1", {"is_stock_item": 1}).name
sfg = make_item(
"Auto Pull SFG 1",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "AP-SFG-.#####",
},
).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
operation1 = {
"operation": "Auto Pull Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "Auto Pull Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
# Operation A -> produces the SFG batch
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{
"from_time": "2024-01-01 08:00:00",
"to_time": "2024-01-01 09:00:00",
"completed_qty": jc_a.for_quantity,
},
)
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
me_a.reload()
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
self.assertTrue(sfg_fg_row.serial_and_batch_bundle)
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
# Operation B -> consumes the SFG; its batch should be auto-pulled from Operation A
jc_b = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op B"}, "name"
),
)
jc_b.append(
"time_logs",
{
"from_time": "2024-02-01 08:00:00",
"to_time": "2024-02-01 09:00:00",
"completed_qty": jc_b.for_quantity,
},
)
jc_b.submit()
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
sfg_consume_row = next(r for r in me_b.items if r.item_code == sfg and r.s_warehouse)
self.assertTrue(
sfg_consume_row.serial_and_batch_bundle,
"Previous operation's batch was not auto-pulled into the semi-finished consumption row",
)
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
def test_semi_fg_auto_pull_with_uom_conversion(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.stock_entry.services.manufacturing import (
set_previous_operation_serial_batch,
)
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
warehouse = "Stores - _TC"
rm1 = make_item("UOM Pull RM 1", {"is_stock_item": 1}).name
rm2 = make_item("UOM Pull RM 2", {"is_stock_item": 1}).name
fg1 = make_item("UOM Pull FG 1", {"is_stock_item": 1}).name
sfg = make_item(
"UOM Pull SFG 1",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "UP-SFG-.#####",
"uoms": [{"uom": "Box", "conversion_factor": 5}],
},
).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
operation1 = {
"operation": "UOM Pull Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "UOM Pull Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=sfg, target=warehouse, qty=5, basic_rate=100, posting_date="2024-01-01")
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "UOM Pull Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{
"from_time": "2024-02-01 08:00:00",
"to_time": "2024-02-01 09:00:00",
"completed_qty": jc_a.for_quantity,
},
)
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
me_a.reload()
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
se = frappe.new_doc("Stock Entry")
se.company = "_Test Company"
se.purpose = "Material Transfer"
se.work_order = work_order.name
se.set_stock_entry_type()
row = se.append(
"items",
{
"item_code": sfg,
"qty": 1,
"uom": "Box",
"conversion_factor": 5,
"s_warehouse": warehouse,
"t_warehouse": "_Test Warehouse - _TC",
},
)
set_previous_operation_serial_batch(se, row)
self.assertTrue(row.serial_and_batch_bundle)
self.assertEqual(
abs(frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty")),
5.0,
)
se.save()
se.submit()
se.reload()
row = se.items[0]
consumed_batches = get_batches_from_bundle(row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
self.assertEqual(abs(sum(consumed_batches.values())), 5.0)
def test_secondary_items_without_sfg(self):
for row in frappe.get_doc("BOM", self.work_order.bom_no).items:

View File

@@ -169,6 +169,29 @@ class OperationsService:
self.doc.set("operations", operations)
self.calculate_time()
self.set_operation_warehouses()
def set_operation_warehouses(self):
"""For semi-finished goods tracking, default each operation's warehouses from the Work
Order and chain them: the first operation pulls from the WO source warehouse and every
later operation pulls from the previous operation's output; intermediate outputs go to the
WIP warehouse while the final operation outputs to the WO finished goods warehouse.
Only empty fields are filled, so values configured on the BOM/operation are preserved."""
if not self.doc.track_semi_finished_goods or not self.doc.operations:
return
operations = self.doc.operations
last_idx = len(operations) - 1
for idx, op in enumerate(operations):
if not op.source_warehouse:
op.source_warehouse = self.doc.source_warehouse
if not op.fg_warehouse:
op.fg_warehouse = self.doc.fg_warehouse if idx == last_idx else self.doc.source_warehouse
if not op.wip_warehouse:
op.wip_warehouse = self.doc.wip_warehouse
def _collect_bom_operations(self):
operations = []

View File

@@ -288,6 +288,7 @@ class WorkOrder(Document):
self.validate_sales_order()
self.set_default_warehouse()
self.set_operation_warehouses()
self.validate_warehouse_belongs_to_company()
self.check_wip_warehouse_skip()
self.calculate_operating_cost()
@@ -975,6 +976,9 @@ class WorkOrder(Document):
def set_work_order_operations(self):
return OperationsService(self).set_work_order_operations()
def set_operation_warehouses(self):
return OperationsService(self).set_operation_warehouses()
def update_operation_status(self):
return OperationsService(self).update_operation_status()