diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 27e7e24a823..6bb4cfceadd 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import copy + import frappe from frappe.tests.utils import FrappeTestCase, change_settings, timeout from frappe.utils import add_days, add_months, cint, flt, now, today @@ -19,6 +21,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import ( ) from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import create_item, make_item +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.stock_entry import test_stock_entry from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.utils import get_bin @@ -28,6 +31,7 @@ class TestWorkOrder(FrappeTestCase): def setUp(self): self.warehouse = "_Test Warehouse 2 - _TC" self.item = "_Test Item" + prepare_data_for_backflush_based_on_materials_transferred() def tearDown(self): frappe.db.rollback() @@ -527,6 +531,8 @@ class TestWorkOrder(FrappeTestCase): work_order.cancel() def test_work_order_with_non_transfer_item(self): + frappe.db.set_value("Manufacturing Settings", None, "backflush_raw_materials_based_on", "BOM") + items = {"Finished Good Transfer Item": 1, "_Test FG Item": 1, "_Test FG Item 1": 0} for item, allow_transfer in items.items(): make_item(item, {"include_item_in_manufacturing": allow_transfer}) @@ -1071,7 +1077,7 @@ class TestWorkOrder(FrappeTestCase): sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100)) for row in sm.get("items"): if row.get("item_code") == "_Test Item": - row.qty = 110 + row.qty = 120 sm.submit() cancel_stock_entry.append(sm.name) @@ -1079,21 +1085,21 @@ class TestWorkOrder(FrappeTestCase): s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90)) for row in s.get("items"): if row.get("item_code") == "_Test Item": - self.assertEqual(row.get("qty"), 100) + self.assertEqual(row.get("qty"), 108) s.submit() cancel_stock_entry.append(s.name) s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) for row in s1.get("items"): if row.get("item_code") == "_Test Item": - self.assertEqual(row.get("qty"), 5) + self.assertEqual(row.get("qty"), 6) s1.submit() cancel_stock_entry.append(s1.name) s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) for row in s2.get("items"): if row.get("item_code") == "_Test Item": - self.assertEqual(row.get("qty"), 5) + self.assertEqual(row.get("qty"), 6) cancel_stock_entry.reverse() for ste in cancel_stock_entry: @@ -1203,6 +1209,269 @@ class TestWorkOrder(FrappeTestCase): self.assertEqual(work_order.required_items[0].transferred_qty, 1) self.assertEqual(work_order.required_items[1].transferred_qty, 2) + def test_backflushed_batch_raw_materials_based_on_transferred(self): + frappe.db.set_value( + "Manufacturing Settings", + None, + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + batch_item = "Test Batch MCC Keyboard" + fg_item = "Test FG Item with Batch Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True + ) + + ste_doc.append( + "items", + { + "item_code": batch_item, + "item_name": batch_item, + "description": batch_item, + "basic_rate": 100, + "t_warehouse": "Stores - _TC", + "qty": 2, + "uom": "Nos", + "stock_uom": "Nos", + "conversion_factor": 1, + }, + ) + + # Inward raw materials in Stores warehouse + ste_doc.insert() + ste_doc.submit() + + batch_list = [row.batch_no for row in ste_doc.items] + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.items[0].qty = 2 + transferred_ste_doc.items[0].batch_no = batch_list[0] + + new_row = copy.deepcopy(transferred_ste_doc.items[0]) + new_row.name = "" + new_row.batch_no = batch_list[1] + + # Transferred two batches from Stores to WIP Warehouse + transferred_ste_doc.append("items", new_row) + transferred_ste_doc.submit() + + # First Manufacture stock entry + manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) + + # Batch no should be same as transferred Batch no + self.assertEqual(manufacture_ste_doc1.items[0].batch_no, batch_list[0]) + self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) + + manufacture_ste_doc1.submit() + + # Second Manufacture stock entry + manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) + + # Batch no should be same as transferred Batch no + self.assertEqual(manufacture_ste_doc2.items[0].batch_no, batch_list[0]) + self.assertEqual(manufacture_ste_doc2.items[0].qty, 1) + self.assertEqual(manufacture_ste_doc2.items[1].batch_no, batch_list[1]) + self.assertEqual(manufacture_ste_doc2.items[1].qty, 1) + + def test_backflushed_serial_no_raw_materials_based_on_transferred(self): + frappe.db.set_value( + "Manufacturing Settings", + None, + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + sn_item = "Test Serial No BTT Headphone" + fg_item = "Test FG Item with Serial No Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=sn_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True + ) + + # Inward raw materials in Stores warehouse + ste_doc.submit() + + serial_nos_list = sorted(get_serial_nos(ste_doc.items[0].serial_no)) + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.items[0].serial_no = "\n".join(serial_nos_list) + transferred_ste_doc.submit() + + # First Manufacture stock entry + manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) + + # Serial nos should be same as transferred Serial nos + self.assertEqual(get_serial_nos(manufacture_ste_doc1.items[0].serial_no), serial_nos_list[0:1]) + self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) + + manufacture_ste_doc1.submit() + + # Second Manufacture stock entry + manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) + + # Serial nos should be same as transferred Serial nos + self.assertEqual(get_serial_nos(manufacture_ste_doc2.items[0].serial_no), serial_nos_list[1:3]) + self.assertEqual(manufacture_ste_doc2.items[0].qty, 2) + + def test_backflushed_serial_no_batch_raw_materials_based_on_transferred(self): + frappe.db.set_value( + "Manufacturing Settings", + None, + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + sn_batch_item = "Test Batch Serial No WebCam" + fg_item = "Test FG Item with Serial & Batch No Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=sn_batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True + ) + + ste_doc.append( + "items", + { + "item_code": sn_batch_item, + "item_name": sn_batch_item, + "description": sn_batch_item, + "basic_rate": 100, + "t_warehouse": "Stores - _TC", + "qty": 2, + "uom": "Nos", + "stock_uom": "Nos", + "conversion_factor": 1, + }, + ) + + # Inward raw materials in Stores warehouse + ste_doc.insert() + ste_doc.submit() + + batch_dict = {row.batch_no: get_serial_nos(row.serial_no) for row in ste_doc.items} + batches = list(batch_dict.keys()) + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.items[0].qty = 2 + transferred_ste_doc.items[0].batch_no = batches[0] + transferred_ste_doc.items[0].serial_no = "\n".join(batch_dict.get(batches[0])) + + new_row = copy.deepcopy(transferred_ste_doc.items[0]) + new_row.name = "" + new_row.batch_no = batches[1] + new_row.serial_no = "\n".join(batch_dict.get(batches[1])) + + # Transferred two batches from Stores to WIP Warehouse + transferred_ste_doc.append("items", new_row) + transferred_ste_doc.submit() + + # First Manufacture stock entry + manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) + + # Batch no & Serial Nos should be same as transferred Batch no & Serial Nos + batch_no = manufacture_ste_doc1.items[0].batch_no + self.assertEqual( + get_serial_nos(manufacture_ste_doc1.items[0].serial_no)[0], batch_dict.get(batch_no)[0] + ) + self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) + + manufacture_ste_doc1.submit() + + # Second Manufacture stock entry + manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) + + # Batch no & Serial Nos should be same as transferred Batch no & Serial Nos + batch_no = manufacture_ste_doc2.items[0].batch_no + self.assertEqual( + get_serial_nos(manufacture_ste_doc2.items[0].serial_no)[0], batch_dict.get(batch_no)[1] + ) + self.assertEqual(manufacture_ste_doc2.items[0].qty, 1) + + batch_no = manufacture_ste_doc2.items[1].batch_no + self.assertEqual( + get_serial_nos(manufacture_ste_doc2.items[1].serial_no)[0], batch_dict.get(batch_no)[0] + ) + self.assertEqual(manufacture_ste_doc2.items[1].qty, 1) + + +def prepare_data_for_backflush_based_on_materials_transferred(): + batch_item_doc = make_item( + "Test Batch MCC Keyboard", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBMK.#####", + "valuation_rate": 100, + "stock_uom": "Nos", + }, + ) + + item = make_item( + "Test FG Item with Batch Raw Materials", + { + "is_stock_item": 1, + }, + ) + + make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[batch_item_doc.name]) + + sn_item_doc = make_item( + "Test Serial No BTT Headphone", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TSBH.#####", + "valuation_rate": 100, + "stock_uom": "Nos", + }, + ) + + item = make_item( + "Test FG Item with Serial No Raw Materials", + { + "is_stock_item": 1, + }, + ) + + make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_item_doc.name]) + + sn_batch_item_doc = make_item( + "Test Batch Serial No WebCam", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBSW.#####", + "has_serial_no": 1, + "serial_no_series": "TBSWC.#####", + "valuation_rate": 100, + "stock_uom": "Nos", + }, + ) + + item = make_item( + "Test FG Item with Serial & Batch No Raw Materials", + { + "is_stock_item": 1, + }, + ) + + make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[sn_batch_item_doc.name]) + def update_job_card(job_card, jc_qty=None): employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e902d1e56b6..4b2850e2790 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -596,21 +596,6 @@ class StockEntry(StockController): title=_("Insufficient Stock"), ) - def set_serial_nos(self, work_order): - previous_se = frappe.db.get_value( - "Stock Entry", - {"work_order": work_order, "purpose": "Material Transfer for Manufacture"}, - "name", - ) - - for d in self.get("items"): - transferred_serial_no = frappe.db.get_value( - "Stock Entry Detail", {"parent": previous_se, "item_code": d.item_code}, "serial_no" - ) - - if transferred_serial_no: - d.serial_no = transferred_serial_no - @frappe.whitelist() def get_stock_and_rate(self): """ @@ -1321,7 +1306,7 @@ class StockEntry(StockController): and not self.pro_doc.skip_transfer and self.flags.backflush_based_on == "Material Transferred for Manufacture" ): - self.get_transfered_raw_materials() + self.add_transfered_raw_materials_in_items() elif ( self.work_order @@ -1365,7 +1350,6 @@ class StockEntry(StockController): # fetch the serial_no of the first stock entry for the second stock entry if self.work_order and self.purpose == "Manufacture": - self.set_serial_nos(self.work_order) work_order = frappe.get_doc("Work Order", self.work_order) add_additional_cost(self, work_order) @@ -1655,119 +1639,78 @@ class StockEntry(StockController): } ) - def get_transfered_raw_materials(self): - transferred_materials = frappe.db.sql( - """ - select - item_name, original_item, item_code, sum(qty) as qty, sed.t_warehouse as warehouse, - description, stock_uom, expense_account, cost_center - from `tabStock Entry` se,`tabStock Entry Detail` sed - where - se.name = sed.parent and se.docstatus=1 and se.purpose='Material Transfer for Manufacture' - and se.work_order= %s and ifnull(sed.t_warehouse, '') != '' - group by sed.item_code, sed.t_warehouse - """, + def add_transfered_raw_materials_in_items(self) -> None: + available_materials = get_available_materials(self.work_order) + + wo_data = frappe.db.get_value( + "Work Order", self.work_order, + ["qty", "produced_qty", "material_transferred_for_manufacturing as trans_qty"], as_dict=1, ) - materials_already_backflushed = frappe.db.sql( - """ - select - item_code, sed.s_warehouse as warehouse, sum(qty) as qty - from - `tabStock Entry` se, `tabStock Entry Detail` sed - where - se.name = sed.parent and se.docstatus=1 - and (se.purpose='Manufacture' or se.purpose='Material Consumption for Manufacture') - and se.work_order= %s and ifnull(sed.s_warehouse, '') != '' - group by sed.item_code, sed.s_warehouse - """, - self.work_order, - as_dict=1, - ) + for key, row in available_materials.items(): + remaining_qty_to_produce = flt(wo_data.trans_qty) - flt(wo_data.produced_qty) + if remaining_qty_to_produce <= 0: + continue - backflushed_materials = {} - for d in materials_already_backflushed: - backflushed_materials.setdefault(d.item_code, []).append({d.warehouse: d.qty}) - - po_qty = frappe.db.sql( - """select qty, produced_qty, material_transferred_for_manufacturing from - `tabWork Order` where name=%s""", - self.work_order, - as_dict=1, - )[0] - - manufacturing_qty = flt(po_qty.qty) or 1 - produced_qty = flt(po_qty.produced_qty) - trans_qty = flt(po_qty.material_transferred_for_manufacturing) or 1 - - for item in transferred_materials: - qty = item.qty - item_code = item.original_item or item.item_code - req_items = frappe.get_all( - "Work Order Item", - filters={"parent": self.work_order, "item_code": item_code}, - fields=["required_qty", "consumed_qty"], - ) - - req_qty = flt(req_items[0].required_qty) if req_items else flt(4) - req_qty_each = flt(req_qty / manufacturing_qty) - consumed_qty = flt(req_items[0].consumed_qty) if req_items else 0 - - if trans_qty and manufacturing_qty > (produced_qty + flt(self.fg_completed_qty)): - if qty >= req_qty: - qty = (req_qty / trans_qty) * flt(self.fg_completed_qty) - else: - qty = qty - consumed_qty - - if self.purpose == "Manufacture": - # If Material Consumption is booked, must pull only remaining components to finish product - if consumed_qty != 0: - remaining_qty = consumed_qty - (produced_qty * req_qty_each) - exhaust_qty = req_qty_each * produced_qty - if remaining_qty > exhaust_qty: - if (remaining_qty / (req_qty_each * flt(self.fg_completed_qty))) >= 1: - qty = 0 - else: - qty = (req_qty_each * flt(self.fg_completed_qty)) - remaining_qty - else: - if self.flags.backflush_based_on == "Material Transferred for Manufacture": - qty = (item.qty / trans_qty) * flt(self.fg_completed_qty) - else: - qty = req_qty_each * flt(self.fg_completed_qty) - - elif backflushed_materials.get(item.item_code): - precision = frappe.get_precision("Stock Entry Detail", "qty") - for d in backflushed_materials.get(item.item_code): - if d.get(item.warehouse) > 0: - if qty > req_qty: - qty = ( - (flt(qty, precision) - flt(d.get(item.warehouse), precision)) - / (flt(trans_qty, precision) - flt(produced_qty, precision)) - ) * flt(self.fg_completed_qty) - - d[item.warehouse] -= qty + qty = (flt(row.qty) * flt(self.fg_completed_qty)) / remaining_qty_to_produce + item = row.item_details if cint(frappe.get_cached_value("UOM", item.stock_uom, "must_be_whole_number")): qty = frappe.utils.ceil(qty) - if qty > 0: - self.add_to_stock_entry_detail( - { - item.item_code: { - "from_warehouse": item.warehouse, - "to_warehouse": "", - "qty": qty, - "item_name": item.item_name, - "description": item.description, - "stock_uom": item.stock_uom, - "expense_account": item.expense_account, - "cost_center": item.buying_cost_center, - "original_item": item.original_item, - } - } - ) + if row.batch_details: + for batch_no, batch_qty in row.batch_details.items(): + if qty <= 0 or batch_qty <= 0: + continue + + if batch_qty > qty: + batch_qty = qty + + item.batch_no = batch_no + self.update_item_in_stock_entry_detail(row, item, batch_qty) + + row.batch_details[batch_no] -= batch_qty + qty -= batch_qty + else: + self.update_item_in_stock_entry_detail(row, item, qty) + + def update_item_in_stock_entry_detail(self, row, item, qty) -> None: + ste_item_details = { + "from_warehouse": item.warehouse, + "to_warehouse": "", + "qty": qty, + "item_name": item.item_name, + "batch_no": item.batch_no, + "description": item.description, + "stock_uom": item.stock_uom, + "expense_account": item.expense_account, + "cost_center": item.buying_cost_center, + "original_item": item.original_item, + } + + if row.serial_nos: + serial_nos = row.serial_nos + if item.batch_no: + serial_nos = self.get_serial_nos_based_on_transferred_batch(item.batch_no, row.serial_nos) + + serial_nos = serial_nos[0 : cint(qty)] + ste_item_details["serial_no"] = "\n".join(serial_nos) + + # remove consumed serial nos from list + for sn in serial_nos: + row.serial_nos.remove(sn) + + self.add_to_stock_entry_detail({item.item_code: ste_item_details}) + + @staticmethod + def get_serial_nos_based_on_transferred_batch(batch_no, serial_nos) -> list: + serial_nos = frappe.get_all( + "Serial No", filters={"batch_no": batch_no, "name": ("in", serial_nos)}, order_by="creation" + ) + + return [d.name for d in serial_nos] def get_pending_raw_materials(self, backflush_based_on=None): """ @@ -2528,3 +2471,81 @@ def get_supplied_items(purchase_order): ) return supplied_item_details + + +def get_available_materials(work_order) -> dict: + data = get_stock_entry_data(work_order) + + available_materials = {} + for row in data: + key = (row.item_code, row.warehouse) + if row.purpose != "Material Transfer for Manufacture": + key = (row.item_code, row.s_warehouse) + + if key not in available_materials: + available_materials.setdefault( + key, + frappe._dict( + {"item_details": row, "batch_details": defaultdict(float), "qty": 0, "serial_nos": []} + ), + ) + + item_data = available_materials[key] + + if row.purpose == "Material Transfer for Manufacture": + item_data.qty += row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] += row.qty + + if row.serial_no: + item_data.serial_nos.extend(get_serial_nos(row.serial_no)) + item_data.serial_nos.sort() + else: + # Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture' + + item_data.qty -= row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] -= row.qty + + if row.serial_no: + for serial_no in get_serial_nos(row.serial_no): + item_data.serial_nos.remove(serial_no) + + return available_materials + + +def get_stock_entry_data(work_order): + stock_entry = frappe.qb.DocType("Stock Entry") + stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(stock_entry) + .from_(stock_entry_detail) + .select( + stock_entry_detail.item_name, + stock_entry_detail.original_item, + stock_entry_detail.item_code, + stock_entry_detail.qty, + (stock_entry_detail.t_warehouse).as_("warehouse"), + (stock_entry_detail.s_warehouse).as_("s_warehouse"), + stock_entry_detail.description, + stock_entry_detail.stock_uom, + stock_entry_detail.expense_account, + stock_entry_detail.cost_center, + stock_entry_detail.batch_no, + stock_entry_detail.serial_no, + stock_entry.purpose, + ) + .where( + (stock_entry.name == stock_entry_detail.parent) + & (stock_entry.work_order == work_order) + & (stock_entry.docstatus == 1) + & (stock_entry_detail.s_warehouse.isnotnull()) + & ( + stock_entry.purpose.isin( + ["Manufacture", "Material Consumption for Manufacture", "Material Transfer for Manufacture"] + ) + ) + ) + .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) + ).run(as_dict=1)