Merge pull request #48184 from iamkhanraheel/update-disassembly-items

fix: Disassembly order items calculation in stock entry & track it in work order
This commit is contained in:
rohitwaghchaure
2025-06-30 11:00:58 +05:30
committed by GitHub
5 changed files with 151 additions and 10 deletions

View File

@@ -2364,6 +2364,105 @@ class TestWorkOrder(IntegrationTestCase):
stock_entry.submit()
def test_disassembly_order_with_qty_behavior(self):
# Create raw material and FG item
raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=10, raw_materials=[raw_item], rm_qty=5)
# Create and submit a Work Order for 10 qty
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
# create material receipt stock entry for raw material
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
make_stock_entry_test_record(
item_code=raw_item,
purpose="Material Receipt",
target=wo.wip_warehouse,
qty=10,
basic_rate=100,
)
make_stock_entry_test_record(
item_code=raw_item,
purpose="Material Receipt",
target=wo.fg_warehouse,
qty=10,
basic_rate=100,
)
# create material transfer for manufacture stock entry
se_for_material_tranfer_mfr = frappe.get_doc(
make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)
)
se_for_material_tranfer_mfr.items[0].s_warehouse = wo.wip_warehouse
se_for_material_tranfer_mfr.save()
se_for_material_tranfer_mfr.submit()
se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_for_manufacture.submit()
# Simulate a disassembly stock entry
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
stock_entry.append(
"items",
{
"item_code": fg_item,
"qty": disassemble_qty,
"s_warehouse": wo.fg_warehouse,
},
)
for bom_item in bom.items:
stock_entry.append(
"items",
{
"item_code": bom_item.item_code,
"qty": (bom_item.qty / bom.quantity) * disassemble_qty,
"t_warehouse": wo.source_warehouse,
},
)
wo.reload()
stock_entry.save()
stock_entry.submit()
# Assert FG item is present with correct qty
finished_good_entry = next((item for item in stock_entry.items if item.item_code == fg_item), None)
self.assertIsNotNone(finished_good_entry, "Finished good item missing from stock entry")
self.assertEqual(
finished_good_entry.qty,
disassemble_qty,
f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}",
)
# Assert raw materials
for item in stock_entry.items:
if item.item_code == fg_item:
continue
bom_item = next((i for i in bom.items if i.item_code == item.item_code), None)
if bom_item:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
self.assertAlmostEqual(
item.qty,
expected_qty,
places=3,
msg=f"Raw item {item.item_code} qty mismatch: expected {expected_qty}, got {item.qty}",
)
else:
self.fail(f"Unexpected item {item.item_code} found in stock entry")
wo.reload()
# Assert disassembled_qty field updated in Work Order
self.assertEqual(
wo.disassembled_qty,
disassemble_qty,
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
)
def test_components_alternate_item_for_bom_based_manufacture_entry(self):
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)
@@ -3296,6 +3395,7 @@ def make_wo_order_test_record(**args):
wo_order.transfer_material_against = args.transfer_material_against or "Work Order"
wo_order.from_wip_warehouse = args.from_wip_warehouse or 0
wo_order.batch_size = args.batch_size or 0
wo_order.status = args.status or "Draft"
if args.source_warehouse:
wo_order.source_warehouse = args.source_warehouse

View File

@@ -870,7 +870,7 @@ erpnext.work_order = {
get_max_transferable_qty: (frm, purpose) => {
let max = 0;
if (purpose === "Disassemble") {
return flt(frm.doc.produced_qty);
return flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
}
if (frm.doc.skip_transfer) {

View File

@@ -20,6 +20,7 @@
"qty",
"material_transferred_for_manufacturing",
"produced_qty",
"disassembled_qty",
"process_loss_qty",
"project",
"track_semi_finished_goods",
@@ -592,6 +593,14 @@
"fieldname": "reserve_stock",
"fieldtype": "Check",
"label": " Reserve Stock"
},
{
"depends_on": "eval:doc.docstatus==1",
"fieldname": "disassembled_qty",
"fieldtype": "Float",
"label": "Disassembled Qty",
"no_copy": 1,
"read_only": 1
}
],
"grid_page_length": 50,
@@ -600,7 +609,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2025-04-25 11:46:38.739588",
"modified": "2025-06-21 00:55:45.916224",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -89,6 +89,7 @@ class WorkOrder(Document):
company: DF.Link
corrective_operation_cost: DF.Currency
description: DF.SmallText | None
disassembled_qty: DF.Float
expected_delivery_date: DF.Date | None
fg_warehouse: DF.Link | None
from_wip_warehouse: DF.Check
@@ -477,6 +478,18 @@ class WorkOrder(Document):
self.set_produced_qty_for_sub_assembly_item()
self.update_production_plan_status()
def update_disassembled_qty(self, qty, is_cancel=False):
if is_cancel:
self.disassembled_qty = max(0, self.disassembled_qty - qty)
else:
if self.docstatus == 1:
self.disassembled_qty += qty
if not is_cancel and self.disassembled_qty > self.produced_qty:
frappe.throw(_("Cannot disassemble more than produced quantity."))
self.db_set("disassembled_qty", self.disassembled_qty)
def get_transferred_or_manufactured_qty(self, purpose):
table = frappe.qb.DocType("Stock Entry")
query = frappe.qb.from_(table).where(
@@ -1983,7 +1996,7 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
stock_entry.set_stock_entry_type()
stock_entry.get_items()
stock_entry.get_items(qty, work_order.production_item)
if purpose != "Disassemble":
stock_entry.set_serial_no_batch_for_finished_good()

View File

@@ -28,6 +28,7 @@ from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
from erpnext.manufacturing.doctype.bom.bom import (
add_additional_cost,
get_bom_items_as_dict,
get_op_cost_from_sub_assemblies,
get_scrap_items_from_sub_assemblies,
validate_bom_no,
@@ -249,6 +250,7 @@ class StockEntry(StockController):
self.validate_closed_subcontracting_order()
self.make_bundle_using_old_serial_batch_fields()
self.update_work_order()
self.update_disassembled_order()
self.update_stock_ledger()
self.make_stock_reserve_for_wip_and_fg()
@@ -280,6 +282,7 @@ class StockEntry(StockController):
self.validate_work_order_status()
self.update_work_order()
self.update_disassembled_order(is_cancel=True)
self.update_stock_ledger()
self.ignore_linked_doctypes = (
@@ -1743,6 +1746,13 @@ class StockEntry(StockController):
if not pro_doc.operations:
pro_doc.set_actual_dates()
def update_disassembled_order(self, is_cancel=False):
if not self.work_order:
return
if self.purpose == "Disassemble" and self.fg_completed_qty:
pro_doc = frappe.get_doc("Work Order", self.work_order)
pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, is_cancel)
def make_stock_reserve_for_wip_and_fg(self):
if self.is_stock_reserve_for_work_order():
pro_doc = frappe.get_doc("Work Order", self.work_order)
@@ -1919,7 +1929,7 @@ class StockEntry(StockController):
},
)
def get_items_for_disassembly(self):
def get_items_for_disassembly(self, disassemble_qty, production_item):
"""Get items for Disassembly Order"""
if not self.work_order:
@@ -1927,9 +1937,9 @@ class StockEntry(StockController):
items = self.get_items_from_manufacture_entry()
s_warehouse = ""
if self.work_order:
s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse")
s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse")
items_dict = get_bom_items_as_dict(self.bom_no, self.company, disassemble_qty)
for row in items:
child_row = self.append("items", {})
@@ -1937,6 +1947,15 @@ class StockEntry(StockController):
if value is not None:
child_row.set(field, value)
# update qty and amount from BOM items
bom_items = items_dict.get(row.item_code)
if bom_items:
child_row.qty = bom_items.get("qty", child_row.qty)
child_row.amount = bom_items.get("amount", child_row.amount)
if row.item_code == production_item:
child_row.qty = disassemble_qty
child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else ""
child_row.t_warehouse = self.to_warehouse if not row.is_finished_item else ""
child_row.is_finished_item = 0 if row.is_finished_item else 1
@@ -1969,12 +1988,12 @@ class StockEntry(StockController):
)
@frappe.whitelist()
def get_items(self):
def get_items(self, qty=None, production_item=None):
self.set("items", [])
self.validate_work_order()
if self.purpose == "Disassemble":
return self.get_items_for_disassembly()
if self.purpose == "Disassemble" and qty is not None:
return self.get_items_for_disassembly(qty, production_item)
if not self.posting_date or not self.posting_time:
frappe.throw(_("Posting date and posting time is mandatory"))