diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index cad1a597386..bb48065af9e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -33,6 +33,7 @@ from erpnext.accounts.utils import ( get_account_currency, update_voucher_outstanding, ) +from erpnext.assets.doctype.asset.asset import split_asset from erpnext.assets.doctype.asset.depreciation import ( depreciate_asset, get_gl_entries_on_asset_disposal, @@ -468,6 +469,8 @@ class SalesInvoice(SellingController): self.update_stock_reservation_entries() self.update_stock_ledger() + self.split_asset_based_on_sale_qty() + self.process_asset_depreciation() # this sequence because outstanding may get -ve @@ -1358,6 +1361,51 @@ class SalesInvoice(SellingController): ): throw(_("Delivery Note {0} is not submitted").format(d.delivery_note)) + def split_asset_based_on_sale_qty(self): + asset_qty_map = self.get_asset_qty() + for asset, qty in asset_qty_map.items(): + if qty["actual_qty"] < qty["sale_qty"]: + frappe.throw( + _( + "Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)." + ).format(asset, qty["actual_qty"]) + ) + + remaining_qty = qty["actual_qty"] - qty["sale_qty"] + if remaining_qty > 0: + split_asset(asset, remaining_qty) + + def get_asset_qty(self): + asset_qty_map = {} + + assets = {row.asset for row in self.items if row.is_fixed_asset and row.asset} + if not assets or self.is_return: + return asset_qty_map + + asset_actual_qty = dict( + frappe.db.get_all( + "Asset", + {"name": ["in", list(assets)]}, + ["name", "asset_quantity"], + as_list=True, + ) + ) + for row in self.items: + if row.is_fixed_asset and row.asset: + actual_qty = asset_actual_qty.get(row.asset) + if row.asset in asset_qty_map.keys(): + asset_qty_map[row.asset]["sale_qty"] += flt(row.qty) + else: + asset_qty_map.setdefault( + row.asset, + { + "sale_qty": flt(row.qty), + "actual_qty": flt(actual_qty), + }, + ) + + return asset_qty_map + def process_asset_depreciation(self): if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1): self.depreciate_asset_on_sale() diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index dc65883eb15..f3a310a101d 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -111,7 +111,7 @@ frappe.ui.form.on("Asset", { frm.add_custom_button( __("Sell Asset"), function () { - frm.trigger("make_sales_invoice"); + frm.trigger("sell_asset"); }, __("Manage") ); @@ -521,22 +521,6 @@ frappe.ui.form.on("Asset", { frm.trigger("toggle_reference_doc"); }, - make_sales_invoice: function (frm) { - frappe.call({ - args: { - asset: frm.doc.name, - item_code: frm.doc.item_code, - company: frm.doc.company, - serial_no: frm.doc.serial_no, - }, - method: "erpnext.assets.doctype.asset.asset.make_sales_invoice", - callback: function (r) { - var doclist = frappe.model.sync(r.message); - frappe.set_route("Form", doclist[0].doctype, doclist[0].name); - }, - }); - }, - create_asset_maintenance: function (frm) { frappe.call({ args: { @@ -585,6 +569,69 @@ frappe.ui.form.on("Asset", { }); }, + sell_asset: function (frm) { + const make_sales_invoice = (sell_qty) => { + frappe.call({ + method: "erpnext.assets.doctype.asset.asset.make_sales_invoice", + args: { + asset: frm.doc.name, + item_code: frm.doc.item_code, + company: frm.doc.company, + serial_no: frm.doc.serial_no, + sell_qty: sell_qty, + }, + callback: function (r) { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + }, + }); + }; + + let dialog = new frappe.ui.Dialog({ + title: __("Sell Asset"), + fields: [ + { + fieldname: "sell_qty", + fieldtype: "Int", + label: __("Sell Qty"), + reqd: 1, + }, + ], + }); + + dialog.set_primary_action(__("Sell"), function () { + const dialog_data = dialog.get_values(); + const sell_qty = cint(dialog_data.sell_qty); + const asset_qty = cint(frm.doc.asset_quantity); + + if (sell_qty <= 0) { + frappe.throw(__("Sell quantity must be greater than zero")); + } + + if (sell_qty > asset_qty) { + frappe.throw(__("Sell quantity cannot exceed the asset quantity")); + } + + if (sell_qty < asset_qty) { + frappe.confirm( + __( + "The sell quantity is less than the total asset quantity. The remaining quantity will be split into a new asset. This action cannot be undone.

Do you want to continue?" + ), + () => { + make_sales_invoice(sell_qty); + dialog.hide(); + } + ); + return; + } + + make_sales_invoice(sell_qty); + dialog.hide(); + }); + + dialog.show(); + }, + split_asset: function (frm) { const title = __("Split Asset"); diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 701be98a0d6..b929eda7d6a 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -482,6 +482,9 @@ class Asset(AccountsController): frappe.throw(_("Available-for-use Date should be after purchase date")) def validate_linked_purchase_documents(self): + if self.flags.is_split_asset: + return + for fieldname, doctype in [ ("purchase_receipt", "Purchase Receipt"), ("purchase_invoice", "Purchase Invoice"), @@ -1083,7 +1086,7 @@ def get_asset_naming_series(): @frappe.whitelist() -def make_sales_invoice(asset, item_code, company, serial_no=None, posting_date=None): +def make_sales_invoice(asset, item_code, company, sell_qty, serial_no=None): asset_doc = frappe.get_doc("Asset", asset) si = frappe.new_doc("Sales Invoice") si.company = company @@ -1098,7 +1101,7 @@ def make_sales_invoice(asset, item_code, company, serial_no=None, posting_date=N "income_account": disposal_account, "serial_no": serial_no, "cost_center": depreciation_cost_center, - "qty": 1, + "qty": sell_qty, }, ) @@ -1378,6 +1381,7 @@ def process_asset_split(existing_asset, split_qty, splitted_asset=None, is_new_a scaling_factor = flt(split_qty) / flt(existing_asset.asset_quantity) new_asset = frappe.copy_doc(existing_asset) if is_new_asset else splitted_asset asset_doc = new_asset if is_new_asset else existing_asset + asset_doc.flags.is_split_asset = True set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset) log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index c54c39ab7c8..600f6126f61 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -330,7 +330,9 @@ class TestAsset(AssetSetup): post_depreciation_entries(date=add_months(purchase_date, 2)) - si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company") + si = make_sales_invoice( + asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=asset.asset_quantity + ) si.customer = "_Test Customer" si.due_date = date si.get("items")[0].rate = 25000 @@ -458,7 +460,9 @@ class TestAsset(AssetSetup): post_depreciation_entries(date="2021-01-01") - si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company") + si = make_sales_invoice( + asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=asset.asset_quantity + ) si.customer = "_Test Customer" si.due_date = nowdate() si.get("items")[0].rate = 25000 @@ -698,6 +702,128 @@ class TestAsset(AssetSetup): frappe.db.set_value("Asset Category Account", name, "capital_work_in_progress_account", cwip_acc) frappe.db.get_value("Company", "_Test Company", "capital_work_in_progress_account", cwip_acc) + def test_partial_asset_sale(self): + date = nowdate() + purchase_date = add_months(get_first_day(date), -2) + depreciation_start_date = add_months(get_last_day(date), -2) + + # create an asset + asset = create_asset( + item_code="Macbook Pro", + is_existing_asset=1, + calculate_depreciation=1, + available_for_use_date=purchase_date, + purchase_date=purchase_date, + depreciation_start_date=depreciation_start_date, + net_purchase_amount=1000000.0, + purchase_amount=1000000.0, + asset_quantity=10, + total_number_of_depreciations=12, + frequency_of_depreciation=1, + submit=1, + ) + asset_depr_schedule_before_sale = get_asset_depr_schedule_doc(asset.name, "Active") + post_depreciation_entries(date) + asset.reload() + + # check asset values before sale + self.assertEqual(asset.asset_quantity, 10) + self.assertEqual(asset.net_purchase_amount, 1000000) + self.assertEqual(asset.status, "Partially Depreciated") + self.assertEqual( + asset_depr_schedule_before_sale.depreciation_schedule[0].get("depreciation_amount"), 83333.33 + ) + + # make a partial sales against the asset + si = make_sales_invoice( + asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=5 + ) + si.customer = "_Test Customer" + si.due_date = date + si.get("items")[0].rate = 25000 + si.insert() + si.submit() + + asset.reload() + asset_depr_schedule_after_sale = get_asset_depr_schedule_doc(asset.name, "Active") + + # check asset values after sales + self.assertEqual(asset.asset_quantity, 5) + self.assertEqual(asset.net_purchase_amount, 500000) + self.assertEqual(asset.status, "Sold") + self.assertEqual( + asset_depr_schedule_after_sale.depreciation_schedule[0].get("depreciation_amount"), 41666.66 + ) + + def test_asset_splitting_for_non_existing_asset(self): + date = nowdate() + purchase_date = add_months(get_first_day(date), -2) + depreciation_start_date = add_months(get_last_day(date), -2) + + asset_qty = 10 + asset_rate = 100000.0 + asset_item = "Macbook Pro" + asset_location = "Test Location" + + frappe.db.set_value("Item", asset_item, "is_grouped_asset", 1) + + # Inward asset via Purchase Receipt + pr = make_purchase_receipt( + item_code="Macbook Pro", + posting_date=purchase_date, + qty=asset_qty, + rate=asset_rate, + location=asset_location, + supplier="_Test Supplier", + ) + + asset = frappe.db.get_value("Asset", {"purchase_receipt": pr.name, "docstatus": 0}, "name") + asset_doc = frappe.get_doc("Asset", asset) + asset_doc.calculate_depreciation = 1 + asset_doc.available_for_use_date = purchase_date + asset_doc.location = asset_location + asset_doc.append( + "finance_books", + { + "expected_value_after_useful_life": 0, + "depreciation_method": "Straight Line", + "total_number_of_depreciations": 12, + "frequency_of_depreciation": 1, + "depreciation_start_date": depreciation_start_date, + }, + ) + asset_doc.submit() + + # check asset values before splitting + asset_depr_schedule_before_splitting = get_asset_depr_schedule_doc(asset_doc.name, "Active") + self.assertEqual(asset_doc.asset_quantity, 10) + self.assertEqual(asset_doc.net_purchase_amount, 1000000) + self.assertEqual( + asset_depr_schedule_before_splitting.depreciation_schedule[0].get("depreciation_amount"), 83333.33 + ) + + # initate asset split + new_asset = split_asset(asset_doc.name, 5) + asset_doc.reload() + asset_depr_schedule_after_sale = get_asset_depr_schedule_doc(asset_doc.name, "Active") + new_asset_depr_schedule = get_asset_depr_schedule_doc(new_asset.name, "Active") + + # check asset values after splitting + self.assertEqual(asset_doc.asset_quantity, 5) + self.assertEqual(asset_doc.net_purchase_amount, 500000) + self.assertEqual( + asset_depr_schedule_after_sale.depreciation_schedule[0].get("depreciation_amount"), 41666.66 + ) + + # check new asset values after splitting + self.assertEqual(new_asset.asset_quantity, 5) + self.assertEqual(new_asset.net_purchase_amount, 500000) + self.assertEqual( + new_asset_depr_schedule.depreciation_schedule[0].get("depreciation_amount"), 41666.66 + ) + + frappe.db.set_value("Item", asset_item, "is_grouped_asset", 0) + class TestDepreciationMethods(AssetSetup): def test_schedule_for_straight_line_method(self): diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 15ceb51648b..d085a4c6e4b 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -51,7 +51,9 @@ class TestAssetRepair(IntegrationTestCase): submit=1, ) - si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company") + si = make_sales_invoice( + asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=asset.asset_quantity + ) si.customer = "_Test Customer" si.due_date = date si.get("items")[0].rate = 25000