diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py
index b8909483a6f..258035e4cf6 100644
--- a/.github/helper/documentation.py
+++ b/.github/helper/documentation.py
@@ -3,52 +3,71 @@ from urllib.parse import urlparse
import requests
-docs_repos = [
- "frappe_docs",
- "erpnext_documentation",
+WEBSITE_REPOS = [
"erpnext_com",
"frappe_io",
]
+DOCUMENTATION_DOMAINS = [
+ "docs.erpnext.com",
+ "frappeframework.com",
+]
-def uri_validator(x):
- result = urlparse(x)
- return all([result.scheme, result.netloc, result.path])
-def docs_link_exists(body):
- for line in body.splitlines():
- for word in line.split():
- if word.startswith('http') and uri_validator(word):
- parsed_url = urlparse(word)
- if parsed_url.netloc == "github.com":
- parts = parsed_url.path.split('/')
- if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos:
- return True
- elif parsed_url.netloc == "docs.erpnext.com":
- return True
+def is_valid_url(url: str) -> bool:
+ parts = urlparse(url)
+ return all((parts.scheme, parts.netloc, parts.path))
+
+
+def is_documentation_link(word: str) -> bool:
+ if not word.startswith("http") or not is_valid_url(word):
+ return False
+
+ parsed_url = urlparse(word)
+ if parsed_url.netloc in DOCUMENTATION_DOMAINS:
+ return True
+
+ if parsed_url.netloc == "github.com":
+ parts = parsed_url.path.split("/")
+ if len(parts) == 5 and parts[1] == "frappe" and parts[2] in WEBSITE_REPOS:
+ return True
+
+ return False
+
+
+def contains_documentation_link(body: str) -> bool:
+ return any(
+ is_documentation_link(word)
+ for line in body.splitlines()
+ for word in line.split()
+ )
+
+
+def check_pull_request(number: str) -> "tuple[int, str]":
+ response = requests.get(f"https://api.github.com/repos/frappe/erpnext/pulls/{number}")
+ if not response.ok:
+ return 1, "Pull Request Not Found! ⚠️"
+
+ payload = response.json()
+ title = (payload.get("title") or "").lower().strip()
+ head_sha = (payload.get("head") or {}).get("sha")
+ body = (payload.get("body") or "").lower()
+
+ if (
+ not title.startswith("feat")
+ or not head_sha
+ or "no-docs" in body
+ or "backport" in body
+ ):
+ return 0, "Skipping documentation checks... 🏃"
+
+ if contains_documentation_link(body):
+ return 0, "Documentation Link Found. You're Awesome! 🎉"
+
+ return 1, "Documentation Link Not Found! ⚠️"
if __name__ == "__main__":
- pr = sys.argv[1]
- response = requests.get("https://api.github.com/repos/frappe/erpnext/pulls/{}".format(pr))
-
- if response.ok:
- payload = response.json()
- title = (payload.get("title") or "").lower().strip()
- head_sha = (payload.get("head") or {}).get("sha")
- body = (payload.get("body") or "").lower()
-
- if (title.startswith("feat")
- and head_sha
- and "no-docs" not in body
- and "backport" not in body
- ):
- if docs_link_exists(body):
- print("Documentation Link Found. You're Awesome! 🎉")
-
- else:
- print("Documentation Link Not Found! ⚠️")
- sys.exit(1)
-
- else:
- print("Skipping documentation checks... 🏃")
+ exit_code, message = check_pull_request(sys.argv[1])
+ print(message)
+ sys.exit(exit_code)
diff --git a/.github/workflows/docs-checker.yml b/.github/workflows/docs-checker.yml
index db46c5621b2..722c1252ed9 100644
--- a/.github/workflows/docs-checker.yml
+++ b/.github/workflows/docs-checker.yml
@@ -12,7 +12,7 @@ jobs:
- name: 'Setup Environment'
uses: actions/setup-python@v2
with:
- python-version: 3.6
+ python-version: '3.10'
- name: 'Clone repo'
uses: actions/checkout@v2
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
index 0da44a464e7..3920d4cf096 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html
@@ -49,7 +49,6 @@
{% endif %}
- {{ _("Against") }}: {{ row.against }}
{{ _("Remarks") }}: {{ row.remarks }}
{% if row.bill_no %}
{{ _("Supplier Invoice No") }}: {{ row.bill_no }}
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 72e9790700f..dde3947bd9d 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -7,17 +7,7 @@ from frappe import _, msgprint, throw
from frappe.contacts.doctype.address.address import get_address_display
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
-from frappe.utils import (
- add_days,
- add_months,
- cint,
- cstr,
- flt,
- formatdate,
- get_link_to_form,
- getdate,
- nowdate,
-)
+from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
from six import iteritems
import erpnext
@@ -33,10 +23,12 @@ from erpnext.accounts.general_ledger import get_round_off_account_and_cost_cente
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details
from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.depreciation import (
+ depreciate_asset,
get_disposal_account_and_cost_center,
get_gl_entries_on_asset_disposal,
get_gl_entries_on_asset_regain,
- make_depreciation_entry,
+ reset_depreciation_schedule,
+ reverse_depreciation_entry_made_after_disposal,
)
from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.selling_controller import SellingController
@@ -1114,18 +1106,20 @@ class SalesInvoice(SellingController):
asset = self.get_asset(item)
if self.is_return:
- if asset.calculate_depreciation:
- self.reverse_depreciation_entry_made_after_sale(asset)
- self.reset_depreciation_schedule(asset)
-
fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
asset, item.base_net_amount, item.finance_book
)
asset.db_set("disposal_date", None)
+ if asset.calculate_depreciation:
+ posting_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
+ reverse_depreciation_entry_made_after_disposal(asset, posting_date)
+ reset_depreciation_schedule(asset, self.posting_date)
+
else:
if asset.calculate_depreciation:
- self.depreciate_asset(asset)
+ depreciate_asset(asset, self.posting_date)
+ asset.reload()
fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
asset, item.base_net_amount, item.finance_book
@@ -1193,95 +1187,6 @@ class SalesInvoice(SellingController):
_("Select finance book for the item {0} at row {1}").format(item.item_code, item.idx)
)
- def depreciate_asset(self, asset):
- asset.flags.ignore_validate_update_after_submit = True
- asset.prepare_depreciation_data(date_of_sale=self.posting_date)
- asset.save()
-
- make_depreciation_entry(asset.name, self.posting_date)
- asset.load_from_db()
-
- def reset_depreciation_schedule(self, asset):
- asset.flags.ignore_validate_update_after_submit = True
-
- # recreate original depreciation schedule of the asset
- asset.prepare_depreciation_data(date_of_return=self.posting_date)
-
- self.modify_depreciation_schedule_for_asset_repairs(asset)
- asset.save()
- asset.load_from_db()
-
- def modify_depreciation_schedule_for_asset_repairs(self, asset):
- asset_repairs = frappe.get_all(
- "Asset Repair", filters={"asset": asset.name}, fields=["name", "increase_in_asset_life"]
- )
-
- for repair in asset_repairs:
- if repair.increase_in_asset_life:
- asset_repair = frappe.get_doc("Asset Repair", repair.name)
- asset_repair.modify_depreciation_schedule()
- asset.prepare_depreciation_data()
-
- def reverse_depreciation_entry_made_after_sale(self, asset):
- from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
-
- posting_date_of_original_invoice = self.get_posting_date_of_sales_invoice()
-
- row = -1
- finance_book = asset.get("schedules")[0].get("finance_book")
- for schedule in asset.get("schedules"):
- if schedule.finance_book != finance_book:
- row = 0
- finance_book = schedule.finance_book
- else:
- row += 1
-
- if schedule.schedule_date == posting_date_of_original_invoice:
- if not self.sale_was_made_on_original_schedule_date(
- asset, schedule, row, posting_date_of_original_invoice
- ) or self.sale_happens_in_the_future(posting_date_of_original_invoice):
-
- reverse_journal_entry = make_reverse_journal_entry(schedule.journal_entry)
- reverse_journal_entry.posting_date = nowdate()
- frappe.flags.is_reverse_depr_entry = True
- reverse_journal_entry.submit()
-
- frappe.flags.is_reverse_depr_entry = False
- asset.flags.ignore_validate_update_after_submit = True
- schedule.journal_entry = None
- depreciation_amount = self.get_depreciation_amount_in_je(reverse_journal_entry)
- asset.finance_books[0].value_after_depreciation += depreciation_amount
- asset.save()
-
- def get_posting_date_of_sales_invoice(self):
- return frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
-
- # if the invoice had been posted on the date the depreciation was initially supposed to happen, the depreciation shouldn't be undone
- def sale_was_made_on_original_schedule_date(
- self, asset, schedule, row, posting_date_of_original_invoice
- ):
- for finance_book in asset.get("finance_books"):
- if schedule.finance_book == finance_book.finance_book:
- orginal_schedule_date = add_months(
- finance_book.depreciation_start_date, row * cint(finance_book.frequency_of_depreciation)
- )
-
- if orginal_schedule_date == posting_date_of_original_invoice:
- return True
- return False
-
- def sale_happens_in_the_future(self, posting_date_of_original_invoice):
- if posting_date_of_original_invoice > getdate():
- return True
-
- return False
-
- def get_depreciation_amount_in_je(self, journal_entry):
- if journal_entry.accounts[0].debit_in_account_currency:
- return journal_entry.accounts[0].debit_in_account_currency
- else:
- return journal_entry.accounts[0].credit_in_account_currency
-
@property
def enable_discount_accounting(self):
if not hasattr(self, "_enable_discount_accounting"):
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 79a67b83631..6035e86d067 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -1117,6 +1117,46 @@ class TestSalesInvoice(unittest.TestCase):
frappe.db.sql("delete from `tabPOS Profile`")
+ def test_bin_details_of_packed_item(self):
+ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ # test Update Items with product bundle
+ if not frappe.db.exists("Item", "_Test Product Bundle Item New"):
+ bundle_item = make_item("_Test Product Bundle Item New", {"is_stock_item": 0})
+ bundle_item.append(
+ "item_defaults", {"company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC"}
+ )
+ bundle_item.save(ignore_permissions=True)
+
+ make_item("_Packed Item New 1", {"is_stock_item": 1})
+ make_product_bundle("_Test Product Bundle Item New", ["_Packed Item New 1"], 2)
+
+ si = create_sales_invoice(
+ item_code="_Test Product Bundle Item New",
+ update_stock=1,
+ warehouse="_Test Warehouse - _TC",
+ transaction_date=add_days(nowdate(), -1),
+ do_not_submit=1,
+ )
+
+ make_stock_entry(item="_Packed Item New 1", target="_Test Warehouse - _TC", qty=120, rate=100)
+
+ bin_details = frappe.db.get_value(
+ "Bin",
+ {"item_code": "_Packed Item New 1", "warehouse": "_Test Warehouse - _TC"},
+ ["actual_qty", "projected_qty", "ordered_qty"],
+ as_dict=1,
+ )
+
+ si.transaction_date = nowdate()
+ si.save()
+
+ packed_item = si.packed_items[0]
+ self.assertEqual(flt(bin_details.actual_qty), flt(packed_item.actual_qty))
+ self.assertEqual(flt(bin_details.projected_qty), flt(packed_item.projected_qty))
+ self.assertEqual(flt(bin_details.ordered_qty), flt(packed_item.ordered_qty))
+
def test_pos_si_without_payment(self):
make_pos_profile()
diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py
index 1eb257ac853..7d2db26c25f 100644
--- a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py
+++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py
@@ -378,15 +378,14 @@ class Deferred_Revenue_and_Expense_Report(object):
ret += [{}]
# add total row
- if ret is not []:
- if self.filters.type == "Revenue":
- total_row = frappe._dict({"name": "Total Deferred Income"})
- elif self.filters.type == "Expense":
- total_row = frappe._dict({"name": "Total Deferred Expense"})
+ if self.filters.type == "Revenue":
+ total_row = frappe._dict({"name": "Total Deferred Income"})
+ elif self.filters.type == "Expense":
+ total_row = frappe._dict({"name": "Total Deferred Expense"})
- for idx, period in enumerate(self.period_list, 0):
- total_row[period.key] = self.period_total[idx].total
- ret.append(total_row)
+ for idx, period in enumerate(self.period_list, 0):
+ total_row[period.key] = self.period_total[idx].total
+ ret.append(total_row)
return ret
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.html b/erpnext/accounts/report/general_ledger/general_ledger.html
index 378fa3791c1..1aad36ca909 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.html
+++ b/erpnext/accounts/report/general_ledger/general_ledger.html
@@ -25,8 +25,8 @@
| {%= __("Date") %} |
- {%= __("Ref") %} |
- {%= __("Party") %} |
+ {%= __("Reference") %} |
+ {%= __("Remarks") %} |
{%= __("Debit") %} |
{%= __("Credit") %} |
{%= __("Balance (Dr - Cr)") %} |
@@ -45,7 +45,6 @@
{% } %}
- {{ __("Against") }}: {%= data[i].against %}
{%= __("Remarks") %}: {%= data[i].remarks %}
{% if(data[i].bill_no) { %}
{%= __("Supplier Invoice No") %}: {%= data[i].bill_no %}
diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js
index 1473b79bea5..587cb147b79 100644
--- a/erpnext/assets/doctype/asset/asset.js
+++ b/erpnext/assets/doctype/asset/asset.js
@@ -202,6 +202,10 @@ frappe.ui.form.on('Asset', {
},
setup_chart: function(frm) {
+ if(frm.doc.finance_books.length > 1) {
+ return
+ }
+
var x_intervals = [frm.doc.purchase_date];
var asset_values = [frm.doc.gross_purchase_amount];
var last_depreciation_date = frm.doc.purchase_date;
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 59187d36865..af32f93cf05 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -79,12 +79,12 @@ class Asset(AccountsController):
_("Purchase Invoice cannot be made against an existing asset {0}").format(self.name)
)
- def prepare_depreciation_data(self, date_of_sale=None, date_of_return=None):
+ def prepare_depreciation_data(self, date_of_disposal=None, date_of_return=None):
if self.calculate_depreciation:
self.value_after_depreciation = 0
self.set_depreciation_rate()
- self.make_depreciation_schedule(date_of_sale)
- self.set_accumulated_depreciation(date_of_sale, date_of_return)
+ self.make_depreciation_schedule(date_of_disposal)
+ self.set_accumulated_depreciation(date_of_disposal, date_of_return)
else:
self.finance_books = []
self.value_after_depreciation = flt(self.gross_purchase_amount) - flt(
@@ -223,7 +223,7 @@ class Asset(AccountsController):
self.get_depreciation_rate(d, on_validate=True), d.precision("rate_of_depreciation")
)
- def make_depreciation_schedule(self, date_of_sale):
+ def make_depreciation_schedule(self, date_of_disposal):
if "Manual" not in [d.depreciation_method for d in self.finance_books] and not self.get(
"schedules"
):
@@ -279,17 +279,17 @@ class Asset(AccountsController):
monthly_schedule_date = add_months(schedule_date, -finance_book.frequency_of_depreciation + 1)
# if asset is being sold
- if date_of_sale:
+ if date_of_disposal:
from_date = self.get_from_date(finance_book.finance_book)
depreciation_amount, days, months = self.get_pro_rata_amt(
- finance_book, depreciation_amount, from_date, date_of_sale
+ finance_book, depreciation_amount, from_date, date_of_disposal
)
if depreciation_amount > 0:
self.append(
"schedules",
{
- "schedule_date": date_of_sale,
+ "schedule_date": date_of_disposal,
"depreciation_amount": depreciation_amount,
"depreciation_method": finance_book.depreciation_method,
"finance_book": finance_book.finance_book,
@@ -364,6 +364,9 @@ class Asset(AccountsController):
},
)
+ if len(self.get("finance_books")) > 1 and any(start):
+ self.sort_depreciation_schedule()
+
# depreciation schedules need to be cleared before modification due to increase in asset life/asset sales
# JE: Journal Entry, FB: Finance Book
def clear_depreciation_schedule(self):
@@ -399,6 +402,14 @@ class Asset(AccountsController):
return start
+ def sort_depreciation_schedule(self):
+ self.schedules = sorted(
+ self.schedules, key=lambda s: (int(s.finance_book_id), getdate(s.schedule_date))
+ )
+
+ for idx, s in enumerate(self.schedules, 1):
+ s.idx = idx
+
def get_from_date(self, finance_book):
if not self.get("schedules"):
return self.available_for_use_date
@@ -531,7 +542,7 @@ class Asset(AccountsController):
return True
def set_accumulated_depreciation(
- self, date_of_sale=None, date_of_return=None, ignore_booked_entry=False
+ self, date_of_disposal=None, date_of_return=None, ignore_booked_entry=False
):
straight_line_idx = [
d.idx for d in self.get("schedules") if d.depreciation_method == "Straight Line"
@@ -554,7 +565,7 @@ class Asset(AccountsController):
if (
straight_line_idx
and i == max(straight_line_idx) - 1
- and not date_of_sale
+ and not date_of_disposal
and not date_of_return
):
book = self.get("finance_books")[cint(d.finance_book_id) - 1]
diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py
index f8f581d8ae2..66dd2e98dc5 100644
--- a/erpnext/assets/doctype/asset/depreciation.py
+++ b/erpnext/assets/doctype/asset/depreciation.py
@@ -4,7 +4,7 @@
import frappe
from frappe import _
-from frappe.utils import cint, flt, get_link_to_form, getdate, today
+from frappe.utils import add_months, cint, flt, get_link_to_form, getdate, nowdate, today
from frappe.utils.user import get_users_with_role
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -247,6 +247,11 @@ def scrap_asset(asset_name):
_("Asset {0} cannot be scrapped, as it is already {1}").format(asset.name, asset.status)
)
+ date = today()
+
+ depreciate_asset(asset, date)
+ asset.reload()
+
depreciation_series = frappe.get_cached_value(
"Company", asset.company, "series_for_depreciation_entry"
)
@@ -254,7 +259,7 @@ def scrap_asset(asset_name):
je = frappe.new_doc("Journal Entry")
je.voucher_type = "Journal Entry"
je.naming_series = depreciation_series
- je.posting_date = today()
+ je.posting_date = date
je.company = asset.company
je.remark = "Scrap Entry for asset {0}".format(asset_name)
@@ -265,7 +270,7 @@ def scrap_asset(asset_name):
je.flags.ignore_permissions = True
je.submit()
- frappe.db.set_value("Asset", asset_name, "disposal_date", today())
+ frappe.db.set_value("Asset", asset_name, "disposal_date", date)
frappe.db.set_value("Asset", asset_name, "journal_entry_for_scrap", je.name)
asset.set_status("Scrapped")
@@ -276,6 +281,9 @@ def scrap_asset(asset_name):
def restore_asset(asset_name):
asset = frappe.get_doc("Asset", asset_name)
+ reverse_depreciation_entry_made_after_disposal(asset, asset.disposal_date)
+ reset_depreciation_schedule(asset, asset.disposal_date)
+
je = asset.journal_entry_for_scrap
asset.db_set("disposal_date", None)
@@ -286,6 +294,96 @@ def restore_asset(asset_name):
asset.set_status()
+def depreciate_asset(asset, date):
+ asset.flags.ignore_validate_update_after_submit = True
+ asset.prepare_depreciation_data(date_of_disposal=date)
+ asset.save()
+
+ make_depreciation_entry(asset.name, date)
+
+
+def reset_depreciation_schedule(asset, date):
+ asset.flags.ignore_validate_update_after_submit = True
+
+ # recreate original depreciation schedule of the asset
+ asset.prepare_depreciation_data(date_of_return=date)
+
+ modify_depreciation_schedule_for_asset_repairs(asset)
+ asset.save()
+
+
+def modify_depreciation_schedule_for_asset_repairs(asset):
+ asset_repairs = frappe.get_all(
+ "Asset Repair", filters={"asset": asset.name}, fields=["name", "increase_in_asset_life"]
+ )
+
+ for repair in asset_repairs:
+ if repair.increase_in_asset_life:
+ asset_repair = frappe.get_doc("Asset Repair", repair.name)
+ asset_repair.modify_depreciation_schedule()
+ asset.prepare_depreciation_data()
+
+
+def reverse_depreciation_entry_made_after_disposal(asset, date):
+ from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
+
+ row = -1
+ finance_book = asset.get("schedules")[0].get("finance_book")
+ for schedule in asset.get("schedules"):
+ if schedule.finance_book != finance_book:
+ row = 0
+ finance_book = schedule.finance_book
+ else:
+ row += 1
+
+ if schedule.schedule_date == date:
+ if not disposal_was_made_on_original_schedule_date(
+ asset, schedule, row, date
+ ) or disposal_happens_in_the_future(date):
+
+ reverse_journal_entry = make_reverse_journal_entry(schedule.journal_entry)
+ reverse_journal_entry.posting_date = nowdate()
+ frappe.flags.is_reverse_depr_entry = True
+ reverse_journal_entry.submit()
+
+ frappe.flags.is_reverse_depr_entry = False
+ asset.flags.ignore_validate_update_after_submit = True
+ schedule.journal_entry = None
+ depreciation_amount = get_depreciation_amount_in_je(reverse_journal_entry)
+
+ idx = cint(schedule.finance_book_id)
+ asset.finance_books[idx - 1].value_after_depreciation += depreciation_amount
+
+ asset.save()
+
+
+def get_depreciation_amount_in_je(journal_entry):
+ if journal_entry.accounts[0].debit_in_account_currency:
+ return journal_entry.accounts[0].debit_in_account_currency
+ else:
+ return journal_entry.accounts[0].credit_in_account_currency
+
+
+# if the invoice had been posted on the date the depreciation was initially supposed to happen, the depreciation shouldn't be undone
+def disposal_was_made_on_original_schedule_date(asset, schedule, row, posting_date_of_disposal):
+ for finance_book in asset.get("finance_books"):
+ if schedule.finance_book == finance_book.finance_book:
+ orginal_schedule_date = add_months(
+ finance_book.depreciation_start_date, row * cint(finance_book.frequency_of_depreciation)
+ )
+
+ if orginal_schedule_date == posting_date_of_disposal:
+ return True
+ return False
+
+
+def disposal_happens_in_the_future(posting_date_of_disposal):
+ if posting_date_of_disposal > getdate():
+ return True
+
+ return False
+
+
def get_gl_entries_on_asset_regain(asset, selling_amount=0, finance_book=None):
(
fixed_asset_account,
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index 26db6396df3..d133cebedb8 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -4,7 +4,16 @@
import unittest
import frappe
-from frappe.utils import add_days, add_months, cstr, flt, get_last_day, getdate, nowdate
+from frappe.utils import (
+ add_days,
+ add_months,
+ cstr,
+ flt,
+ get_first_day,
+ get_last_day,
+ getdate,
+ nowdate,
+)
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.assets.doctype.asset.asset import make_sales_invoice, update_maintenance_status
@@ -153,28 +162,59 @@ class TestAsset(AssetSetup):
self.assertEqual(doc.items[0].is_fixed_asset, 1)
def test_scrap_asset(self):
+ date = nowdate()
+ purchase_date = add_months(get_first_day(date), -2)
+
asset = create_asset(
calculate_depreciation=1,
- available_for_use_date="2020-01-01",
- purchase_date="2020-01-01",
+ available_for_use_date=purchase_date,
+ purchase_date=purchase_date,
expected_value_after_useful_life=10000,
total_number_of_depreciations=10,
frequency_of_depreciation=1,
submit=1,
)
- post_depreciation_entries(date=add_months("2020-01-01", 4))
+ post_depreciation_entries(date=add_months(purchase_date, 2))
+ asset.load_from_db()
+
+ accumulated_depr_amount = flt(
+ asset.gross_purchase_amount - asset.finance_books[0].value_after_depreciation,
+ asset.precision("gross_purchase_amount"),
+ )
+ self.assertEquals(accumulated_depr_amount, 18000.0)
scrap_asset(asset.name)
-
asset.load_from_db()
+
+ accumulated_depr_amount = flt(
+ asset.gross_purchase_amount - asset.finance_books[0].value_after_depreciation,
+ asset.precision("gross_purchase_amount"),
+ )
+ pro_rata_amount, _, _ = asset.get_pro_rata_amt(
+ asset.finance_books[0], 9000, get_last_day(add_months(purchase_date, 1)), date
+ )
+ pro_rata_amount = flt(pro_rata_amount, asset.precision("gross_purchase_amount"))
+ self.assertEquals(
+ accumulated_depr_amount,
+ flt(18000.0 + pro_rata_amount, asset.precision("gross_purchase_amount")),
+ )
+
self.assertEqual(asset.status, "Scrapped")
self.assertTrue(asset.journal_entry_for_scrap)
expected_gle = (
- ("_Test Accumulated Depreciations - _TC", 36000.0, 0.0),
+ (
+ "_Test Accumulated Depreciations - _TC",
+ flt(18000.0 + pro_rata_amount, asset.precision("gross_purchase_amount")),
+ 0.0,
+ ),
("_Test Fixed Asset - _TC", 0.0, 100000.0),
- ("_Test Gain/Loss on Asset Disposal - _TC", 64000.0, 0.0),
+ (
+ "_Test Gain/Loss on Asset Disposal - _TC",
+ flt(82000.0 - pro_rata_amount, asset.precision("gross_purchase_amount")),
+ 0.0,
+ ),
)
gle = frappe.db.sql(
@@ -183,7 +223,7 @@ class TestAsset(AssetSetup):
order by account""",
asset.journal_entry_for_scrap,
)
- self.assertEqual(gle, expected_gle)
+ self.assertSequenceEqual(gle, expected_gle)
restore_asset(asset.name)
@@ -191,34 +231,57 @@ class TestAsset(AssetSetup):
self.assertFalse(asset.journal_entry_for_scrap)
self.assertEqual(asset.status, "Partially Depreciated")
+ accumulated_depr_amount = flt(
+ asset.gross_purchase_amount - asset.finance_books[0].value_after_depreciation,
+ asset.precision("gross_purchase_amount"),
+ )
+ this_month_depr_amount = 9000.0 if is_last_day_of_the_month(date) else 0
+
+ self.assertEquals(accumulated_depr_amount, 18000.0 + this_month_depr_amount)
+
def test_gle_made_by_asset_sale(self):
+ date = nowdate()
+ purchase_date = add_months(get_first_day(date), -2)
+
asset = create_asset(
calculate_depreciation=1,
- available_for_use_date="2020-06-06",
- purchase_date="2020-01-01",
+ available_for_use_date=purchase_date,
+ purchase_date=purchase_date,
expected_value_after_useful_life=10000,
- total_number_of_depreciations=3,
- frequency_of_depreciation=10,
- depreciation_start_date="2020-12-31",
+ total_number_of_depreciations=10,
+ frequency_of_depreciation=1,
submit=1,
)
- post_depreciation_entries(date="2021-01-01")
+
+ post_depreciation_entries(date=add_months(purchase_date, 2))
si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company")
si.customer = "_Test Customer"
- si.set_posting_time = 1
- si.posting_date = "2021-10-31"
- si.due_date = "2021-10-31"
- si.get("items")[0].rate = 75000
+ si.due_date = nowdate()
+ si.get("items")[0].rate = 25000
+ si.insert()
si.submit()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
+ pro_rata_amount, _, _ = asset.get_pro_rata_amt(
+ asset.finance_books[0], 9000, get_last_day(add_months(purchase_date, 1)), date
+ )
+ pro_rata_amount = flt(pro_rata_amount, asset.precision("gross_purchase_amount"))
+
expected_gle = (
- ("_Test Accumulated Depreciations - _TC", 50490.2, 0.0),
+ (
+ "_Test Accumulated Depreciations - _TC",
+ flt(18000.0 + pro_rata_amount, asset.precision("gross_purchase_amount")),
+ 0.0,
+ ),
("_Test Fixed Asset - _TC", 0.0, 100000.0),
- ("_Test Gain/Loss on Asset Disposal - _TC", 0.0, 25490.2),
- ("Debtors - _TC", 75000.0, 0.0),
+ (
+ "_Test Gain/Loss on Asset Disposal - _TC",
+ flt(57000.0 - pro_rata_amount, asset.precision("gross_purchase_amount")),
+ 0.0,
+ ),
+ ("Debtors - _TC", 25000.0, 0.0),
)
gle = frappe.db.sql(
@@ -228,14 +291,9 @@ class TestAsset(AssetSetup):
si.name,
)
- for i, gle_entry in enumerate(gle):
- self.assertEqual(gle_entry[0], expected_gle[i][0])
- self.assertEqual(flt(gle_entry[1], 1), flt(expected_gle[i][1], 1))
- self.assertEqual(flt(gle_entry[2], 1), flt(expected_gle[i][2], 1))
+ self.assertSequenceEqual(gle, expected_gle)
- si.load_from_db()
si.cancel()
-
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
def test_asset_with_maintenance_required_status_after_sale(self):
@@ -1471,3 +1529,9 @@ def set_depreciation_settings_in_company():
def enable_cwip_accounting(asset_category, enable=1):
frappe.db.set_value("Asset Category", asset_category, "enable_cwip_accounting", enable)
+
+
+def is_last_day_of_the_month(dt):
+ last_day_of_the_month = get_last_day(dt)
+
+ return getdate(dt) == getdate(last_day_of_the_month)
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index 590370e808c..2e6df8525cc 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -1239,6 +1239,11 @@ class TestPurchaseOrder(FrappeTestCase):
automatically_fetch_payment_terms(enable=0)
+ def test_variant_item_po(self):
+ po = create_purchase_order(item_code="_Test Variant Item", qty=1, rate=100, do_not_save=1)
+
+ self.assertRaises(frappe.ValidationError, po.save)
+
def make_pr_against_po(po, received_qty=0):
pr = make_purchase_receipt(po)
@@ -1342,8 +1347,8 @@ def create_purchase_order(**args):
},
)
- po.set_missing_values()
if not args.do_not_save:
+ po.set_missing_values()
po.insert()
if not args.do_not_submit:
if po.is_subcontracted == "Yes":
diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py
index 57f8a3e1513..c52a2dfa95b 100644
--- a/erpnext/controllers/selling_controller.py
+++ b/erpnext/controllers/selling_controller.py
@@ -25,7 +25,7 @@ class SellingController(StockController):
def onload(self):
super(SellingController, self).onload()
if self.doctype in ("Sales Order", "Delivery Note", "Sales Invoice"):
- for item in self.get("items"):
+ for item in self.get("items") + (self.get("packed_items") or []):
item.update(get_bin_details(item.item_code, item.warehouse, include_child_warehouses=True))
def validate(self):
diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py
index 62e90ae747d..23dad95fa0d 100644
--- a/erpnext/controllers/status_updater.py
+++ b/erpnext/controllers/status_updater.py
@@ -58,7 +58,7 @@ status_map = {
"eval:(self.per_delivered == 100 or self.skip_delivery_note) and self.per_billed == 100 and self.docstatus == 1",
],
["Cancelled", "eval:self.docstatus==2"],
- ["Closed", "eval:self.status=='Closed'"],
+ ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
["On Hold", "eval:self.status=='On Hold'"],
],
"Purchase Order": [
@@ -79,7 +79,7 @@ status_map = {
["Delivered", "eval:self.status=='Delivered'"],
["Cancelled", "eval:self.docstatus==2"],
["On Hold", "eval:self.status=='On Hold'"],
- ["Closed", "eval:self.status=='Closed'"],
+ ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
],
"Delivery Note": [
["Draft", None],
@@ -87,7 +87,7 @@ status_map = {
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
["Cancelled", "eval:self.docstatus==2"],
- ["Closed", "eval:self.status=='Closed'"],
+ ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
],
"Purchase Receipt": [
["Draft", None],
@@ -95,7 +95,7 @@ status_map = {
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
["Cancelled", "eval:self.docstatus==2"],
- ["Closed", "eval:self.status=='Closed'"],
+ ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
],
"Material Request": [
["Draft", None],
diff --git a/erpnext/e_commerce/doctype/website_item/test_website_item.py b/erpnext/e_commerce/doctype/website_item/test_website_item.py
index 18e18dd31a9..ebf01bf43fb 100644
--- a/erpnext/e_commerce/doctype/website_item/test_website_item.py
+++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py
@@ -173,7 +173,10 @@ class TestWebsiteItem(unittest.TestCase):
# Website Item Portal Tests Begin
def test_website_item_breadcrumbs(self):
- "Check if breadcrumbs include homepage, product listing navigation page, parent item group(s) and item group."
+ """
+ Check if breadcrumbs include homepage, product listing navigation page,
+ parent item group(s) and item group
+ """
from erpnext.setup.doctype.item_group.item_group import get_parent_item_groups
item_code = "Test Breadcrumb Item"
@@ -196,7 +199,7 @@ class TestWebsiteItem(unittest.TestCase):
breadcrumbs = get_parent_item_groups(item.item_group)
self.assertEqual(breadcrumbs[0]["name"], "Home")
- self.assertEqual(breadcrumbs[1]["name"], "Shop by Category")
+ self.assertEqual(breadcrumbs[1]["name"], "All Products")
self.assertEqual(breadcrumbs[2]["name"], "_Test Item Group B") # parent item group
self.assertEqual(breadcrumbs[3]["name"], "_Test Item Group B - 1")
diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.js b/erpnext/manufacturing/report/work_order_summary/work_order_summary.js
index 832be2301c1..67bd24dd805 100644
--- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.js
+++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.js
@@ -13,38 +13,24 @@ frappe.query_reports["Work Order Summary"] = {
reqd: 1
},
{
- fieldname: "fiscal_year",
- label: __("Fiscal Year"),
- fieldtype: "Link",
- options: "Fiscal Year",
- default: frappe.defaults.get_user_default("fiscal_year"),
- reqd: 1,
- on_change: function(query_report) {
- var fiscal_year = query_report.get_values().fiscal_year;
- if (!fiscal_year) {
- return;
- }
- frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) {
- var fy = frappe.model.get_doc("Fiscal Year", fiscal_year);
- frappe.query_report.set_filter_value({
- from_date: fy.year_start_date,
- to_date: fy.year_end_date
- });
- });
- }
+ label: __("Based On"),
+ fieldname:"based_on",
+ fieldtype: "Select",
+ options: "Creation Date\nPlanned Date\nActual Date",
+ default: "Creation Date"
},
{
label: __("From Posting Date"),
fieldname:"from_date",
fieldtype: "Date",
- default: frappe.defaults.get_user_default("year_start_date"),
+ default: frappe.datetime.add_months(frappe.datetime.get_today(), -3),
reqd: 1
},
{
label: __("To Posting Date"),
fieldname:"to_date",
fieldtype: "Date",
- default: frappe.defaults.get_user_default("year_end_date"),
+ default: frappe.datetime.get_today(),
reqd: 1,
},
{
diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
index 08a7e0ccd39..6544c75958e 100644
--- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
+++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py
@@ -31,6 +31,7 @@ def get_data(filters):
"sales_order",
"production_item",
"qty",
+ "creation",
"produced_qty",
"planned_start_date",
"planned_end_date",
@@ -47,8 +48,14 @@ def get_data(filters):
if filters.get(field):
query_filters[field] = filters.get(field)
- query_filters["planned_start_date"] = (">=", filters.get("from_date"))
- query_filters["planned_end_date"] = ("<=", filters.get("to_date"))
+ if filters.get("based_on") == "Planned Date":
+ query_filters["planned_start_date"] = (">=", filters.get("from_date"))
+ query_filters["planned_end_date"] = ("<=", filters.get("to_date"))
+ elif filters.get("based_on") == "Actual Date":
+ query_filters["actual_start_date"] = (">=", filters.get("from_date"))
+ query_filters["actual_end_date"] = ("<=", filters.get("to_date"))
+ else:
+ query_filters["creation"] = ("between", [filters.get("from_date"), filters.get("to_date")])
data = frappe.get_all(
"Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc"
@@ -212,6 +219,12 @@ def get_columns(filters):
"options": "Sales Order",
"width": 90,
},
+ {
+ "label": _("Created On"),
+ "fieldname": "creation",
+ "fieldtype": "Date",
+ "width": 150,
+ },
{
"label": _("Planned Start Date"),
"fieldname": "planned_start_date",
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 47277cc48ce..f49e06675dd 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -374,4 +374,4 @@ erpnext.patches.v13_0.reset_corrupt_defaults
erpnext.patches.v13_0.show_hr_payroll_deprecation_warning
erpnext.patches.v13_0.create_accounting_dimensions_for_asset_repair
execute:frappe.db.set_value("Naming Series", "Naming Series", {"select_doc_for_series": "", "set_options": "", "prefix": "", "current_value": 0, "user_must_always_select": 0})
-erpnext.patches.v13_0.update_schedule_type_in_loans
\ No newline at end of file
+erpnext.patches.v13_0.update_schedule_type_in_loans
diff --git a/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py b/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py
index f6427ca55a6..bf48d3ab04a 100644
--- a/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py
+++ b/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py
@@ -7,6 +7,7 @@ from erpnext.stock.stock_ledger import update_entries_after
def execute():
doctypes_to_reload = [
+ ("setup", "company"),
("stock", "repost_item_valuation"),
("stock", "stock_entry_detail"),
("stock", "purchase_receipt_item"),
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index c75e7b77f70..92796073be7 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -115,24 +115,16 @@ erpnext.taxes_and_totals = erpnext.payments.extend({
calculate_item_values: function() {
let me = this;
if (!this.discount_amount_applied) {
- $.each(this.frm.doc["items"] || [], function(i, item) {
+ for (const item of this.frm.doc.items || []) {
frappe.model.round_floats_in(item);
item.net_rate = item.rate;
-
- if ((!item.qty) && me.frm.doc.is_return) {
- item.amount = flt(item.rate * -1, precision("amount", item));
- } else if ((!item.qty) && me.frm.doc.is_debit_note) {
- item.amount = flt(item.rate, precision("amount", item));
- } else {
- item.amount = flt(item.rate * item.qty, precision("amount", item));
- }
-
- item.net_amount = item.amount;
+ item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;
+ item.net_amount = item.amount = flt(item.rate * item.qty, precision("amount", item));
item.item_tax_amount = 0.0;
item.total_weight = flt(item.weight_per_unit * item.stock_qty);
me.set_in_company_currency(item, ["price_list_rate", "rate", "amount", "net_rate", "net_amount"]);
- });
+ }
}
},
diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py
index 45ceb1566c6..251404a46a1 100644
--- a/erpnext/regional/india/e_invoice/utils.py
+++ b/erpnext/regional/india/e_invoice/utils.py
@@ -71,7 +71,11 @@ def validate_eligibility(doc):
# if export invoice, then taxes can be empty
# invoice can only be ineligible if no taxes applied and is not an export invoice
- no_taxes_applied = not doc.get("taxes") and not doc.get("gst_category") == "Overseas"
+ no_taxes_applied = (
+ not doc.get("taxes")
+ and not doc.get("gst_category") == "Overseas"
+ and not doc.get("gst_category") == "SEZ"
+ )
has_non_gst_item = any(d for d in doc.get("items", []) if d.get("is_non_gst"))
if (
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 8bb25d4538c..a1c6cb5cd6c 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -546,6 +546,42 @@ class TestSalesOrder(FrappeTestCase):
workflow.is_active = 0
workflow.save()
+ def test_bin_details_of_packed_item(self):
+ # test Update Items with product bundle
+ if not frappe.db.exists("Item", "_Test Product Bundle Item New"):
+ bundle_item = make_item("_Test Product Bundle Item New", {"is_stock_item": 0})
+ bundle_item.append(
+ "item_defaults", {"company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC"}
+ )
+ bundle_item.save(ignore_permissions=True)
+
+ make_item("_Packed Item New 1", {"is_stock_item": 1})
+ make_product_bundle("_Test Product Bundle Item New", ["_Packed Item New 1"], 2)
+
+ so = make_sales_order(
+ item_code="_Test Product Bundle Item New",
+ warehouse="_Test Warehouse - _TC",
+ transaction_date=add_days(nowdate(), -1),
+ do_not_submit=1,
+ )
+
+ make_stock_entry(item="_Packed Item New 1", target="_Test Warehouse - _TC", qty=120, rate=100)
+
+ bin_details = frappe.db.get_value(
+ "Bin",
+ {"item_code": "_Packed Item New 1", "warehouse": "_Test Warehouse - _TC"},
+ ["actual_qty", "projected_qty", "ordered_qty"],
+ as_dict=1,
+ )
+
+ so.transaction_date = nowdate()
+ so.save()
+
+ packed_item = so.packed_items[0]
+ self.assertEqual(flt(bin_details.actual_qty), flt(packed_item.actual_qty))
+ self.assertEqual(flt(bin_details.projected_qty), flt(packed_item.projected_qty))
+ self.assertEqual(flt(bin_details.ordered_qty), flt(packed_item.ordered_qty))
+
def test_update_child_product_bundle(self):
# test Update Items with product bundle
if not frappe.db.exists("Item", "_Product Bundle Item"):
diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py
index b0ff1ccfcd7..a11cfc3407a 100644
--- a/erpnext/setup/doctype/item_group/item_group.py
+++ b/erpnext/setup/doctype/item_group/item_group.py
@@ -149,12 +149,12 @@ def get_item_for_list_in_html(context):
def get_parent_item_groups(item_group_name, from_item=False):
- base_nav_page = {"name": _("Shop by Category"), "route": "/shop-by-category"}
+ base_nav_page = {"name": _("All Products"), "route": "/all-products"}
if from_item and frappe.request.environ.get("HTTP_REFERER"):
# base page after 'Home' will vary on Item page
last_page = frappe.request.environ["HTTP_REFERER"].split("/")[-1].split("?")[0]
- if last_page and last_page in ("shop-by-category", "all-products"):
+ if last_page and last_page == "shop-by-category":
base_nav_page_title = " ".join(last_page.split("-")).title()
base_nav_page = {"name": _(base_nav_page_title), "route": "/" + last_page}
diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
index d747383d6a5..903e2af3cb3 100644
--- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
@@ -490,6 +490,46 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(gle_warehouse_amount, 1400)
+ def test_bin_details_of_packed_item(self):
+ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ # test Update Items with product bundle
+ if not frappe.db.exists("Item", "_Test Product Bundle Item New"):
+ bundle_item = make_item("_Test Product Bundle Item New", {"is_stock_item": 0})
+ bundle_item.append(
+ "item_defaults", {"company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC"}
+ )
+ bundle_item.save(ignore_permissions=True)
+
+ make_item("_Packed Item New 1", {"is_stock_item": 1})
+ make_product_bundle("_Test Product Bundle Item New", ["_Packed Item New 1"], 2)
+
+ si = create_delivery_note(
+ item_code="_Test Product Bundle Item New",
+ update_stock=1,
+ warehouse="_Test Warehouse - _TC",
+ transaction_date=add_days(nowdate(), -1),
+ do_not_submit=1,
+ )
+
+ make_stock_entry(item="_Packed Item New 1", target="_Test Warehouse - _TC", qty=120, rate=100)
+
+ bin_details = frappe.db.get_value(
+ "Bin",
+ {"item_code": "_Packed Item New 1", "warehouse": "_Test Warehouse - _TC"},
+ ["actual_qty", "projected_qty", "ordered_qty"],
+ as_dict=1,
+ )
+
+ si.transaction_date = nowdate()
+ si.save()
+
+ packed_item = si.packed_items[0]
+ self.assertEqual(flt(bin_details.actual_qty), flt(packed_item.actual_qty))
+ self.assertEqual(flt(bin_details.projected_qty), flt(packed_item.projected_qty))
+ self.assertEqual(flt(bin_details.ordered_qty), flt(packed_item.ordered_qty))
+
def test_return_for_serialized_items(self):
se = make_serialized_item()
serial_no = get_serial_nos(se.get("items")[0].serial_no)[0]
@@ -650,6 +690,11 @@ class TestDeliveryNote(FrappeTestCase):
update_delivery_note_status(dn.name, "Closed")
self.assertEqual(frappe.db.get_value("Delivery Note", dn.name, "Status"), "Closed")
+ # Check cancelling closed delivery note
+ dn.load_from_db()
+ dn.cancel()
+ self.assertEqual(dn.status, "Cancelled")
+
def test_dn_billing_status_case1(self):
# SO -> DN -> SI
so = make_sales_order()
diff --git a/erpnext/stock/doctype/item_attribute/item_attribute.py b/erpnext/stock/doctype/item_attribute/item_attribute.py
index 391ff06918a..ac4c313e28a 100644
--- a/erpnext/stock/doctype/item_attribute/item_attribute.py
+++ b/erpnext/stock/doctype/item_attribute/item_attribute.py
@@ -74,11 +74,10 @@ class ItemAttribute(Document):
def validate_duplication(self):
values, abbrs = [], []
for d in self.item_attribute_values:
- d.abbr = d.abbr.upper()
- if d.attribute_value in values:
- frappe.throw(_("{0} must appear only once").format(d.attribute_value))
+ if d.attribute_value.lower() in map(str.lower, values):
+ frappe.throw(_("Attribute value: {0} must appear only once").format(d.attribute_value.title()))
values.append(d.attribute_value)
- if d.abbr in abbrs:
- frappe.throw(_("{0} must appear only once").format(d.abbr))
+ if d.abbr.lower() in map(str.lower, abbrs):
+ frappe.throw(_("Abbreviation: {0} must appear only once").format(d.abbr.title()))
abbrs.append(d.abbr)
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index 6fb9205d4b9..15d77544236 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -226,8 +226,10 @@ def validate_item_details(args, item):
validate_end_of_life(item.name, item.end_of_life, item.disabled)
- if args.transaction_type == "selling" and cint(item.has_variants):
- throw(_("Item {0} is a template, please select one of its variants").format(item.name))
+ if cint(item.has_variants):
+ msg = f"Item {item.name} is a template, please select one of its variants"
+
+ throw(_(msg), title=_("Template Item Selected"))
elif args.transaction_type == "buying" and args.doctype != "Material Request":
if args.get("is_subcontracted") == "Yes" and item.is_sub_contracted_item != 1: