diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 2b3d8cb9b31..0c71b41a7cd 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -41,12 +41,17 @@ fi install_whktml() { - wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz - tar -xf /tmp/wkhtmltox.tar.xz -C /tmp - sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf - sudo chmod o+x /usr/local/bin/wkhtmltopdf + if [ "$(lsb_release -rs)" = "22.04" ]; then + wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb + sudo apt install /tmp/wkhtmltox.deb + else + echo "Please update this script to support wkhtmltopdf for $(lsb_release -ds)" + exit 1 + fi } install_whktml & +wkpid=$! + cd ~/frappe-bench || exit @@ -60,6 +65,8 @@ bench get-app erpnext "${GITHUB_WORKSPACE}" if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi +wait $wkpid + bench start &> bench_run_logs.txt & CI=Yes bench build --app frappe & bench --site test_site reinstall --yes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a46002820c..37bb37e1d24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,10 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - name: Setup Node.js v14 + - name: Setup Node.js uses: actions/setup-node@v2 with: - node-version: 14 + node-version: 18 - name: Setup dependencies run: | npm install @semantic-release/git @semantic-release/exec --no-save @@ -28,4 +28,4 @@ jobs: GIT_AUTHOR_EMAIL: "developers@frappe.io" GIT_COMMITTER_NAME: "Frappe PR Bot" GIT_COMMITTER_EMAIL: "developers@frappe.io" - run: npx semantic-release \ No newline at end of file + run: npx semantic-release diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml index bbb8a7e9fad..c70c76f65f7 100644 --- a/.github/workflows/server-tests-mariadb.yml +++ b/.github/workflows/server-tests-mariadb.yml @@ -16,12 +16,12 @@ on: workflow_dispatch: inputs: user: - description: 'user' + description: 'Frappe Framework repository user (add your username for forks)' required: true default: 'frappe' type: string branch: - description: 'Branch name' + description: 'Frappe Framework branch' default: 'develop' required: false type: string diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py index f319003876b..45e04ee6b0f 100644 --- a/erpnext/accounts/deferred_revenue.py +++ b/erpnext/accounts/deferred_revenue.py @@ -378,7 +378,7 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None): return # check if books nor frozen till endate: - if accounts_frozen_upto and (end_date) <= getdate(accounts_frozen_upto): + if accounts_frozen_upto and getdate(end_date) <= getdate(accounts_frozen_upto): end_date = get_last_day(add_days(accounts_frozen_upto, 1)) if via_journal_entry: diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.js b/erpnext/accounts/doctype/bank_clearance/bank_clearance.js index ceba99a56a8..71f2dcca1b2 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.js +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.js @@ -37,14 +37,11 @@ frappe.ui.form.on("Bank Clearance", { refresh: function(frm) { frm.disable_save(); + frm.add_custom_button(__('Get Payment Entries'), () => + frm.trigger("get_payment_entries") + ); - if (frm.doc.account && frm.doc.from_date && frm.doc.to_date) { - frm.add_custom_button(__('Get Payment Entries'), () => - frm.trigger("get_payment_entries") - ); - - frm.change_custom_button_type('Get Payment Entries', null, 'primary'); - } + frm.change_custom_button_type('Get Payment Entries', null, 'primary'); }, update_clearance_date: function(frm) { diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index d353270b453..f5f04aeea8b 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -302,7 +302,7 @@ def reconcile_vouchers(bank_transaction_name, vouchers): dict( account=account, voucher_type=voucher["payment_doctype"], voucher_no=voucher["payment_name"] ), - ["credit", "debit"], + ["credit_in_account_currency as credit", "debit_in_account_currency as debit"], as_dict=1, ) gl_amount, transaction_amount = ( diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index a7885143353..9b36c93a0f3 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -137,7 +137,7 @@ def get_paid_amount(payment_entry, currency, bank_account): ) elif doc.payment_type == "Pay": paid_amount_field = ( - "paid_amount" if doc.paid_to_account_currency == currency else "base_paid_amount" + "paid_amount" if doc.paid_from_account_currency == currency else "base_paid_amount" ) return frappe.db.get_value( diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js index 926a442f808..f72ecc9e501 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js @@ -26,7 +26,7 @@ frappe.ui.form.on('Exchange Rate Revaluation', { doc: frm.doc, callback: function(r) { if (r.message) { - frm.add_custom_button(__('Journal Entry'), function() { + frm.add_custom_button(__('Journal Entries'), function() { return frm.events.make_jv(frm); }, __('Create')); } @@ -35,10 +35,11 @@ frappe.ui.form.on('Exchange Rate Revaluation', { } }, - get_entries: function(frm) { + get_entries: function(frm, account) { frappe.call({ method: "get_accounts_data", doc: cur_frm.doc, + account: account, callback: function(r){ frappe.model.clear_table(frm.doc, "accounts"); if(r.message) { @@ -57,7 +58,6 @@ frappe.ui.form.on('Exchange Rate Revaluation', { let total_gain_loss = 0; frm.doc.accounts.forEach((d) => { - d.gain_loss = flt(d.new_balance_in_base_currency, precision("new_balance_in_base_currency", d)) - flt(d.balance_in_base_currency, precision("balance_in_base_currency", d)); total_gain_loss += flt(d.gain_loss, precision("gain_loss", d)); }); @@ -66,13 +66,19 @@ frappe.ui.form.on('Exchange Rate Revaluation', { }, make_jv : function(frm) { + let revaluation_journal = null; + let zero_balance_journal = null; frappe.call({ - method: "make_jv_entry", + method: "make_jv_entries", doc: frm.doc, + freeze: true, + freeze_message: "Making Journal Entries...", callback: function(r){ if (r.message) { - var doc = frappe.model.sync(r.message)[0]; - frappe.set_route("Form", doc.doctype, doc.name); + let response = r.message; + if(response['revaluation_jv'] || response['zero_balance_jv']) { + frappe.msgprint(__("Journals have been created")); + } } } }); diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.json b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.json index e00b17e5a53..0d198ca1201 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.json +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.json @@ -14,6 +14,9 @@ "get_entries", "accounts", "section_break_6", + "gain_loss_unbooked", + "gain_loss_booked", + "column_break_10", "total_gain_loss", "amended_from" ], @@ -59,13 +62,6 @@ "fieldname": "section_break_6", "fieldtype": "Section Break" }, - { - "fieldname": "total_gain_loss", - "fieldtype": "Currency", - "label": "Total Gain/Loss", - "options": "Company:company:default_currency", - "read_only": 1 - }, { "fieldname": "amended_from", "fieldtype": "Link", @@ -74,11 +70,37 @@ "options": "Exchange Rate Revaluation", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "gain_loss_unbooked", + "fieldtype": "Currency", + "label": "Gain/Loss from Revaluation", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "description": "Gain/Loss accumulated in foreign currency account. Accounts with '0' balance in either Base or Account currency", + "fieldname": "gain_loss_booked", + "fieldtype": "Currency", + "label": "Gain/Loss already booked", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "total_gain_loss", + "fieldtype": "Currency", + "label": "Total Gain/Loss", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" } ], "is_submittable": 1, "links": [], - "modified": "2022-11-17 10:28:03.911554", + "modified": "2022-12-29 19:38:24.416529", "modified_by": "Administrator", "module": "Accounts", "name": "Exchange Rate Revaluation", diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 68e828b24eb..d67d59b5d45 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -3,10 +3,12 @@ import frappe -from frappe import _ +from frappe import _, qb from frappe.model.document import Document from frappe.model.meta import get_field_precision -from frappe.utils import flt +from frappe.query_builder import Criterion, Order +from frappe.query_builder.functions import NullIf, Sum +from frappe.utils import flt, get_link_to_form import erpnext from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on @@ -19,11 +21,25 @@ class ExchangeRateRevaluation(Document): def set_total_gain_loss(self): total_gain_loss = 0 + + gain_loss_booked = 0 + gain_loss_unbooked = 0 + for d in self.accounts: - d.gain_loss = flt( - d.new_balance_in_base_currency, d.precision("new_balance_in_base_currency") - ) - flt(d.balance_in_base_currency, d.precision("balance_in_base_currency")) + if not d.zero_balance: + d.gain_loss = flt( + d.new_balance_in_base_currency, d.precision("new_balance_in_base_currency") + ) - flt(d.balance_in_base_currency, d.precision("balance_in_base_currency")) + + if d.zero_balance: + gain_loss_booked += flt(d.gain_loss, d.precision("gain_loss")) + else: + gain_loss_unbooked += flt(d.gain_loss, d.precision("gain_loss")) + total_gain_loss += flt(d.gain_loss, d.precision("gain_loss")) + + self.gain_loss_booked = gain_loss_booked + self.gain_loss_unbooked = gain_loss_unbooked self.total_gain_loss = flt(total_gain_loss, self.precision("total_gain_loss")) def validate_mandatory(self): @@ -35,98 +51,206 @@ class ExchangeRateRevaluation(Document): @frappe.whitelist() def check_journal_entry_condition(self): - total_debit = frappe.db.get_value( - "Journal Entry Account", - {"reference_type": "Exchange Rate Revaluation", "reference_name": self.name, "docstatus": 1}, - "sum(debit) as sum", + exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account() + + jea = qb.DocType("Journal Entry Account") + journals = ( + qb.from_(jea) + .select(jea.parent) + .distinct() + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + ) + .run() ) - total_amt = 0 - for d in self.accounts: - total_amt = total_amt + d.new_balance_in_base_currency + if journals: + gle = qb.DocType("GL Entry") + total_amt = ( + qb.from_(gle) + .select((Sum(gle.credit) - Sum(gle.debit)).as_("total_amount")) + .where( + (gle.voucher_type == "Journal Entry") + & (gle.voucher_no.isin(journals)) + & (gle.account == exchange_gain_loss_account) + & (gle.is_cancelled == 0) + ) + .run() + ) - if total_amt != total_debit: - return True + if total_amt and total_amt[0][0] != self.total_gain_loss: + return True + else: + return False - return False + return True @frappe.whitelist() - def get_accounts_data(self, account=None): - accounts = [] + def get_accounts_data(self): self.validate_mandatory() - company_currency = erpnext.get_company_currency(self.company) + account_details = self.get_account_balance_from_gle( + company=self.company, posting_date=self.posting_date, account=None, party_type=None, party=None + ) + accounts_with_new_balance = self.calculate_new_account_balance( + self.company, self.posting_date, account_details + ) + + if not accounts_with_new_balance: + self.throw_invalid_response_message(account_details) + + return accounts_with_new_balance + + @staticmethod + def get_account_balance_from_gle(company, posting_date, account, party_type, party): + account_details = [] + + if company and posting_date: + company_currency = erpnext.get_company_currency(company) + + acc = qb.DocType("Account") + if account: + accounts = [account] + else: + res = ( + qb.from_(acc) + .select(acc.name) + .where( + (acc.is_group == 0) + & (acc.report_type == "Balance Sheet") + & (acc.root_type.isin(["Asset", "Liability", "Equity"])) + & (acc.account_type != "Stock") + & (acc.company == company) + & (acc.account_currency != company_currency) + ) + .orderby(acc.name) + .run(as_list=True) + ) + accounts = [x[0] for x in res] + + if accounts: + having_clause = (qb.Field("balance") != qb.Field("balance_in_account_currency")) & ( + (qb.Field("balance_in_account_currency") != 0) | (qb.Field("balance") != 0) + ) + + gle = qb.DocType("GL Entry") + + # conditions + conditions = [] + conditions.append(gle.account.isin(accounts)) + conditions.append(gle.posting_date.lte(posting_date)) + conditions.append(gle.is_cancelled == 0) + + if party_type: + conditions.append(gle.party_type == party_type) + if party: + conditions.append(gle.party == party) + + account_details = ( + qb.from_(gle) + .select( + gle.account, + gle.party_type, + gle.party, + gle.account_currency, + (Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency)).as_( + "balance_in_account_currency" + ), + (Sum(gle.debit) - Sum(gle.credit)).as_("balance"), + (Sum(gle.debit) - Sum(gle.credit) == 0) + ^ (Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency) == 0).as_( + "zero_balance" + ), + ) + .where(Criterion.all(conditions)) + .groupby(gle.account, NullIf(gle.party_type, ""), NullIf(gle.party, "")) + .having(having_clause) + .orderby(gle.account) + .run(as_dict=True) + ) + + return account_details + + @staticmethod + def calculate_new_account_balance(company, posting_date, account_details): + accounts = [] + company_currency = erpnext.get_company_currency(company) precision = get_field_precision( frappe.get_meta("Exchange Rate Revaluation Account").get_field("new_balance_in_base_currency"), company_currency, ) - account_details = self.get_accounts_from_gle() - for d in account_details: - current_exchange_rate = ( - d.balance / d.balance_in_account_currency if d.balance_in_account_currency else 0 - ) - new_exchange_rate = get_exchange_rate(d.account_currency, company_currency, self.posting_date) - new_balance_in_base_currency = flt(d.balance_in_account_currency * new_exchange_rate) - gain_loss = flt(new_balance_in_base_currency, precision) - flt(d.balance, precision) - if gain_loss: - accounts.append( - { - "account": d.account, - "party_type": d.party_type, - "party": d.party, - "account_currency": d.account_currency, - "balance_in_base_currency": d.balance, - "balance_in_account_currency": d.balance_in_account_currency, - "current_exchange_rate": current_exchange_rate, - "new_exchange_rate": new_exchange_rate, - "new_balance_in_base_currency": new_balance_in_base_currency, - } + if account_details: + # Handle Accounts with balance in both Account/Base Currency + for d in [x for x in account_details if not x.zero_balance]: + current_exchange_rate = ( + d.balance / d.balance_in_account_currency if d.balance_in_account_currency else 0 ) + new_exchange_rate = get_exchange_rate(d.account_currency, company_currency, posting_date) + new_balance_in_base_currency = flt(d.balance_in_account_currency * new_exchange_rate) + gain_loss = flt(new_balance_in_base_currency, precision) - flt(d.balance, precision) + if gain_loss: + accounts.append( + { + "account": d.account, + "party_type": d.party_type, + "party": d.party, + "account_currency": d.account_currency, + "balance_in_base_currency": d.balance, + "balance_in_account_currency": d.balance_in_account_currency, + "zero_balance": d.zero_balance, + "current_exchange_rate": current_exchange_rate, + "new_exchange_rate": new_exchange_rate, + "new_balance_in_base_currency": new_balance_in_base_currency, + "new_balance_in_account_currency": d.balance_in_account_currency, + "gain_loss": gain_loss, + } + ) - if not accounts: - self.throw_invalid_response_message(account_details) + # Handle Accounts with '0' balance in Account/Base Currency + for d in [x for x in account_details if x.zero_balance]: + + # TODO: Set new balance in Base/Account currency + if d.balance > 0: + current_exchange_rate = new_exchange_rate = 0 + + new_balance_in_account_currency = 0 # this will be '0' + new_balance_in_base_currency = 0 # this will be '0' + gain_loss = flt(new_balance_in_base_currency, precision) - flt(d.balance, precision) + else: + new_exchange_rate = 0 + new_balance_in_base_currency = 0 + new_balance_in_account_currency = 0 + + current_exchange_rate = calculate_exchange_rate_using_last_gle( + company, d.account, d.party_type, d.party + ) + + gain_loss = new_balance_in_account_currency - ( + current_exchange_rate * d.balance_in_account_currency + ) + + if gain_loss: + accounts.append( + { + "account": d.account, + "party_type": d.party_type, + "party": d.party, + "account_currency": d.account_currency, + "balance_in_base_currency": d.balance, + "balance_in_account_currency": d.balance_in_account_currency, + "zero_balance": d.zero_balance, + "current_exchange_rate": current_exchange_rate, + "new_exchange_rate": new_exchange_rate, + "new_balance_in_base_currency": new_balance_in_base_currency, + "new_balance_in_account_currency": new_balance_in_account_currency, + "gain_loss": gain_loss, + } + ) return accounts - def get_accounts_from_gle(self): - company_currency = erpnext.get_company_currency(self.company) - accounts = frappe.db.sql_list( - """ - select name - from tabAccount - where is_group = 0 - and report_type = 'Balance Sheet' - and root_type in ('Asset', 'Liability', 'Equity') - and account_type != 'Stock' - and company=%s - and account_currency != %s - order by name""", - (self.company, company_currency), - ) - - account_details = [] - if accounts: - account_details = frappe.db.sql( - """ - select - account, party_type, party, account_currency, - sum(debit_in_account_currency) - sum(credit_in_account_currency) as balance_in_account_currency, - sum(debit) - sum(credit) as balance - from `tabGL Entry` - where account in (%s) - and posting_date <= %s - and is_cancelled = 0 - group by account, NULLIF(party_type,''), NULLIF(party,'') - having sum(debit) != sum(credit) - order by account - """ - % (", ".join(["%s"] * len(accounts)), "%s"), - tuple(accounts + [self.posting_date]), - as_dict=1, - ) - - return account_details - def throw_invalid_response_message(self, account_details): if account_details: message = _("No outstanding invoices require exchange rate revaluation") @@ -134,11 +258,7 @@ class ExchangeRateRevaluation(Document): message = _("No outstanding invoices found") frappe.msgprint(message) - @frappe.whitelist() - def make_jv_entry(self): - if self.total_gain_loss == 0: - return - + def get_for_unrealized_gain_loss_account(self): unrealized_exchange_gain_loss_account = frappe.get_cached_value( "Company", self.company, "unrealized_exchange_gain_loss_account" ) @@ -146,6 +266,130 @@ class ExchangeRateRevaluation(Document): frappe.throw( _("Please set Unrealized Exchange Gain/Loss Account in Company {0}").format(self.company) ) + return unrealized_exchange_gain_loss_account + + @frappe.whitelist() + def make_jv_entries(self): + zero_balance_jv = self.make_jv_for_zero_balance() + if zero_balance_jv: + frappe.msgprint( + f"Zero Balance Journal: {get_link_to_form('Journal Entry', zero_balance_jv.name)}" + ) + + revaluation_jv = self.make_jv_for_revaluation() + if revaluation_jv: + frappe.msgprint( + f"Revaluation Journal: {get_link_to_form('Journal Entry', revaluation_jv.name)}" + ) + + return { + "revaluation_jv": revaluation_jv.name if revaluation_jv else None, + "zero_balance_jv": zero_balance_jv.name if zero_balance_jv else None, + } + + def make_jv_for_zero_balance(self): + if self.gain_loss_booked == 0: + return + + accounts = [x for x in self.accounts if x.zero_balance] + + if not accounts: + return + + unrealized_exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account() + + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Exchange Gain Or Loss" + journal_entry.company = self.company + journal_entry.posting_date = self.posting_date + journal_entry.multi_currency = 1 + + journal_entry_accounts = [] + for d in accounts: + journal_account = frappe._dict( + { + "account": d.get("account"), + "party_type": d.get("party_type"), + "party": d.get("party"), + "account_currency": d.get("account_currency"), + "balance": flt( + d.get("balance_in_account_currency"), d.precision("balance_in_account_currency") + ), + "exchange_rate": 0, + "cost_center": erpnext.get_default_cost_center(self.company), + "reference_type": "Exchange Rate Revaluation", + "reference_name": self.name, + } + ) + + # Account Currency has balance + if d.get("balance_in_account_currency") and not d.get("new_balance_in_account_currency"): + dr_or_cr = ( + "credit_in_account_currency" + if d.get("balance_in_account_currency") > 0 + else "debit_in_account_currency" + ) + reverse_dr_or_cr = ( + "debit_in_account_currency" + if dr_or_cr == "credit_in_account_currency" + else "credit_in_account_currency" + ) + journal_account.update( + { + dr_or_cr: flt( + abs(d.get("balance_in_account_currency")), d.precision("balance_in_account_currency") + ), + reverse_dr_or_cr: 0, + "debit": 0, + "credit": 0, + } + ) + elif d.get("balance_in_base_currency") and not d.get("new_balance_in_base_currency"): + # Base currency has balance + dr_or_cr = "credit" if d.get("balance_in_base_currency") > 0 else "debit" + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + journal_account.update( + { + dr_or_cr: flt( + abs(d.get("balance_in_base_currency")), d.precision("balance_in_base_currency") + ), + reverse_dr_or_cr: 0, + "debit_in_account_currency": 0, + "credit_in_account_currency": 0, + } + ) + + journal_entry_accounts.append(journal_account) + + journal_entry_accounts.append( + { + "account": unrealized_exchange_gain_loss_account, + "balance": get_balance_on(unrealized_exchange_gain_loss_account), + "debit": abs(self.gain_loss_booked) if self.gain_loss_booked < 0 else 0, + "credit": abs(self.gain_loss_booked) if self.gain_loss_booked > 0 else 0, + "debit_in_account_currency": abs(self.gain_loss_booked) if self.gain_loss_booked < 0 else 0, + "credit_in_account_currency": self.gain_loss_booked if self.gain_loss_booked > 0 else 0, + "cost_center": erpnext.get_default_cost_center(self.company), + "exchange_rate": 1, + "reference_type": "Exchange Rate Revaluation", + "reference_name": self.name, + } + ) + + journal_entry.set("accounts", journal_entry_accounts) + journal_entry.set_total_debit_credit() + journal_entry.save() + return journal_entry + + def make_jv_for_revaluation(self): + if self.gain_loss_unbooked == 0: + return + + accounts = [x for x in self.accounts if not x.zero_balance] + if not accounts: + return + + unrealized_exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account() journal_entry = frappe.new_doc("Journal Entry") journal_entry.voucher_type = "Exchange Rate Revaluation" @@ -154,7 +398,7 @@ class ExchangeRateRevaluation(Document): journal_entry.multi_currency = 1 journal_entry_accounts = [] - for d in self.accounts: + for d in accounts: dr_or_cr = ( "debit_in_account_currency" if d.get("balance_in_account_currency") > 0 @@ -179,6 +423,7 @@ class ExchangeRateRevaluation(Document): dr_or_cr: flt( abs(d.get("balance_in_account_currency")), d.precision("balance_in_account_currency") ), + "cost_center": erpnext.get_default_cost_center(self.company), "exchange_rate": flt(d.get("new_exchange_rate"), d.precision("new_exchange_rate")), "reference_type": "Exchange Rate Revaluation", "reference_name": self.name, @@ -196,6 +441,7 @@ class ExchangeRateRevaluation(Document): reverse_dr_or_cr: flt( abs(d.get("balance_in_account_currency")), d.precision("balance_in_account_currency") ), + "cost_center": erpnext.get_default_cost_center(self.company), "exchange_rate": flt(d.get("current_exchange_rate"), d.precision("current_exchange_rate")), "reference_type": "Exchange Rate Revaluation", "reference_name": self.name, @@ -206,8 +452,11 @@ class ExchangeRateRevaluation(Document): { "account": unrealized_exchange_gain_loss_account, "balance": get_balance_on(unrealized_exchange_gain_loss_account), - "debit_in_account_currency": abs(self.total_gain_loss) if self.total_gain_loss < 0 else 0, - "credit_in_account_currency": self.total_gain_loss if self.total_gain_loss > 0 else 0, + "debit_in_account_currency": abs(self.gain_loss_unbooked) + if self.gain_loss_unbooked < 0 + else 0, + "credit_in_account_currency": self.gain_loss_unbooked if self.gain_loss_unbooked > 0 else 0, + "cost_center": erpnext.get_default_cost_center(self.company), "exchange_rate": 1, "reference_type": "Exchange Rate Revaluation", "reference_name": self.name, @@ -217,42 +466,90 @@ class ExchangeRateRevaluation(Document): journal_entry.set("accounts", journal_entry_accounts) journal_entry.set_amounts_in_company_currency() journal_entry.set_total_debit_credit() - return journal_entry.as_dict() + journal_entry.save() + return journal_entry + + +def calculate_exchange_rate_using_last_gle(company, account, party_type, party): + """ + Use last GL entry to calculate exchange rate + """ + last_exchange_rate = None + if company and account: + gl = qb.DocType("GL Entry") + + # build conditions + conditions = [] + conditions.append(gl.company == company) + conditions.append(gl.account == account) + conditions.append(gl.is_cancelled == 0) + if party_type: + conditions.append(gl.party_type == party_type) + if party: + conditions.append(gl.party == party) + + voucher_type, voucher_no = ( + qb.from_(gl) + .select(gl.voucher_type, gl.voucher_no) + .where(Criterion.all(conditions)) + .orderby(gl.posting_date, order=Order.desc) + .limit(1) + .run()[0] + ) + + last_exchange_rate = ( + qb.from_(gl) + .select((gl.debit - gl.credit) / (gl.debit_in_account_currency - gl.credit_in_account_currency)) + .where( + (gl.voucher_type == voucher_type) & (gl.voucher_no == voucher_no) & (gl.account == account) + ) + .orderby(gl.posting_date, order=Order.desc) + .limit(1) + .run()[0][0] + ) + + return last_exchange_rate @frappe.whitelist() -def get_account_details(account, company, posting_date, party_type=None, party=None): +def get_account_details(company, posting_date, account, party_type=None, party=None): + if not (company and posting_date): + frappe.throw(_("Company and Posting Date is mandatory")) + account_currency, account_type = frappe.get_cached_value( "Account", account, ["account_currency", "account_type"] ) + if account_type in ["Receivable", "Payable"] and not (party_type and party): frappe.throw(_("Party Type and Party is mandatory for {0} account").format(account_type)) account_details = {} company_currency = erpnext.get_company_currency(company) - balance = get_balance_on( - account, date=posting_date, party_type=party_type, party=party, in_account_currency=False - ) + account_details = { "account_currency": account_currency, } + account_balance = ExchangeRateRevaluation.get_account_balance_from_gle( + company=company, posting_date=posting_date, account=account, party_type=party_type, party=party + ) - if balance: - balance_in_account_currency = get_balance_on( - account, date=posting_date, party_type=party_type, party=party + if account_balance and ( + account_balance[0].balance or account_balance[0].balance_in_account_currency + ): + account_with_new_balance = ExchangeRateRevaluation.calculate_new_account_balance( + company, posting_date, account_balance ) - current_exchange_rate = ( - balance / balance_in_account_currency if balance_in_account_currency else 0 - ) - new_exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date) - new_balance_in_base_currency = balance_in_account_currency * new_exchange_rate - account_details = account_details.update( + row = account_with_new_balance[0] + account_details.update( { - "balance_in_base_currency": balance, - "balance_in_account_currency": balance_in_account_currency, - "current_exchange_rate": current_exchange_rate, - "new_exchange_rate": new_exchange_rate, - "new_balance_in_base_currency": new_balance_in_base_currency, + "balance_in_base_currency": row["balance_in_base_currency"], + "balance_in_account_currency": row["balance_in_account_currency"], + "current_exchange_rate": row["current_exchange_rate"], + "new_exchange_rate": row["new_exchange_rate"], + "new_balance_in_base_currency": row["new_balance_in_base_currency"], + "new_balance_in_account_currency": row["new_balance_in_account_currency"], + "zero_balance": row["zero_balance"], + "gain_loss": row["gain_loss"], } ) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation_account/exchange_rate_revaluation_account.json b/erpnext/accounts/doctype/exchange_rate_revaluation_account/exchange_rate_revaluation_account.json index 80e972bbdf2..2968359a0d0 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation_account/exchange_rate_revaluation_account.json +++ b/erpnext/accounts/doctype/exchange_rate_revaluation_account/exchange_rate_revaluation_account.json @@ -10,14 +10,21 @@ "party", "column_break_2", "account_currency", + "account_balances", "balance_in_account_currency", + "column_break_46yz", + "new_balance_in_account_currency", "balances", "current_exchange_rate", - "balance_in_base_currency", - "column_break_9", + "column_break_xown", "new_exchange_rate", + "column_break_9", + "balance_in_base_currency", + "column_break_ukce", "new_balance_in_base_currency", - "gain_loss" + "section_break_ngrs", + "gain_loss", + "zero_balance" ], "fields": [ { @@ -78,7 +85,7 @@ }, { "fieldname": "column_break_9", - "fieldtype": "Column Break" + "fieldtype": "Section Break" }, { "fieldname": "new_exchange_rate", @@ -102,11 +109,45 @@ "label": "Gain/Loss", "options": "Company:company:default_currency", "read_only": 1 + }, + { + "default": "0", + "description": "This Account has '0' balance in either Base Currency or Account Currency", + "fieldname": "zero_balance", + "fieldtype": "Check", + "label": "Zero Balance" + }, + { + "fieldname": "new_balance_in_account_currency", + "fieldtype": "Currency", + "label": "New Balance In Account Currency", + "options": "account_currency", + "read_only": 1 + }, + { + "fieldname": "account_balances", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_46yz", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_xown", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_ukce", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_ngrs", + "fieldtype": "Section Break" } ], "istable": 1, "links": [], - "modified": "2022-11-17 10:26:18.302728", + "modified": "2022-12-29 19:38:52.915295", "modified_by": "Administrator", "module": "Accounts", "name": "Exchange Rate Revaluation Account", diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index f3120482071..f07a4fa3bce 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -95,7 +95,15 @@ class GLEntry(Document): ) # Zero value transaction is not allowed - if not (flt(self.debit, self.precision("debit")) or flt(self.credit, self.precision("credit"))): + if not ( + flt(self.debit, self.precision("debit")) + or flt(self.credit, self.precision("credit")) + or ( + self.voucher_type == "Journal Entry" + and frappe.get_cached_value("Journal Entry", self.voucher_no, "voucher_type") + == "Exchange Gain Or Loss" + ) + ): frappe.throw( _("{0} {1}: Either debit or credit amount is required for {2}").format( self.voucher_type, self.voucher_no, self.account diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 8e5ba3718f7..3f69d5c7cd8 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -88,7 +88,7 @@ "label": "Entry Type", "oldfieldname": "voucher_type", "oldfieldtype": "Select", - "options": "Journal Entry\nInter Company Journal Entry\nBank Entry\nCash Entry\nCredit Card Entry\nDebit Note\nCredit Note\nContra Entry\nExcise Entry\nWrite Off Entry\nOpening Entry\nDepreciation Entry\nExchange Rate Revaluation\nDeferred Revenue\nDeferred Expense", + "options": "Journal Entry\nInter Company Journal Entry\nBank Entry\nCash Entry\nCredit Card Entry\nDebit Note\nCredit Note\nContra Entry\nExcise Entry\nWrite Off Entry\nOpening Entry\nDepreciation Entry\nExchange Rate Revaluation\nExchange Gain Or Loss\nDeferred Revenue\nDeferred Expense", "reqd": 1, "search_index": 1 }, @@ -539,7 +539,7 @@ "idx": 176, "is_submittable": 1, "links": [], - "modified": "2022-06-23 22:01:32.348337", + "modified": "2022-11-28 17:40:01.241908", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index b63d57c900e..ea8b7d831b2 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -6,7 +6,7 @@ import json import frappe from frappe import _, msgprint, scrub -from frappe.utils import cint, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate +from frappe.utils import cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate import erpnext from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts @@ -23,6 +23,9 @@ from erpnext.accounts.utils import ( get_stock_accounts, get_stock_and_account_balance, ) +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_depr_schedule, +) from erpnext.controllers.accounts_controller import AccountsController @@ -283,16 +286,17 @@ class JournalEntry(AccountsController): for d in self.get("accounts"): if d.reference_type == "Asset" and d.reference_name: asset = frappe.get_doc("Asset", d.reference_name) - for s in asset.get("schedules"): - if s.journal_entry == self.name: - s.db_set("journal_entry", None) + for row in asset.get("finance_books"): + depr_schedule = get_depr_schedule(asset.name, "Active", row.finance_book) - idx = cint(s.finance_book_id) or 1 - finance_books = asset.get("finance_books")[idx - 1] - finance_books.value_after_depreciation += s.depreciation_amount - finance_books.db_update() + for s in depr_schedule or []: + if s.journal_entry == self.name: + s.db_set("journal_entry", None) - asset.set_status() + row.value_after_depreciation += s.depreciation_amount + row.db_update() + + asset.set_status() def unlink_inter_company_jv(self): if ( @@ -589,28 +593,30 @@ class JournalEntry(AccountsController): d.against_account = frappe.db.get_value(d.reference_type, d.reference_name, field) else: for d in self.get("accounts"): - if flt(d.debit > 0): + if flt(d.debit) > 0: accounts_debited.append(d.party or d.account) if flt(d.credit) > 0: accounts_credited.append(d.party or d.account) for d in self.get("accounts"): - if flt(d.debit > 0): + if flt(d.debit) > 0: d.against_account = ", ".join(list(set(accounts_credited))) - if flt(d.credit > 0): + if flt(d.credit) > 0: d.against_account = ", ".join(list(set(accounts_debited))) def validate_debit_credit_amount(self): - for d in self.get("accounts"): - if not flt(d.debit) and not flt(d.credit): - frappe.throw(_("Row {0}: Both Debit and Credit values cannot be zero").format(d.idx)) + if not (self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency): + for d in self.get("accounts"): + if not flt(d.debit) and not flt(d.credit): + frappe.throw(_("Row {0}: Both Debit and Credit values cannot be zero").format(d.idx)) def validate_total_debit_and_credit(self): self.set_total_debit_credit() - if self.difference: - frappe.throw( - _("Total Debit must be equal to Total Credit. The difference is {0}").format(self.difference) - ) + if not (self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency): + if self.difference: + frappe.throw( + _("Total Debit must be equal to Total Credit. The difference is {0}").format(self.difference) + ) def set_total_debit_credit(self): self.total_debit, self.total_credit, self.difference = 0, 0, 0 @@ -648,16 +654,17 @@ class JournalEntry(AccountsController): self.set_exchange_rate() def set_amounts_in_company_currency(self): - for d in self.get("accounts"): - d.debit_in_account_currency = flt( - d.debit_in_account_currency, d.precision("debit_in_account_currency") - ) - d.credit_in_account_currency = flt( - d.credit_in_account_currency, d.precision("credit_in_account_currency") - ) + if not (self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency): + for d in self.get("accounts"): + d.debit_in_account_currency = flt( + d.debit_in_account_currency, d.precision("debit_in_account_currency") + ) + d.credit_in_account_currency = flt( + d.credit_in_account_currency, d.precision("credit_in_account_currency") + ) - d.debit = flt(d.debit_in_account_currency * flt(d.exchange_rate), d.precision("debit")) - d.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit")) + d.debit = flt(d.debit_in_account_currency * flt(d.exchange_rate), d.precision("debit")) + d.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit")) def set_exchange_rate(self): for d in self.get("accounts"): @@ -756,7 +763,7 @@ class JournalEntry(AccountsController): pay_to_recd_from = d.party if pay_to_recd_from and pay_to_recd_from == d.party: - party_amount += d.debit_in_account_currency or d.credit_in_account_currency + party_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency) party_account_currency = d.account_currency elif frappe.get_cached_value("Account", d.account, "account_type") in ["Bank", "Cash"]: @@ -786,7 +793,7 @@ class JournalEntry(AccountsController): def build_gl_map(self): gl_map = [] for d in self.get("accounts"): - if d.debit or d.credit: + if d.debit or d.credit or (self.voucher_type == "Exchange Gain Or Loss"): r = [d.user_remark, self.remark] r = [x for x in r if x] remarks = "\n".join(r) @@ -834,7 +841,7 @@ class JournalEntry(AccountsController): make_gl_entries(gl_map, cancel=cancel, adv_adj=adv_adj, update_outstanding=update_outstanding) @frappe.whitelist() - def get_balance(self): + def get_balance(self, difference_account=None): if not self.get("accounts"): msgprint(_("'Entries' cannot be empty"), raise_exception=True) else: @@ -849,7 +856,13 @@ class JournalEntry(AccountsController): blank_row = d if not blank_row: - blank_row = self.append("accounts", {}) + blank_row = self.append( + "accounts", + { + "account": difference_account, + "cost_center": erpnext.get_default_cost_center(self.company), + }, + ) blank_row.exchange_rate = 1 if diff > 0: diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 79fab6480c1..26192eca2ea 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1758,6 +1758,8 @@ def get_payment_entry( pe.setup_party_account_field() pe.set_missing_values() + update_accounting_dimensions(pe, doc) + if party_account and bank: pe.set_exchange_rate(ref_doc=reference_doc) pe.set_amounts() @@ -1775,6 +1777,18 @@ def get_payment_entry( return pe +def update_accounting_dimensions(pe, doc): + """ + Updates accounting dimensions in Payment Entry based on the accounting dimensions in the reference document + """ + from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, + ) + + for dimension in get_accounting_dimensions(): + pe.set(dimension, doc.get(dimension)) + + def get_bank_cash_account(doc, bank_account): bank = get_default_bank_cash_account( doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment"), account=bank_account diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index 0b334ae076d..d986f320669 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -170,7 +170,7 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo } reconcile() { - var show_dialog = this.frm.doc.allocation.filter(d => d.difference_amount && !d.difference_account); + var show_dialog = this.frm.doc.allocation.filter(d => d.difference_amount); if (show_dialog && show_dialog.length) { @@ -179,8 +179,12 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo title: __("Select Difference Account"), fields: [ { - fieldname: "allocation", fieldtype: "Table", label: __("Allocation"), - data: this.data, in_place_edit: true, + fieldname: "allocation", + fieldtype: "Table", + label: __("Allocation"), + data: this.data, + in_place_edit: true, + cannot_add_rows: true, get_data: () => { return this.data; }, @@ -218,6 +222,10 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo read_only: 1 }] }, + { + fieldtype: 'HTML', + options: " New Journal Entry will be posted for the difference amount " + } ], primary_action: () => { const args = dialog.get_values()["allocation"]; @@ -234,7 +242,7 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo }); this.frm.doc.allocation.forEach(d => { - if (d.difference_amount && !d.difference_account) { + if (d.difference_amount) { dialog.fields_dict.allocation.df.data.push({ 'docname': d.name, 'reference_name': d.reference_name, diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index ff212f2a35f..ac033f7db60 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -14,7 +14,6 @@ from erpnext.accounts.utils import ( QueryPaymentLedger, get_outstanding_invoices, reconcile_against_document, - update_reference_in_payment_entry, ) from erpnext.controllers.accounts_controller import get_advance_payment_entries @@ -80,12 +79,13 @@ class PaymentReconciliation(Document): "t2.against_account like %(bank_cash_account)s" if self.bank_cash_account else "1=1" ) + # nosemgrep journal_entries = frappe.db.sql( """ select "Journal Entry" as reference_type, t1.name as reference_name, t1.posting_date, t1.remark as remarks, t2.name as reference_row, - {dr_or_cr} as amount, t2.is_advance, + {dr_or_cr} as amount, t2.is_advance, t2.exchange_rate, t2.account_currency as currency from `tabJournal Entry` t1, `tabJournal Entry Account` t2 @@ -215,26 +215,26 @@ class PaymentReconciliation(Document): inv.currency = entry.get("currency") inv.outstanding_amount = flt(entry.get("outstanding_amount")) - def get_difference_amount(self, allocated_entry): - if allocated_entry.get("reference_type") != "Payment Entry": - return + def get_difference_amount(self, payment_entry, invoice, allocated_amount): + difference_amount = 0 + if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get( + "exchange_rate", 1 + ): + allocated_amount_in_ref_rate = payment_entry.get("exchange_rate", 1) * allocated_amount + allocated_amount_in_inv_rate = invoice.get("exchange_rate", 1) * allocated_amount + difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate - dr_or_cr = ( - "credit_in_account_currency" - if erpnext.get_party_account_type(self.party_type) == "Receivable" - else "debit_in_account_currency" - ) - - row = self.get_payment_details(allocated_entry, dr_or_cr) - - doc = frappe.get_doc(allocated_entry.reference_type, allocated_entry.reference_name) - update_reference_in_payment_entry(row, doc, do_not_save=True) - - return doc.difference_amount + return difference_amount @frappe.whitelist() def allocate_entries(self, args): self.validate_entries() + + invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices")) + default_exchange_gain_loss_account = frappe.get_cached_value( + "Company", self.company, "exchange_gain_loss_account" + ) + entries = [] for pay in args.get("payments"): pay.update({"unreconciled_amount": pay.get("amount")}) @@ -248,7 +248,10 @@ class PaymentReconciliation(Document): inv["outstanding_amount"] = flt(inv.get("outstanding_amount")) - flt(pay.get("amount")) pay["amount"] = 0 - res.difference_amount = self.get_difference_amount(res) + inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number")) + res.difference_amount = self.get_difference_amount(pay, inv, res["allocated_amount"]) + res.difference_account = default_exchange_gain_loss_account + res.exchange_rate = inv.get("exchange_rate") if pay.get("amount") == 0: entries.append(res) @@ -278,6 +281,7 @@ class PaymentReconciliation(Document): "amount": pay.get("amount"), "allocated_amount": allocated_amount, "difference_amount": pay.get("difference_amount"), + "currency": inv.get("currency"), } ) @@ -300,7 +304,11 @@ class PaymentReconciliation(Document): else: reconciled_entry = entry_list - reconciled_entry.append(self.get_payment_details(row, dr_or_cr)) + payment_details = self.get_payment_details(row, dr_or_cr) + reconciled_entry.append(payment_details) + + if payment_details.difference_amount: + self.make_difference_entry(payment_details) if entry_list: reconcile_against_document(entry_list) @@ -311,6 +319,56 @@ class PaymentReconciliation(Document): msgprint(_("Successfully Reconciled")) self.get_unreconciled_entries() + def make_difference_entry(self, row): + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Exchange Gain Or Loss" + journal_entry.company = self.company + journal_entry.posting_date = nowdate() + journal_entry.multi_currency = 1 + + party_account_currency = frappe.get_cached_value( + "Account", self.receivable_payable_account, "account_currency" + ) + difference_account_currency = frappe.get_cached_value( + "Account", row.difference_account, "account_currency" + ) + + # Account Currency has balance + dr_or_cr = "debit" if self.party_type == "Customer" else "debit" + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + journal_account = frappe._dict( + { + "account": self.receivable_payable_account, + "party_type": self.party_type, + "party": self.party, + "account_currency": party_account_currency, + "exchange_rate": 0, + "cost_center": erpnext.get_default_cost_center(self.company), + "reference_type": row.against_voucher_type, + "reference_name": row.against_voucher, + dr_or_cr: flt(row.difference_amount), + dr_or_cr + "_in_account_currency": 0, + } + ) + + journal_entry.append("accounts", journal_account) + + journal_account = frappe._dict( + { + "account": row.difference_account, + "account_currency": difference_account_currency, + "exchange_rate": 1, + "cost_center": erpnext.get_default_cost_center(self.company), + reverse_dr_or_cr + "_in_account_currency": flt(row.difference_amount), + } + ) + + journal_entry.append("accounts", journal_account) + + journal_entry.save() + journal_entry.submit() + def get_payment_details(self, row, dr_or_cr): return frappe._dict( { @@ -320,6 +378,7 @@ class PaymentReconciliation(Document): "against_voucher_type": row.get("invoice_type"), "against_voucher": row.get("invoice_number"), "account": self.receivable_payable_account, + "exchange_rate": row.get("exchange_rate"), "party_type": self.party_type, "party": self.party, "is_advance": row.get("is_advance"), @@ -344,6 +403,41 @@ class PaymentReconciliation(Document): if not self.get("payments"): frappe.throw(_("No records found in the Payments table")) + def get_invoice_exchange_map(self, invoices): + sales_invoices = [ + d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Sales Invoice" + ] + purchase_invoices = [ + d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Purchase Invoice" + ] + invoice_exchange_map = frappe._dict() + + if sales_invoices: + sales_invoice_map = frappe._dict( + frappe.db.get_all( + "Sales Invoice", + filters={"name": ("in", sales_invoices)}, + fields=["name", "conversion_rate"], + as_list=1, + ) + ) + + invoice_exchange_map.update(sales_invoice_map) + + if purchase_invoices: + purchase_invoice_map = frappe._dict( + frappe.db.get_all( + "Purchase Invoice", + filters={"name": ("in", purchase_invoices)}, + fields=["name", "conversion_rate"], + as_list=1, + ) + ) + + invoice_exchange_map.update(purchase_invoice_map) + + return invoice_exchange_map + def validate_allocation(self): unreconciled_invoices = frappe._dict() diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index 6030134fff2..2ba90b4da9f 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -6,7 +6,7 @@ import unittest import frappe from frappe import qb from frappe.tests.utils import FrappeTestCase -from frappe.utils import add_days, nowdate +from frappe.utils import add_days, flt, nowdate from erpnext import get_default_cost_center from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry @@ -75,33 +75,11 @@ class TestPaymentReconciliation(FrappeTestCase): self.item = item if isinstance(item, str) else item.item_code def create_customer(self): - if frappe.db.exists("Customer", "_Test PR Customer"): - self.customer = "_Test PR Customer" - else: - customer = frappe.new_doc("Customer") - customer.customer_name = "_Test PR Customer" - customer.type = "Individual" - customer.save() - self.customer = customer.name - - if frappe.db.exists("Customer", "_Test PR Customer 2"): - self.customer2 = "_Test PR Customer 2" - else: - customer = frappe.new_doc("Customer") - customer.customer_name = "_Test PR Customer 2" - customer.type = "Individual" - customer.save() - self.customer2 = customer.name - - if frappe.db.exists("Customer", "_Test PR Customer 3"): - self.customer3 = "_Test PR Customer 3" - else: - customer = frappe.new_doc("Customer") - customer.customer_name = "_Test PR Customer 3" - customer.type = "Individual" - customer.default_currency = "EUR" - customer.save() - self.customer3 = customer.name + self.customer = make_customer("_Test PR Customer") + self.customer2 = make_customer("_Test PR Customer 2") + self.customer3 = make_customer("_Test PR Customer 3", "EUR") + self.customer4 = make_customer("_Test PR Customer 4", "EUR") + self.customer5 = make_customer("_Test PR Customer 5", "EUR") def create_account(self): account_name = "Debtors EUR" @@ -598,6 +576,156 @@ class TestPaymentReconciliation(FrappeTestCase): self.assertEqual(pr.payments[0].amount, amount) self.assertEqual(pr.payments[0].currency, "EUR") + def test_difference_amount_via_journal_entry(self): + # Make Sale Invoice + si = self.create_sales_invoice( + qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + si.customer = self.customer4 + si.currency = "EUR" + si.conversion_rate = 85 + si.debit_to = self.debtors_eur + si.save().submit() + + # Make payment using Journal Entry + je1 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 100, nowdate()) + je1.multi_currency = 1 + je1.accounts[0].exchange_rate = 1 + je1.accounts[0].credit_in_account_currency = 0 + je1.accounts[0].credit = 0 + je1.accounts[0].debit_in_account_currency = 8000 + je1.accounts[0].debit = 8000 + je1.accounts[1].party_type = "Customer" + je1.accounts[1].party = self.customer4 + je1.accounts[1].exchange_rate = 80 + je1.accounts[1].credit_in_account_currency = 100 + je1.accounts[1].credit = 8000 + je1.accounts[1].debit_in_account_currency = 0 + je1.accounts[1].debit = 0 + je1.save() + je1.submit() + + je2 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 200, nowdate()) + je2.multi_currency = 1 + je2.accounts[0].exchange_rate = 1 + je2.accounts[0].credit_in_account_currency = 0 + je2.accounts[0].credit = 0 + je2.accounts[0].debit_in_account_currency = 16000 + je2.accounts[0].debit = 16000 + je2.accounts[1].party_type = "Customer" + je2.accounts[1].party = self.customer4 + je2.accounts[1].exchange_rate = 80 + je2.accounts[1].credit_in_account_currency = 200 + je1.accounts[1].credit = 16000 + je1.accounts[1].debit_in_account_currency = 0 + je1.accounts[1].debit = 0 + je2.save() + je2.submit() + + pr = self.create_payment_reconciliation() + pr.party = self.customer4 + pr.receivable_payable_account = self.debtors_eur + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 2) + + # Test exact payment allocation + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[0].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].allocated_amount, 100) + self.assertEqual(pr.allocation[0].difference_amount, -500) + + # Test partial payment allocation (with excess payment entry) + pr.set("allocation", []) + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[1].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.allocation[0].difference_account = "Exchange Gain/Loss - _PR" + + self.assertEqual(pr.allocation[0].allocated_amount, 100) + self.assertEqual(pr.allocation[0].difference_amount, -500) + + # Check if difference journal entry gets generated for difference amount after reconciliation + pr.reconcile() + total_debit_amount = frappe.db.get_all( + "Journal Entry Account", + {"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name}, + "sum(debit) as amount", + group_by="reference_name", + )[0].amount + + self.assertEqual(flt(total_debit_amount, 2), -500) + + def test_difference_amount_via_payment_entry(self): + # Make Sale Invoice + si = self.create_sales_invoice( + qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + si.customer = self.customer5 + si.currency = "EUR" + si.conversion_rate = 85 + si.debit_to = self.debtors_eur + si.save().submit() + + # Make payment using Payment Entry + pe1 = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer5, + paid_from=self.debtors_eur, + paid_to=self.bank, + paid_amount=100, + ) + + pe1.source_exchange_rate = 80 + pe1.received_amount = 8000 + pe1.save() + pe1.submit() + + pe2 = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer5, + paid_from=self.debtors_eur, + paid_to=self.bank, + paid_amount=200, + ) + + pe2.source_exchange_rate = 80 + pe2.received_amount = 16000 + pe2.save() + pe2.submit() + + pr = self.create_payment_reconciliation() + pr.party = self.customer5 + pr.receivable_payable_account = self.debtors_eur + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 2) + + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[0].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].allocated_amount, 100) + self.assertEqual(pr.allocation[0].difference_amount, -500) + + pr.set("allocation", []) + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[1].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].allocated_amount, 100) + self.assertEqual(pr.allocation[0].difference_amount, -500) + def test_differing_cost_center_on_invoice_and_payment(self): """ Cost Center filter should not affect outstanding amount calculation @@ -618,3 +746,17 @@ class TestPaymentReconciliation(FrappeTestCase): # check PR tool output self.assertEqual(len(pr.get("invoices")), 0) self.assertEqual(len(pr.get("payments")), 0) + + +def make_customer(customer_name, currency=None): + if not frappe.db.exists("Customer", customer_name): + customer = frappe.new_doc("Customer") + customer.customer_name = customer_name + customer.type = "Individual" + + if currency: + customer.default_currency = currency + customer.save() + return customer.name + else: + return customer_name diff --git a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json index 6a21692c6ac..0f7e47acfee 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json +++ b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json @@ -20,7 +20,9 @@ "section_break_5", "difference_amount", "column_break_7", - "difference_account" + "difference_account", + "exchange_rate", + "currency" ], "fields": [ { @@ -37,7 +39,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Allocated Amount", - "options": "Currency", + "options": "currency", "reqd": 1 }, { @@ -112,7 +114,7 @@ "fieldtype": "Currency", "hidden": 1, "label": "Unreconciled Amount", - "options": "Currency", + "options": "currency", "read_only": 1 }, { @@ -120,7 +122,7 @@ "fieldtype": "Currency", "hidden": 1, "label": "Amount", - "options": "Currency", + "options": "currency", "read_only": 1 }, { @@ -129,11 +131,24 @@ "hidden": 1, "label": "Reference Row", "read_only": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2021-10-06 11:48:59.616562", + "modified": "2022-12-24 21:01:14.882747", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Allocation", @@ -141,5 +156,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json b/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json index 00c9e1240c5..c4dbd7e8441 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json +++ b/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json @@ -11,7 +11,8 @@ "col_break1", "amount", "outstanding_amount", - "currency" + "currency", + "exchange_rate" ], "fields": [ { @@ -62,11 +63,17 @@ "hidden": 1, "label": "Currency", "options": "Currency" + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "hidden": 1, + "label": "Exchange Rate" } ], "istable": 1, "links": [], - "modified": "2021-08-24 22:42:40.923179", + "modified": "2022-11-08 18:18:02.502149", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Invoice", @@ -75,5 +82,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json index add07e870d8..d300ea97abc 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json +++ b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json @@ -15,7 +15,8 @@ "difference_amount", "sec_break1", "remark", - "currency" + "currency", + "exchange_rate" ], "fields": [ { @@ -91,11 +92,17 @@ "label": "Difference Amount", "options": "currency", "read_only": 1 + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "hidden": 1, + "label": "Exchange Rate" } ], "istable": 1, "links": [], - "modified": "2021-08-30 10:51:48.140062", + "modified": "2022-11-08 18:18:36.268760", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Payment", @@ -103,5 +110,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json index 2f3516e135a..381f3fb531a 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.json +++ b/erpnext/accounts/doctype/payment_request/payment_request.json @@ -32,6 +32,10 @@ "iban", "branch_code", "swift_number", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "project", "recipient_and_message", "print_format", "email_to", @@ -362,13 +366,35 @@ "label": "Payment Channel", "options": "\nEmail\nPhone", "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" } ], "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-09-30 16:19:43.680025", + "modified": "2022-12-21 16:56:40.115737", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Request", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index fc938014b33..4fc12dbc167 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -10,6 +10,9 @@ from frappe.model.document import Document from frappe.utils import flt, get_url, nowdate from frappe.utils.background_jobs import enqueue +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, +) from erpnext.accounts.doctype.payment_entry.payment_entry import ( get_company_defaults, get_payment_entry, @@ -270,6 +273,17 @@ class PaymentRequest(Document): } ) + # Update dimensions + payment_entry.update( + { + "cost_center": self.get("cost_center"), + "project": self.get("project"), + } + ) + + for dimension in get_accounting_dimensions(): + payment_entry.update({dimension: self.get(dimension)}) + if payment_entry.difference_amount: company_details = get_company_defaults(ref_doc.company) @@ -449,6 +463,17 @@ def make_payment_request(**args): } ) + # Update dimensions + pr.update( + { + "cost_center": ref_doc.get("cost_center"), + "project": ref_doc.get("project"), + } + ) + + for dimension in get_accounting_dimensions(): + pr.update({dimension: ref_doc.get(dimension)}) + if args.order_type == "Shopping Cart" or args.mute_email: pr.flags.mute_email = True diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 3989f8a8ac2..1ce780eac86 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -252,10 +252,15 @@ def get_other_conditions(conditions, values, args): if args.get("doctype") in [ "Quotation", + "Quotation Item", "Sales Order", + "Sales Order Item", "Delivery Note", + "Delivery Note Item", "Sales Invoice", + "Sales Invoice Item", "POS Invoice", + "POS Invoice Item", ]: conditions += """ and ifnull(`tabPricing Rule`.selling, 0) = 1""" else: diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index c276be2b006..31cf1206ce3 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1185,11 +1185,24 @@ class SalesInvoice(SellingController): 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) + notes = _( + "This schedule was created when Asset {0} was returned after being sold through Sales Invoice {1}." + ).format( + get_link_to_form(asset.doctype, asset.name), + get_link_to_form(self.doctype, self.get("name")), + ) + reset_depreciation_schedule(asset, self.posting_date, notes) + asset.reload() else: if asset.calculate_depreciation: - depreciate_asset(asset, self.posting_date) + notes = _( + "This schedule was created when Asset {0} was sold through Sales Invoice {1}." + ).format( + get_link_to_form(asset.doctype, asset.name), + get_link_to_form(self.doctype, self.get("name")), + ) + depreciate_asset(asset, self.posting_date, notes) asset.reload() fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 855380ef25b..e96847e1b6e 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -21,6 +21,9 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_comp from erpnext.accounts.utils import PaymentEntryUnlinkError from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_depr_schedule, +) from erpnext.controllers.accounts_controller import update_invoice_status from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency @@ -2774,7 +2777,7 @@ class TestSalesInvoice(unittest.TestCase): ["2021-09-30", 5041.1, 26407.22], ] - for i, schedule in enumerate(asset.schedules): + for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")): self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date) self.assertEqual(expected_values[i][1], schedule.depreciation_amount) self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount) @@ -2805,7 +2808,7 @@ class TestSalesInvoice(unittest.TestCase): expected_values = [["2020-12-31", 30000, 30000], ["2021-12-31", 30000, 60000]] - for i, schedule in enumerate(asset.schedules): + for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")): self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date) self.assertEqual(expected_values[i][1], schedule.depreciation_amount) self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount) @@ -2834,7 +2837,7 @@ class TestSalesInvoice(unittest.TestCase): ["2025-06-06", 18633.88, 100000.0, False], ] - for i, schedule in enumerate(asset.schedules): + for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")): self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date) self.assertEqual(expected_values[i][1], schedule.depreciation_amount) self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount) diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index 62c3ced76a7..35d19ed8434 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -890,7 +890,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-11-02 12:53:12.693217", + "modified": "2022-12-28 16:17:33.484531", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index c757057437b..41fdb6a97f8 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -199,7 +199,14 @@ def merge_similar_entries(gl_map, precision=None): # filter zero debit and credit entries merged_gl_map = filter( - lambda x: flt(x.debit, precision) != 0 or flt(x.credit, precision) != 0, merged_gl_map + lambda x: flt(x.debit, precision) != 0 + or flt(x.credit, precision) != 0 + or ( + x.voucher_type == "Journal Entry" + and frappe.get_cached_value("Journal Entry", x.voucher_no, "voucher_type") + == "Exchange Gain Or Loss" + ), + merged_gl_map, ) merged_gl_map = list(merged_gl_map) @@ -350,15 +357,26 @@ def process_debit_credit_difference(gl_map): allowance = get_debit_credit_allowance(voucher_type, precision) debit_credit_diff = get_debit_credit_difference(gl_map, precision) + if abs(debit_credit_diff) > allowance: - raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no) + if not ( + voucher_type == "Journal Entry" + and frappe.get_cached_value("Journal Entry", voucher_no, "voucher_type") + == "Exchange Gain Or Loss" + ): + raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no) elif abs(debit_credit_diff) >= (1.0 / (10**precision)): make_round_off_gle(gl_map, debit_credit_diff, precision) debit_credit_diff = get_debit_credit_difference(gl_map, precision) if abs(debit_credit_diff) > allowance: - raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no) + if not ( + voucher_type == "Journal Entry" + and frappe.get_cached_value("Journal Entry", voucher_no, "voucher_type") + == "Exchange Gain Or Loss" + ): + raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no) def get_debit_credit_difference(gl_map, precision): diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index fb2e444abd1..94a1510f095 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -810,7 +810,7 @@ class ReceivablePayableReport(object): self.ple.party.isin( qb.from_(self.customer) .select(self.customer.name) - .where(self.customer.default_sales_partner == self.filters.get("payment_terms_template")) + .where(self.customer.default_sales_partner == self.filters.get("sales_partner")) ) ) @@ -869,10 +869,15 @@ class ReceivablePayableReport(object): def get_party_details(self, party): if not party in self.party_details: if self.party_type == "Customer": + fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"] + + if self.filters.get("sales_partner"): + fields.append("default_sales_partner") + self.party_details[party] = frappe.db.get_value( "Customer", party, - ["customer_name", "territory", "customer_group", "customer_primary_contact"], + fields, as_dict=True, ) else: @@ -973,6 +978,9 @@ class ReceivablePayableReport(object): if self.filters.show_sales_person: self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data") + if self.filters.sales_partner: + self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data") + if self.filters.party_type == "Supplier": self.add_column( label=_("Supplier Group"), diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 97a9c15fc76..afd02a006e6 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -184,11 +184,9 @@ class TestAccountsReceivable(FrappeTestCase): err = err.save().submit() # Submit JV for ERR - jv = frappe.get_doc(err.make_jv_entry()) - jv = jv.save() - for x in jv.accounts: - x.cost_center = get_default_cost_center(jv.company) - jv.submit() + err_journals = err.make_jv_entries() + je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv")) + je = je.submit() filters = { "company": company, @@ -201,7 +199,7 @@ class TestAccountsReceivable(FrappeTestCase): report = execute(filters) expected_data_for_err = [0, -5, 0, 5] - row = [x for x in report[1] if x.voucher_type == jv.doctype and x.voucher_no == jv.name][0] + row = [x for x in report[1] if x.voucher_type == je.doctype and x.voucher_no == je.name][0] self.assertEqual( expected_data_for_err, [ 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 889f5a22a8a..29217b04be2 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -121,6 +121,9 @@ class AccountsReceivableSummary(ReceivablePayableReport): if row.sales_person: self.party_total[row.party].sales_person.append(row.sales_person) + if self.filters.sales_partner: + self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner") + def get_columns(self): self.columns = [] self.add_column( @@ -160,6 +163,10 @@ class AccountsReceivableSummary(ReceivablePayableReport): ) if self.filters.show_sales_person: self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data") + + if self.filters.sales_partner: + self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data") + else: self.add_column( label=_("Supplier Group"), diff --git a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py index ad9b1ba58eb..43b95dca80e 100644 --- a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py +++ b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py @@ -131,8 +131,8 @@ def get_assets(filters): else 0 end), 0) as depreciation_amount_during_the_period - from `tabAsset` a, `tabDepreciation Schedule` ds - where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s and a.name = ds.parent and ifnull(ds.journal_entry, '') != '' + from `tabAsset` a, `tabAsset Depreciation Schedule` ads, `tabDepreciation Schedule` ds + where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s and ads.asset = a.name and ads.docstatus=1 and ads.name = ds.parent and ifnull(ds.journal_entry, '') != '' group by a.asset_category union SELECT a.asset_category, diff --git a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py index 6b0d3c97640..4765e3b318a 100644 --- a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py +++ b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py @@ -26,6 +26,7 @@ class PartyLedgerSummaryReport(object): ) self.get_gl_entries() + self.get_additional_columns() self.get_return_invoices() self.get_party_adjustment_amounts() @@ -33,6 +34,42 @@ class PartyLedgerSummaryReport(object): data = self.get_data() return columns, data + def get_additional_columns(self): + """ + Additional Columns for 'User Permission' based access control + """ + from frappe import qb + + if self.filters.party_type == "Customer": + self.territories = frappe._dict({}) + self.customer_group = frappe._dict({}) + + customer = qb.DocType("Customer") + result = ( + frappe.qb.from_(customer) + .select( + customer.name, customer.territory, customer.customer_group, customer.default_sales_partner + ) + .where((customer.disabled == 0)) + .run(as_dict=True) + ) + + for x in result: + self.territories[x.name] = x.territory + self.customer_group[x.name] = x.customer_group + else: + self.supplier_group = frappe._dict({}) + supplier = qb.DocType("Supplier") + result = ( + frappe.qb.from_(supplier) + .select(supplier.name, supplier.supplier_group) + .where((supplier.disabled == 0)) + .run(as_dict=True) + ) + + for x in result: + self.supplier_group[x.name] = x.supplier_group + def get_columns(self): columns = [ { @@ -116,6 +153,35 @@ class PartyLedgerSummaryReport(object): }, ] + # Hidden columns for handling 'User Permissions' + if self.filters.party_type == "Customer": + columns += [ + { + "label": _("Territory"), + "fieldname": "territory", + "fieldtype": "Link", + "options": "Territory", + "hidden": 1, + }, + { + "label": _("Customer Group"), + "fieldname": "customer_group", + "fieldtype": "Link", + "options": "Customer Group", + "hidden": 1, + }, + ] + else: + columns += [ + { + "label": _("Supplier Group"), + "fieldname": "supplier_group", + "fieldtype": "Link", + "options": "Supplier Group", + "hidden": 1, + } + ] + return columns def get_data(self): @@ -143,6 +209,12 @@ class PartyLedgerSummaryReport(object): ), ) + if self.filters.party_type == "Customer": + self.party_data[gle.party].update({"territory": self.territories.get(gle.party)}) + self.party_data[gle.party].update({"customer_group": self.customer_group.get(gle.party)}) + else: + self.party_data[gle.party].update({"supplier_group": self.supplier_group.get(gle.party)}) + amount = gle.get(invoice_dr_or_cr) - gle.get(reverse_dr_or_cr) self.party_data[gle.party].closing_balance += amount diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index e3531b0575e..fc231279359 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -239,7 +239,7 @@ def get_conditions(filters): ): conditions.append("(posting_date >=%(from_date)s or is_opening = 'Yes')") - conditions.append("(posting_date <=%(to_date)s)") + conditions.append("(posting_date <=%(to_date)s or is_opening = 'Yes')") if filters.get("project"): conditions.append("project in %(project)s") diff --git a/erpnext/accounts/report/general_ledger/test_general_ledger.py b/erpnext/accounts/report/general_ledger/test_general_ledger.py index b10e7696187..c5637857636 100644 --- a/erpnext/accounts/report/general_ledger/test_general_ledger.py +++ b/erpnext/accounts/report/general_ledger/test_general_ledger.py @@ -109,8 +109,7 @@ class TestGeneralLedger(FrappeTestCase): frappe.db.set_value( "Company", company, "unrealized_exchange_gain_loss_account", "_Test Exchange Gain/Loss - _TC" ) - revaluation_jv = revaluation.make_jv_entry() - revaluation_jv = frappe.get_doc(revaluation_jv) + revaluation_jv = revaluation.make_jv_for_revaluation() revaluation_jv.cost_center = "_Test Cost Center - _TC" for acc in revaluation_jv.get("accounts"): acc.cost_center = "_Test Cost Center - _TC" diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py index c04b9c71252..d34c21348c8 100644 --- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py +++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py @@ -53,9 +53,6 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum item_details = get_item_details() for d in item_list: - if not d.stock_qty: - continue - item_record = item_details.get(d.item_code) purchase_receipt = None @@ -94,7 +91,7 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum "expense_account": expense_account, "stock_qty": d.stock_qty, "stock_uom": d.stock_uom, - "rate": d.base_net_amount / d.stock_qty, + "rate": d.base_net_amount / d.stock_qty if d.stock_qty else d.base_net_amount, "amount": d.base_net_amount, } ) diff --git a/erpnext/accounts/report/supplier_ledger_summary/supplier_ledger_summary.js b/erpnext/accounts/report/supplier_ledger_summary/supplier_ledger_summary.js index f81297760ed..5dc4c3d1c15 100644 --- a/erpnext/accounts/report/supplier_ledger_summary/supplier_ledger_summary.js +++ b/erpnext/accounts/report/supplier_ledger_summary/supplier_ledger_summary.js @@ -63,24 +63,6 @@ frappe.query_reports["Supplier Ledger Summary"] = { "fieldtype": "Link", "options": "Payment Terms Template" }, - { - "fieldname":"territory", - "label": __("Territory"), - "fieldtype": "Link", - "options": "Territory" - }, - { - "fieldname":"sales_partner", - "label": __("Sales Partner"), - "fieldtype": "Link", - "options": "Sales Partner" - }, - { - "fieldname":"sales_person", - "label": __("Sales Person"), - "fieldtype": "Link", - "options": "Sales Person" - }, { "fieldname":"tax_id", "label": __("Tax Id"), diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index d3cd29013f2..97cc1c4a130 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -101,11 +101,8 @@ def convert_to_presentation_currency(gl_entries, currency_info, company): account_currency = entry["account_currency"] if len(account_currencies) == 1 and account_currency == presentation_currency: - if debit_in_account_currency: - entry["debit"] = debit_in_account_currency - - if credit_in_account_currency: - entry["credit"] = credit_in_account_currency + entry["debit"] = debit_in_account_currency + entry["credit"] = credit_in_account_currency else: date = currency_info["report_date"] converted_debit_value = convert(debit, presentation_currency, company_currency, date) diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py index 882cd694a32..3aca60eae5b 100644 --- a/erpnext/accounts/test/test_utils.py +++ b/erpnext/accounts/test/test_utils.py @@ -3,11 +3,14 @@ import unittest import frappe from frappe.test_runner import make_test_objects +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.party import get_party_shipping_address from erpnext.accounts.utils import ( get_future_stock_vouchers, get_voucherwise_gl_entries, sort_stock_vouchers_by_posting_date, + update_reference_in_payment_entry, ) from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -73,6 +76,47 @@ class TestUtils(unittest.TestCase): sorted_vouchers = sort_stock_vouchers_by_posting_date(list(reversed(vouchers))) self.assertEqual(sorted_vouchers, vouchers) + def test_update_reference_in_payment_entry(self): + item = make_item().name + + purchase_invoice = make_purchase_invoice( + item=item, supplier="_Test Supplier USD", currency="USD", conversion_rate=82.32 + ) + purchase_invoice.submit() + + payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name) + payment_entry.target_exchange_rate = 62.9 + payment_entry.paid_amount = 15725 + payment_entry.deductions = [] + payment_entry.insert() + + self.assertEqual(payment_entry.difference_amount, -4855.00) + payment_entry.references = [] + payment_entry.submit() + + payment_reconciliation = frappe.new_doc("Payment Reconciliation") + payment_reconciliation.company = payment_entry.company + payment_reconciliation.party_type = "Supplier" + payment_reconciliation.party = purchase_invoice.supplier + payment_reconciliation.receivable_payable_account = payment_entry.paid_to + payment_reconciliation.get_unreconciled_entries() + payment_reconciliation.allocate_entries( + { + "payments": [d.__dict__ for d in payment_reconciliation.payments], + "invoices": [d.__dict__ for d in payment_reconciliation.invoices], + } + ) + for d in payment_reconciliation.invoices: + # Reset invoice outstanding_amount because allocate_entries will zero this value out. + d.outstanding_amount = d.amount + for d in payment_reconciliation.allocation: + d.difference_account = "Exchange Gain/Loss - _TC" + payment_reconciliation.reconcile() + + payment_entry.load_from_db() + self.assertEqual(len(payment_entry.references), 1) + self.assertEqual(payment_entry.difference_amount, 0) + ADDRESS_RECORDS = [ { diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 1e573b01bad..445dcc53c63 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -611,11 +611,6 @@ def update_reference_in_payment_entry(d, payment_entry, do_not_save=False): new_row.docstatus = 1 new_row.update(reference_details) - payment_entry.flags.ignore_validate_update_after_submit = True - payment_entry.setup_party_account_field() - payment_entry.set_missing_values() - payment_entry.set_amounts() - if d.difference_amount and d.difference_account: account_details = { "account": d.difference_account, @@ -627,6 +622,11 @@ def update_reference_in_payment_entry(d, payment_entry, do_not_save=False): payment_entry.set_gain_or_loss(account_details=account_details) + payment_entry.flags.ignore_validate_update_after_submit = True + payment_entry.setup_party_account_field() + payment_entry.set_missing_values() + payment_entry.set_amounts() + if not do_not_save: payment_entry.save(ignore_permissions=True) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 7e542197407..b8185c929e6 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -76,7 +76,6 @@ frappe.ui.form.on('Asset', { refresh: function(frm) { frappe.ui.form.trigger("Asset", "is_existing_asset"); frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1); - frm.events.make_schedules_editable(frm); if (frm.doc.docstatus==1) { if (in_list(["Submitted", "Partially Depreciated", "Fully Depreciated"], frm.doc.status)) { @@ -188,7 +187,11 @@ frappe.ui.form.on('Asset', { }) }, - setup_chart: function(frm) { + setup_chart: async 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; @@ -202,7 +205,20 @@ frappe.ui.form.on('Asset', { flt(frm.doc.opening_accumulated_depreciation)); } - $.each(frm.doc.schedules || [], function(i, v) { + let depr_schedule = []; + + if (frm.doc.finance_books.length == 1) { + depr_schedule = (await frappe.call( + "erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule.get_depr_schedule", + { + asset_name: frm.doc.name, + status: frm.doc.docstatus ? "Active" : "Draft", + finance_book: frm.doc.finance_books[0].finance_book || null + } + )).message; + } + + $.each(depr_schedule || [], function(i, v) { x_intervals.push(v.schedule_date); var asset_value = flt(frm.doc.gross_purchase_amount) - flt(v.accumulated_depreciation_amount); if(v.journal_entry) { @@ -266,21 +282,6 @@ frappe.ui.form.on('Asset', { // frm.toggle_reqd("next_depreciation_date", (!frm.doc.is_existing_asset && frm.doc.calculate_depreciation)); }, - opening_accumulated_depreciation: function(frm) { - erpnext.asset.set_accumulated_depreciation(frm); - }, - - make_schedules_editable: function(frm) { - if (frm.doc.finance_books) { - var is_editable = frm.doc.finance_books.filter(d => d.depreciation_method == "Manual").length > 0 - ? true : false; - - frm.toggle_enable("schedules", is_editable); - frm.fields_dict["schedules"].grid.toggle_enable("schedule_date", is_editable); - frm.fields_dict["schedules"].grid.toggle_enable("depreciation_amount", is_editable); - } - }, - make_sales_invoice: function(frm) { frappe.call({ args: { @@ -476,7 +477,6 @@ frappe.ui.form.on('Asset Finance Book', { depreciation_method: function(frm, cdt, cdn) { const row = locals[cdt][cdn]; frm.events.set_depreciation_rate(frm, row); - frm.events.make_schedules_editable(frm); }, expected_value_after_useful_life: function(frm, cdt, cdn) { @@ -512,41 +512,6 @@ frappe.ui.form.on('Asset Finance Book', { } }); -frappe.ui.form.on('Depreciation Schedule', { - make_depreciation_entry: function(frm, cdt, cdn) { - var row = locals[cdt][cdn]; - if (!row.journal_entry) { - frappe.call({ - method: "erpnext.assets.doctype.asset.depreciation.make_depreciation_entry", - args: { - "asset_name": frm.doc.name, - "date": row.schedule_date - }, - callback: function(r) { - frappe.model.sync(r.message); - frm.refresh(); - } - }) - } - }, - - depreciation_amount: function(frm, cdt, cdn) { - erpnext.asset.set_accumulated_depreciation(frm); - } - -}) - -erpnext.asset.set_accumulated_depreciation = function(frm) { - if(frm.doc.depreciation_method != "Manual") return; - - var accumulated_depreciation = flt(frm.doc.opening_accumulated_depreciation); - $.each(frm.doc.schedules || [], function(i, row) { - accumulated_depreciation += flt(row.depreciation_amount); - frappe.model.set_value(row.doctype, row.name, - "accumulated_depreciation_amount", accumulated_depreciation); - }) -}; - erpnext.asset.scrap_asset = function(frm) { frappe.confirm(__("Do you really want to scrap this asset?"), function () { frappe.call({ diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index f0505ff9835..4bac3031e8a 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -52,8 +52,6 @@ "column_break_24", "frequency_of_depreciation", "next_depreciation_date", - "section_break_14", - "schedules", "insurance_details", "policy_number", "insurer", @@ -307,19 +305,6 @@ "label": "Next Depreciation Date", "no_copy": 1 }, - { - "depends_on": "calculate_depreciation", - "fieldname": "section_break_14", - "fieldtype": "Section Break", - "label": "Depreciation Schedule" - }, - { - "fieldname": "schedules", - "fieldtype": "Table", - "label": "Depreciation Schedule", - "no_copy": 1, - "options": "Depreciation Schedule" - }, { "collapsible": 1, "fieldname": "insurance_details", @@ -508,9 +493,14 @@ "group": "Value", "link_doctype": "Asset Value Adjustment", "link_fieldname": "asset" + }, + { + "group": "Depreciation", + "link_doctype": "Asset Depreciation Schedule", + "link_fieldname": "asset" } ], - "modified": "2022-07-20 10:15:12.887372", + "modified": "2022-11-25 12:47:19.689702", "modified_by": "Administrator", "module": "Assets", "name": "Asset", diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index ca6be9b57b2..df05d5e6325 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -8,14 +8,15 @@ import math import frappe from frappe import _ from frappe.utils import ( - add_days, add_months, cint, date_diff, flt, get_datetime, get_last_day, + get_link_to_form, getdate, + is_last_day_of_the_month, month_diff, nowdate, today, @@ -28,6 +29,16 @@ from erpnext.assets.doctype.asset.depreciation import ( get_disposal_account_and_cost_center, ) from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + cancel_asset_depr_schedules, + convert_draft_asset_depr_schedules_into_active, + get_asset_depr_schedule_doc, + get_depr_schedule, + make_draft_asset_depr_schedules, + make_draft_asset_depr_schedules_if_not_present, + set_draft_asset_depr_schedule_details, + update_draft_asset_depr_schedules, +) from erpnext.controllers.accounts_controller import AccountsController @@ -40,9 +51,9 @@ class Asset(AccountsController): self.set_missing_values() if not self.split_from: self.prepare_depreciation_data() + update_draft_asset_depr_schedules(self) self.validate_gross_and_purchase_amount() - if self.get("schedules"): - self.validate_expected_value_after_useful_life() + self.validate_expected_value_after_useful_life() self.status = self.get_status() @@ -52,16 +63,24 @@ class Asset(AccountsController): self.make_asset_movement() if not self.booked_fixed_asset and self.validate_make_gl_entry(): self.make_gl_entries() + if not self.split_from: + make_draft_asset_depr_schedules_if_not_present(self) + convert_draft_asset_depr_schedules_into_active(self) def on_cancel(self): self.validate_cancellation() self.cancel_movement_entries() self.delete_depreciation_entries() + cancel_asset_depr_schedules(self) self.set_status() self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") make_reverse_gl_entries(voucher_type="Asset", voucher_no=self.name) self.db_set("booked_fixed_asset", 0) + def after_insert(self): + if not self.split_from: + make_draft_asset_depr_schedules(self) + def validate_asset_and_reference(self): if self.purchase_invoice or self.purchase_receipt: reference_doc = "Purchase Invoice" if self.purchase_invoice else "Purchase Receipt" @@ -79,12 +98,10 @@ class Asset(AccountsController): _("Purchase Invoice cannot be made against an existing asset {0}").format(self.name) ) - def prepare_depreciation_data(self, date_of_disposal=None, date_of_return=None): + def prepare_depreciation_data(self): if self.calculate_depreciation: self.value_after_depreciation = 0 self.set_depreciation_rate() - 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,148 +240,6 @@ class Asset(AccountsController): self.get_depreciation_rate(d, on_validate=True), d.precision("rate_of_depreciation") ) - 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" - ): - self.schedules = [] - - if not self.available_for_use_date: - return - - start = self.clear_depreciation_schedule() - - for finance_book in self.get("finance_books"): - self._make_depreciation_schedule(finance_book, start, date_of_disposal) - - def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): - self.validate_asset_finance_books(finance_book) - - 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 - ) - - has_pro_rata = self.check_is_pro_rata(finance_book) - if has_pro_rata: - number_of_pending_depreciations += 1 - - skip_row = False - should_get_last_day = is_last_day_of_the_month(finance_book.depreciation_start_date) - - 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) - - 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 should_get_last_day: - schedule_date = get_last_day(schedule_date) - - # 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_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_disposal - ) - - if depreciation_amount > 0: - self._add_depreciation_row( - date_of_disposal, - depreciation_amount, - finance_book.depreciation_method, - finance_book.finance_book, - finance_book.idx, - ) - - break - - # For first row - if has_pro_rata and not self.opening_accumulated_depreciation and n == 0: - from_date = add_days( - self.available_for_use_date, -1 - ) # needed to calc depr amount for available_for_use_date too - depreciation_amount, days, months = self.get_pro_rata_amt( - finance_book, depreciation_amount, from_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: - 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: @@ -376,58 +251,6 @@ class Asset(AccountsController): 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 - def clear_depreciation_schedule(self): - start = [] - num_of_depreciations_completed = 0 - 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) - num_of_depreciations_completed = 0 - - # to ensure that start will only be updated once for each FB - if len(start) == (int(schedule.finance_book_id) - 1): - if schedule.journal_entry: - num_of_depreciations_completed += 1 - depr_schedule.append(schedule) - else: - start.append(num_of_depreciations_completed) - num_of_depreciations_completed = 0 - - # to update start when all the schedule rows corresponding to the last FB are linked with JEs - if len(start) == (len(self.finance_books) - 1): - start.append(num_of_depreciations_completed) - - # when the Depreciation Schedule is being created for the first time - if start == []: - start = [0] * len(self.finance_books) - else: - self.schedules = depr_schedule - - return start - - def get_from_date(self, finance_book): - if not self.get("schedules"): - return self.available_for_use_date - - if len(self.finance_books) == 1: - return self.schedules[-1].schedule_date - - from_date = "" - for schedule in self.get("schedules"): - if schedule.finance_book == finance_book: - from_date = schedule.schedule_date - - if from_date: - return from_date - - # since depr for available_for_use_date is not yet booked - return add_days(self.available_for_use_date, -1) - # if it returns True, depreciation_amount will not be equal for the first and last rows def check_is_pro_rata(self, row): has_pro_rata = False @@ -512,83 +335,15 @@ class Asset(AccountsController): ).format(row.idx) ) - # to ensure that final accumulated depreciation amount is accurate - def get_adjusted_depreciation_amount( - self, depreciation_amount_without_pro_rata, depreciation_amount_for_last_row, finance_book - ): - if not self.opening_accumulated_depreciation: - depreciation_amount_for_first_row = self.get_depreciation_amount_for_first_row(finance_book) - - if ( - depreciation_amount_for_first_row + depreciation_amount_for_last_row - != depreciation_amount_without_pro_rata - ): - depreciation_amount_for_last_row = ( - depreciation_amount_without_pro_rata - depreciation_amount_for_first_row - ) - - return depreciation_amount_for_last_row - - def get_depreciation_amount_for_first_row(self, finance_book): - if self.has_only_one_finance_book(): - return self.schedules[0].depreciation_amount - else: - for schedule in self.schedules: - if schedule.finance_book == finance_book: - return schedule.depreciation_amount - - def has_only_one_finance_book(self): - if len(self.finance_books) == 1: - return True - - def set_accumulated_depreciation( - self, date_of_sale=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" - ] - finance_books = [] - - for i, d in enumerate(self.get("schedules")): - if ignore_booked_entry and d.journal_entry: - continue - - if int(d.finance_book_id) not in finance_books: - accumulated_depreciation = flt(self.opening_accumulated_depreciation) - value_after_depreciation = flt(self.get_value_after_depreciation(d.finance_book_id)) - finance_books.append(int(d.finance_book_id)) - - depreciation_amount = flt(d.depreciation_amount, d.precision("depreciation_amount")) - value_after_depreciation -= flt(depreciation_amount) - - # for the last row, if depreciation method = Straight Line - if ( - straight_line_idx - and i == max(straight_line_idx) - 1 - and not date_of_sale - and not date_of_return - ): - book = self.get("finance_books")[cint(d.finance_book_id) - 1] - depreciation_amount += flt( - value_after_depreciation - flt(book.expected_value_after_useful_life), - d.precision("depreciation_amount"), - ) - - d.depreciation_amount = depreciation_amount - accumulated_depreciation += d.depreciation_amount - d.accumulated_depreciation_amount = flt( - accumulated_depreciation, d.precision("accumulated_depreciation_amount") - ) - - def get_value_after_depreciation(self, idx): - return flt(self.get("finance_books")[cint(idx) - 1].value_after_depreciation) - def validate_expected_value_after_useful_life(self): for row in self.get("finance_books"): + depr_schedule = get_depr_schedule(self.name, "Draft", row.finance_book) + + if not depr_schedule: + continue + accumulated_depreciation_after_full_schedule = [ - d.accumulated_depreciation_amount - for d in self.get("schedules") - if cint(d.finance_book_id) == row.idx + d.accumulated_depreciation_amount for d in depr_schedule ] if accumulated_depreciation_after_full_schedule: @@ -637,10 +392,13 @@ class Asset(AccountsController): movement.cancel() def delete_depreciation_entries(self): - for d in self.get("schedules"): - if d.journal_entry: - frappe.get_doc("Journal Entry", d.journal_entry).cancel() - d.db_set("journal_entry", None) + for row in self.get("finance_books"): + depr_schedule = get_depr_schedule(self.name, "Active", row.finance_book) + + for d in depr_schedule or []: + if d.journal_entry: + frappe.get_doc("Journal Entry", d.journal_entry).cancel() + d.db_set("journal_entry", None) self.db_set( "value_after_depreciation", @@ -1072,32 +830,6 @@ def get_total_days(date, frequency): return date_diff(date, period_start_date) -def is_last_day_of_the_month(date): - last_day_of_the_month = get_last_day(date) - - return getdate(last_day_of_the_month) == getdate(date) - - -@erpnext.allow_regional -def get_depreciation_amount(asset, depreciable_value, row): - if row.depreciation_method in ("Straight Line", "Manual"): - # if the Depreciation Schedule is being prepared for the first time - if not asset.flags.increase_in_asset_life: - depreciation_amount = ( - flt(asset.gross_purchase_amount) - flt(row.expected_value_after_useful_life) - ) / flt(row.total_number_of_depreciations) - - # if the Depreciation Schedule is being modified after Asset Repair - else: - depreciation_amount = ( - flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life) - ) / (date_diff(asset.to_date, asset.available_for_use_date) / 365) - else: - 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) @@ -1109,12 +841,12 @@ def split_asset(asset_name, split_qty): remaining_qty = asset.asset_quantity - split_qty new_asset = create_new_asset_after_split(asset, split_qty) - update_existing_asset(asset, remaining_qty) + update_existing_asset(asset, remaining_qty, new_asset.name) return new_asset -def update_existing_asset(asset, remaining_qty): +def update_existing_asset(asset, remaining_qty, new_asset_name): remaining_gross_purchase_amount = flt( (asset.gross_purchase_amount * remaining_qty) / asset.asset_quantity ) @@ -1132,34 +864,49 @@ def update_existing_asset(asset, remaining_qty): }, ) - for finance_book in asset.get("finance_books"): + for row in asset.get("finance_books"): value_after_depreciation = flt( - (finance_book.value_after_depreciation * remaining_qty) / asset.asset_quantity + (row.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 + (row.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 + "Asset Finance Book", row.name, "value_after_depreciation", value_after_depreciation ) frappe.db.set_value( "Asset Finance Book", - finance_book.name, + row.name, "expected_value_after_useful_life", expected_value_after_useful_life, ) - accumulated_depreciation = 0 + current_asset_depr_schedule_doc = get_asset_depr_schedule_doc( + asset.name, "Active", row.finance_book + ) + new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc) - for term in asset.get("schedules"): - 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", accumulated_depreciation + set_draft_asset_depr_schedule_details(new_asset_depr_schedule_doc, asset, row) + + accumulated_depreciation = 0 + + for term in new_asset_depr_schedule_doc.get("depreciation_schedule"): + depreciation_amount = flt((term.depreciation_amount * remaining_qty) / asset.asset_quantity) + term.depreciation_amount = depreciation_amount + accumulated_depreciation += depreciation_amount + term.accumulated_depreciation_amount = accumulated_depreciation + + notes = _( + "This schedule was created when Asset {0} was updated after being split into new Asset {1}." + ).format( + get_link_to_form(asset.doctype, asset.name), get_link_to_form(asset.doctype, new_asset_name) ) + new_asset_depr_schedule_doc.notes = notes + + current_asset_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True + current_asset_depr_schedule_doc.cancel() + + new_asset_depr_schedule_doc.submit() def create_new_asset_after_split(asset, split_qty): @@ -1173,31 +920,49 @@ def create_new_asset_after_split(asset, 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.asset_quantity + for row in new_asset.get("finance_books"): + row.value_after_depreciation = flt( + (row.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 + row.expected_value_after_useful_life = flt( + (row.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.asset_quantity) - 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 - ) + for row in new_asset.get("finance_books"): + current_asset_depr_schedule_doc = get_asset_depr_schedule_doc( + asset.name, "Active", row.finance_book + ) + new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc) + + set_draft_asset_depr_schedule_details(new_asset_depr_schedule_doc, new_asset, row) + + accumulated_depreciation = 0 + + for term in new_asset_depr_schedule_doc.get("depreciation_schedule"): + 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 + + notes = _("This schedule was created when new Asset {0} was split from Asset {1}.").format( + get_link_to_form(new_asset.doctype, new_asset.name), get_link_to_form(asset.doctype, asset.name) + ) + new_asset_depr_schedule_doc.notes = notes + + new_asset_depr_schedule_doc.submit() + + for row in new_asset.get("finance_books"): + depr_schedule = get_depr_schedule(new_asset.name, "Active", row.finance_book) + for term in depr_schedule: + # 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 diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index 97941706aa8..7686c348a63 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -4,12 +4,18 @@ import frappe from frappe import _ -from frappe.utils import add_months, cint, flt, getdate, nowdate, today +from frappe.utils import add_months, cint, flt, get_link_to_form, getdate, nowdate, today from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_checks_for_pl_and_bs_accounts, ) from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_asset_depr_schedule_doc, + get_asset_depr_schedule_name, + get_temp_asset_depr_schedule_doc, + make_new_active_asset_depr_schedules_and_cancel_current_ones, +) def post_depreciation_entries(date=None, commit=True): @@ -21,8 +27,11 @@ def post_depreciation_entries(date=None, commit=True): if not date: date = today() - for asset in get_depreciable_assets(date): - make_depreciation_entry(asset, date) + for asset_name in get_depreciable_assets(date): + asset_doc = frappe.get_doc("Asset", asset_name) + + make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date) + if commit: frappe.db.commit() @@ -30,21 +39,35 @@ def post_depreciation_entries(date=None, commit=True): def get_depreciable_assets(date): return frappe.db.sql_list( """select distinct a.name - from tabAsset a, `tabDepreciation Schedule` ds - where a.name = ds.parent and a.docstatus=1 and ds.schedule_date<=%s and a.calculate_depreciation = 1 + from tabAsset a, `tabAsset Depreciation Schedule` ads, `tabDepreciation Schedule` ds + where a.name = ads.asset and ads.name = ds.parent and a.docstatus=1 and ads.docstatus=1 and a.status in ('Submitted', 'Partially Depreciated') + and a.calculate_depreciation = 1 + and ds.schedule_date<=%s and ifnull(ds.journal_entry, '')=''""", date, ) +def make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date=None): + for row in asset_doc.get("finance_books"): + asset_depr_schedule_name = get_asset_depr_schedule_name( + asset_doc.name, "Active", row.finance_book + ) + make_depreciation_entry(asset_depr_schedule_name, date) + + @frappe.whitelist() -def make_depreciation_entry(asset_name, date=None): +def make_depreciation_entry(asset_depr_schedule_name, date=None): frappe.has_permission("Journal Entry", throw=True) if not date: date = today() + asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule_name) + + asset_name = asset_depr_schedule_doc.asset + asset = frappe.get_doc("Asset", asset_name) ( fixed_asset_account, @@ -60,14 +83,14 @@ def make_depreciation_entry(asset_name, date=None): accounting_dimensions = get_checks_for_pl_and_bs_accounts() - for d in asset.get("schedules"): + for d in asset_depr_schedule_doc.get("depreciation_schedule"): if not d.journal_entry and getdate(d.schedule_date) <= getdate(date): je = frappe.new_doc("Journal Entry") je.voucher_type = "Depreciation Entry" je.naming_series = depreciation_series je.posting_date = d.schedule_date je.company = asset.company - je.finance_book = d.finance_book + je.finance_book = asset_depr_schedule_doc.finance_book je.remark = "Depreciation Entry against {0} worth {1}".format(asset_name, d.depreciation_amount) credit_account, debit_account = get_credit_and_debit_accounts( @@ -118,14 +141,14 @@ def make_depreciation_entry(asset_name, date=None): d.db_set("journal_entry", je.name) - idx = cint(d.finance_book_id) - finance_books = asset.get("finance_books")[idx - 1] - finance_books.value_after_depreciation -= d.depreciation_amount - finance_books.db_update() + idx = cint(asset_depr_schedule_doc.finance_book_id) + row = asset.get("finance_books")[idx - 1] + row.value_after_depreciation -= d.depreciation_amount + row.db_update() asset.set_status() - return asset + return asset_depr_schedule_doc def get_depreciation_accounts(asset): @@ -199,7 +222,11 @@ def scrap_asset(asset_name): date = today() - depreciate_asset(asset, date) + notes = _("This schedule was created when Asset {0} was scrapped.").format( + get_link_to_form(asset.doctype, asset.name) + ) + + depreciate_asset(asset, date, notes) asset.reload() depreciation_series = frappe.get_cached_value( @@ -232,10 +259,15 @@ 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 + notes = _("This schedule was created when Asset {0} was restored.").format( + get_link_to_form(asset.doctype, asset.name) + ) + + reset_depreciation_schedule(asset, asset.disposal_date, notes) + asset.db_set("disposal_date", None) asset.db_set("journal_entry_for_scrap", None) @@ -244,22 +276,28 @@ 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() +def depreciate_asset(asset_doc, date, notes): + asset_doc.flags.ignore_validate_update_after_submit = True - make_depreciation_entry(asset.name, date) + make_new_active_asset_depr_schedules_and_cancel_current_ones( + asset_doc, notes, date_of_disposal=date + ) + + asset_doc.save() + + make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date) -def reset_depreciation_schedule(asset, date): - asset.flags.ignore_validate_update_after_submit = True +def reset_depreciation_schedule(asset_doc, date, notes): + asset_doc.flags.ignore_validate_update_after_submit = True - # recreate original depreciation schedule of the asset - asset.prepare_depreciation_data(date_of_return=date) + make_new_active_asset_depr_schedules_and_cancel_current_ones( + asset_doc, notes, date_of_return=date + ) - modify_depreciation_schedule_for_asset_repairs(asset) - asset.save() + modify_depreciation_schedule_for_asset_repairs(asset_doc) + + asset_doc.save() def modify_depreciation_schedule_for_asset_repairs(asset): @@ -271,35 +309,36 @@ def modify_depreciation_schedule_for_asset_repairs(asset): if repair.increase_in_asset_life: asset_repair = frappe.get_doc("Asset Repair", repair.name) asset_repair.modify_depreciation_schedule() - asset.prepare_depreciation_data() + notes = _("This schedule was created when Asset {0} went through Asset Repair {1}.").format( + get_link_to_form(asset.doctype, asset.name), + get_link_to_form(asset_repair.doctype, asset_repair.name), + ) + make_new_active_asset_depr_schedules_and_cancel_current_ones(asset, notes) def reverse_depreciation_entry_made_after_disposal(asset, date): - 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 + for row in asset.get("finance_books"): + asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset.name, "Active", row.finance_book) - 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): + for schedule_idx, schedule in enumerate(asset_depr_schedule_doc.get("depreciation_schedule")): + if schedule.schedule_date == date: + if not disposal_was_made_on_original_schedule_date( + schedule_idx, 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() + 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) - asset.finance_books[0].value_after_depreciation += depreciation_amount - asset.save() + frappe.flags.is_reverse_depr_entry = False + asset_depr_schedule_doc.flags.ignore_validate_update_after_submit = True + asset.flags.ignore_validate_update_after_submit = True + schedule.journal_entry = None + depreciation_amount = get_depreciation_amount_in_je(reverse_journal_entry) + row.value_after_depreciation += depreciation_amount + asset_depr_schedule_doc.save() + asset.save() def get_depreciation_amount_in_je(journal_entry): @@ -310,15 +349,14 @@ def get_depreciation_amount_in_je(journal_entry): # 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) - ) +def disposal_was_made_on_original_schedule_date(schedule_idx, row, posting_date_of_disposal): + orginal_schedule_date = add_months( + row.depreciation_start_date, schedule_idx * cint(row.frequency_of_depreciation) + ) + + if orginal_schedule_date == posting_date_of_disposal: + return True - if orginal_schedule_date == posting_date_of_disposal: - return True return False @@ -499,24 +537,27 @@ def get_disposal_account_and_cost_center(company): def get_value_after_depreciation_on_disposal_date(asset, disposal_date, finance_book=None): asset_doc = frappe.get_doc("Asset", asset) - if asset_doc.calculate_depreciation: - asset_doc.prepare_depreciation_data(getdate(disposal_date)) - - finance_book_id = 1 - if finance_book: - for fb in asset_doc.finance_books: - if fb.finance_book == finance_book: - finance_book_id = fb.idx - break - - asset_schedules = [ - sch for sch in asset_doc.schedules if cint(sch.finance_book_id) == finance_book_id - ] - accumulated_depr_amount = asset_schedules[-1].accumulated_depreciation_amount - - return flt( - flt(asset_doc.gross_purchase_amount) - accumulated_depr_amount, - asset_doc.precision("gross_purchase_amount"), - ) - else: + if not asset_doc.calculate_depreciation: return flt(asset_doc.value_after_depreciation) + + idx = 1 + if finance_book: + for d in asset.finance_books: + if d.finance_book == finance_book: + idx = d.idx + break + + row = asset_doc.finance_books[idx - 1] + + temp_asset_depreciation_schedule = get_temp_asset_depr_schedule_doc( + asset_doc, row, getdate(disposal_date) + ) + + accumulated_depr_amount = temp_asset_depreciation_schedule.get("depreciation_schedule")[ + -1 + ].accumulated_depreciation_amount + + return flt( + flt(asset_doc.gross_purchase_amount) - accumulated_depr_amount, + asset_doc.precision("gross_purchase_amount"), + ) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 2bec27371b5..d61ef8ecf8a 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -27,6 +27,11 @@ from erpnext.assets.doctype.asset.depreciation import ( restore_asset, scrap_asset, ) +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + clear_depr_schedule, + get_asset_depr_schedule_doc, + get_depr_schedule, +) from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( make_purchase_invoice as make_invoice, ) @@ -205,6 +210,9 @@ class TestAsset(AssetSetup): submit=1, ) + first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Active") + post_depreciation_entries(date=add_months(purchase_date, 2)) asset.load_from_db() @@ -216,6 +224,11 @@ class TestAsset(AssetSetup): scrap_asset(asset.name) asset.load_from_db() + first_asset_depr_schedule.load_from_db() + + second_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + self.assertEquals(second_asset_depr_schedule.status, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Cancelled") accumulated_depr_amount = flt( asset.gross_purchase_amount - asset.finance_books[0].value_after_depreciation, @@ -256,6 +269,11 @@ class TestAsset(AssetSetup): self.assertSequenceEqual(gle, expected_gle) restore_asset(asset.name) + second_asset_depr_schedule.load_from_db() + + third_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + self.assertEquals(third_asset_depr_schedule.status, "Active") + self.assertEquals(second_asset_depr_schedule.status, "Cancelled") asset.load_from_db() self.assertFalse(asset.journal_entry_for_scrap) @@ -283,6 +301,9 @@ class TestAsset(AssetSetup): submit=1, ) + first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Active") + post_depreciation_entries(date=add_months(purchase_date, 2)) si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company") @@ -294,6 +315,12 @@ class TestAsset(AssetSetup): self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold") + first_asset_depr_schedule.load_from_db() + + second_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + self.assertEquals(second_asset_depr_schedule.status, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Cancelled") + pro_rata_amount, _, _ = asset.get_pro_rata_amt( asset.finance_books[0], 9000, get_last_day(add_months(purchase_date, 1)), date ) @@ -370,6 +397,9 @@ class TestAsset(AssetSetup): submit=1, ) + first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Active") + post_depreciation_entries(date="2021-01-01") self.assertEqual(asset.asset_quantity, 10) @@ -378,21 +408,31 @@ class TestAsset(AssetSetup): new_asset = split_asset(asset.name, 2) asset.load_from_db() + first_asset_depr_schedule.load_from_db() + + second_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + first_asset_depr_schedule_of_new_asset = get_asset_depr_schedule_doc(new_asset.name, "Active") + self.assertEquals(second_asset_depr_schedule.status, "Active") + self.assertEquals(first_asset_depr_schedule_of_new_asset.status, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Cancelled") + + depr_schedule_of_asset = second_asset_depr_schedule.get("depreciation_schedule") + depr_schedule_of_new_asset = first_asset_depr_schedule_of_new_asset.get("depreciation_schedule") 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(depr_schedule_of_new_asset[0].depreciation_amount, 4000) + self.assertEqual(depr_schedule_of_new_asset[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) + self.assertEqual(depr_schedule_of_asset[0].depreciation_amount, 16000) + self.assertEqual(depr_schedule_of_asset[1].depreciation_amount, 16000) - journal_entry = asset.schedules[0].journal_entry + journal_entry = depr_schedule_of_asset[0].journal_entry jv = frappe.get_doc("Journal Entry", journal_entry) self.assertEqual(jv.accounts[0].credit_in_account_currency, 16000) @@ -629,7 +669,7 @@ class TestDepreciationMethods(AssetSetup): schedules = [ [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount] - for d in asset.get("schedules") + for d in get_depr_schedule(asset.name, "Draft") ] self.assertEqual(schedules, expected_schedules) @@ -651,7 +691,7 @@ class TestDepreciationMethods(AssetSetup): expected_schedules = [["2032-12-31", 30000.0, 77095.89], ["2033-06-06", 12904.11, 90000.0]] schedules = [ [cstr(d.schedule_date), flt(d.depreciation_amount, 2), d.accumulated_depreciation_amount] - for d in asset.get("schedules") + for d in get_depr_schedule(asset.name, "Draft") ] self.assertEqual(schedules, expected_schedules) @@ -678,7 +718,7 @@ class TestDepreciationMethods(AssetSetup): schedules = [ [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount] - for d in asset.get("schedules") + for d in get_depr_schedule(asset.name, "Draft") ] self.assertEqual(schedules, expected_schedules) @@ -703,7 +743,7 @@ class TestDepreciationMethods(AssetSetup): schedules = [ [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount] - for d in asset.get("schedules") + for d in get_depr_schedule(asset.name, "Draft") ] self.assertEqual(schedules, expected_schedules) @@ -733,7 +773,7 @@ class TestDepreciationMethods(AssetSetup): flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2), ] - for d in asset.get("schedules") + for d in get_depr_schedule(asset.name, "Draft") ] self.assertEqual(schedules, expected_schedules) @@ -765,7 +805,7 @@ class TestDepreciationMethods(AssetSetup): flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2), ] - for d in asset.get("schedules") + for d in get_depr_schedule(asset.name, "Draft") ] self.assertEqual(schedules, expected_schedules) @@ -798,7 +838,7 @@ class TestDepreciationMethods(AssetSetup): flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2), ] - for d in asset.get("schedules") + for d in get_depr_schedule(asset.name, "Draft") ] self.assertEqual(schedules, expected_schedules) @@ -831,7 +871,7 @@ class TestDepreciationMethods(AssetSetup): flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2), ] - for d in asset.get("schedules") + for d in get_depr_schedule(asset.name, "Draft") ] self.assertEqual(schedules, expected_schedules) @@ -854,7 +894,7 @@ class TestDepreciationBasics(AssetSetup): ["2022-12-31", 30000, 90000], ] - for i, schedule in enumerate(asset.schedules): + for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")): self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date) self.assertEqual(expected_values[i][1], schedule.depreciation_amount) self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount) @@ -877,7 +917,7 @@ class TestDepreciationBasics(AssetSetup): ["2023-01-01", 15000, 90000], ] - for i, schedule in enumerate(asset.schedules): + for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")): self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date) self.assertEqual(expected_values[i][1], schedule.depreciation_amount) self.assertEqual(expected_values[i][2], schedule.accumulated_depreciation_amount) @@ -885,7 +925,9 @@ class TestDepreciationBasics(AssetSetup): def test_get_depreciation_amount(self): """Tests if get_depreciation_amount() returns the right value.""" - from erpnext.assets.doctype.asset.asset import get_depreciation_amount + from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_depreciation_amount, + ) asset = create_asset(item_code="Macbook Pro", available_for_use_date="2019-12-31") @@ -904,8 +946,8 @@ class TestDepreciationBasics(AssetSetup): depreciation_amount = get_depreciation_amount(asset, 100000, asset.finance_books[0]) self.assertEqual(depreciation_amount, 30000) - def test_make_depreciation_schedule(self): - """Tests if make_depreciation_schedule() returns the right values.""" + def test_make_depr_schedule(self): + """Tests if make_depr_schedule() returns the right values.""" asset = create_asset( item_code="Macbook Pro", @@ -920,7 +962,7 @@ class TestDepreciationBasics(AssetSetup): expected_values = [["2020-12-31", 30000.0], ["2021-12-31", 30000.0], ["2022-12-31", 30000.0]] - for i, schedule in enumerate(asset.schedules): + for i, schedule in enumerate(get_depr_schedule(asset.name, "Draft")): self.assertEqual(getdate(expected_values[i][0]), schedule.schedule_date) self.assertEqual(expected_values[i][1], schedule.depreciation_amount) @@ -940,7 +982,7 @@ class TestDepreciationBasics(AssetSetup): expected_values = [30000.0, 60000.0, 90000.0] - for i, schedule in enumerate(asset.schedules): + for i, schedule in enumerate(get_depr_schedule(asset.name, "Draft")): self.assertEqual(expected_values[i], schedule.accumulated_depreciation_amount) def test_check_is_pro_rata(self): @@ -1120,9 +1162,11 @@ class TestDepreciationBasics(AssetSetup): post_depreciation_entries(date="2021-06-01") asset.load_from_db() - self.assertTrue(asset.schedules[0].journal_entry) - self.assertFalse(asset.schedules[1].journal_entry) - self.assertFalse(asset.schedules[2].journal_entry) + depr_schedule = get_depr_schedule(asset.name, "Active") + + self.assertTrue(depr_schedule[0].journal_entry) + self.assertFalse(depr_schedule[1].journal_entry) + self.assertFalse(depr_schedule[2].journal_entry) def test_depr_entry_posting_when_depr_expense_account_is_an_expense_account(self): """Tests if the Depreciation Expense Account gets debited and the Accumulated Depreciation Account gets credited when the former's an Expense Account.""" @@ -1141,7 +1185,7 @@ class TestDepreciationBasics(AssetSetup): post_depreciation_entries(date="2021-06-01") asset.load_from_db() - je = frappe.get_doc("Journal Entry", asset.schedules[0].journal_entry) + je = frappe.get_doc("Journal Entry", get_depr_schedule(asset.name, "Active")[0].journal_entry) accounting_entries = [ {"account": entry.account, "debit": entry.debit, "credit": entry.credit} for entry in je.accounts @@ -1177,7 +1221,7 @@ class TestDepreciationBasics(AssetSetup): post_depreciation_entries(date="2021-06-01") asset.load_from_db() - je = frappe.get_doc("Journal Entry", asset.schedules[0].journal_entry) + je = frappe.get_doc("Journal Entry", get_depr_schedule(asset.name, "Active")[0].journal_entry) accounting_entries = [ {"account": entry.account, "debit": entry.debit, "credit": entry.credit} for entry in je.accounts @@ -1196,8 +1240,8 @@ class TestDepreciationBasics(AssetSetup): depr_expense_account.parent_account = "Expenses - _TC" depr_expense_account.save() - def test_clear_depreciation_schedule(self): - """Tests if clear_depreciation_schedule() works as expected.""" + def test_clear_depr_schedule(self): + """Tests if clear_depr_schedule() works as expected.""" asset = create_asset( item_code="Macbook Pro", @@ -1213,17 +1257,20 @@ class TestDepreciationBasics(AssetSetup): post_depreciation_entries(date="2021-06-01") asset.load_from_db() - asset.clear_depreciation_schedule() + asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset.name, "Active") - self.assertEqual(len(asset.schedules), 1) + clear_depr_schedule(asset_depr_schedule_doc) - def test_clear_depreciation_schedule_for_multiple_finance_books(self): + self.assertEqual(len(asset_depr_schedule_doc.get("depreciation_schedule")), 1) + + def test_clear_depr_schedule_for_multiple_finance_books(self): asset = create_asset(item_code="Macbook Pro", available_for_use_date="2019-12-31", do_not_save=1) asset.calculate_depreciation = 1 asset.append( "finance_books", { + "finance_book": "Test Finance Book 1", "depreciation_method": "Straight Line", "frequency_of_depreciation": 1, "total_number_of_depreciations": 3, @@ -1234,6 +1281,7 @@ class TestDepreciationBasics(AssetSetup): asset.append( "finance_books", { + "finance_book": "Test Finance Book 2", "depreciation_method": "Straight Line", "frequency_of_depreciation": 1, "total_number_of_depreciations": 6, @@ -1244,6 +1292,7 @@ class TestDepreciationBasics(AssetSetup): asset.append( "finance_books", { + "finance_book": "Test Finance Book 3", "depreciation_method": "Straight Line", "frequency_of_depreciation": 12, "total_number_of_depreciations": 3, @@ -1256,15 +1305,23 @@ class TestDepreciationBasics(AssetSetup): post_depreciation_entries(date="2020-04-01") asset.load_from_db() - asset.clear_depreciation_schedule() + asset_depr_schedule_doc_1 = get_asset_depr_schedule_doc( + asset.name, "Active", "Test Finance Book 1" + ) + clear_depr_schedule(asset_depr_schedule_doc_1) + self.assertEqual(len(asset_depr_schedule_doc_1.get("depreciation_schedule")), 3) - self.assertEqual(len(asset.schedules), 6) + asset_depr_schedule_doc_2 = get_asset_depr_schedule_doc( + asset.name, "Active", "Test Finance Book 2" + ) + clear_depr_schedule(asset_depr_schedule_doc_2) + self.assertEqual(len(asset_depr_schedule_doc_2.get("depreciation_schedule")), 3) - for schedule in asset.schedules: - if schedule.idx <= 3: - self.assertEqual(schedule.finance_book_id, "1") - else: - self.assertEqual(schedule.finance_book_id, "2") + asset_depr_schedule_doc_3 = get_asset_depr_schedule_doc( + asset.name, "Active", "Test Finance Book 3" + ) + clear_depr_schedule(asset_depr_schedule_doc_3) + self.assertEqual(len(asset_depr_schedule_doc_3.get("depreciation_schedule")), 0) def test_depreciation_schedules_are_set_up_for_multiple_finance_books(self): asset = create_asset(item_code="Macbook Pro", available_for_use_date="2019-12-31", do_not_save=1) @@ -1273,6 +1330,7 @@ class TestDepreciationBasics(AssetSetup): asset.append( "finance_books", { + "finance_book": "Test Finance Book 1", "depreciation_method": "Straight Line", "frequency_of_depreciation": 12, "total_number_of_depreciations": 3, @@ -1283,6 +1341,7 @@ class TestDepreciationBasics(AssetSetup): asset.append( "finance_books", { + "finance_book": "Test Finance Book 2", "depreciation_method": "Straight Line", "frequency_of_depreciation": 12, "total_number_of_depreciations": 6, @@ -1292,13 +1351,15 @@ class TestDepreciationBasics(AssetSetup): ) asset.save() - self.assertEqual(len(asset.schedules), 9) + asset_depr_schedule_doc_1 = get_asset_depr_schedule_doc( + asset.name, "Draft", "Test Finance Book 1" + ) + self.assertEqual(len(asset_depr_schedule_doc_1.get("depreciation_schedule")), 3) - for schedule in asset.schedules: - if schedule.idx <= 3: - self.assertEqual(schedule.finance_book_id, 1) - else: - self.assertEqual(schedule.finance_book_id, 2) + asset_depr_schedule_doc_2 = get_asset_depr_schedule_doc( + asset.name, "Draft", "Test Finance Book 2" + ) + self.assertEqual(len(asset_depr_schedule_doc_2.get("depreciation_schedule")), 6) def test_depreciation_entry_cancellation(self): asset = create_asset( @@ -1318,12 +1379,12 @@ class TestDepreciationBasics(AssetSetup): asset.load_from_db() # cancel depreciation entry - depr_entry = asset.get("schedules")[0].journal_entry + depr_entry = get_depr_schedule(asset.name, "Active")[0].journal_entry self.assertTrue(depr_entry) + frappe.get_doc("Journal Entry", depr_entry).cancel() - asset.load_from_db() - depr_entry = asset.get("schedules")[0].journal_entry + depr_entry = get_depr_schedule(asset.name, "Active")[0].journal_entry self.assertFalse(depr_entry) def test_asset_expected_value_after_useful_life(self): @@ -1338,7 +1399,7 @@ class TestDepreciationBasics(AssetSetup): ) accumulated_depreciation_after_full_schedule = max( - d.accumulated_depreciation_amount for d in asset.get("schedules") + d.accumulated_depreciation_amount for d in get_depr_schedule(asset.name, "Draft") ) asset_value_after_full_schedule = flt(asset.gross_purchase_amount) - flt( @@ -1369,7 +1430,7 @@ class TestDepreciationBasics(AssetSetup): asset.load_from_db() # check depreciation entry series - self.assertEqual(asset.get("schedules")[0].journal_entry[:4], "DEPR") + self.assertEqual(get_depr_schedule(asset.name, "Active")[0].journal_entry[:4], "DEPR") expected_gle = ( ("_Test Accumulated Depreciations - _TC", 0.0, 30000.0), @@ -1439,7 +1500,7 @@ class TestDepreciationBasics(AssetSetup): "2020-07-15", ] - for i, schedule in enumerate(asset.schedules): + for i, schedule in enumerate(get_depr_schedule(asset.name, "Active")): self.assertEqual(getdate(expected_dates[i]), getdate(schedule.schedule_date)) @@ -1453,6 +1514,15 @@ def create_asset_data(): if not frappe.db.exists("Location", "Test Location"): frappe.get_doc({"doctype": "Location", "location_name": "Test Location"}).insert() + if not frappe.db.exists("Finance Book", "Test Finance Book 1"): + frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "Test Finance Book 1"}).insert() + + if not frappe.db.exists("Finance Book", "Test Finance Book 2"): + frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "Test Finance Book 2"}).insert() + + if not frappe.db.exists("Finance Book", "Test Finance Book 3"): + frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "Test Finance Book 3"}).insert() + def create_asset(**args): args = frappe._dict(args) diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index 08355f047e5..7d3b645be7d 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -7,7 +7,7 @@ import frappe # import erpnext from frappe import _ -from frappe.utils import cint, flt +from frappe.utils import cint, flt, get_link_to_form from six import string_types import erpnext @@ -19,6 +19,9 @@ from erpnext.assets.doctype.asset.depreciation import ( reverse_depreciation_entry_made_after_disposal, ) from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + make_new_active_asset_depr_schedules_and_cancel_current_ones, +) from erpnext.assets.doctype.asset_value_adjustment.asset_value_adjustment import ( get_current_asset_value, ) @@ -427,7 +430,12 @@ class AssetCapitalization(StockController): asset = self.get_asset(item) if asset.calculate_depreciation: - depreciate_asset(asset, self.posting_date) + notes = _( + "This schedule was created when Asset {0} was consumed when Asset Capitalization {1} was submitted." + ).format( + get_link_to_form(asset.doctype, asset.name), get_link_to_form(self.doctype, self.get("name")) + ) + depreciate_asset(asset, self.posting_date, notes) asset.reload() fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( @@ -513,7 +521,12 @@ class AssetCapitalization(StockController): asset_doc.purchase_date = self.posting_date asset_doc.gross_purchase_amount = total_target_asset_value asset_doc.purchase_receipt_amount = total_target_asset_value - asset_doc.prepare_depreciation_data() + notes = _( + "This schedule was created when target Asset {0} was updated when Asset Capitalization {1} was submitted." + ).format( + get_link_to_form(asset_doc.doctype, asset_doc.name), get_link_to_form(self.doctype, self.name) + ) + make_new_active_asset_depr_schedules_and_cancel_current_ones(asset_doc, notes) asset_doc.flags.ignore_validate_update_after_submit = True asset_doc.save() elif self.docstatus == 2: @@ -524,7 +537,12 @@ class AssetCapitalization(StockController): if asset.calculate_depreciation: reverse_depreciation_entry_made_after_disposal(asset, self.posting_date) - reset_depreciation_schedule(asset, self.posting_date) + notes = _( + "This schedule was created when Asset {0} was restored when Asset Capitalization {1} was cancelled." + ).format( + get_link_to_form(asset.doctype, asset.name), get_link_to_form(self.doctype, self.name) + ) + reset_depreciation_schedule(asset, self.posting_date, notes) def get_asset(self, item): asset = frappe.get_doc("Asset", item.asset) diff --git a/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py index 86861f0b165..4d519a60be7 100644 --- a/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py @@ -12,6 +12,9 @@ from erpnext.assets.doctype.asset.test_asset import ( create_asset_data, set_depreciation_settings_in_company, ) +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_asset_depr_schedule_doc, +) from erpnext.stock.doctype.item.test_item import create_item @@ -253,6 +256,9 @@ class TestAssetCapitalization(unittest.TestCase): submit=1, ) + first_asset_depr_schedule = get_asset_depr_schedule_doc(consumed_asset.name, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Active") + # Create and submit Asset Captitalization asset_capitalization = create_asset_capitalization( entry_type="Decapitalization", @@ -282,8 +288,18 @@ class TestAssetCapitalization(unittest.TestCase): consumed_asset.reload() self.assertEqual(consumed_asset.status, "Decapitalized") + first_asset_depr_schedule.load_from_db() + + second_asset_depr_schedule = get_asset_depr_schedule_doc(consumed_asset.name, "Active") + self.assertEquals(second_asset_depr_schedule.status, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Cancelled") + + depr_schedule_of_consumed_asset = second_asset_depr_schedule.get("depreciation_schedule") + consumed_depreciation_schedule = [ - d for d in consumed_asset.schedules if getdate(d.schedule_date) == getdate(capitalization_date) + d + for d in depr_schedule_of_consumed_asset + if getdate(d.schedule_date) == getdate(capitalization_date) ] self.assertTrue( consumed_depreciation_schedule and consumed_depreciation_schedule[0].journal_entry diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/__init__.py b/erpnext/assets/doctype/asset_depreciation_schedule/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js new file mode 100644 index 00000000000..c28b2b3b6a3 --- /dev/null +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.js @@ -0,0 +1,51 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +frappe.provide("erpnext.asset"); + +frappe.ui.form.on('Asset Depreciation Schedule', { + onload: function(frm) { + frm.events.make_schedules_editable(frm); + }, + + make_schedules_editable: function(frm) { + var is_editable = frm.doc.depreciation_method == "Manual" ? true : false; + + frm.toggle_enable("depreciation_schedule", is_editable); + frm.fields_dict["depreciation_schedule"].grid.toggle_enable("schedule_date", is_editable); + frm.fields_dict["depreciation_schedule"].grid.toggle_enable("depreciation_amount", is_editable); + } +}); + +frappe.ui.form.on('Depreciation Schedule', { + make_depreciation_entry: function(frm, cdt, cdn) { + var row = locals[cdt][cdn]; + if (!row.journal_entry) { + frappe.call({ + method: "erpnext.assets.doctype.asset.depreciation.make_depreciation_entry", + args: { + "asset_depr_schedule_name": frm.doc.name, + "date": row.schedule_date + }, + callback: function(r) { + frappe.model.sync(r.message); + frm.refresh(); + } + }) + } + }, + + depreciation_amount: function(frm, cdt, cdn) { + erpnext.asset.set_accumulated_depreciation(frm); + } +}); + +erpnext.asset.set_accumulated_depreciation = function(frm) { + if(frm.doc.depreciation_method != "Manual") return; + + var accumulated_depreciation = flt(frm.doc.opening_accumulated_depreciation); + $.each(frm.doc.schedules || [], function(i, row) { + accumulated_depreciation += flt(row.depreciation_amount); + frappe.model.set_value(row.doctype, row.name, + "accumulated_depreciation_amount", accumulated_depreciation); + }) +}; diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json new file mode 100644 index 00000000000..af09cda8fb3 --- /dev/null +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.json @@ -0,0 +1,202 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "naming_series:", + "creation": "2022-10-31 15:03:35.424877", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "asset", + "naming_series", + "column_break_2", + "opening_accumulated_depreciation", + "finance_book", + "finance_book_id", + "depreciation_details_section", + "depreciation_method", + "total_number_of_depreciations", + "rate_of_depreciation", + "column_break_8", + "frequency_of_depreciation", + "expected_value_after_useful_life", + "depreciation_schedule_section", + "depreciation_schedule", + "details_section", + "notes", + "status", + "amended_from" + ], + "fields": [ + { + "fieldname": "asset", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Asset", + "options": "Asset", + "reqd": 1 + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "ACC-ADS-.YYYY.-" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Asset Depreciation Schedule", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "collapsible": 1, + "fieldname": "depreciation_details_section", + "fieldtype": "Section Break", + "label": "Depreciation Details" + }, + { + "fieldname": "finance_book", + "fieldtype": "Link", + "label": "Finance Book", + "options": "Finance Book" + }, + { + "fieldname": "depreciation_method", + "fieldtype": "Select", + "label": "Depreciation Method", + "options": "\nStraight Line\nDouble Declining Balance\nWritten Down Value\nManual", + "read_only": 1 + }, + { + "depends_on": "eval:doc.depreciation_method == 'Written Down Value'", + "description": "In Percentage", + "fieldname": "rate_of_depreciation", + "fieldtype": "Percent", + "label": "Rate of Depreciation", + "read_only": 1 + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, + { + "depends_on": "total_number_of_depreciations", + "fieldname": "total_number_of_depreciations", + "fieldtype": "Int", + "label": "Total Number of Depreciations", + "read_only": 1 + }, + { + "fieldname": "depreciation_schedule_section", + "fieldtype": "Section Break", + "label": "Depreciation Schedule" + }, + { + "fieldname": "depreciation_schedule", + "fieldtype": "Table", + "label": "Depreciation Schedule", + "options": "Depreciation Schedule" + }, + { + "collapsible": 1, + "collapsible_depends_on": "notes", + "fieldname": "details_section", + "fieldtype": "Section Break", + "label": "Details" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "label": "Status", + "options": "Draft\nActive\nCancelled", + "read_only": 1 + }, + { + "depends_on": "frequency_of_depreciation", + "fieldname": "frequency_of_depreciation", + "fieldtype": "Int", + "label": "Frequency of Depreciation (Months)", + "read_only": 1 + }, + { + "fieldname": "expected_value_after_useful_life", + "fieldtype": "Currency", + "label": "Expected Value After Useful Life", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "finance_book_id", + "fieldtype": "Int", + "hidden": 1, + "label": "Finance Book Id", + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "opening_accumulated_depreciation", + "fieldname": "opening_accumulated_depreciation", + "fieldtype": "Currency", + "label": "Opening Accumulated Depreciation", + "options": "Company:company:default_currency", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-01-02 15:38:30.766779", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Depreciation Schedule", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Quality Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py new file mode 100644 index 00000000000..1446a6e7a2d --- /dev/null +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py @@ -0,0 +1,516 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import ( + add_days, + add_months, + cint, + date_diff, + flt, + get_last_day, + is_last_day_of_the_month, +) + +import erpnext + + +class AssetDepreciationSchedule(Document): + def before_save(self): + if not self.finance_book_id: + self.prepare_draft_asset_depr_schedule_data_from_asset_name_and_fb_name( + self.asset, self.finance_book + ) + + def validate(self): + self.validate_another_asset_depr_schedule_does_not_exist() + + def validate_another_asset_depr_schedule_does_not_exist(self): + finance_book_filter = ["finance_book", "is", "not set"] + if self.finance_book: + finance_book_filter = ["finance_book", "=", self.finance_book] + + asset_depr_schedule = frappe.db.exists( + "Asset Depreciation Schedule", + [ + ["asset", "=", self.asset], + finance_book_filter, + ["docstatus", "<", 2], + ], + ) + + if asset_depr_schedule and asset_depr_schedule != self.name: + if self.finance_book: + frappe.throw( + _( + "Asset Depreciation Schedule {0} for Asset {1} and Finance Book {2} already exists." + ).format(asset_depr_schedule, self.asset, self.finance_book) + ) + else: + frappe.throw( + _("Asset Depreciation Schedule {0} for Asset {1} already exists.").format( + asset_depr_schedule, self.asset + ) + ) + + def on_submit(self): + self.db_set("status", "Active") + + def before_cancel(self): + if not self.flags.should_not_cancel_depreciation_entries: + self.cancel_depreciation_entries() + + def cancel_depreciation_entries(self): + for d in self.get("depreciation_schedule"): + if d.journal_entry: + frappe.get_doc("Journal Entry", d.journal_entry).cancel() + + def on_cancel(self): + self.db_set("status", "Cancelled") + + def prepare_draft_asset_depr_schedule_data_from_asset_name_and_fb_name(self, asset_name, fb_name): + asset_doc = frappe.get_doc("Asset", asset_name) + + finance_book_filter = ["finance_book", "is", "not set"] + if fb_name: + finance_book_filter = ["finance_book", "=", fb_name] + + asset_finance_book_name = frappe.db.get_value( + doctype="Asset Finance Book", + filters=[["parent", "=", asset_name], finance_book_filter], + ) + asset_finance_book_doc = frappe.get_doc("Asset Finance Book", asset_finance_book_name) + + prepare_draft_asset_depr_schedule_data(self, asset_doc, asset_finance_book_doc) + + +def make_draft_asset_depr_schedules_if_not_present(asset_doc): + for row in asset_doc.get("finance_books"): + draft_asset_depr_schedule_name = get_asset_depr_schedule_name( + asset_doc.name, "Draft", row.finance_book + ) + + active_asset_depr_schedule_name = get_asset_depr_schedule_name( + asset_doc.name, "Active", row.finance_book + ) + + if not draft_asset_depr_schedule_name and not active_asset_depr_schedule_name: + make_draft_asset_depr_schedule(asset_doc, row) + + +def make_draft_asset_depr_schedules(asset_doc): + for row in asset_doc.get("finance_books"): + make_draft_asset_depr_schedule(asset_doc, row) + + +def make_draft_asset_depr_schedule(asset_doc, row): + asset_depr_schedule_doc = frappe.new_doc("Asset Depreciation Schedule") + + prepare_draft_asset_depr_schedule_data(asset_depr_schedule_doc, asset_doc, row) + + asset_depr_schedule_doc.insert() + + +def update_draft_asset_depr_schedules(asset_doc): + for row in asset_doc.get("finance_books"): + asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset_doc.name, "Draft", row.finance_book) + + if not asset_depr_schedule_doc: + continue + + prepare_draft_asset_depr_schedule_data(asset_depr_schedule_doc, asset_doc, row) + + asset_depr_schedule_doc.save() + + +def prepare_draft_asset_depr_schedule_data( + asset_depr_schedule_doc, + asset_doc, + row, + date_of_disposal=None, + date_of_return=None, + update_asset_finance_book_row=True, +): + set_draft_asset_depr_schedule_details(asset_depr_schedule_doc, asset_doc, row) + make_depr_schedule( + asset_depr_schedule_doc, asset_doc, row, date_of_disposal, update_asset_finance_book_row + ) + set_accumulated_depreciation(asset_depr_schedule_doc, row, date_of_disposal, date_of_return) + + +def set_draft_asset_depr_schedule_details(asset_depr_schedule_doc, asset_doc, row): + asset_depr_schedule_doc.asset = asset_doc.name + asset_depr_schedule_doc.finance_book = row.finance_book + asset_depr_schedule_doc.finance_book_id = row.idx + asset_depr_schedule_doc.opening_accumulated_depreciation = ( + asset_doc.opening_accumulated_depreciation + ) + asset_depr_schedule_doc.depreciation_method = row.depreciation_method + asset_depr_schedule_doc.total_number_of_depreciations = row.total_number_of_depreciations + asset_depr_schedule_doc.frequency_of_depreciation = row.frequency_of_depreciation + asset_depr_schedule_doc.rate_of_depreciation = row.rate_of_depreciation + asset_depr_schedule_doc.expected_value_after_useful_life = row.expected_value_after_useful_life + asset_depr_schedule_doc.status = "Draft" + + +def convert_draft_asset_depr_schedules_into_active(asset_doc): + for row in asset_doc.get("finance_books"): + asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset_doc.name, "Draft", row.finance_book) + + if not asset_depr_schedule_doc: + continue + + asset_depr_schedule_doc.submit() + + +def cancel_asset_depr_schedules(asset_doc): + for row in asset_doc.get("finance_books"): + asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset_doc.name, "Active", row.finance_book) + + if not asset_depr_schedule_doc: + continue + + asset_depr_schedule_doc.cancel() + + +def make_new_active_asset_depr_schedules_and_cancel_current_ones( + asset_doc, notes, date_of_disposal=None, date_of_return=None +): + for row in asset_doc.get("finance_books"): + current_asset_depr_schedule_doc = get_asset_depr_schedule_doc( + asset_doc.name, "Active", row.finance_book + ) + + if not current_asset_depr_schedule_doc: + frappe.throw( + _("Asset Depreciation Schedule not found for Asset {0} and Finance Book {1}").format( + asset_doc.name, row.finance_book + ) + ) + + new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc) + + make_depr_schedule(new_asset_depr_schedule_doc, asset_doc, row, date_of_disposal) + set_accumulated_depreciation(new_asset_depr_schedule_doc, row, date_of_disposal, date_of_return) + + new_asset_depr_schedule_doc.notes = notes + + current_asset_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True + current_asset_depr_schedule_doc.cancel() + + new_asset_depr_schedule_doc.submit() + + +def get_temp_asset_depr_schedule_doc( + asset_doc, row, date_of_disposal=None, date_of_return=None, update_asset_finance_book_row=False +): + asset_depr_schedule_doc = frappe.new_doc("Asset Depreciation Schedule") + + prepare_draft_asset_depr_schedule_data( + asset_depr_schedule_doc, + asset_doc, + row, + date_of_disposal, + date_of_return, + update_asset_finance_book_row, + ) + + return asset_depr_schedule_doc + + +def get_asset_depr_schedule_name(asset_name, status, finance_book=None): + finance_book_filter = ["finance_book", "is", "not set"] + if finance_book: + finance_book_filter = ["finance_book", "=", finance_book] + + return frappe.db.get_value( + doctype="Asset Depreciation Schedule", + filters=[ + ["asset", "=", asset_name], + finance_book_filter, + ["status", "=", status], + ], + ) + + +@frappe.whitelist() +def get_depr_schedule(asset_name, status, finance_book=None): + asset_depr_schedule_doc = get_asset_depr_schedule_doc(asset_name, status, finance_book) + + if not asset_depr_schedule_doc: + return + + return asset_depr_schedule_doc.get("depreciation_schedule") + + +def get_asset_depr_schedule_doc(asset_name, status, finance_book=None): + asset_depr_schedule_name = get_asset_depr_schedule_name(asset_name, status, finance_book) + + if not asset_depr_schedule_name: + return + + asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule_name) + + return asset_depr_schedule_doc + + +def make_depr_schedule( + asset_depr_schedule_doc, asset_doc, row, date_of_disposal, update_asset_finance_book_row=True +): + if row.depreciation_method != "Manual" and not asset_depr_schedule_doc.get( + "depreciation_schedule" + ): + asset_depr_schedule_doc.depreciation_schedule = [] + + if not asset_doc.available_for_use_date: + return + + start = clear_depr_schedule(asset_depr_schedule_doc) + + _make_depr_schedule( + asset_depr_schedule_doc, asset_doc, row, start, date_of_disposal, update_asset_finance_book_row + ) + + +def clear_depr_schedule(asset_depr_schedule_doc): + start = 0 + num_of_depreciations_completed = 0 + depr_schedule = [] + + for schedule in asset_depr_schedule_doc.get("depreciation_schedule"): + if schedule.journal_entry: + num_of_depreciations_completed += 1 + depr_schedule.append(schedule) + else: + start = num_of_depreciations_completed + break + + asset_depr_schedule_doc.depreciation_schedule = depr_schedule + + return start + + +def _make_depr_schedule( + asset_depr_schedule_doc, asset_doc, row, start, date_of_disposal, update_asset_finance_book_row +): + asset_doc.validate_asset_finance_books(row) + + value_after_depreciation = asset_doc._get_value_after_depreciation(row) + row.value_after_depreciation = value_after_depreciation + + if update_asset_finance_book_row: + row.db_update() + + number_of_pending_depreciations = cint(row.total_number_of_depreciations) - cint( + asset_doc.number_of_depreciations_booked + ) + + has_pro_rata = asset_doc.check_is_pro_rata(row) + if has_pro_rata: + number_of_pending_depreciations += 1 + + skip_row = False + should_get_last_day = is_last_day_of_the_month(row.depreciation_start_date) + + for n in range(start, number_of_pending_depreciations): + # If depreciation is already completed (for double declining balance) + if skip_row: + continue + + depreciation_amount = get_depreciation_amount(asset_doc, value_after_depreciation, row) + + if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: + schedule_date = add_months(row.depreciation_start_date, n * cint(row.frequency_of_depreciation)) + + if should_get_last_day: + schedule_date = get_last_day(schedule_date) + + # 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, -row.frequency_of_depreciation + 1) + + # if asset is being sold or scrapped + if date_of_disposal: + from_date = asset_doc.available_for_use_date + if asset_depr_schedule_doc.depreciation_schedule: + from_date = asset_depr_schedule_doc.depreciation_schedule[-1].schedule_date + + depreciation_amount, days, months = asset_doc.get_pro_rata_amt( + row, depreciation_amount, from_date, date_of_disposal + ) + + if depreciation_amount > 0: + add_depr_schedule_row( + asset_depr_schedule_doc, + date_of_disposal, + depreciation_amount, + row.depreciation_method, + ) + + break + + # For first row + if has_pro_rata and not asset_doc.opening_accumulated_depreciation and n == 0: + from_date = add_days( + asset_doc.available_for_use_date, -1 + ) # needed to calc depr amount for available_for_use_date too + depreciation_amount, days, months = asset_doc.get_pro_rata_amt( + row, depreciation_amount, from_date, row.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(row.depreciation_start_date, -months + 1) + + # For last row + elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: + if not asset_doc.flags.increase_in_asset_life: + # In case of increase_in_asset_life, the asset.to_date is already set on asset_repair submission + asset_doc.to_date = add_months( + asset_doc.available_for_use_date, + (n + asset_doc.number_of_depreciations_booked) * cint(row.frequency_of_depreciation), + ) + + depreciation_amount_without_pro_rata = depreciation_amount + + depreciation_amount, days, months = asset_doc.get_pro_rata_amt( + row, depreciation_amount, schedule_date, asset_doc.to_date + ) + + depreciation_amount = get_adjusted_depreciation_amount( + asset_depr_schedule_doc, depreciation_amount_without_pro_rata, depreciation_amount + ) + + 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, asset_doc.precision("gross_purchase_amount") + ) + + # Adjust depreciation amount in the last period based on the expected value after useful life + if row.expected_value_after_useful_life and ( + ( + n == cint(number_of_pending_depreciations) - 1 + and value_after_depreciation != row.expected_value_after_useful_life + ) + or value_after_depreciation < row.expected_value_after_useful_life + ): + depreciation_amount += value_after_depreciation - row.expected_value_after_useful_life + skip_row = True + + if depreciation_amount > 0: + add_depr_schedule_row( + asset_depr_schedule_doc, + schedule_date, + depreciation_amount, + row.depreciation_method, + ) + + +# to ensure that final accumulated depreciation amount is accurate +def get_adjusted_depreciation_amount( + asset_depr_schedule_doc, depreciation_amount_without_pro_rata, depreciation_amount_for_last_row +): + if not asset_depr_schedule_doc.opening_accumulated_depreciation: + depreciation_amount_for_first_row = get_depreciation_amount_for_first_row( + asset_depr_schedule_doc + ) + + if ( + depreciation_amount_for_first_row + depreciation_amount_for_last_row + != depreciation_amount_without_pro_rata + ): + depreciation_amount_for_last_row = ( + depreciation_amount_without_pro_rata - depreciation_amount_for_first_row + ) + + return depreciation_amount_for_last_row + + +def get_depreciation_amount_for_first_row(asset_depr_schedule_doc): + return asset_depr_schedule_doc.get("depreciation_schedule")[0].depreciation_amount + + +@erpnext.allow_regional +def get_depreciation_amount(asset_doc, depreciable_value, row): + if row.depreciation_method in ("Straight Line", "Manual"): + # if the Depreciation Schedule is being prepared for the first time + if not asset_doc.flags.increase_in_asset_life: + depreciation_amount = ( + flt(asset_doc.gross_purchase_amount) - flt(row.expected_value_after_useful_life) + ) / flt(row.total_number_of_depreciations) + + # if the Depreciation Schedule is being modified after Asset Repair + else: + depreciation_amount = ( + flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life) + ) / (date_diff(asset_doc.to_date, asset_doc.available_for_use_date) / 365) + else: + depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100)) + + return depreciation_amount + + +def add_depr_schedule_row( + asset_depr_schedule_doc, + schedule_date, + depreciation_amount, + depreciation_method, +): + asset_depr_schedule_doc.append( + "depreciation_schedule", + { + "schedule_date": schedule_date, + "depreciation_amount": depreciation_amount, + "depreciation_method": depreciation_method, + }, + ) + + +def set_accumulated_depreciation( + asset_depr_schedule_doc, + row, + date_of_disposal=None, + date_of_return=None, + ignore_booked_entry=False, +): + straight_line_idx = [ + d.idx + for d in asset_depr_schedule_doc.get("depreciation_schedule") + if d.depreciation_method == "Straight Line" + ] + + accumulated_depreciation = flt(asset_depr_schedule_doc.opening_accumulated_depreciation) + value_after_depreciation = flt(row.value_after_depreciation) + + for i, d in enumerate(asset_depr_schedule_doc.get("depreciation_schedule")): + if ignore_booked_entry and d.journal_entry: + continue + + depreciation_amount = flt(d.depreciation_amount, d.precision("depreciation_amount")) + value_after_depreciation -= flt(depreciation_amount) + + # for the last row, if depreciation method = Straight Line + if ( + straight_line_idx + and i == max(straight_line_idx) - 1 + and not date_of_disposal + and not date_of_return + ): + depreciation_amount += flt( + value_after_depreciation - flt(row.expected_value_after_useful_life), + d.precision("depreciation_amount"), + ) + + d.depreciation_amount = depreciation_amount + accumulated_depreciation += d.depreciation_amount + d.accumulated_depreciation_amount = flt( + accumulated_depreciation, d.precision("accumulated_depreciation_amount") + ) diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py new file mode 100644 index 00000000000..024121d3943 --- /dev/null +++ b/erpnext/assets/doctype/asset_depreciation_schedule/test_asset_depreciation_schedule.py @@ -0,0 +1,27 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + +from erpnext.assets.doctype.asset.test_asset import create_asset, create_asset_data +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_asset_depr_schedule_doc, +) + + +class TestAssetDepreciationSchedule(FrappeTestCase): + def setUp(self): + create_asset_data() + + def test_throw_error_if_another_asset_depr_schedule_exist(self): + asset = create_asset(item_code="Macbook Pro", calculate_depreciation=1, submit=1) + + first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Active") + + second_asset_depr_schedule = frappe.get_doc( + {"doctype": "Asset Depreciation Schedule", "asset": asset.name, "finance_book": None} + ) + + self.assertRaises(frappe.ValidationError, second_asset_depr_schedule.insert) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index d5913c59463..b8cd115872c 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -3,11 +3,15 @@ import frappe from frappe import _ -from frappe.utils import add_months, cint, flt, getdate, time_diff_in_hours +from frappe.utils import add_months, cint, flt, get_link_to_form, getdate, time_diff_in_hours import erpnext from erpnext.accounts.general_ledger import make_gl_entries from erpnext.assets.doctype.asset.asset import get_asset_account +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_depr_schedule, + make_new_active_asset_depr_schedules_and_cancel_current_ones, +) from erpnext.controllers.accounts_controller import AccountsController @@ -52,8 +56,11 @@ class AssetRepair(AccountsController): ): self.modify_depreciation_schedule() + notes = _("This schedule was created when Asset Repair {0} was submitted.").format( + get_link_to_form(self.doctype, self.name) + ) self.asset_doc.flags.ignore_validate_update_after_submit = True - self.asset_doc.prepare_depreciation_data() + make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes) self.asset_doc.save() def before_cancel(self): @@ -73,8 +80,11 @@ class AssetRepair(AccountsController): ): self.revert_depreciation_schedule_on_cancellation() + notes = _("This schedule was created when Asset Repair {0} was cancelled.").format( + get_link_to_form(self.doctype, self.name) + ) self.asset_doc.flags.ignore_validate_update_after_submit = True - self.asset_doc.prepare_depreciation_data() + make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes) self.asset_doc.save() def check_repair_status(self): @@ -279,8 +289,10 @@ class AssetRepair(AccountsController): asset.number_of_depreciations_booked ) + depr_schedule = get_depr_schedule(asset.name, "Active", row.finance_book) + # the Schedule Date in the final row of the old Depreciation Schedule - last_schedule_date = asset.schedules[len(asset.schedules) - 1].schedule_date + last_schedule_date = depr_schedule[len(depr_schedule) - 1].schedule_date # the Schedule Date in the final row of the new Depreciation Schedule asset.to_date = add_months(last_schedule_date, extra_months) @@ -310,8 +322,10 @@ class AssetRepair(AccountsController): asset.number_of_depreciations_booked ) + depr_schedule = get_depr_schedule(asset.name, "Active", row.finance_book) + # the Schedule Date in the final row of the modified Depreciation Schedule - last_schedule_date = asset.schedules[len(asset.schedules) - 1].schedule_date + last_schedule_date = depr_schedule[len(depr_schedule) - 1].schedule_date # the Schedule Date in the final row of the original Depreciation Schedule asset.to_date = add_months(last_schedule_date, -extra_months) diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 6e06f52ac65..ff72aa94b99 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -12,6 +12,9 @@ from erpnext.assets.doctype.asset.test_asset import ( create_asset_data, set_depreciation_settings_in_company, ) +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_asset_depr_schedule_doc, +) from erpnext.stock.doctype.item.test_item import create_item @@ -232,13 +235,23 @@ class TestAssetRepair(unittest.TestCase): def test_increase_in_asset_life(self): asset = create_asset(calculate_depreciation=1, submit=1) + + first_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Active") + initial_num_of_depreciations = num_of_depreciations(asset) create_asset_repair(asset=asset, capitalize_repair_cost=1, submit=1) + asset.reload() + first_asset_depr_schedule.load_from_db() + + second_asset_depr_schedule = get_asset_depr_schedule_doc(asset.name, "Active") + self.assertEquals(second_asset_depr_schedule.status, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Cancelled") self.assertEqual((initial_num_of_depreciations + 1), num_of_depreciations(asset)) self.assertEqual( - asset.schedules[-1].accumulated_depreciation_amount, + second_asset_depr_schedule.get("depreciation_schedule")[-1].accumulated_depreciation_amount, asset.finance_books[0].value_after_depreciation, ) diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py index 84aa8fa0239..262d5529974 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -5,13 +5,17 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, date_diff, flt, formatdate, getdate +from frappe.utils import date_diff, flt, formatdate, get_link_to_form, getdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_checks_for_pl_and_bs_accounts, ) -from erpnext.assets.doctype.asset.asset import get_depreciation_amount from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_asset_depr_schedule_doc, + get_depreciation_amount, + set_accumulated_depreciation, +) class AssetValueAdjustment(Document): @@ -112,21 +116,40 @@ class AssetValueAdjustment(Document): for d in asset.finance_books: d.value_after_depreciation = asset_value + current_asset_depr_schedule_doc = get_asset_depr_schedule_doc( + asset.name, "Active", d.finance_book + ) + + new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc) + new_asset_depr_schedule_doc.status = "Draft" + new_asset_depr_schedule_doc.docstatus = 0 + + current_asset_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True + current_asset_depr_schedule_doc.cancel() + + notes = _( + "This schedule was created when Asset {0} was adjusted through Asset Value Adjustment {1}." + ).format( + get_link_to_form(asset.doctype, asset.name), + get_link_to_form(self.get("doctype"), self.get("name")), + ) + new_asset_depr_schedule_doc.notes = notes + + new_asset_depr_schedule_doc.insert() + + depr_schedule = new_asset_depr_schedule_doc.get("depreciation_schedule") + if d.depreciation_method in ("Straight Line", "Manual"): - end_date = max(s.schedule_date for s in asset.schedules if cint(s.finance_book_id) == d.idx) + end_date = max(s.schedule_date for s in depr_schedule) total_days = date_diff(end_date, self.date) rate_per_day = flt(d.value_after_depreciation) / flt(total_days) from_date = self.date else: - no_of_depreciations = len( - [ - s.name for s in asset.schedules if (cint(s.finance_book_id) == d.idx and not s.journal_entry) - ] - ) + no_of_depreciations = len([s.name for s in depr_schedule if not s.journal_entry]) value_after_depreciation = d.value_after_depreciation - for data in asset.schedules: - if cint(data.finance_book_id) == d.idx and not data.journal_entry: + for data in depr_schedule: + if not data.journal_entry: if d.depreciation_method in ("Straight Line", "Manual"): days = date_diff(data.schedule_date, from_date) depreciation_amount = days * rate_per_day @@ -140,10 +163,12 @@ class AssetValueAdjustment(Document): d.db_update() - asset.set_accumulated_depreciation(ignore_booked_entry=True) - for asset_data in asset.schedules: - if not asset_data.journal_entry: - asset_data.db_update() + set_accumulated_depreciation(new_asset_depr_schedule_doc, d, ignore_booked_entry=True) + for asset_data in depr_schedule: + if not asset_data.journal_entry: + asset_data.db_update() + + new_asset_depr_schedule_doc.submit() @frappe.whitelist() diff --git a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py index 62c636624ce..03dcea96c53 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py @@ -7,6 +7,9 @@ import frappe from frappe.utils import add_days, get_last_day, nowdate from erpnext.assets.doctype.asset.test_asset import create_asset_data +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_asset_depr_schedule_doc, +) from erpnext.assets.doctype.asset_value_adjustment.asset_value_adjustment import ( get_current_asset_value, ) @@ -73,12 +76,21 @@ class TestAssetValueAdjustment(unittest.TestCase): ) asset_doc.submit() + first_asset_depr_schedule = get_asset_depr_schedule_doc(asset_doc.name, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Active") + current_value = get_current_asset_value(asset_doc.name) adj_doc = make_asset_value_adjustment( asset=asset_doc.name, current_asset_value=current_value, new_asset_value=50000.0 ) adj_doc.submit() + first_asset_depr_schedule.load_from_db() + + second_asset_depr_schedule = get_asset_depr_schedule_doc(asset_doc.name, "Active") + self.assertEquals(second_asset_depr_schedule.status, "Active") + self.assertEquals(first_asset_depr_schedule.status, "Cancelled") + expected_gle = ( ("_Test Accumulated Depreciations - _TC", 0.0, 50000.0), ("_Test Depreciations - _TC", 50000.0, 0.0), diff --git a/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json b/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json index 35a2c9dd7f3..882c4bf00b1 100644 --- a/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json +++ b/erpnext/assets/doctype/depreciation_schedule/depreciation_schedule.json @@ -1,318 +1,84 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 1, - "autoname": "", - "beta": 0, - "creation": "2016-03-02 15:11:01.278862", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "allow_rename": 1, + "creation": "2016-03-02 15:11:01.278862", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "schedule_date", + "depreciation_amount", + "column_break_3", + "accumulated_depreciation_amount", + "journal_entry", + "make_depreciation_entry", + "depreciation_method" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "finance_book", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Finance Book", - "length": 0, - "no_copy": 0, - "options": "Finance Book", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "schedule_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Schedule Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "schedule_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Schedule Date", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "depreciation_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Depreciation Amount", + "options": "Company:company:default_currency", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "depreciation_amount", - "fieldtype": "Currency", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Depreciation Amount", - "length": 0, - "no_copy": 1, - "options": "Company:company:default_currency", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_3", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "accumulated_depreciation_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Accumulated Depreciation Amount", + "options": "Company:company:default_currency", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "accumulated_depreciation_amount", - "fieldtype": "Currency", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Accumulated Depreciation Amount", - "length": 0, - "no_copy": 1, - "options": "Company:company:default_currency", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "depends_on": "eval:doc.docstatus==1", + "fieldname": "journal_entry", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Journal Entry", + "options": "Journal Entry", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.docstatus==1", - "fieldname": "journal_entry", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Journal Entry", - "length": 0, - "no_copy": 1, - "options": "Journal Entry", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "depends_on": "eval:(doc.docstatus==1 && !doc.journal_entry && doc.schedule_date <= get_today())", + "fieldname": "make_depreciation_entry", + "fieldtype": "Button", + "label": "Make Depreciation Entry" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:(doc.docstatus==1 && !doc.journal_entry && doc.schedule_date <= get_today())", - "fieldname": "make_depreciation_entry", - "fieldtype": "Button", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Make Depreciation Entry", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "finance_book_id", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Finance Book Id", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "depreciation_method", - "fieldtype": "Select", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Depreciation Method", - "length": 0, - "no_copy": 1, - "options": "\nStraight Line\nDouble Declining Balance\nWritten Down Value\nManual", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "depreciation_method", + "fieldtype": "Select", + "hidden": 1, + "label": "Depreciation Method", + "options": "\nStraight Line\nDouble Declining Balance\nWritten Down Value\nManual", + "print_hide": 1, + "read_only": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2018-05-10 15:12:41.679436", - "modified_by": "Administrator", - "module": "Assets", - "name": "Depreciation Schedule", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "istable": 1, + "links": [], + "modified": "2022-12-06 20:35:50.264281", + "modified_by": "Administrator", + "module": "Assets", + "name": "Depreciation Schedule", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py index 6b14dce084e..bb50df0ba28 100644 --- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py +++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py @@ -176,15 +176,17 @@ def get_finance_book_value_map(filters): return frappe._dict( frappe.db.sql( """ Select - parent, SUM(depreciation_amount) - FROM `tabDepreciation Schedule` + ads.asset, SUM(depreciation_amount) + FROM `tabAsset Depreciation Schedule` ads, `tabDepreciation Schedule` ds WHERE - parentfield='schedules' - AND schedule_date<=%s - AND journal_entry IS NOT NULL - AND ifnull(finance_book, '')=%s - GROUP BY parent""", - (date, cstr(filters.finance_book or "")), + ds.parent = ads.name + AND ifnull(ads.finance_book, '')=%s + AND ads.docstatus=1 + AND ds.parentfield='depreciation_schedule' + AND ds.schedule_date<=%s + AND ds.journal_entry IS NOT NULL + GROUP BY ads.asset""", + (cstr(filters.finance_book or ""), date), ) ) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index ce7de874c56..e1dd6797815 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -108,7 +108,7 @@ "contact_display", "contact_mobile", "contact_email", - "company_shipping_address_section", + "shipping_address_section", "shipping_address", "column_break_99", "shipping_address_display", @@ -385,7 +385,7 @@ { "fieldname": "shipping_address", "fieldtype": "Link", - "label": "Company Shipping Address", + "label": "Shipping Address", "options": "Address", "print_hide": 1 }, @@ -1207,11 +1207,6 @@ "fieldtype": "Tab Break", "label": "Address & Contact" }, - { - "fieldname": "company_shipping_address_section", - "fieldtype": "Section Break", - "label": "Company Shipping Address" - }, { "fieldname": "company_billing_address_section", "fieldtype": "Section Break", @@ -1263,13 +1258,18 @@ "fieldname": "named_place", "fieldtype": "Data", "label": "Named Place" + }, + { + "fieldname": "shipping_address_section", + "fieldtype": "Section Break", + "label": "Shipping Address" } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2022-12-12 18:36:37.455134", + "modified": "2022-12-25 18:08:59.074182", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 4c10b4812e7..5a4168a573e 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -207,31 +207,36 @@ class PurchaseOrder(BuyingController): ) def validate_fg_item_for_subcontracting(self): - if self.is_subcontracted and not self.is_old_subcontracting_flow: + if self.is_subcontracted: + if not self.is_old_subcontracting_flow: + for item in self.items: + if not item.fg_item: + frappe.throw( + _("Row #{0}: Finished Good Item is not specified for service item {1}").format( + item.idx, item.item_code + ) + ) + else: + if not frappe.get_value("Item", item.fg_item, "is_sub_contracted_item"): + frappe.throw( + _( + "Row #{0}: Finished Good Item {1} must be a sub-contracted item for service item {2}" + ).format(item.idx, item.fg_item, item.item_code) + ) + elif not frappe.get_value("Item", item.fg_item, "default_bom"): + frappe.throw( + _("Row #{0}: Default BOM not found for FG Item {1}").format(item.idx, item.fg_item) + ) + if not item.fg_item_qty: + frappe.throw( + _("Row #{0}: Finished Good Item Qty is not specified for service item {0}").format( + item.idx, item.item_code + ) + ) + else: for item in self.items: - if not item.fg_item: - frappe.throw( - _("Row #{0}: Finished Good Item is not specified for service item {1}").format( - item.idx, item.item_code - ) - ) - else: - if not frappe.get_value("Item", item.fg_item, "is_sub_contracted_item"): - frappe.throw( - _( - "Row #{0}: Finished Good Item {1} must be a sub-contracted item for service item {2}" - ).format(item.idx, item.fg_item, item.item_code) - ) - elif not frappe.get_value("Item", item.fg_item, "default_bom"): - frappe.throw( - _("Row #{0}: Default BOM not found for FG Item {1}").format(item.idx, item.fg_item) - ) - if not item.fg_item_qty: - frappe.throw( - _("Row #{0}: Finished Good Item Qty is not specified for service item {0}").format( - item.idx, item.item_code - ) - ) + item.set("fg_item", None) + item.set("fg_item_qty", 0) def get_schedule_dates(self): for d in self.get("items"): diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 334a2d806d6..788dc4982e5 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -584,7 +584,12 @@ class AccountsController(TransactionBase): if bool(uom) != bool(stock_uom): # xor item.stock_uom = item.uom = uom or stock_uom - item.conversion_factor = get_uom_conv_factor(item.get("uom"), item.get("stock_uom")) + # UOM cannot be zero so substitute as 1 + item.conversion_factor = ( + get_uom_conv_factor(item.get("uom"), item.get("stock_uom")) + or item.get("conversion_factor") + or 1 + ) if self.doctype == "Purchase Invoice": self.set_expense_account(for_validate) diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 8b073a43202..cd1168d4aca 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -23,7 +23,7 @@ class SellingController(StockController): super(SellingController, self).onload() if self.doctype in ("Sales Order", "Delivery Note", "Sales Invoice"): for item in self.get("items"): - item.update(get_bin_details(item.item_code, item.warehouse)) + item.update(get_bin_details(item.item_code, item.warehouse, include_child_warehouses=True)) def validate(self): super(SellingController, self).validate() diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 8d67e300a30..335d92f43f3 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -829,6 +829,9 @@ def make_rm_stock_entry( order_doctype: { "doctype": "Stock Entry", "field_map": { + "supplier": "supplier", + "supplier_name": "supplier_name", + "supplier_address": "supplier_address", "to_warehouse": "supplier_warehouse", }, "field_no_map": [field_no_map], diff --git a/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json b/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json index 72f47b53ec2..0d42ca8c85d 100644 --- a/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json +++ b/erpnext/erpnext_integrations/doctype/exotel_settings/exotel_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2019-05-21 07:41:53.536536", "doctype": "DocType", "engine": "InnoDB", @@ -7,10 +8,14 @@ "section_break_2", "account_sid", "api_key", - "api_token" + "api_token", + "section_break_6", + "map_custom_field_to_doctype", + "target_doctype" ], "fields": [ { + "default": "0", "fieldname": "enabled", "fieldtype": "Check", "label": "Enabled" @@ -18,7 +23,8 @@ { "depends_on": "enabled", "fieldname": "section_break_2", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Credentials" }, { "fieldname": "account_sid", @@ -34,10 +40,31 @@ "fieldname": "api_key", "fieldtype": "Data", "label": "API Key" + }, + { + "depends_on": "enabled", + "fieldname": "section_break_6", + "fieldtype": "Section Break", + "label": "Custom Field" + }, + { + "default": "0", + "fieldname": "map_custom_field_to_doctype", + "fieldtype": "Check", + "label": "Map Custom Field to DocType" + }, + { + "depends_on": "map_custom_field_to_doctype", + "fieldname": "target_doctype", + "fieldtype": "Link", + "label": "Target DocType", + "mandatory_depends_on": "map_custom_field_to_doctype", + "options": "DocType" } ], "issingle": 1, - "modified": "2019-05-22 06:25:18.026997", + "links": [], + "modified": "2022-12-14 17:24:50.176107", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "Exotel Settings", @@ -57,5 +84,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/erpnext_integrations/exotel_integration.py b/erpnext/erpnext_integrations/exotel_integration.py index fd0f7835759..0d40667e32a 100644 --- a/erpnext/erpnext_integrations/exotel_integration.py +++ b/erpnext/erpnext_integrations/exotel_integration.py @@ -72,6 +72,24 @@ def get_call_log(call_payload): return frappe.get_doc("Call Log", call_log_id) +def map_custom_field(call_payload, call_log): + field_value = call_payload.get("CustomField") + + if not field_value: + return call_log + + settings = get_exotel_settings() + target_doctype = settings.target_doctype + mapping_enabled = settings.map_custom_field_to_doctype + + if not mapping_enabled or not target_doctype: + return call_log + + call_log.append("links", {"link_doctype": target_doctype, "link_name": field_value}) + + return call_log + + def create_call_log(call_payload): call_log = frappe.new_doc("Call Log") call_log.id = call_payload.get("CallSid") @@ -79,6 +97,7 @@ def create_call_log(call_payload): call_log.medium = call_payload.get("To") call_log.status = "Ringing" setattr(call_log, "from", call_payload.get("CallFrom")) + map_custom_field(call_payload, call_log) call_log.save(ignore_permissions=True) frappe.db.commit() return call_log @@ -93,10 +112,10 @@ def get_call_status(call_id): @frappe.whitelist() -def make_a_call(from_number, to_number, caller_id): +def make_a_call(from_number, to_number, caller_id, **kwargs): endpoint = get_exotel_endpoint("Calls/connect.json?details=true") response = requests.post( - endpoint, data={"From": from_number, "To": to_number, "CallerId": caller_id} + endpoint, data={"From": from_number, "To": to_number, "CallerId": caller_id, **kwargs} ) return response.json() diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 7d72c76b819..fd19d2585cc 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -420,7 +420,6 @@ scheduler_events = { "erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall.create_process_loan_security_shortfall", "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_term_loans", "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event", - "erpnext.stock.doctype.stock_entry.stock_entry.audit_incorrect_valuation_entries", ], "monthly_long": [ "erpnext.accounts.deferred_revenue.process_deferred_accounting", diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index ecad41fe7b8..4dd8205a70c 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -4,7 +4,7 @@ frappe.provide("erpnext.bom"); frappe.ui.form.on("BOM", { - setup: function(frm) { + setup(frm) { frm.custom_make_buttons = { 'Work Order': 'Work Order', 'Quality Inspection': 'Quality Inspection' @@ -65,11 +65,11 @@ frappe.ui.form.on("BOM", { }); }, - onload_post_render: function(frm) { + onload_post_render(frm) { frm.get_field("items").grid.set_multiple_add("item_code", "qty"); }, - refresh: function(frm) { + refresh(frm) { frm.toggle_enable("item", frm.doc.__islocal); frm.set_indicator_formatter('item_code', @@ -152,7 +152,7 @@ frappe.ui.form.on("BOM", { } }, - make_work_order: function(frm) { + make_work_order(frm) { frm.events.setup_variant_prompt(frm, "Work Order", (frm, item, data, variant_items) => { frappe.call({ method: "erpnext.manufacturing.doctype.work_order.work_order.make_work_order", @@ -164,7 +164,7 @@ frappe.ui.form.on("BOM", { variant_items: variant_items }, freeze: true, - callback: function(r) { + callback(r) { if(r.message) { let doc = frappe.model.sync(r.message)[0]; frappe.set_route("Form", doc.doctype, doc.name); @@ -174,7 +174,7 @@ frappe.ui.form.on("BOM", { }); }, - make_variant_bom: function(frm) { + make_variant_bom(frm) { frm.events.setup_variant_prompt(frm, "Variant BOM", (frm, item, data, variant_items) => { frappe.call({ method: "erpnext.manufacturing.doctype.bom.bom.make_variant_bom", @@ -185,7 +185,7 @@ frappe.ui.form.on("BOM", { variant_items: variant_items }, freeze: true, - callback: function(r) { + callback(r) { if(r.message) { let doc = frappe.model.sync(r.message)[0]; frappe.set_route("Form", doc.doctype, doc.name); @@ -195,7 +195,7 @@ frappe.ui.form.on("BOM", { }, true); }, - setup_variant_prompt: function(frm, title, callback, skip_qty_field) { + setup_variant_prompt(frm, title, callback, skip_qty_field) { const fields = []; if (frm.doc.has_variants) { @@ -205,7 +205,7 @@ frappe.ui.form.on("BOM", { fieldname: 'item', options: "Item", reqd: 1, - get_query: function() { + get_query() { return { query: "erpnext.controllers.queries.item_query", filters: { @@ -273,7 +273,7 @@ frappe.ui.form.on("BOM", { fieldtype: "Link", in_list_view: 1, reqd: 1, - get_query: function(data) { + get_query(data) { if (!data.item_code) { frappe.throw(__("Select template item")); } @@ -308,7 +308,7 @@ frappe.ui.form.on("BOM", { ], in_place_edit: true, data: [], - get_data: function () { + get_data () { return []; }, }); @@ -343,14 +343,14 @@ frappe.ui.form.on("BOM", { } }, - make_quality_inspection: function(frm) { + make_quality_inspection(frm) { frappe.model.open_mapped_doc({ method: "erpnext.stock.doctype.quality_inspection.quality_inspection.make_quality_inspection", frm: frm }) }, - update_cost: function(frm, save_doc=false) { + update_cost(frm, save_doc=false) { return frappe.call({ doc: frm.doc, method: "update_cost", @@ -360,26 +360,26 @@ frappe.ui.form.on("BOM", { save: save_doc, from_child_bom: false }, - callback: function(r) { + callback(r) { refresh_field("items"); if(!r.exc) frm.refresh_fields(); } }); }, - rm_cost_as_per: function(frm) { + rm_cost_as_per(frm) { if (in_list(["Valuation Rate", "Last Purchase Rate"], frm.doc.rm_cost_as_per)) { frm.set_value("plc_conversion_rate", 1.0); } }, - routing: function(frm) { + routing(frm) { if (frm.doc.routing) { frappe.call({ doc: frm.doc, method: "get_routing", freeze: true, - callback: function(r) { + callback(r) { if (!r.exc) { frm.refresh_fields(); erpnext.bom.calculate_op_cost(frm.doc); @@ -388,6 +388,16 @@ frappe.ui.form.on("BOM", { } }); } + }, + + process_loss_percentage(frm) { + let qty = 0.0 + if (frm.doc.process_loss_percentage) { + qty = (frm.doc.quantity * frm.doc.process_loss_percentage) / 100; + } + + frm.set_value("process_loss_qty", qty); + frm.set_value("add_process_loss_cost_in_fg", qty ? 1: 0); } }); @@ -479,10 +489,6 @@ var get_bom_material_detail = function(doc, cdt, cdn, scrap_items) { }, callback: function(r) { d = locals[cdt][cdn]; - if (d.is_process_loss) { - r.message.rate = 0; - r.message.base_rate = 0; - } $.extend(d, r.message); refresh_field("items"); @@ -717,10 +723,6 @@ frappe.tour['BOM'] = [ frappe.ui.form.on("BOM Scrap Item", { item_code(frm, cdt, cdn) { const { item_code } = locals[cdt][cdn]; - if (item_code === frm.doc.item) { - locals[cdt][cdn].is_process_loss = 1; - trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code); - } }, }); diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 0b441969400..c31b69f3dc5 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -6,6 +6,7 @@ "document_type": "Setup", "engine": "InnoDB", "field_order": [ + "production_item_tab", "item", "company", "item_name", @@ -19,14 +20,15 @@ "quantity", "image", "currency_detail", - "currency", - "conversion_rate", - "column_break_12", "rm_cost_as_per", "buying_price_list", "price_list_currency", "plc_conversion_rate", + "column_break_ivyw", + "currency", + "conversion_rate", "section_break_21", + "operations_section_section", "with_operations", "column_break_23", "transfer_material_against", @@ -34,13 +36,14 @@ "operations_section", "operations", "materials_section", - "inspection_required", - "quality_inspection_template", - "column_break_31", - "section_break_33", "items", "scrap_section", + "scrap_items_section", "scrap_items", + "process_loss_section", + "process_loss_percentage", + "column_break_ssj2", + "process_loss_qty", "costing", "operating_cost", "raw_material_cost", @@ -52,10 +55,14 @@ "column_break_26", "total_cost", "base_total_cost", - "section_break_25", + "more_info_tab", "description", "column_break_27", "has_variants", + "quality_inspection_section_break", + "inspection_required", + "column_break_dxp7", + "quality_inspection_template", "section_break0", "exploded_items", "website_section", @@ -68,7 +75,8 @@ "show_items", "show_operations", "web_long_description", - "amended_from" + "amended_from", + "connections_tab" ], "fields": [ { @@ -183,7 +191,7 @@ { "fieldname": "currency_detail", "fieldtype": "Section Break", - "label": "Currency and Price List" + "label": "Cost Configuration" }, { "fieldname": "company", @@ -208,10 +216,6 @@ "precision": "9", "reqd": 1 }, - { - "fieldname": "column_break_12", - "fieldtype": "Column Break" - }, { "fieldname": "currency", "fieldtype": "Link", @@ -261,7 +265,7 @@ { "fieldname": "materials_section", "fieldtype": "Section Break", - "label": "Materials", + "label": "Raw Materials", "oldfieldtype": "Section Break" }, { @@ -276,18 +280,18 @@ { "collapsible": 1, "fieldname": "scrap_section", - "fieldtype": "Section Break", - "label": "Scrap" + "fieldtype": "Tab Break", + "label": "Scrap & Process Loss" }, { "fieldname": "scrap_items", "fieldtype": "Table", - "label": "Scrap Items", + "label": "Items", "options": "BOM Scrap Item" }, { "fieldname": "costing", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Costing", "oldfieldtype": "Section Break" }, @@ -379,10 +383,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "section_break_25", - "fieldtype": "Section Break" - }, { "fetch_from": "item.description", "fieldname": "description", @@ -478,8 +478,8 @@ }, { "fieldname": "section_break_21", - "fieldtype": "Section Break", - "label": "Operations" + "fieldtype": "Tab Break", + "label": "Operations & Materials" }, { "fieldname": "column_break_23", @@ -511,6 +511,7 @@ "fetch_from": "item.has_variants", "fieldname": "has_variants", "fieldtype": "Check", + "hidden": 1, "in_list_view": 1, "label": "Has Variants", "no_copy": 1, @@ -518,13 +519,63 @@ "read_only": 1 }, { - "fieldname": "column_break_31", + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 + }, + { + "fieldname": "operations_section_section", + "fieldtype": "Section Break", + "label": "Operations" + }, + { + "fieldname": "process_loss_section", + "fieldtype": "Section Break", + "label": "Process Loss" + }, + { + "fieldname": "process_loss_percentage", + "fieldtype": "Percent", + "label": "% Process Loss" + }, + { + "fieldname": "process_loss_qty", + "fieldtype": "Float", + "label": "Process Loss Qty", + "read_only": 1 + }, + { + "fieldname": "column_break_ssj2", "fieldtype": "Column Break" }, { - "fieldname": "section_break_33", + "fieldname": "more_info_tab", + "fieldtype": "Tab Break", + "label": "More Info" + }, + { + "fieldname": "column_break_dxp7", + "fieldtype": "Column Break" + }, + { + "fieldname": "quality_inspection_section_break", "fieldtype": "Section Break", - "hide_border": 1 + "label": "Quality Inspection" + }, + { + "fieldname": "production_item_tab", + "fieldtype": "Tab Break", + "label": "Production Item" + }, + { + "fieldname": "column_break_ivyw", + "fieldtype": "Column Break" + }, + { + "fieldname": "scrap_items_section", + "fieldtype": "Section Break", + "label": "Scrap Items" } ], "icon": "fa fa-sitemap", @@ -532,7 +583,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2022-01-30 21:27:54.727298", + "modified": "2023-01-03 18:42:27.732107", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index ca4f63df772..53af28df8a5 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -193,6 +193,7 @@ class BOM(WebsiteGenerator): self.update_exploded_items(save=False) self.update_stock_qty() self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False) + self.set_process_loss_qty() self.validate_scrap_items() def get_context(self, context): @@ -233,6 +234,7 @@ class BOM(WebsiteGenerator): "sequence_id", "operation", "workstation", + "workstation_type", "description", "time_in_mins", "batch_size", @@ -876,36 +878,19 @@ class BOM(WebsiteGenerator): """Get a complete tree representation preserving order of child items.""" return BOMTree(self.name) + def set_process_loss_qty(self): + if self.process_loss_percentage: + self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100 + def validate_scrap_items(self): - for item in self.scrap_items: - msg = "" - if item.item_code == self.item and not item.is_process_loss: - msg = _( - "Scrap/Loss Item: {0} should have Is Process Loss checked as it is the same as the item to be manufactured or repacked." - ).format(frappe.bold(item.item_code)) - elif item.item_code != self.item and item.is_process_loss: - msg = _( - "Scrap/Loss Item: {0} should not have Is Process Loss checked as it is different from the item to be manufactured or repacked" - ).format(frappe.bold(item.item_code)) + must_be_whole_number = frappe.get_value("UOM", self.uom, "must_be_whole_number") - must_be_whole_number = frappe.get_value("UOM", item.stock_uom, "must_be_whole_number") - if item.is_process_loss and must_be_whole_number: - msg = _( - "Item: {0} with Stock UOM: {1} cannot be a Scrap/Loss Item as {1} is a whole UOM." - ).format(frappe.bold(item.item_code), frappe.bold(item.stock_uom)) + if self.process_loss_percentage and self.process_loss_percentage > 100: + frappe.throw(_("Process Loss Percentage cannot be greater than 100")) - if item.is_process_loss and (item.stock_qty >= self.quantity): - msg = _("Scrap/Loss Item: {0} should have Qty less than finished goods Quantity.").format( - frappe.bold(item.item_code) - ) - - if item.is_process_loss and (item.rate > 0): - msg = _( - "Scrap/Loss Item: {0} should have Rate set to 0 because Is Process Loss is checked." - ).format(frappe.bold(item.item_code)) - - if msg: - frappe.throw(msg, title=_("Note")) + if self.process_loss_qty and must_be_whole_number and self.process_loss_qty % 1 != 0: + msg = f"Item: {frappe.bold(self.item)} with Stock UOM: {frappe.bold(self.uom)} can't have fractional process loss qty as UOM {frappe.bold(self.uom)} is a whole Number." + frappe.throw(msg, title=_("Invalid Process Loss Configuration")) def get_bom_item_rate(args, bom_doc): @@ -1053,7 +1038,7 @@ def get_bom_items_as_dict( query = query.format( table="BOM Scrap Item", where_conditions="", - select_columns=", item.description, is_process_loss", + select_columns=", item.description", is_stock_item=is_stock_item, qty_field="stock_qty", ) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index e34ac12cd23..16f5c793720 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -384,36 +384,16 @@ class TestBOM(FrappeTestCase): def test_bom_with_process_loss_item(self): fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items() - if not frappe.db.exists("BOM", f"BOM-{fg_item_non_whole.item_code}-001"): - bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=0.25, scrap_rate=0, fg_qty=1 - ) - bom_doc.submit() - bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=2, scrap_rate=0 + fg_item_non_whole, bom_item, scrap_qty=2, scrap_rate=0, process_loss_percentage=110 ) - # PL Item qty can't be >= FG Item qty + # PL can't be > 100 self.assertRaises(frappe.ValidationError, bom_doc.submit) - bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=1, scrap_rate=100 - ) - # PL Item rate has to be 0 - self.assertRaises(frappe.ValidationError, bom_doc.submit) - - bom_doc = create_bom_with_process_loss_item( - fg_item_whole, bom_item, scrap_qty=0.25, scrap_rate=0 - ) + bom_doc = create_bom_with_process_loss_item(fg_item_whole, bom_item, process_loss_percentage=20) # Items with whole UOMs can't be PL Items self.assertRaises(frappe.ValidationError, bom_doc.submit) - bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=0.25, scrap_rate=0, is_process_loss=0 - ) - # FG Items in Scrap/Loss Table should have Is Process Loss set - self.assertRaises(frappe.ValidationError, bom_doc.submit) - def test_bom_item_query(self): query = partial( item_query, @@ -744,7 +724,7 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non def create_bom_with_process_loss_item( - fg_item, bom_item, scrap_qty, scrap_rate, fg_qty=2, is_process_loss=1 + fg_item, bom_item, scrap_qty=0, scrap_rate=0, fg_qty=2, process_loss_percentage=0 ): bom_doc = frappe.new_doc("BOM") bom_doc.item = fg_item.item_code @@ -759,19 +739,22 @@ def create_bom_with_process_loss_item( "rate": 100.0, }, ) - bom_doc.append( - "scrap_items", - { - "item_code": fg_item.item_code, - "qty": scrap_qty, - "stock_qty": scrap_qty, - "uom": fg_item.stock_uom, - "stock_uom": fg_item.stock_uom, - "rate": scrap_rate, - "is_process_loss": is_process_loss, - }, - ) + + if scrap_qty: + bom_doc.append( + "scrap_items", + { + "item_code": fg_item.item_code, + "qty": scrap_qty, + "stock_qty": scrap_qty, + "uom": fg_item.stock_uom, + "stock_uom": fg_item.stock_uom, + "rate": scrap_rate, + }, + ) + bom_doc.currency = "INR" + bom_doc.process_loss_percentage = process_loss_percentage return bom_doc diff --git a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json b/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json index 7018082e402..b2ef19b20f0 100644 --- a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json +++ b/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json @@ -8,7 +8,6 @@ "item_code", "column_break_2", "item_name", - "is_process_loss", "quantity_and_rate", "stock_qty", "rate", @@ -89,17 +88,11 @@ { "fieldname": "column_break_2", "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "is_process_loss", - "fieldtype": "Check", - "label": "Is Process Loss" } ], "istable": 1, "links": [], - "modified": "2021-06-22 16:46:12.153311", + "modified": "2023-01-03 14:19:28.460965", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Scrap Item", @@ -108,5 +101,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index f568264c908..729ed42f51a 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -846,20 +846,20 @@ class TestWorkOrder(FrappeTestCase): create_process_loss_bom_items, ) - qty = 4 + qty = 10 scrap_qty = 0.25 # bom item qty = 1, consider as 25% of FG source_warehouse = "Stores - _TC" wip_warehouse = "_Test Warehouse - _TC" fg_item_non_whole, _, bom_item = create_process_loss_bom_items() test_stock_entry.make_stock_entry( - item_code=bom_item.item_code, target=source_warehouse, qty=4, basic_rate=100 + item_code=bom_item.item_code, target=source_warehouse, qty=qty, basic_rate=100 ) bom_no = f"BOM-{fg_item_non_whole.item_code}-001" if not frappe.db.exists("BOM", bom_no): bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=scrap_qty, scrap_rate=0, fg_qty=1, is_process_loss=1 + fg_item_non_whole, bom_item, fg_qty=1, process_loss_percentage=10 ) bom_doc.submit() @@ -883,19 +883,15 @@ class TestWorkOrder(FrappeTestCase): # Testing stock entry values items = se.get("items") - self.assertEqual(len(items), 3, "There should be 3 items including process loss.") + self.assertEqual(len(items), 2, "There should be 3 items including process loss.") + fg_item = items[1] - source_item, fg_item, pl_item = items + self.assertEqual(fg_item.qty, qty - 1) + self.assertEqual(se.process_loss_percentage, 10) + self.assertEqual(se.process_loss_qty, 1) - total_pl_qty = qty * scrap_qty - actual_fg_qty = qty - total_pl_qty - - self.assertEqual(pl_item.qty, total_pl_qty) - self.assertEqual(fg_item.qty, actual_fg_qty) - - # Testing Work Order values - self.assertEqual(frappe.db.get_value("Work Order", wo.name, "produced_qty"), qty) - self.assertEqual(frappe.db.get_value("Work Order", wo.name, "process_loss_qty"), total_pl_qty) + wo.load_from_db() + self.assertEqual(wo.status, "In Process") @timeout(seconds=60) def test_job_card_scrap_item(self): diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 9452a63d70b..25e16d63376 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -14,13 +14,13 @@ "item_name", "image", "bom_no", + "sales_order", "column_break1", "company", "qty", "material_transferred_for_manufacturing", "produced_qty", "process_loss_qty", - "sales_order", "project", "serial_no_and_batch_for_finished_good_section", "has_serial_no", @@ -28,6 +28,7 @@ "column_break_17", "serial_no", "batch_size", + "work_order_configuration", "settings_section", "allow_alternative_item", "use_multi_level_bom", @@ -42,7 +43,11 @@ "fg_warehouse", "scrap_warehouse", "required_items_section", + "materials_and_operations_tab", "required_items", + "operations_section", + "operations", + "transfer_material_against", "time", "planned_start_date", "planned_end_date", @@ -51,9 +56,6 @@ "actual_start_date", "actual_end_date", "lead_time", - "operations_section", - "transfer_material_against", - "operations", "section_break_22", "planned_operating_cost", "actual_operating_cost", @@ -72,12 +74,14 @@ "production_plan_item", "production_plan_sub_assembly_item", "product_bundle_item", - "amended_from" + "amended_from", + "connections_tab" ], "fields": [ { "fieldname": "item", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", + "label": "Production Item", "options": "fa fa-gift" }, { @@ -236,7 +240,7 @@ { "fieldname": "warehouses", "fieldtype": "Section Break", - "label": "Warehouses", + "label": "Warehouse", "options": "fa fa-building" }, { @@ -390,8 +394,8 @@ { "collapsible": 1, "fieldname": "more_info", - "fieldtype": "Section Break", - "label": "More Information", + "fieldtype": "Tab Break", + "label": "More Info", "options": "fa fa-file-text" }, { @@ -474,8 +478,7 @@ }, { "fieldname": "settings_section", - "fieldtype": "Section Break", - "label": "Settings" + "fieldtype": "Section Break" }, { "fieldname": "column_break_18", @@ -568,6 +571,22 @@ "no_copy": 1, "non_negative": 1, "read_only": 1 + }, + { + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 + }, + { + "fieldname": "work_order_configuration", + "fieldtype": "Tab Break", + "label": "Configuration" + }, + { + "fieldname": "materials_and_operations_tab", + "fieldtype": "Tab Break", + "label": "Materials & Operations" } ], "icon": "fa fa-cogs", @@ -575,7 +594,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2022-01-24 21:18:12.160114", + "modified": "2023-01-03 14:16:35.427731", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 52753a092d4..ae9e9c69628 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -246,21 +246,11 @@ class WorkOrder(Document): status = "Draft" elif self.docstatus == 1: if status != "Stopped": - stock_entries = frappe._dict( - frappe.db.sql( - """select purpose, sum(fg_completed_qty) - from `tabStock Entry` where work_order=%s and docstatus=1 - group by purpose""", - self.name, - ) - ) - status = "Not Started" - if stock_entries: + if flt(self.material_transferred_for_manufacturing) > 0: status = "In Process" - produced_qty = stock_entries.get("Manufacture") - if flt(produced_qty) >= flt(self.qty): - status = "Completed" + if flt(self.produced_qty) >= flt(self.qty): + status = "Completed" else: status = "Cancelled" @@ -285,14 +275,7 @@ class WorkOrder(Document): ): continue - qty = flt( - frappe.db.sql( - """select sum(fg_completed_qty) - from `tabStock Entry` where work_order=%s and docstatus=1 - and purpose=%s""", - (self.name, purpose), - )[0][0] - ) + qty = self.get_transferred_or_manufactured_qty(purpose) completed_qty = self.qty + (allowance_percentage / 100 * self.qty) if qty > completed_qty: @@ -314,26 +297,30 @@ class WorkOrder(Document): if self.production_plan: self.update_production_plan_status() - def set_process_loss_qty(self): - process_loss_qty = flt( - frappe.db.sql( - """ - SELECT sum(qty) FROM `tabStock Entry Detail` - WHERE - is_process_loss=1 - AND parent IN ( - SELECT name FROM `tabStock Entry` - WHERE - work_order=%s - AND purpose='Manufacture' - AND docstatus=1 - ) - """, - (self.name,), - )[0][0] + def get_transferred_or_manufactured_qty(self, purpose): + table = frappe.qb.DocType("Stock Entry") + query = frappe.qb.from_(table).where( + (table.work_order == self.name) & (table.docstatus == 1) & (table.purpose == purpose) ) - if process_loss_qty is not None: - self.db_set("process_loss_qty", process_loss_qty) + + if purpose == "Manufacture": + query = query.select(Sum(table.fg_completed_qty) - Sum(table.process_loss_qty)) + else: + query = query.select(Sum(table.fg_completed_qty)) + + return flt(query.run()[0][0]) + + def set_process_loss_qty(self): + table = frappe.qb.DocType("Stock Entry") + process_loss_qty = ( + frappe.qb.from_(table) + .select(Sum(table.process_loss_qty)) + .where( + (table.work_order == self.name) & (table.purpose == "Manufacture") & (table.docstatus == 1) + ) + ).run()[0][0] + + self.db_set("process_loss_qty", flt(process_loss_qty)) def update_production_plan_status(self): production_plan = frappe.get_doc("Production Plan", self.production_plan) @@ -352,6 +339,7 @@ class WorkOrder(Document): produced_qty = total_qty[0][0] if total_qty else 0 + self.update_status() production_plan.run_method( "update_produced_pending_qty", produced_qty, self.production_plan_item ) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 0aad1d34e5f..6be6e06ca77 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -268,6 +268,7 @@ erpnext.patches.v13_0.show_hr_payroll_deprecation_warning erpnext.patches.v13_0.reset_corrupt_defaults erpnext.patches.v13_0.create_accounting_dimensions_for_asset_repair erpnext.patches.v15_0.delete_taxjar_doctypes +erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets [post_model_sync] execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') @@ -318,4 +319,6 @@ erpnext.patches.v13_0.update_schedule_type_in_loans erpnext.patches.v14_0.create_accounting_dimensions_for_asset_capitalization erpnext.patches.v14_0.update_partial_tds_fields erpnext.patches.v14_0.create_incoterms_and_migrate_shipment -erpnext.patches.v14_0.setup_clear_repost_logs \ No newline at end of file +erpnext.patches.v14_0.setup_clear_repost_logs +erpnext.patches.v14_0.create_accounting_dimensions_for_payment_request +erpnext.patches.v14_0.update_entry_type_for_journal_entry diff --git a/erpnext/patches/v14_0/create_accounting_dimensions_for_payment_request.py b/erpnext/patches/v14_0/create_accounting_dimensions_for_payment_request.py new file mode 100644 index 00000000000..bede419ad29 --- /dev/null +++ b/erpnext/patches/v14_0/create_accounting_dimensions_for_payment_request.py @@ -0,0 +1,31 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_field + + +def execute(): + accounting_dimensions = frappe.db.get_all( + "Accounting Dimension", fields=["fieldname", "label", "document_type", "disabled"] + ) + + if not accounting_dimensions: + return + + doctype = "Payment Request" + + for d in accounting_dimensions: + field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": d.fieldname}) + + if field: + continue + + df = { + "fieldname": d.fieldname, + "label": d.label, + "fieldtype": "Link", + "options": d.document_type, + "insert_after": "accounting_dimensions_section", + } + + create_custom_field(doctype, df, ignore_validate=True) + + frappe.clear_cache(doctype=doctype) diff --git a/erpnext/patches/v14_0/update_entry_type_for_journal_entry.py b/erpnext/patches/v14_0/update_entry_type_for_journal_entry.py new file mode 100644 index 00000000000..bce92555577 --- /dev/null +++ b/erpnext/patches/v14_0/update_entry_type_for_journal_entry.py @@ -0,0 +1,18 @@ +import frappe + + +def execute(): + """ + Update Propery Setters for Journal Entry with new 'Entry Type' + """ + new_voucher_type = "Exchange Gain Or Loss" + prop_setter = frappe.db.get_list( + "Property Setter", + filters={"doc_type": "Journal Entry", "field_name": "voucher_type", "property": "options"}, + ) + if prop_setter: + property_setter_doc = frappe.get_doc("Property Setter", prop_setter[0].get("name")) + + if new_voucher_type not in property_setter_doc.value.split("\n"): + property_setter_doc.value += "\n" + new_voucher_type + property_setter_doc.save() diff --git a/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py b/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py new file mode 100644 index 00000000000..5dc3cdde6f8 --- /dev/null +++ b/erpnext/patches/v15_0/create_asset_depreciation_schedules_from_assets.py @@ -0,0 +1,80 @@ +import frappe + +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + set_draft_asset_depr_schedule_details, +) + + +def execute(): + frappe.reload_doc("assets", "doctype", "Asset Depreciation Schedule") + + assets = get_details_of_draft_or_submitted_depreciable_assets() + + for asset in assets: + finance_book_rows = get_details_of_asset_finance_books_rows(asset.name) + + for fb_row in finance_book_rows: + asset_depr_schedule_doc = frappe.new_doc("Asset Depreciation Schedule") + + set_draft_asset_depr_schedule_details(asset_depr_schedule_doc, asset, fb_row) + + asset_depr_schedule_doc.insert() + + if asset.docstatus == 1: + asset_depr_schedule_doc.submit() + + update_depreciation_schedules(asset.name, asset_depr_schedule_doc.name, fb_row.idx) + + +def get_details_of_draft_or_submitted_depreciable_assets(): + asset = frappe.qb.DocType("Asset") + + records = ( + frappe.qb.from_(asset) + .select(asset.name, asset.opening_accumulated_depreciation, asset.docstatus) + .where(asset.calculate_depreciation == 1) + .where(asset.docstatus < 2) + ).run(as_dict=True) + + return records + + +def get_details_of_asset_finance_books_rows(asset_name): + afb = frappe.qb.DocType("Asset Finance Book") + + records = ( + frappe.qb.from_(afb) + .select( + afb.finance_book, + afb.idx, + afb.depreciation_method, + afb.total_number_of_depreciations, + afb.frequency_of_depreciation, + afb.rate_of_depreciation, + afb.expected_value_after_useful_life, + ) + .where(afb.parent == asset_name) + ).run(as_dict=True) + + return records + + +def update_depreciation_schedules(asset_name, asset_depr_schedule_name, fb_row_idx): + ds = frappe.qb.DocType("Depreciation Schedule") + + depr_schedules = ( + frappe.qb.from_(ds) + .select(ds.name) + .where((ds.parent == asset_name) & (ds.finance_book_id == str(fb_row_idx))) + .orderby(ds.idx) + ).run(as_dict=True) + + for idx, depr_schedule in enumerate(depr_schedules, start=1): + ( + frappe.qb.update(ds) + .set(ds.idx, idx) + .set(ds.parent, asset_depr_schedule_name) + .set(ds.parentfield, "depreciation_schedule") + .set(ds.parenttype, "Asset Depreciation Schedule") + .where(ds.name == depr_schedule.name) + ).run() diff --git a/erpnext/projects/doctype/project/project.js b/erpnext/projects/doctype/project/project.js index c48ed918024..f366f775560 100644 --- a/erpnext/projects/doctype/project/project.js +++ b/erpnext/projects/doctype/project/project.js @@ -20,7 +20,7 @@ frappe.ui.form.on("Project", { onload: function (frm) { const so = frm.get_docfield("sales_order"); so.get_route_options_for_new_doc = () => { - if (frm.is_new()) return; + if (frm.is_new()) return {}; return { "customer": frm.doc.customer, "project_name": frm.doc.name diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 4735f24e571..7d80ac1cb7d 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -7,6 +7,8 @@ from email_reply_parser import EmailReplyParser from frappe import _ from frappe.desk.reportview import get_match_cond from frappe.model.document import Document +from frappe.query_builder import Interval +from frappe.query_builder.functions import Count, CurDate, Date, UnixTimestamp from frappe.utils import add_days, flt, get_datetime, get_time, get_url, nowtime, today from erpnext import get_default_company @@ -297,17 +299,19 @@ class Project(Document): user.welcome_email_sent = 1 -def get_timeline_data(doctype, name): +def get_timeline_data(doctype: str, name: str) -> dict[int, int]: """Return timeline for attendance""" + + timesheet_detail = frappe.qb.DocType("Timesheet Detail") + return dict( - frappe.db.sql( - """select unix_timestamp(from_time), count(*) - from `tabTimesheet Detail` where project=%s - and from_time > date_sub(curdate(), interval 1 year) - and docstatus < 2 - group by date(from_time)""", - name, - ) + frappe.qb.from_(timesheet_detail) + .select(UnixTimestamp(timesheet_detail.from_time), Count("*")) + .where(timesheet_detail.project == name) + .where(timesheet_detail.from_time > CurDate() - Interval(years=1)) + .where(timesheet_detail.docstatus < 2) + .groupby(Date(timesheet_detail.from_time)) + .run() ) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index b9bb37a05cf..1179364b834 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -25,12 +25,18 @@ class Timesheet(Document): def validate(self): self.set_status() self.validate_dates() + self.calculate_hours() self.validate_time_logs() self.update_cost() self.calculate_total_amounts() self.calculate_percentage_billed() self.set_dates() + def calculate_hours(self): + for row in self.time_logs: + if row.to_time and row.from_time: + row.hours = time_diff_in_hours(row.to_time, row.from_time) + def calculate_total_amounts(self): self.total_hours = 0.0 self.total_billable_hours = 0.0 diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index ca01f68140c..b5e6ab871d1 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -355,12 +355,14 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "deposit", fieldtype: "Currency", label: "Deposit", + options: "currency", read_only: 1, }, { fieldname: "withdrawal", fieldtype: "Currency", label: "Withdrawal", + options: "currency", read_only: 1, }, { @@ -378,6 +380,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "allocated_amount", fieldtype: "Currency", label: "Allocated Amount", + options: "Currency", read_only: 1, }, @@ -385,8 +388,17 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "unallocated_amount", fieldtype: "Currency", label: "Unallocated Amount", + options: "Currency", read_only: 1, }, + { + fieldname: "currency", + fieldtype: "Link", + label: "Currency", + options: "Currency", + read_only: 1, + hidden: 1, + } ]; } diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 09779d89ec1..b0e08cc6f26 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -225,7 +225,8 @@ erpnext.buying.BuyingController = class BuyingController extends erpnext.Transac args: { item_code: item.item_code, warehouse: item.warehouse, - company: doc.company + company: doc.company, + include_child_warehouses: true } }); } diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index aa57bc2168e..f2f1ce132e9 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -272,7 +272,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe let quality_inspection_field = this.frm.get_docfield("items", "quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function(row) { - if(me.frm.is_new()) return; + if(me.frm.is_new()) return {}; return { "inspection_type": inspection_type, "reference_type": me.frm.doc.doctype, diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 12ecb0112a4..d9dab33501e 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -737,7 +737,7 @@ def get_customer_primary_contact(doctype, txt, searchfield, start, page_len, fil qb.from_(con) .join(dlink) .on(con.name == dlink.parent) - .select(con.name, con.full_name, con.email_id) + .select(con.name, con.email_id) .where((dlink.link_name == customer) & (con.name.like(f"%{txt}%"))) .run() ) diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 31a95896bc1..ca7dfd23378 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -90,7 +90,6 @@ "oldfieldtype": "Link", "options": "Item", "print_width": "150px", - "reqd": 1, "search_index": 1, "width": "150px" }, @@ -649,7 +648,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-07-15 12:40:51.074820", + "modified": "2022-12-25 02:49:53.926625", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 0013c95032f..7c0601e3dd5 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1024,6 +1024,15 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): ] items_to_map = list(set(items_to_map)) + def is_drop_ship_order(target): + drop_ship = True + for item in target.items: + if not item.delivered_by_supplier: + drop_ship = False + break + + return drop_ship + def set_missing_values(source, target): target.supplier = "" target.apply_discount_on = "" @@ -1031,8 +1040,14 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): target.discount_amount = 0.0 target.inter_company_order_reference = "" target.shipping_rule = "" - target.customer = "" - target.customer_name = "" + + if is_drop_ship_order(target): + target.customer = source.customer + target.customer_name = source.customer_name + target.shipping_address = source.shipping_address_name + else: + target.customer = target.customer_name = target.shipping_address = None + target.run_method("set_missing_values") target.run_method("calculate_taxes_and_totals") diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index b801de314cc..d0dabad5c99 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -114,7 +114,6 @@ "oldfieldtype": "Link", "options": "Item", "print_width": "150px", - "reqd": 1, "width": "150px" }, { @@ -865,7 +864,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-11-18 11:39:01.741665", + "modified": "2022-12-25 02:51:10.247569", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/setup/doctype/customer_group/customer_group.json b/erpnext/setup/doctype/customer_group/customer_group.json index 0e2ed9efcf8..d6a431ea616 100644 --- a/erpnext/setup/doctype/customer_group/customer_group.json +++ b/erpnext/setup/doctype/customer_group/customer_group.json @@ -139,10 +139,11 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2021-02-08 17:01:52.162202", + "modified": "2022-12-24 11:15:17.142746", "modified_by": "Administrator", "module": "Setup", "name": "Customer Group", + "naming_rule": "By fieldname", "nsm_parent_field": "parent_customer_group", "owner": "Administrator", "permissions": [ @@ -198,10 +199,19 @@ "role": "Customer", "select": 1, "share": 1 + }, + { + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1 } ], "search_fields": "parent_customer_group", "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/setup/doctype/item_group/item_group.json b/erpnext/setup/doctype/item_group/item_group.json index 50f923d87e0..2986087277c 100644 --- a/erpnext/setup/doctype/item_group/item_group.json +++ b/erpnext/setup/doctype/item_group/item_group.json @@ -123,6 +123,7 @@ "fieldname": "route", "fieldtype": "Data", "label": "Route", + "no_copy": 1, "unique": 1 }, { @@ -232,11 +233,10 @@ "is_tree": 1, "links": [], "max_attachments": 3, - "modified": "2022-03-09 12:27:11.055782", + "modified": "2023-01-05 12:21:30.458628", "modified_by": "Administrator", "module": "Setup", "name": "Item Group", - "name_case": "Title Case", "naming_rule": "By fieldname", "nsm_parent_field": "parent_item_group", "owner": "Administrator", diff --git a/erpnext/setup/doctype/sales_person/sales_person.py b/erpnext/setup/doctype/sales_person/sales_person.py index 0082c700758..beff7f5374c 100644 --- a/erpnext/setup/doctype/sales_person/sales_person.py +++ b/erpnext/setup/doctype/sales_person/sales_person.py @@ -2,8 +2,13 @@ # License: GNU General Public License v3. See license.txt +from collections import defaultdict +from itertools import chain + import frappe from frappe import _ +from frappe.query_builder import Interval +from frappe.query_builder.functions import Count, CurDate, UnixTimestamp from frappe.utils import flt from frappe.utils.nestedset import NestedSet, get_root_of @@ -77,61 +82,31 @@ def on_doctype_update(): frappe.db.add_index("Sales Person", ["lft", "rgt"]) -def get_timeline_data(doctype, name): +def get_timeline_data(doctype: str, name: str) -> dict[int, int]: + def _fetch_activity(doctype: str, date_field: str): + sales_team = frappe.qb.DocType("Sales Team") + transaction = frappe.qb.DocType(doctype) - out = {} - - out.update( - dict( - frappe.db.sql( - """select - unix_timestamp(dt.transaction_date), count(st.parenttype) - from - `tabSales Order` dt, `tabSales Team` st - where - st.sales_person = %s and st.parent = dt.name and dt.transaction_date > date_sub(curdate(), interval 1 year) - group by dt.transaction_date """, - name, - ) + return dict( + frappe.qb.from_(transaction) + .join(sales_team) + .on(transaction.name == sales_team.parent) + .select(UnixTimestamp(transaction[date_field]), Count("*")) + .where(sales_team.sales_person == name) + .where(transaction[date_field] > CurDate() - Interval(years=1)) + .groupby(transaction[date_field]) + .run() ) - ) - sales_invoice = dict( - frappe.db.sql( - """select - unix_timestamp(dt.posting_date), count(st.parenttype) - from - `tabSales Invoice` dt, `tabSales Team` st - where - st.sales_person = %s and st.parent = dt.name and dt.posting_date > date_sub(curdate(), interval 1 year) - group by dt.posting_date """, - name, - ) - ) + sales_order_activity = _fetch_activity("Sales Order", "transaction_date") + sales_invoice_activity = _fetch_activity("Sales Invoice", "posting_date") + delivery_note_activity = _fetch_activity("Delivery Note", "posting_date") - for key in sales_invoice: - if out.get(key): - out[key] += sales_invoice[key] - else: - out[key] = sales_invoice[key] + merged_activities = defaultdict(int) - delivery_note = dict( - frappe.db.sql( - """select - unix_timestamp(dt.posting_date), count(st.parenttype) - from - `tabDelivery Note` dt, `tabSales Team` st - where - st.sales_person = %s and st.parent = dt.name and dt.posting_date > date_sub(curdate(), interval 1 year) - group by dt.posting_date """, - name, - ) - ) + for ts, count in chain( + sales_order_activity.items(), sales_invoice_activity.items(), delivery_note_activity.items() + ): + merged_activities[ts] += count - for key in delivery_note: - if out.get(key): - out[key] += delivery_note[key] - else: - out[key] = delivery_note[key] - - return out + return merged_activities diff --git a/erpnext/setup/doctype/supplier_group/supplier_group.json b/erpnext/setup/doctype/supplier_group/supplier_group.json index 9119bb947cb..b3ed608cd03 100644 --- a/erpnext/setup/doctype/supplier_group/supplier_group.json +++ b/erpnext/setup/doctype/supplier_group/supplier_group.json @@ -6,6 +6,7 @@ "creation": "2013-01-10 16:34:24", "doctype": "DocType", "document_type": "Setup", + "engine": "InnoDB", "field_order": [ "supplier_group_name", "parent_supplier_group", @@ -106,10 +107,11 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-03-18 18:10:49.228407", + "modified": "2022-12-24 11:16:12.486719", "modified_by": "Administrator", "module": "Setup", "name": "Supplier Group", + "naming_rule": "By fieldname", "nsm_parent_field": "parent_supplier_group", "owner": "Administrator", "permissions": [ @@ -156,8 +158,18 @@ "permlevel": 1, "read": 1, "role": "Purchase User" + }, + { + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1 } ], "show_name_in_global_search": 1, - "sort_order": "ASC" + "sort_field": "modified", + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/setup/doctype/territory/territory.json b/erpnext/setup/doctype/territory/territory.json index a25bda054b9..c3a49933746 100644 --- a/erpnext/setup/doctype/territory/territory.json +++ b/erpnext/setup/doctype/territory/territory.json @@ -123,11 +123,12 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2021-02-08 17:10:03.767426", + "modified": "2022-12-24 11:16:39.964956", "modified_by": "Administrator", "module": "Setup", "name": "Territory", "name_case": "Title Case", + "naming_rule": "By fieldname", "nsm_parent_field": "parent_territory", "owner": "Administrator", "permissions": [ @@ -175,10 +176,19 @@ "role": "Customer", "select": 1, "share": 1 + }, + { + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1 } ], "search_fields": "parent_territory,territory_manager", "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/stock/dashboard/item_dashboard.js b/erpnext/stock/dashboard/item_dashboard.js index 6e7622c067f..1be528f1dd1 100644 --- a/erpnext/stock/dashboard/item_dashboard.js +++ b/erpnext/stock/dashboard/item_dashboard.js @@ -102,6 +102,9 @@ erpnext.stock.ItemDashboard = class ItemDashboard { args: args, callback: function (r) { me.render(r.message); + if(me.after_refresh) { + me.after_refresh(); + } } }); } diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index d1d228dfdc6..629e50efeb9 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -22,7 +22,6 @@ "allow_alternative_item", "is_stock_item", "has_variants", - "include_item_in_manufacturing", "opening_stock", "valuation_rate", "standard_rate", @@ -112,6 +111,7 @@ "quality_inspection_template", "inspection_required_before_delivery", "manufacturing", + "include_item_in_manufacturing", "is_sub_contracted_item", "default_bom", "column_break_74", @@ -911,7 +911,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2022-09-13 04:08:17.431731", + "modified": "2023-01-07 22:45:00.341745", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 20bc9d9b2c9..cf12380d8d6 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -8,6 +8,8 @@ from typing import Dict, List, Optional import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder import Interval +from frappe.query_builder.functions import Count, CurDate, UnixTimestamp from frappe.utils import ( cint, cstr, @@ -997,18 +999,19 @@ def make_item_price(item, price_list_name, item_price): ).insert() -def get_timeline_data(doctype, name): +def get_timeline_data(doctype: str, name: str) -> dict[int, int]: """get timeline data based on Stock Ledger Entry. This is displayed as heatmap on the item page.""" - items = frappe.db.sql( - """select unix_timestamp(posting_date), count(*) - from `tabStock Ledger Entry` - where item_code=%s and posting_date > date_sub(curdate(), interval 1 year) - group by posting_date""", - name, - ) + sle = frappe.qb.DocType("Stock Ledger Entry") - return dict(items) + return dict( + frappe.qb.from_(sle) + .select(UnixTimestamp(sle.posting_date), Count("*")) + .where(sle.item_code == name) + .where(sle.posting_date > CurDate() - Interval(years=1)) + .groupby(sle.posting_date) + .run() + ) def validate_end_of_life(item_code, end_of_life=None, disabled=None): diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index e1ee9389de9..7e426ae4af8 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -83,6 +83,7 @@ class TestItem(FrappeTestCase): def test_get_item_details(self): # delete modified item price record and make as per test_records frappe.db.sql("""delete from `tabItem Price`""") + frappe.db.sql("""delete from `tabBin`""") to_check = { "item_code": "_Test Item", @@ -103,9 +104,26 @@ class TestItem(FrappeTestCase): "batch_no": None, "uom": "_Test UOM", "conversion_factor": 1.0, + "reserved_qty": 1, + "actual_qty": 5, + "ordered_qty": 10, + "projected_qty": 14, } make_test_objects("Item Price") + make_test_objects( + "Bin", + [ + { + "item_code": "_Test Item", + "warehouse": "_Test Warehouse - _TC", + "reserved_qty": 1, + "actual_qty": 5, + "ordered_qty": 10, + "projected_qty": 14, + } + ], + ) company = "_Test Company" currency = frappe.get_cached_value("Company", company, "default_currency") @@ -129,7 +147,7 @@ class TestItem(FrappeTestCase): ) for key, value in to_check.items(): - self.assertEqual(value, details.get(key)) + self.assertEqual(value, details.get(key), key) def test_item_tax_template(self): expected_item_tax_template = [ diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 799406cd79e..8213adb89bf 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -51,7 +51,15 @@ frappe.ui.form.on('Pick List', { if (!(frm.doc.locations && frm.doc.locations.length)) { frappe.msgprint(__('Add items in the Item Locations table')); } else { - frm.call('set_item_locations', {save: save}); + frappe.call({ + method: "set_item_locations", + doc: frm.doc, + args: { + "save": save, + }, + freeze: 1, + freeze_message: __("Setting Item Locations..."), + }); } }, get_item_locations: (frm) => { diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index aff5e0539c7..65a792fb46b 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -100,6 +100,7 @@ class PickList(Document): item_table, item.sales_order_item, ["picked_qty", stock_qty_field], + for_update=True, ) if self.docstatus == 1: @@ -118,7 +119,7 @@ class PickList(Document): def update_sales_order_picking_status(sales_orders: Set[str]) -> None: for sales_order in sales_orders: if sales_order: - frappe.get_doc("Sales Order", sales_order).update_picking_status() + frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status() @frappe.whitelist() def set_item_locations(self, save=False): @@ -135,6 +136,7 @@ class PickList(Document): # reset self.delete_key("locations") + updated_locations = frappe._dict() for item_doc in items: item_code = item_doc.item_code @@ -155,7 +157,26 @@ class PickList(Document): for row in locations: location = item_doc.as_dict() location.update(row) - self.append("locations", location) + key = ( + location.item_code, + location.warehouse, + location.uom, + location.batch_no, + location.serial_no, + location.sales_order_item or location.material_request_item, + ) + + if key not in updated_locations: + updated_locations.setdefault(key, location) + else: + updated_locations[key].qty += location.qty + updated_locations[key].stock_qty += location.stock_qty + + for location in updated_locations.values(): + if location.picked_qty > location.stock_qty: + location.picked_qty = location.stock_qty + + self.append("locations", location) # If table is empty on update after submit, set stock_qty, picked_qty to 0 so that indicator is red # and give feedback to the user. This is to avoid empty Pick Lists. @@ -242,7 +263,7 @@ class PickList(Document): for so_row, item_code in product_bundles.items(): picked_qty = self._compute_picked_qty_for_bundle(so_row, product_bundle_qty_map[item_code]) item_table = "Sales Order Item" - already_picked = frappe.db.get_value(item_table, so_row, "picked_qty") + already_picked = frappe.db.get_value(item_table, so_row, "picked_qty", for_update=True) frappe.db.set_value( item_table, so_row, @@ -441,7 +462,7 @@ def get_available_item_locations_for_batched_item( sle.`batch_no`, sle.`item_code` HAVING `qty` > 0 - ORDER BY IFNULL(batch.`expiry_date`, '2200-01-01'), batch.`creation` + ORDER BY IFNULL(batch.`expiry_date`, '2200-01-01'), batch.`creation`, sle.`batch_no`, sle.`warehouse` """.format( warehouse_condition=warehouse_condition ), diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index b9102445e01..897fca3978a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -112,6 +112,10 @@ frappe.ui.form.on('Stock Entry', { } }); attach_bom_items(frm.doc.bom_no); + + if(!check_should_not_attach_bom_items(frm.doc.bom_no)) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + } }, setup_quality_inspection: function(frm) { @@ -129,7 +133,7 @@ frappe.ui.form.on('Stock Entry', { let quality_inspection_field = frm.get_docfield("items", "quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function(row) { - if (frm.is_new()) return; + if (frm.is_new()) return {}; return { "inspection_type": "Incoming", "reference_type": frm.doc.doctype, @@ -326,7 +330,11 @@ frappe.ui.form.on('Stock Entry', { } frm.trigger("setup_quality_inspection"); - attach_bom_items(frm.doc.bom_no) + attach_bom_items(frm.doc.bom_no); + + if(!check_should_not_attach_bom_items(frm.doc.bom_no)) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + } }, before_save: function(frm) { @@ -939,7 +947,10 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle method: "get_items", callback: function(r) { if(!r.exc) refresh_field("items"); - if(me.frm.doc.bom_no) attach_bom_items(me.frm.doc.bom_no) + if(me.frm.doc.bom_no) { + attach_bom_items(me.frm.doc.bom_no); + erpnext.accounts.dimensions.update_dimension(me.frm, me.frm.doctype); + } } }); } diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 7e9420d5035..9c0f1fc03f4 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -7,7 +7,7 @@ "document_type": "Document", "engine": "InnoDB", "field_order": [ - "items_section", + "stock_entry_details_tab", "naming_series", "stock_entry_type", "outgoing_stock_entry", @@ -26,15 +26,20 @@ "posting_time", "set_posting_time", "inspection_required", - "from_bom", "apply_putaway_rule", - "sb1", - "bom_no", - "fg_completed_qty", - "cb1", + "items_tab", + "bom_info_section", + "from_bom", "use_multi_level_bom", + "bom_no", + "cb1", + "fg_completed_qty", "get_items", - "section_break_12", + "section_break_7qsm", + "process_loss_percentage", + "column_break_e92r", + "process_loss_qty", + "section_break_jwgn", "from_warehouse", "source_warehouse_address", "source_address_display", @@ -44,6 +49,7 @@ "target_address_display", "sb0", "scan_barcode", + "items_section", "items", "get_stock_and_rate", "section_break_19", @@ -54,6 +60,7 @@ "additional_costs_section", "additional_costs", "total_additional_costs", + "supplier_info_tab", "contact_section", "supplier", "supplier_name", @@ -61,7 +68,7 @@ "address_display", "accounting_dimensions_section", "project", - "dimension_col_break", + "other_info_tab", "printing_settings", "select_print_heading", "print_settings_col_break", @@ -78,11 +85,6 @@ "is_return" ], "fields": [ - { - "fieldname": "items_section", - "fieldtype": "Section Break", - "oldfieldtype": "Section Break" - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -236,17 +238,12 @@ }, { "default": "0", - "depends_on": "eval:in_list([\"Material Issue\", \"Material Transfer\", \"Manufacture\", \"Repack\", \t\t\t\t\t\"Send to Subcontractor\", \"Material Transfer for Manufacture\", \"Material Consumption for Manufacture\"], doc.purpose)", + "depends_on": "eval:in_list([\"Material Issue\", \"Material Transfer\", \"Manufacture\", \"Repack\", \"Send to Subcontractor\", \"Material Transfer for Manufacture\", \"Material Consumption for Manufacture\"], doc.purpose)", "fieldname": "from_bom", "fieldtype": "Check", "label": "From BOM", "print_hide": 1 }, - { - "depends_on": "eval: doc.from_bom && (doc.purpose!==\"Sales Return\" && doc.purpose!==\"Purchase Return\")", - "fieldname": "sb1", - "fieldtype": "Section Break" - }, { "depends_on": "from_bom", "fieldname": "bom_no", @@ -285,10 +282,6 @@ "oldfieldtype": "Button", "print_hide": 1 }, - { - "fieldname": "section_break_12", - "fieldtype": "Section Break" - }, { "description": "Sets 'Source Warehouse' in each row of the items table.", "fieldname": "from_warehouse", @@ -411,7 +404,7 @@ "collapsible": 1, "collapsible_depends_on": "total_additional_costs", "fieldname": "additional_costs_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Additional Costs" }, { @@ -576,13 +569,9 @@ { "collapsible": 1, "fieldname": "accounting_dimensions_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Accounting Dimensions" }, - { - "fieldname": "dimension_col_break", - "fieldtype": "Column Break" - }, { "fieldname": "pick_list", "fieldtype": "Link", @@ -621,6 +610,66 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "items_tab", + "fieldtype": "Tab Break", + "label": "Items" + }, + { + "fieldname": "bom_info_section", + "fieldtype": "Section Break", + "label": "BOM Info" + }, + { + "collapsible": 1, + "fieldname": "section_break_jwgn", + "fieldtype": "Section Break", + "label": "Default Warehouse" + }, + { + "fieldname": "other_info_tab", + "fieldtype": "Tab Break", + "label": "Other Info" + }, + { + "fieldname": "supplier_info_tab", + "fieldtype": "Tab Break", + "label": "Supplier Info" + }, + { + "fieldname": "stock_entry_details_tab", + "fieldtype": "Tab Break", + "label": "Details", + "oldfieldtype": "Section Break" + }, + { + "fieldname": "section_break_7qsm", + "fieldtype": "Section Break" + }, + { + "depends_on": "process_loss_percentage", + "fieldname": "process_loss_qty", + "fieldtype": "Float", + "label": "Process Loss Qty", + "read_only": 1 + }, + { + "fieldname": "column_break_e92r", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.from_bom && doc.fg_completed_qty", + "fetch_from": "bom_no.process_loss_percentage", + "fetch_if_empty": 1, + "fieldname": "process_loss_percentage", + "fieldtype": "Percent", + "label": "% Process Loss" + }, + { + "fieldname": "items_section", + "fieldtype": "Section Break", + "label": "Items" } ], "icon": "fa fa-file-text", @@ -628,7 +677,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-10-07 14:39:51.943770", + "modified": "2023-01-03 16:02:50.741816", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a047a9b8142..352ef57c628 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -4,24 +4,12 @@ import json from collections import defaultdict -from typing import Dict import frappe from frappe import _ from frappe.model.mapper import get_mapped_doc from frappe.query_builder.functions import Sum -from frappe.utils import ( - add_days, - cint, - comma_or, - cstr, - flt, - format_time, - formatdate, - getdate, - nowdate, - today, -) +from frappe.utils import cint, comma_or, cstr, flt, format_time, formatdate, getdate, nowdate import erpnext from erpnext.accounts.general_ledger import process_gl_map @@ -125,6 +113,7 @@ class StockEntry(StockController): self.validate_warehouse() self.validate_work_order() self.validate_bom() + self.set_process_loss_qty() self.validate_purchase_order() self.validate_subcontracting_order() @@ -135,7 +124,7 @@ class StockEntry(StockController): self.validate_with_material_request() self.validate_batch() self.validate_inspection() - # self.validate_fg_completed_qty() + self.validate_fg_completed_qty() self.validate_difference_account() self.set_job_card_data() self.set_purpose_for_stock_entry() @@ -397,11 +386,20 @@ class StockEntry(StockController): item_wise_qty = {} if self.purpose == "Manufacture" and self.work_order: for d in self.items: - if d.is_finished_item or d.is_process_loss: + if d.is_finished_item: item_wise_qty.setdefault(d.item_code, []).append(d.qty) + precision = frappe.get_precision("Stock Entry Detail", "qty") for item_code, qty_list in item_wise_qty.items(): - total = flt(sum(qty_list), frappe.get_precision("Stock Entry Detail", "qty")) + total = flt(sum(qty_list), precision) + + if (self.fg_completed_qty - total) > 0: + self.process_loss_qty = flt(self.fg_completed_qty - total, precision) + self.process_loss_percentage = flt(self.process_loss_qty * 100 / self.fg_completed_qty) + + if self.process_loss_qty: + total += flt(self.process_loss_qty, precision) + if self.fg_completed_qty != total: frappe.throw( _("The finished product {0} quantity {1} and For Quantity {2} cannot be different").format( @@ -480,7 +478,7 @@ class StockEntry(StockController): if self.purpose == "Manufacture": if validate_for_manufacture: - if d.is_finished_item or d.is_scrap_item or d.is_process_loss: + if d.is_finished_item or d.is_scrap_item: d.s_warehouse = None if not d.t_warehouse: frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx)) @@ -657,9 +655,7 @@ class StockEntry(StockController): outgoing_items_cost = self.set_rate_for_outgoing_items( reset_outgoing_rate, raise_error_if_no_rate ) - finished_item_qty = sum( - d.transfer_qty for d in self.items if d.is_finished_item or d.is_process_loss - ) + finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item) # Set basic rate for incoming items for d in self.get("items"): @@ -698,8 +694,6 @@ class StockEntry(StockController): # do not round off basic rate to avoid precision loss d.basic_rate = flt(d.basic_rate) - if d.is_process_loss: - d.basic_rate = flt(0.0) d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): @@ -1250,7 +1244,6 @@ class StockEntry(StockController): if self.work_order: pro_doc = frappe.get_doc("Work Order", self.work_order) _validate_work_order(pro_doc) - pro_doc.run_method("update_status") if self.fg_completed_qty: pro_doc.run_method("update_work_order_qty") @@ -1258,6 +1251,7 @@ class StockEntry(StockController): pro_doc.run_method("update_planned_qty") pro_doc.update_batch_produced_qty(self) + pro_doc.run_method("update_status") if not pro_doc.operations: pro_doc.set_actual_dates() @@ -1478,11 +1472,11 @@ class StockEntry(StockController): # add finished goods item if self.purpose in ("Manufacture", "Repack"): + self.set_process_loss_qty() self.load_items_from_bom() self.set_scrap_items() self.set_actual_qty() - self.update_items_for_process_loss() self.validate_customer_provided_item() self.calculate_rate_and_amount(raise_error_if_no_rate=False) @@ -1495,6 +1489,21 @@ class StockEntry(StockController): self.add_to_stock_entry_detail(scrap_item_dict, bom_no=self.bom_no) + def set_process_loss_qty(self): + if self.purpose not in ("Manufacture", "Repack"): + return + + self.process_loss_qty = 0.0 + if not self.process_loss_percentage: + self.process_loss_percentage = frappe.get_cached_value( + "BOM", self.bom_no, "process_loss_percentage" + ) + + if self.process_loss_percentage: + self.process_loss_qty = flt( + (flt(self.fg_completed_qty) * flt(self.process_loss_percentage)) / 100 + ) + def set_work_order_details(self): if not getattr(self, "pro_doc", None): self.pro_doc = frappe._dict() @@ -1527,7 +1536,7 @@ class StockEntry(StockController): args = { "to_warehouse": to_warehouse, "from_warehouse": "", - "qty": self.fg_completed_qty, + "qty": flt(self.fg_completed_qty) - flt(self.process_loss_qty), "item_name": item.item_name, "description": item.description, "stock_uom": item.stock_uom, @@ -1975,7 +1984,6 @@ class StockEntry(StockController): ) se_child.is_finished_item = item_row.get("is_finished_item", 0) se_child.is_scrap_item = item_row.get("is_scrap_item", 0) - se_child.is_process_loss = item_row.get("is_process_loss", 0) se_child.po_detail = item_row.get("po_detail") se_child.sco_rm_detail = item_row.get("sco_rm_detail") @@ -2222,31 +2230,6 @@ class StockEntry(StockController): material_requests.append(material_request) frappe.db.set_value("Material Request", material_request, "transfer_status", status) - def update_items_for_process_loss(self): - process_loss_dict = {} - for d in self.get("items"): - if not d.is_process_loss: - continue - - scrap_warehouse = frappe.db.get_single_value( - "Manufacturing Settings", "default_scrap_warehouse" - ) - if scrap_warehouse is not None: - d.t_warehouse = scrap_warehouse - d.is_scrap_item = 0 - - if d.item_code not in process_loss_dict: - process_loss_dict[d.item_code] = [flt(0), flt(0)] - process_loss_dict[d.item_code][0] += flt(d.transfer_qty) - process_loss_dict[d.item_code][1] += flt(d.qty) - - for d in self.get("items"): - if not d.is_finished_item or d.item_code not in process_loss_dict: - continue - # Assumption: 1 finished item has 1 row. - d.transfer_qty -= process_loss_dict[d.item_code][0] - d.qty -= process_loss_dict[d.item_code][1] - def set_serial_no_batch_for_finished_good(self): serial_nos = [] if self.pro_doc.serial_no: @@ -2712,62 +2695,3 @@ def get_stock_entry_data(work_order): ) .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) ).run(as_dict=1) - - -def audit_incorrect_valuation_entries(): - # Audit of stock transfer entries having incorrect valuation - from erpnext.controllers.stock_controller import create_repost_item_valuation_entry - - stock_entries = get_incorrect_stock_entries() - - for stock_entry, values in stock_entries.items(): - reposting_data = frappe._dict( - { - "posting_date": values.posting_date, - "posting_time": values.posting_time, - "voucher_type": "Stock Entry", - "voucher_no": stock_entry, - "company": values.company, - } - ) - - create_repost_item_valuation_entry(reposting_data) - - -def get_incorrect_stock_entries() -> Dict: - stock_entry = frappe.qb.DocType("Stock Entry") - stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") - transfer_purposes = [ - "Material Transfer", - "Material Transfer for Manufacture", - "Send to Subcontractor", - ] - - query = ( - frappe.qb.from_(stock_entry) - .inner_join(stock_ledger_entry) - .on(stock_entry.name == stock_ledger_entry.voucher_no) - .select( - stock_entry.name, - stock_entry.company, - stock_entry.posting_date, - stock_entry.posting_time, - Sum(stock_ledger_entry.stock_value_difference).as_("stock_value"), - ) - .where( - (stock_entry.docstatus == 1) - & (stock_entry.purpose.isin(transfer_purposes)) - & (stock_ledger_entry.modified > add_days(today(), -2)) - ) - .groupby(stock_ledger_entry.voucher_detail_no) - .having(Sum(stock_ledger_entry.stock_value_difference) != 0) - ) - - data = query.run(as_dict=True) - stock_entries = {} - - for row in data: - if abs(row.stock_value) > 0.1 and row.name not in stock_entries: - stock_entries.setdefault(row.name, row) - - return stock_entries diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 680d209735e..b574b718fe1 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -5,7 +5,7 @@ import frappe from frappe.permissions import add_user_permission, remove_user_permission from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import add_days, flt, now, nowdate, nowtime, today +from frappe.utils import add_days, flt, nowdate, nowtime, today from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.stock.doctype.item.test_item import ( @@ -17,8 +17,6 @@ from erpnext.stock.doctype.item.test_item import ( from erpnext.stock.doctype.serial_no.serial_no import * # noqa from erpnext.stock.doctype.stock_entry.stock_entry import ( FinishedGoodError, - audit_incorrect_valuation_entries, - get_incorrect_stock_entries, move_sample_to_retention_warehouse, ) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry @@ -1616,44 +1614,6 @@ class TestStockEntry(FrappeTestCase): self.assertRaises(BatchExpiredError, se.save) - def test_audit_incorrect_stock_entries(self): - item_code = "Test Incorrect Valuation Rate Item - 001" - create_item(item_code=item_code, is_stock_item=1) - - make_stock_entry( - item_code=item_code, - purpose="Material Receipt", - posting_date=add_days(nowdate(), -10), - qty=2, - rate=500, - to_warehouse="_Test Warehouse - _TC", - ) - - transfer_entry = make_stock_entry( - item_code=item_code, - purpose="Material Transfer", - qty=2, - rate=500, - from_warehouse="_Test Warehouse - _TC", - to_warehouse="_Test Warehouse 1 - _TC", - ) - - sle_name = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": transfer_entry.name, "actual_qty": (">", 0)}, "name" - ) - - frappe.db.set_value( - "Stock Ledger Entry", sle_name, {"modified": add_days(now(), -1), "stock_value_difference": 10} - ) - - stock_entries = get_incorrect_stock_entries() - self.assertTrue(transfer_entry.name in stock_entries) - - audit_incorrect_valuation_entries() - - stock_entries = get_incorrect_stock_entries() - self.assertFalse(transfer_entry.name in stock_entries) - def make_serialized_item(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 95f4f5fd369..fe81a87558c 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -20,7 +20,6 @@ "is_finished_item", "is_scrap_item", "quality_inspection", - "is_process_loss", "subcontracted_item", "section_break_8", "description", @@ -559,12 +558,6 @@ "print_hide": 1, "read_only": 1 }, - { - "default": "0", - "fieldname": "is_process_loss", - "fieldtype": "Check", - "label": "Is Process Loss" - }, { "default": "0", "depends_on": "barcode", @@ -578,7 +571,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-11-02 13:00:34.258828", + "modified": "2023-01-03 14:51:16.575515", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 1741d654601..8561dc2e91e 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -102,9 +102,11 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru elif out.get("warehouse"): if doc and doc.get("doctype") == "Purchase Order": # calculate company_total_stock only for po - bin_details = get_bin_details(args.item_code, out.warehouse, args.company) + bin_details = get_bin_details( + args.item_code, out.warehouse, args.company, include_child_warehouses=True + ) else: - bin_details = get_bin_details(args.item_code, out.warehouse) + bin_details = get_bin_details(args.item_code, out.warehouse, include_child_warehouses=True) out.update(bin_details) @@ -1060,7 +1062,9 @@ def get_pos_profile_item_details(company, args, pos_profile=None, update_data=Fa res[fieldname] = pos_profile.get(fieldname) if res.get("warehouse"): - res.actual_qty = get_bin_details(args.item_code, res.warehouse).get("actual_qty") + res.actual_qty = get_bin_details( + args.item_code, res.warehouse, include_child_warehouses=True + ).get("actual_qty") return res @@ -1171,16 +1175,31 @@ def get_projected_qty(item_code, warehouse): @frappe.whitelist() -def get_bin_details(item_code, warehouse, company=None): - bin_details = frappe.db.get_value( - "Bin", - {"item_code": item_code, "warehouse": warehouse}, - ["projected_qty", "actual_qty", "reserved_qty"], - as_dict=True, - cache=True, - ) or {"projected_qty": 0, "actual_qty": 0, "reserved_qty": 0} +def get_bin_details(item_code, warehouse, company=None, include_child_warehouses=False): + bin_details = {"projected_qty": 0, "actual_qty": 0, "reserved_qty": 0, "ordered_qty": 0} + + if warehouse: + from frappe.query_builder.functions import Coalesce, Sum + + from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses + + warehouses = get_child_warehouses(warehouse) if include_child_warehouses else [warehouse] + + bin = frappe.qb.DocType("Bin") + bin_details = ( + frappe.qb.from_(bin) + .select( + Coalesce(Sum(bin.projected_qty), 0).as_("projected_qty"), + Coalesce(Sum(bin.actual_qty), 0).as_("actual_qty"), + Coalesce(Sum(bin.reserved_qty), 0).as_("reserved_qty"), + Coalesce(Sum(bin.ordered_qty), 0).as_("ordered_qty"), + ) + .where((bin.item_code == item_code) & (bin.warehouse.isin(warehouses))) + ).run(as_dict=True)[0] + if company: bin_details["company_total_stock"] = get_company_total_stock(item_code, company) + return bin_details diff --git a/erpnext/templates/includes/cart/cart_address.html b/erpnext/templates/includes/cart/cart_address.html index cf600173731..a8188ec8254 100644 --- a/erpnext/templates/includes/cart/cart_address.html +++ b/erpnext/templates/includes/cart/cart_address.html @@ -55,6 +55,7 @@ {% endif %}