diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index 789ca6c5ee..b5e780bcbe 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -328,8 +328,6 @@ class AssetCapitalization(StockController): { "item_code": self.target_item_code, "warehouse": self.target_warehouse, - "batch_no": self.target_batch_no, - "serial_no": self.target_serial_no, "actual_qty": flt(self.target_qty), "incoming_rate": flt(self.target_incoming_rate), }, diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index b55574fb4a..c064e5a914 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -5,7 +5,7 @@ import frappe from frappe import ValidationError, _, msgprint from frappe.contacts.doctype.address.address import get_address_display -from frappe.utils import cint, cstr, flt, getdate +from frappe.utils import cint, flt, getdate from frappe.utils.data import nowtime from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget @@ -497,7 +497,6 @@ class BuyingController(SubcontractingController): d, { "actual_qty": flt(pr_qty), - "serial_no": cstr(d.serial_no).strip(), "serial_and_batch_bundle": ( d.serial_and_batch_bundle if not self.is_internal_transfer() @@ -550,7 +549,6 @@ class BuyingController(SubcontractingController): { "warehouse": d.rejected_warehouse, "actual_qty": flt(d.rejected_qty) * flt(d.conversion_factor), - "serial_no": cstr(d.rejected_serial_no).strip(), "incoming_rate": 0.0, "serial_and_batch_bundle": d.rejected_serial_and_batch_bundle, }, diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 2048a42323..8c3bd4d75d 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -407,7 +407,6 @@ class StockController(AccountsController): else: bundle_doc.save(ignore_permissions=True) - print(bundle_doc.name) return bundle_doc.name def get_sl_entries(self, d, args): @@ -428,7 +427,6 @@ class StockController(AccountsController): ), "incoming_rate": 0, "company": self.company, - "serial_no": d.get("serial_no"), "project": d.get("project") or self.get("project"), "is_cancelled": 1 if self.docstatus == 2 else 0, } diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 0e666ffa7b..1418e5f939 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -8,7 +8,7 @@ from collections import defaultdict import frappe from frappe import _ from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, cstr, flt, get_link_to_form +from frappe.utils import cint, flt, get_link_to_form from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( @@ -768,9 +768,7 @@ class SubcontractingController(StockController): scr_qty = flt(item.qty) * flt(item.conversion_factor) if scr_qty: - sle = self.get_sl_entries( - item, {"actual_qty": flt(scr_qty), "serial_no": cstr(item.serial_no).strip()} - ) + sle = self.get_sl_entries(item, {"actual_qty": flt(scr_qty)}) rate_db_precision = 6 if cint(self.precision("rate", item)) <= 6 else 9 incoming_rate = flt(item.rate, rate_db_precision) sle.update( @@ -788,7 +786,6 @@ class SubcontractingController(StockController): { "warehouse": item.rejected_warehouse, "actual_qty": flt(item.rejected_qty) * flt(item.conversion_factor), - "serial_no": cstr(item.rejected_serial_no).strip(), "incoming_rate": 0.0, }, ) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index d83bd1dfd1..aecace673c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -42,7 +42,6 @@ "has_serial_no", "has_batch_no", "column_break_18", - "serial_no", "batch_size", "required_items_section", "materials_and_operations_tab", @@ -532,14 +531,6 @@ "label": "Has Batch No", "read_only": 1 }, - { - "depends_on": "has_serial_no", - "fieldname": "serial_no", - "fieldtype": "Small Text", - "label": "Serial Nos", - "no_copy": 1, - "read_only": 1 - }, { "default": "0", "depends_on": "has_batch_no", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index e30a302893..a5b8972017 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -17,6 +17,7 @@ from frappe.utils import ( get_datetime, get_link_to_form, getdate, + now, nowdate, time_diff_in_hours, ) @@ -32,11 +33,7 @@ from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings ) from erpnext.stock.doctype.batch.batch import make_batch from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life -from erpnext.stock.doctype.serial_no.serial_no import ( - clean_serial_no_string, - get_auto_serial_nos, - get_serial_nos, -) +from erpnext.stock.doctype.serial_no.serial_no import get_auto_serial_nos, get_serial_nos from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty from erpnext.stock.utils import get_bin, get_latest_stock_qty, validate_warehouse_company from erpnext.utilities.transaction_base import validate_uom_is_integer @@ -447,24 +444,53 @@ class WorkOrder(Document): frappe.delete_doc("Batch", row.name) def make_serial_nos(self, args): - self.serial_no = clean_serial_no_string(self.serial_no) - serial_no_series = frappe.get_cached_value("Item", self.production_item, "serial_no_series") - if serial_no_series: - self.serial_no = get_auto_serial_nos(serial_no_series, self.qty) + item_details = frappe.get_cached_value( + "Item", self.production_item, ["serial_no_series", "item_name", "description"], as_dict=1 + ) - if self.serial_no: - args.update({"serial_no": self.serial_no, "actual_qty": self.qty}) - # auto_make_serial_nos(args) + serial_nos = [] + if item_details.serial_no_series: + serial_nos = get_auto_serial_nos(item_details.serial_no_series, self.qty) - serial_nos_length = len(get_serial_nos(self.serial_no)) - if serial_nos_length != self.qty: - frappe.throw( - _("{0} Serial Numbers required for Item {1}. You have provided {2}.").format( - self.qty, self.production_item, serial_nos_length - ), - SerialNoQtyError, + if not serial_nos: + return + + fields = [ + "name", + "serial_no", + "creation", + "modified", + "owner", + "modified_by", + "company", + "item_code", + "item_name", + "description", + "status", + "work_order", + ] + + serial_nos_details = [] + for serial_no in serial_nos: + serial_nos_details.append( + ( + serial_no, + serial_no, + now(), + now(), + frappe.session.user, + frappe.session.user, + self.company, + self.production_item, + item_details.item_name, + item_details.description, + "Inactive", + self.name, + ) ) + frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details)) + def create_job_card(self): manufacturing_settings_doc = frappe.get_doc("Manufacturing Settings") @@ -1041,24 +1067,6 @@ class WorkOrder(Document): bom.set_bom_material_details() return bom - def update_batch_produced_qty(self, stock_entry_doc): - if not cint( - frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order") - ): - return - - for row in stock_entry_doc.items: - if row.batch_no and (row.is_finished_item or row.is_scrap_item): - qty = frappe.get_all( - "Stock Entry Detail", - filters={"batch_no": row.batch_no, "docstatus": 1}, - or_filters={"is_finished_item": 1, "is_scrap_item": 1}, - fields=["sum(qty)"], - as_list=1, - )[0][0] - - frappe.db.set_value("Batch", row.batch_no, "produced_qty", flt(qty)) - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 824691cafc..4969713e21 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -27,8 +27,8 @@ class SerialandBatchBundle(Document): self.validate_serial_nos_inventory() def before_save(self): - self.set_total_qty() self.set_is_outward() + self.set_total_qty() self.set_warehouse() self.set_incoming_rate() self.validate_qty_and_stock_value_difference() @@ -51,7 +51,9 @@ class SerialandBatchBundle(Document): ) for serial_no in serial_nos: - if serial_no_warehouse.get(serial_no) != self.warehouse: + if ( + not serial_no_warehouse.get(serial_no) or serial_no_warehouse.get(serial_no) != self.warehouse + ): frappe.throw( _(f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.") ) @@ -73,6 +75,9 @@ class SerialandBatchBundle(Document): if d.stock_value_difference and d.stock_value_difference > 0: d.stock_value_difference *= -1 + def get_serial_nos(self): + return [d.serial_no for d in self.ledgers if d.serial_no] + def set_incoming_rate_for_outward_transaction(self, row=None, save=False): sle = self.get_sle_for_outward_transaction(row) if self.has_serial_no: @@ -271,6 +276,11 @@ class SerialandBatchBundle(Document): def set_is_outward(self): for row in self.ledgers: + if self.type_of_transaction == "Outward" and row.qty > 0: + row.qty *= -1 + elif self.type_of_transaction == "Inward" and row.qty < 0: + row.qty *= -1 + row.is_outward = 1 if self.type_of_transaction == "Outward" else 0 @frappe.whitelist() diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 4c5156c066..5b4f41e926 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -9,10 +9,9 @@ import frappe from frappe import ValidationError, _ from frappe.model.naming import make_autoname from frappe.query_builder.functions import Coalesce -from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate, safe_json_loads +from frappe.utils import cint, cstr, getdate, nowdate, safe_json_loads from erpnext.controllers.stock_controller import StockController -from erpnext.stock.get_item_details import get_reserved_qty_for_so class SerialNoCannotCreateDirectError(ValidationError): @@ -108,384 +107,12 @@ class SerialNo(StockController): ) -def process_serial_no(sle): - item_det = get_item_details(sle.item_code) - validate_serial_no(sle, item_det) - - -def validate_serial_no(sle, item_det): - serial_nos = get_serial_nos(sle.serial_and_batch_bundle) if sle.serial_and_batch_bundle else [] - validate_material_transfer_entry(sle) - - if item_det.has_serial_no == 0: - if serial_nos: - frappe.throw( - _("Item {0} is not setup for Serial Nos. Column must be blank").format(sle.item_code), - SerialNoNotRequiredError, - ) - elif not sle.is_cancelled: - return - if serial_nos: - if cint(sle.actual_qty) != flt(sle.actual_qty): - frappe.throw( - _("Serial No {0} quantity {1} cannot be a fraction").format(sle.item_code, sle.actual_qty) - ) - - if len(serial_nos) and len(serial_nos) != abs(cint(sle.actual_qty)): - frappe.throw( - _("{0} Serial Numbers required for Item {1}. You have provided {2}.").format( - abs(sle.actual_qty), sle.item_code, len(serial_nos) - ), - SerialNoQtyError, - ) - - if len(serial_nos) != len(set(serial_nos)): - frappe.throw( - _("Duplicate Serial No entered for Item {0}").format(sle.item_code), SerialNoDuplicateError - ) - - for serial_no in serial_nos: - if frappe.db.exists("Serial No", serial_no): - sr = frappe.db.get_value( - "Serial No", - serial_no, - [ - "name", - "item_code", - "batch_no", - "sales_order", - "delivery_document_no", - "delivery_document_type", - "warehouse", - "purchase_document_type", - "purchase_document_no", - "company", - "status", - ], - as_dict=1, - ) - - if sr.item_code != sle.item_code: - if not allow_serial_nos_with_different_item(serial_no, sle): - frappe.throw( - _("Serial No {0} does not belong to Item {1}").format(serial_no, sle.item_code), - SerialNoItemError, - ) - - if cint(sle.actual_qty) > 0 and has_serial_no_exists(sr, sle): - doc_name = frappe.bold(get_link_to_form(sr.purchase_document_type, sr.purchase_document_no)) - frappe.throw( - _("Serial No {0} has already been received in the {1} #{2}").format( - frappe.bold(serial_no), sr.purchase_document_type, doc_name - ), - SerialNoDuplicateError, - ) - - if ( - sr.delivery_document_no - and sle.voucher_type not in ["Stock Entry", "Stock Reconciliation"] - and sle.voucher_type == sr.delivery_document_type - ): - return_against = frappe.db.get_value(sle.voucher_type, sle.voucher_no, "return_against") - if return_against and return_against != sr.delivery_document_no: - frappe.throw(_("Serial no {0} has been already returned").format(sr.name)) - - if cint(sle.actual_qty) < 0: - if sr.warehouse != sle.warehouse: - frappe.throw( - _("Serial No {0} does not belong to Warehouse {1}").format(serial_no, sle.warehouse), - SerialNoWarehouseError, - ) - - if not sr.purchase_document_no: - frappe.throw(_("Serial No {0} not in stock").format(serial_no), SerialNoNotExistsError) - - if sle.voucher_type in ("Delivery Note", "Sales Invoice"): - - if sr.batch_no and sr.batch_no != sle.batch_no: - frappe.throw( - _("Serial No {0} does not belong to Batch {1}").format(serial_no, sle.batch_no), - SerialNoBatchError, - ) - - if not sle.is_cancelled and not sr.warehouse: - frappe.throw( - _("Serial No {0} does not belong to any Warehouse").format(serial_no), - SerialNoWarehouseError, - ) - - # if Sales Order reference in Serial No validate the Delivery Note or Invoice is against the same - if sr.sales_order: - if sle.voucher_type == "Sales Invoice": - if not frappe.db.exists( - "Sales Invoice Item", - {"parent": sle.voucher_no, "item_code": sle.item_code, "sales_order": sr.sales_order}, - ): - frappe.throw( - _( - "Cannot deliver Serial No {0} of item {1} as it is reserved to fullfill Sales Order {2}" - ).format(sr.name, sle.item_code, sr.sales_order) - ) - elif sle.voucher_type == "Delivery Note": - if not frappe.db.exists( - "Delivery Note Item", - { - "parent": sle.voucher_no, - "item_code": sle.item_code, - "against_sales_order": sr.sales_order, - }, - ): - invoice = frappe.db.get_value( - "Delivery Note Item", - {"parent": sle.voucher_no, "item_code": sle.item_code}, - "against_sales_invoice", - ) - if not invoice or frappe.db.exists( - "Sales Invoice Item", - {"parent": invoice, "item_code": sle.item_code, "sales_order": sr.sales_order}, - ): - frappe.throw( - _( - "Cannot deliver Serial No {0} of item {1} as it is reserved to fullfill Sales Order {2}" - ).format(sr.name, sle.item_code, sr.sales_order) - ) - # if Sales Order reference in Delivery Note or Invoice validate SO reservations for item - if sle.voucher_type == "Sales Invoice": - sales_order = frappe.db.get_value( - "Sales Invoice Item", - {"parent": sle.voucher_no, "item_code": sle.item_code}, - "sales_order", - ) - if sales_order and get_reserved_qty_for_so(sales_order, sle.item_code): - validate_so_serial_no(sr, sales_order) - elif sle.voucher_type == "Delivery Note": - sales_order = frappe.get_value( - "Delivery Note Item", - {"parent": sle.voucher_no, "item_code": sle.item_code}, - "against_sales_order", - ) - if sales_order and get_reserved_qty_for_so(sales_order, sle.item_code): - validate_so_serial_no(sr, sales_order) - else: - sales_invoice = frappe.get_value( - "Delivery Note Item", - {"parent": sle.voucher_no, "item_code": sle.item_code}, - "against_sales_invoice", - ) - if sales_invoice: - sales_order = frappe.db.get_value( - "Sales Invoice Item", - {"parent": sales_invoice, "item_code": sle.item_code}, - "sales_order", - ) - if sales_order and get_reserved_qty_for_so(sales_order, sle.item_code): - validate_so_serial_no(sr, sales_order) - elif cint(sle.actual_qty) < 0: - # transfer out - frappe.throw(_("Serial No {0} not in stock").format(serial_no), SerialNoNotExistsError) - elif cint(sle.actual_qty) < 0 or not item_det.serial_no_series: - frappe.throw( - _("Serial Nos Required for Serialized Item {0}").format(sle.item_code), SerialNoRequiredError - ) - elif serial_nos: - return - # SLE is being cancelled and has serial nos - for serial_no in serial_nos: - check_serial_no_validity_on_cancel(serial_no, sle) - - -def check_serial_no_validity_on_cancel(serial_no, sle): - sr = frappe.db.get_value( - "Serial No", serial_no, ["name", "warehouse", "company", "status"], as_dict=1 - ) - sr_link = frappe.utils.get_link_to_form("Serial No", serial_no) - doc_link = frappe.utils.get_link_to_form(sle.voucher_type, sle.voucher_no) - actual_qty = cint(sle.actual_qty) - is_stock_reco = sle.voucher_type == "Stock Reconciliation" - msg = None - - if sr and (actual_qty < 0 or is_stock_reco) and (sr.warehouse and sr.warehouse != sle.warehouse): - # receipt(inward) is being cancelled - msg = _("Cannot cancel {0} {1} as Serial No {2} does not belong to the warehouse {3}").format( - sle.voucher_type, doc_link, sr_link, frappe.bold(sle.warehouse) - ) - elif sr and actual_qty > 0 and not is_stock_reco: - # delivery is being cancelled, check for warehouse. - if sr.warehouse: - # serial no is active in another warehouse/company. - msg = _("Cannot cancel {0} {1} as Serial No {2} is active in warehouse {3}").format( - sle.voucher_type, doc_link, sr_link, frappe.bold(sr.warehouse) - ) - elif sr.company != sle.company and sr.status == "Delivered": - # serial no is inactive (allowed) or delivered from another company (block). - msg = _("Cannot cancel {0} {1} as Serial No {2} does not belong to the company {3}").format( - sle.voucher_type, doc_link, sr_link, frappe.bold(sle.company) - ) - - if msg: - frappe.throw(msg, title=_("Cannot cancel")) - - -def validate_material_transfer_entry(sle_doc): - sle_doc.update({"skip_update_serial_no": False, "skip_serial_no_validaiton": False}) - - if ( - sle_doc.voucher_type == "Stock Entry" - and not sle_doc.is_cancelled - and frappe.get_cached_value("Stock Entry", sle_doc.voucher_no, "purpose") == "Material Transfer" - ): - if sle_doc.actual_qty < 0: - sle_doc.skip_update_serial_no = True - else: - sle_doc.skip_serial_no_validaiton = True - - -def validate_so_serial_no(sr, sales_order): - if not sr.sales_order or sr.sales_order != sales_order: - msg = _( - "Sales Order {0} has reservation for the item {1}, you can only deliver reserved {1} against {0}." - ).format(sales_order, sr.item_code) - - frappe.throw(_("""{0} Serial No {1} cannot be delivered""").format(msg, sr.name)) - - -def has_serial_no_exists(sn, sle): - if ( - sn.warehouse and not sle.skip_serial_no_validaiton and sle.voucher_type != "Stock Reconciliation" - ): - return True - - if sn.company != sle.company: - return False - - -def allow_serial_nos_with_different_item(sle_serial_no, sle): - """ - Allows same serial nos for raw materials and finished goods - in Manufacture / Repack type Stock Entry - """ - allow_serial_nos = False - if sle.voucher_type == "Stock Entry" and cint(sle.actual_qty) > 0: - stock_entry = frappe.get_cached_doc("Stock Entry", sle.voucher_no) - if stock_entry.purpose in ("Repack", "Manufacture"): - for d in stock_entry.get("items"): - if d.serial_no and (d.s_warehouse if not sle.is_cancelled else d.t_warehouse): - serial_nos = get_serial_nos(d.serial_no) - if sle_serial_no in serial_nos: - allow_serial_nos = True - - return allow_serial_nos - - -def update_warehouse_in_serial_no(sle, item_det): - serial_nos = get_serial_nos(sle.serial_and_batch_bundle) - serial_no_data = get_serial_nos_warehouse(sle.item_code, serial_nos) - - if not serial_no_data: - for serial_no in serial_nos: - frappe.db.set_value("Serial No", serial_no, "warehouse", None) - - else: - for row in serial_no_data: - if not row.serial_no: - continue - - warehouse = row.warehouse if row.actual_qty > 0 else None - frappe.db.set_value("Serial No", row.serial_no, "warehouse", warehouse) - - -def get_serial_nos_warehouse(item_code, serial_nos): - ledger_table = frappe.qb.DocType("Serial and Batch Ledger") - sle_table = frappe.qb.DocType("Stock Ledger Entry") - - return ( - frappe.qb.from_(ledger_table) - .inner_join(sle_table) - .on(ledger_table.parent == sle_table.serial_and_batch_bundle) - .select( - ledger_table.serial_no, - sle_table.actual_qty, - ledger_table.warehouse, - ) - .where( - (ledger_table.serial_no.isin(serial_nos)) - & (sle_table.is_cancelled == 0) - & (sle_table.item_code == item_code) - & (sle_table.serial_and_batch_bundle.isnotnull()) - ) - .orderby(sle_table.posting_date, order=frappe.qb.desc) - .orderby(sle_table.posting_time, order=frappe.qb.desc) - .orderby(sle_table.creation, order=frappe.qb.desc) - .groupby(ledger_table.serial_no) - ).run(as_dict=True) - - -def create_batch_for_serial_no(sle): - from erpnext.stock.doctype.batch.batch import make_batch - - return make_batch( - frappe._dict( - { - "item": sle.item_code, - "reference_doctype": sle.voucher_type, - "reference_name": sle.voucher_no, - } - ) - ) - - -def auto_create_serial_nos(sle, item_details) -> List[str]: - sr_nos = [] - serial_nos_details = [] - current_series = frappe.db.sql( - "select current from `tabSeries` where name = %s", item_details.serial_no_series - ) - - for i in range(cint(sle.actual_qty)): - serial_no = make_autoname(item_details.serial_no_series, "Serial No") - sr_nos.append(serial_no) - serial_nos_details.append( - ( - serial_no, - serial_no, - now(), - now(), - frappe.session.user, - frappe.session.user, - sle.warehouse, - sle.company, - sle.item_code, - item_details.item_name, - item_details.description, - ) - ) - - if serial_nos_details: - fields = [ - "name", - "serial_no", - "creation", - "modified", - "owner", - "modified_by", - "warehouse", - "company", - "item_code", - "item_name", - "description", - ] - - frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details)) - - return sr_nos - - -def get_auto_serial_nos(serial_no_series, qty): +def get_auto_serial_nos(serial_no_series, qty) -> List[str]: serial_nos = [] for i in range(cint(qty)): serial_nos.append(get_new_serial_number(serial_no_series)) - return "\n".join(serial_nos) + return serial_nos def get_new_serial_number(series): @@ -534,72 +161,6 @@ def clean_serial_no_string(serial_no: str) -> str: return "\n".join(serial_no_list) -def update_serial_nos_after_submit(controller, parentfield): - return - stock_ledger_entries = frappe.db.sql( - """select voucher_detail_no, serial_no, actual_qty, warehouse - from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s""", - (controller.doctype, controller.name), - as_dict=True, - ) - - if not stock_ledger_entries: - return - - for d in controller.get(parentfield): - if d.serial_no: - continue - - update_rejected_serial_nos = ( - True - if ( - controller.doctype in ("Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt") - and d.rejected_qty - ) - else False - ) - accepted_serial_nos_updated = False - - if controller.doctype == "Stock Entry": - warehouse = d.t_warehouse - qty = d.transfer_qty - elif controller.doctype in ("Sales Invoice", "Delivery Note"): - warehouse = d.warehouse - qty = d.stock_qty - else: - warehouse = d.warehouse - qty = ( - d.qty - if controller.doctype in ["Stock Reconciliation", "Subcontracting Receipt"] - else d.stock_qty - ) - for sle in stock_ledger_entries: - if sle.voucher_detail_no == d.name: - if ( - not accepted_serial_nos_updated - and qty - and abs(sle.actual_qty) == abs(qty) - and sle.warehouse == warehouse - and sle.serial_no != d.serial_no - ): - d.serial_no = sle.serial_no - frappe.db.set_value(d.doctype, d.name, "serial_no", sle.serial_no) - accepted_serial_nos_updated = True - if not update_rejected_serial_nos: - break - elif ( - update_rejected_serial_nos - and abs(sle.actual_qty) == d.rejected_qty - and sle.warehouse == d.rejected_warehouse - and sle.serial_no != d.rejected_serial_no - ): - d.rejected_serial_no = sle.serial_no - frappe.db.set_value(d.doctype, d.name, "rejected_serial_no", sle.serial_no) - update_rejected_serial_nos = False - if accepted_serial_nos_updated: - break - - def update_maintenance_status(): serial_nos = frappe.db.sql( """select name from `tabSerial No` where (amc_expiry_date<%s or diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 5e8aff373f..d71814bc7c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -4,6 +4,7 @@ import json from collections import defaultdict +from typing import List import frappe from frappe import _ @@ -37,8 +38,8 @@ from erpnext.stock.get_item_details import ( get_bin_details, get_conversion_factor, get_default_cost_center, - get_reserved_qty_for_so, ) +from erpnext.stock.serial_batch_bundle import get_empty_batches_based_work_order from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get_valuation_rate from erpnext.stock.utils import get_bin, get_incoming_rate @@ -203,13 +204,9 @@ class StockEntry(StockController): self.repost_future_sle_and_gle() self.update_cost_in_project() - self.validate_reserved_serial_no_consumption() self.update_transferred_qty() self.update_quality_inspection() - if self.work_order and self.purpose == "Manufacture": - self.update_so_in_serial_number() - if self.purpose == "Material Transfer" and self.add_to_transit: self.set_material_request_transfer_status("In Transit") if self.purpose == "Material Transfer" and self.outgoing_stock_entry: @@ -359,7 +356,6 @@ class StockEntry(StockController): def validate_item(self): stock_items = self.get_stock_items() - serialized_items = self.get_serialized_items() for item in self.get("items"): if flt(item.qty) and flt(item.qty) < 0: frappe.throw( @@ -401,16 +397,6 @@ class StockEntry(StockController): flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item) ) - # if ( - # self.purpose in ("Material Transfer", "Material Transfer for Manufacture") - # and not item.serial_and_batch_bundle - # and item.item_code in serialized_items - # ): - # frappe.throw( - # _("Row #{0}: Please specify Serial No for Item {1}").format(item.idx, item.item_code), - # frappe.MandatoryError, - # ) - def validate_qty(self): manufacture_purpose = ["Manufacture", "Material Consumption for Manufacture"] @@ -1352,7 +1338,6 @@ class StockEntry(StockController): pro_doc.run_method("update_work_order_qty") if self.purpose == "Manufacture": pro_doc.run_method("update_planned_qty") - pro_doc.update_batch_produced_qty(self) pro_doc.run_method("update_status") if not pro_doc.operations: @@ -1479,8 +1464,6 @@ class StockEntry(StockController): "ste_detail": d.name, "stock_uom": d.stock_uom, "conversion_factor": d.conversion_factor, - "serial_no": d.serial_no, - "batch_no": d.batch_no, }, ) @@ -1651,6 +1634,7 @@ class StockEntry(StockController): if ( self.work_order and self.pro_doc.has_batch_no + and not self.pro_doc.has_serial_no and cint( frappe.db.get_single_value( "Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True @@ -1662,42 +1646,34 @@ class StockEntry(StockController): self.add_finished_goods(args, item) def set_batchwise_finished_goods(self, args, item): - filters = { - "reference_name": self.pro_doc.name, - "reference_doctype": self.pro_doc.doctype, - "qty_to_produce": (">", 0), - "batch_qty": ("=", 0), - } + batches = get_empty_batches_based_work_order(self.work_order, self.pro_doc.production_item) - fields = ["qty_to_produce as qty", "produced_qty", "name"] - - data = frappe.get_all("Batch", filters=filters, fields=fields, order_by="creation asc") - - if not data: + if not batches: self.add_finished_goods(args, item) else: - self.add_batchwise_finished_good(data, args, item) + self.add_batchwise_finished_good(batches, args, item) - def add_batchwise_finished_good(self, data, args, item): + def add_batchwise_finished_good(self, batches, args, item): qty = flt(self.fg_completed_qty) + row = frappe._dict({"batches_to_be_consume": defaultdict(float)}) - for row in data: - batch_qty = flt(row.qty) - flt(row.produced_qty) - if not batch_qty: - continue + self.update_batches_to_be_consume(batches, row, qty) - if qty <= 0: - break + if not row.batches_to_be_consume: + return - fg_qty = batch_qty - if batch_qty >= qty: - fg_qty = qty + id = create_serial_and_batch_bundle( + row, + frappe._dict( + { + "item_code": self.pro_doc.production_item, + "warehouse": args.get("to_warehouse"), + } + ), + ) - qty -= batch_qty - args["qty"] = fg_qty - args["batch_no"] = row.name - - self.add_finished_goods(args, item) + args["serial_and_batch_bundle"] = id + self.add_finished_goods(args, item) def add_finished_goods(self, args, item): self.add_to_stock_entry_detail({item.name: args}, bom_no=self.bom_no) @@ -1902,27 +1878,8 @@ class StockEntry(StockController): if row.batch_details: row.batches_to_be_consume = defaultdict(float) - batches = sorted(row.batch_details.items(), key=lambda x: x[0]) - qty_to_be_consumed = qty - for batch_no, batch_qty in batches: - if qty_to_be_consumed <= 0 or batch_qty <= 0: - continue - - if batch_qty > qty_to_be_consumed: - batch_qty = qty_to_be_consumed - - row.batches_to_be_consume[batch_no] += batch_qty - - if batch_no and row.serial_nos: - serial_nos = self.get_serial_nos_based_on_transferred_batch(batch_no, row.serial_nos) - serial_nos = serial_nos[0 : cint(batch_qty)] - - # remove consumed serial nos from list - for sn in serial_nos: - row.serial_nos.remove(sn) - - row.batch_details[batch_no] -= batch_qty - qty_to_be_consumed -= batch_qty + batches = row.batch_details + self.update_batches_to_be_consume(batches, row, qty) elif row.serial_nos: serial_nos = row.serial_nos[0 : cint(qty)] @@ -1930,6 +1887,32 @@ class StockEntry(StockController): self.update_item_in_stock_entry_detail(row, item, qty) + def update_batches_to_be_consume(self, batches, row, qty): + qty_to_be_consumed = qty + batches = sorted(batches.items(), key=lambda x: x[0]) + + for batch_no, batch_qty in batches: + if qty_to_be_consumed <= 0 or batch_qty <= 0: + continue + + if batch_qty > qty_to_be_consumed: + batch_qty = qty_to_be_consumed + + row.batches_to_be_consume[batch_no] += batch_qty + + if batch_no and row.serial_nos: + serial_nos = self.get_serial_nos_based_on_transferred_batch(batch_no, row.serial_nos) + serial_nos = serial_nos[0 : cint(batch_qty)] + + # remove consumed serial nos from list + for sn in serial_nos: + row.serial_nos.remove(sn) + + if "batch_details" in row: + row.batch_details[batch_no] -= batch_qty + + qty_to_be_consumed -= batch_qty + def update_item_in_stock_entry_detail(self, row, item, qty) -> None: if not qty: return @@ -1939,7 +1922,7 @@ class StockEntry(StockController): "to_warehouse": "", "qty": qty, "item_name": item.item_name, - "serial_and_batch_bundle": create_serial_and_batch_bundle(row, item), + "serial_and_batch_bundle": create_serial_and_batch_bundle(row, item, "Outward"), "description": item.description, "stock_uom": item.stock_uom, "expense_account": item.expense_account, @@ -2099,8 +2082,6 @@ class StockEntry(StockController): "expense_account", "description", "item_name", - "serial_no", - "batch_no", "serial_and_batch_bundle", "allow_zero_valuation_rate", ]: @@ -2210,42 +2191,6 @@ class StockEntry(StockController): stock_bin = get_bin(item_code, reserve_warehouse) stock_bin.update_reserved_qty_for_sub_contracting() - def update_so_in_serial_number(self): - so_name, item_code = frappe.db.get_value( - "Work Order", self.work_order, ["sales_order", "production_item"] - ) - if so_name and item_code: - qty_to_reserve = get_reserved_qty_for_so(so_name, item_code) - if qty_to_reserve: - reserved_qty = frappe.db.sql( - """select count(name) from `tabSerial No` where item_code=%s and - sales_order=%s""", - (item_code, so_name), - ) - if reserved_qty and reserved_qty[0][0]: - qty_to_reserve -= reserved_qty[0][0] - if qty_to_reserve > 0: - for item in self.items: - has_serial_no = frappe.get_cached_value("Item", item.item_code, "has_serial_no") - if item.item_code == item_code and has_serial_no: - serial_nos = (item.serial_no).split("\n") - for serial_no in serial_nos: - if qty_to_reserve > 0: - frappe.db.set_value("Serial No", serial_no, "sales_order", so_name) - qty_to_reserve -= 1 - - def validate_reserved_serial_no_consumption(self): - for item in self.items: - if item.s_warehouse and not item.t_warehouse and item.serial_no: - for sr in get_serial_nos(item.serial_no): - sales_order = frappe.db.get_value("Serial No", sr, "sales_order") - if sales_order: - msg = _( - "(Serial No: {0}) cannot be consumed as it's reserverd to fullfill Sales Order {1}." - ).format(sr, sales_order) - - frappe.throw(_("Item {0} {1}").format(item.item_code, msg)) - def update_transferred_qty(self): if self.purpose == "Material Transfer" and self.outgoing_stock_entry: stock_entries = {} @@ -2338,40 +2283,48 @@ class StockEntry(StockController): frappe.db.set_value("Material Request", material_request, "transfer_status", status) def set_serial_no_batch_for_finished_good(self): - serial_nos = [] - if self.pro_doc.serial_no: - serial_nos = self.get_serial_nos_for_fg() or [] + if not ( + (self.pro_doc.has_serial_no or self.pro_doc.has_batch_no) + and frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order") + ): + return - for row in self.items: - if row.is_finished_item and row.item_code == self.pro_doc.production_item: + for d in self.items: + if d.is_finished_item and d.item_code == self.pro_doc.production_item: + serial_nos = self.get_available_serial_nos() if serial_nos: - row.serial_no = "\n".join(serial_nos[0 : cint(row.qty)]) + row = frappe._dict({"serial_nos": serial_nos[0 : cint(d.qty)]}) - def get_serial_nos_for_fg(self): - fields = [ - "`tabStock Entry`.`name`", - "`tabStock Entry Detail`.`qty`", - "`tabStock Entry Detail`.`serial_no`", - "`tabStock Entry Detail`.`batch_no`", - ] + id = create_serial_and_batch_bundle( + row, + frappe._dict( + { + "item_code": d.item_code, + "warehouse": d.t_warehouse, + } + ), + ) - filters = [ - ["Stock Entry", "work_order", "=", self.work_order], - ["Stock Entry", "purpose", "=", "Manufacture"], - ["Stock Entry", "docstatus", "<", 2], - ["Stock Entry Detail", "item_code", "=", self.pro_doc.production_item], - ] + d.serial_and_batch_bundle = id - stock_entries = frappe.get_all("Stock Entry", fields=fields, filters=filters) - return self.get_available_serial_nos(stock_entries) + def get_available_serial_nos(self) -> List[str]: + serial_nos = [] + data = frappe.get_all( + "Serial No", + filters={ + "item_code": self.pro_doc.production_item, + "warehouse": ("is", "not set"), + "status": "Inactive", + "work_order": self.pro_doc.name, + }, + fields=["name"], + order_by="creation asc", + ) - def get_available_serial_nos(self, stock_entries): - used_serial_nos = [] - for row in stock_entries: - if row.serial_no: - used_serial_nos.extend(get_serial_nos(row.serial_no)) + for row in data: + serial_nos.append(row.name) - return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos))) + return serial_nos def update_subcontracting_order_status(self): if self.subcontracting_order and self.purpose in ["Send to Subcontractor", "Material Transfer"]: @@ -2847,14 +2800,24 @@ def get_stock_entry_data(work_order): return data -def create_serial_and_batch_bundle(row, child): +def create_serial_and_batch_bundle(row, child, type_of_transaction=None): + item_details = frappe.get_cached_value( + "Item", child.item_code, ["has_serial_no", "has_batch_no"], as_dict=1 + ) + + if not (item_details.has_serial_no or item_details.has_batch_no): + return + + if not type_of_transaction: + type_of_transaction = "Inward" + doc = frappe.get_doc( { "doctype": "Serial and Batch Bundle", "voucher_type": "Stock Entry", "item_code": child.item_code, "warehouse": child.warehouse, - "type_of_transaction": "Outward", + "type_of_transaction": type_of_transaction, } ) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 3b01287ab6..56802d951e 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -127,8 +127,6 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru out.update(data) - update_stock(args, out) - if args.transaction_date and item.lead_time_days: out.schedule_date = out.lead_time_date = add_days(args.transaction_date, item.lead_time_days) @@ -150,28 +148,6 @@ def remove_standard_fields(details): return details -def update_stock(args, out): - if ( - ( - args.get("doctype") == "Delivery Note" - or (args.get("doctype") == "Sales Invoice" and args.get("update_stock")) - ) - and out.warehouse - and out.stock_qty > 0 - ): - if out.has_serial_no and args.get("batch_no"): - reserved_so = get_so_reservation_for_item(args) - out.batch_no = args.get("batch_no") - out.serial_no = get_serial_no(out, args.serial_no, sales_order=reserved_so) - - elif out.has_serial_no: - reserved_so = get_so_reservation_for_item(args) - out.serial_no = get_serial_no(out, args.serial_no, sales_order=reserved_so) - - if not out.serial_no: - out.pop("serial_no", None) - - def set_valuation_rate(out, args): if frappe.db.exists("Product Bundle", args.item_code, cache=True): valuation_rate = 0.0 @@ -1490,41 +1466,3 @@ def get_blanket_order_details(args): blanket_order_details = blanket_order_details[0] if blanket_order_details else "" return blanket_order_details - - -def get_so_reservation_for_item(args): - reserved_so = None - if args.get("against_sales_order"): - if get_reserved_qty_for_so(args.get("against_sales_order"), args.get("item_code")): - reserved_so = args.get("against_sales_order") - elif args.get("against_sales_invoice"): - sales_order = frappe.db.get_all( - "Sales Invoice Item", - filters={ - "parent": args.get("against_sales_invoice"), - "item_code": args.get("item_code"), - "docstatus": 1, - }, - fields="sales_order", - ) - if sales_order and sales_order[0]: - if get_reserved_qty_for_so(sales_order[0].sales_order, args.get("item_code")): - reserved_so = sales_order[0] - elif args.get("sales_order"): - if get_reserved_qty_for_so(args.get("sales_order"), args.get("item_code")): - reserved_so = args.get("sales_order") - return reserved_so - - -def get_reserved_qty_for_so(sales_order, item_code): - reserved_qty = frappe.db.get_value( - "Sales Order Item", - filters={ - "parent": sales_order, - "item_code": item_code, - "ensure_delivery_based_on_produced_serial_no": 1, - }, - fieldname="sum(qty)", - ) - - return reserved_qty or 0 diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 2b88e8b8e4..e3752233a4 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -586,3 +586,62 @@ class BatchNoBundleValuation(DeprecatedBatchNoValuation): def get_incoming_rate(self): return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty)) + + +def get_empty_batches_based_work_order(work_order, item_code): + batches = get_batches_from_work_order(work_order) + if not batches: + return batches + + entries = get_batches_from_stock_entries(work_order) + if not entries: + return batches + + ids = [d.serial_and_batch_bundle for d in entries if d.serial_and_batch_bundle] + if ids: + set_batch_details_from_package(ids, batches) + + # Will be deprecated in v16 + for d in entries: + if not d.batch_no: + continue + + batches[d.batch_no] -= d.qty + + return batches + + +def get_batches_from_work_order(work_order): + return frappe._dict( + frappe.get_all( + "Batch", fields=["name", "qty_to_produce"], filters={"reference_name": work_order}, as_list=1 + ) + ) + + +def get_batches_from_stock_entries(work_order): + entries = frappe.get_all( + "Stock Entry", + filters={"work_order": work_order, "docstatus": 1, "purpose": "Manufacture"}, + fields=["name"], + ) + + return frappe.get_all( + "Stock Entry Detail", + fields=["batch_no", "qty", "serial_and_batch_bundle"], + filters={ + "parent": ("in", [d.name for d in entries]), + "is_finished_item": 1, + }, + ) + + +def set_batch_details_from_package(ids, batches): + entries = frappe.get_all( + "Serial and Batch Ledger", + filters={"parent": ("in", ids), "is_outward": 0}, + fields=["batch_no", "qty"], + ) + + for d in entries: + batches[d.batch_no] -= d.qty