From 333c62de72b1e47a15285b463ec6715cbf46bc40 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 6 Jun 2022 01:19:45 +0530 Subject: [PATCH 1/6] fix: transferred batches are not fetched while making Manufacture stock entry --- .../stock/doctype/stock_entry/stock_entry.py | 223 ++++++++++-------- 1 file changed, 120 insertions(+), 103 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index f1df54dd6a..293c2e54f5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1656,118 +1656,58 @@ 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 - """, + 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, - ) - - 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"], + for key, row in available_materials.items(): + qty = (flt(row.qty) * flt(self.fg_completed_qty)) / ( + flt(wo_data.trans_qty) - flt(wo_data.produced_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 - + 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: + 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): + 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 item.serial_no: + ste_item_details["serial_no"] = "\n".join(item.serial_no[0 : cint(qty)]) + + self.add_to_stock_entry_detail({item.item_code: ste_item_details}) def get_pending_raw_materials(self, backflush_based_on=None): """ @@ -2521,3 +2461,80 @@ 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)) + 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, debug=1) From d94ff3ede8ca1d57b1ad869a31bbc6bf93fa8a72 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 6 Jun 2022 16:07:17 +0530 Subject: [PATCH 2/6] test: test cases to cover batch, serialized raw materials --- .../doctype/work_order/test_work_order.py | 277 +++++++++++++++++- .../stock/doctype/stock_entry/stock_entry.py | 56 ++-- 2 files changed, 303 insertions(+), 30 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 2aba48231b..bd19dec504 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() @@ -518,6 +522,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}) @@ -1062,7 +1068,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) @@ -1070,21 +1076,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: @@ -1194,6 +1200,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 293c2e54f5..08ce83b707 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,7 +1639,7 @@ class StockEntry(StockController): } ) - def get_transfered_raw_materials(self): + def add_transfered_raw_materials_in_items(self) -> None: available_materials = get_available_materials(self.work_order) wo_data = frappe.db.get_value( @@ -1666,9 +1650,11 @@ class StockEntry(StockController): ) for key, row in available_materials.items(): - qty = (flt(row.qty) * flt(self.fg_completed_qty)) / ( - flt(wo_data.trans_qty) - flt(wo_data.produced_qty) - ) + remaining_qty_to_produce = flt(wo_data.trans_qty) - flt(wo_data.produced_qty) + if remaining_qty_to_produce <= 0: + continue + + 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")): @@ -1676,7 +1662,7 @@ class StockEntry(StockController): if row.batch_details: for batch_no, batch_qty in row.batch_details.items(): - if qty <= 0: + if qty <= 0 or batch_qty <= 0: continue if batch_qty > qty: @@ -1690,7 +1676,7 @@ class StockEntry(StockController): else: self.update_item_in_stock_entry_detail(row, item, qty) - def update_item_in_stock_entry_detail(self, row, item, qty): + def update_item_in_stock_entry_detail(self, row, item, qty) -> None: ste_item_details = { "from_warehouse": item.warehouse, "to_warehouse": "", @@ -1704,11 +1690,28 @@ class StockEntry(StockController): "original_item": item.original_item, } - if item.serial_no: - ste_item_details["serial_no"] = "\n".join(item.serial_no[0 : cint(qty)]) + 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): """ issue (item quantity) that is pending to issue or desire to transfer, @@ -2489,6 +2492,7 @@ def get_available_materials(work_order) -> dict: 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' @@ -2537,4 +2541,4 @@ def get_stock_entry_data(work_order): ) ) .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) - ).run(as_dict=1, debug=1) + ).run(as_dict=1) From 0320b59ea615b372c9d627db54d272607b104878 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 20 Jun 2022 16:25:34 +0530 Subject: [PATCH 3/6] chore: Clear Progress section for completed logs & `on_submit` UX - Delete `BOM Update Batch` table on 'Completed' log, to save space - Hide Progress section on 'Completed' log - Enqueue `on_submit` for 'Update Cost' job, getting leaf boms could take time for huge DBs. Users have to wait for screen to unfreeze. - Add error handling to `process_boms_cost_level_wise` (Called via cron job and on submit, both in background) --- .../bom_update_log/bom_update_log.json | 10 ++-- .../doctype/bom_update_log/bom_update_log.py | 59 +++++++++++-------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json index c32e383b08..a926e69ee6 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json @@ -7,11 +7,11 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "current_bom", - "new_bom", - "column_break_3", "update_type", "status", + "column_break_3", + "current_bom", + "new_bom", "error_log", "progress_section", "current_level", @@ -37,6 +37,7 @@ "options": "BOM" }, { + "depends_on": "eval:doc.update_type === \"Replace BOM\"", "fieldname": "column_break_3", "fieldtype": "Column Break" }, @@ -87,6 +88,7 @@ "options": "BOM Update Batch" }, { + "depends_on": "eval:doc.status !== \"Completed\"", "fieldname": "current_level", "fieldtype": "Int", "label": "Current Level" @@ -96,7 +98,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-06-06 15:15:23.883251", + "modified": "2022-06-20 15:43:55.696388", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Update Log", diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index 9c9c24044a..af5ff8e1c2 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -77,7 +77,11 @@ class BOMUpdateLog(Document): now=frappe.flags.in_test, ) else: - process_boms_cost_level_wise(self) + frappe.enqueue( + method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.process_boms_cost_level_wise", + update_doc=self, + now=frappe.flags.in_test, + ) def run_replace_bom_job( @@ -112,28 +116,31 @@ def process_boms_cost_level_wise( current_boms = {} values = {} - if update_doc.status == "Queued": - # First level yet to process. On Submit. - current_level = 0 - current_boms = get_leaf_boms() - values = { - "processed_boms": json.dumps({}), - "status": "In Progress", - "current_level": current_level, - } - else: - # Resume next level. via Cron Job. - if not parent_boms: - return + try: + if update_doc.status == "Queued": + # First level yet to process. On Submit. + current_level = 0 + current_boms = get_leaf_boms() + values = { + "processed_boms": json.dumps({}), + "status": "In Progress", + "current_level": current_level, + } + else: + # Resume next level. via Cron Job. + if not parent_boms: + return - current_level = cint(update_doc.current_level) + 1 + current_level = cint(update_doc.current_level) + 1 - # Process the next level BOMs. Stage parents as current BOMs. - current_boms = parent_boms.copy() - values = {"current_level": current_level} + # Process the next level BOMs. Stage parents as current BOMs. + current_boms = parent_boms.copy() + values = {"current_level": current_level} - set_values_in_log(update_doc.name, values, commit=True) - queue_bom_cost_jobs(current_boms, update_doc, current_level) + set_values_in_log(update_doc.name, values, commit=True) + queue_bom_cost_jobs(current_boms, update_doc, current_level) + except Exception: + handle_exception(update_doc) def queue_bom_cost_jobs( @@ -199,16 +206,22 @@ def resume_bom_cost_update_jobs(): current_boms, processed_boms = get_processed_current_boms(log, bom_batches) parent_boms = get_next_higher_level_boms(child_boms=current_boms, processed_boms=processed_boms) - # Unset processed BOMs if log is complete, it is used for next level BOMs + # Unset processed BOMs (it is used for next level BOMs) & change status if log is complete + status = "Completed" if not parent_boms else "In Progress" + processed_boms = json.dumps([] if not parent_boms else processed_boms) set_values_in_log( log.name, values={ - "processed_boms": json.dumps([] if not parent_boms else processed_boms), - "status": "Completed" if not parent_boms else "In Progress", + "processed_boms": processed_boms, + "status": status, }, commit=True, ) + # clear progress section + if status == "Completed": + frappe.db.delete("BOM Update Batch", {"parent": log.name}) + if parent_boms: # there is a next level to process process_boms_cost_level_wise( update_doc=frappe.get_doc("BOM Update Log", log.name), parent_boms=parent_boms From 4cf2225a29c0d1c1a1967de20c78b411f055bf53 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 21 Jun 2022 14:03:05 +0530 Subject: [PATCH 4/6] chore: Implement Log clearing interface in BOM Update Log - Implement Log clearing interface in BOM Update Log - Add additional info in sidebar: Log clearing only happens for 'Update Cost' type logs - 'Replace BOM' logs have important info and is used in BOM timeline, so we'll let users decide if they wanna keep or discard it --- .../doctype/bom_update_log/bom_update_log.py | 13 ++++++++++++ .../bom_update_log/bom_update_log_list.js | 21 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index af5ff8e1c2..c3f52d4583 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now from frappe.utils import cint, cstr from erpnext.manufacturing.doctype.bom_update_log.bom_updation_utils import ( @@ -22,6 +24,17 @@ class BOMMissingError(frappe.ValidationError): class BOMUpdateLog(Document): + @staticmethod + def clear_old_logs(days=None): + days = days or 90 + table = DocType("BOM Update Log") + frappe.db.delete( + table, + filters=( + (table.modified < (Now() - Interval(days=days))) & (table.update_type == "Update Cost") + ), + ) + def validate(self): if self.update_type == "Replace BOM": self.validate_boms_are_specified() diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js index e39b5637c7..bc709d8fc4 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js @@ -1,6 +1,6 @@ frappe.listview_settings['BOM Update Log'] = { add_fields: ["status"], - get_indicator: function(doc) { + get_indicator: (doc) => { let status_map = { "Queued": "orange", "In Progress": "blue", @@ -9,5 +9,22 @@ frappe.listview_settings['BOM Update Log'] = { }; return [__(doc.status), status_map[doc.status], "status,=," + doc.status]; - } + }, + onload: () => { + if (!frappe.model.can_write("Log Settings")) { + return; + } + + let sidebar_entry = $( + '' + ).appendTo(cur_list.page.sidebar); + let message = __("Note: Automatic log deletion only applies to logs of type Update Cost"); + $(`
${message}
`).appendTo(sidebar_entry); + + frappe.require("logtypes.bundle.js", () => { + frappe.utils.logtypes.show_log_retention_message(cur_list.doctype); + }); + + + }, }; \ No newline at end of file From 5826b7b0718a1838746eb9755bb001308ed9a642 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 21 Jun 2022 19:50:50 +0530 Subject: [PATCH 5/6] fix: identify empty values "" in against_voucher columns --- erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py index 1e0d20d059..e15aa4a1f4 100644 --- a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py +++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py @@ -1,6 +1,6 @@ import frappe from frappe import qb -from frappe.query_builder import Case +from frappe.query_builder import Case, CustomFunction from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import IfNull @@ -87,6 +87,7 @@ def execute(): gl = qb.DocType("GL Entry") account = qb.DocType("Account") + ifelse = CustomFunction("IF", ["condition", "then", "else"]) gl_entries = ( qb.from_(gl) @@ -96,8 +97,12 @@ def execute(): gl.star, ConstantColumn(1).as_("docstatus"), account.account_type.as_("account_type"), - IfNull(gl.against_voucher_type, gl.voucher_type).as_("against_voucher_type"), - IfNull(gl.against_voucher, gl.voucher_no).as_("against_voucher_no"), + IfNull( + ifelse(gl.against_voucher_type == "", None, gl.against_voucher_type), gl.voucher_type + ).as_("against_voucher_type"), + IfNull(ifelse(gl.against_voucher == "", None, gl.against_voucher), gl.voucher_no).as_( + "against_voucher_no" + ), # convert debit/credit to amount Case() .when(account.account_type == "Receivable", gl.debit - gl.credit) From 8b1ff96e30e88f6a8c9dabed097efeac310645c1 Mon Sep 17 00:00:00 2001 From: hrzzz Date: Tue, 21 Jun 2022 15:10:19 -0300 Subject: [PATCH 6/6] fix: translation for filter status on report --- .../report/purchase_order_analysis/purchase_order_analysis.js | 1 + .../selling/report/sales_order_analysis/sales_order_analysis.js | 1 + 2 files changed, 2 insertions(+) diff --git a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js index ca3be03da6..721e54e46f 100644 --- a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js +++ b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.js @@ -59,6 +59,7 @@ frappe.query_reports["Purchase Order Analysis"] = { for (let option of status){ options.push({ "value": option, + "label": __(option), "description": "" }) } diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js index 76a5bb51ca..91748bc7be 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.js @@ -55,6 +55,7 @@ frappe.query_reports["Sales Order Analysis"] = { for (let option of status){ options.push({ "value": option, + "label": __(option), "description": "" }) }