From 1e17d6a6079d1f65980c3735f180f35fb7d265ab Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 14 Dec 2021 14:58:20 +0530 Subject: [PATCH 01/18] fix: qty filter not working if apply_multiple_pricing_rules is enabled --- erpnext/accounts/doctype/pricing_rule/utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 02bfc9defd..1bd18bca86 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -67,13 +67,12 @@ def sorted_by_priority(pricing_rules, args, doc=None): if not pricing_rule.get('priority'): pricing_rule['priority'] = 1 - if pricing_rule.get('apply_multiple_pricing_rules'): - pricing_rule_dict.setdefault(cint(pricing_rule.get("priority")), []).append(pricing_rule) + pricing_rule_dict.setdefault(cint(pricing_rule.get("priority")), []).append(pricing_rule) for key in sorted(pricing_rule_dict): pricing_rules_list.extend(pricing_rule_dict.get(key)) - return pricing_rules_list or pricing_rules + return pricing_rules_list def filter_pricing_rule_based_on_condition(pricing_rules, doc=None): filtered_pricing_rules = [] From c0d1f4869f52b4def9d83017577b307e34494e09 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 14 Dec 2021 14:58:31 +0530 Subject: [PATCH 02/18] test: add test_multiple_pricing_rules --- .../doctype/pricing_rule/test_pricing_rule.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index d8b860671f..c574f29cbe 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -628,6 +628,26 @@ class TestPricingRule(unittest.TestCase): for doc in [si, si1]: doc.delete() + def test_multiple_pricing_rules(self): + make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4, + apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1") + make_pricing_rule(discount_percentage=10, selling=1, priority=2, min_qty=4, + apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 2") + + si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1, currency="USD") + item = si.items[0] + item.stock_qty = 1 + si.save() + self.assertFalse(item.discount_percentage) + item.qty = 5 + item.stock_qty = 5 + si.save() + self.assertEqual(item.discount_percentage, 30) + si.delete() + + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1") + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2") + test_dependencies = ["Campaign"] def make_pricing_rule(**args): From 5a7a9a598b39553901bbd37e8f003943a0446a58 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 15 Dec 2021 10:32:23 +0530 Subject: [PATCH 03/18] chore: rename redefinition of multiple pricing rule test --- erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index c574f29cbe..94c2187824 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -628,7 +628,7 @@ class TestPricingRule(unittest.TestCase): for doc in [si, si1]: doc.delete() - def test_multiple_pricing_rules(self): + def test_multiple_pricing_rules_with_min_qty(self): make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4, apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1") make_pricing_rule(discount_percentage=10, selling=1, priority=2, min_qty=4, From d37feb8208fac99c38c8417c01173c3dfd136c3f Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 19 Jan 2022 10:00:05 +0530 Subject: [PATCH 04/18] feat: Splitting group assets --- erpnext/assets/doctype/asset/asset.js | 41 +++ erpnext/assets/doctype/asset/asset.json | 20 +- erpnext/assets/doctype/asset/asset.py | 344 +++++++++++++-------- erpnext/assets/doctype/asset/test_asset.py | 27 +- 4 files changed, 303 insertions(+), 129 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 153f5c537a..f0e59ee115 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -108,6 +108,10 @@ frappe.ui.form.on('Asset', { frm.trigger("create_asset_repair"); }, __("Manage")); + frm.add_custom_button(__("Split Asset"), function() { + frm.trigger("split_asset"); + }, __("Manage")); + if (frm.doc.status != 'Fully Depreciated') { frm.add_custom_button(__("Adjust Asset Value"), function() { frm.trigger("create_asset_value_adjustment"); @@ -322,6 +326,43 @@ frappe.ui.form.on('Asset', { }); }, + split_asset: function(frm) { + const title = __('Split Asset'); + + const fields = [ + { + fieldname: 'split_qty', + fieldtype:'Int', + label: __('Split Qty'), + reqd: 1 + } + ]; + + let dialog = new frappe.ui.Dialog({ + title: title, + fields: fields + }); + + dialog.set_primary_action(__('Split'), function() { + const dialog_data = dialog.get_values(); + frappe.call({ + args: { + "asset_name": frm.doc.name, + "split_qty": cint(dialog_data.split_qty) + }, + method: "erpnext.assets.doctype.asset.asset.split_asset", + callback: function(r) { + let doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + } + }); + + dialog.hide(); + }); + + dialog.show(); + }, + create_asset_value_adjustment: function(frm) { frappe.call({ args: { diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index de060757e2..72f692c533 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -3,7 +3,7 @@ "allow_import": 1, "allow_rename": 1, "autoname": "naming_series:", - "creation": "2016-03-01 17:01:27.920130", + "creation": "2022-01-18 02:26:55.975005", "doctype": "DocType", "document_type": "Document", "engine": "InnoDB", @@ -23,6 +23,8 @@ "asset_name", "asset_category", "location", + "split_from", + "qty", "custodian", "department", "disposal_date", @@ -480,6 +482,18 @@ "fieldname": "section_break_36", "fieldtype": "Section Break", "label": "Finance Books" + }, + { + "fieldname": "qty", + "fieldtype": "Int", + "label": "Qty" + }, + { + "fieldname": "split_from", + "fieldtype": "Link", + "label": "Split From", + "options": "Asset", + "read_only": 1 } ], "idx": 72, @@ -502,10 +516,11 @@ "link_fieldname": "asset" } ], - "modified": "2021-06-24 14:58:51.097908", + "modified": "2022-01-18 09:15:34.238601", "modified_by": "Administrator", "module": "Assets", "name": "Asset", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -542,6 +557,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "asset_name", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index ee3ec8e63a..3d51af80da 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -37,7 +37,8 @@ class Asset(AccountsController): self.validate_asset_and_reference() self.validate_item() self.set_missing_values() - self.prepare_depreciation_data() + if not self.split_from: + self.prepare_depreciation_data() self.validate_gross_and_purchase_amount() if self.get("schedules"): self.validate_expected_value_after_useful_life() @@ -188,142 +189,142 @@ class Asset(AccountsController): start = self.clear_depreciation_schedule() for finance_book in self.get('finance_books'): - self.validate_asset_finance_books(finance_book) + self._make_depreciation_schedule(finance_book, start, date_of_sale) - # value_after_depreciation - current Asset value - if self.docstatus == 1 and finance_book.value_after_depreciation: - value_after_depreciation = flt(finance_book.value_after_depreciation) - else: - value_after_depreciation = (flt(self.gross_purchase_amount) - - flt(self.opening_accumulated_depreciation)) + def _make_depreciation_schedule(self, finance_book, start, date_of_sale): + self.validate_asset_finance_books(finance_book) - finance_book.value_after_depreciation = value_after_depreciation + value_after_depreciation = self._get_value_after_depreciation(finance_book) + finance_book.value_after_depreciation = value_after_depreciation - number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \ - cint(self.number_of_depreciations_booked) + number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \ + cint(self.number_of_depreciations_booked) - has_pro_rata = self.check_is_pro_rata(finance_book) + has_pro_rata = self.check_is_pro_rata(finance_book) + if has_pro_rata: + number_of_pending_depreciations += 1 - if has_pro_rata: - number_of_pending_depreciations += 1 + skip_row = False - skip_row = False + for n in range(start[finance_book.idx-1], number_of_pending_depreciations): + # If depreciation is already completed (for double declining balance) + if skip_row: continue - for n in range(start[finance_book.idx-1], number_of_pending_depreciations): - # If depreciation is already completed (for double declining balance) - if skip_row: continue + depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book) - depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book) + if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: + schedule_date = add_months(finance_book.depreciation_start_date, + n * cint(finance_book.frequency_of_depreciation)) - if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: - schedule_date = add_months(finance_book.depreciation_start_date, - n * cint(finance_book.frequency_of_depreciation)) + # schedule date will be a year later from start date + # so monthly schedule date is calculated by removing 11 months from it + monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1) - # schedule date will be a year later from start date - # so monthly schedule date is calculated by removing 11 months from it - monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1) - - # if asset is being sold - if date_of_sale: - 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) - - if depreciation_amount > 0: - self.append("schedules", { - "schedule_date": date_of_sale, - "depreciation_amount": depreciation_amount, - "depreciation_method": finance_book.depreciation_method, - "finance_book": finance_book.finance_book, - "finance_book_id": finance_book.idx - }) - - break - - # For first row - if has_pro_rata and not self.opening_accumulated_depreciation and n==0: - depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, - self.available_for_use_date, finance_book.depreciation_start_date) - - # For first depr schedule date will be the start date - # so monthly schedule date is calculated by removing month difference between use date and start date - monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1) - - # For last row - elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: - if not self.flags.increase_in_asset_life: - # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission - self.to_date = add_months(self.available_for_use_date, - (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation)) - - depreciation_amount_without_pro_rata = depreciation_amount - - depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, - depreciation_amount, schedule_date, self.to_date) - - depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata, - depreciation_amount, finance_book.finance_book) - - monthly_schedule_date = add_months(schedule_date, 1) - schedule_date = add_days(schedule_date, days) - last_schedule_date = schedule_date - - if not depreciation_amount: continue - value_after_depreciation -= flt(depreciation_amount, - self.precision("gross_purchase_amount")) - - # Adjust depreciation amount in the last period based on the expected value after useful life - if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1 - and value_after_depreciation != finance_book.expected_value_after_useful_life) - or value_after_depreciation < finance_book.expected_value_after_useful_life): - depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life) - skip_row = True + # if asset is being sold + if date_of_sale: + 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) if depreciation_amount > 0: - # With monthly depreciation, each depreciation is divided by months remaining until next date - if self.allow_monthly_depreciation: - # month range is 1 to 12 - # In pro rata case, for first and last depreciation, month range would be different - month_range = months \ - if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \ - else finance_book.frequency_of_depreciation + self._add_depreciation_row(date_of_sale, depreciation_amount, finance_book.depreciation_method, + finance_book.finance_book, finance_book.idx) - for r in range(month_range): - if (has_pro_rata and n == 0): - # For first entry of monthly depr - if r == 0: - days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date) - per_day_amt = depreciation_amount / days - depreciation_amount_for_current_month = per_day_amt * days_until_first_depr - depreciation_amount -= depreciation_amount_for_current_month - date = monthly_schedule_date - amount = depreciation_amount_for_current_month - else: - date = add_months(monthly_schedule_date, r) - amount = depreciation_amount / (month_range - 1) - elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1: - # For last entry of monthly depr - date = last_schedule_date - amount = depreciation_amount / month_range + break + + # For first row + if has_pro_rata and not self.opening_accumulated_depreciation and n==0: + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, + self.available_for_use_date, finance_book.depreciation_start_date) + + # For first depr schedule date will be the start date + # so monthly schedule date is calculated by removing month difference between use date and start date + monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1) + + # For last row + elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: + if not self.flags.increase_in_asset_life: + # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission + self.to_date = add_months(self.available_for_use_date, + (n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation)) + + depreciation_amount_without_pro_rata = depreciation_amount + + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, + depreciation_amount, schedule_date, self.to_date) + + depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata, + depreciation_amount, finance_book.finance_book) + + monthly_schedule_date = add_months(schedule_date, 1) + schedule_date = add_days(schedule_date, days) + last_schedule_date = schedule_date + + if not depreciation_amount: continue + value_after_depreciation -= flt(depreciation_amount, + self.precision("gross_purchase_amount")) + + # Adjust depreciation amount in the last period based on the expected value after useful life + if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1 + and value_after_depreciation != finance_book.expected_value_after_useful_life) + or value_after_depreciation < finance_book.expected_value_after_useful_life): + depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life) + skip_row = True + + if depreciation_amount > 0: + # With monthly depreciation, each depreciation is divided by months remaining until next date + if self.allow_monthly_depreciation: + # month range is 1 to 12 + # In pro rata case, for first and last depreciation, month range would be different + month_range = months \ + if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \ + else finance_book.frequency_of_depreciation + + for r in range(month_range): + if (has_pro_rata and n == 0): + # For first entry of monthly depr + if r == 0: + days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date) + per_day_amt = depreciation_amount / days + depreciation_amount_for_current_month = per_day_amt * days_until_first_depr + depreciation_amount -= depreciation_amount_for_current_month + date = monthly_schedule_date + amount = depreciation_amount_for_current_month else: date = add_months(monthly_schedule_date, r) - amount = depreciation_amount / month_range + amount = depreciation_amount / (month_range - 1) + elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1: + # For last entry of monthly depr + date = last_schedule_date + amount = depreciation_amount / month_range + else: + date = add_months(monthly_schedule_date, r) + amount = depreciation_amount / month_range - self.append("schedules", { - "schedule_date": date, - "depreciation_amount": amount, - "depreciation_method": finance_book.depreciation_method, - "finance_book": finance_book.finance_book, - "finance_book_id": finance_book.idx - }) - else: - self.append("schedules", { - "schedule_date": schedule_date, - "depreciation_amount": depreciation_amount, - "depreciation_method": finance_book.depreciation_method, - "finance_book": finance_book.finance_book, - "finance_book_id": finance_book.idx - }) + self._add_depreciation_row(date, amount, finance_book.depreciation_method, + finance_book.finance_book, finance_book.idx) + else: + self._add_depreciation_row(schedule_date, depreciation_amount, finance_book.depreciation_method, + finance_book.finance_book, finance_book.idx) + + def _add_depreciation_row(self, schedule_date, depreciation_amount, depreciation_method, finance_book, finance_book_id): + self.append("schedules", { + "schedule_date": schedule_date, + "depreciation_amount": depreciation_amount, + "depreciation_method": depreciation_method, + "finance_book": finance_book, + "finance_book_id": finance_book_id + }) + + def _get_value_after_depreciation(self, finance_book): + # value_after_depreciation - current Asset value + if self.docstatus == 1 and finance_book.value_after_depreciation: + value_after_depreciation = flt(finance_book.value_after_depreciation) + else: + value_after_depreciation = (flt(self.gross_purchase_amount) - + flt(self.opening_accumulated_depreciation)) + + return value_after_depreciation # depreciation schedules need to be cleared before modification due to increase in asset life/asset sales # JE: Journal Entry, FB: Finance Book @@ -333,7 +334,6 @@ class Asset(AccountsController): depr_schedule = [] for schedule in self.get('schedules'): - # to update start when there are JEs linked with all the schedule rows corresponding to an FB if len(start) == (int(schedule.finance_book_id) - 2): start.append(num_of_depreciations_completed) @@ -907,3 +907,103 @@ def get_depreciation_amount(asset, depreciable_value, row): depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100)) return depreciation_amount + +@frappe.whitelist() +def split_asset(asset_name, split_qty): + asset = frappe.get_doc("Asset", asset_name) + split_qty = cint(split_qty) + + if split_qty >= asset.qty: + frappe.throw(_("Split qty cannot be grater than or equal to asset qty")) + + remaining_qty = asset.qty - split_qty + + # Update gross purchase amount + new_gross_purchase_amount = flt((asset.gross_purchase_amount * split_qty) / asset.qty) + remaining_gross_purchase_amount = flt((asset.gross_purchase_amount * split_qty) / asset.qty) + + new_asset = create_new_asset_after_split(asset, new_gross_purchase_amount, split_qty) + update_existing_asset(asset, remaining_gross_purchase_amount, remaining_qty) + + return new_asset + +def update_existing_asset(asset, remaingin_gross_purchase_amount, remaining_qty): + frappe.db.set_value("Asset", asset.name, { + 'gross_purchase_amount': remaingin_gross_purchase_amount, + 'qty': remaining_qty + }) + + for finance_book in asset.get('finance_books'): + value_after_depreciation = flt((finance_book.value_after_depreciation * remaining_qty)/asset.qty) + frappe.db.set_value('Asset Finance Book', finance_book.name, 'value_after_depreciation', value_after_depreciation) + + accumulated_depreciation = 0 + + for term in asset.get('schedules'): + depreciation_amount = flt((term.depreciation_amount * remaining_qty)/asset.qty) + frappe.db.set_value('Depreciation Schedule', term.name, 'depreciation_amount', depreciation_amount) + accumulated_depreciation += depreciation_amount + frappe.db.set_value('Depreciation Schedule', term.name, 'accumulated_depreciation_amount', depreciation_amount) + +def create_new_asset_after_split(asset, new_gross_purchase_amount, split_qty): + new_asset = frappe.copy_doc(asset) + + new_asset.gross_purchase_amount = new_gross_purchase_amount + new_asset.qty = split_qty + new_asset.split_from = asset.name + accumulated_depreciation = 0 + + for finance_book in new_asset.get('finance_books'): + finance_book.value_after_depreciation = flt((finance_book.value_after_depreciation * split_qty)/asset.qty) + + for term in new_asset.get('schedules'): + depreciation_amount = flt((term.depreciation_amount * split_qty)/asset.qty) + term.depreciation_amount = depreciation_amount + accumulated_depreciation += depreciation_amount + term.accumulated_depreciation_amount = accumulated_depreciation + + new_asset.submit() + new_asset.set_status() + + for term in new_asset.get('schedules'): + # Update references in JV + if term.journal_entry: + add_reference_in_jv_on_split(term.journal_entry, new_asset.name, asset.name, term.depreciation_amount) + + return new_asset + +def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount): + journal_entry = frappe.get_doc('Journal Entry', entry_name) + entries_to_add = [] + + for account in journal_entry.get('accounts'): + if account.reference_name == old_asset_name: + entries_to_add.append(frappe.copy_doc(account).as_dict()) + if account.credit: + account.credit = account.credit - depreciation_amount + account.credit_in_account_currency = account.credit_in_account_currency - \ + account.exchange_rate * depreciation_amount + elif account.debit: + account.debit = account.debit - depreciation_amount + account.debit_in_account_currency = account.debit_in_account_currency - \ + account.exchange_rate * depreciation_amount + + for entry in entries_to_add: + entry.reference_name = new_asset_name + if entry.credit: + entry.credit = depreciation_amount + entry.credit_in_account_currency = entry.exchange_rate * depreciation_amount + elif entry.debit: + entry.debit = depreciation_amount + entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount + + journal_entry.append('accounts', entry) + + journal_entry.flags.ignore_validate_update_after_submit = True + journal_entry.save() + + # Repost GL Entries + journal_entry.docstatus = 2 + journal_entry.make_gl_entries(1) + journal_entry.docstatus = 1 + journal_entry.make_gl_entries() \ No newline at end of file diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 44c4ce542d..283b2ea161 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -7,7 +7,7 @@ import frappe from frappe.utils import add_days, add_months, cstr, flt, 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 +from erpnext.assets.doctype.asset.asset import make_sales_invoice, split_asset from erpnext.assets.doctype.asset.depreciation import ( post_depreciation_entries, restore_asset, @@ -28,9 +28,9 @@ class AssetSetup(unittest.TestCase): make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=100000.0, location="Test Location") frappe.db.sql("delete from `tabTax Rule`") - @classmethod - def tearDownClass(cls): - frappe.db.rollback() + # @classmethod + # def tearDownClass(cls): + # frappe.db.rollback() class TestAsset(AssetSetup): def test_asset_category_is_fetched(self): @@ -222,6 +222,22 @@ class TestAsset(AssetSetup): si.cancel() self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated") + def test_asset_splitting(self): + asset = create_asset( + calculate_depreciation = 1, + qty=10, + available_for_use_date = '2020-01-01', + purchase_date = '2020-01-01', + expected_value_after_useful_life = 0, + total_number_of_depreciations = 5, + frequency_of_depreciation = 10, + depreciation_start_date = '2021-01-01', + submit = 1 + ) + + post_depreciation_entries(date="2021-01-01") + split_asset(asset.name, 5) + def test_expense_head(self): pr = make_purchase_receipt(item_code="Macbook Pro", qty=2, rate=200000.0, location="Test Location") @@ -1164,7 +1180,8 @@ def create_asset(**args): "available_for_use_date": args.available_for_use_date or "2020-06-06", "location": args.location or "Test Location", "asset_owner": args.asset_owner or "Company", - "is_existing_asset": args.is_existing_asset or 1 + "is_existing_asset": args.is_existing_asset or 1, + "qty": args.get("qty") or 1 }) if asset.calculate_depreciation: From 4abc7da2c04d6aaac366b4c2505181c620152f13 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 19 Jan 2022 14:28:00 +0530 Subject: [PATCH 05/18] chore: Add test case --- erpnext/assets/doctype/asset/asset.js | 2 +- erpnext/assets/doctype/asset/asset.json | 14 +++---- erpnext/assets/doctype/asset/asset.py | 46 ++++++++++++-------- erpnext/assets/doctype/asset/test_asset.py | 49 ++++++++++++++++++---- 4 files changed, 78 insertions(+), 33 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index f0e59ee115..f414930d72 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -332,7 +332,7 @@ frappe.ui.form.on('Asset', { const fields = [ { fieldname: 'split_qty', - fieldtype:'Int', + fieldtype: 'Int', label: __('Split Qty'), reqd: 1 } diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index 72f692c533..9dba17121e 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -24,7 +24,6 @@ "asset_category", "location", "split_from", - "qty", "custodian", "department", "disposal_date", @@ -37,6 +36,7 @@ "available_for_use_date", "column_break_23", "gross_purchase_amount", + "asset_quantity", "purchase_date", "section_break_23", "calculate_depreciation", @@ -483,17 +483,17 @@ "fieldtype": "Section Break", "label": "Finance Books" }, - { - "fieldname": "qty", - "fieldtype": "Int", - "label": "Qty" - }, { "fieldname": "split_from", "fieldtype": "Link", "label": "Split From", "options": "Asset", "read_only": 1 + }, + { + "fieldname": "asset_quantity", + "fieldtype": "Int", + "label": "Asset Quantity" } ], "idx": 72, @@ -516,7 +516,7 @@ "link_fieldname": "asset" } ], - "modified": "2022-01-18 09:15:34.238601", + "modified": "2022-01-19 01:36:51.361485", "modified_by": "Administrator", "module": "Assets", "name": "Asset", diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 3d51af80da..01c1f71649 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -913,51 +913,57 @@ def split_asset(asset_name, split_qty): asset = frappe.get_doc("Asset", asset_name) split_qty = cint(split_qty) - if split_qty >= asset.qty: + if split_qty >= asset.asset_quantity: frappe.throw(_("Split qty cannot be grater than or equal to asset qty")) - remaining_qty = asset.qty - split_qty + remaining_qty = asset.asset_quantity - split_qty - # Update gross purchase amount - new_gross_purchase_amount = flt((asset.gross_purchase_amount * split_qty) / asset.qty) - remaining_gross_purchase_amount = flt((asset.gross_purchase_amount * split_qty) / asset.qty) - - new_asset = create_new_asset_after_split(asset, new_gross_purchase_amount, split_qty) - update_existing_asset(asset, remaining_gross_purchase_amount, remaining_qty) + new_asset = create_new_asset_after_split(asset, split_qty) + update_existing_asset(asset, remaining_qty) return new_asset -def update_existing_asset(asset, remaingin_gross_purchase_amount, remaining_qty): +def update_existing_asset(asset, remaining_qty): + remaining_gross_purchase_amount = flt((asset.gross_purchase_amount * remaining_qty) / asset.asset_quantity) + opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * remaining_qty) / asset.asset_quantity) + frappe.db.set_value("Asset", asset.name, { - 'gross_purchase_amount': remaingin_gross_purchase_amount, - 'qty': remaining_qty + 'opening_accumulated_depreciation': opening_accumulated_depreciation, + 'gross_purchase_amount': remaining_gross_purchase_amount, + 'asset_quantity': remaining_qty }) for finance_book in asset.get('finance_books'): - value_after_depreciation = flt((finance_book.value_after_depreciation * remaining_qty)/asset.qty) + value_after_depreciation = flt((finance_book.value_after_depreciation * remaining_qty)/asset.asset_quantity) + expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * remaining_qty)/asset.asset_quantity) frappe.db.set_value('Asset Finance Book', finance_book.name, 'value_after_depreciation', value_after_depreciation) + frappe.db.set_value('Asset Finance Book', finance_book.name, 'expected_value_after_useful_life', expected_value_after_useful_life) accumulated_depreciation = 0 for term in asset.get('schedules'): - depreciation_amount = flt((term.depreciation_amount * remaining_qty)/asset.qty) + depreciation_amount = flt((term.depreciation_amount * remaining_qty)/asset.asset_quantity) frappe.db.set_value('Depreciation Schedule', term.name, 'depreciation_amount', depreciation_amount) accumulated_depreciation += depreciation_amount - frappe.db.set_value('Depreciation Schedule', term.name, 'accumulated_depreciation_amount', depreciation_amount) + frappe.db.set_value('Depreciation Schedule', term.name, 'accumulated_depreciation_amount', accumulated_depreciation) -def create_new_asset_after_split(asset, new_gross_purchase_amount, split_qty): +def create_new_asset_after_split(asset, split_qty): new_asset = frappe.copy_doc(asset) + new_gross_purchase_amount = flt((asset.gross_purchase_amount * split_qty) / asset.asset_quantity) + opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * split_qty) / asset.asset_quantity) new_asset.gross_purchase_amount = new_gross_purchase_amount - new_asset.qty = split_qty + new_asset.opening_accumulated_depreciation = opening_accumulated_depreciation + new_asset.asset_quantity = split_qty new_asset.split_from = asset.name accumulated_depreciation = 0 for finance_book in new_asset.get('finance_books'): - finance_book.value_after_depreciation = flt((finance_book.value_after_depreciation * split_qty)/asset.qty) + finance_book.value_after_depreciation = flt((finance_book.value_after_depreciation * split_qty)/asset.asset_quantity) + finance_book.expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * split_qty)/asset.asset_quantity) for term in new_asset.get('schedules'): - depreciation_amount = flt((term.depreciation_amount * split_qty)/asset.qty) + depreciation_amount = flt((term.depreciation_amount * split_qty)/asset.asset_quantity) term.depreciation_amount = depreciation_amount accumulated_depreciation += depreciation_amount term.accumulated_depreciation_amount = accumulated_depreciation @@ -975,6 +981,7 @@ def create_new_asset_after_split(asset, new_gross_purchase_amount, split_qty): def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount): journal_entry = frappe.get_doc('Journal Entry', entry_name) entries_to_add = [] + idx = len(journal_entry.get('accounts')) + 1 for account in journal_entry.get('accounts'): if account.reference_name == old_asset_name: @@ -997,6 +1004,9 @@ def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, dep entry.debit = depreciation_amount entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount + entry.idx = idx + idx += 1 + journal_entry.append('accounts', entry) journal_entry.flags.ignore_validate_update_after_submit = True diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 283b2ea161..7fdcb0dd3b 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -28,9 +28,9 @@ class AssetSetup(unittest.TestCase): make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=100000.0, location="Test Location") frappe.db.sql("delete from `tabTax Rule`") - # @classmethod - # def tearDownClass(cls): - # frappe.db.rollback() + @classmethod + def tearDownClass(cls): + frappe.db.rollback() class TestAsset(AssetSetup): def test_asset_category_is_fetched(self): @@ -225,18 +225,53 @@ class TestAsset(AssetSetup): def test_asset_splitting(self): asset = create_asset( calculate_depreciation = 1, - qty=10, + asset_quantity=10, available_for_use_date = '2020-01-01', purchase_date = '2020-01-01', expected_value_after_useful_life = 0, - total_number_of_depreciations = 5, + total_number_of_depreciations = 6, + number_of_depreciations_booked = 1, frequency_of_depreciation = 10, depreciation_start_date = '2021-01-01', + opening_accumulated_depreciation=20000, + gross_purchase_amount=120000, submit = 1 ) post_depreciation_entries(date="2021-01-01") - split_asset(asset.name, 5) + + self.assertEqual(asset.asset_quantity, 10) + self.assertEqual(asset.gross_purchase_amount, 120000) + self.assertEqual(asset.opening_accumulated_depreciation, 20000) + + new_asset = split_asset(asset.name, 2) + asset.load_from_db() + + self.assertEqual(new_asset.asset_quantity, 2) + self.assertEqual(new_asset.gross_purchase_amount, 24000) + self.assertEqual(new_asset.opening_accumulated_depreciation, 4000) + self.assertEqual(new_asset.split_from, asset.name) + self.assertEqual(new_asset.schedules[0].depreciation_amount, 4000) + self.assertEqual(new_asset.schedules[1].depreciation_amount, 4000) + + self.assertEqual(asset.asset_quantity, 8) + self.assertEqual(asset.gross_purchase_amount, 96000) + self.assertEqual(asset.opening_accumulated_depreciation, 16000) + self.assertEqual(asset.schedules[0].depreciation_amount, 16000) + self.assertEqual(asset.schedules[1].depreciation_amount, 16000) + + journal_entry = asset.schedules[0].journal_entry + + jv = frappe.get_doc('Journal Entry', journal_entry) + self.assertEqual(jv.accounts[0].credit_in_account_currency, 16000) + self.assertEqual(jv.accounts[1].debit_in_account_currency, 16000) + self.assertEqual(jv.accounts[2].credit_in_account_currency, 4000) + self.assertEqual(jv.accounts[3].debit_in_account_currency, 4000) + + self.assertEqual(jv.accounts[0].reference_name, asset.name) + self.assertEqual(jv.accounts[1].reference_name, asset.name) + self.assertEqual(jv.accounts[2].reference_name, new_asset.name) + self.assertEqual(jv.accounts[3].reference_name, new_asset.name) def test_expense_head(self): pr = make_purchase_receipt(item_code="Macbook Pro", @@ -1181,7 +1216,7 @@ def create_asset(**args): "location": args.location or "Test Location", "asset_owner": args.asset_owner or "Company", "is_existing_asset": args.is_existing_asset or 1, - "qty": args.get("qty") or 1 + "asset_quantity": args.get("asset_quantity") or 1 }) if asset.calculate_depreciation: From 3da2cac772b0557e15ddf4ee9673381b0d98bca1 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 20 Jan 2022 12:09:04 +0530 Subject: [PATCH 06/18] chore: undo unnecessary changes --- erpnext/accounts/doctype/pricing_rule/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 1bd18bca86..7792590c9c 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -67,7 +67,8 @@ def sorted_by_priority(pricing_rules, args, doc=None): if not pricing_rule.get('priority'): pricing_rule['priority'] = 1 - pricing_rule_dict.setdefault(cint(pricing_rule.get("priority")), []).append(pricing_rule) + if pricing_rule.get('apply_multiple_pricing_rules'): + pricing_rule_dict.setdefault(cint(pricing_rule.get("priority")), []).append(pricing_rule) for key in sorted(pricing_rule_dict): pricing_rules_list.extend(pricing_rule_dict.get(key)) From b05287dea06f7f8a769268148d346d6dd4dd4796 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 27 Jan 2022 20:08:05 +0530 Subject: [PATCH 07/18] fix: drop `six` code (#29479) --- erpnext/accounts/doctype/bank_transaction/bank_transaction.py | 3 ++- .../accounts_receivable_summary/accounts_receivable_summary.py | 3 +-- erpnext/crm/doctype/linkedin_settings/linkedin_settings.py | 3 ++- .../doctype/amazon_mws_settings/amazon_methods.py | 2 +- .../doctype/gocardless_settings/gocardless_settings.py | 3 ++- .../doctype/woocommerce_settings/woocommerce_settings.py | 3 ++- erpnext/erpnext_integrations/utils.py | 2 +- erpnext/regional/germany/utils/datev/datev_csv.py | 2 +- erpnext/regional/report/datev/test_datev.py | 2 +- erpnext/setup/doctype/item_group/item_group.py | 2 +- 10 files changed, 14 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 44cea31ed3..51e1d6e9a0 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -2,9 +2,10 @@ # For license information, please see license.txt +from functools import reduce + import frappe from frappe.utils import flt -from six.moves import reduce from erpnext.controllers.status_updater import StatusUpdater diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 4559fa94a4..8e3bd8be1b 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -5,7 +5,6 @@ import frappe from frappe import _, scrub from frappe.utils import cint, flt -from six import iteritems from erpnext.accounts.party import get_partywise_advanced_payment_amount from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport @@ -40,7 +39,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): if self.filters.show_gl_balance: gl_balance_map = get_gl_balance(self.filters.report_date) - for party, party_dict in iteritems(self.party_total): + for party, party_dict in self.party_total.items(): if party_dict.outstanding == 0: continue diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py index 8fd4978715..d2ac10adea 100644 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py @@ -2,13 +2,14 @@ # For license information, please see license.txt +from urllib.parse import urlencode + import frappe import requests from frappe import _ from frappe.model.document import Document from frappe.utils import get_url_to_form from frappe.utils.file_manager import get_file_path -from six.moves.urllib.parse import urlencode class LinkedInSettings(Document): diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py index 66826ba8d7..03c1a1a0b5 100644 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py +++ b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py @@ -5,11 +5,11 @@ import csv import math import time +from io import StringIO import dateutil import frappe from frappe import _ -from six import StringIO import erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_api as mws diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py index e242ace60f..a8119ac86c 100644 --- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py @@ -2,13 +2,14 @@ # For license information, please see license.txt +from urllib.parse import urlencode + import frappe import gocardless_pro from frappe import _ from frappe.integrations.utils import create_payment_gateway, create_request_log from frappe.model.document import Document from frappe.utils import call_hook_method, cint, flt, get_url -from six.moves.urllib.parse import urlencode class GoCardlessSettings(Document): diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py index 8da52f49f1..309d2cb0e3 100644 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py +++ b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py @@ -2,12 +2,13 @@ # For license information, please see license.txt +from urllib.parse import urlparse + import frappe from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.model.document import Document from frappe.utils.nestedset import get_root_of -from six.moves.urllib.parse import urlparse class WoocommerceSettings(Document): diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index d922d875fd..30d3948fee 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -1,10 +1,10 @@ import base64 import hashlib import hmac +from urllib.parse import urlparse import frappe from frappe import _ -from six.moves.urllib.parse import urlparse from erpnext import get_default_company diff --git a/erpnext/regional/germany/utils/datev/datev_csv.py b/erpnext/regional/germany/utils/datev/datev_csv.py index 2d1e02eadb..ec271a1029 100644 --- a/erpnext/regional/germany/utils/datev/datev_csv.py +++ b/erpnext/regional/germany/utils/datev/datev_csv.py @@ -1,11 +1,11 @@ import datetime import zipfile from csv import QUOTE_NONNUMERIC +from io import BytesIO import frappe import pandas as pd from frappe import _ -from six import BytesIO from .datev_constants import DataCategory diff --git a/erpnext/regional/report/datev/test_datev.py b/erpnext/regional/report/datev/test_datev.py index 14d5495eed..052fb2a724 100644 --- a/erpnext/regional/report/datev/test_datev.py +++ b/erpnext/regional/report/datev/test_datev.py @@ -1,9 +1,9 @@ import zipfile +from io import BytesIO from unittest import TestCase import frappe from frappe.utils import cstr, now_datetime, today -from six import BytesIO from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.regional.germany.utils.datev.datev_constants import ( diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index c94b3463fc..9f1eb753d9 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -3,6 +3,7 @@ import copy +from urllib.parse import quote import frappe from frappe import _ @@ -10,7 +11,6 @@ from frappe.utils import cint, cstr, nowdate from frappe.utils.nestedset import NestedSet from frappe.website.utils import clear_cache from frappe.website.website_generator import WebsiteGenerator -from six.moves.urllib.parse import quote from erpnext.shopping_cart.filters import ProductFiltersBuilder from erpnext.shopping_cart.product_info import set_product_info_for_website From 0dafa8a12a0c0745bbabceb457332075fd6db9f9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 27 Jan 2022 20:23:24 +0530 Subject: [PATCH 08/18] fix: stock ledger rendering glitch undefined Issue (cherry picked from commit f38004219518e901eb736780b181c19fe88ce823) Co-authored-by: smehata <53169014+smehata@users.noreply.github.com> --- erpnext/stock/report/stock_ledger/stock_ledger.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index fe2417bba7..ef7c2cc7d9 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -86,10 +86,10 @@ frappe.query_reports["Stock Ledger"] = { ], "formatter": function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); - if (column.fieldname == "out_qty" && data.out_qty < 0) { + if (column.fieldname == "out_qty" && data && data.out_qty < 0) { value = "" + value + ""; } - else if (column.fieldname == "in_qty" && data.in_qty > 0) { + else if (column.fieldname == "in_qty" && data && data.in_qty > 0) { value = "" + value + ""; } From 251576a6bb38c9b0f0793330e10e256fe7898e5c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 28 Jan 2022 13:17:43 +0530 Subject: [PATCH 09/18] chore: disable annoying flake8 formatting for QB (#29488) [skip ci] --- .flake8 | 1 + 1 file changed, 1 insertion(+) diff --git a/.flake8 b/.flake8 index 56c9b9a369..5735456ae7 100644 --- a/.flake8 +++ b/.flake8 @@ -28,6 +28,7 @@ ignore = B007, B950, W191, + E124, # closing bracket, irritating while writing QB code max-line-length = 200 exclude=.github/helper/semgrep_rules From 86efa0dbfd45f21d72d047ef77ca0cb8ae2b8d94 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 28 Jan 2022 11:59:18 +0530 Subject: [PATCH 10/18] fix: ensure correct-ish transfer against --- erpnext/manufacturing/doctype/bom/bom.py | 7 +++++ erpnext/manufacturing/doctype/bom/test_bom.py | 29 +++++++++++++++++++ .../doctype/work_order/work_order.py | 12 ++++++++ 3 files changed, 48 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index d953697e2e..045e5bc95a 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -149,6 +149,7 @@ class BOM(WebsiteGenerator): self.set_bom_material_details() self.set_bom_scrap_items_detail() self.validate_materials() + self.validate_transfer_against() self.set_routing_operations() self.validate_operations() self.calculate_cost() @@ -690,6 +691,12 @@ class BOM(WebsiteGenerator): if act_pbom and act_pbom[0][0]: frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs")) + def validate_transfer_against(self): + if not self.with_operations: + self.transfer_material_against = "Work Order" + if not self.transfer_material_against and not self.is_new(): + frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value")) + def set_routing_operations(self): if self.routing and self.with_operations and not self.operations: self.get_routing() diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 7d90933dbc..c77a0dd232 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -403,6 +403,35 @@ class TestBOM(ERPNextTestCase): new_bom.delete() + def test_valid_transfer_defaults(self): + bom_with_op = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "with_operations": 1, "is_active": 1}) + bom = frappe.copy_doc(frappe.get_doc("BOM", bom_with_op), ignore_no_copy=False) + + # test defaults + bom.docstatus = 0 + bom.transfer_material_against = None + bom.insert() + self.assertEqual(bom.transfer_material_against, "Work Order") + + bom.reload() + bom.transfer_material_against = None + with self.assertRaises(frappe.ValidationError): + bom.save() + bom.reload() + + # test saner default + bom.transfer_material_against = "Job Card" + bom.with_operations = 0 + bom.save() + self.assertEqual(bom.transfer_material_against, "Work Order") + + # test no value on existing doc + bom.transfer_material_against = None + bom.with_operations = 0 + bom.save() + self.assertEqual(bom.transfer_material_against, "Work Order") + + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 170454c823..93ca805e34 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -65,6 +65,7 @@ class WorkOrder(Document): self.validate_warehouse_belongs_to_company() self.calculate_operating_cost() self.validate_qty() + self.validate_transfer_against() self.validate_operation_time() self.status = self.get_status() @@ -72,6 +73,7 @@ class WorkOrder(Document): self.set_required_items(reset_only_qty = len(self.get("required_items"))) + def validate_sales_order(self): if self.sales_order: self.check_sales_order_on_hold_or_close() @@ -625,6 +627,16 @@ class WorkOrder(Document): if not self.qty > 0: frappe.throw(_("Quantity to Manufacture must be greater than 0.")) + def validate_transfer_against(self): + if not self.docstatus == 1: + # let user configure operations until they're ready to submit + return + if not self.operations: + self.transfer_material_against = "Work Order" + if not self.transfer_material_against: + frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value")) + + def validate_operation_time(self): for d in self.operations: if not d.time_in_mins > 0: From e4c220053f9732255a1af2361b0419230ade8ae6 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 28 Jan 2022 13:00:15 +0530 Subject: [PATCH 11/18] fix(patch): update BOMs to have correct transfer_material_against --- erpnext/patches.txt | 1 + erpnext/patches/v13_0/update_sane_transfer_against.py | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 erpnext/patches/v13_0/update_sane_transfer_against.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9f7e733af4..ad5062fc0e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -334,3 +334,4 @@ erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022 erpnext.patches.v14_0.delete_agriculture_doctypes erpnext.patches.v14_0.rearrange_company_fields erpnext.patches.v14_0.update_leave_notification_template +erpnext.patches.v13_0.update_sane_transfer_against diff --git a/erpnext/patches/v13_0/update_sane_transfer_against.py b/erpnext/patches/v13_0/update_sane_transfer_against.py new file mode 100644 index 0000000000..a163d38584 --- /dev/null +++ b/erpnext/patches/v13_0/update_sane_transfer_against.py @@ -0,0 +1,11 @@ +import frappe + + +def execute(): + bom = frappe.qb.DocType("BOM") + + (frappe.qb + .update(bom) + .set(bom.transfer_material_against, "Work Order") + .where(bom.with_operations == 0) + ).run() From 28994c0e7653f30025e90b84674a15e7434a0c52 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 28 Jan 2022 13:50:57 +0530 Subject: [PATCH 12/18] test: delete data after running test --- erpnext/manufacturing/doctype/bom/test_bom.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index c77a0dd232..53437c8012 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -430,6 +430,7 @@ class TestBOM(ERPNextTestCase): bom.with_operations = 0 bom.save() self.assertEqual(bom.transfer_material_against, "Work Order") + bom.delete() def get_default_bom(item_code="_Test FG Item 2"): From 2bf405d44ca23bb25dfeb9e5b51be42128dd7cd8 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 24 Jan 2022 15:59:38 +0530 Subject: [PATCH 13/18] fix:Add shipping charges to taxes only if applicable (cherry picked from commit e8bc4f12a734ff286a008e6b31dffa9c6c946365) --- erpnext/accounts/doctype/shipping_rule/shipping_rule.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index 7e5129911e..792e7d21a7 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -71,7 +71,8 @@ class ShippingRule(Document): if doc.currency != doc.company_currency: shipping_amount = flt(shipping_amount / doc.conversion_rate, 2) - self.add_shipping_rule_to_tax_table(doc, shipping_amount) + if shipping_amount: + self.add_shipping_rule_to_tax_table(doc, shipping_amount) def get_shipping_amount_from_rules(self, value): for condition in self.get("conditions"): From 14e3e163aeec88a61748beca415e59bc0aa62870 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 28 Jan 2022 14:12:57 +0530 Subject: [PATCH 14/18] fix: ignore alternate item while checking pending qty --- erpnext/public/js/utils/serial_no_batch_selector.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 597d77c6e9..08270bdea1 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -592,6 +592,6 @@ function check_can_calculate_pending_qty(me) { && doc.fg_completed_qty && erpnext.stock.bom && erpnext.stock.bom.name === doc.bom_no; - const itemChecks = !!item; + const itemChecks = !!item && !item.allow_alternative_item; return docChecks && itemChecks; } From b20df3745eafdcc18d3b7cb9a93065562aef5c6f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 24 Jan 2022 19:19:58 +0530 Subject: [PATCH 15/18] refactor: reusable clean_serial_nos --- erpnext/controllers/stock_controller.py | 13 ++++--------- erpnext/stock/doctype/serial_no/serial_no.py | 7 +++++++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index b97432e748..d7a68f7800 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -77,17 +77,12 @@ class StockController(AccountsController): .format(d.idx, get_link_to_form("Batch", d.get("batch_no")))) def clean_serial_nos(self): + from erpnext.stock.doctype.serial_no.serial_no import clean_serial_no_string + for row in self.get("items"): if hasattr(row, "serial_no") and row.serial_no: - # replace commas by linefeed - row.serial_no = row.serial_no.replace(",", "\n") - - # strip preceeding and succeeding spaces for each SN - # (SN could have valid spaces in between e.g. SN - 123 - 2021) - serial_no_list = row.serial_no.split("\n") - serial_no_list = [sn.strip() for sn in serial_no_list] - - row.serial_no = "\n".join(serial_no_list) + # remove extra whitespace and store one serial no on each line + row.serial_no = clean_serial_no_string(row.serial_no) def get_gl_entries(self, warehouse_account=None, default_expense_account=None, default_cost_center=None): diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index ee55af3475..ee08e38f33 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -465,6 +465,13 @@ def get_serial_nos(serial_no): return [s.strip() for s in cstr(serial_no).strip().upper().replace(',', '\n').split('\n') if s.strip()] +def clean_serial_no_string(serial_no: str) -> str: + if not serial_no: + return "" + + serial_no_list = get_serial_nos(serial_no) + return "\n".join(serial_no_list) + def update_args_for_serial_no(serial_no_doc, serial_no, args, is_new=False): for field in ["item_code", "work_order", "company", "batch_no", "supplier", "location"]: if args.get(field): From e177c5277f223cd0f513d7189053e444554bd745 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 24 Jan 2022 19:28:26 +0530 Subject: [PATCH 16/18] fix: extend sr_no cleanup to packed items too --- erpnext/controllers/stock_controller.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index d7a68f7800..2912d3eb0b 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -84,6 +84,11 @@ class StockController(AccountsController): # remove extra whitespace and store one serial no on each line row.serial_no = clean_serial_no_string(row.serial_no) + for row in self.get('packed_items') or []: + if hasattr(row, "serial_no") and row.serial_no: + # remove extra whitespace and store one serial no on each line + row.serial_no = clean_serial_no_string(row.serial_no) + def get_gl_entries(self, warehouse_account=None, default_expense_account=None, default_cost_center=None): From 43bd88e741b69fcfd4a2547fd59e5e6286229522 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 24 Jan 2022 19:33:47 +0530 Subject: [PATCH 17/18] fix: work order serial no allows storing whitespace --- erpnext/manufacturing/doctype/work_order/work_order.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 93ca805e34..03e0910345 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -31,6 +31,7 @@ from erpnext.stock.doctype.batch.batch import make_batch from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life from erpnext.stock.doctype.serial_no.serial_no import ( auto_make_serial_nos, + clean_serial_no_string, get_auto_serial_nos, get_serial_nos, ) @@ -358,6 +359,7 @@ class WorkOrder(Document): frappe.delete_doc("Batch", row.name) def make_serial_nos(self, args): + self.serial_no = clean_serial_no_string(self.serial_no) serial_no_series = frappe.get_cached_value("Item", self.production_item, "serial_no_series") if serial_no_series: self.serial_no = get_auto_serial_nos(serial_no_series, self.qty) From 36882f3734eecffc94e17b3d9b6669f875ad8654 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sat, 29 Jan 2022 15:27:36 +0530 Subject: [PATCH 18/18] fix: Employee Onboarding and Separation UX (#29504) --- .../employee_boarding_controller.py | 8 ++++++-- .../hr/doctype/employee/employee_dashboard.py | 2 +- .../employee_boarding_activity.json | 4 +++- .../employee_onboarding.js | 20 +++++++++++++------ .../employee_onboarding.json | 8 +++++++- .../employee_onboarding.py | 7 ++++++- erpnext/hr/doctype/job_offer/job_offer.py | 1 + erpnext/projects/doctype/project/project.json | 10 ++++++---- erpnext/projects/doctype/task/task.json | 6 ++++-- 9 files changed, 48 insertions(+), 18 deletions(-) diff --git a/erpnext/controllers/employee_boarding_controller.py b/erpnext/controllers/employee_boarding_controller.py index b8dc92efde..ae2c73758c 100644 --- a/erpnext/controllers/employee_boarding_controller.py +++ b/erpnext/controllers/employee_boarding_controller.py @@ -132,13 +132,17 @@ class EmployeeBoardingController(Document): def on_cancel(self): # delete task project - for task in frappe.get_all('Task', filters={'project': self.project}): + project = self.project + for task in frappe.get_all('Task', filters={'project': project}): frappe.delete_doc('Task', task.name, force=1) - frappe.delete_doc('Project', self.project, force=1) + frappe.delete_doc('Project', project, force=1) self.db_set('project', '') for activity in self.activities: activity.db_set('task', '') + frappe.msgprint(_('Linked Project {} and Tasks deleted.').format( + project), alert=True, indicator='blue') + @frappe.whitelist() def get_onboarding_details(parent, parenttype): diff --git a/erpnext/hr/doctype/employee/employee_dashboard.py b/erpnext/hr/doctype/employee/employee_dashboard.py index a1247d9eb1..fb62b0414d 100644 --- a/erpnext/hr/doctype/employee/employee_dashboard.py +++ b/erpnext/hr/doctype/employee/employee_dashboard.py @@ -21,7 +21,7 @@ def get_data(): }, { 'label': _('Lifecycle'), - 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Grievance'] + 'items': ['Employee Onboarding', 'Employee Transfer', 'Employee Promotion', 'Employee Grievance'] }, { 'label': _('Exit'), diff --git a/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json b/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json index 044a5a9886..8474bd09d5 100644 --- a/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json +++ b/erpnext/hr/doctype/employee_boarding_activity/employee_boarding_activity.json @@ -62,6 +62,7 @@ }, { "default": "0", + "depends_on": "eval:['Employee Onboarding', 'Employee Onboarding Template'].includes(doc.parenttype)", "description": "Applicable in the case of Employee Onboarding", "fieldname": "required_for_employee_creation", "fieldtype": "Check", @@ -93,7 +94,7 @@ ], "istable": 1, "links": [], - "modified": "2021-07-30 15:55:22.470102", + "modified": "2022-01-29 14:05:00.543122", "modified_by": "Administrator", "module": "HR", "name": "Employee Boarding Activity", @@ -102,5 +103,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js index 5d1a024ebb..6fbb54d002 100644 --- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js +++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.js @@ -3,12 +3,6 @@ frappe.ui.form.on('Employee Onboarding', { setup: function(frm) { - frm.add_fetch("employee_onboarding_template", "company", "company"); - frm.add_fetch("employee_onboarding_template", "department", "department"); - frm.add_fetch("employee_onboarding_template", "designation", "designation"); - frm.add_fetch("employee_onboarding_template", "employee_grade", "employee_grade"); - - frm.set_query("job_applicant", function () { return { filters:{ @@ -71,5 +65,19 @@ frappe.ui.form.on('Employee Onboarding', { } }); } + }, + + job_applicant: function(frm) { + if (frm.doc.job_applicant) { + frappe.db.get_value('Employee', {'job_applicant': frm.doc.job_applicant}, 'name', (r) => { + if (r.name) { + frm.set_value('employee', r.name); + } else { + frm.set_value('employee', ''); + } + }); + } else { + frm.set_value('employee', ''); + } } }); diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json index fd877a68d8..1d2ea0c669 100644 --- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json +++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.json @@ -92,6 +92,7 @@ "options": "Employee Onboarding Template" }, { + "fetch_from": "employee_onboarding_template.company", "fieldname": "company", "fieldtype": "Link", "label": "Company", @@ -99,6 +100,7 @@ "reqd": 1 }, { + "fetch_from": "employee_onboarding_template.department", "fieldname": "department", "fieldtype": "Link", "in_list_view": 1, @@ -106,6 +108,7 @@ "options": "Department" }, { + "fetch_from": "employee_onboarding_template.designation", "fieldname": "designation", "fieldtype": "Link", "in_list_view": 1, @@ -113,6 +116,7 @@ "options": "Designation" }, { + "fetch_from": "employee_onboarding_template.employee_grade", "fieldname": "employee_grade", "fieldtype": "Link", "label": "Employee Grade", @@ -170,10 +174,11 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-07-30 14:55:04.560683", + "modified": "2022-01-29 12:33:57.120384", "modified_by": "Administrator", "module": "HR", "name": "Employee Onboarding", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -194,6 +199,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "employee_name", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py index eba2a03193..a0939a847b 100644 --- a/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py +++ b/erpnext/hr/doctype/employee_onboarding/employee_onboarding.py @@ -14,10 +14,15 @@ class IncompleteTaskError(frappe.ValidationError): pass class EmployeeOnboarding(EmployeeBoardingController): def validate(self): super(EmployeeOnboarding, self).validate() + self.set_employee() self.validate_duplicate_employee_onboarding() + def set_employee(self): + if not self.employee: + self.employee = frappe.db.get_value('Employee', {'job_applicant': self.job_applicant}, 'name') + def validate_duplicate_employee_onboarding(self): - emp_onboarding = frappe.db.exists("Employee Onboarding", {"job_applicant": self.job_applicant}) + emp_onboarding = frappe.db.exists("Employee Onboarding", {"job_applicant": self.job_applicant, "docstatus": ("!=", 2)}) if emp_onboarding and emp_onboarding != self.name: frappe.throw(_("Employee Onboarding: {0} already exists for Job Applicant: {1}").format(frappe.bold(emp_onboarding), frappe.bold(self.job_applicant))) diff --git a/erpnext/hr/doctype/job_offer/job_offer.py b/erpnext/hr/doctype/job_offer/job_offer.py index 39f471929b..072fc73271 100644 --- a/erpnext/hr/doctype/job_offer/job_offer.py +++ b/erpnext/hr/doctype/job_offer/job_offer.py @@ -78,6 +78,7 @@ def make_employee(source_name, target_doc=None): "doctype": "Employee", "field_map": { "applicant_name": "employee_name", + "offer_date": "scheduled_confirmation_date" }} }, target_doc, set_missing_values) return doc diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json index 2570df7026..1cda0a08c4 100644 --- a/erpnext/projects/doctype/project/project.json +++ b/erpnext/projects/doctype/project/project.json @@ -235,13 +235,13 @@ { "fieldname": "actual_start_date", "fieldtype": "Data", - "label": "Actual Start Date", + "label": "Actual Start Date (via Time Sheet)", "read_only": 1 }, { "fieldname": "actual_time", "fieldtype": "Float", - "label": "Actual Time (in Hours)", + "label": "Actual Time (in Hours via Time Sheet)", "read_only": 1 }, { @@ -251,7 +251,7 @@ { "fieldname": "actual_end_date", "fieldtype": "Date", - "label": "Actual End Date", + "label": "Actual End Date (via Time Sheet)", "oldfieldname": "act_completion_date", "oldfieldtype": "Date", "read_only": 1 @@ -458,10 +458,11 @@ "index_web_pages_for_search": 1, "links": [], "max_attachments": 4, - "modified": "2021-04-28 16:36:11.654632", + "modified": "2022-01-29 13:58:27.712714", "modified_by": "Administrator", "module": "Projects", "name": "Project", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -499,6 +500,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "customer", "title_field": "project_name", "track_seen": 1 diff --git a/erpnext/projects/doctype/task/task.json b/erpnext/projects/doctype/task/task.json index ef4740d9ee..81822085a5 100644 --- a/erpnext/projects/doctype/task/task.json +++ b/erpnext/projects/doctype/task/task.json @@ -249,7 +249,7 @@ { "fieldname": "actual_time", "fieldtype": "Float", - "label": "Actual Time (in hours)", + "label": "Actual Time (in Hours via Time Sheet)", "read_only": 1 }, { @@ -397,10 +397,11 @@ "is_tree": 1, "links": [], "max_attachments": 5, - "modified": "2021-04-16 12:46:51.556741", + "modified": "2022-01-29 13:58:47.005241", "modified_by": "Administrator", "module": "Projects", "name": "Task", + "naming_rule": "Expression (old style)", "nsm_parent_field": "parent_task", "owner": "Administrator", "permissions": [ @@ -421,6 +422,7 @@ "show_preview_popup": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "project", "title_field": "subject", "track_seen": 1