diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 6dfe5009a07..ed6d4efe43d 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -193,10 +193,18 @@ frappe.ui.form.on("Item", { __("View") ); - frm.toggle_display( - ["opening_stock"], - frappe.model.can_create("Stock Entry") && frappe.model.can_write("Stock Entry") - ); + const can_create_stock_entry = + frappe.model.can_create("Stock Entry") && frappe.model.can_write("Stock Entry"); + + const has_existing_stock = frm.doc.__onload && frm.doc.__onload.stock_exists ? 1 : 0; + + if (can_create_stock_entry && !has_existing_stock) { + frm.add_custom_button( + __("Set Opening Stock"), + () => erpnext.item.show_opening_stock_dialog(frm), + __("Actions") + ); + } } if (frm.doc.is_fixed_asset) { @@ -874,6 +882,143 @@ $.extend(erpnext.item, { }); }, + show_opening_stock_dialog: function (frm) { + const has_serial = cint(frm.doc.has_serial_no); + const has_batch = cint(frm.doc.has_batch_no); + + if (has_serial || has_batch) { + const default_company = frappe.defaults.get_default("company"); + const row = (frm.doc.item_defaults || []).find((d) => d.company === default_company); + const default_warehouse = (row && row.default_warehouse) || ""; + + frappe.route_options = { + purpose: "Opening Stock", + company: default_company, + }; + + frappe.new_doc("Stock Reconciliation", null, (doc) => { + const child = doc.items[0]; + frappe.model.set_value(child.doctype, child.name, "item_code", frm.doc.name); + if (default_warehouse) { + frappe.model.set_value(child.doctype, child.name, "warehouse", default_warehouse); + } + }); + return; + } + + const companies = (frm.doc.item_defaults || []).map((d) => d.company).filter(Boolean); + + if (!companies.length) { + frappe.msgprint({ + title: __("No Company Found"), + message: __( + "Please add at least one row in Item Defaults with a Company before setting opening stock." + ), + indicator: "orange", + }); + return; + } + + const get_warehouse_for_company = (company) => { + const row = (frm.doc.item_defaults || []).find((d) => d.company === company); + return (row && row.default_warehouse) || ""; + }; + + const fields = [ + { + label: __("Company"), + fieldname: "company", + fieldtype: "Select", + options: companies.join("\n"), + default: companies[0], + reqd: 1, + onchange: function () { + const warehouse = get_warehouse_for_company(dialog.get_value("company")); + dialog.set_value("warehouse", warehouse); + dialog.set_df_property( + "warehouse", + "description", + warehouse + ? __("Default warehouse from Item Defaults.") + : __( + "No default warehouse set for this company. Entry will use Stock Settings default." + ) + ); + }, + }, + { + label: __("Default Warehouse"), + fieldname: "warehouse", + fieldtype: "Data", + read_only: 1, + description: __("Default warehouse from Item Defaults."), + }, + { fieldtype: "Column Break" }, + { + label: __("Opening Stock"), + fieldname: "qty", + fieldtype: "Float", + default: frm.doc.opening_stock || 1, + reqd: 1, + }, + { + label: __("Valuation Rate"), + fieldname: "valuation_rate", + fieldtype: "Currency", + default: frm.doc.valuation_rate || 0, + description: __("Leave as 0 to allow zero valuation rate."), + }, + ]; + + const dialog = new frappe.ui.Dialog({ + title: __("Add Opening Stock"), + fields: fields, + primary_action_label: __("Save"), + primary_action: function (values) { + frappe.call({ + method: "erpnext.stock.doctype.item.item.make_opening_stock_entry", + args: { + item_code: frm.doc.name, + company: values.company, + qty: values.qty, + valuation_rate: values.valuation_rate || 0, + warehouse: values.warehouse || null, + }, + freeze: true, + freeze_message: __("Creating Opening Stock Entry..."), + callback: function (r) { + if (!r.exc && r.message) { + dialog.hide(); + frm.reload_doc(); + } + }, + }); + }, + }); + + dialog.set_value("warehouse", get_warehouse_for_company(companies[0])); + dialog.show(); + dialog.add_custom_action(__("Edit Full Form"), function () { + const default_company = frappe.defaults.get_default("company"); + const row = (frm.doc.item_defaults || []).find((d) => d.company === default_company); + + frappe.route_options = { + purpose: "Opening Stock", + company: default_company, + }; + + frappe.new_doc("Stock Reconciliation", null, (doc) => { + const child = doc.items[0]; + frappe.model.set_value(child.doctype, child.name, "item_code", frm.doc.name); + if (row && row.default_warehouse) { + frappe.model.set_value(child.doctype, child.name, "warehouse", row.default_warehouse); + } + }); + + dialog.hide(); + }); + }, + weight_to_validate: function (frm) { if (frm.doc.weight_per_unit && !frm.doc.weight_uom) { frappe.msgprint({ diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index ed7886d6be3..8a458e8ea04 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -255,8 +255,6 @@ }, { "bold": 1, - "depends_on": "eval:(doc.__islocal&&doc.is_stock_item && !doc.has_serial_no && !doc.has_batch_no)", - "description": "Used to create an opening Stock Entry with the Valuation Rate when the item is saved", "fieldname": "opening_stock", "fieldtype": "Float", "hidden": 1, diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 29e233a7bc8..74e8dde48af 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -31,6 +31,7 @@ from erpnext.controllers.item_variant import ( validate_item_variant_attributes, ) from erpnext.stock.doctype.item_default.item_default import ItemDefault +from erpnext.stock.serial_batch_bundle import SerialBatchCreation from erpnext.stock.utils import get_valuation_method @@ -196,7 +197,7 @@ class Item(Document): ) frappe.msgprint( _( - "Opening stock creation has been queued and will be created in the background. Please check the stock entry after some time." + "Opening stock creation has been queued and will be created in the background. Please check the Stock Reconciliation after some time." ), indicator="orange", alert=True, @@ -312,9 +313,6 @@ class Item(Document): if self.valuation_rate is None and not self.is_customer_provided_item: frappe.throw(_("Valuation Rate is mandatory if Opening Stock entered")) - from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry - - # default warehouse, or Stores for default in self.item_defaults or [ frappe._dict({"company": frappe.defaults.get_defaults().company}) ]: @@ -330,38 +328,40 @@ class Item(Document): ) if default_warehouse: - stock_entry = make_stock_entry( - item_code=self.name, - target=default_warehouse, - qty=self.opening_stock, - rate=self.valuation_rate, - company=default.company, - posting_date=getdate(), - posting_time=nowtime(), - do_not_save=True, + opening_account = frappe.db.get_value( + "Account", + {"company": default.company, "account_type": "Temporary", "is_group": 0}, + "name", ) - if self.valuation_rate == 0: - for item in stock_entry.items: - item.allow_zero_valuation_rate = 1 + if not opening_account: + frappe.throw( + _( + "Please set a Temporary Opening account for company {0} to create an Opening Stock reconciliation." + ).format(frappe.bold(default.company)) + ) + stock_reco = create_opening_stock_reconciliation( + item_code=self.name, + company=default.company, + qty=self.opening_stock, + valuation_rate=self.valuation_rate, + warehouse=default_warehouse, + expense_account=opening_account, + ) + stock_reco.add_comment("Comment", _("Opening Stock")) - stock_entry.insert() - stock_entry.submit() - stock_entry.load_from_db() - stock_entry.add_comment("Comment", _("Opening Stock")) - - stock_entry_link = frappe.utils.get_link_to_form("Stock Entry", stock_entry.name) + stock_reco_link = frappe.utils.get_link_to_form("Stock Reconciliation", stock_reco.name) if self.valuation_rate == 0: frappe.msgprint( - _("Opening Stock entry created with zero valuation rate: {0}").format( - stock_entry_link + _("Opening Stock reconciliation created with zero valuation rate: {0}").format( + stock_reco_link ), indicator="orange", alert=True, ) else: frappe.msgprint( - _("Opening Stock entry created: {0}").format(stock_entry_link), + _("Opening Stock reconciliation created: {0}").format(stock_reco_link), indicator="green", alert=True, ) @@ -1584,3 +1584,169 @@ def get_item_prices(item_code: str): "prices": prices[:10], "has_more": has_more, } + + +@frappe.whitelist() +def make_opening_stock_entry( + item_code: str, + company: str, + qty: float, + valuation_rate: float, + warehouse: str | None = None, +): + if not frappe.has_permission("Item", "write", item_code): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + item = frappe.get_doc("Item", item_code) + + if not item.is_stock_item: + frappe.throw(_("Opening Stock can only be set for stock items.")) + if item.has_serial_no or item.has_batch_no: + frappe.throw( + _("Opening Stock for serialised or batch items must be set via the Stock Reconciliation form.") + ) + if item.stock_ledger_created(): + frappe.throw( + _("Opening Stock cannot be created as stock transactions already exist for item {0}.").format( + frappe.bold(item_code) + ) + ) + + if flt(qty) <= 0: + frappe.throw(_("Quantity must be greater than zero.")) + + if flt(valuation_rate) < 0: + frappe.throw(_("Valuation Rate cannot be negative.")) + + if warehouse: + warehouse_company = frappe.db.get_value("Warehouse", warehouse, "company") + if warehouse_company != company: + frappe.throw(_("Warehouse {0} does not belong to Company {1}.").format(warehouse, company)) + + target_warehouse = get_default_warehouse_for_opening_stock(item, company, warehouse) + + opening_account = frappe.db.get_value( + "Account", + {"company": company, "account_type": "Temporary", "is_group": 0}, + "name", + ) + + if not opening_account: + frappe.throw( + _( + "Please set a Temporary Opening account for company {0} to create an Opening Stock reconciliation." + ).format(frappe.bold(company)) + ) + + stock_reco = create_opening_stock_reconciliation( + item_code=item_code, + company=company, + qty=qty, + valuation_rate=valuation_rate, + warehouse=target_warehouse, + expense_account=opening_account, + ) + stock_reco.add_comment("Comment", _("Opening Stock")) + + frappe.msgprint( + _("Opening Stock reconciliation created: {0}").format( + get_link_to_form("Stock Reconciliation", stock_reco.name) + ), + indicator="green", + alert=True, + ) + + return stock_reco.name + + +def create_opening_stock_reconciliation( + item_code: str, + company: str, + qty: float, + valuation_rate: float, + warehouse: str, + expense_account: str, +): + stock_reco = frappe.get_doc( + { + "doctype": "Stock Reconciliation", + "purpose": "Opening Stock", + "company": company, + "expense_account": expense_account, + "items": [ + { + "item_code": item_code, + "warehouse": warehouse, + "qty": flt(qty), + "valuation_rate": flt(valuation_rate), + "allow_zero_valuation_rate": 1 if flt(valuation_rate) == 0 else 0, + "reconcile_all_serial_batch": 1, + } + ], + } + ) + + stock_reco.insert() + set_opening_stock_serial_batch_bundle(stock_reco) + stock_reco.submit() + + return stock_reco + + +def set_opening_stock_serial_batch_bundle(stock_reco): + row = stock_reco.items[0] + item_details = frappe.get_cached_value( + "Item", row.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 + + bundle = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": row.warehouse, + "voucher_type": stock_reco.doctype, + "voucher_no": stock_reco.name, + "voucher_detail_no": row.name, + "posting_date": stock_reco.posting_date, + "posting_time": stock_reco.posting_time, + "qty": row.qty, + "avg_rate": row.valuation_rate, + "type_of_transaction": "Inward", + "company": stock_reco.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle() + + if not bundle: + return + + row.db_set("serial_and_batch_bundle", bundle.name, update_modified=False) + row.serial_and_batch_bundle = bundle.name + + +def get_default_warehouse_for_opening_stock(item, company: str, warehouse: str | None): + if warehouse: + return warehouse + + for default in item.item_defaults: + if default.company == company and default.default_warehouse: + return default.default_warehouse + + settings_warehouse = frappe.get_single_value("Stock Settings", "default_warehouse") + if settings_warehouse: + warehouse_company = frappe.db.get_value("Warehouse", settings_warehouse, "company") + if warehouse_company == company: + return settings_warehouse + + stores_warehouse = frappe.db.get_value("Warehouse", {"warehouse_name": _("Stores"), "company": company}) + + if stores_warehouse: + return stores_warehouse + + frappe.throw( + _( + "No warehouse found for company {0}. Please set a Default Warehouse in Item Defaults or Stock Settings." + ).format(frappe.bold(company)) + ) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 4c05765e65d..eb67a776f1d 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -997,13 +997,24 @@ class TestItem(ERPNextTestSuite): for item_code, properties in items.items(): make_item(item_code, properties) - serial_and_batch_bundle = frappe.db.get_value( + stock_entry_bundle = frappe.db.get_value( "Stock Entry Detail", {"docstatus": 1, "item_code": item_code}, "serial_and_batch_bundle" ) + self.assertFalse(stock_entry_bundle) + + serial_and_batch_bundle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Stock Reconciliation", + "is_cancelled": 0, + "item_code": item_code, + }, + "serial_and_batch_bundle", + ) self.assertTrue(serial_and_batch_bundle) sabb_qty = frappe.db.get_value("Serial and Batch Bundle", serial_and_batch_bundle, "total_qty") - self.assertEqual(sabb_qty, properties["opening_stock"]) + self.assertEqual(abs(sabb_qty), properties["opening_stock"]) def set_item_variant_settings(fields):