Skip to content

Commit 10be8f1

Browse files
fix(asset): handle partial asset sales by splitting remaining quantity (backport #51363) (#52394)
* fix(asset): handle partial asset sales by splitting remaining quantity (cherry picked from commit 9a2710b) * fix: refactor older testcases (cherry picked from commit a88fe2e) * test: validate asset partial sales (cherry picked from commit 9eeccb7) # Conflicts: # erpnext/assets/doctype/asset/test_asset.py * fix(asset): skip purchase document validation while splitting existing asset (cherry picked from commit e7e6567) * fix(asset): handle same asset being sold in multiple line items in sales invoice (cherry picked from commit 23b094f) * test: validate asset split for auto created asset from purchase voucher (cherry picked from commit 4adeaed) # Conflicts: # erpnext/assets/doctype/asset/test_asset.py * fix: use new_asset instead of asset_doc when checking values after splitting (cherry picked from commit ca97f34) * fix: remove the redundant purchase receipt submit (cherry picked from commit eeb6d0e) * chore: fix conflict --------- Co-authored-by: Navin-S-R <navin@aerele.in>
1 parent 2ccb8c8 commit 10be8f1

5 files changed

Lines changed: 249 additions & 22 deletions

File tree

erpnext/accounts/doctype/sales_invoice/sales_invoice.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
get_account_currency,
3434
update_voucher_outstanding,
3535
)
36+
from erpnext.assets.doctype.asset.asset import split_asset
3637
from erpnext.assets.doctype.asset.depreciation import (
3738
depreciate_asset,
3839
get_gl_entries_on_asset_disposal,
@@ -480,6 +481,8 @@ def on_submit(self):
480481
self.update_stock_reservation_entries()
481482
self.update_stock_ledger()
482483

484+
self.split_asset_based_on_sale_qty()
485+
483486
self.process_asset_depreciation()
484487

485488
# this sequence because outstanding may get -ve
@@ -1402,6 +1405,51 @@ def check_prev_docstatus(self):
14021405
):
14031406
throw(_("Delivery Note {0} is not submitted").format(d.delivery_note))
14041407

1408+
def split_asset_based_on_sale_qty(self):
1409+
asset_qty_map = self.get_asset_qty()
1410+
for asset, qty in asset_qty_map.items():
1411+
if qty["actual_qty"] < qty["sale_qty"]:
1412+
frappe.throw(
1413+
_(
1414+
"Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)."
1415+
).format(asset, qty["actual_qty"])
1416+
)
1417+
1418+
remaining_qty = qty["actual_qty"] - qty["sale_qty"]
1419+
if remaining_qty > 0:
1420+
split_asset(asset, remaining_qty)
1421+
1422+
def get_asset_qty(self):
1423+
asset_qty_map = {}
1424+
1425+
assets = {row.asset for row in self.items if row.is_fixed_asset and row.asset}
1426+
if not assets or self.is_return:
1427+
return asset_qty_map
1428+
1429+
asset_actual_qty = dict(
1430+
frappe.db.get_all(
1431+
"Asset",
1432+
{"name": ["in", list(assets)]},
1433+
["name", "asset_quantity"],
1434+
as_list=True,
1435+
)
1436+
)
1437+
for row in self.items:
1438+
if row.is_fixed_asset and row.asset:
1439+
actual_qty = asset_actual_qty.get(row.asset)
1440+
if row.asset in asset_qty_map.keys():
1441+
asset_qty_map[row.asset]["sale_qty"] += flt(row.qty)
1442+
else:
1443+
asset_qty_map.setdefault(
1444+
row.asset,
1445+
{
1446+
"sale_qty": flt(row.qty),
1447+
"actual_qty": flt(actual_qty),
1448+
},
1449+
)
1450+
1451+
return asset_qty_map
1452+
14051453
def process_asset_depreciation(self):
14061454
if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1):
14071455
self.depreciate_asset_on_sale()

erpnext/assets/doctype/asset/asset.js

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ frappe.ui.form.on("Asset", {
111111
frm.add_custom_button(
112112
__("Sell Asset"),
113113
function () {
114-
frm.trigger("make_sales_invoice");
114+
frm.trigger("sell_asset");
115115
},
116116
__("Manage")
117117
);
@@ -523,22 +523,6 @@ frappe.ui.form.on("Asset", {
523523
}
524524
},
525525

526-
make_sales_invoice: function (frm) {
527-
frappe.call({
528-
args: {
529-
asset: frm.doc.name,
530-
item_code: frm.doc.item_code,
531-
company: frm.doc.company,
532-
serial_no: frm.doc.serial_no,
533-
},
534-
method: "erpnext.assets.doctype.asset.asset.make_sales_invoice",
535-
callback: function (r) {
536-
var doclist = frappe.model.sync(r.message);
537-
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
538-
},
539-
});
540-
},
541-
542526
create_asset_maintenance: function (frm) {
543527
frappe.call({
544528
args: {
@@ -587,6 +571,69 @@ frappe.ui.form.on("Asset", {
587571
});
588572
},
589573

574+
sell_asset: function (frm) {
575+
const make_sales_invoice = (sell_qty) => {
576+
frappe.call({
577+
method: "erpnext.assets.doctype.asset.asset.make_sales_invoice",
578+
args: {
579+
asset: frm.doc.name,
580+
item_code: frm.doc.item_code,
581+
company: frm.doc.company,
582+
serial_no: frm.doc.serial_no,
583+
sell_qty: sell_qty,
584+
},
585+
callback: function (r) {
586+
var doclist = frappe.model.sync(r.message);
587+
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
588+
},
589+
});
590+
};
591+
592+
let dialog = new frappe.ui.Dialog({
593+
title: __("Sell Asset"),
594+
fields: [
595+
{
596+
fieldname: "sell_qty",
597+
fieldtype: "Int",
598+
label: __("Sell Qty"),
599+
reqd: 1,
600+
},
601+
],
602+
});
603+
604+
dialog.set_primary_action(__("Sell"), function () {
605+
const dialog_data = dialog.get_values();
606+
const sell_qty = cint(dialog_data.sell_qty);
607+
const asset_qty = cint(frm.doc.asset_quantity);
608+
609+
if (sell_qty <= 0) {
610+
frappe.throw(__("Sell quantity must be greater than zero"));
611+
}
612+
613+
if (sell_qty > asset_qty) {
614+
frappe.throw(__("Sell quantity cannot exceed the asset quantity"));
615+
}
616+
617+
if (sell_qty < asset_qty) {
618+
frappe.confirm(
619+
__(
620+
"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. <br><br><b>Do you want to continue?</b>"
621+
),
622+
() => {
623+
make_sales_invoice(sell_qty);
624+
dialog.hide();
625+
}
626+
);
627+
return;
628+
}
629+
630+
make_sales_invoice(sell_qty);
631+
dialog.hide();
632+
});
633+
634+
dialog.show();
635+
},
636+
590637
split_asset: function (frm) {
591638
const title = __("Split Asset");
592639

erpnext/assets/doctype/asset/asset.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,9 @@ def validate_asset_values(self):
484484
frappe.throw(_("Available-for-use Date should be after purchase date"))
485485

486486
def validate_linked_purchase_documents(self):
487+
if self.flags.is_split_asset:
488+
return
489+
487490
for fieldname, doctype in [
488491
("purchase_receipt", "Purchase Receipt"),
489492
("purchase_invoice", "Purchase Invoice"),
@@ -1085,7 +1088,7 @@ def get_asset_naming_series():
10851088

10861089

10871090
@frappe.whitelist()
1088-
def make_sales_invoice(asset, item_code, company, serial_no=None, posting_date=None):
1091+
def make_sales_invoice(asset, item_code, company, sell_qty, serial_no=None):
10891092
asset_doc = frappe.get_doc("Asset", asset)
10901093
si = frappe.new_doc("Sales Invoice")
10911094
si.company = company
@@ -1100,7 +1103,7 @@ def make_sales_invoice(asset, item_code, company, serial_no=None, posting_date=N
11001103
"income_account": disposal_account,
11011104
"serial_no": serial_no,
11021105
"cost_center": depreciation_cost_center,
1103-
"qty": 1,
1106+
"qty": sell_qty,
11041107
},
11051108
)
11061109

@@ -1380,6 +1383,7 @@ def process_asset_split(existing_asset, split_qty, splitted_asset=None, is_new_a
13801383
scaling_factor = flt(split_qty) / flt(existing_asset.asset_quantity)
13811384
new_asset = frappe.copy_doc(existing_asset) if is_new_asset else splitted_asset
13821385
asset_doc = new_asset if is_new_asset else existing_asset
1386+
asset_doc.flags.is_split_asset = True
13831387

13841388
set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset)
13851389
log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset)

erpnext/assets/doctype/asset/test_asset.py

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,9 @@ def test_gle_made_by_asset_sale(self):
330330

331331
post_depreciation_entries(date=add_months(purchase_date, 2))
332332

333-
si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company")
333+
si = make_sales_invoice(
334+
asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=asset.asset_quantity
335+
)
334336
si.customer = "_Test Customer"
335337
si.due_date = date
336338
si.get("items")[0].rate = 25000
@@ -458,7 +460,9 @@ def test_asset_with_maintenance_required_status_after_sale(self):
458460

459461
post_depreciation_entries(date="2021-01-01")
460462

461-
si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company")
463+
si = make_sales_invoice(
464+
asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=asset.asset_quantity
465+
)
462466
si.customer = "_Test Customer"
463467
si.due_date = nowdate()
464468
si.get("items")[0].rate = 25000
@@ -698,6 +702,128 @@ def test_asset_cwip_toggling_cases(self):
698702
frappe.db.set_value("Asset Category Account", name, "capital_work_in_progress_account", cwip_acc)
699703
frappe.db.get_value("Company", "_Test Company", "capital_work_in_progress_account", cwip_acc)
700704

705+
def test_partial_asset_sale(self):
706+
date = nowdate()
707+
purchase_date = add_months(get_first_day(date), -2)
708+
depreciation_start_date = add_months(get_last_day(date), -2)
709+
710+
# create an asset
711+
asset = create_asset(
712+
item_code="Macbook Pro",
713+
is_existing_asset=1,
714+
calculate_depreciation=1,
715+
available_for_use_date=purchase_date,
716+
purchase_date=purchase_date,
717+
depreciation_start_date=depreciation_start_date,
718+
net_purchase_amount=1000000.0,
719+
purchase_amount=1000000.0,
720+
asset_quantity=10,
721+
total_number_of_depreciations=12,
722+
frequency_of_depreciation=1,
723+
submit=1,
724+
)
725+
asset_depr_schedule_before_sale = get_asset_depr_schedule_doc(asset.name, "Active")
726+
post_depreciation_entries(date)
727+
asset.reload()
728+
729+
# check asset values before sale
730+
self.assertEqual(asset.asset_quantity, 10)
731+
self.assertEqual(asset.net_purchase_amount, 1000000)
732+
self.assertEqual(asset.status, "Partially Depreciated")
733+
self.assertEqual(
734+
asset_depr_schedule_before_sale.depreciation_schedule[0].get("depreciation_amount"), 83333.33
735+
)
736+
737+
# make a partial sales against the asset
738+
si = make_sales_invoice(
739+
asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=5
740+
)
741+
si.customer = "_Test Customer"
742+
si.due_date = date
743+
si.get("items")[0].rate = 25000
744+
si.insert()
745+
si.submit()
746+
747+
asset.reload()
748+
asset_depr_schedule_after_sale = get_asset_depr_schedule_doc(asset.name, "Active")
749+
750+
# check asset values after sales
751+
self.assertEqual(asset.asset_quantity, 5)
752+
self.assertEqual(asset.net_purchase_amount, 500000)
753+
self.assertEqual(asset.status, "Sold")
754+
self.assertEqual(
755+
asset_depr_schedule_after_sale.depreciation_schedule[0].get("depreciation_amount"), 41666.66
756+
)
757+
758+
def test_asset_splitting_for_non_existing_asset(self):
759+
date = nowdate()
760+
purchase_date = add_months(get_first_day(date), -2)
761+
depreciation_start_date = add_months(get_last_day(date), -2)
762+
763+
asset_qty = 10
764+
asset_rate = 100000.0
765+
asset_item = "Macbook Pro"
766+
asset_location = "Test Location"
767+
768+
frappe.db.set_value("Item", asset_item, "is_grouped_asset", 1)
769+
770+
# Inward asset via Purchase Receipt
771+
pr = make_purchase_receipt(
772+
item_code="Macbook Pro",
773+
posting_date=purchase_date,
774+
qty=asset_qty,
775+
rate=asset_rate,
776+
location=asset_location,
777+
supplier="_Test Supplier",
778+
)
779+
780+
asset = frappe.db.get_value("Asset", {"purchase_receipt": pr.name, "docstatus": 0}, "name")
781+
asset_doc = frappe.get_doc("Asset", asset)
782+
asset_doc.calculate_depreciation = 1
783+
asset_doc.available_for_use_date = purchase_date
784+
asset_doc.location = asset_location
785+
asset_doc.append(
786+
"finance_books",
787+
{
788+
"expected_value_after_useful_life": 0,
789+
"depreciation_method": "Straight Line",
790+
"total_number_of_depreciations": 12,
791+
"frequency_of_depreciation": 1,
792+
"depreciation_start_date": depreciation_start_date,
793+
},
794+
)
795+
asset_doc.submit()
796+
797+
# check asset values before splitting
798+
asset_depr_schedule_before_splitting = get_asset_depr_schedule_doc(asset_doc.name, "Active")
799+
self.assertEqual(asset_doc.asset_quantity, 10)
800+
self.assertEqual(asset_doc.net_purchase_amount, 1000000)
801+
self.assertEqual(
802+
asset_depr_schedule_before_splitting.depreciation_schedule[0].get("depreciation_amount"), 83333.33
803+
)
804+
805+
# initate asset split
806+
new_asset = split_asset(asset_doc.name, 5)
807+
asset_doc.reload()
808+
asset_depr_schedule_after_sale = get_asset_depr_schedule_doc(asset_doc.name, "Active")
809+
new_asset_depr_schedule = get_asset_depr_schedule_doc(new_asset.name, "Active")
810+
811+
# check asset values after splitting
812+
self.assertEqual(asset_doc.asset_quantity, 5)
813+
self.assertEqual(asset_doc.net_purchase_amount, 500000)
814+
self.assertEqual(
815+
asset_depr_schedule_after_sale.depreciation_schedule[0].get("depreciation_amount"), 41666.66
816+
)
817+
818+
# check new asset values after splitting
819+
self.assertEqual(new_asset.asset_quantity, 5)
820+
self.assertEqual(new_asset.net_purchase_amount, 500000)
821+
self.assertEqual(
822+
new_asset_depr_schedule.depreciation_schedule[0].get("depreciation_amount"), 41666.66
823+
)
824+
825+
frappe.db.set_value("Item", asset_item, "is_grouped_asset", 0)
826+
701827

702828
class TestDepreciationMethods(AssetSetup):
703829
def test_schedule_for_straight_line_method(self):

erpnext/assets/doctype/asset_repair/test_asset_repair.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ def test_asset_status(self):
5151
submit=1,
5252
)
5353

54-
si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company")
54+
si = make_sales_invoice(
55+
asset=asset.name, item_code="Macbook Pro", company="_Test Company", sell_qty=asset.asset_quantity
56+
)
5557
si.customer = "_Test Customer"
5658
si.due_date = date
5759
si.get("items")[0].rate = 25000

0 commit comments

Comments
 (0)