diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 2bb950fcfcc..cb82d2ad733 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -8,8 +8,9 @@ sudo apt update && sudo apt install redis-server libcups2-dev pip install frappe-bench +githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}} frappeuser=${FRAPPE_USER:-"frappe"} -frappebranch=${FRAPPE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF##*/}}} +frappebranch=${FRAPPE_BRANCH:-$githubbranch} git clone "https://github.com/${frappeuser}/frappe" --branch "${frappebranch}" --depth 1 bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench @@ -56,7 +57,7 @@ sed -i 's/schedule:/# schedule:/g' Procfile sed -i 's/socketio:/# socketio:/g' Procfile sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile -bench get-app payments +bench get-app payments --branch ${githubbranch%"-hotfix"} bench get-app erpnext "${GITHUB_WORKSPACE}" if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index 9dff1168fde..6635d3e733b 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -393,7 +393,13 @@ def update_account_number(name, account_name, account_number=None, from_descenda if ancestors and not allow_independent_account_creation: for ancestor in ancestors: - if frappe.db.get_value("Account", {"account_name": old_acc_name, "company": ancestor}, "name"): + old_name = frappe.db.get_value( + "Account", + {"account_number": old_acc_number, "account_name": old_acc_name, "company": ancestor}, + "name", + ) + + if old_name: # same account in parent company exists allow_child_account_creation = _("Allow Account Creation Against Child Company") diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js index 059e1d31588..35d606ba3ae 100644 --- a/erpnext/accounts/doctype/bank/bank.js +++ b/erpnext/accounts/doctype/bank/bank.js @@ -118,6 +118,10 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink { } plaid_success(token, response) { - frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.update_bank_account_ids', { + response: response, + }).then(() => { + frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + }); } }; diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js index c083189eb27..d9772614411 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js @@ -18,6 +18,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }, onload: function (frm) { + // Set default filter dates + today = frappe.datetime.get_today() + frm.doc.bank_statement_from_date = frappe.datetime.add_months(today, -1); + frm.doc.bank_statement_to_date = today; frm.trigger('bank_account'); }, @@ -32,6 +36,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }, refresh: function (frm) { + frm.disable_save(); frappe.require("bank-reconciliation-tool.bundle.js", () => frm.trigger("make_reconciliation_tool") ); @@ -72,10 +77,12 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }, }) }); - }, - after_save: function (frm) { - frm.trigger("make_reconciliation_tool"); + frm.add_custom_button(__('Get Unreconciled Entries'), function() { + frm.trigger("make_reconciliation_tool"); + }); + frm.change_custom_button_type('Get Unreconciled Entries', null, 'primary'); + }, bank_account: function (frm) { @@ -89,7 +96,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { r.account, "account_currency", (r) => { - frm.currency = r.account_currency; + frm.doc.account_currency = r.account_currency; frm.trigger("render_chart"); } ); @@ -155,19 +162,19 @@ frappe.ui.form.on("Bank Reconciliation Tool", { } }, - render_chart: frappe.utils.debounce((frm) => { + render_chart(frm) { frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager( { $reconciliation_tool_cards: frm.get_field( "reconciliation_tool_cards" ).$wrapper, bank_statement_closing_balance: - frm.doc.bank_statement_closing_balance, + frm.doc.bank_statement_closing_balance, cleared_balance: frm.cleared_balance, - currency: frm.currency, + currency: frm.doc.account_currency, } ); - }, 500), + }, render(frm) { if (frm.doc.bank_account) { diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json index 80993d6608d..93fc4439d35 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json @@ -14,6 +14,7 @@ "to_reference_date", "filter_by_reference_date", "column_break_2", + "account_currency", "account_opening_balance", "bank_statement_closing_balance", "section_break_1", @@ -59,7 +60,7 @@ "fieldname": "account_opening_balance", "fieldtype": "Currency", "label": "Account Opening Balance", - "options": "Currency", + "options": "account_currency", "read_only": 1 }, { @@ -67,7 +68,7 @@ "fieldname": "bank_statement_closing_balance", "fieldtype": "Currency", "label": "Closing Balance", - "options": "Currency" + "options": "account_currency" }, { "fieldname": "section_break_1", @@ -104,13 +105,20 @@ "fieldname": "filter_by_reference_date", "fieldtype": "Check", "label": "Filter by Reference Date" + }, + { + "fieldname": "account_currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Account Currency", + "options": "Currency" } ], "hide_toolbar": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-01-13 13:00:02.022919", + "modified": "2023-03-07 11:02:24.535714", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Reconciliation Tool", 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 57bc351f414..c4a23a640c3 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -10,7 +10,7 @@ from frappe.model.document import Document from frappe.query_builder.custom import ConstantColumn from frappe.utils import cint, flt -from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_paid_amount +from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount from erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_statement import ( get_amounts_not_reflected_in_system, get_entries, @@ -28,7 +28,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): filters = [] filters.append(["bank_account", "=", bank_account]) filters.append(["docstatus", "=", 1]) - filters.append(["unallocated_amount", ">", 0]) + filters.append(["unallocated_amount", ">", 0.0]) if to_date: filters.append(["date", "<=", to_date]) if from_date: @@ -66,7 +66,7 @@ def get_account_balance(bank_account, till_date): balance_as_per_system = get_balance_on(filters["account"], filters["report_date"]) - total_debit, total_credit = 0, 0 + total_debit, total_credit = 0.0, 0.0 for d in data: total_debit += flt(d.debit) total_credit += flt(d.credit) @@ -145,10 +145,8 @@ def create_journal_entry_bts( accounts.append( { "account": second_account, - "credit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0, - "debit_in_account_currency": bank_transaction.withdrawal - if bank_transaction.withdrawal > 0 - else 0, + "credit_in_account_currency": bank_transaction.deposit, + "debit_in_account_currency": bank_transaction.withdrawal, "party_type": party_type, "party": party, } @@ -158,10 +156,8 @@ def create_journal_entry_bts( { "account": company_account, "bank_account": bank_transaction.bank_account, - "credit_in_account_currency": bank_transaction.withdrawal - if bank_transaction.withdrawal > 0 - else 0, - "debit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0, + "credit_in_account_currency": bank_transaction.withdrawal, + "debit_in_account_currency": bank_transaction.deposit, } ) @@ -185,16 +181,22 @@ def create_journal_entry_bts( journal_entry.insert() journal_entry.submit() - if bank_transaction.deposit > 0: + if bank_transaction.deposit > 0.0: paid_amount = bank_transaction.deposit else: paid_amount = bank_transaction.withdrawal vouchers = json.dumps( - [{"payment_doctype": "Journal Entry", "payment_name": journal_entry.name, "amount": paid_amount}] + [ + { + "payment_doctype": "Journal Entry", + "payment_name": journal_entry.name, + "amount": paid_amount, + } + ] ) - return reconcile_vouchers(bank_transaction.name, vouchers) + return reconcile_vouchers(bank_transaction_name, vouchers) @frappe.whitelist() @@ -218,7 +220,7 @@ def create_payment_entry_bts( as_dict=True, )[0] paid_amount = bank_transaction.unallocated_amount - payment_type = "Receive" if bank_transaction.deposit > 0 else "Pay" + payment_type = "Receive" if bank_transaction.deposit > 0.0 else "Pay" company_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account") company = frappe.get_value("Account", company_account, "company") @@ -257,9 +259,15 @@ def create_payment_entry_bts( payment_entry.submit() vouchers = json.dumps( - [{"payment_doctype": "Payment Entry", "payment_name": payment_entry.name, "amount": paid_amount}] + [ + { + "payment_doctype": "Payment Entry", + "payment_name": payment_entry.name, + "amount": paid_amount, + } + ] ) - return reconcile_vouchers(bank_transaction.name, vouchers) + return reconcile_vouchers(bank_transaction_name, vouchers) @frappe.whitelist() @@ -341,59 +349,7 @@ def reconcile_vouchers(bank_transaction_name, vouchers): # updated clear date of all the vouchers based on the bank transaction vouchers = json.loads(vouchers) transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) - company_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") - - if transaction.unallocated_amount == 0: - frappe.throw(_("This bank transaction is already fully reconciled")) - total_amount = 0 - for voucher in vouchers: - voucher["payment_entry"] = frappe.get_doc(voucher["payment_doctype"], voucher["payment_name"]) - total_amount += get_paid_amount( - frappe._dict( - { - "payment_document": voucher["payment_doctype"], - "payment_entry": voucher["payment_name"], - } - ), - transaction.currency, - company_account, - ) - - if total_amount > transaction.unallocated_amount: - frappe.throw( - _( - "The sum total of amounts of all selected vouchers should be less than the unallocated amount of the bank transaction" - ) - ) - account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") - - for voucher in vouchers: - gl_entry = frappe.db.get_value( - "GL Entry", - dict( - account=account, voucher_type=voucher["payment_doctype"], voucher_no=voucher["payment_name"] - ), - ["credit_in_account_currency as credit", "debit_in_account_currency as debit"], - as_dict=1, - ) - gl_amount, transaction_amount = ( - (gl_entry.credit, transaction.deposit) - if gl_entry.credit > 0 - else (gl_entry.debit, transaction.withdrawal) - ) - allocated_amount = gl_amount if gl_amount >= transaction_amount else transaction_amount - - transaction.append( - "payment_entries", - { - "payment_document": voucher["payment_entry"].doctype, - "payment_entry": voucher["payment_entry"].name, - "allocated_amount": allocated_amount, - }, - ) - - transaction.save() - transaction.update_allocations() + transaction.add_payment_entries(vouchers) return frappe.get_doc("Bank Transaction", bank_transaction_name) @@ -412,9 +368,9 @@ def get_linked_payments( bank_account = frappe.db.get_values( "Bank Account", transaction.bank_account, ["account", "company"], as_dict=True )[0] - (account, company) = (bank_account.account, bank_account.company) + (gl_account, company) = (bank_account.account, bank_account.company) matching = check_matching( - account, + gl_account, company, transaction, document_types, @@ -424,7 +380,27 @@ def get_linked_payments( from_reference_date, to_reference_date, ) - return matching + return subtract_allocations(gl_account, matching) + + +def subtract_allocations(gl_account, vouchers): + "Look up & subtract any existing Bank Transaction allocations" + copied = [] + for voucher in vouchers: + rows = get_total_allocated_amount(voucher[1], voucher[2]) + amount = None + for row in rows: + if row["gl_account"] == gl_account: + amount = row["total"] + break + + if amount: + l = list(voucher) + l[3] -= amount + copied.append(tuple(l)) + else: + copied.append(voucher) + return copied def check_matching( @@ -438,6 +414,7 @@ def check_matching( from_reference_date, to_reference_date, ): + exact_match = True if "exact_match" in document_types else False # combine all types of vouchers subquery = get_queries( bank_account, @@ -449,10 +426,11 @@ def check_matching( filter_by_reference_date, from_reference_date, to_reference_date, + exact_match, ) filters = { "amount": transaction.unallocated_amount, - "payment_type": "Receive" if transaction.deposit > 0 else "Pay", + "payment_type": "Receive" if transaction.deposit > 0.0 else "Pay", "reference_no": transaction.reference_number, "party_type": transaction.party_type, "party": transaction.party, @@ -461,7 +439,9 @@ def check_matching( matching_vouchers = [] - matching_vouchers.extend(get_loan_vouchers(bank_account, transaction, document_types, filters)) + matching_vouchers.extend( + get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match) + ) for query in subquery: matching_vouchers.extend( @@ -483,10 +463,10 @@ def get_queries( filter_by_reference_date, from_reference_date, to_reference_date, + exact_match, ): # get queries to get matching vouchers - amount_condition = "=" if "exact_match" in document_types else "<=" - account_from_to = "paid_to" if transaction.deposit > 0 else "paid_from" + account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from" queries = [] # get matching queries from all the apps @@ -497,7 +477,7 @@ def get_queries( company, transaction, document_types, - amount_condition, + exact_match, account_from_to, from_date, to_date, @@ -516,7 +496,7 @@ def get_matching_queries( company, transaction, document_types, - amount_condition, + exact_match, account_from_to, from_date, to_date, @@ -526,8 +506,8 @@ def get_matching_queries( ): queries = [] if "payment_entry" in document_types: - pe_amount_matching = get_pe_matching_query( - amount_condition, + query = get_pe_matching_query( + exact_match, account_from_to, transaction, from_date, @@ -536,11 +516,11 @@ def get_matching_queries( from_reference_date, to_reference_date, ) - queries.extend([pe_amount_matching]) + queries.append(query) if "journal_entry" in document_types: - je_amount_matching = get_je_matching_query( - amount_condition, + query = get_je_matching_query( + exact_match, transaction, from_date, to_date, @@ -548,34 +528,70 @@ def get_matching_queries( from_reference_date, to_reference_date, ) - queries.extend([je_amount_matching]) + queries.append(query) - if transaction.deposit > 0 and "sales_invoice" in document_types: - si_amount_matching = get_si_matching_query(amount_condition) - queries.extend([si_amount_matching]) + if transaction.deposit > 0.0 and "sales_invoice" in document_types: + query = get_si_matching_query(exact_match) + queries.append(query) - if transaction.withdrawal > 0: + if transaction.withdrawal > 0.0: if "purchase_invoice" in document_types: - pi_amount_matching = get_pi_matching_query(amount_condition) - queries.extend([pi_amount_matching]) + query = get_pi_matching_query(exact_match) + queries.append(query) + + if "bank_transaction" in document_types: + query = get_bt_matching_query(exact_match, transaction) + queries.append(query) return queries -def get_loan_vouchers(bank_account, transaction, document_types, filters): +def get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match): vouchers = [] - amount_condition = True if "exact_match" in document_types else False - if transaction.withdrawal > 0 and "loan_disbursement" in document_types: - vouchers.extend(get_ld_matching_query(bank_account, amount_condition, filters)) + if transaction.withdrawal > 0.0 and "loan_disbursement" in document_types: + vouchers.extend(get_ld_matching_query(bank_account, exact_match, filters)) - if transaction.deposit > 0 and "loan_repayment" in document_types: - vouchers.extend(get_lr_matching_query(bank_account, amount_condition, filters)) + if transaction.deposit > 0.0 and "loan_repayment" in document_types: + vouchers.extend(get_lr_matching_query(bank_account, exact_match, filters)) return vouchers -def get_ld_matching_query(bank_account, amount_condition, filters): +def get_bt_matching_query(exact_match, transaction): + # get matching bank transaction query + # find bank transactions in the same bank account with opposite sign + # same bank account must have same company and currency + field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal" + + return f""" + + SELECT + (CASE WHEN reference_number = %(reference_no)s THEN 1 ELSE 0 END + + CASE WHEN {field} = %(amount)s THEN 1 ELSE 0 END + + CASE WHEN ( party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN unallocated_amount = %(amount)s THEN 1 ELSE 0 END + + 1) AS rank, + 'Bank Transaction' AS doctype, + name, + unallocated_amount AS paid_amount, + reference_number AS reference_no, + date AS reference_date, + party, + party_type, + date AS posting_date, + currency + FROM + `tabBank Transaction` + WHERE + status != 'Reconciled' + AND name != '{transaction.name}' + AND bank_account = '{transaction.bank_account}' + AND {field} {'= %(amount)s' if exact_match else '> 0.0'} + """ + + +def get_ld_matching_query(bank_account, exact_match, filters): loan_disbursement = frappe.qb.DocType("Loan Disbursement") matching_reference = loan_disbursement.reference_number == filters.get("reference_number") matching_party = loan_disbursement.applicant_type == filters.get( @@ -603,17 +619,17 @@ def get_ld_matching_query(bank_account, amount_condition, filters): .where(loan_disbursement.disbursement_account == bank_account) ) - if amount_condition: + if exact_match: query.where(loan_disbursement.disbursed_amount == filters.get("amount")) else: - query.where(loan_disbursement.disbursed_amount <= filters.get("amount")) + query.where(loan_disbursement.disbursed_amount > 0.0) vouchers = query.run(as_list=True) return vouchers -def get_lr_matching_query(bank_account, amount_condition, filters): +def get_lr_matching_query(bank_account, exact_match, filters): loan_repayment = frappe.qb.DocType("Loan Repayment") matching_reference = loan_repayment.reference_number == filters.get("reference_number") matching_party = loan_repayment.applicant_type == filters.get( @@ -644,10 +660,10 @@ def get_lr_matching_query(bank_account, amount_condition, filters): if frappe.db.has_column("Loan Repayment", "repay_from_salary"): query = query.where((loan_repayment.repay_from_salary == 0)) - if amount_condition: + if exact_match: query.where(loan_repayment.amount_paid == filters.get("amount")) else: - query.where(loan_repayment.amount_paid <= filters.get("amount")) + query.where(loan_repayment.amount_paid > 0.0) vouchers = query.run() @@ -655,7 +671,7 @@ def get_lr_matching_query(bank_account, amount_condition, filters): def get_pe_matching_query( - amount_condition, + exact_match, account_from_to, transaction, from_date, @@ -665,7 +681,7 @@ def get_pe_matching_query( to_reference_date, ): # get matching payment entries query - if transaction.deposit > 0: + if transaction.deposit > 0.0: currency_field = "paid_to_account_currency as currency" else: currency_field = "paid_from_account_currency as currency" @@ -680,7 +696,8 @@ def get_pe_matching_query( return f""" SELECT (CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END - + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END + 1 ) AS rank, 'Payment Entry' as doctype, name, @@ -694,20 +711,19 @@ def get_pe_matching_query( FROM `tabPayment Entry` WHERE - paid_amount {amount_condition} %(amount)s - AND docstatus = 1 + docstatus = 1 AND payment_type IN (%(payment_type)s, 'Internal Transfer') AND ifnull(clearance_date, '') = "" AND {account_from_to} = %(bank_account)s + AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} {filter_by_date} {filter_by_reference_no} order by{order_by} - """ def get_je_matching_query( - amount_condition, + exact_match, transaction, from_date, to_date, @@ -719,7 +735,7 @@ def get_je_matching_query( # We have mapping at the bank level # So one bank could have both types of bank accounts like asset and liability # So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type - cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit" + cr_or_dr = "credit" if transaction.withdrawal > 0.0 else "debit" filter_by_date = f"AND je.posting_date between '{from_date}' and '{to_date}'" order_by = " je.posting_date" filter_by_reference_no = "" @@ -731,26 +747,29 @@ def get_je_matching_query( return f""" SELECT (CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END + + CASE WHEN jea.{cr_or_dr}_in_account_currency = %(amount)s THEN 1 ELSE 0 END + 1) AS rank , - 'Journal Entry' as doctype, + 'Journal Entry' AS doctype, je.name, - jea.{cr_or_dr}_in_account_currency as paid_amount, - je.cheque_no as reference_no, - je.cheque_date as reference_date, - je.pay_to_recd_from as party, + jea.{cr_or_dr}_in_account_currency AS paid_amount, + je.cheque_no AS reference_no, + je.cheque_date AS reference_date, + je.pay_to_recd_from AS party, jea.party_type, je.posting_date, - jea.account_currency as currency + jea.account_currency AS currency FROM - `tabJournal Entry Account` as jea + `tabJournal Entry Account` AS jea JOIN - `tabJournal Entry` as je + `tabJournal Entry` AS je ON jea.parent = je.name WHERE - (je.clearance_date is null or je.clearance_date='0000-00-00') + je.docstatus = 1 + AND je.voucher_type NOT IN ('Opening Entry') + AND (je.clearance_date IS NULL OR je.clearance_date='0000-00-00') AND jea.account = %(bank_account)s - AND jea.{cr_or_dr}_in_account_currency {amount_condition} %(amount)s + AND jea.{cr_or_dr}_in_account_currency {'= %(amount)s' if exact_match else '> 0.0'} AND je.docstatus = 1 {filter_by_date} {filter_by_reference_no} @@ -758,11 +777,12 @@ def get_je_matching_query( """ -def get_si_matching_query(amount_condition): - # get matchin sales invoice query +def get_si_matching_query(exact_match): + # get matching sales invoice query return f""" SELECT - ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END + ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END + + CASE WHEN sip.amount = %(amount)s THEN 1 ELSE 0 END + 1 ) AS rank, 'Sales Invoice' as doctype, si.name, @@ -780,18 +800,20 @@ def get_si_matching_query(amount_condition): `tabSales Invoice` as si ON sip.parent = si.name - WHERE (sip.clearance_date is null or sip.clearance_date='0000-00-00') + WHERE + si.docstatus = 1 + AND (sip.clearance_date is null or sip.clearance_date='0000-00-00') AND sip.account = %(bank_account)s - AND sip.amount {amount_condition} %(amount)s - AND si.docstatus = 1 + AND sip.amount {'= %(amount)s' if exact_match else '> 0.0'} """ -def get_pi_matching_query(amount_condition): - # get matching purchase invoice query +def get_pi_matching_query(exact_match): + # get matching purchase invoice query when they are also used as payment entries (is_paid) return f""" SELECT ( CASE WHEN supplier = %(party)s THEN 1 ELSE 0 END + + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END + 1 ) AS rank, 'Purchase Invoice' as doctype, name, @@ -805,9 +827,9 @@ def get_pi_matching_query(amount_condition): FROM `tabPurchase Invoice` WHERE - paid_amount {amount_condition} %(amount)s - AND docstatus = 1 + docstatus = 1 AND is_paid = 1 AND ifnull(clearance_date, '') = "" - AND cash_bank_account = %(bank_account)s + AND cash_bank_account = %(bank_account)s + AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} """ diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js index 6f2900a6808..e548b4c7e9a 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js @@ -12,8 +12,13 @@ frappe.ui.form.on("Bank Transaction", { }; }); }, - - bank_account: function(frm) { + refresh(frm) { + frm.add_custom_button(__('Unreconcile Transaction'), () => { + frm.call('remove_payment_entries') + .then( () => frm.refresh() ); + }); + }, + bank_account: function (frm) { set_bank_statement_filter(frm); }, @@ -34,6 +39,7 @@ frappe.ui.form.on("Bank Transaction", { "Journal Entry", "Sales Invoice", "Purchase Invoice", + "Bank Transaction", ]; } }); @@ -49,7 +55,7 @@ const update_clearance_date = (frm, cdt, cdn) => { frappe .xcall( "erpnext.accounts.doctype.bank_transaction.bank_transaction.unclear_reference_payment", - { doctype: cdt, docname: cdn } + { doctype: cdt, docname: cdn, bt_name: frm.doc.name } ) .then((e) => { if (e == "success") { diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json index 2bdaa1049b7..768d2f0fa45 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json @@ -20,9 +20,11 @@ "currency", "section_break_10", "description", - "section_break_14", "reference_number", + "column_break_10", "transaction_id", + "transaction_type", + "section_break_14", "payment_entries", "section_break_18", "allocated_amount", @@ -190,11 +192,21 @@ "label": "Withdrawal", "oldfieldname": "credit", "options": "currency" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "transaction_type", + "fieldtype": "Data", + "label": "Transaction Type", + "length": 50 } ], "is_submittable": 1, "links": [], - "modified": "2022-03-21 19:05:04.208222", + "modified": "2022-05-29 18:36:50.475964", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Transaction", @@ -248,4 +260,4 @@ "states": [], "title_field": "bank_account", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 9b36c93a0f3..15162376c15 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -1,9 +1,6 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - -from functools import reduce - import frappe from frappe.utils import flt @@ -18,72 +15,137 @@ class BankTransaction(StatusUpdater): self.clear_linked_payment_entries() self.set_status() + _saving_flag = False + + # nosemgrep: frappe-semgrep-rules.rules.frappe-modifying-but-not-comitting def on_update_after_submit(self): - self.update_allocations() - self.clear_linked_payment_entries() - self.set_status(update=True) + "Run on save(). Avoid recursion caused by multiple saves" + if not self._saving_flag: + self._saving_flag = True + self.clear_linked_payment_entries() + self.update_allocations() + self._saving_flag = False def on_cancel(self): self.clear_linked_payment_entries(for_cancel=True) self.set_status(update=True) def update_allocations(self): + "The doctype does not allow modifications after submission, so write to the db direct" if self.payment_entries: - allocated_amount = reduce( - lambda x, y: flt(x) + flt(y), [x.allocated_amount for x in self.payment_entries] - ) + allocated_amount = sum(p.allocated_amount for p in self.payment_entries) else: - allocated_amount = 0 + allocated_amount = 0.0 - if allocated_amount: - frappe.db.set_value(self.doctype, self.name, "allocated_amount", flt(allocated_amount)) - frappe.db.set_value( - self.doctype, - self.name, - "unallocated_amount", - abs(flt(self.withdrawal) - flt(self.deposit)) - flt(allocated_amount), - ) + amount = abs(flt(self.withdrawal) - flt(self.deposit)) + self.db_set("allocated_amount", flt(allocated_amount)) + self.db_set("unallocated_amount", amount - flt(allocated_amount)) + self.reload() + self.set_status(update=True) - else: - frappe.db.set_value(self.doctype, self.name, "allocated_amount", 0) - frappe.db.set_value( - self.doctype, self.name, "unallocated_amount", abs(flt(self.withdrawal) - flt(self.deposit)) - ) + def add_payment_entries(self, vouchers): + "Add the vouchers with zero allocation. Save() will perform the allocations and clearance" + if 0.0 >= self.unallocated_amount: + frappe.throw(frappe._(f"Bank Transaction {self.name} is already fully reconciled")) - amount = self.deposit or self.withdrawal - if amount == self.allocated_amount: - frappe.db.set_value(self.doctype, self.name, "status", "Reconciled") + added = False + for voucher in vouchers: + # Can't add same voucher twice + found = False + for pe in self.payment_entries: + if ( + pe.payment_document == voucher["payment_doctype"] + and pe.payment_entry == voucher["payment_name"] + ): + found = True + + if not found: + pe = { + "payment_document": voucher["payment_doctype"], + "payment_entry": voucher["payment_name"], + "allocated_amount": 0.0, # Temporary + } + child = self.append("payment_entries", pe) + added = True + + # runs on_update_after_submit + if added: + self.save() + + def allocate_payment_entries(self): + """Refactored from bank reconciliation tool. + Non-zero allocations must be amended/cleared manually + Get the bank transaction amount (b) and remove as we allocate + For each payment_entry if allocated_amount == 0: + - get the amount already allocated against all transactions (t), need latest date + - get the voucher amount (from gl) (v) + - allocate (a = v - t) + - a = 0: should already be cleared, so clear & remove payment_entry + - 0 < a <= u: allocate a & clear + - 0 < a, a > u: allocate u + - 0 > a: Error: already over-allocated + - clear means: set the latest transaction date as clearance date + """ + gl_bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account") + remaining_amount = self.unallocated_amount + for payment_entry in self.payment_entries: + if payment_entry.allocated_amount == 0.0: + unallocated_amount, should_clear, latest_transaction = get_clearance_details( + self, payment_entry + ) + + if 0.0 == unallocated_amount: + if should_clear: + latest_transaction.clear_linked_payment_entry(payment_entry) + self.db_delete_payment_entry(payment_entry) + + elif remaining_amount <= 0.0: + self.db_delete_payment_entry(payment_entry) + + elif 0.0 < unallocated_amount and unallocated_amount <= remaining_amount: + payment_entry.db_set("allocated_amount", unallocated_amount) + remaining_amount -= unallocated_amount + if should_clear: + latest_transaction.clear_linked_payment_entry(payment_entry) + + elif 0.0 < unallocated_amount and unallocated_amount > remaining_amount: + payment_entry.db_set("allocated_amount", remaining_amount) + remaining_amount = 0.0 + + elif 0.0 > unallocated_amount: + self.db_delete_payment_entry(payment_entry) + frappe.throw( + frappe._(f"Voucher {payment_entry.payment_entry} is over-allocated by {unallocated_amount}") + ) self.reload() - def clear_linked_payment_entries(self, for_cancel=False): + def db_delete_payment_entry(self, payment_entry): + frappe.db.delete("Bank Transaction Payments", {"name": payment_entry.name}) + + @frappe.whitelist() + def remove_payment_entries(self): for payment_entry in self.payment_entries: - if payment_entry.payment_document == "Sales Invoice": - self.clear_sales_invoice(payment_entry, for_cancel=for_cancel) - elif payment_entry.payment_document in get_doctypes_for_bank_reconciliation(): - self.clear_simple_entry(payment_entry, for_cancel=for_cancel) + self.remove_payment_entry(payment_entry) + # runs on_update_after_submit + self.save() - def clear_simple_entry(self, payment_entry, for_cancel=False): - if payment_entry.payment_document == "Payment Entry": - if ( - frappe.db.get_value("Payment Entry", payment_entry.payment_entry, "payment_type") - == "Internal Transfer" - ): - if len(get_reconciled_bank_transactions(payment_entry)) < 2: - return + def remove_payment_entry(self, payment_entry): + "Clear payment entry and clearance" + self.clear_linked_payment_entry(payment_entry, for_cancel=True) + self.remove(payment_entry) - clearance_date = self.date if not for_cancel else None - frappe.db.set_value( - payment_entry.payment_document, payment_entry.payment_entry, "clearance_date", clearance_date - ) + def clear_linked_payment_entries(self, for_cancel=False): + if for_cancel: + for payment_entry in self.payment_entries: + self.clear_linked_payment_entry(payment_entry, for_cancel) + else: + self.allocate_payment_entries() - def clear_sales_invoice(self, payment_entry, for_cancel=False): - clearance_date = self.date if not for_cancel else None - frappe.db.set_value( - "Sales Invoice Payment", - dict(parenttype=payment_entry.payment_document, parent=payment_entry.payment_entry), - "clearance_date", - clearance_date, + def clear_linked_payment_entry(self, payment_entry, for_cancel=False): + clearance_date = None if for_cancel else self.date + set_voucher_clearance( + payment_entry.payment_document, payment_entry.payment_entry, clearance_date, self ) @@ -93,38 +155,112 @@ def get_doctypes_for_bank_reconciliation(): return frappe.get_hooks("bank_reconciliation_doctypes") -def get_reconciled_bank_transactions(payment_entry): - reconciled_bank_transactions = frappe.get_all( - "Bank Transaction Payments", - filters={"payment_entry": payment_entry.payment_entry}, - fields=["parent"], +def get_clearance_details(transaction, payment_entry): + """ + There should only be one bank gle for a voucher. + Could be none for a Bank Transaction. + But if a JE, could affect two banks. + Should only clear the voucher if all bank gles are allocated. + """ + gl_bank_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") + gles = get_related_bank_gl_entries(payment_entry.payment_document, payment_entry.payment_entry) + bt_allocations = get_total_allocated_amount( + payment_entry.payment_document, payment_entry.payment_entry ) - return reconciled_bank_transactions + unallocated_amount = min( + transaction.unallocated_amount, + get_paid_amount(payment_entry, transaction.currency, gl_bank_account), + ) + unmatched_gles = len(gles) + latest_transaction = transaction + for gle in gles: + if gle["gl_account"] == gl_bank_account: + if gle["amount"] <= 0.0: + frappe.throw( + frappe._(f"Voucher {payment_entry.payment_entry} value is broken: {gle['amount']}") + ) + + unmatched_gles -= 1 + unallocated_amount = gle["amount"] + for a in bt_allocations: + if a["gl_account"] == gle["gl_account"]: + unallocated_amount = gle["amount"] - a["total"] + if frappe.utils.getdate(transaction.date) < a["latest_date"]: + latest_transaction = frappe.get_doc("Bank Transaction", a["latest_name"]) + else: + # Must be a Journal Entry affecting more than one bank + for a in bt_allocations: + if a["gl_account"] == gle["gl_account"] and a["total"] == gle["amount"]: + unmatched_gles -= 1 + + return unallocated_amount, unmatched_gles == 0, latest_transaction -def get_total_allocated_amount(payment_entry): - return frappe.db.sql( +def get_related_bank_gl_entries(doctype, docname): + # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql + result = frappe.db.sql( """ SELECT - SUM(btp.allocated_amount) as allocated_amount, - bt.name + ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount, + gle.account AS gl_account FROM - `tabBank Transaction Payments` as btp + `tabGL Entry` gle LEFT JOIN - `tabBank Transaction` bt ON bt.name=btp.parent + `tabAccount` ac ON ac.name=gle.account WHERE - btp.payment_document = %s - AND - btp.payment_entry = %s - AND - bt.docstatus = 1""", - (payment_entry.payment_document, payment_entry.payment_entry), + ac.account_type = 'Bank' + AND gle.voucher_type = %(doctype)s + AND gle.voucher_no = %(docname)s + AND is_cancelled = 0 + """, + dict(doctype=doctype, docname=docname), as_dict=True, ) + return result -def get_paid_amount(payment_entry, currency, bank_account): +def get_total_allocated_amount(doctype, docname): + """ + Gets the sum of allocations for a voucher on each bank GL account + along with the latest bank transaction name & date + NOTE: query may also include just saved vouchers/payments but with zero allocated_amount + """ + # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql + result = frappe.db.sql( + """ + SELECT total, latest_name, latest_date, gl_account FROM ( + SELECT + ROW_NUMBER() OVER w AS rownum, + SUM(btp.allocated_amount) OVER(PARTITION BY ba.account) AS total, + FIRST_VALUE(bt.name) OVER w AS latest_name, + FIRST_VALUE(bt.date) OVER w AS latest_date, + ba.account AS gl_account + FROM + `tabBank Transaction Payments` btp + LEFT JOIN `tabBank Transaction` bt ON bt.name=btp.parent + LEFT JOIN `tabBank Account` ba ON ba.name=bt.bank_account + WHERE + btp.payment_document = %(doctype)s + AND btp.payment_entry = %(docname)s + AND bt.docstatus = 1 + WINDOW w AS (PARTITION BY ba.account ORDER BY bt.date desc) + ) temp + WHERE + rownum = 1 + """, + dict(doctype=doctype, docname=docname), + as_dict=True, + ) + for row in result: + # Why is this *sometimes* a byte string? + if isinstance(row["latest_name"], bytes): + row["latest_name"] = row["latest_name"].decode() + row["latest_date"] = frappe.utils.getdate(row["latest_date"]) + return result + + +def get_paid_amount(payment_entry, currency, gl_bank_account): if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]: paid_amount_field = "paid_amount" @@ -147,7 +283,7 @@ def get_paid_amount(payment_entry, currency, bank_account): elif payment_entry.payment_document == "Journal Entry": return frappe.db.get_value( "Journal Entry Account", - {"parent": payment_entry.payment_entry, "account": bank_account}, + {"parent": payment_entry.payment_entry, "account": gl_bank_account}, "sum(credit_in_account_currency)", ) @@ -166,6 +302,12 @@ def get_paid_amount(payment_entry, currency, bank_account): payment_entry.payment_document, payment_entry.payment_entry, "amount_paid" ) + elif payment_entry.payment_document == "Bank Transaction": + dep, wth = frappe.db.get_value( + "Bank Transaction", payment_entry.payment_entry, ("deposit", "withdrawal") + ) + return abs(flt(wth) - flt(dep)) + else: frappe.throw( "Please reconcile {0}: {1} manually".format( @@ -174,18 +316,55 @@ def get_paid_amount(payment_entry, currency, bank_account): ) -@frappe.whitelist() -def unclear_reference_payment(doctype, docname): - if frappe.db.exists(doctype, docname): - doc = frappe.get_doc(doctype, docname) - if doctype == "Sales Invoice": - frappe.db.set_value( - "Sales Invoice Payment", - dict(parenttype=doc.payment_document, parent=doc.payment_entry), - "clearance_date", - None, - ) - else: - frappe.db.set_value(doc.payment_document, doc.payment_entry, "clearance_date", None) +def set_voucher_clearance(doctype, docname, clearance_date, self): + if doctype in [ + "Payment Entry", + "Journal Entry", + "Purchase Invoice", + "Expense Claim", + "Loan Repayment", + "Loan Disbursement", + ]: + if ( + doctype == "Payment Entry" + and frappe.db.get_value("Payment Entry", docname, "payment_type") == "Internal Transfer" + and len(get_reconciled_bank_transactions(doctype, docname)) < 2 + ): + return + frappe.db.set_value(doctype, docname, "clearance_date", clearance_date) - return doc.payment_entry + elif doctype == "Sales Invoice": + frappe.db.set_value( + "Sales Invoice Payment", + dict(parenttype=doctype, parent=docname), + "clearance_date", + clearance_date, + ) + + elif doctype == "Bank Transaction": + # For when a second bank transaction has fixed another, e.g. refund + bt = frappe.get_doc(doctype, docname) + if clearance_date: + vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}] + bt.add_payment_entries(vouchers) + else: + for pe in bt.payment_entries: + if pe.payment_document == self.doctype and pe.payment_entry == self.name: + bt.remove(pe) + bt.save() + break + + +def get_reconciled_bank_transactions(doctype, docname): + return frappe.get_all( + "Bank Transaction Payments", + filters={"payment_document": doctype, "payment_entry": docname}, + pluck="parent", + ) + + +@frappe.whitelist() +def unclear_reference_payment(doctype, docname, bt_name): + bt = frappe.get_doc("Bank Transaction", bt_name) + set_voucher_clearance(doctype, docname, None, bt) + return docname diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index e3d9c26b2d1..c9e3998ac8a 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -221,12 +221,15 @@ class PaymentReconciliation(Document): 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 + if frappe.get_cached_value( + "Account", self.receivable_payable_account, "account_currency" + ) != frappe.get_cached_value("Company", self.company, "default_currency"): + 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 return difference_amount diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index f9dda0593b0..3be11ae31a7 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -5,7 +5,7 @@ import unittest import frappe from frappe import qb -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, flt, nowdate from erpnext import get_default_cost_center @@ -349,6 +349,11 @@ class TestPaymentReconciliation(FrappeTestCase): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() si.reload() @@ -390,6 +395,11 @@ class TestPaymentReconciliation(FrappeTestCase): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() # check PR tool output @@ -414,6 +424,11 @@ class TestPaymentReconciliation(FrappeTestCase): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() # assert outstanding @@ -450,6 +465,11 @@ class TestPaymentReconciliation(FrappeTestCase): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() self.assertEqual(pr.get("invoices"), []) @@ -824,6 +844,52 @@ class TestPaymentReconciliation(FrappeTestCase): payment_vouchers = [x.get("reference_name") for x in pr.get("payments")] self.assertCountEqual(payment_vouchers, [je2.name, pe2.name]) + @change_settings( + "Accounts Settings", + { + "allow_multi_currency_invoices_against_single_party_account": 1, + }, + ) + def test_no_difference_amount_for_base_currency_accounts(self): + # Make Sale Invoice + si = self.create_sales_invoice( + qty=1, rate=1, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + si.customer = self.customer + si.currency = "EUR" + si.conversion_rate = 85 + si.debit_to = self.debit_to + si.save().submit() + + # Make payment using Payment Entry + pe1 = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer, + paid_from=self.debit_to, + paid_to=self.bank, + paid_amount=100, + ) + + pe1.save() + pe1.submit() + + pr = self.create_payment_reconciliation() + pr.party = self.customer + pr.receivable_payable_account = self.debit_to + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + + 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, 85) + self.assertEqual(pr.allocation[0].difference_amount, 0) + def make_customer(customer_name, currency=None): if not frappe.db.exists("Customer", customer_name): diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 56b857992c7..cced37589ba 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -112,7 +112,8 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex party_type: "Customer", account: this.frm.doc.debit_to, price_list: this.frm.doc.selling_price_list, - pos_profile: pos_profile + pos_profile: pos_profile, + company_address: this.frm.doc.company_address }, () => { this.apply_pricing_rule(); }); diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 94a1510f095..11de9a098dc 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -859,7 +859,7 @@ class ReceivablePayableReport(object): ) else: self.qb_selection_filter.append( - self.ple[dimension.fieldname] == self.filters[dimension.fieldname] + self.ple[dimension.fieldname].isin(self.filters[dimension.fieldname]) ) def is_invoice(self, ple): diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index e4b4f2260c6..41ba11adc6f 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -501,7 +501,14 @@ class GrossProfitGenerator(object): ): returned_item_rows = self.returned_invoices[row.parent][row.item_code] for returned_item_row in returned_item_rows: - row.qty += flt(returned_item_row.qty) + # returned_items 'qty' should be stateful + if returned_item_row.qty != 0: + if row.qty >= abs(returned_item_row.qty): + row.qty += returned_item_row.qty + returned_item_row.qty = 0 + else: + row.qty = 0 + returned_item_row.qty += row.qty row.base_amount += flt(returned_item_row.base_amount, self.currency_precision) row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision) if flt(row.qty) or row.base_amount: @@ -734,6 +741,8 @@ class GrossProfitGenerator(object): if self.filters.to_date: conditions += " and posting_date <= %(to_date)s" + conditions += " and (is_return = 0 or (is_return=1 and return_against is null))" + if self.filters.item_group: conditions += " and {0}".format(get_item_group_condition(self.filters.item_group)) diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 21681bef5b5..82fe1a0ba12 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -381,3 +381,82 @@ class TestGrossProfit(FrappeTestCase): } gp_entry = [x for x in data if x.parent_invoice == sinv.name] self.assertDictContainsSubset(expected_entry, gp_entry[0]) + + def test_crnote_against_invoice_with_multiple_instances_of_same_item(self): + """ + Item Qty for Sales Invoices with multiple instances of same item go in the -ve. Ideally, the credit noteshould cancel out the invoice items. + """ + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + + # Invoice with an item added twice + sinv = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate(), do_not_submit=True) + sinv.append("items", frappe.copy_doc(sinv.items[0], ignore_no_copy=False)) + sinv = sinv.save().submit() + + # Create Credit Note for Invoice + cr_note = make_sales_return(sinv.name) + cr_note = cr_note.save().submit() + + filters = frappe._dict( + company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice" + ) + + columns, data = execute(filters=filters) + expected_entry = { + "parent_invoice": sinv.name, + "currency": "INR", + "sales_invoice": self.item, + "customer": self.customer, + "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()), + "item_code": self.item, + "item_name": self.item, + "warehouse": "Stores - _GP", + "qty": 0.0, + "avg._selling_rate": 0.0, + "valuation_rate": 0.0, + "selling_amount": -100.0, + "buying_amount": 0.0, + "gross_profit": -100.0, + "gross_profit_%": 100.0, + } + gp_entry = [x for x in data if x.parent_invoice == sinv.name] + # Both items of Invoice should have '0' qty + self.assertEqual(len(gp_entry), 2) + self.assertDictContainsSubset(expected_entry, gp_entry[0]) + self.assertDictContainsSubset(expected_entry, gp_entry[1]) + + def test_standalone_cr_notes(self): + """ + Standalone cr notes will be reported as usual + """ + # Make Cr Note + sinv = self.create_sales_invoice( + qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + sinv.is_return = 1 + sinv = sinv.save().submit() + + filters = frappe._dict( + company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice" + ) + + columns, data = execute(filters=filters) + expected_entry = { + "parent_invoice": sinv.name, + "currency": "INR", + "sales_invoice": self.item, + "customer": self.customer, + "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()), + "item_code": self.item, + "item_name": self.item, + "warehouse": "Stores - _GP", + "qty": -1.0, + "avg._selling_rate": 100.0, + "valuation_rate": 0.0, + "selling_amount": -100.0, + "buying_amount": 0.0, + "gross_profit": -100.0, + "gross_profit_%": 100.0, + } + gp_entry = [x for x in data if x.parent_invoice == sinv.name] + self.assertDictContainsSubset(expected_entry, gp_entry[0]) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 9db40658506..ee73729da0a 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -375,12 +375,19 @@ class Asset(AccountsController): 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 + 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 + ) + and ( + not self.flags.increase_in_asset_value_due_to_repair + or not finance_book.depreciation_method in ("Written Down Value", "Double Declining Balance") ) - 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 @@ -1175,17 +1182,21 @@ def get_total_days(date, frequency): @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: + # if the Depreciation Schedule is being modified after Asset Repair due to increase in asset life and value + if asset.flags.increase_in_asset_life: 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) + # if the Depreciation Schedule is being modified after Asset Repair due to increase in asset value + elif asset.flags.increase_in_asset_value_due_to_repair: + depreciation_amount = ( + flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life) + ) / flt(row.total_number_of_depreciations) + # if the Depreciation Schedule is being prepared for the first time + else: + depreciation_amount = ( + flt(asset.gross_purchase_amount) - flt(row.expected_value_after_useful_life) + ) / flt(row.total_number_of_depreciations) else: depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100)) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 9ccf778a4bb..eec7ccba754 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -39,43 +39,51 @@ class AssetRepair(AccountsController): def before_submit(self): self.check_repair_status() - if self.get("stock_consumption") or self.get("capitalize_repair_cost"): - self.increase_asset_value() - if self.get("stock_consumption"): - self.check_for_stock_items_and_warehouse() - self.decrease_stock_quantity() - if self.get("capitalize_repair_cost"): - self.make_gl_entries() - if ( - frappe.db.get_value("Asset", self.asset, "calculate_depreciation") - and self.increase_in_asset_life - ): - self.modify_depreciation_schedule() + self.asset_doc.flags.increase_in_asset_value_due_to_repair = False - self.asset_doc.flags.ignore_validate_update_after_submit = True - self.asset_doc.prepare_depreciation_data() - self.asset_doc.save() + if self.get("stock_consumption") or self.get("capitalize_repair_cost"): + self.asset_doc.flags.increase_in_asset_value_due_to_repair = True + + self.increase_asset_value() + + if self.get("stock_consumption"): + self.check_for_stock_items_and_warehouse() + self.decrease_stock_quantity() + if self.get("capitalize_repair_cost"): + self.make_gl_entries() + if self.asset_doc.calculate_depreciation and self.increase_in_asset_life: + self.modify_depreciation_schedule() + + self.asset_doc.flags.ignore_validate_update_after_submit = True + self.asset_doc.prepare_depreciation_data() + if self.asset_doc.calculate_depreciation: + self.update_asset_expected_value_after_useful_life() + self.asset_doc.save() def before_cancel(self): self.asset_doc = frappe.get_doc("Asset", self.asset) - if self.get("stock_consumption") or self.get("capitalize_repair_cost"): - self.decrease_asset_value() - if self.get("stock_consumption"): - self.increase_stock_quantity() - if self.get("capitalize_repair_cost"): - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") - self.make_gl_entries(cancel=True) - self.db_set("stock_entry", None) - if ( - frappe.db.get_value("Asset", self.asset, "calculate_depreciation") - and self.increase_in_asset_life - ): - self.revert_depreciation_schedule_on_cancellation() + self.asset_doc.flags.increase_in_asset_value_due_to_repair = False - self.asset_doc.flags.ignore_validate_update_after_submit = True - self.asset_doc.prepare_depreciation_data() - self.asset_doc.save() + if self.get("stock_consumption") or self.get("capitalize_repair_cost"): + self.asset_doc.flags.increase_in_asset_value_due_to_repair = True + + self.decrease_asset_value() + + if self.get("stock_consumption"): + self.increase_stock_quantity() + if self.get("capitalize_repair_cost"): + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") + self.make_gl_entries(cancel=True) + self.db_set("stock_entry", None) + if self.asset_doc.calculate_depreciation and self.increase_in_asset_life: + self.revert_depreciation_schedule_on_cancellation() + + self.asset_doc.flags.ignore_validate_update_after_submit = True + self.asset_doc.prepare_depreciation_data() + if self.asset_doc.calculate_depreciation: + self.update_asset_expected_value_after_useful_life() + self.asset_doc.save() def after_delete(self): frappe.get_doc("Asset", self.asset).set_status() @@ -95,6 +103,26 @@ class AssetRepair(AccountsController): title=_("Missing Warehouse"), ) + def update_asset_expected_value_after_useful_life(self): + for row in self.asset_doc.get("finance_books"): + if row.depreciation_method in ("Written Down Value", "Double Declining Balance"): + accumulated_depreciation_after_full_schedule = [ + d.accumulated_depreciation_amount + for d in self.asset_doc.get("schedules") + if cint(d.finance_book_id) == row.idx + ] + + accumulated_depreciation_after_full_schedule = max( + accumulated_depreciation_after_full_schedule + ) + + asset_value_after_full_schedule = flt( + flt(row.value_after_depreciation) - flt(accumulated_depreciation_after_full_schedule), + row.precision("expected_value_after_useful_life"), + ) + + row.expected_value_after_useful_life = asset_value_after_full_schedule + def increase_asset_value(self): total_value_of_stock_consumed = self.get_total_value_of_stock_consumed() diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 95857e4604d..8c73e56a99e 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -16,6 +16,7 @@ "transaction_settings_section", "po_required", "pr_required", + "over_order_allowance", "column_break_12", "maintain_same_rate", "set_landed_cost_based_on_purchase_invoice_rate", @@ -156,6 +157,13 @@ "fieldname": "set_landed_cost_based_on_purchase_invoice_rate", "fieldtype": "Check", "label": "Set Landed Cost Based on Purchase Invoice Rate" + }, + { + "default": "0", + "description": "Percentage you are allowed to order more against the Blanket Order Quantity. For example: If you have a Blanket Order of Quantity 100 units. and your Allowance is 10% then you are allowed to order 110 units.", + "fieldname": "over_order_allowance", + "fieldtype": "Float", + "label": "Over Order Allowance (%)" } ], "icon": "fa fa-cog", @@ -163,7 +171,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-02-28 15:41:32.686805", + "modified": "2023-03-02 17:02:14.404622", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 2415aec8cb9..06b9d29e69c 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -21,6 +21,9 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category from erpnext.accounts.party import get_party_account, get_party_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( + validate_against_blanket_order, +) from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.item.item import get_item_defaults, get_last_purchase_details from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty @@ -69,6 +72,7 @@ class PurchaseOrder(BuyingController): self.validate_with_previous_doc() self.validate_for_subcontracting() self.validate_minimum_order_qty() + validate_against_blanket_order(self) if self.is_old_subcontracting_flow: self.validate_bom_for_subcontracting_items() diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 8c403aa9bfe..1edd7bf85e1 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -24,11 +24,19 @@ class calculate_taxes_and_totals(object): def __init__(self, doc: Document): self.doc = doc frappe.flags.round_off_applicable_accounts = [] + + self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items") + get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts) self.calculate() + def filter_rows(self): + """Exclude rows, that do not fulfill the filter criteria, from totals computation.""" + items = list(filter(lambda item: not item.get("is_alternative"), self.doc.get("items"))) + return items + def calculate(self): - if not len(self.doc.get("items")): + if not len(self._items): return self.discount_amount_applied = False @@ -70,7 +78,7 @@ class calculate_taxes_and_totals(object): if hasattr(self.doc, "tax_withholding_net_total"): sum_net_amount = 0 sum_base_net_amount = 0 - for item in self.doc.get("items"): + for item in self._items: if hasattr(item, "apply_tds") and item.apply_tds: sum_net_amount += item.net_amount sum_base_net_amount += item.base_net_amount @@ -79,7 +87,7 @@ class calculate_taxes_and_totals(object): self.doc.base_tax_withholding_net_total = sum_base_net_amount def validate_item_tax_template(self): - for item in self.doc.get("items"): + for item in self._items: if item.item_code and item.get("item_tax_template"): item_doc = frappe.get_cached_doc("Item", item.item_code) args = { @@ -137,7 +145,7 @@ class calculate_taxes_and_totals(object): return if not self.discount_amount_applied: - for item in self.doc.get("items"): + for item in self._items: self.doc.round_floats_in(item) if item.discount_percentage == 100: @@ -236,7 +244,7 @@ class calculate_taxes_and_totals(object): if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")): return - for item in self.doc.get("items"): + for item in self._items: item_tax_map = self._load_item_tax_rate(item.item_tax_rate) cumulated_tax_fraction = 0 total_inclusive_tax_amount_per_qty = 0 @@ -317,7 +325,7 @@ class calculate_taxes_and_totals(object): self.doc.total ) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0 - for item in self.doc.get("items"): + for item in self._items: self.doc.total += item.amount self.doc.total_qty += item.qty self.doc.base_total += item.base_amount @@ -354,7 +362,7 @@ class calculate_taxes_and_totals(object): ] ) - for n, item in enumerate(self.doc.get("items")): + for n, item in enumerate(self._items): item_tax_map = self._load_item_tax_rate(item.item_tax_rate) for i, tax in enumerate(self.doc.get("taxes")): # tax_amount represents the amount of tax for the current step @@ -363,7 +371,7 @@ class calculate_taxes_and_totals(object): # Adjust divisional loss to the last item if tax.charge_type == "Actual": actual_tax_dict[tax.idx] -= current_tax_amount - if n == len(self.doc.get("items")) - 1: + if n == len(self._items) - 1: current_tax_amount += actual_tax_dict[tax.idx] # accumulate tax amount into tax.tax_amount @@ -391,7 +399,7 @@ class calculate_taxes_and_totals(object): ) # set precision in the last item iteration - if n == len(self.doc.get("items")) - 1: + if n == len(self._items) - 1: self.round_off_totals(tax) self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"]) @@ -570,7 +578,7 @@ class calculate_taxes_and_totals(object): def calculate_total_net_weight(self): if self.doc.meta.get_field("total_net_weight"): self.doc.total_net_weight = 0.0 - for d in self.doc.items: + for d in self._items: if d.total_weight: self.doc.total_net_weight += d.total_weight @@ -630,7 +638,7 @@ class calculate_taxes_and_totals(object): if total_for_discount_amount: # calculate item amount after Discount Amount - for i, item in enumerate(self.doc.get("items")): + for i, item in enumerate(self._items): distributed_amount = ( flt(self.doc.discount_amount) * item.net_amount / total_for_discount_amount ) @@ -643,7 +651,7 @@ class calculate_taxes_and_totals(object): self.doc.apply_discount_on == "Net Total" or not taxes or total_for_discount_amount == self.doc.net_total - ) and i == len(self.doc.get("items")) - 1: + ) and i == len(self._items) - 1: discount_amount_loss = flt( self.doc.net_total - net_total - self.doc.discount_amount, self.doc.precision("net_total") ) diff --git a/erpnext/e_commerce/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py index df62c23aa48..1a3e7379281 100644 --- a/erpnext/e_commerce/variant_selector/utils.py +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -1,5 +1,5 @@ import frappe -from frappe.utils import cint +from frappe.utils import cint, flt from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( get_shopping_cart_settings, @@ -166,6 +166,27 @@ def get_next_attribute_and_values(item_code, selected_attributes): else: product_info = None + product_id = "" + website_warehouse = "" + if exact_match or filtered_items: + if exact_match and len(exact_match) == 1: + product_id = exact_match[0] + elif filtered_items_count == 1: + product_id = list(filtered_items)[0] + + if product_id: + website_warehouse = frappe.get_cached_value( + "Website Item", {"item_code": product_id}, "website_warehouse" + ) + + available_qty = 0.0 + if website_warehouse: + available_qty = flt( + frappe.db.get_value( + "Bin", {"item_code": product_id, "warehouse": website_warehouse}, "actual_qty" + ) + ) + return { "next_attribute": next_attribute, "valid_options_for_attributes": valid_options_for_attributes, @@ -173,6 +194,7 @@ def get_next_attribute_and_values(item_code, selected_attributes): "filtered_items": filtered_items if filtered_items_count < 10 else [], "exact_match": exact_match, "product_info": product_info, + "available_qty": available_qty, } diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index 38d69932f24..f44fad333cf 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -12,7 +12,7 @@ class PlaidConnector: def __init__(self, access_token=None): self.access_token = access_token self.settings = frappe.get_single("Plaid Settings") - self.products = ["auth", "transactions"] + self.products = ["transactions"] self.client_name = frappe.local.site self.client = plaid.Client( client_id=self.settings.plaid_client_id, diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js index 3740d049839..3ba6bb99873 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js @@ -47,7 +47,7 @@ erpnext.integrations.plaidLink = class plaidLink { } async init_config() { - this.product = ["auth", "transactions"]; + this.product = ["transactions"]; this.plaid_env = this.frm.doc.plaid_env; this.client_name = frappe.boot.sitename; this.token = await this.get_link_token(); diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 62ea85fc5d2..f3aa6a37935 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -70,7 +70,8 @@ def add_bank_accounts(response, bank, company): except TypeError: pass - bank = json.loads(bank) + if isinstance(bank, str): + bank = json.loads(bank) result = [] default_gl_account = get_default_bank_cash_account(company, "Bank") @@ -177,16 +178,15 @@ def sync_transactions(bank, bank_account): ) result = [] - for transaction in reversed(transactions): - result += new_bank_transaction(transaction) + if transactions: + for transaction in reversed(transactions): + result += new_bank_transaction(transaction) if result: last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date") frappe.logger().info( - "Plaid added {} new Bank Transactions from '{}' between {} and {}".format( - len(result), bank_account, start_date, end_date - ) + f"Plaid added {len(result)} new Bank Transactions from '{bank_account}' between {start_date} and {end_date}" ) frappe.db.set_value( @@ -230,19 +230,20 @@ def new_bank_transaction(transaction): bank_account = frappe.db.get_value("Bank Account", dict(integration_id=transaction["account_id"])) - if float(transaction["amount"]) >= 0: - debit = 0 - credit = float(transaction["amount"]) + amount = float(transaction["amount"]) + if amount >= 0.0: + deposit = 0.0 + withdrawal = amount else: - debit = abs(float(transaction["amount"])) - credit = 0 + deposit = abs(amount) + withdrawal = 0.0 status = "Pending" if transaction["pending"] == "True" else "Settled" tags = [] try: tags += transaction["category"] - tags += ["Plaid Cat. {}".format(transaction["category_id"])] + tags += [f'Plaid Cat. {transaction["category_id"]}'] except KeyError: pass @@ -254,11 +255,18 @@ def new_bank_transaction(transaction): "date": getdate(transaction["date"]), "status": status, "bank_account": bank_account, - "deposit": debit, - "withdrawal": credit, + "deposit": deposit, + "withdrawal": withdrawal, "currency": transaction["iso_currency_code"], "transaction_id": transaction["transaction_id"], - "reference_number": transaction["payment_meta"]["reference_number"], + "transaction_type": ( + transaction["transaction_code"] or transaction["payment_meta"]["payment_method"] + ), + "reference_number": ( + transaction["check_number"] + or transaction["payment_meta"]["reference_number"] + or transaction["name"] + ), "description": transaction["name"], } ) @@ -271,7 +279,7 @@ def new_bank_transaction(transaction): result.append(new_transaction.name) except Exception: - frappe.throw(title=_("Bank transaction creation error")) + frappe.throw(_("Bank transaction creation error")) return result @@ -300,3 +308,26 @@ def enqueue_synchronization(): def get_link_token_for_update(access_token): plaid = PlaidConnector(access_token) return plaid.get_link_token(update_mode=True) + + +def get_company(bank_account_name): + from frappe.defaults import get_user_default + + company_names = frappe.db.get_all("Company", pluck="name") + if len(company_names) == 1: + return company_names[0] + if frappe.db.exists("Bank Account", bank_account_name): + return frappe.db.get_value("Bank Account", bank_account_name, "company") + company_default = get_user_default("Company") + if company_default: + return company_default + frappe.throw(_("Could not detect the Company for updating Bank Accounts")) + + +@frappe.whitelist() +def update_bank_account_ids(response): + data = json.loads(response) + institution_name = data["institution"]["name"] + bank = frappe.get_doc("Bank", institution_name).as_dict() + bank_account_name = f"{data['account']['name']} - {institution_name}" + return add_bank_accounts(response, bank, get_company(bank_account_name)) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py index e8dc3e258f6..6d34a204cd2 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py @@ -125,6 +125,8 @@ class TestPlaidSettings(unittest.TestCase): "unofficial_currency_code": None, "name": "INTRST PYMNT", "transaction_type": "place", + "transaction_code": "direct debit", + "check_number": "3456789", "amount": -4.22, "location": { "city": None, diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js index d3bb33e86e0..7b26a14a57b 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js @@ -7,6 +7,12 @@ frappe.ui.form.on('Blanket Order', { }, setup: function(frm) { + frm.custom_make_buttons = { + 'Purchase Order': 'Purchase Order', + 'Sales Order': 'Sales Order', + 'Quotation': 'Quotation', + }; + frm.add_fetch("customer", "customer_name", "customer_name"); frm.add_fetch("supplier", "supplier_name", "supplier_name"); }, diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py index ff2140199de..32f1c365ade 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc +from frappe.query_builder.functions import Sum from frappe.utils import flt, getdate from erpnext.stock.doctype.item.item import get_item_defaults @@ -29,21 +30,23 @@ class BlanketOrder(Document): def update_ordered_qty(self): ref_doctype = "Sales Order" if self.blanket_order_type == "Selling" else "Purchase Order" + + trans = frappe.qb.DocType(ref_doctype) + trans_item = frappe.qb.DocType(f"{ref_doctype} Item") + item_ordered_qty = frappe._dict( - frappe.db.sql( - """ - select trans_item.item_code, sum(trans_item.stock_qty) as qty - from `tab{0} Item` trans_item, `tab{0}` trans - where trans.name = trans_item.parent - and trans_item.blanket_order=%s - and trans.docstatus=1 - and trans.status not in ('Closed', 'Stopped') - group by trans_item.item_code - """.format( - ref_doctype - ), - self.name, - ) + ( + frappe.qb.from_(trans_item) + .from_(trans) + .select(trans_item.item_code, Sum(trans_item.stock_qty).as_("qty")) + .where( + (trans.name == trans_item.parent) + & (trans_item.blanket_order == self.name) + & (trans.docstatus == 1) + & (trans.status.notin(["Stopped", "Closed"])) + ) + .groupby(trans_item.item_code) + ).run() ) for d in self.items: @@ -79,7 +82,43 @@ def make_order(source_name): "doctype": doctype + " Item", "field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"}, "postprocess": update_item, + "condition": lambda item: (flt(item.qty) - flt(item.ordered_qty)) > 0, }, }, ) return target_doc + + +def validate_against_blanket_order(order_doc): + if order_doc.doctype in ("Sales Order", "Purchase Order"): + order_data = {} + + for item in order_doc.get("items"): + if item.against_blanket_order and item.blanket_order: + if item.blanket_order in order_data: + if item.item_code in order_data[item.blanket_order]: + order_data[item.blanket_order][item.item_code] += item.qty + else: + order_data[item.blanket_order][item.item_code] = item.qty + else: + order_data[item.blanket_order] = {item.item_code: item.qty} + + if order_data: + allowance = flt( + frappe.db.get_single_value( + "Selling Settings" if order_doc.doctype == "Sales Order" else "Buying Settings", + "over_order_allowance", + ) + ) + for bo_name, item_data in order_data.items(): + bo_doc = frappe.get_doc("Blanket Order", bo_name) + for item in bo_doc.get("items"): + if item.item_code in item_data: + remaining_qty = item.qty - item.ordered_qty + allowed_qty = remaining_qty + (remaining_qty * (allowance / 100)) + if allowed_qty < item_data[item.item_code]: + frappe.throw( + _("Item {0} cannot be ordered more than {1} against Blanket Order {2}.").format( + item.item_code, allowed_qty, bo_name + ) + ) diff --git a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py index 2f1f3ae0f52..58f3c950598 100644 --- a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py @@ -63,6 +63,33 @@ class TestBlanketOrder(FrappeTestCase): po1.currency = get_company_currency(po1.company) self.assertEqual(po1.items[0].qty, (bo.items[0].qty - bo.items[0].ordered_qty)) + def test_over_order_allowance(self): + # Sales Order + bo = make_blanket_order(blanket_order_type="Selling", quantity=100) + + frappe.flags.args.doctype = "Sales Order" + so = make_order(bo.name) + so.currency = get_company_currency(so.company) + so.delivery_date = today() + so.items[0].qty = 110 + self.assertRaises(frappe.ValidationError, so.submit) + + frappe.db.set_single_value("Selling Settings", "over_order_allowance", 10) + so.submit() + + # Purchase Order + bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100) + + frappe.flags.args.doctype = "Purchase Order" + po = make_order(bo.name) + po.currency = get_company_currency(po.company) + po.schedule_date = today() + po.items[0].qty = 110 + self.assertRaises(frappe.ValidationError, po.submit) + + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 10) + po.submit() + def make_blanket_order(**args): args = frappe._dict(args) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index d60feb2b391..01bf2e4315f 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -6,7 +6,7 @@ from collections import deque from functools import partial import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, timeout from frappe.utils import cstr, flt from erpnext.controllers.tests.test_subcontracting_controller import ( @@ -27,6 +27,7 @@ test_dependencies = ["Item", "Quality Inspection Template"] class TestBOM(FrappeTestCase): + @timeout def test_get_items(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict @@ -37,6 +38,7 @@ class TestBOM(FrappeTestCase): self.assertTrue(test_records[2]["items"][1]["item_code"] in items_dict) self.assertEqual(len(items_dict.values()), 2) + @timeout def test_get_items_exploded(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict @@ -49,11 +51,13 @@ class TestBOM(FrappeTestCase): self.assertTrue(test_records[0]["items"][1]["item_code"] in items_dict) self.assertEqual(len(items_dict.values()), 3) + @timeout def test_get_items_list(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items self.assertEqual(len(get_bom_items(bom=get_default_bom(), company="_Test Company")), 3) + @timeout def test_default_bom(self): def _get_default_bom_in_item(): return cstr(frappe.db.get_value("Item", "_Test FG Item 2", "default_bom")) @@ -71,6 +75,7 @@ class TestBOM(FrappeTestCase): self.assertTrue(_get_default_bom_in_item(), bom.name) + @timeout def test_update_bom_cost_in_all_boms(self): # get current rate for '_Test Item 2' bom_rates = frappe.db.get_values( @@ -99,6 +104,7 @@ class TestBOM(FrappeTestCase): ): self.assertEqual(d.base_rate, rm_base_rate + 10) + @timeout def test_bom_cost(self): bom = frappe.copy_doc(test_records[2]) bom.insert() @@ -127,6 +133,7 @@ class TestBOM(FrappeTestCase): self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost) self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) + @timeout def test_bom_cost_with_batch_size(self): bom = frappe.copy_doc(test_records[2]) bom.docstatus = 0 @@ -145,6 +152,7 @@ class TestBOM(FrappeTestCase): self.assertAlmostEqual(bom.operating_cost, op_cost / 2) bom.delete() + @timeout def test_bom_cost_multi_uom_multi_currency_based_on_price_list(self): frappe.db.set_value("Price List", "_Test Price List", "price_not_uom_dependent", 1) for item_code, rate in (("_Test Item", 3600), ("_Test Item Home Desktop Manufactured", 3000)): @@ -181,6 +189,7 @@ class TestBOM(FrappeTestCase): self.assertEqual(bom.base_raw_material_cost, 27000) self.assertEqual(bom.base_total_cost, 33000) + @timeout def test_bom_cost_multi_uom_based_on_valuation_rate(self): bom = frappe.copy_doc(test_records[2]) bom.set_rate_of_sub_assembly_item_based_on_bom = 0 @@ -202,6 +211,7 @@ class TestBOM(FrappeTestCase): self.assertEqual(bom.items[0].rate, 20) + @timeout def test_bom_cost_with_fg_based_operating_cost(self): bom = frappe.copy_doc(test_records[4]) bom.insert() @@ -229,6 +239,7 @@ class TestBOM(FrappeTestCase): self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost) self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) + @timeout def test_subcontractor_sourced_item(self): item_code = "_Test Subcontracted FG Item 1" set_backflush_based_on("Material Transferred for Subcontract") @@ -310,6 +321,7 @@ class TestBOM(FrappeTestCase): supplied_items = sorted([d.rm_item_code for d in sco.supplied_items]) self.assertEqual(bom_items, supplied_items) + @timeout def test_bom_tree_representation(self): bom_tree = { "Assembly": { @@ -335,6 +347,7 @@ class TestBOM(FrappeTestCase): for reqd_item, created_item in zip(reqd_order, created_order): self.assertEqual(reqd_item, created_item.item_code) + @timeout def test_generated_variant_bom(self): from erpnext.controllers.item_variant import create_variant @@ -375,6 +388,7 @@ class TestBOM(FrappeTestCase): self.assertEqual(reqd_item.qty, created_item.qty) self.assertEqual(reqd_item.exploded_qty, created_item.exploded_qty) + @timeout def test_bom_recursion_1st_level(self): """BOM should not allow BOM item again in child""" item_code = make_item(properties={"is_stock_item": 1}).name @@ -387,6 +401,7 @@ class TestBOM(FrappeTestCase): bom.items[0].bom_no = bom.name bom.save() + @timeout def test_bom_recursion_transitive(self): item1 = make_item(properties={"is_stock_item": 1}).name item2 = make_item(properties={"is_stock_item": 1}).name @@ -408,6 +423,7 @@ class TestBOM(FrappeTestCase): bom1.save() bom2.save() + @timeout def test_bom_with_process_loss_item(self): fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items() @@ -421,6 +437,7 @@ class TestBOM(FrappeTestCase): # Items with whole UOMs can't be PL Items self.assertRaises(frappe.ValidationError, bom_doc.submit) + @timeout def test_bom_item_query(self): query = partial( item_query, @@ -440,6 +457,7 @@ class TestBOM(FrappeTestCase): ) self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results") + @timeout def test_exclude_exploded_items_from_bom(self): bom_no = get_default_bom() new_bom = frappe.copy_doc(frappe.get_doc("BOM", bom_no)) @@ -458,6 +476,7 @@ class TestBOM(FrappeTestCase): new_bom.delete() + @timeout def test_valid_transfer_defaults(self): bom_with_op = frappe.db.get_value( "BOM", {"item": "_Test FG Item 2", "with_operations": 1, "is_active": 1} @@ -489,11 +508,13 @@ class TestBOM(FrappeTestCase): self.assertEqual(bom.transfer_material_against, "Work Order") bom.delete() + @timeout def test_bom_name_length(self): """test >140 char names""" bom_tree = {"x" * 140: {" ".join(["abc"] * 35): {}}} create_nested_bom(bom_tree, prefix="") + @timeout def test_version_index(self): bom = frappe.new_doc("BOM") @@ -515,6 +536,7 @@ class TestBOM(FrappeTestCase): msg=f"Incorrect index for {existing_boms}", ) + @timeout def test_bom_versioning(self): bom_tree = {frappe.generate_hash(length=10): {frappe.generate_hash(length=10): {}}} bom = create_nested_bom(bom_tree, prefix="") @@ -547,6 +569,7 @@ class TestBOM(FrappeTestCase): self.assertNotEqual(amendment.name, version.name) self.assertEqual(int(version.name.split("-")[-1]), 2) + @timeout def test_clear_inpection_quality(self): bom = frappe.copy_doc(test_records[2], ignore_no_copy=True) @@ -565,6 +588,7 @@ class TestBOM(FrappeTestCase): self.assertEqual(bom.quality_inspection_template, None) + @timeout def test_bom_pricing_based_on_lpp(self): from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -585,6 +609,7 @@ class TestBOM(FrappeTestCase): bom.submit() self.assertEqual(bom.items[0].rate, 42) + @timeout def test_set_default_bom_for_item_having_single_bom(self): from erpnext.stock.doctype.item.test_item import make_item @@ -621,6 +646,7 @@ class TestBOM(FrappeTestCase): bom.reload() self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name) + @timeout def test_exploded_items_rate(self): rm_item = make_item( properties={"is_stock_item": 1, "valuation_rate": 99, "last_purchase_rate": 89} @@ -649,6 +675,7 @@ class TestBOM(FrappeTestCase): bom.submit() self.assertEqual(bom.exploded_items[0].rate, bom.items[0].base_rate) + @timeout def test_bom_cost_update_flag(self): rm_item = make_item( properties={"is_stock_item": 1, "valuation_rate": 99, "last_purchase_rate": 89} diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py index 5dd557f8ab1..2026f629147 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py @@ -2,7 +2,7 @@ # License: GNU General Public License v3. See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, timeout from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import ( update_cost_in_all_boms_in_test, @@ -20,6 +20,7 @@ class TestBOMUpdateTool(FrappeTestCase): def tearDown(self): frappe.db.rollback() + @timeout def test_replace_bom(self): current_bom = "BOM-_Test Item Home Desktop Manufactured-001" @@ -33,6 +34,7 @@ class TestBOMUpdateTool(FrappeTestCase): self.assertFalse(frappe.db.exists("BOM Item", {"bom_no": current_bom, "docstatus": 1})) self.assertTrue(frappe.db.exists("BOM Item", {"bom_no": bom_doc.name, "docstatus": 1})) + @timeout def test_bom_cost(self): for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]: item_doc = create_item(item, valuation_rate=100) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 3c0eb5107ce..5803f46dea3 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -325,5 +325,6 @@ erpnext.patches.v14_0.update_entry_type_for_journal_entry erpnext.patches.v14_0.change_autoname_for_tax_withheld_vouchers erpnext.patches.v14_0.update_asset_value_for_manual_depr_entries erpnext.patches.v14_0.set_pick_list_status +erpnext.patches.v13_0.update_docs_link # below migration patches should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger diff --git a/erpnext/patches/v13_0/update_docs_link.py b/erpnext/patches/v13_0/update_docs_link.py new file mode 100644 index 00000000000..4bc5c053d27 --- /dev/null +++ b/erpnext/patches/v13_0/update_docs_link.py @@ -0,0 +1,14 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + + +import frappe + + +def execute(): + navbar_settings = frappe.get_single("Navbar Settings") + for item in navbar_settings.help_dropdown: + if item.is_standard and item.route == "https://erpnext.com/docs/user/manual": + item.route = "https://docs.erpnext.com/docs/v14/user/manual/en/introduction" + + navbar_settings.save() diff --git a/erpnext/patches/v14_0/update_opportunity_currency_fields.py b/erpnext/patches/v14_0/update_opportunity_currency_fields.py index b803e9fa2dd..af736919d83 100644 --- a/erpnext/patches/v14_0/update_opportunity_currency_fields.py +++ b/erpnext/patches/v14_0/update_opportunity_currency_fields.py @@ -7,6 +7,9 @@ from erpnext.setup.utils import get_exchange_rate def execute(): + frappe.reload_doc( + "accounts", "doctype", "currency_exchange_settings" + ) # get_exchange_rate depends on Currency Exchange Settings frappe.reload_doctype("Opportunity") opportunities = frappe.db.get_list( "Opportunity", diff --git a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js index f7c19a1b7ff..0cda93880fa 100644 --- a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js @@ -182,6 +182,9 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager { ); } else { this.transactions.splice(transaction_index, 1); + for (const [k, v] of Object.entries(this.transaction_dt_map)) { + if (v > transaction_index) this.transaction_dt_map[k] = v - 1; + } } this.datatable.refresh(this.transactions, this.columns); diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index 911343d8b64..1271e38049a 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -20,7 +20,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { doctype: "Bank Transaction", filters: { name: this.bank_transaction_name }, fieldname: [ - "date as reference_date", + "date", "deposit", "withdrawal", "currency", @@ -33,6 +33,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { "party", "unallocated_amount", "allocated_amount", + "transaction_type", ], }, callback: (r) => { @@ -41,11 +42,23 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { r.message.payment_entry = 1; r.message.journal_entry = 1; this.dialog.set_values(r.message); + this.copy_data_to_voucher(); this.dialog.show(); } }, }); } + + copy_data_to_voucher() { + let copied = { + reference_number: this.bank_transaction.reference_number || this.bank_transaction.description, + posting_date: this.bank_transaction.date, + reference_date: this.bank_transaction.date, + mode_of_payment: this.bank_transaction.transaction_type, + }; + this.dialog.set_values(copied); + } + get_linked_vouchers(document_types) { frappe.call({ method: @@ -75,10 +88,9 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { row[1], row[2], reference_date, - row[8], format_currency(row[3], row[9]), - row[6], row[4], + row[6], ]); }); this.get_dt_columns(); @@ -104,7 +116,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { { name: __("Document Name"), editable: false, - width: 150, + width: 1, }, { name: __("Reference Date"), @@ -112,25 +124,19 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { width: 120, }, { - name: "Posting Date", - editable: false, - width: 120, - }, - { - name: __("Amount"), + name: __("Remaining"), editable: false, width: 100, }, - { - name: __("Party"), - editable: false, - width: 120, - }, - { name: __("Reference Number"), editable: false, - width: 140, + width: 200, + }, + { + name: __("Party"), + editable: false, + width: 100, }, ]; } @@ -224,6 +230,16 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "exact_match", onchange: () => this.update_options(), }, + { + fieldname: "column_break_5", + fieldtype: "Column Break", + }, + { + fieldtype: "Check", + label: "Bank Transaction", + fieldname: "bank_transaction", + onchange: () => this.update_options(), + }, { fieldtype: "Section Break", fieldname: "section_break_1", @@ -289,7 +305,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldtype: "Column Break", }, { - default: "Journal Entry Type", + default: "Bank Entry", fieldname: "journal_entry_type", fieldtype: "Select", label: "Journal Entry Type", @@ -364,20 +380,30 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldtype: "Section Break", fieldname: "details_section", label: "Transaction Details", - collapsible: 1, + }, + { + fieldname: "date", + fieldtype: "Date", + label: "Date", + read_only: 1, }, { fieldname: "deposit", fieldtype: "Currency", label: "Deposit", - options: "currency", + options: "account_currency", read_only: 1, }, { fieldname: "withdrawal", fieldtype: "Currency", label: "Withdrawal", - options: "currency", + options: "account_currency", + read_only: 1, + }, + { + fieldname: "column_break_17", + fieldtype: "Column Break", read_only: 1, }, { @@ -386,28 +412,22 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { label: "Description", read_only: 1, }, - { - fieldname: "column_break_17", - fieldtype: "Column Break", - read_only: 1, - }, { fieldname: "allocated_amount", fieldtype: "Currency", label: "Allocated Amount", - options: "Currency", + options: "account_currency", read_only: 1, }, - { fieldname: "unallocated_amount", fieldtype: "Currency", label: "Unallocated Amount", - options: "Currency", + options: "account_currency", read_only: 1, }, { - fieldname: "currency", + fieldname: "account_currency", fieldtype: "Link", label: "Currency", options: "Currency", @@ -593,4 +613,4 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { } } -}; \ No newline at end of file +}; diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 974b937fa26..8e57ebd3677 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -91,6 +91,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } _calculate_taxes_and_totals() { + const is_quotation = this.frm.doc.doctype == "Quotation"; + this.frm.doc._items = is_quotation ? this.filtered_items() : this.frm.doc.items; + this.validate_conversion_rate(); this.calculate_item_values(); this.initialize_taxes(); @@ -122,7 +125,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { calculate_item_values() { var me = this; if (!this.discount_amount_applied) { - for (const item of this.frm.doc.items || []) { + for (const item of this.frm.doc._items || []) { frappe.model.round_floats_in(item); item.net_rate = item.rate; item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty; @@ -132,7 +135,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } else { // allow for '0' qty on Credit/Debit notes - let qty = item.qty || -1 + let qty = item.qty || me.frm.doc.is_debit_note ? 1 : -1; item.net_amount = item.amount = flt(item.rate * qty, precision("amount", item)); } @@ -206,7 +209,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { }); if(has_inclusive_tax==false) return; - $.each(me.frm.doc["items"] || [], function(n, item) { + $.each(me.frm.doc._items || [], function(n, item) { var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); var cumulated_tax_fraction = 0.0; var total_inclusive_tax_amount_per_qty = 0; @@ -277,7 +280,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { var me = this; this.frm.doc.total_qty = this.frm.doc.total = this.frm.doc.base_total = this.frm.doc.net_total = this.frm.doc.base_net_total = 0.0; - $.each(this.frm.doc["items"] || [], function(i, item) { + $.each(this.frm.doc._items || [], function(i, item) { me.frm.doc.total += item.amount; me.frm.doc.total_qty += item.qty; me.frm.doc.base_total += item.base_amount; @@ -330,7 +333,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } }); - $.each(this.frm.doc["items"] || [], function(n, item) { + $.each(this.frm.doc._items || [], function(n, item) { var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); $.each(me.frm.doc["taxes"] || [], function(i, tax) { // tax_amount represents the amount of tax for the current step @@ -339,7 +342,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { // Adjust divisional loss to the last item if (tax.charge_type == "Actual") { actual_tax_dict[tax.idx] -= current_tax_amount; - if (n == me.frm.doc["items"].length - 1) { + if (n == me.frm.doc._items.length - 1) { current_tax_amount += actual_tax_dict[tax.idx]; } } @@ -376,7 +379,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } // set precision in the last item iteration - if (n == me.frm.doc["items"].length - 1) { + if (n == me.frm.doc._items.length - 1) { me.round_off_totals(tax); me.set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"]); @@ -599,10 +602,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { _cleanup() { this.frm.doc.base_in_words = this.frm.doc.in_words = ""; + let items = this.frm.doc._items; - if(this.frm.doc["items"] && this.frm.doc["items"].length) { - if(!frappe.meta.get_docfield(this.frm.doc["items"][0].doctype, "item_tax_amount", this.frm.doctype)) { - $.each(this.frm.doc["items"] || [], function(i, item) { + if(items && items.length) { + if(!frappe.meta.get_docfield(items[0].doctype, "item_tax_amount", this.frm.doctype)) { + $.each(items || [], function(i, item) { delete item["item_tax_amount"]; }); } @@ -655,7 +659,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { var net_total = 0; // calculate item amount after Discount Amount if (total_for_discount_amount) { - $.each(this.frm.doc["items"] || [], function(i, item) { + $.each(this.frm.doc._items || [], function(i, item) { distributed_amount = flt(me.frm.doc.discount_amount) * item.net_amount / total_for_discount_amount; item.net_amount = flt(item.net_amount - distributed_amount, precision("base_amount", item)); @@ -663,7 +667,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { // discount amount rounding loss adjustment if no taxes if ((!(me.frm.doc.taxes || []).length || total_for_discount_amount==me.frm.doc.net_total || (me.frm.doc.apply_discount_on == "Net Total")) - && i == (me.frm.doc.items || []).length - 1) { + && i == (me.frm.doc._items || []).length - 1) { var discount_amount_loss = flt(me.frm.doc.net_total - net_total - me.frm.doc.discount_amount, precision("net_total")); item.net_amount = flt(item.net_amount + discount_amount_loss, @@ -892,4 +896,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } } + + filtered_items() { + return this.frm.doc.items.filter(item => !item["is_alternative"]); + } }; diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index b348bd35754..81ef44d53ed 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -90,7 +90,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. || frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) { this.frm.add_custom_button( __("Sales Order"), - this.frm.cscript["Make Sales Order"], + () => this.make_sales_order(), __("Create") ); } @@ -145,6 +145,20 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. } + make_sales_order() { + var me = this; + + let has_alternative_item = this.frm.doc.items.some((item) => item.is_alternative); + if (has_alternative_item) { + this.show_alternative_items_dialog(); + } else { + frappe.model.open_mapped_doc({ + method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", + frm: me.frm + }); + } + } + set_dynamic_field_label(){ if (this.frm.doc.quotation_to == "Customer") { @@ -220,17 +234,111 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. } }) } + + show_alternative_items_dialog() { + let me = this; + + const table_fields = [ + { + fieldtype:"Data", + fieldname:"name", + label: __("Name"), + read_only: 1, + }, + { + fieldtype:"Link", + fieldname:"item_code", + options: "Item", + label: __("Item Code"), + read_only: 1, + in_list_view: 1, + columns: 2, + formatter: (value, df, options, doc) => { + return doc.is_alternative ? `${value}` : value; + } + }, + { + fieldtype:"Data", + fieldname:"description", + label: __("Description"), + in_list_view: 1, + read_only: 1, + }, + { + fieldtype:"Currency", + fieldname:"amount", + label: __("Amount"), + options: "currency", + in_list_view: 1, + read_only: 1, + }, + { + fieldtype:"Check", + fieldname:"is_alternative", + label: __("Is Alternative"), + read_only: 1, + }]; + + + this.data = this.frm.doc.items.filter( + (item) => item.is_alternative || item.has_alternative_item + ).map((item) => { + return { + "name": item.name, + "item_code": item.item_code, + "description": item.description, + "amount": item.amount, + "is_alternative": item.is_alternative, + } + }); + + const dialog = new frappe.ui.Dialog({ + title: __("Select Alternative Items for Sales Order"), + fields: [ + { + fieldname: "info", + fieldtype: "HTML", + read_only: 1 + }, + { + fieldname: "alternative_items", + fieldtype: "Table", + cannot_add_rows: true, + in_place_edit: true, + reqd: 1, + data: this.data, + description: __("Select an item from each set to be used in the Sales Order."), + get_data: () => { + return this.data; + }, + fields: table_fields + }, + ], + primary_action: function() { + frappe.model.open_mapped_doc({ + method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", + frm: me.frm, + args: { + selected_items: dialog.fields_dict.alternative_items.grid.get_selected_children() + } + }); + dialog.hide(); + }, + primary_action_label: __('Continue') + }); + + dialog.fields_dict.info.$wrapper.html( + `

+ + Alternative Items +

` + ) + dialog.show(); + } }; cur_frm.script_manager.make(erpnext.selling.QuotationController); -cur_frm.cscript['Make Sales Order'] = function() { - frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", - frm: cur_frm - }) -} - frappe.ui.form.on("Quotation Item", "items_on_form_rendered", "packed_items_on_form_rendered", function(frm, cdt, cdn) { // enable tax_amount field if Actual }) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 063813b2dc7..fc66db20d29 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -35,6 +35,9 @@ class Quotation(SellingController): make_packing_list(self) + def before_submit(self): + self.set_has_alternative_item() + def validate_valid_till(self): if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date): frappe.throw(_("Valid till date cannot be before transaction date")) @@ -59,7 +62,18 @@ class Quotation(SellingController): title=_("Unpublished Item"), ) + def set_has_alternative_item(self): + """Mark 'Has Alternative Item' for rows.""" + if not any(row.is_alternative for row in self.get("items")): + return + + items_with_alternatives = self.get_rows_with_alternatives() + for row in self.get("items"): + if not row.is_alternative and row.name in items_with_alternatives: + row.has_alternative_item = 1 + def get_ordered_status(self): + status = "Open" ordered_items = frappe._dict( frappe.db.get_all( "Sales Order Item", @@ -70,16 +84,40 @@ class Quotation(SellingController): ) ) - status = "Open" - if ordered_items: + if not ordered_items: + return status + + has_alternatives = any(row.is_alternative for row in self.get("items")) + self._items = self.get_valid_items() if has_alternatives else self.get("items") + + if any(row.qty > ordered_items.get(row.item_code, 0.0) for row in self._items): + status = "Partially Ordered" + else: status = "Ordered" - for item in self.get("items"): - if item.qty > ordered_items.get(item.item_code, 0.0): - status = "Partially Ordered" - return status + def get_valid_items(self): + """ + Filters out items in an alternatives set that were not ordered. + """ + + def is_in_sales_order(row): + in_sales_order = bool( + frappe.db.exists( + "Sales Order Item", {"quotation_item": row.name, "item_code": row.item_code, "docstatus": 1} + ) + ) + return in_sales_order + + def can_map(row) -> bool: + if row.is_alternative or row.has_alternative_item: + return is_in_sales_order(row) + + return True + + return list(filter(can_map, self.get("items"))) + def is_fully_ordered(self): return self.get_ordered_status() == "Ordered" @@ -176,6 +214,22 @@ class Quotation(SellingController): def on_recurring(self, reference_doc, auto_repeat_doc): self.valid_till = None + def get_rows_with_alternatives(self): + rows_with_alternatives = [] + table_length = len(self.get("items")) + + for idx, row in enumerate(self.get("items")): + if row.is_alternative: + continue + + if idx == (table_length - 1): + break + + if self.get("items")[idx + 1].is_alternative: + rows_with_alternatives.append(row.name) + + return rows_with_alternatives + def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context @@ -221,6 +275,8 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): ) ) + selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])] + def set_missing_values(source, target): if customer: target.customer = customer.name @@ -244,6 +300,24 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): target.blanket_order = obj.blanket_order target.blanket_order_rate = obj.blanket_order_rate + def can_map_row(item) -> bool: + """ + Row mapping from Quotation to Sales order: + 1. If no selections, map all non-alternative rows (that sum up to the grand total) + 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty + 3. If selections: Simple row: Map if adequate qty + """ + has_qty = item.qty > 0 + + if not selected_rows: + return not item.is_alternative + + if selected_rows and (item.is_alternative or item.has_alternative_item): + return (item.name in selected_rows) and has_qty + + # Simple row + return has_qty + doclist = get_mapped_doc( "Quotation", source_name, @@ -253,7 +327,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): "doctype": "Sales Order Item", "field_map": {"parent": "prevdoc_docname", "name": "quotation_item"}, "postprocess": update_item, - "condition": lambda doc: doc.qty > 0, + "condition": can_map_row, }, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, @@ -322,7 +396,11 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): source_name, { "Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}}, - "Quotation Item": {"doctype": "Sales Invoice Item", "postprocess": update_item}, + "Quotation Item": { + "doctype": "Sales Invoice Item", + "postprocess": update_item, + "condition": lambda row: not row.is_alternative, + }, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, }, diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index cdf5f5d00c5..67f6518657e 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -457,6 +457,139 @@ class TestQuotation(FrappeTestCase): expected_index = id + 1 self.assertEqual(item.idx, expected_index) + def test_alternative_items_with_stock_items(self): + """ + Check if taxes & totals considers only non-alternative items with: + - One set of non-alternative & alternative items [first 3 rows] + - One simple stock item + """ + from erpnext.stock.doctype.item.test_item import make_item + + item_list = [] + stock_items = { + "_Test Simple Item 1": 100, + "_Test Alt 1": 120, + "_Test Alt 2": 110, + "_Test Simple Item 2": 200, + } + + for item, rate in stock_items.items(): + make_item(item, {"is_stock_item": 1}) + item_list.append( + { + "item_code": item, + "qty": 1, + "rate": rate, + "is_alternative": bool("Alt" in item), + } + ) + + quotation = make_quotation(item_list=item_list, do_not_submit=1) + quotation.append( + "taxes", + { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 10, + }, + ) + quotation.submit() + + self.assertEqual(quotation.net_total, 300) + self.assertEqual(quotation.grand_total, 330) + + def test_alternative_items_with_service_items(self): + """ + Check if taxes & totals considers only non-alternative items with: + - One set of non-alternative & alternative service items [first 3 rows] + - One simple non-alternative service item + All having the same item code and unique item name/description due to + dynamic services + """ + from erpnext.stock.doctype.item.test_item import make_item + + item_list = [] + service_items = { + "Tiling with Standard Tiles": 100, + "Alt Tiling with Durable Tiles": 150, + "Alt Tiling with Premium Tiles": 180, + "False Ceiling with Material #234": 190, + } + + make_item("_Test Dynamic Service Item", {"is_stock_item": 0}) + + for name, rate in service_items.items(): + item_list.append( + { + "item_code": "_Test Dynamic Service Item", + "item_name": name, + "description": name, + "qty": 1, + "rate": rate, + "is_alternative": bool("Alt" in name), + } + ) + + quotation = make_quotation(item_list=item_list, do_not_submit=1) + quotation.append( + "taxes", + { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 10, + }, + ) + quotation.submit() + + self.assertEqual(quotation.net_total, 290) + self.assertEqual(quotation.grand_total, 319) + + def test_alternative_items_sales_order_mapping_with_stock_items(self): + from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.stock.doctype.item.test_item import make_item + + frappe.flags.args = frappe._dict() + item_list = [] + stock_items = { + "_Test Simple Item 1": 100, + "_Test Alt 1": 120, + "_Test Alt 2": 110, + "_Test Simple Item 2": 200, + } + + for item, rate in stock_items.items(): + make_item(item, {"is_stock_item": 1}) + item_list.append( + { + "item_code": item, + "qty": 1, + "rate": rate, + "is_alternative": bool("Alt" in item), + "warehouse": "_Test Warehouse - _TC", + } + ) + + quotation = make_quotation(item_list=item_list) + + frappe.flags.args.selected_items = [quotation.items[2]] + sales_order = make_sales_order(quotation.name) + sales_order.delivery_date = add_days(sales_order.transaction_date, 10) + sales_order.save() + + self.assertEqual(sales_order.items[0].item_code, "_Test Alt 2") + self.assertEqual(sales_order.items[1].item_code, "_Test Simple Item 2") + self.assertEqual(sales_order.net_total, 310) + + sales_order.submit() + quotation.reload() + self.assertEqual(quotation.status, "Ordered") + test_records = frappe.get_test_records("Quotation") diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 31a95896bc1..fb810318e93 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -49,6 +49,8 @@ "pricing_rules", "stock_uom_rate", "is_free_item", + "is_alternative", + "has_alternative_item", "section_break_43", "valuation_rate", "column_break_45", @@ -644,12 +646,28 @@ "no_copy": 1, "options": "currency", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_alternative", + "fieldtype": "Check", + "label": "Is Alternative", + "print_hide": 1 + }, + { + "default": "0", + "fieldname": "has_alternative_item", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Alternative Item", + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2021-07-15 12:40:51.074820", + "modified": "2023-02-06 11:00:07.042364", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", @@ -657,5 +675,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index ee0752549da..449d461561a 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -275,7 +275,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex if (this.frm.doc.docstatus===0) { this.frm.add_custom_button(__('Quotation'), function() { - erpnext.utils.map_current_doc({ + let d = erpnext.utils.map_current_doc({ method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", source_doctype: "Quotation", target: me.frm, @@ -293,7 +293,16 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex docstatus: 1, status: ["!=", "Lost"] } - }) + }); + + setTimeout(() => { + d.$parent.append(` + + ${__("Note: Please create Sales Orders from individual Quotations to select from among Alternative Items.")} + + `); + }, 200); + }, __("Get Items From")); } diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 385d0f3a585..ee9161bee48 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -21,6 +21,9 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( ) from erpnext.accounts.party import get_party_account from erpnext.controllers.selling_controller import SellingController +from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( + validate_against_blanket_order, +) from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_items_for_material_requests, ) @@ -52,6 +55,7 @@ class SalesOrder(SellingController): self.validate_warehouse() self.validate_drop_ship() self.validate_serial_no_based_delivery() + validate_against_blanket_order(self) validate_inter_company_party( self.doctype, self.customer, self.company, self.inter_company_order_reference ) diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 6ea66a02378..45ad7d95a15 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -24,6 +24,7 @@ "so_required", "dn_required", "sales_update_frequency", + "over_order_allowance", "column_break_5", "allow_multiple_items", "allow_against_multiple_purchase_orders", @@ -179,6 +180,12 @@ "fieldname": "allow_sales_order_creation_for_expired_quotation", "fieldtype": "Check", "label": "Allow Sales Order Creation For Expired Quotation" + }, + { + "description": "Percentage you are allowed to order more against the Blanket Order Quantity. For example: If you have a Blanket Order of Quantity 100 units. and your Allowance is 10% then you are allowed to order 110 units.", + "fieldname": "over_order_allowance", + "fieldtype": "Float", + "label": "Over Order Allowance (%)" } ], "icon": "fa fa-cog", @@ -186,7 +193,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-02-04 12:37:53.380857", + "modified": "2023-03-03 11:16:54.333615", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 07ee2890c46..fcdf245659b 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -808,7 +808,7 @@ def get_default_company_address(name, sort_key="is_primary_address", existing_ad return existing_address if out: - return min(out, key=lambda x: x[1])[0] # find min by sort_key + return max(out, key=lambda x: x[1])[0] # find max by sort_key else: return None diff --git a/erpnext/setup/doctype/company/test_company.py b/erpnext/setup/doctype/company/test_company.py index 29e056e34f0..fd2fe300fac 100644 --- a/erpnext/setup/doctype/company/test_company.py +++ b/erpnext/setup/doctype/company/test_company.py @@ -11,6 +11,7 @@ from frappe.utils import random_string from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import ( get_charts_for_country, ) +from erpnext.setup.doctype.company.company import get_default_company_address test_ignore = ["Account", "Cost Center", "Payment Terms Template", "Salary Component", "Warehouse"] test_dependencies = ["Fiscal Year"] @@ -132,6 +133,38 @@ class TestCompany(unittest.TestCase): self.assertTrue(lft >= min_lft) self.assertTrue(rgt <= max_rgt) + def test_primary_address(self): + company = "_Test Company" + + secondary = frappe.get_doc( + { + "address_title": "Non Primary", + "doctype": "Address", + "address_type": "Billing", + "address_line1": "Something", + "city": "Mumbai", + "state": "Maharashtra", + "country": "India", + "is_primary_address": 1, + "pincode": "400098", + "links": [ + { + "link_doctype": "Company", + "link_name": company, + } + ], + } + ) + secondary.insert() + self.addCleanup(secondary.delete) + + primary = frappe.copy_doc(secondary) + primary.is_primary_address = 1 + primary.insert() + self.addCleanup(primary.delete) + + self.assertEqual(get_default_company_address(company), primary.name) + def get_no_of_children(self, company): def get_no_of_children(companies, no_of_children): children = [] diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 1f7dddfb95b..088958d1b26 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -155,7 +155,7 @@ def add_standard_navbar_items(): { "item_label": "Documentation", "item_type": "Route", - "route": "https://erpnext.com/docs/user/manual", + "route": "https://docs.erpnext.com/docs/v14/user/manual/en/introduction", "is_standard": 1, }, { diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index 916ab2a05be..1763269193a 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -636,7 +636,8 @@ "no_copy": 1, "options": "Sales Invoice", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "so_detail", @@ -837,7 +838,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-11-09 12:17:50.850142", + "modified": "2023-03-20 14:24:10.406746", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e263a278bef..7e39cb92f70 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -661,6 +661,7 @@ class StockEntry(StockController): ) finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item) + items = [] # Set basic rate for incoming items for d in self.get("items"): if d.s_warehouse or d.set_basic_rate_manually: @@ -668,12 +669,7 @@ class StockEntry(StockController): if d.allow_zero_valuation_rate: d.basic_rate = 0.0 - frappe.msgprint( - _( - "Row {0}: Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {1}" - ).format(d.idx, d.item_code), - alert=1, - ) + items.append(d.item_code) elif d.is_finished_item: if self.purpose == "Manufacture": @@ -700,6 +696,20 @@ class StockEntry(StockController): d.basic_rate = flt(d.basic_rate) d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) + if items: + message = "" + + if len(items) > 1: + message = _( + "Items rate has been updated to zero as Allow Zero Valuation Rate is checked for the following items: {0}" + ).format(", ".join(frappe.bold(item) for item in items)) + else: + message = _( + "Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {0}" + ).format(frappe.bold(items[0])) + + frappe.msgprint(message, alert=True) + def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): outgoing_items_cost = 0.0 for d in self.get("items"): diff --git a/erpnext/templates/generators/item/item_configure.js b/erpnext/templates/generators/item/item_configure.js index 231ae0587ed..613c967e3d6 100644 --- a/erpnext/templates/generators/item/item_configure.js +++ b/erpnext/templates/generators/item/item_configure.js @@ -186,14 +186,14 @@ class ItemConfigure { this.dialog.$status_area.empty(); } - get_html_for_item_found({ filtered_items_count, filtered_items, exact_match, product_info }) { + get_html_for_item_found({ filtered_items_count, filtered_items, exact_match, product_info, available_qty, settings }) { const one_item = exact_match.length === 1 ? exact_match[0] : filtered_items_count === 1 ? filtered_items[0] : ''; - const item_add_to_cart = one_item ? ` + let item_add_to_cart = one_item ? `