diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 943091487d2..ceb2e9d0ffd 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -297,7 +297,9 @@ class BOM(WebsiteGenerator): self.validate_scrap_items() self.set_default_uom() self.validate_semi_finished_goods() - self.validate_raw_materials_of_operation() + + if self.docstatus == 1: + self.validate_raw_materials_of_operation() def validate_semi_finished_goods(self): if not self.track_semi_finished_goods or not self.operations: @@ -333,7 +335,7 @@ class BOM(WebsiteGenerator): if row.bom_no: continue - operation_idx_with_no_rm[row.idx] = row.operation + operation_idx_with_no_rm[row.idx] = row for row in self.items: if row.operation_row_id and row.operation_row_id in operation_idx_with_no_rm: diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index aa872c13946..0bc6807bf2a 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -166,7 +166,9 @@ class JobCard(Document): self.validate_work_order() self.set_employees() - self.validate_semi_finished_goods() + + if self.docstatus == 1: + self.validate_semi_finished_goods() def validate_semi_finished_goods(self): if not self.track_semi_finished_goods: diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 8f8302b4a21..912131e2825 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1609,6 +1609,7 @@ class WorkOrder(Document): "item_code": row.item_code, "voucher_detail_no": row.name, "warehouse": row.source_warehouse, + "status": ("not in", ["Closed", "Cancelled", "Completed"]), }, pluck="name", ): @@ -1817,24 +1818,10 @@ class WorkOrder(Document): elif stock_entry.job_card: # Reserve the final product for the job card. finished_good = frappe.db.get_value("Job Card", stock_entry.job_card, "finished_good") + if finished_good == self.production_item: + return - for row in stock_entry.items: - if row.item_code == finished_good: - item_details = [ - frappe._dict( - { - "item_code": row.item_code, - "stock_qty": row.qty, - "stock_reserved_qty": 0, - "warehouse": row.t_warehouse, - "voucher_no": stock_entry.work_order, - "voucher_type": "Work Order", - "name": row.name, - "delivered_qty": 0, - } - ) - ] - break + item_details = self.get_items_to_reserve_for_job_card(stock_entry, finished_good) else: # Reserve the final product for the sales order. item_details = self.get_so_details() @@ -1888,6 +1875,53 @@ class WorkOrder(Document): return items + def get_items_to_reserve_for_job_card(self, stock_entry, finished_good): + item_details = [] + for row in stock_entry.items: + if row.item_code == finished_good: + name = frappe.db.get_value( + "Work Order Item", + {"item_code": finished_good, "parent": self.name}, + "name", + ) + + sres = frappe.get_all( + "Stock Reservation Entry", + fields=["reserved_qty"], + filters={ + "voucher_no": self.name, + "item_code": finished_good, + "voucher_detail_no": name, + "warehouse": row.t_warehouse, + "docstatus": 1, + "status": "Reserved", + }, + ) + + pending_qty = row.qty + for d in sres: + pending_qty -= d.reserved_qty + + if pending_qty > 0: + item_details = [ + frappe._dict( + { + "item_code": row.item_code, + "stock_qty": pending_qty, + "stock_reserved_qty": 0, + "warehouse": row.t_warehouse, + "voucher_no": stock_entry.work_order, + "voucher_type": "Work Order", + "name": name, + "delivered_qty": 0, + } + ) + ] + + break + + return item_details + def get_wo_details(self): doctype = frappe.qb.DocType("Work Order") child_doctype = frappe.qb.DocType("Work Order Item") diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py index b6b886365ca..207ba79bb73 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -2,10 +2,15 @@ # For license information, please see license.txt +from collections import defaultdict + import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import flt +from frappe.utils import cint, flt + +from erpnext.stock.serial_batch_bundle import SerialBatchCreation +from erpnext.stock.utils import get_combine_datetime class StockEntryType(Document): @@ -104,6 +109,10 @@ class ManufactureEntry: "Manufacturing Settings", "backflush_raw_materials_based_on" ) + available_serial_batches = frappe._dict({}) + if backflush_based_on != "BOM": + available_serial_batches = self.get_transferred_serial_batches() + for item_code, _dict in item_dict.items(): _dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse _dict.to_warehouse = "" @@ -118,9 +127,131 @@ class ManufactureEntry: ) _dict.qty = calculated_qty + self.update_available_serial_batches(_dict, available_serial_batches) self.stock_entry.add_to_stock_entry_detail(item_dict) + def parse_available_serial_batches(self, item_dict, available_serial_batches): + key = (item_dict.item_code, item_dict.from_warehouse) + if key not in available_serial_batches: + return [], {} + + _avl_dict = available_serial_batches[key] + + qty = item_dict.qty + serial_nos = [] + batches = frappe._dict() + + if _avl_dict.serial_nos: + serial_nos = _avl_dict.serial_nos[: cint(qty)] + qty -= len(serial_nos) + for sn in serial_nos: + _avl_dict.serial_nos.remove(sn) + + elif _avl_dict.batches: + batches = frappe._dict() + for batch_no, batch_qty in _avl_dict.batches.items(): + if qty <= 0: + break + if batch_qty <= qty: + batches[batch_no] = batch_qty + qty -= batch_qty + else: + batches[batch_no] = qty + qty = 0 + + for _used_batch_no in batches: + _avl_dict.batches[_used_batch_no] -= batches[_used_batch_no] + if _avl_dict.batches[_used_batch_no] <= 0: + del _avl_dict.batches[_used_batch_no] + + return serial_nos, batches + + def update_available_serial_batches(self, item_dict, available_serial_batches): + serial_nos, batches = self.parse_available_serial_batches(item_dict, available_serial_batches) + if serial_nos or batches: + sabb = SerialBatchCreation( + { + "item_code": item_dict.item_code, + "warehouse": item_dict.from_warehouse, + "posting_datetime": get_combine_datetime( + self.stock_entry.posting_date, self.stock_entry.posting_time + ), + "voucher_type": self.stock_entry.doctype, + "company": self.stock_entry.company, + "type_of_transaction": "Outward", + "qty": item_dict.qty, + "serial_nos": serial_nos, + "batches": batches, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle() + + item_dict.serial_and_batch_bundle = sabb.name + + def get_stock_entry_data(self): + stock_entry = frappe.qb.DocType("Stock Entry") + stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(stock_entry) + .inner_join(stock_entry_detail) + .on(stock_entry.name == stock_entry_detail.parent) + .select( + stock_entry_detail.item_code, + stock_entry_detail.qty, + stock_entry_detail.serial_and_batch_bundle, + stock_entry_detail.s_warehouse, + stock_entry_detail.t_warehouse, + stock_entry.purpose, + ) + .where( + (stock_entry.job_card == self.job_card) + & (stock_entry_detail.serial_and_batch_bundle.isnotnull()) + & (stock_entry.docstatus == 1) + & (stock_entry.purpose.isin(["Material Transfer for Manufacture", "Manufacture"])) + ) + .orderby(stock_entry.posting_date, stock_entry.posting_time) + ).run(as_dict=True) + + def get_transferred_serial_batches(self): + available_serial_batches = frappe._dict({}) + + stock_entry_data = self.get_stock_entry_data() + + for row in stock_entry_data: + warehouse = ( + row.t_warehouse if row.purpose == "Material Transfer for Manufacture" else row.s_warehouse + ) + key = (row.item_code, warehouse) + if key not in available_serial_batches: + available_serial_batches[key] = frappe._dict( + { + "batches": defaultdict(float), + "serial_nos": [], + } + ) + + _avl_dict = available_serial_batches[key] + + sabb_data = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": row.serial_and_batch_bundle}, + fields=["serial_no", "batch_no", "qty"], + ) + for entry in sabb_data: + if entry.serial_no: + if entry.qty > 0: + _avl_dict.serial_nos.append(entry.serial_no) + else: + _avl_dict.serial_nos.remove(entry.serial_no) + if entry.batch_no: + _avl_dict.batches[entry.batch_no] += flt(entry.qty) * ( + -1 if row.purpose == "Material Transfer for Manufacture" else 1 + ) + + return available_serial_batches + def get_items_from_job_card(self): item_dict = {} items = frappe.get_all(