diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index befde71775a..6156abad312 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -7,7 +7,7 @@ from typing import List, Tuple import frappe from frappe import _ -from frappe.utils import cint, cstr, flt, get_link_to_form, getdate +from frappe.utils import cint, flt, get_link_to_form, getdate import erpnext from erpnext.accounts.general_ledger import ( @@ -328,26 +328,49 @@ class StockController(AccountsController): def make_batches(self, warehouse_field): """Create batches if required. Called before submit""" for d in self.items: - if d.get(warehouse_field) and not d.batch_no: + if d.get(warehouse_field) and not d.serial_and_batch_bundle: has_batch_no, create_new_batch = frappe.get_cached_value( "Item", d.item_code, ["has_batch_no", "create_new_batch"] ) if has_batch_no and create_new_batch: - d.batch_no = ( + batch_no = ( frappe.get_doc( - dict( - doctype="Batch", - item=d.item_code, - supplier=getattr(self, "supplier", None), - reference_doctype=self.doctype, - reference_name=self.name, - ) + dict(doctype="Batch", item=d.item_code, supplier=getattr(self, "supplier", None)) ) .insert() .name ) + d.serial_and_batch_bundle = ( + frappe.get_doc( + { + "doctype": "Serial and Batch Bundle", + "item_code": d.item_code, + "voucher_type": self.doctype, + "voucher_no": self.name, + "ledgers": [ + { + "batch_no": batch_no, + "qty": d.qty, + "warehouse": d.get(warehouse_field), + } + ], + } + ) + .submit() + .name + ) + + frappe.db.set_value( + "Batch", + batch_no, + { + "reference_doctype": "Serial and Batch Bundle", + "reference_name": d.serial_and_batch_bundle, + }, + ) + def check_expense_account(self, item): if not item.get("expense_account"): msg = _("Please set an Expense Account in the Items table") @@ -387,27 +410,20 @@ class StockController(AccountsController): ) def delete_auto_created_batches(self): - for d in self.items: - if not d.batch_no: - continue + for row in self.items: + if row.serial_and_batch_bundle: + frappe.db.set_value( + "Serial and Batch Bundle", row.serial_and_batch_bundle, {"is_cancelled": 1} + ) - frappe.db.set_value( - "Serial No", {"batch_no": d.batch_no, "status": "Inactive"}, "batch_no", None - ) - - d.batch_no = None - d.db_set("batch_no", None) - - for data in frappe.get_all( - "Batch", {"reference_name": self.name, "reference_doctype": self.doctype} - ): - frappe.delete_doc("Batch", data.name) + row.db_set("serial_and_batch_bundle", None) def get_sl_entries(self, d, args): sl_dict = frappe._dict( { "item_code": d.get("item_code", None), "warehouse": d.get("warehouse", None), + "serial_and_batch_bundle": d.get("serial_and_batch_bundle"), "posting_date": self.posting_date, "posting_time": self.posting_time, "fiscal_year": get_fiscal_year(self.posting_date, company=self.company)[0], @@ -420,7 +436,6 @@ class StockController(AccountsController): ), "incoming_rate": 0, "company": self.company, - "batch_no": cstr(d.get("batch_no")).strip(), "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/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index b0e08cc6f26..e37a9b735ba 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -341,10 +341,36 @@ erpnext.buying.BuyingController = class BuyingController extends erpnext.Transac } frappe.throw(msg); } - }); - - } + } + ); } + } + + update_serial_batch_bundle(doc, cdt, cdn) { + let item = locals[cdt][cdn]; + let me = this; + let path = "assets/erpnext/js/utils/serial_no_batch_selector.js"; + + frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]) + .then((r) => { + if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) { + item.has_serial_no = r.message.has_serial_no; + item.has_batch_no = r.message.has_batch_no; + + frappe.require(path, function() { + new erpnext.SerialNoBatchBundleUpdate( + me.frm, item, (r) => { + if (r) { + me.frm.refresh_fields(); + frappe.model.set_value(cdt, cdn, + "serial_and_batch_bundle", r.name); + } + } + ); + }); + } + }); + } }; cur_frm.add_fetch('project', 'cost_center', 'cost_center'); diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 96ff44e0e5b..b4676c12077 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -119,9 +119,14 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } }); - if(this.frm.fields_dict["items"].grid.get_field('batch_no')) { - this.frm.set_query("batch_no", "items", function(doc, cdt, cdn) { - return me.set_query_for_batch(doc, cdt, cdn); + if(this.frm.fields_dict["items"].grid.get_field('serial_and_batch_bundle')) { + this.frm.set_query("serial_and_batch_bundle", "items", function(doc, cdt, cdn) { + let item_row = locals[cdt][cdn]; + return { + filters: { + 'item_code': item_row.item_code + } + } }); } diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 64c5ee59dc8..1c980375093 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -616,3 +616,195 @@ function check_can_calculate_pending_qty(me) { } //# sourceURL=serial_no_batch_selector.js + + +erpnext.SerialNoBatchBundleUpdate = class SerialNoBatchBundleUpdate { + constructor(frm, item, callback) { + this.frm = frm; + this.item = item; + this.qty = item.qty; + this.callback = callback; + this.make(); + this.render_data(); + } + + make() { + this.dialog = new frappe.ui.Dialog({ + title: __('Update Serial No / Batch No'), + fields: this.get_dialog_fields(), + primary_action_label: __('Update'), + primary_action: () => this.update_ledgers() + }); + this.dialog.show(); + } + + get_serial_no_filters() { + return { + 'item_code': this.item.item_code, + 'warehouse': ["=", ""], + 'delivery_document_no': ["=", ""], + }; + } + + get_dialog_fields() { + let fields = []; + + if (this.item.has_serial_no) { + fields.push({ + fieldtype: 'Link', + fieldname: 'scan_serial_no', + label: __('Scan Serial No'), + options: 'Serial No', + get_query: () => { + return { + filters: this.get_serial_no_filters() + }; + }, + onchange: () => this.update_serial_batch_no() + }); + } + + if (this.item.has_batch_no && this.item.has_serial_no) { + fields.push({ + fieldtype: 'Column Break', + label: __('Batch No') + }); + } + + if (this.item.has_batch_no) { + fields.push({ + fieldtype: 'Link', + fieldname: 'scan_batch_no', + label: __('Scan Batch No'), + options: 'Batch', + onchange: () => this.update_serial_batch_no() + }); + } + + if (this.item.has_batch_no && this.item.has_serial_no) { + fields.push({ + fieldtype: 'Section Break', + }); + } + + fields.push({ + fieldname: 'ledgers', + fieldtype: 'Table', + allow_bulk_edit: true, + data: [], + fields: this.get_dialog_table_fields(), + }); + + return fields; + } + + get_dialog_table_fields() { + let fields = [] + + if (this.item.has_serial_no) { + fields.push({ + fieldtype: 'Link', + options: 'Serial No', + fieldname: 'serial_no', + label: __('Serial No'), + in_list_view: 1, + get_query: () => { + return { + filters: this.get_serial_no_filters() + } + } + }) + } else if (this.item.has_batch_no) { + fields = [ + { + fieldtype: 'Link', + options: 'Batch', + fieldname: 'batch_no', + label: __('Batch No'), + in_list_view: 1, + }, + { + fieldtype: 'Float', + fieldname: 'qty', + label: __('Quantity'), + in_list_view: 1, + } + ] + } + + fields.push({ + fieldtype: 'Data', + fieldname: 'name', + label: __('Name'), + hidden: 1, + }) + + return fields; + } + + update_serial_batch_no() { + const { scan_serial_no, scan_batch_no } = this.dialog.get_values(); + + if (scan_serial_no) { + this.dialog.fields_dict.ledgers.df.data.push({ + serial_no: scan_serial_no + }); + + this.dialog.fields_dict.scan_serial_no.set_value(''); + } else if (scan_batch_no) { + this.dialog.fields_dict.ledgers.df.data.push({ + batch_no: scan_batch_no + }); + + this.dialog.fields_dict.scan_batch_no.set_value(''); + } + + this.dialog.fields_dict.ledgers.grid.refresh(); + } + + update_ledgers() { + if (!this.frm.is_new()) { + let ledgers = this.dialog.get_values().ledgers; + + if (ledgers && !ledgers.length) { + frappe.throw(__('Please add atleast one Serial No / Batch No')); + } + + frappe.call({ + method: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.add_serial_batch_no_ledgers', + args: { + ledgers: ledgers, + child_row: this.item + } + }).then(r => { + this.callback && this.callback(r.message); + this.dialog.hide(); + }) + } + } + + render_data() { + if (!this.frm.is_new() && this.item.serial_and_batch_bundle) { + frappe.call({ + method: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.get_serial_batch_no_ledgers', + args: { + item_code: this.item.item_code, + name: this.item.serial_and_batch_bundle, + voucher_no: this.item.parent, + } + }).then(r => { + if (r.message) { + this.set_data(r.message); + } + }) + } + } + + set_data(data) { + data.forEach(d => { + this.dialog.fields_dict.ledgers.df.data.push(d); + }); + + this.dialog.fields_dict.ledgers.grid.refresh(); + } +} \ No newline at end of file diff --git a/erpnext/stock/doctype/package_item/__init__.py b/erpnext/stock/doctype/package_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/package_item/package_item.js b/erpnext/stock/doctype/package_item/package_item.js new file mode 100644 index 00000000000..65fda462388 --- /dev/null +++ b/erpnext/stock/doctype/package_item/package_item.js @@ -0,0 +1,8 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Package Item', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/stock/doctype/package_item/package_item.json b/erpnext/stock/doctype/package_item/package_item.json new file mode 100644 index 00000000000..5b0246f9f89 --- /dev/null +++ b/erpnext/stock/doctype/package_item/package_item.json @@ -0,0 +1,138 @@ +{ + "actions": [], + "creation": "2022-09-29 14:56:38.338267", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_details_tab", + "company", + "item_code", + "column_break_4", + "warehouse", + "qty", + "serial_no_and_batch_no_tab", + "transactions", + "reference_details_tab", + "voucher_type", + "voucher_no", + "column_break_12", + "voucher_detail_no", + "amended_from" + ], + "fields": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Package Item", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "item_details_tab", + "fieldtype": "Tab Break", + "label": "Item Details" + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Warehouse", + "options": "Warehouse", + "reqd": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "qty", + "fieldtype": "Float", + "label": "Total Qty" + }, + { + "fieldname": "reference_details_tab", + "fieldtype": "Tab Break", + "label": "Reference Details" + }, + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "label": "Voucher Type", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "label": "Voucher No", + "options": "voucher_type" + }, + { + "fieldname": "voucher_detail_no", + "fieldtype": "Data", + "label": "Voucher Detail No", + "read_only": 1 + }, + { + "fieldname": "serial_no_and_batch_no_tab", + "fieldtype": "Tab Break", + "label": "Serial No and Batch No" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "fieldname": "transactions", + "fieldtype": "Table", + "label": "Items", + "options": "Serial and Batch No Transaction", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2022-10-06 22:07:31.732744", + "modified_by": "Administrator", + "module": "Stock", + "name": "Package Item", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/stock/doctype/package_item/package_item.py b/erpnext/stock/doctype/package_item/package_item.py new file mode 100644 index 00000000000..c0a2eaa53af --- /dev/null +++ b/erpnext/stock/doctype/package_item/package_item.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class PackageItem(Document): + pass diff --git a/erpnext/stock/doctype/package_item/test_package_item.py b/erpnext/stock/doctype/package_item/test_package_item.py new file mode 100644 index 00000000000..6dcc9cbfe9b --- /dev/null +++ b/erpnext/stock/doctype/package_item/test_package_item.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPackageItem(FrappeTestCase): + pass diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 312c166f8b7..e0cb8ca0216 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -7,6 +7,8 @@ frappe.provide("erpnext.stock"); frappe.ui.form.on("Purchase Receipt", { setup: (frm) => { + frm.ignore_doctypes_on_cancel_all = ['Serial and Batch Bundle']; + frm.make_methods = { 'Landed Cost Voucher': () => { let lcv = frappe.model.get_new_doc('Landed Cost Voucher'); diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 3373d8ac8c5..660504d2bf5 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -283,7 +283,12 @@ class PurchaseReceipt(BuyingController): self.update_stock_ledger() self.make_gl_entries_on_cancel() self.repost_future_sle_and_gle() - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") + self.ignore_linked_doctypes = ( + "GL Entry", + "Stock Ledger Entry", + "Repost Item Valuation", + "Serial and Batch Bundle", + ) self.delete_auto_created_batches() self.set_consumed_qty_in_subcontract_order() diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index cd320fdfcd0..97e7d72bb07 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -91,14 +91,12 @@ "delivery_note_item", "putaway_rule", "section_break_45", - "allow_zero_valuation_rate", - "bom", - "serial_no", + "update_serial_batch_bundle", + "serial_and_batch_bundle", "col_break5", + "allow_zero_valuation_rate", "include_exploded_items", - "batch_no", - "rejected_serial_no", - "item_tax_rate", + "bom", "item_weight_details", "weight_per_unit", "total_weight", @@ -110,6 +108,7 @@ "manufacturer_part_no", "accounting_details_section", "expense_account", + "item_tax_rate", "column_break_102", "provisional_expense_account", "accounting_dimensions_section", @@ -565,37 +564,8 @@ }, { "fieldname": "section_break_45", - "fieldtype": "Section Break" - }, - { - "depends_on": "eval:!doc.is_fixed_asset", - "fieldname": "serial_no", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Serial No", - "no_copy": 1, - "oldfieldname": "serial_no", - "oldfieldtype": "Text" - }, - { - "depends_on": "eval:!doc.is_fixed_asset", - "fieldname": "batch_no", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Batch No", - "no_copy": 1, - "oldfieldname": "batch_no", - "oldfieldtype": "Link", - "options": "Batch", - "print_hide": 1 - }, - { - "depends_on": "eval:!doc.is_fixed_asset", - "fieldname": "rejected_serial_no", - "fieldtype": "Small Text", - "label": "Rejected Serial No", - "no_copy": 1, - "print_hide": 1 + "fieldtype": "Section Break", + "label": "Serial and Batch No" }, { "fieldname": "item_tax_template", @@ -1016,12 +986,23 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "serial_and_batch_bundle", + "fieldtype": "Link", + "label": "Serial and Batch Bundle", + "options": "Serial and Batch Bundle" + }, + { + "fieldname": "update_serial_batch_bundle", + "fieldtype": "Button", + "label": "Add Serial / Batch No" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-02-28 15:43:04.470104", + "modified": "2023-02-28 16:43:04.470104", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/__init__.py b/erpnext/stock/doctype/serial_and_batch_bundle/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js new file mode 100644 index 00000000000..085e33db13b --- /dev/null +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.js @@ -0,0 +1,80 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Serial and Batch Bundle', { + setup(frm) { + frm.trigger('set_queries'); + }, + + refresh(frm) { + frm.trigger('toggle_fields'); + }, + + set_queries(frm) { + frm.set_query('item_code', () => { + return { + query: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.item_query', + }; + }); + + frm.set_query('voucher_type', () => { + return { + filters: { + 'istable': 0, + 'issingle': 0, + 'is_submittable': 1, + } + }; + }); + + frm.set_query('voucher_no', () => { + return { + filters: { + 'docstatus': ["!=", 2], + } + }; + }); + + frm.set_query('serial_no', 'ledgers', () => { + return { + filters: { + item_code: frm.doc.item_code, + } + }; + }); + + frm.set_query('batch_no', 'ledgers', () => { + return { + filters: { + item: frm.doc.item_code, + } + }; + }); + + frm.set_query('warehouse', 'ledgers', () => { + return { + filters: { + company: frm.doc.company, + } + }; + }); + }, + + has_serial_no(frm) { + frm.trigger('toggle_fields'); + }, + + has_batch_no(frm) { + frm.trigger('toggle_fields'); + }, + + toggle_fields(frm) { + frm.fields_dict.ledgers.grid.update_docfield_property( + 'serial_no', 'read_only', !frm.doc.has_serial_no + ); + + frm.fields_dict.ledgers.grid.update_docfield_property( + 'batch_no', 'read_only', !frm.doc.has_batch_no + ); + } +}); diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json new file mode 100644 index 00000000000..a08ed830139 --- /dev/null +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json @@ -0,0 +1,162 @@ +{ + "actions": [], + "creation": "2022-09-29 14:56:38.338267", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_details_tab", + "company", + "item_group", + "has_serial_no", + "column_break_4", + "item_code", + "item_name", + "has_batch_no", + "serial_no_and_batch_no_tab", + "ledgers", + "qty", + "tab_break_12", + "voucher_type", + "voucher_no", + "is_cancelled", + "amended_from" + ], + "fields": [ + { + "fieldname": "item_details_tab", + "fieldtype": "Tab Break", + "label": "Item Details" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fetch_from": "item_code.item_group", + "fieldname": "item_group", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group" + }, + { + "default": "0", + "fetch_from": "item_code.has_serial_no", + "fieldname": "has_serial_no", + "fieldtype": "Check", + "label": "Has Serial No", + "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name" + }, + { + "default": "0", + "fetch_from": "item_code.has_batch_no", + "fieldname": "has_batch_no", + "fieldtype": "Check", + "label": "Has Batch No", + "read_only": 1 + }, + { + "fieldname": "serial_no_and_batch_no_tab", + "fieldtype": "Section Break" + }, + { + "allow_bulk_edit": 1, + "fieldname": "ledgers", + "fieldtype": "Table", + "label": "Serial / Batch Ledgers", + "options": "Serial and Batch Ledger", + "reqd": 1 + }, + { + "fieldname": "qty", + "fieldtype": "Float", + "label": "Total Qty", + "read_only": 1 + }, + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "label": "Voucher Type", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "label": "Voucher No", + "options": "voucher_type" + }, + { + "default": "0", + "fieldname": "is_cancelled", + "fieldtype": "Check", + "label": "Is Cancelled", + "read_only": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Serial and Batch Bundle", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "tab_break_12", + "fieldtype": "Tab Break", + "label": "Reference" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2022-11-24 13:05:11.623968", + "modified_by": "Administrator", + "module": "Stock", + "name": "Serial and Batch Bundle", + "owner": "Administrator", + "permissions": [ + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "item_code" +} \ No newline at end of file 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 new file mode 100644 index 00000000000..ae25aad6129 --- /dev/null +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -0,0 +1,127 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class SerialandBatchBundle(Document): + def validate(self): + self.validate_serial_and_batch_no() + + def validate_serial_and_batch_no(self): + if self.item_code and not self.has_serial_no and not self.has_batch_no: + msg = f"The Item {self.item_code} does not have Serial No or Batch No" + frappe.throw(_(msg)) + + def before_cancel(self): + self.delink_serial_and_batch_bundle() + self.clear_table() + + def delink_serial_and_batch_bundle(self): + self.voucher_no = None + + sles = frappe.get_all("Stock Ledger Entry", filters={"serial_and_batch_bundle": self.name}) + + for sle in sles: + frappe.db.set_value("Stock Ledger Entry", sle.name, "serial_and_batch_bundle", None) + + def clear_table(self): + self.set("ledgers", []) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False): + item_filters = {"disabled": 0} + if txt: + item_filters["name"] = ("like", f"%{txt}%") + + return frappe.get_all( + "Item", + filters=item_filters, + or_filters={"has_serial_no": 1, "has_batch_no": 1}, + fields=["name", "item_name"], + as_list=1, + ) + + +@frappe.whitelist() +def get_serial_batch_no_ledgers(item_code, voucher_no, name=None): + return frappe.get_all( + "Serial and Batch Bundle", + fields=[ + "`tabSerial and Batch Ledger`.`name`", + "`tabSerial and Batch Ledger`.`qty`", + "`tabSerial and Batch Ledger`.`warehouse`", + "`tabSerial and Batch Ledger`.`batch_no`", + "`tabSerial and Batch Ledger`.`serial_no`", + ], + filters=[ + ["Serial and Batch Bundle", "item_code", "=", item_code], + ["Serial and Batch Ledger", "parent", "=", name], + ["Serial and Batch Bundle", "voucher_no", "=", voucher_no], + ["Serial and Batch Bundle", "docstatus", "!=", 2], + ], + ) + + +@frappe.whitelist() +def add_serial_batch_no_ledgers(ledgers, child_row) -> object: + if isinstance(child_row, str): + child_row = frappe._dict(frappe.parse_json(child_row)) + + if isinstance(ledgers, str): + ledgers = frappe.parse_json(ledgers) + + if frappe.db.exists("Serial and Batch Bundle", child_row.serial_and_batch_bundle): + doc = update_serial_batch_no_ledgers(ledgers, child_row) + else: + doc = create_serial_batch_no_ledgers(ledgers, child_row) + + return doc + + +def create_serial_batch_no_ledgers(ledgers, child_row) -> object: + doc = frappe.get_doc( + { + "doctype": "Serial and Batch Bundle", + "voucher_type": child_row.parenttype, + "voucher_no": child_row.parent, + "item_code": child_row.item_code, + "voucher_detail_no": child_row.name, + } + ) + + for row in ledgers: + row = frappe._dict(row) + doc.append( + "ledgers", + { + "qty": row.qty or 1.0, + "warehouse": child_row.warehouse, + "batch_no": row.batch_no, + "serial_no": row.serial_no, + }, + ) + + doc.save() + + frappe.db.set_value(child_row.doctype, child_row.name, "serial_and_batch_bundle", doc.name) + + frappe.msgprint(_("Serial and Batch Bundle created"), alert=True) + + return doc + + +def update_serial_batch_no_ledgers(ledgers, child_row) -> object: + doc = frappe.get_doc("Serial and Batch Bundle", child_row.serial_and_batch_bundle) + doc.voucher_detail_no = child_row.name + doc.set("ledgers", []) + doc.set("ledgers", ledgers) + doc.save() + + frappe.msgprint(_("Serial and Batch Bundle updated"), alert=True) + + return doc diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py new file mode 100644 index 00000000000..02e5349bfd9 --- /dev/null +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestSerialandBatchBundle(FrappeTestCase): + pass diff --git a/erpnext/stock/doctype/serial_and_batch_ledger/__init__.py b/erpnext/stock/doctype/serial_and_batch_ledger/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/serial_and_batch_ledger/serial_and_batch_ledger.json b/erpnext/stock/doctype/serial_and_batch_ledger/serial_and_batch_ledger.json new file mode 100644 index 00000000000..7fa9574494e --- /dev/null +++ b/erpnext/stock/doctype/serial_and_batch_ledger/serial_and_batch_ledger.json @@ -0,0 +1,73 @@ +{ + "actions": [], + "creation": "2022-09-29 14:55:15.909881", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "serial_no", + "batch_no", + "column_break_2", + "qty", + "warehouse", + "is_rejected" + ], + "fields": [ + { + "depends_on": "eval:parent.has_serial_no == 1", + "fieldname": "serial_no", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Serial No", + "mandatory_depends_on": "eval:parent.has_serial_no == 1", + "options": "Serial No" + }, + { + "depends_on": "eval:parent.has_batch_no == 1", + "fieldname": "batch_no", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Batch No", + "mandatory_depends_on": "eval:parent.has_batch_no == 1", + "options": "Batch" + }, + { + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Qty" + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Warehouse", + "options": "Warehouse" + }, + { + "default": "0", + "depends_on": "eval:parent.voucher_type == 'Purchase Receipt'", + "fieldname": "is_rejected", + "fieldtype": "Check", + "label": "Is Rejected" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-11-24 13:00:23.598351", + "modified_by": "Administrator", + "module": "Stock", + "name": "Serial and Batch Ledger", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/stock/doctype/serial_and_batch_ledger/serial_and_batch_ledger.py b/erpnext/stock/doctype/serial_and_batch_ledger/serial_and_batch_ledger.py new file mode 100644 index 00000000000..945fdc1bc38 --- /dev/null +++ b/erpnext/stock/doctype/serial_and_batch_ledger/serial_and_batch_ledger.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class SerialandBatchLedger(Document): + pass diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 541d4d17e18..9338dc57357 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -189,6 +189,7 @@ class SerialNo(StockController): def get_last_sle(self, serial_no=None): entries = {} sle_dict = self.get_stock_ledger_entries(serial_no) + print("sle_dict", sle_dict) if sle_dict: if sle_dict.get("incoming", []): entries["purchase_sle"] = sle_dict["incoming"][0] @@ -206,33 +207,23 @@ class SerialNo(StockController): if not serial_no: serial_no = self.name + print("serial_no", serial_no) for sle in frappe.db.sql( """ - SELECT voucher_type, voucher_no, - posting_date, posting_time, incoming_rate, actual_qty, serial_no + SELECT sle.voucher_type, sle.voucher_no, serial_and_batch_bundle, + sle.posting_date, sle.posting_time, sle.incoming_rate, sle.actual_qty, snb.serial_no FROM - `tabStock Ledger Entry` + `tabStock Ledger Entry` sle, `tabSerial and Batch Ledger` snb WHERE - item_code=%s AND company = %s - AND is_cancelled = 0 - AND (serial_no = %s - OR serial_no like %s - OR serial_no like %s - OR serial_no like %s - ) + sle.item_code=%s AND sle.company = %s + AND sle.is_cancelled = 0 + AND snb.serial_no = %s and snb.parent = sle.serial_and_batch_bundle ORDER BY - posting_date desc, posting_time desc, creation desc""", - ( - self.item_code, - self.company, - serial_no, - serial_no + "\n%", - "%\n" + serial_no, - "%\n" + serial_no + "\n%", - ), + sle.posting_date desc, sle.posting_time desc, sle.creation desc""", + (self.item_code, self.company, serial_no), as_dict=1, ): - if serial_no.upper() in get_serial_nos(sle.serial_no): + if serial_no.upper() in get_serial_nos(sle.serial_and_batch_bundle): if cint(sle.actual_qty) > 0: sle_dict.setdefault("incoming", []).append(sle) else: @@ -262,6 +253,7 @@ class SerialNo(StockController): def update_serial_no_reference(self, serial_no=None): last_sle = self.get_last_sle(serial_no) + print(last_sle) self.set_purchase_details(last_sle.get("purchase_sle")) self.set_sales_details(last_sle.get("delivery_sle")) self.set_maintenance_status() @@ -275,7 +267,7 @@ def process_serial_no(sle): def validate_serial_no(sle, item_det): - serial_nos = get_serial_nos(sle.serial_no) if sle.serial_no else [] + 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: @@ -541,7 +533,7 @@ def update_serial_nos(sle, item_det): return if ( not sle.is_cancelled - and not sle.serial_no + and not sle.serial_and_batch_bundle and cint(sle.actual_qty) > 0 and item_det.has_serial_no == 1 and item_det.serial_no_series @@ -549,7 +541,7 @@ def update_serial_nos(sle, item_det): serial_nos = get_auto_serial_nos(item_det.serial_no_series, sle.actual_qty) sle.db_set("serial_no", serial_nos) validate_serial_no(sle, item_det) - if sle.serial_no: + if sle.serial_and_batch_bundle: auto_make_serial_nos(sle) @@ -569,7 +561,7 @@ def get_new_serial_number(series): def auto_make_serial_nos(args): - serial_nos = get_serial_nos(args.get("serial_no")) + serial_nos = get_serial_nos(args.get("serial_and_batch_bundle")) created_numbers = [] voucher_type = args.get("voucher_type") item_code = args.get("item_code") @@ -624,13 +616,14 @@ def get_item_details(item_code): )[0] -def get_serial_nos(serial_no): - if isinstance(serial_no, list): - return serial_no +def get_serial_nos(serial_and_batch_bundle): + serial_nos = frappe.get_all( + "Serial and Batch Ledger", + filters={"parent": serial_and_batch_bundle, "serial_no": ("is", "set")}, + fields=["serial_no"], + ) - return [ - s.strip() for s in cstr(serial_no).strip().upper().replace(",", "\n").split("\n") if s.strip() - ] + return [d.serial_no for d in serial_nos] def clean_serial_no_string(serial_no: str) -> str: diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json index 46ce9debf3b..0df0a0416c5 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json @@ -31,6 +31,7 @@ "company", "stock_uom", "project", + "serial_and_batch_bundle", "batch_no", "column_break_26", "fiscal_year", @@ -309,6 +310,13 @@ "label": "Recalculate Incoming/Outgoing Rate", "no_copy": 1, "read_only": 1 + }, + { + "fieldname": "serial_and_batch_bundle", + "fieldtype": "Link", + "label": "Serial and Batch Bundle", + "options": "Serial and Batch Bundle", + "search_index": 1 } ], "hide_toolbar": 1, @@ -317,7 +325,7 @@ "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-21 06:25:30.040801", + "modified": "2022-11-24 13:14:31.974743", "modified_by": "Administrator", "module": "Stock", "name": "Stock Ledger Entry", diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 052f7781c13..916b14a6632 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -40,7 +40,7 @@ class StockLedgerEntry(Document): from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company self.validate_mandatory() - self.validate_item() + self.validate_serial_batch_no_bundle() self.validate_batch() validate_disabled_warehouse(self.warehouse) validate_warehouse_company(self.warehouse, self.company) @@ -79,47 +79,43 @@ class StockLedgerEntry(Document): if self.voucher_type != "Stock Reconciliation" and not self.actual_qty: frappe.throw(_("Actual Qty is mandatory")) - def validate_item(self): - item_det = frappe.db.sql( - """select name, item_name, has_batch_no, docstatus, - is_stock_item, has_variants, stock_uom, create_new_batch - from tabItem where name=%s""", + def validate_serial_batch_no_bundle(self): + item_detail = frappe.get_cached_value( + "Item", self.item_code, - as_dict=True, + ["has_serial_no", "has_batch_no", "is_stock_item", "has_variants", "stock_uom"], + as_dict=1, ) - if not item_det: + if not item_detail: frappe.throw(_("Item {0} not found").format(self.item_code)) - item_det = item_det[0] - - if item_det.is_stock_item != 1: - frappe.throw(_("Item {0} must be a stock Item").format(self.item_code)) - - # check if batch number is valid - if item_det.has_batch_no == 1: - batch_item = ( - self.item_code - if self.item_code == item_det.item_name - else self.item_code + ":" + item_det.item_name - ) - if not self.batch_no: - frappe.throw(_("Batch number is mandatory for Item {0}").format(batch_item)) - elif not frappe.db.get_value("Batch", {"item": self.item_code, "name": self.batch_no}): - frappe.throw( - _("{0} is not a valid Batch Number for Item {1}").format(self.batch_no, batch_item) - ) - - elif item_det.has_batch_no == 0 and self.batch_no and self.is_cancelled == 0: - frappe.throw(_("The Item {0} cannot have Batch").format(self.item_code)) - - if item_det.has_variants: + if item_detail.has_variants: frappe.throw( _("Stock cannot exist for Item {0} since has variants").format(self.item_code), ItemTemplateCannotHaveStock, ) - self.stock_uom = item_det.stock_uom + if item_detail.is_stock_item != 1: + frappe.throw(_("Item {0} must be a stock Item").format(self.item_code)) + + if item_detail.has_serial_no or item_detail.has_batch_no: + if not self.serial_and_batch_bundle: + frappe.throw(_(f"Serial No and Batch No are mandatory for Item {self.item_code}")) + elif self.item_code != frappe.get_cached_value( + "Serial and Batch Bundle", self.serial_and_batch_bundle, "item_code" + ): + frappe.throw( + _( + f"Serial No and Batch No Bundle {self.serial_and_batch_bundle} is not for Item {self.item_code}" + ) + ) + + if self.serial_and_batch_bundle and not (item_detail.has_serial_no or item_detail.has_batch_no): + frappe.throw(_(f"Serial No and Batch No are not allowed for Item {self.item_code}")) + + if self.stock_uom != item_detail.stock_uom: + self.stock_uom = item_detail.stock_uom def check_stock_frozen_date(self): stock_settings = frappe.get_cached_doc("Stock Settings")