diff --git a/CODEOWNERS b/CODEOWNERS index 465337a0c5c..7f8c4d1ac87 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,13 +3,13 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence, -erpnext/accounts/ @nextchamp-saqib @deepeshgarg007 @ruthra-kumar +erpnext/accounts/ @deepeshgarg007 @ruthra-kumar erpnext/assets/ @anandbaburajan @deepeshgarg007 -erpnext/loan_management/ @nextchamp-saqib @deepeshgarg007 -erpnext/regional @nextchamp-saqib @deepeshgarg007 @ruthra-kumar -erpnext/selling @nextchamp-saqib @deepeshgarg007 @ruthra-kumar -erpnext/support/ @nextchamp-saqib @deepeshgarg007 -pos* @nextchamp-saqib +erpnext/loan_management/ @deepeshgarg007 +erpnext/regional @deepeshgarg007 @ruthra-kumar +erpnext/selling @deepeshgarg007 @ruthra-kumar +erpnext/support/ @deepeshgarg007 +pos* erpnext/buying/ @rohitwaghchaure @s-aga-r erpnext/maintenance/ @rohitwaghchaure @s-aga-r @@ -18,12 +18,8 @@ erpnext/quality_management/ @rohitwaghchaure @s-aga-r erpnext/stock/ @rohitwaghchaure @s-aga-r erpnext/subcontracting @rohitwaghchaure @s-aga-r -erpnext/crm/ @NagariaHussain -erpnext/education/ @rutwikhdev -erpnext/projects/ @ruchamahabal +erpnext/controllers/ @deepeshgarg007 @rohitwaghchaure +erpnext/patches/ @deepeshgarg007 -erpnext/controllers/ @deepeshgarg007 @nextchamp-saqib @rohitwaghchaure -erpnext/patches/ @deepeshgarg007 @nextchamp-saqib - -.github/ @ankush +.github/ @deepeshgarg007 pyproject.toml @ankush diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 7a447fef30e..61180579d2b 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -1,8 +1,9 @@ +import functools import inspect import frappe -__version__ = "14.2.3" +__version__ = "14.20.3" def get_default_company(user=None): @@ -120,12 +121,14 @@ def get_region(company=None): You can also set global company flag in `frappe.flags.company` """ - if company or frappe.flags.company: - return frappe.get_cached_value("Company", company or frappe.flags.company, "country") - elif frappe.flags.country: - return frappe.flags.country - else: - return frappe.get_system_settings("country") + + if not company: + company = frappe.local.flags.company + + if company: + return frappe.get_cached_value("Company", company, "country") + + return frappe.flags.country or frappe.get_system_settings("country") def allow_regional(fn): @@ -136,6 +139,7 @@ def allow_regional(fn): def myfunction(): pass""" + @functools.wraps(fn) def caller(*args, **kwargs): overrides = frappe.get_hooks("regional_overrides", {}).get(get_region()) function_path = f"{inspect.getmodule(fn).__name__}.{fn.__name__}" diff --git a/erpnext/accounts/doctype/account/account.json b/erpnext/accounts/doctype/account/account.json index d2659d429b0..e79fb660625 100644 --- a/erpnext/accounts/doctype/account/account.json +++ b/erpnext/accounts/doctype/account/account.json @@ -18,7 +18,6 @@ "root_type", "report_type", "account_currency", - "inter_company_account", "column_break1", "parent_account", "account_type", @@ -34,15 +33,11 @@ { "fieldname": "properties", "fieldtype": "Section Break", - "oldfieldtype": "Section Break", - "show_days": 1, - "show_seconds": 1 + "oldfieldtype": "Section Break" }, { "fieldname": "column_break0", "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1, "width": "50%" }, { @@ -53,9 +48,7 @@ "no_copy": 1, "oldfieldname": "account_name", "oldfieldtype": "Data", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "account_number", @@ -63,17 +56,13 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Account Number", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "default": "0", "fieldname": "is_group", "fieldtype": "Check", - "label": "Is Group", - "show_days": 1, - "show_seconds": 1 + "label": "Is Group" }, { "fieldname": "company", @@ -85,9 +74,7 @@ "options": "Company", "read_only": 1, "remember_last_selected_value": 1, - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "root_type", @@ -95,9 +82,7 @@ "in_standard_filter": 1, "label": "Root Type", "options": "\nAsset\nLiability\nIncome\nExpense\nEquity", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "fieldname": "report_type", @@ -105,32 +90,18 @@ "in_standard_filter": 1, "label": "Report Type", "options": "\nBalance Sheet\nProfit and Loss", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "depends_on": "eval:doc.is_group==0", "fieldname": "account_currency", "fieldtype": "Link", "label": "Currency", - "options": "Currency", - "show_days": 1, - "show_seconds": 1 - }, - { - "default": "0", - "fieldname": "inter_company_account", - "fieldtype": "Check", - "label": "Inter Company Account", - "show_days": 1, - "show_seconds": 1 + "options": "Currency" }, { "fieldname": "column_break1", "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1, "width": "50%" }, { @@ -142,9 +113,7 @@ "oldfieldtype": "Link", "options": "Account", "reqd": 1, - "search_index": 1, - "show_days": 1, - "show_seconds": 1 + "search_index": 1 }, { "description": "Setting Account Type helps in selecting this Account in transactions.", @@ -154,9 +123,7 @@ "label": "Account Type", "oldfieldname": "account_type", "oldfieldtype": "Select", - "options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nDepreciation\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nPayable\nReceivable\nRound Off\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary", - "show_days": 1, - "show_seconds": 1 + "options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nDepreciation\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nPayable\nReceivable\nRound Off\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary" }, { "description": "Rate at which this tax is applied", @@ -164,9 +131,7 @@ "fieldtype": "Float", "label": "Rate", "oldfieldname": "tax_rate", - "oldfieldtype": "Currency", - "show_days": 1, - "show_seconds": 1 + "oldfieldtype": "Currency" }, { "description": "If the account is frozen, entries are allowed to restricted users.", @@ -175,17 +140,13 @@ "label": "Frozen", "oldfieldname": "freeze_account", "oldfieldtype": "Select", - "options": "No\nYes", - "show_days": 1, - "show_seconds": 1 + "options": "No\nYes" }, { "fieldname": "balance_must_be", "fieldtype": "Select", "label": "Balance must be", - "options": "\nDebit\nCredit", - "show_days": 1, - "show_seconds": 1 + "options": "\nDebit\nCredit" }, { "fieldname": "lft", @@ -194,9 +155,7 @@ "label": "Lft", "print_hide": 1, "read_only": 1, - "search_index": 1, - "show_days": 1, - "show_seconds": 1 + "search_index": 1 }, { "fieldname": "rgt", @@ -205,9 +164,7 @@ "label": "Rgt", "print_hide": 1, "read_only": 1, - "search_index": 1, - "show_days": 1, - "show_seconds": 1 + "search_index": 1 }, { "fieldname": "old_parent", @@ -215,33 +172,27 @@ "hidden": 1, "label": "Old Parent", "print_hide": 1, - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "default": "0", "depends_on": "eval:(doc.report_type == 'Profit and Loss' && !doc.is_group)", "fieldname": "include_in_gross", "fieldtype": "Check", - "label": "Include in gross", - "show_days": 1, - "show_seconds": 1 + "label": "Include in gross" }, { "default": "0", "fieldname": "disabled", "fieldtype": "Check", - "label": "Disable", - "show_days": 1, - "show_seconds": 1 + "label": "Disable" } ], "icon": "fa fa-money", "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-06-11 15:15:54.338622", + "modified": "2023-04-11 16:08:46.983677", "modified_by": "Administrator", "module": "Accounts", "name": "Account", @@ -301,5 +252,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "ASC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/account/test_account.py b/erpnext/accounts/doctype/account/test_account.py index f9c9173af08..3a360c48c43 100644 --- a/erpnext/accounts/doctype/account/test_account.py +++ b/erpnext/accounts/doctype/account/test_account.py @@ -297,7 +297,7 @@ def _make_test_records(verbose=None): # fixed asset depreciation ["_Test Fixed Asset", "Current Assets", 0, "Fixed Asset", None], ["_Test Accumulated Depreciations", "Current Assets", 0, "Accumulated Depreciation", None], - ["_Test Depreciations", "Expenses", 0, None, None], + ["_Test Depreciations", "Expenses", 0, "Depreciation", None], ["_Test Gain/Loss on Asset Disposal", "Expenses", 0, None, None], # Receivable / Payable Account ["_Test Receivable", "Current Assets", 0, "Receivable", None], diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 1e2e2acd79a..d5ed0676fd1 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -19,6 +19,8 @@ "column_break_17", "enable_common_party_accounting", "allow_multi_currency_invoices_against_single_party_account", + "journals_section", + "merge_similar_account_heads", "report_setting_section", "use_custom_cash_flow", "deferred_accounting_settings_section", @@ -31,12 +33,15 @@ "determine_address_tax_category_from", "column_break_19", "add_taxes_from_item_tax_template", + "book_tax_discount_loss", "print_settings", "show_inclusive_tax_in_print", "column_break_12", "show_payment_schedule_in_print", "currency_exchange_section", "allow_stale", + "section_break_jpd0", + "auto_reconcile_payments", "stale_days", "invoicing_settings_tab", "accounts_transactions_settings_section", @@ -167,11 +172,6 @@ "fieldtype": "Int", "label": "Stale Days" }, - { - "fieldname": "report_settings_sb", - "fieldtype": "Section Break", - "label": "Report Settings" - }, { "default": "0", "description": "Only select this if you have set up the Cash Flow Mapper documents", @@ -181,6 +181,7 @@ }, { "default": "0", + "description": "Payment Terms from orders will be fetched into the invoices as is", "fieldname": "automatically_fetch_payment_terms", "fieldtype": "Check", "label": "Automatically Fetch Payment Terms from Order" @@ -347,6 +348,36 @@ "fieldname": "allow_multi_currency_invoices_against_single_party_account", "fieldtype": "Check", "label": "Allow multi-currency invoices against single party account " + }, + { + "default": "0", + "description": "Split Early Payment Discount Loss into Income and Tax Loss", + "fieldname": "book_tax_discount_loss", + "fieldtype": "Check", + "label": "Book Tax Loss on Early Payment Discount" + }, + { + "fieldname": "journals_section", + "fieldtype": "Section Break", + "label": "Journals" + }, + { + "default": "0", + "description": "Rows with Same Account heads will be merged on Ledger", + "fieldname": "merge_similar_account_heads", + "fieldtype": "Check", + "label": "Merge Similar Account Heads" + }, + { + "fieldname": "section_break_jpd0", + "fieldtype": "Section Break", + "label": "Payment Reconciliations" + }, + { + "default": "0", + "fieldname": "auto_reconcile_payments", + "fieldtype": "Check", + "label": "Auto Reconcile Payments" } ], "icon": "icon-cog", @@ -354,7 +385,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-11-27 21:49:52.538655", + "modified": "2023-04-21 13:11:37.130743", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 80878ac5068..8ad0bd17b48 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -56,7 +56,7 @@ class BankClearance(Document): select "Payment Entry" as payment_document, name as payment_entry, reference_no as cheque_number, reference_date as cheque_date, - if(paid_from=%(account)s, paid_amount, 0) as credit, + if(paid_from=%(account)s, paid_amount + total_taxes_and_charges, 0) as credit, if(paid_from=%(account)s, 0, received_amount) as debit, posting_date, ifnull(party,if(paid_from=%(account)s,paid_to,paid_from)) as against_account, clearance_date, if(paid_to=%(account)s, paid_to_account_currency, paid_from_account_currency) as account_currency @@ -81,7 +81,7 @@ class BankClearance(Document): loan_disbursement = frappe.qb.DocType("Loan Disbursement") - loan_disbursements = ( + query = ( frappe.qb.from_(loan_disbursement) .select( ConstantColumn("Loan Disbursement").as_("payment_document"), @@ -90,17 +90,22 @@ class BankClearance(Document): ConstantColumn(0).as_("debit"), loan_disbursement.reference_number.as_("cheque_number"), loan_disbursement.reference_date.as_("cheque_date"), + loan_disbursement.clearance_date.as_("clearance_date"), loan_disbursement.disbursement_date.as_("posting_date"), loan_disbursement.applicant.as_("against_account"), ) .where(loan_disbursement.docstatus == 1) .where(loan_disbursement.disbursement_date >= self.from_date) .where(loan_disbursement.disbursement_date <= self.to_date) - .where(loan_disbursement.clearance_date.isnull()) .where(loan_disbursement.disbursement_account.isin([self.bank_account, self.account])) .orderby(loan_disbursement.disbursement_date) .orderby(loan_disbursement.name, order=frappe.qb.desc) - ).run(as_dict=1) + ) + + if not self.include_reconciled_entries: + query = query.where(loan_disbursement.clearance_date.isnull()) + + loan_disbursements = query.run(as_dict=1) loan_repayment = frappe.qb.DocType("Loan Repayment") @@ -113,16 +118,19 @@ class BankClearance(Document): ConstantColumn(0).as_("credit"), loan_repayment.reference_number.as_("cheque_number"), loan_repayment.reference_date.as_("cheque_date"), + loan_repayment.clearance_date.as_("clearance_date"), loan_repayment.applicant.as_("against_account"), loan_repayment.posting_date, ) .where(loan_repayment.docstatus == 1) - .where(loan_repayment.clearance_date.isnull()) .where(loan_repayment.posting_date >= self.from_date) .where(loan_repayment.posting_date <= self.to_date) .where(loan_repayment.payment_account.isin([self.bank_account, self.account])) ) + if not self.include_reconciled_entries: + query = query.where(loan_repayment.clearance_date.isnull()) + if frappe.db.has_column("Loan Repayment", "repay_from_salary"): query = query.where((loan_repayment.repay_from_salary == 0)) diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 15162376c15..d2d961f7c2a 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -281,10 +281,13 @@ def get_paid_amount(payment_entry, currency, gl_bank_account): ) elif payment_entry.payment_document == "Journal Entry": - return frappe.db.get_value( - "Journal Entry Account", - {"parent": payment_entry.payment_entry, "account": gl_bank_account}, - "sum(credit_in_account_currency)", + return abs( + frappe.db.get_value( + "Journal Entry Account", + {"parent": payment_entry.payment_entry, "account": gl_bank_account}, + "sum(debit_in_account_currency-credit_in_account_currency)", + ) + or 0 ) elif payment_entry.payment_document == "Expense Claim": diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 089f20b467d..302acc4f1f7 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -8,7 +8,7 @@ frappe.provide("erpnext.journal_entry"); frappe.ui.form.on("Journal Entry", { setup: function(frm) { frm.add_fetch("bank_account", "account", "account"); - frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger"]; + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger", 'Asset', 'Asset Movement']; }, refresh: function(frm) { diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 608267154b6..92c37bbe5e5 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -69,6 +69,7 @@ class JournalEntry(AccountsController): self.validate_empty_accounts_table() self.set_account_and_party_balance() self.validate_inter_company_accounts() + self.validate_depr_entry_voucher_type() if self.docstatus == 0: self.apply_tax_withholding() @@ -130,6 +131,13 @@ class JournalEntry(AccountsController): if self.total_credit != doc.total_debit or self.total_debit != doc.total_credit: frappe.throw(_("Total Credit/ Debit Amount should be same as linked Journal Entry")) + def validate_depr_entry_voucher_type(self): + if ( + any(d.account_type == "Depreciation" for d in self.get("accounts")) + and self.voucher_type != "Depreciation Entry" + ): + frappe.throw(_("Journal Entry type should be set as Depreciation Entry for asset depreciation")) + def validate_stock_accounts(self): stock_accounts = get_stock_accounts(self.company, self.doctype, self.name) for account in stock_accounts: @@ -233,25 +241,30 @@ class JournalEntry(AccountsController): self.remove(d) def update_asset_value(self): - if self.voucher_type != "Depreciation Entry": + if self.flags.planned_depr_entry or self.voucher_type != "Depreciation Entry": return - processed_assets = [] - for d in self.get("accounts"): if ( - d.reference_type == "Asset" and d.reference_name and d.reference_name not in processed_assets + d.reference_type == "Asset" + and d.reference_name + and d.account_type == "Depreciation" + and d.debit ): - processed_assets.append(d.reference_name) - asset = frappe.get_doc("Asset", d.reference_name) if asset.calculate_depreciation: - continue - - depr_value = d.debit or d.credit - - asset.db_set("value_after_depreciation", asset.value_after_depreciation - depr_value) + fb_idx = 1 + if self.finance_book: + for fb_row in asset.get("finance_books"): + if fb_row.finance_book == self.finance_book: + fb_idx = fb_row.idx + break + fb_row = asset.get("finance_books")[fb_idx - 1] + fb_row.value_after_depreciation -= d.debit + fb_row.db_update() + else: + asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit) asset.set_status() @@ -316,35 +329,35 @@ class JournalEntry(AccountsController): if self.voucher_type != "Depreciation Entry": return - processed_assets = [] - for d in self.get("accounts"): if ( - d.reference_type == "Asset" and d.reference_name and d.reference_name not in processed_assets + d.reference_type == "Asset" + and d.reference_name + and d.account_type == "Depreciation" + and d.debit ): - processed_assets.append(d.reference_name) - asset = frappe.get_doc("Asset", d.reference_name) if asset.calculate_depreciation: + fb_idx = None for s in asset.get("schedules"): if s.journal_entry == self.name: s.db_set("journal_entry", None) - - idx = cint(s.finance_book_id) or 1 - finance_books = asset.get("finance_books")[idx - 1] - finance_books.value_after_depreciation += s.depreciation_amount - finance_books.db_update() - - asset.set_status() - + fb_idx = cint(s.finance_book_id) or 1 break + if not fb_idx: + fb_idx = 1 + if self.finance_book: + for fb_row in asset.get("finance_books"): + if fb_row.finance_book == self.finance_book: + fb_idx = fb_row.idx + break + fb_row = asset.get("finance_books")[fb_idx - 1] + fb_row.value_after_depreciation += d.debit + fb_row.db_update() else: - depr_value = d.debit or d.credit - - asset.db_set("value_after_depreciation", asset.value_after_depreciation + depr_value) - - asset.set_status() + asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit) + asset.set_status() def unlink_inter_company_jv(self): if ( @@ -878,6 +891,8 @@ class JournalEntry(AccountsController): def make_gl_entries(self, cancel=0, adv_adj=0): from erpnext.accounts.general_ledger import make_gl_entries + merge_entries = frappe.db.get_single_value("Accounts Settings", "merge_similar_account_heads") + gl_map = self.build_gl_map() if self.voucher_type in ("Deferred Revenue", "Deferred Expense"): update_outstanding = "No" @@ -885,7 +900,13 @@ class JournalEntry(AccountsController): update_outstanding = "Yes" if gl_map: - make_gl_entries(gl_map, cancel=cancel, adv_adj=adv_adj, update_outstanding=update_outstanding) + make_gl_entries( + gl_map, + cancel=cancel, + adv_adj=adv_adj, + merge_entries=merge_entries, + update_outstanding=update_outstanding, + ) @frappe.whitelist() def get_balance(self, difference_account=None): diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py index 2cc5378e927..f7297d19e0f 100644 --- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py @@ -287,10 +287,6 @@ class TestJournalEntry(unittest.TestCase): jv.submit() def test_inter_company_jv(self): - frappe.db.set_value("Account", "Sales Expenses - _TC", "inter_company_account", 1) - frappe.db.set_value("Account", "Buildings - _TC", "inter_company_account", 1) - frappe.db.set_value("Account", "Sales Expenses - _TC1", "inter_company_account", 1) - frappe.db.set_value("Account", "Buildings - _TC1", "inter_company_account", 1) jv = make_journal_entry( "Sales Expenses - _TC", "Buildings - _TC", diff --git a/erpnext/accounts/doctype/journal_entry_template/journal_entry_template.js b/erpnext/accounts/doctype/journal_entry_template/journal_entry_template.js index 88f1c9069c8..5ebdf61db2c 100644 --- a/erpnext/accounts/doctype/journal_entry_template/journal_entry_template.js +++ b/erpnext/accounts/doctype/journal_entry_template/journal_entry_template.js @@ -2,6 +2,21 @@ // For license information, please see license.txt frappe.ui.form.on("Journal Entry Template", { + onload: function(frm) { + if(frm.is_new()) { + frappe.call({ + type: "GET", + method: "erpnext.accounts.doctype.journal_entry_template.journal_entry_template.get_naming_series", + callback: function(r){ + if(r.message) { + frm.set_df_property("naming_series", "options", r.message.split("\n")); + frm.set_value("naming_series", r.message.split("\n")[0]); + frm.refresh_field("naming_series"); + } + } + }); + } + }, refresh: function(frm) { frappe.model.set_default_values(frm.doc); @@ -19,18 +34,6 @@ frappe.ui.form.on("Journal Entry Template", { return { filters: filters }; }); - - frappe.call({ - type: "GET", - method: "erpnext.accounts.doctype.journal_entry_template.journal_entry_template.get_naming_series", - callback: function(r){ - if(r.message){ - frm.set_df_property("naming_series", "options", r.message.split("\n")); - frm.set_value("naming_series", r.message.split("\n")[0]); - frm.refresh_field("naming_series"); - } - } - }); }, voucher_type: function(frm) { var add_accounts = function(doc, r) { diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 91374ae217b..969027dd97c 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -245,8 +245,6 @@ frappe.ui.form.on('Payment Entry', { frm.set_currency_labels(["total_amount", "outstanding_amount", "allocated_amount"], party_account_currency, "references"); - frm.set_currency_labels(["amount"], company_currency, "deductions"); - cur_frm.set_df_property("source_exchange_rate", "description", ("1 " + frm.doc.paid_from_account_currency + " = [?] " + company_currency)); @@ -973,29 +971,48 @@ frappe.ui.form.on('Payment Entry', { }, callback: function(r, rt) { if(r.message) { - var write_off_row = $.map(frm.doc["deductions"] || [], function(t) { + const write_off_row = $.map(frm.doc["deductions"] || [], function(t) { return t.account==r.message[account] ? t : null; }); - var row = []; - - var difference_amount = flt(frm.doc.difference_amount, + const difference_amount = flt(frm.doc.difference_amount, precision("difference_amount")); - if (!write_off_row.length && difference_amount) { - row = frm.add_child("deductions"); - row.account = r.message[account]; - row.cost_center = r.message["cost_center"]; - } else { - row = write_off_row[0]; - } + const add_deductions = (details) => { + let row = null; + if (!write_off_row.length && difference_amount) { + row = frm.add_child("deductions"); + row.account = details[account]; + row.cost_center = details["cost_center"]; + } else { + row = write_off_row[0]; + } - if (row) { - row.amount = flt(row.amount) + difference_amount; - } else { - frappe.msgprint(__("No gain or loss in the exchange rate")) - } + if (row) { + row.amount = flt(row.amount) + difference_amount; + } else { + frappe.msgprint(__("No gain or loss in the exchange rate")) + } + refresh_field("deductions"); + }; - refresh_field("deductions"); + if (!r.message[account]) { + frappe.prompt({ + label: __("Please Specify Account"), + fieldname: account, + fieldtype: "Link", + options: "Account", + get_query: () => ({ + filters: { + company: frm.doc.company, + } + }) + }, (values) => { + const details = Object.assign({}, r.message, values); + add_deductions(details); + }, __(frappe.unscrub(account))); + } else { + add_deductions(r.message); + } frm.events.set_unallocated_amount(frm); } diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index a585924d20f..49b75dc0570 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -60,6 +60,7 @@ class PaymentEntry(AccountsController): def validate(self): self.setup_party_account_field() self.set_missing_values() + self.set_missing_ref_details() self.validate_payment_type() self.validate_party_details() self.set_exchange_rate() @@ -219,11 +220,16 @@ class PaymentEntry(AccountsController): else self.paid_to_account_currency ) - self.set_missing_ref_details() - - def set_missing_ref_details(self, force=False): + def set_missing_ref_details( + self, force: bool = False, update_ref_details_only_for: list | None = None + ) -> None: for d in self.get("references"): if d.allocated_amount: + if update_ref_details_only_for and ( + not (d.reference_doctype, d.reference_name) in update_ref_details_only_for + ): + continue + ref_details = get_reference_details( d.reference_doctype, d.reference_name, self.party_account_currency ) @@ -416,7 +422,7 @@ class PaymentEntry(AccountsController): for ref in self.get("references"): if ref.payment_term and ref.reference_name: - key = (ref.payment_term, ref.reference_name) + key = (ref.payment_term, ref.reference_name, ref.reference_doctype) invoice_payment_amount_map.setdefault(key, 0.0) invoice_payment_amount_map[key] += ref.allocated_amount @@ -424,20 +430,37 @@ class PaymentEntry(AccountsController): payment_schedule = frappe.get_all( "Payment Schedule", filters={"parent": ref.reference_name}, - fields=["paid_amount", "payment_amount", "payment_term", "discount", "outstanding"], + fields=[ + "paid_amount", + "payment_amount", + "payment_term", + "discount", + "outstanding", + "discount_type", + ], ) for term in payment_schedule: - invoice_key = (term.payment_term, ref.reference_name) + invoice_key = (term.payment_term, ref.reference_name, ref.reference_doctype) invoice_paid_amount_map.setdefault(invoice_key, {}) invoice_paid_amount_map[invoice_key]["outstanding"] = term.outstanding - invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * ( - term.discount / 100 - ) + if not (term.discount_type and term.discount): + continue + + if term.discount_type == "Percentage": + invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * ( + term.discount / 100 + ) + else: + invoice_paid_amount_map[invoice_key]["discounted_amt"] = term.discount for idx, (key, allocated_amount) in enumerate(invoice_payment_amount_map.items(), 1): if not invoice_paid_amount_map.get(key): frappe.throw(_("Payment term {0} not used in {1}").format(key[0], key[1])) + allocated_amount = self.get_allocated_amount_in_transaction_currency( + allocated_amount, key[2], key[1] + ) + outstanding = flt(invoice_paid_amount_map.get(key, {}).get("outstanding")) discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get("discounted_amt")) @@ -472,6 +495,33 @@ class PaymentEntry(AccountsController): (allocated_amount - discounted_amt, discounted_amt, allocated_amount, key[1], key[0]), ) + def get_allocated_amount_in_transaction_currency( + self, allocated_amount, reference_doctype, reference_docname + ): + """ + Payment Entry could be in base currency while reference's payment schedule + is always in transaction currency. + E.g. + * SI with base=INR and currency=USD + * SI with payment schedule in USD + * PE in INR (accounting done in base currency) + """ + ref_currency, ref_exchange_rate = frappe.db.get_value( + reference_doctype, reference_docname, ["currency", "conversion_rate"] + ) + is_single_currency = self.paid_from_account_currency == self.paid_to_account_currency + # PE in different currency + reference_is_multi_currency = self.paid_from_account_currency != ref_currency + + if not (is_single_currency and reference_is_multi_currency): + return allocated_amount + + allocated_amount = flt( + allocated_amount / ref_exchange_rate, self.precision("total_allocated_amount") + ) + + return allocated_amount + def set_status(self): if self.docstatus == 2: self.status = "Cancelled" @@ -604,6 +654,28 @@ class PaymentEntry(AccountsController): self.precision("base_received_amount"), ) + def calculate_base_allocated_amount_for_reference(self, d) -> float: + base_allocated_amount = 0 + if d.reference_doctype in frappe.get_hooks("advance_payment_doctypes"): + # When referencing Sales/Purchase Order, use the source/target exchange rate depending on payment type. + # This is so there are no Exchange Gain/Loss generated for such doctypes + + exchange_rate = 1 + if self.payment_type == "Receive": + exchange_rate = self.source_exchange_rate + elif self.payment_type == "Pay": + exchange_rate = self.target_exchange_rate + + base_allocated_amount += flt( + flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount") + ) + else: + base_allocated_amount += flt( + flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount") + ) + + return base_allocated_amount + def set_total_allocated_amount(self): if self.payment_type == "Internal Transfer": return @@ -612,9 +684,7 @@ class PaymentEntry(AccountsController): for d in self.get("references"): if d.allocated_amount: total_allocated_amount += flt(d.allocated_amount) - base_total_allocated_amount += flt( - flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount") - ) + base_total_allocated_amount += self.calculate_base_allocated_amount_for_reference(d) self.total_allocated_amount = abs(total_allocated_amount) self.base_total_allocated_amount = abs(base_total_allocated_amount) @@ -831,9 +901,7 @@ class PaymentEntry(AccountsController): } ) - allocated_amount_in_company_currency = flt( - flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("paid_amount") - ) + allocated_amount_in_company_currency = self.calculate_base_allocated_amount_for_reference(d) gle.update( { @@ -1550,17 +1618,7 @@ def get_account_details(account, date, cost_center=None): @frappe.whitelist() def get_company_defaults(company): fields = ["write_off_account", "exchange_gain_loss_account", "cost_center"] - ret = frappe.get_cached_value("Company", company, fields, as_dict=1) - - for fieldname in fields: - if not ret[fieldname]: - frappe.throw( - _("Please set default {0} in Company {1}").format( - frappe.get_meta("Company").get_label(fieldname), company - ) - ) - - return ret + return frappe.get_cached_value("Company", company, fields, as_dict=1) def get_outstanding_on_journal_entry(name): @@ -1642,11 +1700,21 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre @frappe.whitelist() def get_payment_entry( - dt, dn, party_amount=None, bank_account=None, bank_amount=None, party_type=None, payment_type=None + dt, + dn, + party_amount=None, + bank_account=None, + bank_amount=None, + party_type=None, + payment_type=None, + reference_date=None, ): reference_doc = None doc = frappe.get_doc(dt, dn) - if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= 99.99: + over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance") + if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= ( + 100.0 + over_billing_allowance + ): frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) if not party_type: @@ -1665,12 +1733,20 @@ def get_payment_entry( # bank or cash bank = get_bank_cash_account(doc, bank_account) + # if default bank or cash account is not set in company master and party has default company bank account, fetch it + if party_type in ["Customer", "Supplier"] and not bank: + party_bank_account = get_party_bank_account(party_type, doc.get(scrub(party_type))) + if party_bank_account: + account = frappe.db.get_value("Bank Account", party_bank_account, "account") + bank = get_bank_cash_account(doc, account) + paid_amount, received_amount = set_paid_amount_and_received_amount( dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc ) - paid_amount, received_amount, discount_amount = apply_early_payment_discount( - paid_amount, received_amount, doc + reference_date = getdate(reference_date) + paid_amount, received_amount, discount_amount, valid_discounts = apply_early_payment_discount( + paid_amount, received_amount, doc, party_account_currency, reference_date ) pe = frappe.new_doc("Payment Entry") @@ -1678,6 +1754,7 @@ def get_payment_entry( pe.company = doc.company pe.cost_center = doc.get("cost_center") pe.posting_date = nowdate() + pe.reference_date = reference_date pe.mode_of_payment = doc.get("mode_of_payment") pe.party_type = party_type pe.party = doc.get(scrub(party_type)) @@ -1711,14 +1788,19 @@ def get_payment_entry( if doc.doctype == "Purchase Invoice" and doc.invoice_is_blocked(): frappe.msgprint(_("{0} is on hold till {1}").format(doc.name, doc.release_date)) else: - if doc.doctype in ("Sales Invoice", "Purchase Invoice") and frappe.get_value( + if doc.doctype in ( + "Sales Invoice", + "Purchase Invoice", + "Purchase Order", + "Sales Order", + ) and frappe.get_cached_value( "Payment Terms Template", {"name": doc.payment_terms_template}, "allocate_payment_based_on_payment_terms", ): for reference in get_reference_as_per_payment_terms( - doc.payment_schedule, dt, dn, doc, grand_total, outstanding_amount + doc.payment_schedule, dt, dn, doc, grand_total, outstanding_amount, party_account_currency ): pe.append("references", reference) else: @@ -1763,22 +1845,24 @@ def get_payment_entry( pe.setup_party_account_field() pe.set_missing_values() + pe.set_missing_ref_details() update_accounting_dimensions(pe, doc) if party_account and bank: pe.set_exchange_rate(ref_doc=reference_doc) pe.set_amounts() + if discount_amount: - pe.set_gain_or_loss( - account_details={ - "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), - "cost_center": pe.cost_center - or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": discount_amount * (-1 if payment_type == "Pay" else 1), - } + base_total_discount_loss = 0 + if frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss"): + base_total_discount_loss = split_early_payment_discount_loss(pe, doc, valid_discounts) + + set_pending_discount_loss( + pe, doc, discount_amount, base_total_discount_loss, party_account_currency ) - pe.set_difference_amount() + + pe.set_difference_amount() return pe @@ -1872,37 +1956,53 @@ def set_paid_amount_and_received_amount( paid_amount = received_amount = 0 if party_account_currency == bank.account_currency: paid_amount = received_amount = abs(outstanding_amount) - elif payment_type == "Receive": - paid_amount = abs(outstanding_amount) - if bank_amount: - received_amount = bank_amount - else: - received_amount = paid_amount * doc.get("conversion_rate", 1) else: - received_amount = abs(outstanding_amount) - if bank_amount: - paid_amount = bank_amount + company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency") + if payment_type == "Receive": + paid_amount = abs(outstanding_amount) + if bank_amount: + received_amount = bank_amount + else: + if company_currency != bank.account_currency: + received_amount = paid_amount / doc.get("conversion_rate", 1) + else: + received_amount = paid_amount * doc.get("conversion_rate", 1) else: - # if party account currency and bank currency is different then populate paid amount as well - paid_amount = received_amount * doc.get("conversion_rate", 1) + received_amount = abs(outstanding_amount) + if bank_amount: + paid_amount = bank_amount + else: + if company_currency != bank.account_currency: + paid_amount = received_amount / doc.get("conversion_rate", 1) + else: + # if party account currency and bank currency is different then populate paid amount as well + paid_amount = received_amount * doc.get("conversion_rate", 1) return paid_amount, received_amount -def apply_early_payment_discount(paid_amount, received_amount, doc): +def apply_early_payment_discount( + paid_amount, received_amount, doc, party_account_currency, reference_date +): total_discount = 0 + valid_discounts = [] eligible_for_payments = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"] has_payment_schedule = hasattr(doc, "payment_schedule") and doc.payment_schedule + is_multi_currency = party_account_currency != doc.company_currency if doc.doctype in eligible_for_payments and has_payment_schedule: for term in doc.payment_schedule: - if not term.discounted_amount and term.discount and getdate(nowdate()) <= term.discount_date: + if not term.discounted_amount and term.discount and reference_date <= term.discount_date: + if term.discount_type == "Percentage": - discount_amount = flt(doc.get("grand_total")) * (term.discount / 100) + grand_total = doc.get("grand_total") if is_multi_currency else doc.get("base_grand_total") + discount_amount = flt(grand_total) * (term.discount / 100) else: discount_amount = term.discount - discount_amount_in_foreign_currency = discount_amount * doc.get("conversion_rate", 1) + # if accounting is done in the same currency, paid_amount = received_amount + conversion_rate = doc.get("conversion_rate", 1) if is_multi_currency else 1 + discount_amount_in_foreign_currency = discount_amount * conversion_rate if doc.doctype == "Sales Invoice": paid_amount -= discount_amount @@ -1911,23 +2011,151 @@ def apply_early_payment_discount(paid_amount, received_amount, doc): received_amount -= discount_amount paid_amount -= discount_amount_in_foreign_currency + valid_discounts.append({"type": term.discount_type, "discount": term.discount}) total_discount += discount_amount if total_discount: - money = frappe.utils.fmt_money(total_discount, currency=doc.get("currency")) + currency = doc.get("currency") if is_multi_currency else doc.company_currency + money = frappe.utils.fmt_money(total_discount, currency=currency) frappe.msgprint(_("Discount of {} applied as per Payment Term").format(money), alert=1) - return paid_amount, received_amount, total_discount + return paid_amount, received_amount, total_discount, valid_discounts + + +def set_pending_discount_loss( + pe, doc, discount_amount, base_total_discount_loss, party_account_currency +): + # If multi-currency, get base discount amount to adjust with base currency deductions/losses + if party_account_currency != doc.company_currency: + discount_amount = discount_amount * doc.get("conversion_rate", 1) + + # Avoid considering miniscule losses + discount_amount = flt(discount_amount - base_total_discount_loss, doc.precision("grand_total")) + + # Set base discount amount (discount loss/pending rounding loss) in deductions + if discount_amount > 0.0: + positive_negative = -1 if pe.payment_type == "Pay" else 1 + + # If tax loss booking is enabled, pending loss will be rounding loss. + # Otherwise it will be the total discount loss. + book_tax_loss = frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss") + account_type = "round_off_account" if book_tax_loss else "default_discount_account" + + pe.set_gain_or_loss( + account_details={ + "account": frappe.get_cached_value("Company", pe.company, account_type), + "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), + "amount": discount_amount * positive_negative, + } + ) + + +def split_early_payment_discount_loss(pe, doc, valid_discounts) -> float: + """Split early payment discount into Income Loss & Tax Loss.""" + total_discount_percent = get_total_discount_percent(doc, valid_discounts) + + if not total_discount_percent: + return 0.0 + + base_loss_on_income = add_income_discount_loss(pe, doc, total_discount_percent) + base_loss_on_taxes = add_tax_discount_loss(pe, doc, total_discount_percent) + + # Round off total loss rather than individual losses to reduce rounding error + return flt(base_loss_on_income + base_loss_on_taxes, doc.precision("grand_total")) + + +def get_total_discount_percent(doc, valid_discounts) -> float: + """Get total percentage and amount discount applied as a percentage.""" + total_discount_percent = ( + sum( + discount.get("discount") for discount in valid_discounts if discount.get("type") == "Percentage" + ) + or 0.0 + ) + + # Operate in percentages only as it makes the income & tax split easier + total_discount_amount = ( + sum(discount.get("discount") for discount in valid_discounts if discount.get("type") == "Amount") + or 0.0 + ) + + if total_discount_amount: + discount_percentage = (total_discount_amount / doc.get("grand_total")) * 100 + total_discount_percent += discount_percentage + return total_discount_percent + + return total_discount_percent + + +def add_income_discount_loss(pe, doc, total_discount_percent) -> float: + """Add loss on income discount in base currency.""" + precision = doc.precision("total") + base_loss_on_income = doc.get("base_total") * (total_discount_percent / 100) + + pe.append( + "deductions", + { + "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), + "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), + "amount": flt(base_loss_on_income, precision), + }, + ) + + return base_loss_on_income # Return loss without rounding + + +def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float: + """Add loss on tax discount in base currency.""" + tax_discount_loss = {} + base_total_tax_loss = 0 + precision = doc.precision("tax_amount_after_discount_amount", "taxes") + + # The same account head could be used more than once + for tax in doc.get("taxes", []): + base_tax_loss = tax.get("base_tax_amount_after_discount_amount") * ( + total_discount_percentage / 100 + ) + + account = tax.get("account_head") + if not tax_discount_loss.get(account): + tax_discount_loss[account] = base_tax_loss + else: + tax_discount_loss[account] += base_tax_loss + + for account, loss in tax_discount_loss.items(): + base_total_tax_loss += loss + if loss == 0.0: + continue + + pe.append( + "deductions", + { + "account": account, + "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), + "amount": flt(loss, precision), + }, + ) + + return base_total_tax_loss # Return loss without rounding def get_reference_as_per_payment_terms( - payment_schedule, dt, dn, doc, grand_total, outstanding_amount + payment_schedule, dt, dn, doc, grand_total, outstanding_amount, party_account_currency ): references = [] + is_multi_currency_acc = (doc.currency != doc.company_currency) and ( + party_account_currency != doc.company_currency + ) + for payment_term in payment_schedule: payment_term_outstanding = flt( payment_term.payment_amount - payment_term.paid_amount, payment_term.precision("payment_amount") ) + if not is_multi_currency_acc: + # If accounting is done in company currency for multi-currency transaction + payment_term_outstanding = flt( + payment_term_outstanding * doc.get("conversion_rate"), payment_term.precision("payment_amount") + ) if payment_term_outstanding: references.append( diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 123b5dfd512..68f333dc29f 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.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 flt, nowdate from erpnext.accounts.doctype.payment_entry.payment_entry import ( @@ -51,6 +51,38 @@ class TestPaymentEntry(FrappeTestCase): so_advance_paid = frappe.db.get_value("Sales Order", so.name, "advance_paid") self.assertEqual(so_advance_paid, 0) + def test_payment_against_sales_order_usd_to_inr(self): + so = make_sales_order( + customer="_Test Customer USD", currency="USD", qty=1, rate=100, do_not_submit=True + ) + so.conversion_rate = 50 + so.submit() + pe = get_payment_entry("Sales Order", so.name) + pe.source_exchange_rate = 55 + pe.received_amount = 5500 + pe.insert() + pe.submit() + + # there should be no difference amount + pe.reload() + self.assertEqual(pe.difference_amount, 0) + self.assertEqual(pe.deductions, []) + + expected_gle = dict( + (d[0], d) + for d in [["_Test Receivable USD - _TC", 0, 5500, so.name], ["Cash - _TC", 5500.0, 0, None]] + ) + + self.validate_gl_entries(pe.name, expected_gle) + + so_advance_paid = frappe.db.get_value("Sales Order", so.name, "advance_paid") + self.assertEqual(so_advance_paid, 100) + + pe.cancel() + + so_advance_paid = frappe.db.get_value("Sales Order", so.name, "advance_paid") + self.assertEqual(so_advance_paid, 0) + def test_payment_entry_for_blocked_supplier_invoice(self): supplier = frappe.get_doc("Supplier", "_Test Supplier") supplier.on_hold = 1 @@ -256,10 +288,25 @@ class TestPaymentEntry(FrappeTestCase): }, ) si.save() - si.submit() + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 1) + pe_with_tax_loss = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + + self.assertEqual(pe_with_tax_loss.references[0].payment_term, "30 Credit Days with 10% Discount") + self.assertEqual(pe_with_tax_loss.references[0].allocated_amount, 236.0) + self.assertEqual(pe_with_tax_loss.paid_amount, 212.4) + self.assertEqual(pe_with_tax_loss.deductions[0].amount, 20.0) # Loss on Income + self.assertEqual(pe_with_tax_loss.deductions[1].amount, 3.6) # Loss on Tax + self.assertEqual(pe_with_tax_loss.deductions[1].account, "_Test Account Service Tax - _TC") + + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 0) pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + + self.assertEqual(pe.references[0].allocated_amount, 236.0) + self.assertEqual(pe.paid_amount, 212.4) + self.assertEqual(pe.deductions[0].amount, 23.6) + pe.submit() si.load_from_db() @@ -269,6 +316,190 @@ class TestPaymentEntry(FrappeTestCase): self.assertEqual(si.payment_schedule[0].outstanding, 0) self.assertEqual(si.payment_schedule[0].discounted_amount, 23.6) + def test_payment_entry_against_payment_terms_with_discount_amount(self): + si = create_sales_invoice(do_not_save=1, qty=1, rate=200) + + si.payment_terms_template = "Test Discount Amount Template" + create_payment_terms_template_with_discount( + name="30 Credit Days with Rs.50 Discount", + discount_type="Amount", + discount=50, + template_name="Test Discount Amount Template", + ) + frappe.db.set_value("Company", si.company, "default_discount_account", "Write Off - _TC") + + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 18, + }, + ) + si.save() + si.submit() + + # Set reference date past discount cut off date + pe_1 = get_payment_entry( + "Sales Invoice", + si.name, + bank_account="_Test Cash - _TC", + reference_date=frappe.utils.add_days(si.posting_date, 2), + ) + self.assertEqual(pe_1.paid_amount, 236.0) # discount not applied + + # Test if tax loss is booked on enabling configuration + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 1) + pe_with_tax_loss = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + self.assertEqual(pe_with_tax_loss.deductions[0].amount, 42.37) # Loss on Income + self.assertEqual(pe_with_tax_loss.deductions[1].amount, 7.63) # Loss on Tax + self.assertEqual(pe_with_tax_loss.deductions[1].account, "_Test Account Service Tax - _TC") + + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 0) + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + self.assertEqual(pe.references[0].allocated_amount, 236.0) + self.assertEqual(pe.paid_amount, 186) + self.assertEqual(pe.deductions[0].amount, 50.0) + + pe.submit() + si.load_from_db() + + self.assertEqual(si.payment_schedule[0].payment_amount, 236.0) + self.assertEqual(si.payment_schedule[0].paid_amount, 186) + self.assertEqual(si.payment_schedule[0].outstanding, 0) + self.assertEqual(si.payment_schedule[0].discounted_amount, 50) + + @change_settings( + "Accounts Settings", + { + "allow_multi_currency_invoices_against_single_party_account": 1, + "book_tax_discount_loss": 1, + }, + ) + def test_payment_entry_multicurrency_si_with_base_currency_accounting_early_payment_discount( + self, + ): + """ + 1. Multi-currency SI with single currency accounting (company currency) + 2. PE with early payment discount + 3. Test if Paid Amount is calculated in company currency + 4. Test if deductions are calculated in company currency + + SI is in USD to document agreed amounts that are in USD, but the accounting is in base currency. + """ + si = create_sales_invoice( + customer="_Test Customer", + currency="USD", + conversion_rate=50, + do_not_save=1, + ) + create_payment_terms_template_with_discount() + si.payment_terms_template = "Test Discount Template" + + frappe.db.set_value("Company", si.company, "default_discount_account", "Write Off - _TC") + si.save() + si.submit() + + pe = get_payment_entry( + "Sales Invoice", + si.name, + bank_account="_Test Bank - _TC", + ) + pe.reference_no = si.name + pe.reference_date = nowdate() + + # Early payment discount loss on income + self.assertEqual(pe.paid_amount, 4500.0) # Amount in company currency + self.assertEqual(pe.received_amount, 4500.0) + self.assertEqual(pe.deductions[0].amount, 500.0) + self.assertEqual(pe.deductions[0].account, "Write Off - _TC") + self.assertEqual(pe.difference_amount, 0.0) + + pe.insert() + pe.submit() + + expected_gle = dict( + (d[0], d) + for d in [ + ["Debtors - _TC", 0, 5000, si.name], + ["_Test Bank - _TC", 4500, 0, None], + ["Write Off - _TC", 500.0, 0, None], + ] + ) + + self.validate_gl_entries(pe.name, expected_gle) + + outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) + self.assertEqual(outstanding_amount, 0) + + def test_payment_entry_multicurrency_accounting_si_with_early_payment_discount(self): + """ + 1. Multi-currency SI with multi-currency accounting + 2. PE with early payment discount and also exchange loss + 3. Test if Paid Amount is calculated in transaction currency + 4. Test if deductions are calculated in base/company currency + 5. Test if exchange loss is reflected in difference + """ + si = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=50, + do_not_save=1, + ) + create_payment_terms_template_with_discount() + si.payment_terms_template = "Test Discount Template" + + frappe.db.set_value("Company", si.company, "default_discount_account", "Write Off - _TC") + si.save() + si.submit() + + pe = get_payment_entry( + "Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700 + ) + pe.reference_no = si.name + pe.reference_date = nowdate() + + # Early payment discount loss on income + self.assertEqual(pe.paid_amount, 90.0) + self.assertEqual(pe.received_amount, 4200.0) # 5000 - 500 (discount) - 300 (exchange loss) + self.assertEqual(pe.deductions[0].amount, 500.0) + self.assertEqual(pe.deductions[0].account, "Write Off - _TC") + + # Exchange loss + self.assertEqual(pe.difference_amount, 300.0) + + pe.append( + "deductions", + { + "account": "_Test Exchange Gain/Loss - _TC", + "cost_center": "_Test Cost Center - _TC", + "amount": 300.0, + }, + ) + + pe.insert() + pe.submit() + + self.assertEqual(pe.difference_amount, 0.0) + + expected_gle = dict( + (d[0], d) + for d in [ + ["_Test Receivable USD - _TC", 0, 5000, si.name], + ["_Test Bank - _TC", 4200, 0, None], + ["Write Off - _TC", 500.0, 0, None], + ["_Test Exchange Gain/Loss - _TC", 300.0, 0, None], + ] + ) + + self.validate_gl_entries(pe.name, expected_gle) + + outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) + self.assertEqual(outstanding_amount, 0) + def test_payment_against_purchase_invoice_to_check_status(self): pi = make_purchase_invoice( supplier="_Test Supplier USD", @@ -839,24 +1070,27 @@ def create_payment_terms_template(): ).insert() -def create_payment_terms_template_with_discount(): +def create_payment_terms_template_with_discount( + name=None, discount_type=None, discount=None, template_name=None +): + create_payment_term(name or "30 Credit Days with 10% Discount") + template_name = template_name or "Test Discount Template" - create_payment_term("30 Credit Days with 10% Discount") - - if not frappe.db.exists("Payment Terms Template", "Test Discount Template"): - payment_term_template = frappe.get_doc( + if not frappe.db.exists("Payment Terms Template", template_name): + frappe.get_doc( { "doctype": "Payment Terms Template", - "template_name": "Test Discount Template", + "template_name": template_name, "allocate_payment_based_on_payment_terms": 1, "terms": [ { "doctype": "Payment Terms Template Detail", - "payment_term": "30 Credit Days with 10% Discount", + "payment_term": name or "30 Credit Days with 10% Discount", "invoice_portion": 100, "credit_days_based_on": "Day(s) after invoice date", "credit_days": 2, - "discount": 10, + "discount_type": discount_type or "Percentage", + "discount": discount or 10, "discount_validity_based_on": "Day(s) after invoice date", "discount_validity": 1, } diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json index 61a1462dd7a..1c31829f0ea 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json @@ -3,6 +3,7 @@ "creation": "2016-06-15 15:56:30.815503", "doctype": "DocType", "editable_grid": 1, + "engine": "InnoDB", "field_order": [ "account", "cost_center", @@ -17,9 +18,7 @@ "in_list_view": 1, "label": "Account", "options": "Account", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "cost_center", @@ -28,37 +27,30 @@ "label": "Cost Center", "options": "Cost Center", "print_hide": 1, - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "Amount", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "label": "Amount (Company Currency)", + "options": "Company:company:default_currency", + "reqd": 1 }, { "fieldname": "column_break_2", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "fieldname": "description", "fieldtype": "Small Text", - "label": "Description", - "show_days": 1, - "show_seconds": 1 + "label": "Description" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-09-12 20:38:08.110674", + "modified": "2023-03-06 07:11:57.739619", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry Deduction", @@ -66,5 +58,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index d986f320669..08d38dde474 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -82,6 +82,32 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo this.frm.change_custom_button_type('Get Unreconciled Entries', null, 'default'); this.frm.change_custom_button_type('Allocate', null, 'default'); } + + // check for any running reconciliation jobs + if (this.frm.doc.receivable_payable_account) { + frappe.db.get_single_value("Accounts Settings", "auto_reconcile_payments").then((enabled) => { + if(enabled) { + this.frm.call({ + 'method': "erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.is_any_doc_running", + "args": { + for_filter: { + company: this.frm.doc.company, + party_type: this.frm.doc.party_type, + party: this.frm.doc.party, + receivable_payable_account: this.frm.doc.receivable_payable_account + } + } + }).then(r => { + if (r.message) { + let doc_link = frappe.utils.get_form_link("Process Payment Reconciliation", r.message, true); + let msg = __("Payment Reconciliation Job: {0} is running for this party. Can't reconcile now.", [doc_link]); + this.frm.dashboard.add_comment(msg, "yellow"); + } + }); + } + }); + } + } company() { @@ -272,4 +298,32 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo } }; +frappe.ui.form.on('Payment Reconciliation Allocation', { + allocated_amount: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + // filter invoice + let invoice = frm.doc.invoices.filter((x) => (x.invoice_number == row.invoice_number)); + // filter payment + let payment = frm.doc.payments.filter((x) => (x.reference_name == row.reference_name)); + + frm.call({ + doc: frm.doc, + method: 'calculate_difference_on_allocation_change', + args: { + payment_entry: payment, + invoice: invoice, + allocated_amount: row.allocated_amount + }, + callback: (r) => { + if (r.message) { + row.difference_amount = r.message; + frm.refresh(); + } + } + }); + } +}); + + + extend_cscript(cur_frm.cscript, new erpnext.accounts.PaymentReconciliationController({frm: cur_frm})); diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index c9e3998ac8a..cc2b9420cc2 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -7,9 +7,12 @@ from frappe import _, msgprint, qb from frappe.model.document import Document from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import IfNull -from frappe.utils import flt, getdate, nowdate, today +from frappe.utils import flt, get_link_to_form, getdate, nowdate, today import erpnext +from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import ( + is_any_doc_running, +) from erpnext.accounts.utils import ( QueryPaymentLedger, get_outstanding_invoices, @@ -233,6 +236,15 @@ class PaymentReconciliation(Document): return difference_amount + @frappe.whitelist() + def calculate_difference_on_allocation_change(self, payment_entry, invoice, allocated_amount): + invoice_exchange_map = self.get_invoice_exchange_map(invoice, payment_entry) + invoice[0]["exchange_rate"] = invoice_exchange_map.get(invoice[0].get("invoice_number")) + new_difference_amount = self.get_difference_amount( + payment_entry[0], invoice[0], allocated_amount + ) + return new_difference_amount + @frappe.whitelist() def allocate_entries(self, args): self.validate_entries() @@ -295,9 +307,7 @@ class PaymentReconciliation(Document): } ) - @frappe.whitelist() - def reconcile(self): - self.validate_allocation() + def reconcile_allocations(self, skip_ref_details_update_for_pe=False): dr_or_cr = ( "credit_in_account_currency" if erpnext.get_party_account_type(self.party_type) == "Receivable" @@ -321,12 +331,35 @@ class PaymentReconciliation(Document): self.make_difference_entry(payment_details) if entry_list: - reconcile_against_document(entry_list) + reconcile_against_document(entry_list, skip_ref_details_update_for_pe) if dr_or_cr_notes: reconcile_dr_cr_note(dr_or_cr_notes, self.company) + @frappe.whitelist() + def reconcile(self): + if frappe.db.get_single_value("Accounts Settings", "auto_reconcile_payments"): + running_doc = is_any_doc_running( + dict( + company=self.company, + party_type=self.party_type, + party=self.party, + receivable_payable_account=self.receivable_payable_account, + ) + ) + + if running_doc: + frappe.throw( + _("A Reconciliation Job {0} is running for the same filters. Cannot reconcile now").format( + get_link_to_form("Auto Reconcile", running_doc) + ) + ) + return + + self.validate_allocation() + self.reconcile_allocations() msgprint(_("Successfully Reconciled")) + self.get_unreconciled_entries() def make_difference_entry(self, row): diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index d9b07435fdb..0955664d98b 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -497,10 +497,16 @@ def get_amount(ref_doc, payment_account=None): if dt in ["Sales Order", "Purchase Order"]: grand_total = flt(ref_doc.rounded_total) or flt(ref_doc.grand_total) elif dt in ["Sales Invoice", "Purchase Invoice"]: - if ref_doc.party_account_currency == ref_doc.currency: - grand_total = flt(ref_doc.outstanding_amount) - else: - grand_total = flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate + if not ref_doc.get("is_pos"): + if ref_doc.party_account_currency == ref_doc.currency: + grand_total = flt(ref_doc.outstanding_amount) + else: + grand_total = flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate + elif dt == "Sales Invoice": + for pay in ref_doc.payments: + if pay.type == "Phone" and pay.account == payment_account: + grand_total = pay.amount + break elif dt == "POS Invoice": for pay in ref_doc.payments: if pay.type == "Phone" and pay.account == payment_account: diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 4279aa4f85c..e17a846dd81 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -6,6 +6,7 @@ import unittest import frappe from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.setup.utils import get_exchange_rate @@ -74,6 +75,29 @@ class TestPaymentRequest(unittest.TestCase): self.assertEqual(pr.reference_name, si_usd.name) self.assertEqual(pr.currency, "USD") + def test_payment_entry_against_purchase_invoice(self): + si_usd = make_purchase_invoice( + customer="_Test Supplier USD", + debit_to="_Test Payable USD - _TC", + currency="USD", + conversion_rate=50, + ) + + pr = make_payment_request( + dt="Purchase Invoice", + dn=si_usd.name, + recipient_id="user@example.com", + mute_email=1, + payment_gateway_account="_Test Gateway - USD", + submit_doc=1, + return_doc=1, + ) + + pe = pr.create_payment_entry() + pr.load_from_db() + + self.assertEqual(pr.status, "Paid") + def test_payment_entry(self): frappe.db.set_value( "Company", "_Test Company", "exchange_gain_loss_account", "_Test Exchange Gain/Loss - _TC" diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index e9ed2e46940..57458c619a1 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -5,7 +5,7 @@ import unittest import frappe -from frappe.utils import today +from frappe.utils import add_months, today from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry @@ -176,6 +176,23 @@ class TestPeriodClosingVoucher(unittest.TestCase): ) self.assertSequenceEqual(pcv_gle, expected_gle) + warehouse = frappe.db.get_value("Warehouse", {"company": company}, "name") + + repost_doc = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "company": company, + "posting_date": "2020-03-15", + "based_on": "Item and Warehouse", + "item_code": "Test Item 1", + "warehouse": warehouse, + } + ) + + self.assertRaises(frappe.ValidationError, repost_doc.save) + + repost_doc.posting_date = add_months(today(), 13) + repost_doc.save() def make_period_closing_voucher(self, submit=True): surplus_account = create_account() diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 0af4c0ea480..27e6f0e598f 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -674,7 +674,7 @@ def get_bin_qty(item_code, warehouse): def get_pos_reserved_qty(item_code, warehouse): reserved_qty = frappe.db.sql( - """select sum(p_item.qty) as qty + """select sum(p_item.stock_qty) as qty from `tabPOS Invoice` p, `tabPOS Invoice Item` p_item where p.name = p_item.parent and ifnull(p.consolidated_invoice, '') = '' diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/__init__.py b/erpnext/accounts/doctype/process_payment_reconciliation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.js b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.js new file mode 100644 index 00000000000..dd601bfc451 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.js @@ -0,0 +1,130 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Process Payment Reconciliation", { + onload: function(frm) { + // set queries + frm.set_query("party_type", function() { + return { + "filters": { + "name": ["in", Object.keys(frappe.boot.party_account_types)], + } + } + }); + frm.set_query('receivable_payable_account', function(doc) { + return { + filters: { + "company": doc.company, + "is_group": 0, + "account_type": frappe.boot.party_account_types[doc.party_type] + } + }; + }); + frm.set_query('cost_center', function(doc) { + return { + filters: { + "company": doc.company, + "is_group": 0, + } + }; + }); + frm.set_query('bank_cash_account', function(doc) { + return { + filters:[ + ['Account', 'company', '=', doc.company], + ['Account', 'is_group', '=', 0], + ['Account', 'account_type', 'in', ['Bank', 'Cash']] + ] + }; + }); + + }, + refresh: function(frm) { + if (frm.doc.docstatus==1 && ['Queued', 'Paused'].find(x => x == frm.doc.status)) { + let execute_btn = __("Start / Resume") + + frm.add_custom_button(execute_btn, () => { + frm.call({ + method: 'erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.trigger_job_for_doc', + args: { + docname: frm.doc.name + } + }).then(r => { + if(!r.exc) { + frappe.show_alert(__("Job Started")); + frm.reload_doc(); + } + }); + }); + } + if (frm.doc.docstatus==1 && ['Completed', 'Running', 'Paused', 'Partially Reconciled'].find(x => x == frm.doc.status)) { + frm.call({ + 'method': "erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.get_reconciled_count", + args: { + "docname": frm.docname, + } + }).then(r => { + if (r.message) { + let progress = 0; + let description = ""; + + if (r.message.processed) { + progress = (r.message.processed/r.message.total) * 100; + description = r.message.processed + "/" + r.message.total + " processed"; + } else if (r.message.total == 0 && frm.doc.status == "Completed") { + progress = 100; + } + + + frm.dashboard.add_progress('Reconciliation Progress', progress, description); + } + }) + } + if (frm.doc.docstatus==1 && frm.doc.status == 'Running') { + let execute_btn = __("Pause") + + frm.add_custom_button(execute_btn, () => { + frm.call({ + 'method': "erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.pause_job_for_doc", + args: { + "docname": frm.docname, + } + }).then(r => { + if (!r.exc) { + frappe.show_alert(__("Job Paused")); + frm.reload_doc() + } + }); + + }); + } + }, + company(frm) { + frm.set_value('party', ''); + frm.set_value('receivable_payable_account', ''); + }, + party_type(frm) { + frm.set_value('party', ''); + }, + + party(frm) { + frm.set_value('receivable_payable_account', ''); + if (!frm.doc.receivable_payable_account && frm.doc.party_type && frm.doc.party) { + return frappe.call({ + method: "erpnext.accounts.party.get_party_account", + args: { + company: frm.doc.company, + party_type: frm.doc.party_type, + party: frm.doc.party + }, + callback: (r) => { + if (!r.exc && r.message) { + frm.set_value("receivable_payable_account", r.message); + } + frm.refresh(); + + } + }); + } + } +}); diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json new file mode 100644 index 00000000000..8bb7092dc50 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json @@ -0,0 +1,173 @@ +{ + "actions": [], + "autoname": "format:ACC-PPR-{#####}", + "beta": 1, + "creation": "2023-03-30 21:28:39.793927", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "party_type", + "column_break_io6c", + "party", + "receivable_payable_account", + "filter_section", + "from_invoice_date", + "to_invoice_date", + "column_break_kegk", + "from_payment_date", + "to_payment_date", + "column_break_uj04", + "cost_center", + "bank_cash_account", + "section_break_2n02", + "status", + "error_log", + "section_break_a8yx", + "amended_from" + ], + "fields": [ + { + "allow_on_submit": 1, + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "\nQueued\nRunning\nPaused\nCompleted\nPartially Reconciled\nFailed\nCancelled", + "read_only": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "party_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Party Type", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "column_break_io6c", + "fieldtype": "Column Break" + }, + { + "fieldname": "party", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Party", + "options": "party_type", + "reqd": 1 + }, + { + "fieldname": "receivable_payable_account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Receivable/Payable Account", + "options": "Account", + "reqd": 1 + }, + { + "fieldname": "filter_section", + "fieldtype": "Section Break", + "label": "Filters" + }, + { + "fieldname": "from_invoice_date", + "fieldtype": "Date", + "label": "From Invoice Date" + }, + { + "fieldname": "to_invoice_date", + "fieldtype": "Date", + "label": "To Invoice Date" + }, + { + "fieldname": "column_break_kegk", + "fieldtype": "Column Break" + }, + { + "fieldname": "from_payment_date", + "fieldtype": "Date", + "label": "From Payment Date" + }, + { + "fieldname": "to_payment_date", + "fieldtype": "Date", + "label": "To Payment Date" + }, + { + "fieldname": "column_break_uj04", + "fieldtype": "Column Break" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "bank_cash_account", + "fieldtype": "Link", + "label": "Bank/Cash Account", + "options": "Account" + }, + { + "fieldname": "section_break_2n02", + "fieldtype": "Section Break", + "label": "Status" + }, + { + "depends_on": "eval:doc.error_log", + "fieldname": "error_log", + "fieldtype": "Long Text", + "label": "Error Log" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Process Payment Reconciliation", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "section_break_a8yx", + "fieldtype": "Section Break" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-04-21 17:19:30.912953", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Process Payment Reconciliation", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "company" +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py new file mode 100644 index 00000000000..ecb51ce1445 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py @@ -0,0 +1,503 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, qb +from frappe.model.document import Document +from frappe.utils import get_link_to_form +from frappe.utils.scheduler import is_scheduler_inactive + + +class ProcessPaymentReconciliation(Document): + def validate(self): + self.validate_receivable_payable_account() + self.validate_bank_cash_account() + + def validate_receivable_payable_account(self): + if self.receivable_payable_account: + if self.company != frappe.db.get_value("Account", self.receivable_payable_account, "company"): + frappe.throw( + _("Receivable/Payable Account: {0} doesn't belong to company {1}").format( + frappe.bold(self.receivable_payable_account), frappe.bold(self.company) + ) + ) + + def validate_bank_cash_account(self): + if self.bank_cash_account: + if self.company != frappe.db.get_value("Account", self.bank_cash_account, "company"): + frappe.throw( + _("Bank/Cash Account {0} doesn't belong to company {1}").format( + frappe.bold(self.bank_cash_account), frappe.bold(self.company) + ) + ) + + def before_save(self): + self.status = "" + self.error_log = "" + + def on_submit(self): + self.db_set("status", "Queued") + self.db_set("error_log", None) + + def on_cancel(self): + self.db_set("status", "Cancelled") + log = frappe.db.get_value( + "Process Payment Reconciliation Log", filters={"process_pr": self.name} + ) + if log: + frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Cancelled") + + +@frappe.whitelist() +def get_reconciled_count(docname: str | None = None) -> float: + current_status = {} + if docname: + reconcile_log = frappe.db.get_value( + "Process Payment Reconciliation Log", filters={"process_pr": docname}, fieldname="name" + ) + if reconcile_log: + res = frappe.get_all( + "Process Payment Reconciliation Log", + filters={"name": reconcile_log}, + fields=["reconciled_entries", "total_allocations"], + as_list=1, + ) + current_status["processed"], current_status["total"] = res[0] + + return current_status + + +def get_pr_instance(doc: str): + process_payment_reconciliation = frappe.get_doc("Process Payment Reconciliation", doc) + + pr = frappe.get_doc("Payment Reconciliation") + fields = [ + "company", + "party_type", + "party", + "receivable_payable_account", + "from_invoice_date", + "to_invoice_date", + "from_payment_date", + "to_payment_date", + ] + d = {} + for field in fields: + d[field] = process_payment_reconciliation.get(field) + pr.update(d) + pr.invoice_limit = 1000 + pr.payment_limit = 1000 + return pr + + +def is_job_running(job_name: str) -> bool: + jobs = frappe.db.get_all("RQ Job", filters={"status": ["in", ["started", "queued"]]}) + for x in jobs: + if x.job_name == job_name: + return True + return False + + +@frappe.whitelist() +def pause_job_for_doc(docname: str | None = None): + if docname: + frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Paused") + log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": docname}) + if log: + frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Paused") + + +@frappe.whitelist() +def trigger_job_for_doc(docname: str | None = None): + """ + Trigger background job + """ + if not docname: + return + + if not frappe.db.get_single_value("Accounts Settings", "auto_reconcile_payments"): + frappe.throw( + _("Auto Reconciliation of Payments has been disabled. Enable it through {0}").format( + get_link_to_form("Accounts Settings", "Accounts Settings") + ) + ) + + return + + if not is_scheduler_inactive(): + if frappe.db.get_value("Process Payment Reconciliation", docname, "status") == "Queued": + frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Running") + job_name = f"start_processing_{docname}" + if not is_job_running(job_name): + job = frappe.enqueue( + method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile_based_on_filters", + queue="long", + is_async=True, + job_name=job_name, + enqueue_after_commit=True, + doc=docname, + ) + + elif frappe.db.get_value("Process Payment Reconciliation", docname, "status") == "Paused": + frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Running") + log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": docname}) + if log: + frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Running") + + # Resume tasks for running doc + job_name = f"start_processing_{docname}" + if not is_job_running(job_name): + job = frappe.enqueue( + method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile_based_on_filters", + queue="long", + is_async=True, + job_name=job_name, + doc=docname, + ) + else: + frappe.msgprint(_("Scheduler is Inactive. Can't trigger job now.")) + + +def trigger_reconciliation_for_queued_docs(): + """ + Will be called from Cron Job + Fetch queued docs and start reconciliation process for each one + """ + if not frappe.db.get_single_value("Accounts Settings", "auto_reconcile_payments"): + frappe.throw( + _("Auto Reconciliation of Payments has been disabled. Enable it through {0}").format( + get_link_to_form("Accounts Settings", "Accounts Settings") + ) + ) + + return + + if not is_scheduler_inactive(): + # Get all queued documents + all_queued = frappe.db.get_all( + "Process Payment Reconciliation", + filters={"docstatus": 1, "status": "Queued"}, + order_by="creation desc", + as_list=1, + ) + + docs_to_trigger = [] + unique_filters = set() + queue_size = 5 + + fields = ["company", "party_type", "party", "receivable_payable_account"] + + def get_filters_as_tuple(fields, doc): + filters = () + for x in fields: + filters += tuple(doc.get(x)) + return filters + + for x in all_queued: + doc = frappe.get_doc("Process Payment Reconciliation", x) + filters = get_filters_as_tuple(fields, doc) + if filters not in unique_filters: + unique_filters.add(filters) + docs_to_trigger.append(doc.name) + if len(docs_to_trigger) == queue_size: + break + + # trigger reconcilation process for queue_size unique filters + for doc in docs_to_trigger: + trigger_job_for_doc(doc) + + else: + frappe.msgprint(_("Scheduler is Inactive. Can't trigger jobs now.")) + + +def reconcile_based_on_filters(doc: None | str = None) -> None: + """ + Identify current state of document and execute next tasks in background + """ + if doc: + log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": doc}) + if not log: + log = frappe.new_doc("Process Payment Reconciliation Log") + log.process_pr = doc + log.status = "Running" + log = log.save() + + job_name = f"process_{doc}_fetch_and_allocate" + if not is_job_running(job_name): + job = frappe.enqueue( + method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.fetch_and_allocate", + queue="long", + timeout="3600", + is_async=True, + job_name=job_name, + enqueue_after_commit=True, + doc=doc, + ) + else: + res = frappe.get_all( + "Process Payment Reconciliation Log", + filters={"name": log}, + fields=["allocated", "reconciled"], + as_list=1, + ) + allocated, reconciled = res[0] + + if not allocated: + job_name = f"process__{doc}_fetch_and_allocate" + if not is_job_running(job_name): + job = frappe.enqueue( + method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.fetch_and_allocate", + queue="long", + timeout="3600", + is_async=True, + job_name=job_name, + enqueue_after_commit=True, + doc=doc, + ) + elif not reconciled: + allocation = get_next_allocation(log) + if allocation: + reconcile_job_name = ( + f"process_{doc}_reconcile_allocation_{allocation[0].idx}_{allocation[-1].idx}" + ) + else: + reconcile_job_name = f"process_{doc}_reconcile" + if not is_job_running(reconcile_job_name): + job = frappe.enqueue( + method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile", + queue="long", + timeout="3600", + is_async=True, + job_name=reconcile_job_name, + enqueue_after_commit=True, + doc=doc, + ) + elif reconciled: + frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed") + + +def get_next_allocation(log: str) -> list: + if log: + allocations = [] + next = frappe.db.get_all( + "Process Payment Reconciliation Log Allocations", + filters={"parent": log, "reconciled": 0}, + fields=["reference_type", "reference_name"], + order_by="idx", + limit=1, + ) + + if next: + allocations = frappe.db.get_all( + "Process Payment Reconciliation Log Allocations", + filters={ + "parent": log, + "reconciled": 0, + "reference_type": next[0].reference_type, + "reference_name": next[0].reference_name, + }, + fields=["*"], + order_by="idx", + ) + + return allocations + return [] + + +def fetch_and_allocate(doc: str) -> None: + """ + Fetch Invoices and Payments based on filters applied. FIFO ordering is used for allocation. + """ + + if doc: + log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": doc}) + if log: + if not frappe.db.get_value("Process Payment Reconciliation Log", log, "allocated"): + reconcile_log = frappe.get_doc("Process Payment Reconciliation Log", log) + + pr = get_pr_instance(doc) + pr.get_unreconciled_entries() + + if len(pr.invoices) > 0 and len(pr.payments) > 0: + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + for x in pr.get("allocation"): + reconcile_log.append( + "allocations", + x.as_dict().update( + { + "parenttype": "Process Payment Reconciliation Log", + "parent": reconcile_log.name, + "name": None, + "reconciled": False, + } + ), + ) + reconcile_log.allocated = True + reconcile_log.total_allocations = len(reconcile_log.get("allocations")) + reconcile_log.reconciled_entries = 0 + reconcile_log.save() + + # generate reconcile job name + allocation = get_next_allocation(log) + if allocation: + reconcile_job_name = ( + f"process_{doc}_reconcile_allocation_{allocation[0].idx}_{allocation[-1].idx}" + ) + else: + reconcile_job_name = f"process_{doc}_reconcile" + + if not is_job_running(reconcile_job_name): + job = frappe.enqueue( + method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile", + queue="long", + timeout="3600", + is_async=True, + job_name=reconcile_job_name, + enqueue_after_commit=True, + doc=doc, + ) + + +def reconcile(doc: None | str = None) -> None: + if doc: + log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": doc}) + if log: + res = frappe.get_all( + "Process Payment Reconciliation Log", + filters={"name": log}, + fields=["reconciled_entries", "total_allocations"], + as_list=1, + limit=1, + ) + + reconciled_entries, total_allocations = res[0] + if reconciled_entries != total_allocations: + try: + # Fetch next allocation + allocations = get_next_allocation(log) + + pr = get_pr_instance(doc) + + # pass allocation to PR instance + for x in allocations: + pr.append("allocation", x) + + # reconcile + pr.reconcile_allocations(skip_ref_details_update_for_pe=True) + + # If Payment Entry, update details only for newly linked references + # This is for performance + if allocations[0].reference_type == "Payment Entry": + + references = [(x.invoice_type, x.invoice_number) for x in allocations] + pe = frappe.get_doc(allocations[0].reference_type, allocations[0].reference_name) + pe.flags.ignore_validate_update_after_submit = True + pe.set_missing_ref_details(update_ref_details_only_for=references) + pe.save() + + # Update reconciled flag + allocation_names = [x.name for x in allocations] + ppa = qb.DocType("Process Payment Reconciliation Log Allocations") + qb.update(ppa).set(ppa.reconciled, True).where(ppa.name.isin(allocation_names)).run() + + # Update reconciled count + reconciled_count = frappe.db.count( + "Process Payment Reconciliation Log Allocations", filters={"parent": log, "reconciled": True} + ) + frappe.db.set_value( + "Process Payment Reconciliation Log", log, "reconciled_entries", reconciled_count + ) + + except Exception as err: + # Update the parent doc about the exception + frappe.db.rollback() + + traceback = frappe.get_traceback() + if traceback: + message = "Traceback:
" + traceback + frappe.db.set_value("Process Payment Reconciliation Log", log, "error_log", message) + frappe.db.set_value( + "Process Payment Reconciliation", + doc, + "error_log", + message, + ) + if reconciled_entries and total_allocations and reconciled_entries < total_allocations: + frappe.db.set_value( + "Process Payment Reconciliation Log", log, "status", "Partially Reconciled" + ) + frappe.db.set_value( + "Process Payment Reconciliation", + doc, + "status", + "Partially Reconciled", + ) + else: + frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Failed") + frappe.db.set_value( + "Process Payment Reconciliation", + doc, + "status", + "Failed", + ) + finally: + if reconciled_entries == total_allocations: + frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Reconciled") + frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True) + frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed") + else: + + if not (frappe.db.get_value("Process Payment Reconciliation", doc, "status") == "Paused"): + # trigger next batch in job + # generate reconcile job name + allocation = get_next_allocation(log) + if allocation: + reconcile_job_name = ( + f"process_{doc}_reconcile_allocation_{allocation[0].idx}_{allocation[-1].idx}" + ) + else: + reconcile_job_name = f"process_{doc}_reconcile" + + if not is_job_running(reconcile_job_name): + job = frappe.enqueue( + method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile", + queue="long", + timeout="3600", + is_async=True, + job_name=reconcile_job_name, + enqueue_after_commit=True, + doc=doc, + ) + else: + frappe.db.set_value("Process Payment Reconciliation Log", log, "status", "Reconciled") + frappe.db.set_value("Process Payment Reconciliation Log", log, "reconciled", True) + frappe.db.set_value("Process Payment Reconciliation", doc, "status", "Completed") + + +@frappe.whitelist() +def is_any_doc_running(for_filter: str | dict | None = None) -> str | None: + running_doc = None + if for_filter: + if type(for_filter) == str: + for_filter = frappe.json.loads(for_filter) + + running_doc = frappe.db.get_value( + "Process Payment Reconciliation", + filters={ + "docstatus": 1, + "status": ["in", ["Running", "Paused"]], + "company": for_filter.get("company"), + "party_type": for_filter.get("party_type"), + "party": for_filter.get("party"), + "receivable_payable_account": for_filter.get("receivable_payable_account"), + }, + fieldname="name", + ) + else: + running_doc = frappe.db.get_value( + "Process Payment Reconciliation", filters={"docstatus": 1, "status": "Running"} + ) + return running_doc diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation_dashboard.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation_dashboard.py new file mode 100644 index 00000000000..784f4548bd4 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation_dashboard.py @@ -0,0 +1,15 @@ +from frappe import _ + + +def get_data(): + return { + "fieldname": "process_pr", + "transactions": [ + { + "label": _("Reconciliation Logs"), + "items": [ + "Process Payment Reconciliation Log", + ], + }, + ], + } diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation_list.js b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation_list.js new file mode 100644 index 00000000000..8012d6e0374 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation_list.js @@ -0,0 +1,15 @@ +frappe.listview_settings['Process Payment Reconciliation'] = { + add_fields: ["status"], + get_indicator: function(doc) { + let colors = { + 'Queued': 'orange', + 'Paused': 'orange', + 'Completed': 'green', + 'Partially Reconciled': 'orange', + 'Running': 'blue', + 'Failed': 'red', + }; + let status = doc.status; + return [__(status), colors[status], 'status,=,'+status]; + }, +}; diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/test_process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/test_process_payment_reconciliation.py new file mode 100644 index 00000000000..ad1e952579d --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation/test_process_payment_reconciliation.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestProcessPaymentReconciliation(FrappeTestCase): + pass diff --git a/erpnext/accounts/doctype/process_payment_reconciliation_log/__init__.py b/erpnext/accounts/doctype/process_payment_reconciliation_log/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log.js b/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log.js new file mode 100644 index 00000000000..2468f10bccf --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log.js @@ -0,0 +1,17 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Process Payment Reconciliation Log", { + refresh(frm) { + if (['Completed', 'Running', 'Paused', 'Partially Reconciled'].find(x => x == frm.doc.status)) { + let progress = 0; + if (frm.doc.reconciled_entries != 0) { + progress = frm.doc.reconciled_entries / frm.doc.total_allocations * 100; + } else if(frm.doc.total_allocations == 0 && frm.doc.status == "Completed"){ + progress = 100; + } + frm.dashboard.add_progress(__('Reconciliation Progress'), progress); + } + + }, +}); diff --git a/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log.json b/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log.json new file mode 100644 index 00000000000..1131a0fca69 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log.json @@ -0,0 +1,137 @@ +{ + "actions": [], + "autoname": "format:PPR-LOG-{##}", + "beta": 1, + "creation": "2023-03-13 15:00:09.149681", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "process_pr", + "section_break_fvdw", + "status", + "tasks_section", + "allocated", + "reconciled", + "column_break_yhin", + "total_allocations", + "reconciled_entries", + "section_break_4ywv", + "error_log", + "allocations_section", + "allocations" + ], + "fields": [ + { + "fieldname": "allocations", + "fieldtype": "Table", + "label": "Allocations", + "options": "Process Payment Reconciliation Log Allocations", + "read_only": 1 + }, + { + "default": "0", + "description": "All allocations have been successfully reconciled", + "fieldname": "reconciled", + "fieldtype": "Check", + "label": "Reconciled", + "read_only": 1 + }, + { + "fieldname": "total_allocations", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Total Allocations", + "read_only": 1 + }, + { + "default": "0", + "description": "Invoices and Payments have been Fetched and Allocated", + "fieldname": "allocated", + "fieldtype": "Check", + "label": "Allocated", + "read_only": 1 + }, + { + "fieldname": "reconciled_entries", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Reconciled Entries", + "read_only": 1 + }, + { + "fieldname": "tasks_section", + "fieldtype": "Section Break", + "label": "Tasks" + }, + { + "fieldname": "allocations_section", + "fieldtype": "Section Break", + "label": "Allocations" + }, + { + "fieldname": "column_break_yhin", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_4ywv", + "fieldtype": "Section Break" + }, + { + "depends_on": "eval:doc.error_log", + "fieldname": "error_log", + "fieldtype": "Long Text", + "label": "Reconciliation Error Log", + "read_only": 1 + }, + { + "fieldname": "process_pr", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Parent Document", + "options": "Process Payment Reconciliation", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "section_break_fvdw", + "fieldtype": "Section Break", + "label": "Status" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Running\nPaused\nReconciled\nPartially Reconciled\nFailed\nCancelled", + "read_only": 1 + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-04-21 17:36:26.642617", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Process Payment Reconciliation Log", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "allocated, reconciled, total_allocations, reconciled_entries", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log.py b/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log.py new file mode 100644 index 00000000000..85d70a48327 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class ProcessPaymentReconciliationLog(Document): + pass diff --git a/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log_list.js b/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log_list.js new file mode 100644 index 00000000000..5a652048a23 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation_log/process_payment_reconciliation_log_list.js @@ -0,0 +1,15 @@ +frappe.listview_settings['Process Payment Reconciliation Log'] = { + add_fields: ["status"], + get_indicator: function(doc) { + var colors = { + 'Partially Reconciled': 'orange', + 'Paused': 'orange', + 'Reconciled': 'green', + 'Failed': 'red', + 'Cancelled': 'red', + 'Running': 'blue', + }; + let status = doc.status; + return [__(status), colors[status], "status,=,"+status]; + }, +}; diff --git a/erpnext/accounts/doctype/process_payment_reconciliation_log/test_process_payment_reconciliation_log.py b/erpnext/accounts/doctype/process_payment_reconciliation_log/test_process_payment_reconciliation_log.py new file mode 100644 index 00000000000..c2da62e2de4 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation_log/test_process_payment_reconciliation_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestProcessPaymentReconciliationLog(FrappeTestCase): + pass diff --git a/erpnext/accounts/doctype/process_payment_reconciliation_log_allocations/__init__.py b/erpnext/accounts/doctype/process_payment_reconciliation_log_allocations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/process_payment_reconciliation_log_allocations/process_payment_reconciliation_log_allocations.json b/erpnext/accounts/doctype/process_payment_reconciliation_log_allocations/process_payment_reconciliation_log_allocations.json new file mode 100644 index 00000000000..b97d73886a9 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation_log_allocations/process_payment_reconciliation_log_allocations.json @@ -0,0 +1,170 @@ +{ + "actions": [], + "creation": "2023-03-13 13:51:27.351463", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_type", + "reference_name", + "reference_row", + "column_break_3", + "invoice_type", + "invoice_number", + "section_break_6", + "allocated_amount", + "unreconciled_amount", + "column_break_8", + "amount", + "is_advance", + "section_break_5", + "difference_amount", + "column_break_7", + "difference_account", + "exchange_rate", + "currency", + "reconciled" + ], + "fields": [ + { + "fieldname": "reference_type", + "fieldtype": "Link", + "label": "Reference Type", + "options": "DocType", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference Name", + "options": "reference_type", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "reference_row", + "fieldtype": "Data", + "hidden": 1, + "label": "Reference Row", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "invoice_type", + "fieldtype": "Link", + "label": "Invoice Type", + "options": "DocType", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "invoice_number", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Invoice Number", + "options": "invoice_type", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, + { + "fieldname": "allocated_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Allocated Amount", + "options": "currency", + "reqd": 1 + }, + { + "fieldname": "unreconciled_amount", + "fieldtype": "Currency", + "hidden": 1, + "label": "Unreconciled Amount", + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "hidden": 1, + "label": "Amount", + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "is_advance", + "fieldtype": "Data", + "hidden": 1, + "label": "Is Advance", + "read_only": 1 + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break" + }, + { + "fieldname": "difference_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Difference Amount", + "options": "Currency", + "read_only": 1 + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, + { + "fieldname": "difference_account", + "fieldtype": "Link", + "label": "Difference Account", + "options": "Account", + "read_only": 1 + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate", + "read_only": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Currency", + "options": "Currency" + }, + { + "default": "0", + "fieldname": "reconciled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Reconciled" + } + ], + "istable": 1, + "links": [], + "modified": "2023-03-20 21:05:43.121945", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Process Payment Reconciliation Log Allocations", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/process_payment_reconciliation_log_allocations/process_payment_reconciliation_log_allocations.py b/erpnext/accounts/doctype/process_payment_reconciliation_log_allocations/process_payment_reconciliation_log_allocations.py new file mode 100644 index 00000000000..c3e43297d08 --- /dev/null +++ b/erpnext/accounts/doctype/process_payment_reconciliation_log_allocations/process_payment_reconciliation_log_allocations.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class ProcessPaymentReconciliationLogAllocations(Document): + pass diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html index b9680dfb3bf..03abc93e0b8 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html @@ -15,7 +15,12 @@

{{ _("STATEMENTS OF ACCOUNTS") }}

-
{{ _("Customer: ") }} {{filters.party_name[0] }}
+ {% if filters.party[0] == filters.party_name[0] %} +
{{ _("Customer: ") }} {{ filters.party_name[0] }}
+ {% else %} +
{{ _("Customer: ") }} {{ filters.party[0] }}
+
{{ _("Customer Name: ") }} {{filters.party_name[0] }}
+ {% endif %}
{{ _("Date: ") }} {{ frappe.format(filters.from_date, 'Date')}} diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json index a26267ba5e8..0620e566b74 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json @@ -34,6 +34,8 @@ "terms_and_conditions", "section_break_1", "enable_auto_email", + "column_break_ocfq", + "sender", "section_break_18", "frequency", "filter_duration", @@ -284,10 +286,32 @@ "fieldtype": "Link", "label": "Terms and Conditions", "options": "Terms and Conditions" + }, + { + "default": "1", + "fieldname": "include_break", + "fieldtype": "Check", + "label": "Page Break After Each SoA" + }, + { + "default": "0", + "fieldname": "show_net_values_in_party_account", + "fieldtype": "Check", + "label": "Show Net Values in Party Account" + }, + { + "fieldname": "sender", + "fieldtype": "Link", + "label": "Sender", + "options": "Email Account" + }, + { + "fieldname": "column_break_ocfq", + "fieldtype": "Column Break" } ], "links": [], - "modified": "2021-09-06 21:00:45.732505", + "modified": "2023-04-26 12:46:43.645455", "modified_by": "Administrator", "module": "Accounts", "name": "Process Statement Of Accounts", diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 39b80143208..18d2f3b5a13 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -327,7 +327,7 @@ def send_emails(document_name, from_scheduler=False): queue="short", method=frappe.sendmail, recipients=recipients, - sender=frappe.session.user, + sender=doc.sender or frappe.session.user, cc=cc, subject=subject, message=message, diff --git a/erpnext/accounts/doctype/process_statement_of_accounts_customer/process_statement_of_accounts_customer.json b/erpnext/accounts/doctype/process_statement_of_accounts_customer/process_statement_of_accounts_customer.json index 8bffd6a93b9..1749d72e167 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts_customer/process_statement_of_accounts_customer.json +++ b/erpnext/accounts/doctype/process_statement_of_accounts_customer/process_statement_of_accounts_customer.json @@ -27,7 +27,7 @@ }, { "fieldname": "billing_email", - "fieldtype": "Read Only", + "fieldtype": "Data", "in_list_view": 1, "label": "Billing Email" }, @@ -41,7 +41,7 @@ ], "istable": 1, "links": [], - "modified": "2023-03-13 00:12:34.508086", + "modified": "2023-04-26 13:02:41.964499", "modified_by": "Administrator", "module": "Accounts", "name": "Process Statement Of Accounts Customer", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index e2b4a1ad5be..5c9168bf9c5 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -82,7 +82,11 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. if(doc.docstatus == 1 && doc.outstanding_amount != 0 && !(doc.is_return && doc.return_against) && !doc.on_hold) { - this.frm.add_custom_button(__('Payment'), this.make_payment_entry, __('Create')); + this.frm.add_custom_button( + __('Payment'), + () => this.make_payment_entry(), + __('Create') + ); cur_frm.page.set_inner_btn_group_as_primary(__('Create')); } diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 629a0ffb582..719ef7d4c44 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -89,6 +89,7 @@ "column_break8", "grand_total", "rounding_adjustment", + "use_company_roundoff_cost_center", "rounded_total", "in_words", "total_advance", @@ -118,6 +119,7 @@ "paid_amount", "advances_section", "allocate_advances_automatically", + "only_include_allocated_payments", "get_advances", "advances", "advance_tax", @@ -1550,17 +1552,30 @@ "fieldname": "named_place", "fieldtype": "Data", "label": "Named Place" + }, + { + "default": "0", + "depends_on": "allocate_advances_automatically", + "description": "Advance payments allocated against orders will only be fetched", + "fieldname": "only_include_allocated_payments", + "fieldtype": "Check", + "label": "Only Include Allocated Payments" + }, + { + "default": "0", + "fieldname": "use_company_roundoff_cost_center", + "fieldtype": "Check", + "label": "Use Company Default Round Off Cost Center" } ], "icon": "fa fa-file-text", "idx": 204, "is_submittable": 1, "links": [], - "modified": "2023-01-28 19:18:56.586321", + "modified": "2023-04-28 12:57:50.832598", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", - "name_case": "Title Case", "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index ae707ab1435..6d905a37cde 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -117,7 +117,7 @@ class PurchaseInvoice(BuyingController): self.validate_expense_account() self.set_against_expense_account() self.validate_write_off_account() - self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount", "items") + self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount") self.create_remarks() self.set_status() self.validate_purchase_receipt_if_update_stock() @@ -232,7 +232,7 @@ class PurchaseInvoice(BuyingController): ) if ( - cint(frappe.db.get_single_value("Buying Settings", "maintain_same_rate")) + cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate")) and not self.is_return and not self.is_internal_supplier ): @@ -581,6 +581,7 @@ class PurchaseInvoice(BuyingController): self.make_supplier_gl_entry(gl_entries) self.make_item_gl_entries(gl_entries) + self.make_precision_loss_gl_entry(gl_entries) if self.check_asset_cwip_enabled(): self.get_asset_gl_entry(gl_entries) @@ -975,6 +976,30 @@ class PurchaseInvoice(BuyingController): item.item_tax_amount, item.precision("item_tax_amount") ) + def make_precision_loss_gl_entry(self, gl_entries): + round_off_account, round_off_cost_center = get_round_off_account_and_cost_center( + self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center + ) + + precision_loss = self.get("base_net_total") - flt( + self.get("net_total") * self.conversion_rate, self.precision("net_total") + ) + + if precision_loss: + gl_entries.append( + self.get_gl_dict( + { + "account": round_off_account, + "against": self.supplier, + "credit": precision_loss, + "cost_center": round_off_cost_center + if self.use_company_roundoff_cost_center + else self.cost_center or round_off_cost_center, + "remarks": _("Net total calculation precision loss"), + } + ) + ) + def get_asset_gl_entry(self, gl_entries): arbnb_account = self.get_company_default("asset_received_but_not_billed") eiiav_account = self.get_company_default("expenses_included_in_asset_valuation") @@ -1363,7 +1388,7 @@ class PurchaseInvoice(BuyingController): not self.is_internal_transfer() and self.rounding_adjustment and self.base_rounding_adjustment ): round_off_account, round_off_cost_center = get_round_off_account_and_cost_center( - self.company, "Purchase Invoice", self.name + self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center ) gl_entries.append( @@ -1373,7 +1398,9 @@ class PurchaseInvoice(BuyingController): "against": self.supplier, "debit_in_account_currency": self.rounding_adjustment, "debit": self.base_rounding_adjustment, - "cost_center": self.cost_center or round_off_cost_center, + "cost_center": round_off_cost_center + if self.use_company_roundoff_cost_center + else (self.cost_center or round_off_cost_center), }, item=self, ) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 47e3f9b9354..8cb29505eb2 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -93,9 +93,12 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e if (doc.docstatus == 1 && doc.outstanding_amount!=0 && !(cint(doc.is_return) && doc.return_against)) { - cur_frm.add_custom_button(__('Payment'), - this.make_payment_entry, __('Create')); - cur_frm.page.set_inner_btn_group_as_primary(__('Create')); + this.frm.add_custom_button( + __('Payment'), + () => this.make_payment_entry(), + __('Create') + ); + this.frm.page.set_inner_btn_group_as_primary(__('Create')); } if(doc.docstatus==1 && !doc.is_return) { @@ -331,6 +334,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e } make_inter_company_invoice() { + let me = this; frappe.model.open_mapped_doc({ method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_inter_company_purchase_invoice", frm: me.frm diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 9a5b42be4bb..0e6118abe6b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -79,6 +79,7 @@ "column_break5", "grand_total", "rounding_adjustment", + "use_company_roundoff_cost_center", "rounded_total", "in_words", "total_advance", @@ -120,6 +121,7 @@ "account_for_change_amount", "advances_section", "allocate_advances_automatically", + "only_include_allocated_payments", "get_advances", "advances", "write_off_section", @@ -2126,6 +2128,19 @@ "label": "Repost Required", "no_copy": 1, "read_only": 1 + }, + { + "depends_on": "allocate_advances_automatically", + "description": "Advance payments allocated against orders will only be fetched", + "fieldname": "only_include_allocated_payments", + "fieldtype": "Check", + "label": "Only Include Allocated Payments" + }, + { + "default": "0", + "fieldname": "use_company_roundoff_cost_center", + "fieldtype": "Check", + "label": "Use Company default Cost Center for Round off" } ], "icon": "fa fa-file-text", @@ -2138,7 +2153,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2023-03-13 11:43:15.883055", + "modified": "2023-04-28 14:15:59.901154", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index f5be4c7a3f3..bb85fedab05 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -145,7 +145,7 @@ class SalesInvoice(SellingController): self.set_against_income_account() self.validate_time_sheets_are_submitted() - self.validate_multiple_billing("Delivery Note", "dn_detail", "amount", "items") + self.validate_multiple_billing("Delivery Note", "dn_detail", "amount") if not self.is_return: self.validate_serial_numbers() else: @@ -1450,7 +1450,7 @@ class SalesInvoice(SellingController): and not self.is_internal_transfer() ): round_off_account, round_off_cost_center = get_round_off_account_and_cost_center( - self.company, "Sales Invoice", self.name + self.company, "Sales Invoice", self.name, self.use_company_roundoff_cost_center ) gl_entries.append( @@ -1462,7 +1462,9 @@ class SalesInvoice(SellingController): self.rounding_adjustment, self.precision("rounding_adjustment") ), "credit": flt(self.base_rounding_adjustment, self.precision("base_rounding_adjustment")), - "cost_center": self.cost_center or round_off_cost_center, + "cost_center": round_off_cost_center + if self.use_company_roundoff_cost_center + else (self.cost_center or round_off_cost_center), }, item=self, ) diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index f0146ea70eb..ad3477ef3d3 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -215,7 +215,7 @@ def get_tax_row_for_tds(tax_details, tax_amount): } -def get_lower_deduction_certificate(tax_details, pan_no): +def get_lower_deduction_certificate(company, tax_details, pan_no): ldc_name = frappe.db.get_value( "Lower Deduction Certificate", { @@ -223,6 +223,7 @@ def get_lower_deduction_certificate(tax_details, pan_no): "tax_withholding_category": tax_details.tax_withholding_category, "valid_from": (">=", tax_details.from_date), "valid_upto": ("<=", tax_details.to_date), + "company": company, }, "name", ) @@ -255,7 +256,7 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N tax_amount = 0 if party_type == "Supplier": - ldc = get_lower_deduction_certificate(tax_details, pan_no) + ldc = get_lower_deduction_certificate(inv.company, tax_details, pan_no) if tax_deducted: net_total = inv.tax_withholding_net_total if ldc: diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 41fdb6a97f8..7beb7cc3b19 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -472,7 +472,9 @@ def update_accounting_dimensions(round_off_gle): round_off_gle[dimension] = dimension_values.get(dimension) -def get_round_off_account_and_cost_center(company, voucher_type, voucher_no): +def get_round_off_account_and_cost_center( + company, voucher_type, voucher_no, use_company_default=False +): round_off_account, round_off_cost_center = frappe.get_cached_value( "Company", company, ["round_off_account", "round_off_cost_center"] ) or [None, None] @@ -480,7 +482,7 @@ def get_round_off_account_and_cost_center(company, voucher_type, voucher_no): meta = frappe.get_meta(voucher_type) # Give first preference to parent cost center for round off GLE - if meta.has_field("cost_center"): + if not use_company_default and meta.has_field("cost_center"): parent_cost_center = frappe.db.get_value(voucher_type, voucher_no, "cost_center") if parent_cost_center: round_off_cost_center = parent_cost_center diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 22cff133089..30d949b73b6 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -259,6 +259,8 @@ def set_address_details( ) if doctype in TRANSACTION_TYPES: + # required to set correct region + frappe.flags.company = company get_regional_address_details(party_details, doctype, company) return party_address, shipping_address diff --git a/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py b/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py index 57d80492ae0..f21c94b4940 100644 --- a/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py +++ b/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py @@ -25,6 +25,7 @@ def get_data(filters): ["posting_date", "<=", filters.get("to_date")], ["against_voucher_type", "=", "Asset"], ["account", "in", depreciation_accounts], + ["is_cancelled", "=", 0], ] if filters.get("asset"): diff --git a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py index 9cea37c4f80..d67eee3552d 100644 --- a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py +++ b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py @@ -114,28 +114,6 @@ def get_assets(filters): sum(results.depreciation_eliminated_during_the_period) as depreciation_eliminated_during_the_period, sum(results.depreciation_amount_during_the_period) as depreciation_amount_during_the_period from (SELECT a.asset_category, - ifnull(sum(case when ds.schedule_date < %(from_date)s and (ifnull(a.disposal_date, 0) = 0 or a.disposal_date >= %(from_date)s) then - ds.depreciation_amount - else - 0 - end), 0) as accumulated_depreciation_as_on_from_date, - ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and a.disposal_date >= %(from_date)s - and a.disposal_date <= %(to_date)s and ds.schedule_date <= a.disposal_date then - ds.depreciation_amount - else - 0 - end), 0) as depreciation_eliminated_during_the_period, - ifnull(sum(case when ds.schedule_date >= %(from_date)s and ds.schedule_date <= %(to_date)s - and (ifnull(a.disposal_date, 0) = 0 or ds.schedule_date <= a.disposal_date) then - ds.depreciation_amount - else - 0 - end), 0) as depreciation_amount_during_the_period - from `tabAsset` a, `tabDepreciation Schedule` ds - where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s and a.name = ds.parent and ifnull(ds.journal_entry, '') != '' - group by a.asset_category - union - SELECT a.asset_category, ifnull(sum(case when gle.posting_date < %(from_date)s and (ifnull(a.disposal_date, 0) = 0 or a.disposal_date >= %(from_date)s) then gle.debit else @@ -160,7 +138,7 @@ def get_assets(filters): aca.parent = a.asset_category and aca.company_name = %(company)s join `tabCompany` company on company.name = %(company)s - where a.docstatus=1 and a.company=%(company)s and a.calculate_depreciation=0 and a.purchase_date <= %(to_date)s and gle.debit != 0 and gle.is_cancelled = 0 and gle.account = ifnull(aca.depreciation_expense_account, company.depreciation_expense_account) + where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s and gle.debit != 0 and gle.is_cancelled = 0 and gle.account = ifnull(aca.depreciation_expense_account, company.depreciation_expense_account) group by a.asset_category union SELECT a.asset_category, diff --git a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py index 449ebdcd924..2d68bb70b83 100644 --- a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py +++ b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.custom import ConstantColumn from frappe.utils import getdate, nowdate @@ -79,7 +80,7 @@ def get_entries(filters): payment_entries = frappe.db.sql( """SELECT "Payment Entry", name, posting_date, reference_no, clearance_date, party, - if(paid_from=%(account)s, paid_amount * -1, received_amount) + if(paid_from=%(account)s, ((paid_amount * -1) - total_taxes_and_charges) , received_amount) FROM `tabPayment Entry` WHERE @@ -91,4 +92,65 @@ def get_entries(filters): as_list=1, ) - return sorted(journal_entries + payment_entries, key=lambda k: k[2] or getdate(nowdate())) + # Loan Disbursement + loan_disbursement = frappe.qb.DocType("Loan Disbursement") + + query = ( + frappe.qb.from_(loan_disbursement) + .select( + ConstantColumn("Loan Disbursement").as_("payment_document_type"), + loan_disbursement.name.as_("payment_entry"), + loan_disbursement.disbursement_date.as_("posting_date"), + loan_disbursement.reference_number.as_("cheque_no"), + loan_disbursement.clearance_date.as_("clearance_date"), + loan_disbursement.applicant.as_("against"), + -loan_disbursement.disbursed_amount.as_("amount"), + ) + .where(loan_disbursement.docstatus == 1) + .where(loan_disbursement.disbursement_date >= filters["from_date"]) + .where(loan_disbursement.disbursement_date <= filters["to_date"]) + .where(loan_disbursement.disbursement_account == filters["account"]) + .orderby(loan_disbursement.disbursement_date, order=frappe.qb.desc) + .orderby(loan_disbursement.name, order=frappe.qb.desc) + ) + + if filters.get("from_date"): + query = query.where(loan_disbursement.disbursement_date >= filters["from_date"]) + if filters.get("to_date"): + query = query.where(loan_disbursement.disbursement_date <= filters["to_date"]) + + loan_disbursements = query.run(as_list=1) + + # Loan Repayment + loan_repayment = frappe.qb.DocType("Loan Repayment") + + query = ( + frappe.qb.from_(loan_repayment) + .select( + ConstantColumn("Loan Repayment").as_("payment_document_type"), + loan_repayment.name.as_("payment_entry"), + loan_repayment.posting_date.as_("posting_date"), + loan_repayment.reference_number.as_("cheque_no"), + loan_repayment.clearance_date.as_("clearance_date"), + loan_repayment.applicant.as_("against"), + loan_repayment.amount_paid.as_("amount"), + ) + .where(loan_repayment.docstatus == 1) + .where(loan_repayment.posting_date >= filters["from_date"]) + .where(loan_repayment.posting_date <= filters["to_date"]) + .where(loan_repayment.payment_account == filters["account"]) + .orderby(loan_repayment.posting_date, order=frappe.qb.desc) + .orderby(loan_repayment.name, order=frappe.qb.desc) + ) + + if filters.get("from_date"): + query = query.where(loan_repayment.posting_date >= filters["from_date"]) + if filters.get("to_date"): + query = query.where(loan_repayment.posting_date <= filters["to_date"]) + + loan_repayments = query.run(as_list=1) + + return sorted( + journal_entries + payment_entries + loan_disbursements + loan_repayments, + key=lambda k: k[2] or getdate(nowdate()), + ) diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py index 36825771f8a..86fdaaa924a 100644 --- a/erpnext/accounts/report/financial_statements.py +++ b/erpnext/accounts/report/financial_statements.py @@ -492,11 +492,22 @@ def get_additional_conditions(from_date, ignore_closing_entries, filters): additional_conditions.append("cost_center in %(cost_center)s") if filters.get("include_default_book_entries"): - additional_conditions.append( - "(finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" - ) + if filters.get("finance_book"): + if filters.get("company_fb") and cstr(filters.get("finance_book")) != cstr( + filters.get("company_fb") + ): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default Book Entries'") + ) + else: + additional_conditions.append("(finance_book in (%(finance_book)s) OR finance_book IS NULL)") + else: + additional_conditions.append("(finance_book in (%(company_fb)s) OR finance_book IS NULL)") else: - additional_conditions.append("(finance_book in (%(finance_book)s, '') OR finance_book IS NULL)") + if filters.get("finance_book"): + additional_conditions.append("(finance_book in (%(finance_book)s) OR finance_book IS NULL)") + else: + additional_conditions.append("(finance_book IS NULL)") if accounting_dimensions: for dimension in accounting_dimensions: diff --git a/erpnext/accounts/report/general_ledger/general_ledger.js b/erpnext/accounts/report/general_ledger/general_ledger.js index 2100f26c1ec..57a9091cf9b 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.js +++ b/erpnext/accounts/report/general_ledger/general_ledger.js @@ -176,7 +176,8 @@ frappe.query_reports["General Ledger"] = { { "fieldname": "include_default_book_entries", "label": __("Include Default Book Entries"), - "fieldtype": "Check" + "fieldtype": "Check", + "default": 1 }, { "fieldname": "show_cancelled_entries", diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 745b6dfbdce..6a4f394ebc8 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -244,13 +244,23 @@ def get_conditions(filters): if filters.get("project"): conditions.append("project in %(project)s") - if filters.get("finance_book"): - if filters.get("include_default_book_entries"): - conditions.append( - "(finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" - ) + if filters.get("include_default_book_entries"): + if filters.get("finance_book"): + if filters.get("company_fb") and cstr(filters.get("finance_book")) != cstr( + filters.get("company_fb") + ): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default Book Entries'") + ) + else: + conditions.append("(finance_book in (%(finance_book)s) OR finance_book IS NULL)") else: - conditions.append("finance_book in (%(finance_book)s)") + conditions.append("(finance_book in (%(company_fb)s) OR finance_book IS NULL)") + else: + if filters.get("finance_book"): + conditions.append("(finance_book in (%(finance_book)s) OR finance_book IS NULL)") + else: + conditions.append("(finance_book IS NULL)") if not filters.get("show_cancelled_entries"): conditions.append("is_cancelled = 0") diff --git a/erpnext/accounts/report/general_ledger/test_general_ledger.py b/erpnext/accounts/report/general_ledger/test_general_ledger.py index c5637857636..a8c362e78c1 100644 --- a/erpnext/accounts/report/general_ledger/test_general_ledger.py +++ b/erpnext/accounts/report/general_ledger/test_general_ledger.py @@ -24,7 +24,6 @@ class TestGeneralLedger(FrappeTestCase): "root_type": "Asset", "report_type": "Balance Sheet", "account_currency": "USD", - "inter_company_account": 0, "parent_account": "Bank Accounts - _TC", "account_type": "Bank", "doctype": "Account", diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js index 615804ef623..cf4ccef2433 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.js +++ b/erpnext/accounts/report/gross_profit/gross_profit.js @@ -59,7 +59,7 @@ frappe.query_reports["Gross Profit"] = { if (column.fieldname == "sales_invoice" && column.options == "Item" && data && data.indent == 0) { column._options = "Sales Invoice"; } else { - column._options = "Item"; + column._options = ""; } value = default_formatter(value, row, column, data); diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 41ba11adc6f..224f79babad 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -250,7 +250,7 @@ def get_columns(group_wise_columns, filters): "label": _("Warehouse"), "fieldname": "warehouse", "fieldtype": "Link", - "options": "warehouse", + "options": "Warehouse", "width": 100, }, "qty": {"label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", "width": 80}, @@ -305,7 +305,8 @@ def get_columns(group_wise_columns, filters): "sales_person": { "label": _("Sales Person"), "fieldname": "sales_person", - "fieldtype": "Data", + "fieldtype": "Link", + "options": "Sales Person", "width": 100, }, "allocated_amount": { @@ -326,14 +327,14 @@ def get_columns(group_wise_columns, filters): "label": _("Customer Group"), "fieldname": "customer_group", "fieldtype": "Link", - "options": "customer", + "options": "Customer Group", "width": 100, }, "territory": { "label": _("Territory"), "fieldname": "territory", "fieldtype": "Link", - "options": "territory", + "options": "Territory", "width": 100, }, "monthly": { diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index bfe2a0fd2be..98838907be1 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -4,7 +4,6 @@ import frappe from frappe import _ -from frappe.utils import flt def execute(filters=None): @@ -66,12 +65,6 @@ def get_result( else: total_amount_credited += entry.credit - ## Check if ldc is applied and show rate as per ldc - actual_rate = (tds_deducted / total_amount_credited) * 100 - - if flt(actual_rate) < flt(rate): - rate = actual_rate - if tds_deducted: row = { "pan" diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 61bc58009a6..d2300cd1238 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -157,12 +157,23 @@ def get_rootwise_opening_balances(filters, report_type): if filters.project: additional_conditions += " and project = %(project)s" + company_fb = frappe.db.get_value("Company", filters.company, "default_finance_book") + if filters.get("include_default_book_entries"): - additional_conditions += ( - " AND (finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)" - ) + if filters.get("finance_book"): + if company_fb and cstr(filters.get("finance_book")) != cstr(company_fb): + frappe.throw( + _("To use a different finance book, please uncheck 'Include Default Book Entries'") + ) + else: + additional_conditions += " AND (finance_book in (%(finance_book)s) OR finance_book IS NULL)" + else: + additional_conditions += " AND (finance_book in (%(company_fb)s) OR finance_book IS NULL)" else: - additional_conditions += " AND (finance_book in (%(finance_book)s, '') OR finance_book IS NULL)" + if filters.get("finance_book"): + additional_conditions += " AND (finance_book in (%(finance_book)s) OR finance_book IS NULL)" + else: + additional_conditions += " AND (finance_book IS NULL)" accounting_dimensions = get_accounting_dimensions(as_list=False) @@ -174,7 +185,7 @@ def get_rootwise_opening_balances(filters, report_type): "year_start_date": filters.year_start_date, "project": filters.project, "finance_book": filters.finance_book, - "company_fb": frappe.db.get_value("Company", filters.company, "default_finance_book"), + "company_fb": company_fb, } if accounting_dimensions: diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 4ab0d56f681..320cf9de68c 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -420,7 +420,7 @@ def add_cc(args=None): return cc.name -def reconcile_against_document(args): # nosemgrep +def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # nosemgrep """ Cancel PE or JV, Update against document, split if required and resubmit """ @@ -449,7 +449,9 @@ def reconcile_against_document(args): # nosemgrep if voucher_type == "Journal Entry": update_reference_in_journal_entry(entry, doc, do_not_save=True) else: - update_reference_in_payment_entry(entry, doc, do_not_save=True) + update_reference_in_payment_entry( + entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe + ) doc.save(ignore_permissions=True) # re-submit advance entry @@ -586,7 +588,9 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False): journal_entry.save(ignore_permissions=True) -def update_reference_in_payment_entry(d, payment_entry, do_not_save=False): +def update_reference_in_payment_entry( + d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False +): reference_details = { "reference_doctype": d.against_voucher_type, "reference_name": d.against_voucher, @@ -632,6 +636,13 @@ def update_reference_in_payment_entry(d, payment_entry, do_not_save=False): payment_entry.set_gain_or_loss(account_details=account_details) + payment_entry.flags.ignore_validate_update_after_submit = True + payment_entry.setup_party_account_field() + payment_entry.set_missing_values() + if not skip_ref_details_update_for_pe: + payment_entry.set_missing_ref_details() + payment_entry.set_amounts() + if not do_not_save: payment_entry.save(ignore_permissions=True) @@ -1352,10 +1363,7 @@ def get_stock_and_account_balance(account=None, posting_date=None, company=None) if wh_details.account == account and not wh_details.is_group ] - total_stock_value = 0.0 - for warehouse in related_warehouses: - value = get_stock_value_on(warehouse, posting_date) - total_stock_value += value + total_stock_value = get_stock_value_on(related_warehouses, posting_date) precision = frappe.get_precision("Journal Entry Account", "debit_in_account_currency") return flt(account_balance, precision), flt(total_stock_value, precision), related_warehouses diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 47b5f75e668..fe1fd98aa09 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -294,17 +294,42 @@ class Asset(AccountsController): if has_pro_rata: number_of_pending_depreciations += 1 + has_wdv_or_dd_non_yearly_pro_rata = False + if ( + finance_book.depreciation_method in ("Written Down Value", "Double Declining Balance") + and cint(finance_book.frequency_of_depreciation) != 12 + ): + has_wdv_or_dd_non_yearly_pro_rata = self.check_is_pro_rata( + finance_book, wdv_or_dd_non_yearly=True + ) + skip_row = False should_get_last_day = is_last_day_of_the_month(finance_book.depreciation_start_date) + depreciation_amount = 0 + for n in range(start[finance_book.idx - 1], number_of_pending_depreciations): # If depreciation is already completed (for double declining balance) if skip_row: continue - depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book) + if n > 0 and len(self.get("schedules")) > n - 1: + prev_depreciation_amount = self.get("schedules")[n - 1].depreciation_amount + else: + prev_depreciation_amount = 0 - if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: + depreciation_amount = get_depreciation_amount( + self, + value_after_depreciation, + finance_book, + n, + prev_depreciation_amount, + has_wdv_or_dd_non_yearly_pro_rata, + ) + + if not has_pro_rata or ( + n < (cint(number_of_pending_depreciations) - 1) or number_of_pending_depreciations == 2 + ): schedule_date = add_months( finance_book.depreciation_start_date, n * cint(finance_book.frequency_of_depreciation) ) @@ -320,7 +345,10 @@ class Asset(AccountsController): if date_of_disposal: from_date = self.get_from_date(finance_book.finance_book) depreciation_amount, days, months = self.get_pro_rata_amt( - finance_book, depreciation_amount, from_date, date_of_disposal + finance_book, + depreciation_amount, + from_date, + date_of_disposal, ) if depreciation_amount > 0: @@ -335,12 +363,20 @@ class Asset(AccountsController): break # For first row - if has_pro_rata and not self.opening_accumulated_depreciation and n == 0: + if ( + (has_pro_rata or has_wdv_or_dd_non_yearly_pro_rata) + and not self.opening_accumulated_depreciation + and n == 0 + ): from_date = add_days( self.available_for_use_date, -1 ) # needed to calc depr amount for available_for_use_date too depreciation_amount, days, months = self.get_pro_rata_amt( - finance_book, depreciation_amount, from_date, finance_book.depreciation_start_date + finance_book, + depreciation_amount, + from_date, + finance_book.depreciation_start_date, + has_wdv_or_dd_non_yearly_pro_rata, ) # For first depr schedule date will be the start date @@ -359,7 +395,11 @@ class Asset(AccountsController): depreciation_amount_without_pro_rata = depreciation_amount depreciation_amount, days, months = self.get_pro_rata_amt( - finance_book, depreciation_amount, schedule_date, self.to_date + finance_book, + depreciation_amount, + schedule_date, + self.to_date, + has_wdv_or_dd_non_yearly_pro_rata, ) depreciation_amount = self.get_adjusted_depreciation_amount( @@ -479,28 +519,37 @@ class Asset(AccountsController): return add_days(self.available_for_use_date, -1) # if it returns True, depreciation_amount will not be equal for the first and last rows - def check_is_pro_rata(self, row): + def check_is_pro_rata(self, row, wdv_or_dd_non_yearly=False): has_pro_rata = False # if not existing asset, from_date = available_for_use_date # otherwise, if number_of_depreciations_booked = 2, available_for_use_date = 01/01/2020 and frequency_of_depreciation = 12 # from_date = 01/01/2022 - from_date = self.get_modified_available_for_use_date(row) + from_date = self.get_modified_available_for_use_date(row, wdv_or_dd_non_yearly) days = date_diff(row.depreciation_start_date, from_date) + 1 - # if frequency_of_depreciation is 12 months, total_days = 365 - total_days = get_total_days(row.depreciation_start_date, row.frequency_of_depreciation) + if wdv_or_dd_non_yearly: + total_days = get_total_days(row.depreciation_start_date, 12) + else: + # if frequency_of_depreciation is 12 months, total_days = 365 + total_days = get_total_days(row.depreciation_start_date, row.frequency_of_depreciation) if days < total_days: has_pro_rata = True return has_pro_rata - def get_modified_available_for_use_date(self, row): - return add_months( - self.available_for_use_date, - (self.number_of_depreciations_booked * row.frequency_of_depreciation), - ) + def get_modified_available_for_use_date(self, row, wdv_or_dd_non_yearly=False): + if wdv_or_dd_non_yearly: + return add_months( + self.available_for_use_date, + (self.number_of_depreciations_booked * 12), + ) + else: + return add_months( + self.available_for_use_date, + (self.number_of_depreciations_booked * row.frequency_of_depreciation), + ) def validate_asset_finance_books(self, row): if flt(row.expected_value_after_useful_life) >= flt(self.gross_purchase_amount): @@ -903,7 +952,12 @@ class Asset(AccountsController): float_precision = cint(frappe.db.get_default("float_precision")) or 2 if args.get("depreciation_method") == "Double Declining Balance": - return 200.0 / args.get("total_number_of_depreciations") + return 200.0 / ( + ( + flt(args.get("total_number_of_depreciations"), 2) * flt(args.get("frequency_of_depreciation")) + ) + / 12 + ) if args.get("depreciation_method") == "Written Down Value": if ( @@ -920,14 +974,29 @@ class Asset(AccountsController): else: value = flt(args.get("expected_value_after_useful_life")) / flt(self.gross_purchase_amount) - depreciation_rate = math.pow(value, 1.0 / flt(args.get("total_number_of_depreciations"), 2)) + depreciation_rate = math.pow( + value, + 1.0 + / ( + ( + flt(args.get("total_number_of_depreciations"), 2) + * flt(args.get("frequency_of_depreciation")) + ) + / 12 + ), + ) return flt((100 * (1 - depreciation_rate)), float_precision) - def get_pro_rata_amt(self, row, depreciation_amount, from_date, to_date): + def get_pro_rata_amt( + self, row, depreciation_amount, from_date, to_date, has_wdv_or_dd_non_yearly_pro_rata=False + ): days = date_diff(to_date, from_date) months = month_diff(to_date, from_date) - total_days = get_total_days(to_date, row.frequency_of_depreciation) + if has_wdv_or_dd_non_yearly_pro_rata: + total_days = get_total_days(to_date, 12) + else: + total_days = get_total_days(to_date, row.frequency_of_depreciation) return (depreciation_amount * flt(days)) / flt(total_days), days, months @@ -1184,27 +1253,72 @@ def get_total_days(date, frequency): @erpnext.allow_regional -def get_depreciation_amount(asset, depreciable_value, row): +def get_depreciation_amount( + asset, + depreciable_value, + row, + schedule_idx=0, + prev_depreciation_amount=0, + has_wdv_or_dd_non_yearly_pro_rata=False, +): if row.depreciation_method in ("Straight Line", "Manual"): - # 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) + return get_straight_line_or_manual_depr_amount(asset, row) else: - depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100)) + return get_wdv_or_dd_depr_amount( + depreciable_value, + row.rate_of_depreciation, + row.frequency_of_depreciation, + schedule_idx, + prev_depreciation_amount, + has_wdv_or_dd_non_yearly_pro_rata, + ) - return depreciation_amount + +def get_straight_line_or_manual_depr_amount(asset, row): + # 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: + return (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: + return (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: + return (flt(asset.gross_purchase_amount) - flt(row.expected_value_after_useful_life)) / flt( + row.total_number_of_depreciations + ) + + +def get_wdv_or_dd_depr_amount( + depreciable_value, + rate_of_depreciation, + frequency_of_depreciation, + schedule_idx, + prev_depreciation_amount, + has_wdv_or_dd_non_yearly_pro_rata, +): + if cint(frequency_of_depreciation) == 12: + return flt(depreciable_value) * (flt(rate_of_depreciation) / 100) + else: + if has_wdv_or_dd_non_yearly_pro_rata: + if schedule_idx == 0: + return flt(depreciable_value) * (flt(rate_of_depreciation) / 100) + elif schedule_idx % (12 / cint(frequency_of_depreciation)) == 1: + return ( + flt(depreciable_value) * flt(frequency_of_depreciation) * (flt(rate_of_depreciation) / 1200) + ) + else: + return prev_depreciation_amount + else: + if schedule_idx % (12 / cint(frequency_of_depreciation)) == 0: + return ( + flt(depreciable_value) * flt(frequency_of_depreciation) * (flt(rate_of_depreciation) / 1200) + ) + else: + return prev_depreciation_amount @frappe.whitelist() diff --git a/erpnext/assets/doctype/asset/asset_list.js b/erpnext/assets/doctype/asset/asset_list.js index 3d00eb74aa0..5f53b005aaf 100644 --- a/erpnext/assets/doctype/asset/asset_list.js +++ b/erpnext/assets/doctype/asset/asset_list.js @@ -36,7 +36,7 @@ frappe.listview_settings['Asset'] = { } }, onload: function(me) { - me.page.add_action_item('Make Asset Movement', function() { + me.page.add_action_item(__("Make Asset Movement"), function() { const assets = me.get_checked_items(); frappe.call({ method: "erpnext.assets.doctype.asset.asset.make_asset_movement", diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index 74625890a69..a1d29f88cb1 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -126,6 +126,7 @@ def make_depreciation_entry(asset_name, date=None): je.append("accounts", debit_entry) je.flags.ignore_permissions = True + je.flags.planned_depr_entry = True je.save() if not je.meta.get_workflow(): je.submit() diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 71f578c6703..8a1df6f71ce 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -818,12 +818,12 @@ class TestDepreciationMethods(AssetSetup): ) expected_schedules = [ - ["2022-02-28", 647.25, 647.25], - ["2022-03-31", 1210.71, 1857.96], - ["2022-04-30", 1053.99, 2911.95], - ["2022-05-31", 917.55, 3829.5], - ["2022-06-30", 798.77, 4628.27], - ["2022-07-15", 371.73, 5000.0], + ["2022-02-28", 310.89, 310.89], + ["2022-03-31", 654.45, 965.34], + ["2022-04-30", 654.45, 1619.79], + ["2022-05-31", 654.45, 2274.24], + ["2022-06-30", 654.45, 2928.69], + ["2022-07-15", 2071.31, 5000.0], ] schedules = [ @@ -1453,7 +1453,7 @@ class TestDepreciationBasics(AssetSetup): ) self.assertEqual(asset.status, "Submitted") - self.assertEqual(asset.get("value_after_depreciation"), 100000) + self.assertEqual(asset.get_value_after_depreciation(), 100000) jv = make_journal_entry( "_Test Depreciations - _TC", "_Test Accumulated Depreciations - _TC", 100, save=False @@ -1466,12 +1466,68 @@ class TestDepreciationBasics(AssetSetup): jv.submit() asset.reload() - self.assertEqual(asset.get("value_after_depreciation"), 99900) + self.assertEqual(asset.get_value_after_depreciation(), 99900) jv.cancel() asset.reload() - self.assertEqual(asset.get("value_after_depreciation"), 100000) + self.assertEqual(asset.get_value_after_depreciation(), 100000) + + def test_manual_depreciation_for_depreciable_asset(self): + asset = create_asset( + item_code="Macbook Pro", + calculate_depreciation=1, + purchase_date="2020-01-30", + available_for_use_date="2020-01-30", + expected_value_after_useful_life=10000, + total_number_of_depreciations=10, + frequency_of_depreciation=1, + submit=1, + ) + + self.assertEqual(asset.status, "Submitted") + self.assertEqual(asset.get_value_after_depreciation(), 100000) + + jv = make_journal_entry( + "_Test Depreciations - _TC", "_Test Accumulated Depreciations - _TC", 100, save=False + ) + for d in jv.accounts: + d.reference_type = "Asset" + d.reference_name = asset.name + jv.voucher_type = "Depreciation Entry" + jv.insert() + jv.submit() + + asset.reload() + self.assertEqual(asset.get_value_after_depreciation(), 99900) + + jv.cancel() + + asset.reload() + self.assertEqual(asset.get_value_after_depreciation(), 100000) + + def test_manual_depreciation_with_incorrect_jv_voucher_type(self): + asset = create_asset( + item_code="Macbook Pro", + calculate_depreciation=1, + purchase_date="2020-01-30", + available_for_use_date="2020-01-30", + expected_value_after_useful_life=10000, + total_number_of_depreciations=10, + frequency_of_depreciation=1, + submit=1, + ) + + jv = make_journal_entry( + "_Test Depreciations - _TC", "_Test Accumulated Depreciations - _TC", 100, save=False + ) + for d in jv.accounts: + d.reference_type = "Asset" + d.reference_name = asset.name + d.account_type = "Depreciation" + jv.voucher_type = "Journal Entry" + + self.assertRaises(frappe.ValidationError, jv.insert) def create_asset_data(): diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py index e718472e164..ee75b83af39 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -116,7 +116,9 @@ class AssetValueAdjustment(Document): if d.depreciation_method in ("Straight Line", "Manual"): end_date = max(s.schedule_date for s in asset.schedules if cint(s.finance_book_id) == d.idx) total_days = date_diff(end_date, self.date) - rate_per_day = flt(d.value_after_depreciation) / flt(total_days) + rate_per_day = flt(d.value_after_depreciation - d.expected_value_after_useful_life) / flt( + total_days + ) from_date = self.date else: no_of_depreciations = len( diff --git a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.js b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.js index 06989a95da7..4f7b8361077 100644 --- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.js +++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.js @@ -24,7 +24,7 @@ frappe.query_reports["Fixed Asset Register"] = { "label": __("Period Based On"), "fieldtype": "Select", "options": ["Fiscal Year", "Date Range"], - "default": ["Fiscal Year"], + "default": "Fiscal Year", "reqd": 1 }, { @@ -75,12 +75,6 @@ frappe.query_reports["Fixed Asset Register"] = { fieldtype: "Link", options: "Asset Category" }, - { - fieldname:"finance_book", - label: __("Finance Book"), - fieldtype: "Link", - options: "Finance Book" - }, { fieldname:"cost_center", label: __("Cost Center"), @@ -96,8 +90,20 @@ frappe.query_reports["Fixed Asset Register"] = { reqd: 1 }, { - fieldname:"is_existing_asset", - label: __("Is Existing Asset"), + fieldname:"finance_book", + label: __("Finance Book"), + fieldtype: "Link", + options: "Finance Book", + depends_on: "eval: doc.filter_by_finance_book == 1", + }, + { + fieldname:"filter_by_finance_book", + label: __("Filter by Finance Book"), + fieldtype: "Check" + }, + { + fieldname:"only_existing_assets", + label: __("Only existing assets"), fieldtype: "Check" }, ] diff --git a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py index ae99c491a93..c6fbc6c58e8 100644 --- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py +++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py @@ -45,8 +45,8 @@ def get_conditions(filters): filters.year_end_date = getdate(fiscal_year.year_end_date) conditions[date_field] = ["between", [filters.year_start_date, filters.year_end_date]] - if filters.get("is_existing_asset"): - conditions["is_existing_asset"] = filters.get("is_existing_asset") + if filters.get("only_existing_assets"): + conditions["is_existing_asset"] = filters.get("only_existing_assets") if filters.get("asset_category"): conditions["asset_category"] = filters.get("asset_category") if filters.get("cost_center"): @@ -102,19 +102,18 @@ def get_data(filters): ] assets_record = frappe.db.get_all("Asset", filters=conditions, fields=fields) - assets_linked_to_fb = frappe.db.get_all( - doctype="Asset Finance Book", - filters={"finance_book": filters.finance_book or ("is", "not set")}, - pluck="parent", - ) + assets_linked_to_fb = None + + if filters.filter_by_finance_book: + assets_linked_to_fb = frappe.db.get_all( + doctype="Asset Finance Book", + filters={"finance_book": filters.finance_book or ("is", "not set")}, + pluck="parent", + ) for asset in assets_record: - if filters.finance_book: - if asset.asset_id not in assets_linked_to_fb: - continue - else: - if asset.calculate_depreciation and asset.asset_id not in assets_linked_to_fb: - continue + if assets_linked_to_fb and asset.asset_id not in assets_linked_to_fb: + continue asset_value = get_asset_value_after_depreciation(asset.asset_id, filters.finance_book) row = { @@ -172,11 +171,11 @@ def prepare_chart_data(data, filters): "datasets": [ { "name": _("Asset Value"), - "values": [d.get("asset_value") for d in labels_values_map.values()], + "values": [flt(d.get("asset_value"), 2) for d in labels_values_map.values()], }, { "name": _("Depreciatied Amount"), - "values": [d.get("depreciated_amount") for d in labels_values_map.values()], + "values": [flt(d.get("depreciated_amount"), 2) for d in labels_values_map.values()], }, ], }, @@ -310,7 +309,7 @@ def get_columns(filters): return [ { - "label": _("Asset Id"), + "label": _("Asset ID"), "fieldtype": "Link", "fieldname": "asset_id", "options": "Asset", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 47089f7d850..c6c9f1f98a3 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -236,7 +236,11 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e this.make_purchase_invoice, __('Create')); if(flt(doc.per_billed) < 100 && doc.status != "Delivered") { - cur_frm.add_custom_button(__('Payment'), cur_frm.cscript.make_payment_entry, __('Create')); + this.frm.add_custom_button( + __('Payment'), + () => this.make_payment_entry(), + __('Create') + ); } if(flt(doc.per_billed) < 100) { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 29afc8476e4..ff08ddd33dc 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -495,6 +495,7 @@ "allow_bulk_edit": 1, "fieldname": "items", "fieldtype": "Table", + "label": "Items", "oldfieldname": "po_details", "oldfieldtype": "Table", "options": "Purchase Order Item", @@ -1100,8 +1101,7 @@ { "fieldname": "before_items_section", "fieldtype": "Section Break", - "hide_border": 1, - "label": "Items" + "hide_border": 1 }, { "fieldname": "items_col_break", @@ -1271,7 +1271,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2023-01-28 18:59:16.322824", + "modified": "2023-04-14 16:42:29.448464", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order_list.js b/erpnext/buying/doctype/purchase_order/purchase_order_list.js index d7907e4274b..6594746cfc5 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order_list.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order_list.js @@ -43,7 +43,7 @@ frappe.listview_settings['Purchase Order'] = { }); listview.page.add_action_item(__("Advance Payment"), ()=>{ - erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Advance Payment"); + erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Payment Entry"); }); } diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 7927beb8233..4590f8c3d93 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -113,7 +113,10 @@ class RequestforQuotation(BuyingController): def get_link(self): # RFQ link for supplier portal - return get_url("/app/request-for-quotation/" + self.name) + route = frappe.db.get_value( + "Portal Menu Item", {"reference_doctype": "Request for Quotation"}, ["route"] + ) + return get_url("/app/{0}/".format(route) + self.name) def update_supplier_part_no(self, supplier): self.vendor = supplier diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index c5b369bedd5..11ff91af94d 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -310,7 +310,6 @@ "fieldname": "items_section", "fieldtype": "Section Break", "hide_border": 1, - "label": "Items", "oldfieldtype": "Section Break", "options": "fa fa-shopping-cart" }, @@ -318,6 +317,7 @@ "allow_bulk_edit": 1, "fieldname": "items", "fieldtype": "Table", + "label": "Items", "oldfieldname": "po_details", "oldfieldtype": "Table", "options": "Supplier Quotation Item", @@ -844,7 +844,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-12-12 18:35:39.740974", + "modified": "2023-04-14 16:43:41.714832", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 3705fcf4990..d0ec6541623 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -5,7 +5,7 @@ import json import frappe -from frappe import _, throw +from frappe import _, bold, throw from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied from frappe.query_builder.functions import Abs, Sum from frappe.utils import ( @@ -273,8 +273,8 @@ class AccountsController(TransactionBase): self.validate_payment_schedule_dates() self.set_due_date() self.set_payment_schedule() - self.validate_payment_schedule_amount() if not self.get("ignore_default_payment_terms_template"): + self.validate_payment_schedule_amount() self.validate_due_date() self.validate_advance_entries() @@ -405,6 +405,15 @@ class AccountsController(TransactionBase): msg += _("Please create purchase from internal sale or delivery document itself") frappe.throw(msg, title=_("Internal Sales Reference Missing")) + label = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item" + + field = frappe.scrub(label) + + for row in self.get("items"): + if not row.get(field): + msg = f"At Row {row.idx}: The field {bold(label)} is mandatory for internal transfer" + frappe.throw(_(msg), title=_("Internal Transfer Reference Missing")) + def disable_pricing_rule_on_internal_transfer(self): if not self.get("ignore_pricing_rule") and self.is_internal_transfer(): self.ignore_pricing_rule = 1 @@ -515,6 +524,7 @@ class AccountsController(TransactionBase): parent_dict.update({"customer": parent_dict.get("party_name")}) self.pricing_rules = [] + for item in self.get("items"): if item.get("item_code"): args = parent_dict.copy() @@ -833,7 +843,9 @@ class AccountsController(TransactionBase): def set_advances(self): """Returns list of advances against Account, Party, Reference""" - res = self.get_advance_entries() + res = self.get_advance_entries( + include_unallocated=not cint(self.get("only_include_allocated_payments")) + ) self.set("advances", []) advance_allocated = 0 @@ -1232,7 +1244,7 @@ class AccountsController(TransactionBase): ) ) - def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on, parentfield): + def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on): from erpnext.controllers.status_updater import get_allowance_for item_allowance = {} @@ -1245,17 +1257,20 @@ class AccountsController(TransactionBase): total_overbilled_amt = 0.0 + reference_names = [d.get(item_ref_dn) for d in self.get("items") if d.get(item_ref_dn)] + reference_details = self.get_billing_reference_details( + reference_names, ref_dt + " Item", based_on + ) + for item in self.get("items"): if not item.get(item_ref_dn): continue - ref_amt = flt( - frappe.db.get_value(ref_dt + " Item", item.get(item_ref_dn), based_on), - self.precision(based_on, item), - ) + ref_amt = flt(reference_details.get(item.get(item_ref_dn)), self.precision(based_on, item)) + if not ref_amt: frappe.msgprint( - _("System will not check overbilling since amount for Item {0} in {1} is zero").format( + _("System will not check over billing since amount for Item {0} in {1} is zero").format( item.item_code, ref_dt ), title=_("Warning"), @@ -1302,6 +1317,16 @@ class AccountsController(TransactionBase): alert=True, ) + def get_billing_reference_details(self, reference_names, reference_doctype, based_on): + return frappe._dict( + frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name", based_on], + as_list=1, + ) + ) + def get_billed_amount_for_item(self, item, item_ref_dn, based_on): """ Returns Sum of Amount of @@ -1591,6 +1616,7 @@ class AccountsController(TransactionBase): base_grand_total = self.get("base_rounded_total") or self.base_grand_total grand_total = self.get("rounded_total") or self.grand_total + automatically_fetch_payment_terms = 0 if self.doctype in ("Sales Invoice", "Purchase Invoice"): base_grand_total = base_grand_total - flt(self.base_write_off_amount) @@ -1636,19 +1662,23 @@ class AccountsController(TransactionBase): ) self.append("payment_schedule", data) - for d in self.get("payment_schedule"): - if d.invoice_portion: - d.payment_amount = flt( - grand_total * flt(d.invoice_portion / 100), d.precision("payment_amount") - ) - d.base_payment_amount = flt( - base_grand_total * flt(d.invoice_portion / 100), d.precision("base_payment_amount") - ) - d.outstanding = d.payment_amount - elif not d.invoice_portion: - d.base_payment_amount = flt( - d.payment_amount * self.get("conversion_rate"), d.precision("base_payment_amount") - ) + if not ( + automatically_fetch_payment_terms + and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) + ): + for d in self.get("payment_schedule"): + if d.invoice_portion: + d.payment_amount = flt( + grand_total * flt(d.invoice_portion / 100), d.precision("payment_amount") + ) + d.base_payment_amount = flt( + base_grand_total * flt(d.invoice_portion / 100), d.precision("base_payment_amount") + ) + d.outstanding = d.payment_amount + elif not d.invoice_portion: + d.base_payment_amount = flt( + d.payment_amount * self.get("conversion_rate"), d.precision("base_payment_amount") + ) def get_order_details(self): if self.doctype == "Sales Invoice": @@ -1701,6 +1731,10 @@ class AccountsController(TransactionBase): "invoice_portion": schedule.invoice_portion, "mode_of_payment": schedule.mode_of_payment, "description": schedule.description, + "payment_amount": schedule.payment_amount, + "base_payment_amount": schedule.base_payment_amount, + "outstanding": schedule.outstanding, + "paid_amount": schedule.paid_amount, } if schedule.discount_type == "Percentage": @@ -1870,12 +1904,14 @@ class AccountsController(TransactionBase): reconcilation_entry.party = secondary_party reconcilation_entry.reference_type = self.doctype reconcilation_entry.reference_name = self.name - reconcilation_entry.cost_center = self.cost_center + reconcilation_entry.cost_center = self.cost_center or erpnext.get_default_cost_center( + self.company + ) advance_entry.account = primary_account advance_entry.party_type = primary_party_type advance_entry.party = primary_party - advance_entry.cost_center = self.cost_center + advance_entry.cost_center = self.cost_center or erpnext.get_default_cost_center(self.company) advance_entry.is_advance = "Yes" if self.doctype == "Sales Invoice": diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index b0cf7241669..799fed99cc7 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -576,7 +576,9 @@ def get_income_account(doctype, txt, searchfield, start, page_len, filters): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs -def get_filtered_dimensions(doctype, txt, searchfield, start, page_len, filters): +def get_filtered_dimensions( + doctype, txt, searchfield, start, page_len, filters, reference_doctype=None +): from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import ( get_dimension_filter_map, ) @@ -617,7 +619,12 @@ def get_filtered_dimensions(doctype, txt, searchfield, start, page_len, filters) query_filters.append(["name", query_selector, dimensions]) output = frappe.get_list( - doctype, fields=fields, filters=query_filters, or_filters=or_filters, as_list=1 + doctype, + fields=fields, + filters=query_filters, + or_filters=or_filters, + as_list=1, + reference_doctype=reference_doctype, ) return [tuple(d) for d in set(output)] diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 1e4fabe0d26..a27e34819d4 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -329,9 +329,10 @@ class StockController(AccountsController): """Create batches if required. Called before submit""" for d in self.items: if d.get(warehouse_field) and not d.batch_no: - has_batch_no, create_new_batch = frappe.db.get_value( + has_batch_no, create_new_batch = frappe.get_cached_value( "Item", d.item_code, ["has_batch_no", "create_new_batch"] ) + if has_batch_no and create_new_batch: d.batch_no = ( frappe.get_doc( @@ -414,7 +415,7 @@ class StockController(AccountsController): "voucher_no": self.name, "voucher_detail_no": d.name, "actual_qty": (self.docstatus == 1 and 1 or -1) * flt(d.get("stock_qty")), - "stock_uom": frappe.db.get_value( + "stock_uom": frappe.get_cached_value( "Item", args.get("item_code") or d.get("item_code"), "stock_uom" ), "incoming_rate": 0, @@ -609,7 +610,7 @@ class StockController(AccountsController): def validate_customer_provided_item(self): for d in self.get("items"): # Customer Provided parts will have zero valuation rate - if frappe.db.get_value("Item", d.item_code, "is_customer_provided_item"): + if frappe.get_cached_value("Item", d.item_code, "is_customer_provided_item"): d.allow_zero_valuation_rate = 1 def set_rate_of_stock_uom(self): @@ -722,7 +723,7 @@ class StockController(AccountsController): message += _("Please adjust the qty or edit {0} to proceed.").format(rule_link) return message - def repost_future_sle_and_gle(self): + def repost_future_sle_and_gle(self, force=False): args = frappe._dict( { "posting_date": self.posting_date, @@ -733,7 +734,7 @@ class StockController(AccountsController): } ) - if future_sle_exists(args) or repost_required_for_queue(self): + if force or future_sle_exists(args) or repost_required_for_queue(self): item_based_reposting = cint( frappe.db.get_single_value("Stock Reposting Settings", "item_based_reposting") ) @@ -859,6 +860,8 @@ def is_reposting_pending(): def future_sle_exists(args, sl_entries=None): key = (args.voucher_type, args.voucher_no) + if not hasattr(frappe.local, "future_sle"): + frappe.local.future_sle = {} if validate_future_sle_not_exists(args, key, sl_entries): return False @@ -903,6 +906,9 @@ def validate_future_sle_not_exists(args, key, sl_entries=None): item_key = (args.get("item_code"), args.get("warehouse")) if not sl_entries and hasattr(frappe.local, "future_sle"): + if key not in frappe.local.future_sle: + return False + if not frappe.local.future_sle.get(key) or ( item_key and item_key not in frappe.local.future_sle.get(key) ): @@ -910,9 +916,6 @@ def validate_future_sle_not_exists(args, key, sl_entries=None): def get_cached_data(args, key): - if not hasattr(frappe.local, "future_sle"): - frappe.local.future_sle = {} - if key not in frappe.local.future_sle: frappe.local.future_sle[key] = frappe._dict({}) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 1edd7bf85e1..4661c5ca7e8 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -976,6 +976,8 @@ def get_itemised_tax_breakup_html(doc): @frappe.whitelist() def get_round_off_applicable_accounts(company, account_list): + # required to set correct region + frappe.flags.company = company account_list = get_regional_round_off_accounts(company, account_list) return account_list diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 6a5fead0f82..2a8d65f4866 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -59,7 +59,7 @@ class Opportunity(TransactionBase, CRMNote): if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field): try: value = frappe.db.get_value(self.opportunity_from, self.party_name, field) - self.db_set(field, value) + self.set(field, value) except Exception: continue diff --git a/erpnext/e_commerce/doctype/website_item/test_website_item.py b/erpnext/e_commerce/doctype/website_item/test_website_item.py index bbe04d5514d..019a5f9ee4f 100644 --- a/erpnext/e_commerce/doctype/website_item/test_website_item.py +++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py @@ -199,8 +199,14 @@ class TestWebsiteItem(unittest.TestCase): breadcrumbs = get_parent_item_groups(item.item_group) + settings = frappe.get_cached_doc("E Commerce Settings") + if settings.enable_field_filters: + base_breadcrumb = "Shop by Category" + else: + base_breadcrumb = "All Products" + self.assertEqual(breadcrumbs[0]["name"], "Home") - self.assertEqual(breadcrumbs[1]["name"], "All Products") + self.assertEqual(breadcrumbs[1]["name"], base_breadcrumb) self.assertEqual(breadcrumbs[2]["name"], "_Test Item Group B") # parent item group self.assertEqual(breadcrumbs[3]["name"], "_Test Item Group B - 1") diff --git a/erpnext/e_commerce/doctype/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py index 3e5d5f768fa..81b8ecab48e 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.py +++ b/erpnext/e_commerce/doctype/website_item/website_item.py @@ -315,6 +315,7 @@ class WebsiteItem(WebsiteGenerator): self.item_code, skip_quotation_creation=True ) + @frappe.whitelist() def copy_specification_from_item_group(self): self.set("website_specifications", []) if self.item_group: diff --git a/erpnext/hooks.py b/erpnext/hooks.py index f1ee370e97e..0ba8a89bea3 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -370,6 +370,7 @@ scheduler_events = { "cron": { "0/15 * * * *": [ "erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs", + "erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.trigger_reconciliation_for_queued_docs", ], "0/30 * * * *": [ "erpnext.utilities.doctype.video.video.update_youtube_data", diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 4304193afae..7cdcef9c7ae 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -411,7 +411,6 @@ frappe.ui.form.on("BOM", { } frm.set_value("process_loss_qty", qty); - frm.set_value("add_process_loss_cost_in_fg", qty ? 1: 0); } }); diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index db699b94d8f..d02402299e3 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -9,15 +9,14 @@ "production_item_tab", "item", "company", - "item_name", "uom", + "quantity", "cb0", "is_active", "is_default", "allow_alternative_item", "set_rate_of_sub_assembly_item_based_on_bom", "project", - "quantity", "image", "currency_detail", "rm_cost_as_per", @@ -27,6 +26,8 @@ "column_break_ivyw", "currency", "conversion_rate", + "materials_section", + "items", "section_break_21", "operations_section_section", "with_operations", @@ -38,8 +39,6 @@ "operating_cost_per_bom_quantity", "operations_section", "operations", - "materials_section", - "items", "scrap_section", "scrap_items_section", "scrap_items", @@ -59,6 +58,7 @@ "total_cost", "base_total_cost", "more_info_tab", + "item_name", "description", "column_break_27", "has_variants", @@ -192,6 +192,7 @@ "options": "Quality Inspection Template" }, { + "collapsible": 1, "fieldname": "currency_detail", "fieldtype": "Section Break", "label": "Cost Configuration" @@ -417,7 +418,7 @@ { "collapsible": 1, "fieldname": "website_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Website" }, { @@ -482,7 +483,7 @@ { "fieldname": "section_break_21", "fieldtype": "Tab Break", - "label": "Operations & Materials" + "label": "Operations" }, { "fieldname": "column_break_23", @@ -605,7 +606,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2023-02-13 17:31:37.504565", + "modified": "2023-04-06 12:47:58.514795", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 619a415c8bc..b53149affd3 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -943,7 +943,8 @@ def get_valuation_rate(data): 2) If no value, get last valuation rate from SLE 3) If no value, get valuation rate from Item """ - from frappe.query_builder.functions import Sum + from frappe.query_builder.functions import Count, IfNull, Sum + from pypika import Case item_code, company = data.get("item_code"), data.get("company") valuation_rate = 0.0 @@ -954,7 +955,14 @@ def get_valuation_rate(data): frappe.qb.from_(bin_table) .join(wh_table) .on(bin_table.warehouse == wh_table.name) - .select((Sum(bin_table.stock_value) / Sum(bin_table.actual_qty)).as_("valuation_rate")) + .select( + Case() + .when( + Count(bin_table.name) > 0, IfNull(Sum(bin_table.stock_value) / Sum(bin_table.actual_qty), 0.0) + ) + .else_(None) + .as_("valuation_rate") + ) .where((bin_table.item_code == item_code) & (wh_table.company == company)) ).run(as_dict=True)[0] @@ -1309,7 +1317,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): if not field in searchfields ] - query_filters = {"disabled": 0, "end_of_life": (">", today())} + query_filters = {"disabled": 0, "ifnull(end_of_life, '3099-12-31')": (">", today())} or_cond_filters = {} if txt: diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index 51f7b24e745..7477f9528ec 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -164,7 +164,7 @@ def queue_bom_cost_jobs( while current_boms_list: batch_no += 1 - batch_size = 20_000 + batch_size = 7_000 boms_to_process = current_boms_list[:batch_size] # slice out batch of 20k BOMs # update list to exclude 20K (queued) BOMs @@ -212,7 +212,7 @@ def resume_bom_cost_update_jobs(): ["name", "boms_updated", "status"], ) incomplete_level = any(row.get("status") == "Pending" for row in bom_batches) - if not bom_batches or not incomplete_level: + if not bom_batches or incomplete_level: continue # Prep parent BOMs & updated processed BOMs for next level @@ -252,9 +252,6 @@ def get_processed_current_boms( current_boms = [] for row in bom_batches: - if not row.boms_updated: - continue - boms_updated = json.loads(row.boms_updated) current_boms.extend(boms_updated) boms_updated_dict = {bom: True for bom in boms_updated} diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index e82f37977cd..f89951619e0 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -74,6 +74,37 @@ class JobCard(Document): self.update_sub_operation_status() self.validate_work_order() + def on_update(self): + self.validate_job_card_qty() + + def validate_job_card_qty(self): + if not (self.operation_id and self.work_order): + return + + wo_qty = flt(frappe.get_cached_value("Work Order", self.work_order, "qty")) + + completed_qty = flt( + frappe.db.get_value("Work Order Operation", self.operation_id, "completed_qty") + ) + + job_card_qty = frappe.get_all( + "Job Card", + fields=["sum(for_quantity)"], + filters={ + "work_order": self.work_order, + "operation_id": self.operation_id, + "docstatus": ["!=", 2], + }, + as_list=1, + ) + + job_card_qty = flt(job_card_qty[0][0]) if job_card_qty else 0 + + if job_card_qty and ((job_card_qty - completed_qty) > wo_qty): + msg = f"""Job Card quantity cannot be greater than + Work Order quantity for the operation {self.operation}""" + frappe.throw(_(msg), title=_("Extra Job Card Quantity")) + def set_sub_operations(self): if not self.sub_operations and self.operation: self.sub_operations = [] diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 2624daa41e2..fdaa4a2a1d4 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -344,6 +344,7 @@ { "fieldname": "prod_plan_references", "fieldtype": "Table", + "hidden": 1, "label": "Production Plan Item Reference", "options": "Production Plan Item Reference" }, @@ -397,7 +398,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-11-26 14:51:08.774372", + "modified": "2023-03-31 10:30:48.118932", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json index 84dee4ad284..15ef20794cb 100644 --- a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json +++ b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json @@ -28,7 +28,7 @@ "fieldname": "qty", "fieldtype": "Data", "in_list_view": 1, - "label": "qty" + "label": "Qty" }, { "fieldname": "item_reference", @@ -40,7 +40,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-07 17:03:49.707487", + "modified": "2023-03-31 10:30:14.604051", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Item Reference", @@ -48,5 +48,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 729ed42f51a..540b7dc9ea6 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1598,6 +1598,57 @@ class TestWorkOrder(FrappeTestCase): self.assertEqual(row.to_time, add_to_date(planned_start_date, minutes=30)) self.assertEqual(row.workstation, workstations_to_check[index]) + def test_job_card_extra_qty(self): + items = [ + "Test FG Item for Scrap Item Test 1", + "Test RM Item 1 for Scrap Item Test 1", + "Test RM Item 2 for Scrap Item Test 1", + ] + + company = "_Test Company with perpetual inventory" + for item_code in items: + create_item( + item_code=item_code, + is_stock_item=1, + is_purchase_item=1, + opening_stock=100, + valuation_rate=10, + company=company, + warehouse="Stores - TCP1", + ) + + item = "Test FG Item for Scrap Item Test 1" + raw_materials = ["Test RM Item 1 for Scrap Item Test 1", "Test RM Item 2 for Scrap Item Test 1"] + if not frappe.db.get_value("BOM", {"item": item}): + bom = make_bom( + item=item, source_warehouse="Stores - TCP1", raw_materials=raw_materials, do_not_save=True + ) + bom.with_operations = 1 + bom.append( + "operations", + { + "operation": "_Test Operation 1", + "workstation": "_Test Workstation 1", + "hour_rate": 20, + "time_in_mins": 60, + }, + ) + + bom.submit() + + wo_order = make_wo_order_test_record( + item=item, + company=company, + planned_start_date=now(), + qty=20, + ) + job_card = frappe.db.get_value("Job Card", {"work_order": wo_order.name}, "name") + job_card_doc = frappe.get_doc("Job Card", job_card) + + # Make another Job Card for the same Work Order + job_card2 = frappe.copy_doc(job_card_doc) + self.assertRaises(frappe.ValidationError, job_card2.save) + def prepare_data_for_workstation_type_check(): from erpnext.manufacturing.doctype.operation.test_operation import make_operation diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 97480b29454..d0c9966f8ba 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -625,20 +625,18 @@ erpnext.work_order = { // all materials transferred for manufacturing, make this primary finish_btn.addClass('btn-primary'); } - } else { - frappe.db.get_doc("Manufacturing Settings").then((doc) => { - let allowance_percentage = doc.overproduction_percentage_for_work_order; + } else if (frm.doc.__onload && frm.doc.__onload.overproduction_percentage) { + let allowance_percentage = frm.doc.__onload.overproduction_percentage; - if (allowance_percentage > 0) { - let allowed_qty = frm.doc.qty + ((allowance_percentage / 100) * frm.doc.qty); + if (allowance_percentage > 0) { + let allowed_qty = frm.doc.qty + ((allowance_percentage / 100) * frm.doc.qty); - if ((flt(doc.produced_qty) < allowed_qty)) { - frm.add_custom_button(__('Finish'), function() { - erpnext.work_order.make_se(frm, 'Manufacture'); - }); - } + if ((flt(doc.produced_qty) < allowed_qty)) { + frm.add_custom_button(__('Finish'), function() { + erpnext.work_order.make_se(frm, 'Manufacture'); + }); } - }); + } } } } else { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 25e16d63376..aa9049801cc 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -22,17 +22,13 @@ "produced_qty", "process_loss_qty", "project", - "serial_no_and_batch_for_finished_good_section", - "has_serial_no", - "has_batch_no", - "column_break_17", - "serial_no", - "batch_size", + "section_break_ndpq", + "required_items", "work_order_configuration", "settings_section", "allow_alternative_item", "use_multi_level_bom", - "column_break_18", + "column_break_17", "skip_transfer", "from_wip_warehouse", "update_consumed_material_cost_in_project", @@ -42,9 +38,14 @@ "column_break_12", "fg_warehouse", "scrap_warehouse", + "serial_no_and_batch_for_finished_good_section", + "has_serial_no", + "has_batch_no", + "column_break_18", + "serial_no", + "batch_size", "required_items_section", "materials_and_operations_tab", - "required_items", "operations_section", "operations", "transfer_material_against", @@ -586,7 +587,11 @@ { "fieldname": "materials_and_operations_tab", "fieldtype": "Tab Break", - "label": "Materials & Operations" + "label": "Operations" + }, + { + "fieldname": "section_break_ndpq", + "fieldtype": "Section Break" } ], "icon": "fa fa-cogs", @@ -594,7 +599,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2023-01-03 14:16:35.427731", + "modified": "2023-04-06 12:35:12.149827", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py b/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py index de96a6c0323..38e05852ee8 100644 --- a/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py +++ b/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py @@ -69,7 +69,7 @@ def get_columns(filters): "label": _("Id"), "fieldname": "name", "fieldtype": "Link", - "options": "Work Order", + "options": "Quality Inspection", "width": 100, }, {"label": _("Report Date"), "fieldname": "report_date", "fieldtype": "Date", "width": 150}, diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 82c8b06555b..8cdc57752d1 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -328,5 +328,7 @@ erpnext.patches.v14_0.set_pick_list_status erpnext.patches.v13_0.update_docs_link execute:frappe.delete_doc_if_exists("Report", "Tax Detail") erpnext.patches.v15_0.enable_all_leads +execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0) # below migration patches should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger +erpnext.patches.v14_0.update_company_in_ldc diff --git a/erpnext/patches/v14_0/update_company_in_ldc.py b/erpnext/patches/v14_0/update_company_in_ldc.py new file mode 100644 index 00000000000..ca95cf2fd7a --- /dev/null +++ b/erpnext/patches/v14_0/update_company_in_ldc.py @@ -0,0 +1,14 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + + +import frappe + +from erpnext import get_default_company + + +def execute(): + company = get_default_company() + if company: + for d in frappe.get_all("Lower Deduction Certificate", pluck="name"): + frappe.db.set_value("Lower Deduction Certificate", d, "company", company, update_modified=False) diff --git a/erpnext/portal/doctype/homepage_section/test_homepage_section.py b/erpnext/portal/doctype/homepage_section/test_homepage_section.py index 27c8fe4c95a..3df56e67f60 100644 --- a/erpnext/portal/doctype/homepage_section/test_homepage_section.py +++ b/erpnext/portal/doctype/homepage_section/test_homepage_section.py @@ -10,62 +10,6 @@ from frappe.website.serve import get_response class TestHomepageSection(unittest.TestCase): - def test_homepage_section_card(self): - try: - frappe.get_doc( - { - "doctype": "Homepage Section", - "name": "Card Section", - "section_based_on": "Cards", - "section_cards": [ - { - "title": "Card 1", - "subtitle": "Subtitle 1", - "content": "This is test card 1", - "route": "/card-1", - }, - { - "title": "Card 2", - "subtitle": "Subtitle 2", - "content": "This is test card 2", - "image": "test.jpg", - }, - ], - "no_of_columns": 3, - } - ).insert(ignore_if_duplicate=True) - except frappe.DuplicateEntryError: - pass - - set_request(method="GET", path="home") - response = get_response() - - self.assertEqual(response.status_code, 200) - - html = frappe.safe_decode(response.get_data()) - - soup = BeautifulSoup(html, "html.parser") - sections = soup.find("main").find_all("section") - self.assertEqual(len(sections), 3) - - homepage_section = sections[2] - self.assertEqual(homepage_section.h3.text, "Card Section") - - cards = homepage_section.find_all(class_="card") - - self.assertEqual(len(cards), 2) - self.assertEqual(cards[0].h5.text, "Card 1") - self.assertEqual(cards[0].a["href"], "/card-1") - self.assertEqual(cards[1].p.text, "Subtitle 2") - - img = cards[1].find(class_="card-img-top") - - self.assertEqual(img["src"], "test.jpg") - self.assertEqual(img["loading"], "lazy") - - # cleanup - frappe.db.rollback() - def test_homepage_section_custom_html(self): frappe.get_doc( { diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json index 468300661a0..ba6262dc3de 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.json +++ b/erpnext/projects/doctype/timesheet/timesheet.json @@ -96,7 +96,6 @@ "read_only": 1 }, { - "depends_on": "eval:!doc.work_order || doc.docstatus == 1", "fieldname": "employee_detail", "fieldtype": "Section Break", "label": "Employee Detail" @@ -311,7 +310,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2023-02-14 04:55:41.735991", + "modified": "2023-04-20 15:59:11.107831", "modified_by": "Administrator", "module": "Projects", "name": "Timesheet", diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js index a07f75d1c5d..d943126018a 100644 --- a/erpnext/public/js/controllers/accounts.js +++ b/erpnext/public/js/controllers/accounts.js @@ -55,6 +55,14 @@ frappe.ui.form.on(cur_frm.doctype, { }, allocate_advances_automatically: function(frm) { + frm.trigger('fetch_advances'); + }, + + only_include_allocated_payments: function(frm) { + frm.trigger('fetch_advances'); + }, + + fetch_advances: function(frm) { if(frm.doc.allocate_advances_automatically) { frappe.call({ doc: frm.doc, diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 8e57ebd3677..8efc47d18e5 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -135,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 || me.frm.doc.is_debit_note ? 1 : -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)); } diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index f7620db2f1e..29437beab10 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1696,7 +1696,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } $.each(["company", "customer"], function(i, fieldname) { - if(frappe.meta.has_field(me.frm.doc.doctype, fieldname) && me.frm.doc.doctype != "Purchase Order") { + if(frappe.meta.has_field(me.frm.doc.doctype, fieldname) && !["Purchase Order","Purchase Invoice"].includes(me.frm.doc.doctype)) { if (!me.frm.doc[fieldname]) { frappe.msgprint(__("Please specify") + ": " + frappe.meta.get_label(me.frm.doc.doctype, fieldname, me.frm.doc.name) + @@ -1897,20 +1897,60 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } make_payment_entry() { + let via_journal_entry = this.frm.doc.__onload && this.frm.doc.__onload.make_payment_via_journal_entry; + if(this.has_discount_in_schedule() && !via_journal_entry) { + // If early payment discount is applied, ask user for reference date + this.prompt_user_for_reference_date(); + } else { + this.make_mapped_payment_entry(); + } + } + + make_mapped_payment_entry(args) { + var me = this; + args = args || { "dt": this.frm.doc.doctype, "dn": this.frm.doc.name }; return frappe.call({ - method: cur_frm.cscript.get_method_for_payment(), - args: { - "dt": cur_frm.doc.doctype, - "dn": cur_frm.doc.name - }, + method: me.get_method_for_payment(), + args: args, callback: function(r) { var doclist = frappe.model.sync(r.message); frappe.set_route("Form", doclist[0].doctype, doclist[0].name); - // cur_frm.refresh_fields() } }); } + prompt_user_for_reference_date(){ + let me = this; + frappe.prompt({ + label: __("Cheque/Reference Date"), + fieldname: "reference_date", + fieldtype: "Date", + reqd: 1, + }, (values) => { + let args = { + "dt": me.frm.doc.doctype, + "dn": me.frm.doc.name, + "reference_date": values.reference_date + } + me.make_mapped_payment_entry(args); + }, + __("Reference Date for Early Payment Discount"), + __("Continue") + ); + } + + has_discount_in_schedule() { + let is_eligible = in_list( + ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"], + this.frm.doctype + ); + let has_payment_schedule = this.frm.doc.payment_schedule && this.frm.doc.payment_schedule.length; + if(!is_eligible || !has_payment_schedule) return false; + + let has_discount = this.frm.doc.payment_schedule.some(row => row.discount); + return has_discount; + } + make_quality_inspection() { let data = []; const fields = [ diff --git a/erpnext/public/js/projects/timer.js b/erpnext/public/js/projects/timer.js index 9dae7118d9a..0209f4c2322 100644 --- a/erpnext/public/js/projects/timer.js +++ b/erpnext/public/js/projects/timer.js @@ -68,7 +68,7 @@ erpnext.timesheet.control_timer = function(frm, dialog, row, timestamp=0) { // New activity if no activities found var args = dialog.get_values(); if(!args) return; - if (frm.doc.time_logs.length <= 1 && !frm.doc.time_logs[0].activity_type && !frm.doc.time_logs[0].from_time) { + if (frm.doc.time_logs.length == 1 && !frm.doc.time_logs[0].activity_type && !frm.doc.time_logs[0].from_time) { frm.doc.time_logs = []; } row = frappe.model.add_child(frm.doc, "Timesheet Detail", "time_logs"); diff --git a/erpnext/public/js/setup_wizard.js b/erpnext/public/js/setup_wizard.js index 9288f515cd4..a913844e186 100644 --- a/erpnext/public/js/setup_wizard.js +++ b/erpnext/public/js/setup_wizard.js @@ -13,19 +13,11 @@ frappe.setup.on("before_load", function () { erpnext.setup.slides_settings = [ { - // Brand - name: 'brand', - icon: "fa fa-bookmark", - title: __("The Brand"), - // help: __('Upload your letter head and logo. (you can edit them later).'), + // Organization + name: 'organization', + title: __("Setup your organization"), + icon: "fa fa-building", fields: [ - { - fieldtype: "Attach Image", fieldname: "attach_logo", - label: __("Attach Logo"), - description: __("100px by 100px"), - is_private: 0, - align: 'center' - }, { fieldname: 'company_name', label: __('Company Name'), @@ -35,54 +27,9 @@ erpnext.setup.slides_settings = [ { fieldname: 'company_abbr', label: __('Company Abbreviation'), - fieldtype: 'Data' - } - ], - onload: function(slide) { - this.bind_events(slide); - }, - bind_events: function (slide) { - slide.get_input("company_name").on("change", function () { - var parts = slide.get_input("company_name").val().split(" "); - var abbr = $.map(parts, function (p) { return p ? p.substr(0, 1) : null }).join(""); - slide.get_field("company_abbr").set_value(abbr.slice(0, 10).toUpperCase()); - }).val(frappe.boot.sysdefaults.company_name || "").trigger("change"); - - slide.get_input("company_abbr").on("change", function () { - if (slide.get_input("company_abbr").val().length > 10) { - frappe.msgprint(__("Company Abbreviation cannot have more than 5 characters")); - slide.get_field("company_abbr").set_value(""); - } - }); - }, - validate: function() { - if ((this.values.company_name || "").toLowerCase() == "company") { - frappe.msgprint(__("Company Name cannot be Company")); - return false; - } - if (!this.values.company_abbr) { - return false; - } - if (this.values.company_abbr.length > 10) { - return false; - } - return true; - } - }, - { - // Organisation - name: 'organisation', - title: __("Your Organization"), - icon: "fa fa-building", - fields: [ - { - fieldname: 'company_tagline', - label: __('What does it do?'), fieldtype: 'Data', - placeholder: __('e.g. "Build tools for builders"'), - reqd: 1 + hidden: 1 }, - { fieldname: 'bank_account', label: __('Bank Name'), fieldtype: 'Data', reqd: 1 }, { fieldname: 'chart_of_accounts', label: __('Chart of Accounts'), options: "", fieldtype: 'Select' @@ -94,40 +41,24 @@ erpnext.setup.slides_settings = [ ], onload: function (slide) { - this.load_chart_of_accounts(slide); this.bind_events(slide); + this.load_chart_of_accounts(slide); this.set_fy_dates(slide); }, - validate: function () { - let me = this; - let exist; - if (!this.validate_fy_dates()) { return false; } - // Validate bank name - if(me.values.bank_account) { - frappe.call({ - async: false, - method: "erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts.validate_bank_account", - args: { - "coa": me.values.chart_of_accounts, - "bank_account": me.values.bank_account - }, - callback: function (r) { - if(r.message){ - exist = r.message; - me.get_field("bank_account").set_value(""); - let message = __('Account {0} already exists. Please enter a different name for your bank account.', - [me.values.bank_account] - ); - frappe.msgprint(message); - } - } - }); - return !exist; // Return False if exist = true + if ((this.values.company_name || "").toLowerCase() == "company") { + frappe.msgprint(__("Company Name cannot be Company")); + return false; + } + if (!this.values.company_abbr) { + return false; + } + if (this.values.company_abbr.length > 10) { + return false; } return true; @@ -151,15 +82,15 @@ erpnext.setup.slides_settings = [ var country = frappe.wizard.values.country; if (country) { - var fy = erpnext.setup.fiscal_years[country]; - var current_year = moment(new Date()).year(); - var next_year = current_year + 1; + let fy = erpnext.setup.fiscal_years[country]; + let current_year = moment(new Date()).year(); + let next_year = current_year + 1; if (!fy) { fy = ["01-01", "12-31"]; next_year = current_year; } - var year_start_date = current_year + "-" + fy[0]; + let year_start_date = current_year + "-" + fy[0]; if (year_start_date > frappe.datetime.get_today()) { next_year = current_year; current_year -= 1; @@ -171,7 +102,7 @@ erpnext.setup.slides_settings = [ load_chart_of_accounts: function (slide) { - var country = frappe.wizard.values.country; + let country = frappe.wizard.values.country; if (country) { frappe.call({ @@ -202,12 +133,25 @@ erpnext.setup.slides_settings = [ me.charts_modal(slide, chart_template); }); + + slide.get_input("company_name").on("change", function () { + let parts = slide.get_input("company_name").val().split(" "); + let abbr = $.map(parts, function (p) { return p ? p.substr(0, 1) : null }).join(""); + slide.get_field("company_abbr").set_value(abbr.slice(0, 10).toUpperCase()); + }).val(frappe.boot.sysdefaults.company_name || "").trigger("change"); + + slide.get_input("company_abbr").on("change", function () { + if (slide.get_input("company_abbr").val().length > 10) { + frappe.msgprint(__("Company Abbreviation cannot have more than 5 characters")); + slide.get_field("company_abbr").set_value(""); + } + }); }, charts_modal: function(slide, chart_template) { let parent = __('All Accounts'); - var dialog = new frappe.ui.Dialog({ + let dialog = new frappe.ui.Dialog({ title: chart_template, fields: [ {'fieldname': 'expand_all', 'label': __('Expand All'), 'fieldtype': 'Button', diff --git a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json index c32ab6bec24..d332b4e76bd 100644 --- a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json +++ b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json @@ -10,6 +10,7 @@ "tax_withholding_category", "fiscal_year", "column_break_3", + "company", "certificate_no", "section_break_3", "supplier", @@ -123,11 +124,18 @@ "label": "Tax Withholding Category", "options": "Tax Withholding Category", "reqd": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-10-23 18:33:38.962622", + "modified": "2023-04-18 08:25:35.302081", "modified_by": "Administrator", "module": "Regional", "name": "Lower Deduction Certificate", @@ -136,5 +144,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/regional/report/fichier_des_ecritures_comptables_[fec]/fichier_des_ecritures_comptables_[fec].py b/erpnext/regional/report/fichier_des_ecritures_comptables_[fec]/fichier_des_ecritures_comptables_[fec].py index c75179ee5d1..67179890088 100644 --- a/erpnext/regional/report/fichier_des_ecritures_comptables_[fec]/fichier_des_ecritures_comptables_[fec].py +++ b/erpnext/regional/report/fichier_des_ecritures_comptables_[fec]/fichier_des_ecritures_comptables_[fec].py @@ -1,31 +1,135 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - import re import frappe from frappe import _ from frappe.utils import format_datetime +COLUMNS = [ + { + "label": "JournalCode", + "fieldname": "JournalCode", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "JournalLib", + "fieldname": "JournalLib", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "EcritureNum", + "fieldname": "EcritureNum", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "EcritureDate", + "fieldname": "EcritureDate", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "CompteNum", + "fieldname": "CompteNum", + "fieldtype": "Link", + "options": "Account", + "width": 100, + }, + { + "label": "CompteLib", + "fieldname": "CompteLib", + "fieldtype": "Link", + "options": "Account", + "width": 200, + }, + { + "label": "CompAuxNum", + "fieldname": "CompAuxNum", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "CompAuxLib", + "fieldname": "CompAuxLib", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "PieceRef", + "fieldname": "PieceRef", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "PieceDate", + "fieldname": "PieceDate", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "EcritureLib", + "fieldname": "EcritureLib", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "Debit", + "fieldname": "Debit", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "Credit", + "fieldname": "Credit", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "EcritureLet", + "fieldname": "EcritureLet", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "DateLet", + "fieldname": "DateLet", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "ValidDate", + "fieldname": "ValidDate", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "Montantdevise", + "fieldname": "Montantdevise", + "fieldtype": "Data", + "width": 90, + }, + { + "label": "Idevise", + "fieldname": "Idevise", + "fieldtype": "Data", + "width": 90, + }, +] + def execute(filters=None): - account_details = {} - for acc in frappe.db.sql("""select name, is_group from tabAccount""", as_dict=1): - account_details.setdefault(acc.name, acc) - - validate_filters(filters, account_details) - - filters = set_account_currency(filters) - - columns = get_columns(filters) - - res = get_result(filters) - - return columns, res + validate_filters(filters) + return COLUMNS, get_result( + company=filters["company"], + fiscal_year=filters["fiscal_year"], + ) -def validate_filters(filters, account_details): +def validate_filters(filters): if not filters.get("company"): frappe.throw(_("{0} is mandatory").format(_("Company"))) @@ -33,107 +137,96 @@ def validate_filters(filters, account_details): frappe.throw(_("{0} is mandatory").format(_("Fiscal Year"))) -def set_account_currency(filters): +def get_gl_entries(company, fiscal_year): + gle = frappe.qb.DocType("GL Entry") + sales_invoice = frappe.qb.DocType("Sales Invoice") + purchase_invoice = frappe.qb.DocType("Purchase Invoice") + journal_entry = frappe.qb.DocType("Journal Entry") + payment_entry = frappe.qb.DocType("Payment Entry") + customer = frappe.qb.DocType("Customer") + supplier = frappe.qb.DocType("Supplier") + employee = frappe.qb.DocType("Employee") - filters["company_currency"] = frappe.get_cached_value( - "Company", filters.company, "default_currency" + debit = frappe.query_builder.functions.Sum(gle.debit).as_("debit") + credit = frappe.query_builder.functions.Sum(gle.credit).as_("credit") + debit_currency = frappe.query_builder.functions.Sum(gle.debit_in_account_currency).as_( + "debitCurr" + ) + credit_currency = frappe.query_builder.functions.Sum(gle.credit_in_account_currency).as_( + "creditCurr" ) - return filters - - -def get_columns(filters): - columns = [ - "JournalCode" + "::90", - "JournalLib" + "::90", - "EcritureNum" + ":Dynamic Link:90", - "EcritureDate" + "::90", - "CompteNum" + ":Link/Account:100", - "CompteLib" + ":Link/Account:200", - "CompAuxNum" + "::90", - "CompAuxLib" + "::90", - "PieceRef" + "::90", - "PieceDate" + "::90", - "EcritureLib" + "::90", - "Debit" + "::90", - "Credit" + "::90", - "EcritureLet" + "::90", - "DateLet" + "::90", - "ValidDate" + "::90", - "Montantdevise" + "::90", - "Idevise" + "::90", - ] - - return columns - - -def get_result(filters): - gl_entries = get_gl_entries(filters) - - result = get_result_as_list(gl_entries, filters) - - return result - - -def get_gl_entries(filters): - - group_by_condition = ( - "group by voucher_type, voucher_no, account" - if filters.get("group_by_voucher") - else "group by gl.name" + query = ( + frappe.qb.from_(gle) + .left_join(sales_invoice) + .on(gle.voucher_no == sales_invoice.name) + .left_join(purchase_invoice) + .on(gle.voucher_no == purchase_invoice.name) + .left_join(journal_entry) + .on(gle.voucher_no == journal_entry.name) + .left_join(payment_entry) + .on(gle.voucher_no == payment_entry.name) + .left_join(customer) + .on(gle.party == customer.name) + .left_join(supplier) + .on(gle.party == supplier.name) + .left_join(employee) + .on(gle.party == employee.name) + .select( + gle.posting_date.as_("GlPostDate"), + gle.name.as_("GlName"), + gle.account, + gle.transaction_date, + debit, + credit, + debit_currency, + credit_currency, + gle.voucher_type, + gle.voucher_no, + gle.against_voucher_type, + gle.against_voucher, + gle.account_currency, + gle.against, + gle.party_type, + gle.party, + sales_invoice.name.as_("InvName"), + sales_invoice.title.as_("InvTitle"), + sales_invoice.posting_date.as_("InvPostDate"), + purchase_invoice.name.as_("PurName"), + purchase_invoice.title.as_("PurTitle"), + purchase_invoice.posting_date.as_("PurPostDate"), + journal_entry.cheque_no.as_("JnlRef"), + journal_entry.posting_date.as_("JnlPostDate"), + journal_entry.title.as_("JnlTitle"), + payment_entry.name.as_("PayName"), + payment_entry.posting_date.as_("PayPostDate"), + payment_entry.title.as_("PayTitle"), + customer.customer_name, + customer.name.as_("cusName"), + supplier.supplier_name, + supplier.name.as_("supName"), + employee.employee_name, + employee.name.as_("empName"), + ) + .where((gle.company == company) & (gle.fiscal_year == fiscal_year)) + .groupby(gle.voucher_type, gle.voucher_no, gle.account) + .orderby(gle.posting_date, gle.voucher_no) ) - gl_entries = frappe.db.sql( - """ - select - gl.posting_date as GlPostDate, gl.name as GlName, gl.account, gl.transaction_date, - sum(gl.debit) as debit, sum(gl.credit) as credit, - sum(gl.debit_in_account_currency) as debitCurr, sum(gl.credit_in_account_currency) as creditCurr, - gl.voucher_type, gl.voucher_no, gl.against_voucher_type, - gl.against_voucher, gl.account_currency, gl.against, - gl.party_type, gl.party, - inv.name as InvName, inv.title as InvTitle, inv.posting_date as InvPostDate, - pur.name as PurName, pur.title as PurTitle, pur.posting_date as PurPostDate, - jnl.cheque_no as JnlRef, jnl.posting_date as JnlPostDate, jnl.title as JnlTitle, - pay.name as PayName, pay.posting_date as PayPostDate, pay.title as PayTitle, - cus.customer_name, cus.name as cusName, - sup.supplier_name, sup.name as supName, - emp.employee_name, emp.name as empName, - stu.title as student_name, stu.name as stuName, - member_name, mem.name as memName - - from `tabGL Entry` gl - left join `tabSales Invoice` inv on gl.voucher_no = inv.name - left join `tabPurchase Invoice` pur on gl.voucher_no = pur.name - left join `tabJournal Entry` jnl on gl.voucher_no = jnl.name - left join `tabPayment Entry` pay on gl.voucher_no = pay.name - left join `tabCustomer` cus on gl.party = cus.name - left join `tabSupplier` sup on gl.party = sup.name - left join `tabEmployee` emp on gl.party = emp.name - left join `tabStudent` stu on gl.party = stu.name - left join `tabMember` mem on gl.party = mem.name - where gl.company=%(company)s and gl.fiscal_year=%(fiscal_year)s - {group_by_condition} - order by GlPostDate, voucher_no""".format( - group_by_condition=group_by_condition - ), - filters, - as_dict=1, - ) - - return gl_entries + return query.run(as_dict=True) -def get_result_as_list(data, filters): +def get_result(company, fiscal_year): + data = get_gl_entries(company, fiscal_year) + result = [] - company_currency = frappe.get_cached_value("Company", filters.company, "default_currency") + company_currency = frappe.get_cached_value("Company", company, "default_currency") accounts = frappe.get_all( - "Account", filters={"Company": filters.company}, fields=["name", "account_number"] + "Account", filters={"Company": company}, fields=["name", "account_number"] ) for d in data: - JournalCode = re.split("-|/|[0-9]", d.get("voucher_no"))[0] if d.get("voucher_no").startswith("{0}-".format(JournalCode)) or d.get("voucher_no").startswith( @@ -141,9 +234,7 @@ def get_result_as_list(data, filters): ): EcritureNum = re.split("-|/", d.get("voucher_no"))[1] else: - EcritureNum = re.search( - r"{0}(\d+)".format(JournalCode), d.get("voucher_no"), re.IGNORECASE - ).group(1) + EcritureNum = re.search(r"{0}(\d+)".format(JournalCode), d.get("voucher_no"), re.IGNORECASE)[1] EcritureDate = format_datetime(d.get("GlPostDate"), "yyyyMMdd") @@ -185,7 +276,7 @@ def get_result_as_list(data, filters): ValidDate = format_datetime(d.get("GlPostDate"), "yyyyMMdd") - PieceRef = d.get("voucher_no") if d.get("voucher_no") else "Sans Reference" + PieceRef = d.get("voucher_no") or "Sans Reference" # EcritureLib is the reference title unless it is an opening entry if d.get("is_opening") == "Yes": diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index eb2c0a48ac5..2ffa6a5c120 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -416,7 +416,6 @@ "fieldname": "items_section", "fieldtype": "Section Break", "hide_border": 1, - "label": "Items", "oldfieldtype": "Section Break", "options": "fa fa-shopping-cart" }, @@ -424,6 +423,7 @@ "allow_bulk_edit": 1, "fieldname": "items", "fieldtype": "Table", + "label": "Items", "oldfieldname": "quotation_details", "oldfieldtype": "Table", "options": "Quotation Item", @@ -1072,7 +1072,7 @@ "idx": 82, "is_submittable": 1, "links": [], - "modified": "2022-12-12 18:32:28.671332", + "modified": "2023-04-14 16:50:44.550098", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index ccea8407ab8..4f498fb20d5 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -30,10 +30,6 @@ "cost_center", "dimension_col_break", "project", - "column_break_77", - "source", - "campaign", - "custom_dimensions_section", "currency_and_price_list", "currency", "conversion_rate", @@ -162,7 +158,9 @@ "is_internal_customer", "represents_company", "column_break_152", + "source", "inter_company_order_reference", + "campaign", "party_account_currency", "connections_tab" ], @@ -1164,12 +1162,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "column_break_77", - "fieldtype": "Column Break", - "hide_days": 1, - "hide_seconds": 1 - }, { "fieldname": "source", "fieldtype": "Link", @@ -1612,10 +1604,6 @@ "fieldname": "column_break_92", "fieldtype": "Column Break" }, - { - "fieldname": "custom_dimensions_section", - "fieldtype": "Section Break" - }, { "collapsible": 1, "fieldname": "additional_info_section", @@ -1643,7 +1631,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2022-12-12 18:34:00.681780", + "modified": "2023-04-20 11:14:01.036202", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index ee9161bee48..de63f6dec58 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -547,7 +547,7 @@ def make_material_request(source_name, target_doc=None): # qty is for packed items, because packed items don't have stock_qty field qty = source.get("qty") target.project = source_parent.project - target.qty = qty - requested_item_qty.get(source.name, 0) + target.qty = qty - requested_item_qty.get(source.name, 0) - source.delivered_qty target.stock_qty = flt(target.qty) * flt(target.conversion_factor) args = target.as_dict().copy() @@ -581,7 +581,7 @@ def make_material_request(source_name, target_doc=None): "doctype": "Material Request Item", "field_map": {"name": "sales_order_item", "parent": "sales_order"}, "condition": lambda doc: not frappe.db.exists("Product Bundle", doc.item_code) - and doc.stock_qty > requested_item_qty.get(doc.name, 0), + and (doc.stock_qty - doc.delivered_qty) > requested_item_qty.get(doc.name, 0), "postprocess": update_item, }, }, @@ -1340,8 +1340,9 @@ def get_work_order_items(sales_order, for_raw_material_request=0): .select(Sum(wo.qty)) .where( (wo.production_item == i.item_code) - & (wo.sales_order == so.name) * (wo.sales_order_item == i.name) - & (wo.docstatus.lte(2)) + & (wo.sales_order == so.name) + & (wo.sales_order_item == i.name) + & (wo.docstatus.lt(2)) ) .run()[0][0] ) diff --git a/erpnext/selling/doctype/sales_order/sales_order_list.js b/erpnext/selling/doctype/sales_order/sales_order_list.js index 4691190d2a5..64c58ef5d7b 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_list.js +++ b/erpnext/selling/doctype/sales_order/sales_order_list.js @@ -57,7 +57,7 @@ frappe.listview_settings['Sales Order'] = { }); listview.page.add_action_item(__("Advance Payment"), ()=>{ - erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Advance Payment"); + erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Payment Entry"); }); } diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 627914f0c7e..ba8bbc21857 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1878,6 +1878,37 @@ class TestSalesOrder(FrappeTestCase): self.assertEqual(pe.references[1].reference_name, so.name) self.assertEqual(pe.references[1].allocated_amount, 300) + def test_delivered_item_material_request(self): + "SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO." + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_se_from_wo, + ) + from erpnext.stock.doctype.material_request.material_request import raise_work_orders + + so = make_sales_order( + item_list=[ + {"item_code": "_Test FG Item", "qty": 10, "rate": 100, "warehouse": "Work In Progress - _TC"} + ] + ) + + make_stock_entry( + item_code="_Test FG Item", target="Work In Progress - _TC", qty=4, basic_rate=100 + ) + + dn = make_delivery_note(so.name) + dn.items[0].qty = 4 + dn.submit() + + so.load_from_db() + self.assertEqual(so.items[0].delivered_qty, 4) + + mr = make_material_request(so.name) + mr.material_request_type = "Purchase" + mr.schedule_date = today() + mr.save() + + self.assertEqual(mr.items[0].qty, 6) + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index da798ab6d2d..07b1c2eb7ee 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -559,8 +559,10 @@ erpnext.PointOfSale.Controller = class { item_row = this.frm.add_child('items', new_item); - if (field === 'qty' && value !== 0 && !this.allow_negative_stock) - await this.check_stock_availability(item_row, value, this.frm.doc.set_warehouse); + if (field === 'qty' && value !== 0 && !this.allow_negative_stock) { + const qty_needed = value * item_row.conversion_factor; + await this.check_stock_availability(item_row, qty_needed, this.frm.doc.set_warehouse); + } await this.trigger_new_item_events(item_row); diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py index b08f1deefe3..605d2fac44f 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -168,7 +168,7 @@ class Analytics(object): def get_sales_transactions_based_on_items(self): if self.filters["value_quantity"] == "Value": - value_field = "base_amount" + value_field = "base_net_amount" else: value_field = "stock_qty" @@ -216,7 +216,7 @@ class Analytics(object): def get_sales_transactions_based_on_item_group(self): if self.filters["value_quantity"] == "Value": - value_field = "base_amount" + value_field = "base_net_amount" else: value_field = "qty" diff --git a/erpnext/selling/workspace/selling/selling.json b/erpnext/selling/workspace/selling/selling.json index 45e160d143e..180a3d783e0 100644 --- a/erpnext/selling/workspace/selling/selling.json +++ b/erpnext/selling/workspace/selling/selling.json @@ -704,7 +704,7 @@ "type": "Link" } ], - "modified": "2022-04-26 13:29:55.087240", + "modified": "2023-04-16 13:29:55.087240", "modified_by": "Administrator", "module": "Selling", "name": "Selling", diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index 2fdfcf647d0..f5432c18258 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -36,8 +36,24 @@ class ItemGroup(NestedSet, WebsiteGenerator): self.make_route() self.validate_item_group_defaults() + self.check_item_tax() ECommerceSettings.validate_field_filters(self.filter_fields, enable_field_filters=True) + def check_item_tax(self): + """Check whether Tax Rate is not entered twice for same Tax Type""" + check_list = [] + for d in self.get("taxes"): + if d.item_tax_template: + if (d.item_tax_template, d.tax_category) in check_list: + frappe.throw( + _("{0} entered twice {1} in Item Taxes").format( + frappe.bold(d.item_tax_template), + "for tax category {0}".format(frappe.bold(d.tax_category)) if d.tax_category else "", + ) + ) + else: + check_list.append((d.item_tax_template, d.tax_category)) + def on_update(self): NestedSet.on_update(self) invalidate_cache_for(self) @@ -148,12 +164,17 @@ def get_item_for_list_in_html(context): def get_parent_item_groups(item_group_name, from_item=False): - base_nav_page = {"name": _("All Products"), "route": "/all-products"} + settings = frappe.get_cached_doc("E Commerce Settings") + + if settings.enable_field_filters: + base_nav_page = {"name": _("Shop by Category"), "route": "/shop-by-category"} + else: + base_nav_page = {"name": _("All Products"), "route": "/all-products"} if from_item and frappe.request.environ.get("HTTP_REFERER"): # base page after 'Home' will vary on Item page last_page = frappe.request.environ["HTTP_REFERER"].split("/")[-1].split("?")[0] - if last_page and last_page == "shop-by-category": + if last_page and last_page in ("shop-by-category", "all-products"): base_nav_page_title = " ".join(last_page.split("-")).title() base_nav_page = {"name": _(base_nav_page_title), "route": "/" + last_page} diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 088958d1b26..3e1e39410ed 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -161,7 +161,7 @@ def add_standard_navbar_items(): { "item_label": "User Forum", "item_type": "Route", - "route": "https://discuss.erpnext.com", + "route": "https://discuss.frappe.io", "is_standard": 1, }, { diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index 45e39c5bd0b..c6efbb78125 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -581,6 +581,11 @@ "title": "Bauleistungen nach § 13b UStG", "is_default": 0, "taxes": [] + }, + { + "title": "Nullsteuersatz nach § 12 Abs. 3 UStG", + "is_default": 0, + "taxes": [] } ], "purchase_tax_templates": [ @@ -1339,6 +1344,11 @@ "title": "Bauleistungen nach § 13b UStG", "is_default": 0, "taxes": [] + }, + { + "title": "Nullsteuersatz nach § 12 Abs. 3 UStG", + "is_default": 0, + "taxes": [] } ], "purchase_tax_templates": [ @@ -2097,6 +2107,11 @@ "title": "Bauleistungen nach § 13b UStG", "is_default": 0, "taxes": [] + }, + { + "title": "Nullsteuersatz nach § 12 Abs. 3 UStG", + "is_default": 0, + "taxes": [] } ], "purchase_tax_templates": [ @@ -2849,6 +2864,11 @@ "title": "Bauleistungen nach § 13b UStG", "is_default": 0, "taxes": [] + }, + { + "title": "Nullsteuersatz nach § 12 Abs. 3 UStG", + "is_default": 0, + "taxes": [] } ], "purchase_tax_templates": [ diff --git a/erpnext/setup/setup_wizard/operations/company_setup.py b/erpnext/setup/setup_wizard/operations/company_setup.py index aadc98989fa..ace5cca0b02 100644 --- a/erpnext/setup/setup_wizard/operations/company_setup.py +++ b/erpnext/setup/setup_wizard/operations/company_setup.py @@ -4,7 +4,6 @@ import frappe from frappe import _ from frappe.utils import cstr, getdate -from .default_website import website_maker def create_fiscal_year_and_company(args): @@ -48,83 +47,6 @@ def enable_shopping_cart(args): # nosemgrep ).insert() -def create_email_digest(): - from frappe.utils.user import get_system_managers - - system_managers = get_system_managers(only_name=True) - - if not system_managers: - return - - recipients = [] - for d in system_managers: - recipients.append({"recipient": d}) - - companies = frappe.db.sql_list("select name FROM `tabCompany`") - for company in companies: - if not frappe.db.exists("Email Digest", "Default Weekly Digest - " + company): - edigest = frappe.get_doc( - { - "doctype": "Email Digest", - "name": "Default Weekly Digest - " + company, - "company": company, - "frequency": "Weekly", - "recipients": recipients, - } - ) - - for df in edigest.meta.get("fields", {"fieldtype": "Check"}): - if df.fieldname != "scheduler_errors": - edigest.set(df.fieldname, 1) - - edigest.insert() - - # scheduler errors digest - if companies: - edigest = frappe.new_doc("Email Digest") - edigest.update( - { - "name": "Scheduler Errors", - "company": companies[0], - "frequency": "Daily", - "recipients": recipients, - "scheduler_errors": 1, - "enabled": 1, - } - ) - edigest.insert() - - -def create_logo(args): - if args.get("attach_logo"): - attach_logo = args.get("attach_logo").split(",") - if len(attach_logo) == 3: - filename, filetype, content = attach_logo - _file = frappe.get_doc( - { - "doctype": "File", - "file_name": filename, - "attached_to_doctype": "Website Settings", - "attached_to_name": "Website Settings", - "decode": True, - } - ) - _file.save() - fileurl = _file.file_url - frappe.db.set_value( - "Website Settings", - "Website Settings", - "brand_html", - " {1}".format( - fileurl, args.get("company_name") - ), - ) - - -def create_website(args): - website_maker(args) - - def get_fy_details(fy_start_date, fy_end_date): start_year = getdate(fy_start_date).year if start_year == getdate(fy_end_date).year: diff --git a/erpnext/setup/setup_wizard/operations/default_website.py b/erpnext/setup/setup_wizard/operations/default_website.py deleted file mode 100644 index 40b02b35dfd..00000000000 --- a/erpnext/setup/setup_wizard/operations/default_website.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - - -import frappe -from frappe import _ -from frappe.utils import nowdate - - -class website_maker(object): - def __init__(self, args): - self.args = args - self.company = args.company_name - self.tagline = args.company_tagline - self.user = args.get("email") - self.make_web_page() - self.make_website_settings() - self.make_blog() - - def make_web_page(self): - # home page - homepage = frappe.get_doc("Homepage", "Homepage") - homepage.company = self.company - homepage.tag_line = self.tagline - homepage.setup_items() - homepage.save() - - def make_website_settings(self): - # update in home page in settings - website_settings = frappe.get_doc("Website Settings", "Website Settings") - website_settings.home_page = "home" - website_settings.brand_html = self.company - website_settings.copyright = self.company - website_settings.top_bar_items = [] - website_settings.append( - "top_bar_items", {"doctype": "Top Bar Item", "label": "Contact", "url": "/contact"} - ) - website_settings.append( - "top_bar_items", {"doctype": "Top Bar Item", "label": "Blog", "url": "/blog"} - ) - website_settings.append( - "top_bar_items", {"doctype": "Top Bar Item", "label": _("Products"), "url": "/all-products"} - ) - website_settings.save() - - def make_blog(self): - blog_category = frappe.get_doc( - {"doctype": "Blog Category", "category_name": "general", "published": 1, "title": _("General")} - ).insert() - - if not self.user: - # Admin setup - return - - blogger = frappe.new_doc("Blogger") - user = frappe.get_doc("User", self.user) - blogger.user = self.user - blogger.full_name = user.first_name + (" " + user.last_name if user.last_name else "") - blogger.short_name = user.first_name.lower() - blogger.avatar = user.user_image - blogger.insert() - - frappe.get_doc( - { - "doctype": "Blog Post", - "title": "Welcome", - "published": 1, - "published_on": nowdate(), - "blogger": blogger.name, - "blog_category": blog_category.name, - "blog_intro": "My First Blog", - "content": frappe.get_template("setup/setup_wizard/data/sample_blog_post.html").render(), - } - ).insert() - - -def test(): - frappe.delete_doc("Web Page", "test-company") - frappe.delete_doc("Blog Post", "welcome") - frappe.delete_doc("Blogger", "administrator") - frappe.delete_doc("Blog Category", "general") - website_maker( - { - "company": "Test Company", - "company_tagline": "Better Tools for Everyone", - "name": "Administrator", - } - ) - frappe.db.commit() diff --git a/erpnext/setup/setup_wizard/setup_wizard.py b/erpnext/setup/setup_wizard/setup_wizard.py index bd86a5b9693..65b268e385c 100644 --- a/erpnext/setup/setup_wizard/setup_wizard.py +++ b/erpnext/setup/setup_wizard/setup_wizard.py @@ -5,7 +5,6 @@ import frappe from frappe import _ -from .operations import company_setup from .operations import install_fixtures as fixtures @@ -35,7 +34,6 @@ def get_setup_stages(args=None): "fail_msg": "Failed to set defaults", "tasks": [ {"fn": setup_defaults, "args": args, "fail_msg": _("Failed to setup defaults")}, - {"fn": stage_four, "args": args, "fail_msg": _("Failed to create website")}, ], }, { @@ -60,12 +58,6 @@ def setup_defaults(args): fixtures.install_defaults(frappe._dict(args)) -def stage_four(args): - company_setup.create_website(args) - company_setup.create_email_digest() - company_setup.create_logo(args) - - def fin(args): frappe.local.message_log = [] login_as_first_user(args) @@ -81,5 +73,4 @@ def setup_complete(args=None): stage_fixtures(args) setup_company(args) setup_defaults(args) - stage_four(args) fin(args) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 4a165212dce..1843c6e7975 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.model.naming import make_autoname, revert_series_if_last +from frappe.query_builder.functions import CombineDatetime, CurDate, Sum from frappe.utils import cint, flt, get_link_to_form, nowtime from frappe.utils.data import add_days from frappe.utils.jinja import render_template @@ -176,49 +177,42 @@ def get_batch_qty( :param warehouse: Optional - give qty for this warehouse :param item_code: Optional - give qty for this item""" + sle = frappe.qb.DocType("Stock Ledger Entry") + out = 0 if batch_no and warehouse: - cond = "" + query = ( + frappe.qb.from_(sle) + .select(Sum(sle.actual_qty)) + .where((sle.is_cancelled == 0) & (sle.warehouse == warehouse) & (sle.batch_no == batch_no)) + ) if posting_date: if posting_time is None: posting_time = nowtime() - cond = " and timestamp(posting_date, posting_time) <= timestamp('{0}', '{1}')".format( - posting_date, posting_time + query = query.where( + CombineDatetime(sle.posting_date, sle.posting_time) + <= CombineDatetime(posting_date, posting_time) ) - out = float( - frappe.db.sql( - """select sum(actual_qty) - from `tabStock Ledger Entry` - where is_cancelled = 0 and warehouse=%s and batch_no=%s {0}""".format( - cond - ), - (warehouse, batch_no), - )[0][0] - or 0 - ) + out = query.run(as_list=True)[0][0] or 0 if batch_no and not warehouse: - out = frappe.db.sql( - """select warehouse, sum(actual_qty) as qty - from `tabStock Ledger Entry` - where is_cancelled = 0 and batch_no=%s - group by warehouse""", - batch_no, - as_dict=1, - ) + out = ( + frappe.qb.from_(sle) + .select(sle.warehouse, Sum(sle.actual_qty).as_("qty")) + .where((sle.is_cancelled == 0) & (sle.batch_no == batch_no)) + .groupby(sle.warehouse) + ).run(as_dict=True) if not batch_no and item_code and warehouse: - out = frappe.db.sql( - """select batch_no, sum(actual_qty) as qty - from `tabStock Ledger Entry` - where is_cancelled = 0 and item_code = %s and warehouse=%s - group by batch_no""", - (item_code, warehouse), - as_dict=1, - ) + out = ( + frappe.qb.from_(sle) + .select(sle.batch_no, Sum(sle.actual_qty).as_("qty")) + .where((sle.is_cancelled == 0) & (sle.item_code == item_code) & (sle.warehouse == warehouse)) + .groupby(sle.batch_no) + ).run(as_dict=True) return out @@ -314,40 +308,44 @@ def get_batch_no(item_code, warehouse, qty=1, throw=False, serial_no=None): def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos - cond = "" + batch = frappe.qb.DocType("Batch") + sle = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(batch) + .join(sle) + .on(batch.batch_id == sle.batch_no) + .select( + batch.batch_id, + Sum(sle.actual_qty).as_("qty"), + ) + .where( + (sle.item_code == item_code) + & (sle.warehouse == warehouse) + & (sle.is_cancelled == 0) + & ((batch.expiry_date >= CurDate()) | (batch.expiry_date.isnull())) + ) + .groupby(batch.batch_id) + .orderby(batch.expiry_date, batch.creation) + ) + if serial_no and frappe.get_cached_value("Item", item_code, "has_batch_no"): serial_nos = get_serial_nos(serial_no) - batch = frappe.get_all( + batches = frappe.get_all( "Serial No", fields=["distinct batch_no"], filters={"item_code": item_code, "warehouse": warehouse, "name": ("in", serial_nos)}, ) - if not batch: + if not batches: validate_serial_no_with_batch(serial_nos, item_code) - if batch and len(batch) > 1: + if batches and len(batches) > 1: return [] - cond = " and `tabBatch`.name = %s" % (frappe.db.escape(batch[0].batch_no)) + query = query.where(batch.name == batches[0].batch_no) - return frappe.db.sql( - """ - select batch_id, sum(`tabStock Ledger Entry`.actual_qty) as qty - from `tabBatch` - join `tabStock Ledger Entry` ignore index (item_code, warehouse) - on (`tabBatch`.batch_id = `tabStock Ledger Entry`.batch_no ) - where `tabStock Ledger Entry`.item_code = %s and `tabStock Ledger Entry`.warehouse = %s - and `tabStock Ledger Entry`.is_cancelled = 0 - and (`tabBatch`.expiry_date >= CURRENT_DATE or `tabBatch`.expiry_date IS NULL) {0} - group by batch_id - order by `tabBatch`.expiry_date ASC, `tabBatch`.creation ASC - """.format( - cond - ), - (item_code, warehouse), - as_dict=True, - ) + return query.run(as_dict=True) def validate_serial_no_with_batch(serial_nos, item_code): @@ -379,7 +377,7 @@ def get_pos_reserved_batch_qty(filters): p = frappe.qb.DocType("POS Invoice").as_("p") item = frappe.qb.DocType("POS Invoice Item").as_("item") - sum_qty = frappe.query_builder.functions.Sum(item.qty).as_("qty") + sum_qty = frappe.query_builder.functions.Sum(item.stock_qty).as_("qty") reserved_batch_qty = ( frappe.qb.from_(p) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 0c1f82029e6..2adf9c310f9 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -28,8 +28,6 @@ "column_break_18", "project", "dimension_col_break", - "campaign", - "source", "custom_dimensions_section", "currency_and_price_list", "currency", @@ -161,11 +159,12 @@ "inter_company_reference", "customer_group", "territory", + "source", + "campaign", "column_break5", "excise_page", "instructions", - "connections_tab", - "column_break_25" + "connections_tab" ], "fields": [ { @@ -1339,10 +1338,6 @@ "fieldname": "column_break_10", "fieldtype": "Column Break" }, - { - "fieldname": "column_break_25", - "fieldtype": "Column Break" - }, { "fieldname": "section_break_30", "fieldtype": "Section Break", @@ -1403,7 +1398,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2023-02-14 04:45:44.179670", + "modified": "2023-04-21 11:15:23.931084", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 9f9f5cbe2a4..16caceba09f 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -118,7 +118,7 @@ class DeliveryNote(SellingController): def so_required(self): """check in manage account if sales order required or not""" - if frappe.db.get_value("Selling Settings", None, "so_required") == "Yes": + if frappe.db.get_single_value("Selling Settings", "so_required") == "Yes": for d in self.get("items"): if not d.against_sales_order: frappe.throw(_("Sales Order required for Item {0}").format(d.item_code)) @@ -205,7 +205,7 @@ class DeliveryNote(SellingController): super(DeliveryNote, self).validate_warehouse() for d in self.get_item_list(): - if not d["warehouse"] and frappe.db.get_value("Item", d["item_code"], "is_stock_item") == 1: + if not d["warehouse"] and frappe.get_cached_value("Item", d["item_code"], "is_stock_item") == 1: frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"])) def update_current_stock(self): 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 1763269193a..e46cab05762 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -83,6 +83,8 @@ "actual_qty", "installed_qty", "item_tax_rate", + "column_break_atna", + "received_qty", "accounting_details_section", "expense_account", "allow_zero_valuation_rate", @@ -627,7 +629,8 @@ "no_copy": 1, "options": "Sales Order", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "against_sales_invoice", @@ -660,7 +663,8 @@ "label": "Against Sales Invoice Item", "no_copy": 1, "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "installed_qty", @@ -832,13 +836,27 @@ "fieldname": "material_request_item", "fieldtype": "Data", "label": "Material Request Item" + }, + { + "fieldname": "column_break_atna", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: parent.is_internal_customer", + "fieldname": "received_qty", + "fieldtype": "Float", + "label": "Received Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "report_hide": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-03-20 14:24:10.406746", + "modified": "2023-05-01 21:05:14.175640", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 423b9defc19..8a25be58618 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -350,10 +350,15 @@ class Item(Document): check_list = [] for d in self.get("taxes"): if d.item_tax_template: - if d.item_tax_template in check_list: - frappe.throw(_("{0} entered twice in Item Tax").format(d.item_tax_template)) + if (d.item_tax_template, d.tax_category) in check_list: + frappe.throw( + _("{0} entered twice {1} in Item Taxes").format( + frappe.bold(d.item_tax_template), + "for tax category {0}".format(frappe.bold(d.tax_category)) if d.tax_category else "", + ) + ) else: - check_list.append(d.item_tax_template) + check_list.append((d.item_tax_template, d.tax_category)) def validate_barcode(self): from stdnum import ean diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 3548148145e..ebd9a17c3bc 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -619,7 +619,7 @@ def make_stock_entry(source_name, target_doc=None): target.set_transfer_qty() target.set_actual_qty() target.calculate_rate_and_amount(raise_error_if_no_rate=False) - target.set_stock_entry_type() + target.stock_entry_type = target.purpose target.set_job_card_data() doclist = get_mapped_doc( diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index a707c74c7db..03f58c664d3 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -54,6 +54,8 @@ class TestMaterialRequest(FrappeTestCase): mr.submit() se = make_stock_entry(mr.name) + self.assertEqual(se.stock_entry_type, "Material Transfer") + self.assertEqual(se.purpose, "Material Transfer") self.assertEqual(se.doctype, "Stock Entry") self.assertEqual(len(se.get("items")), len(mr.get("items"))) @@ -69,6 +71,8 @@ class TestMaterialRequest(FrappeTestCase): in_transit_warehouse = get_in_transit_warehouse(mr.company) se = make_in_transit_stock_entry(mr.name, in_transit_warehouse) + self.assertEqual(se.stock_entry_type, "Material Transfer") + self.assertEqual(se.purpose, "Material Transfer") self.assertEqual(se.doctype, "Stock Entry") for row in se.get("items"): self.assertEqual(row.t_warehouse, in_transit_warehouse) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index c1abd31bcc1..530427328a8 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -65,6 +65,16 @@ class PurchaseReceipt(BuyingController): "percent_join_field": "purchase_invoice", "overflow_type": "receipt", }, + { + "source_dt": "Purchase Receipt Item", + "target_dt": "Delivery Note Item", + "join_field": "delivery_note_item", + "source_field": "received_qty", + "target_field": "received_qty", + "target_parent_dt": "Delivery Note", + "target_ref_field": "qty", + "overflow_type": "receipt", + }, ] if cint(self.is_return): @@ -370,7 +380,19 @@ class PurchaseReceipt(BuyingController): outgoing_amount = d.base_net_amount if self.is_internal_supplier and d.valuation_rate: - outgoing_amount = d.valuation_rate * d.stock_qty + outgoing_amount = abs( + frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Purchase Receipt", + "voucher_no": self.name, + "voucher_detail_no": d.name, + "warehouse": d.from_warehouse, + "is_cancelled": 0, + }, + "stock_value_difference", + ) + ) credit_amount = outgoing_amount if credit_amount: diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index b6341466f87..b5740ca575b 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1544,6 +1544,213 @@ class TestPurchaseReceipt(FrappeTestCase): res = get_item_details(args) self.assertEqual(res.get("last_purchase_rate"), 100) + def test_validate_received_qty_for_internal_pr(self): + prepare_data_for_internal_transfer() + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + from_warehouse = create_warehouse("_Test Internal From Warehouse New", company=company) + target_warehouse = create_warehouse("_Test Internal GIT Warehouse New", company=company) + to_warehouse = create_warehouse("_Test Internal To Warehouse New", company=company) + + # Step 1: Create Item + item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}) + + # Step 2: Create Stock Entry (Material Receipt) + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + make_stock_entry( + purpose="Material Receipt", + item_code=item.name, + qty=15, + company=company, + to_warehouse=from_warehouse, + ) + + # Step 3: Create Delivery Note with Internal Customer + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + dn = create_delivery_note( + item_code=item.name, + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=10, + rate=100, + warehouse=from_warehouse, + target_warehouse=target_warehouse, + ) + + # Step 4: Create Internal Purchase Receipt + from erpnext.controllers.status_updater import OverAllowanceError + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + + pr = make_inter_company_purchase_receipt(dn.name) + pr.items[0].qty = 15 + pr.items[0].from_warehouse = target_warehouse + pr.items[0].warehouse = to_warehouse + pr.items[0].rejected_warehouse = from_warehouse + pr.save() + + self.assertRaises(OverAllowanceError, pr.submit) + + # Step 5: Test Over Receipt Allowance + frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50) + + make_stock_entry( + purpose="Material Transfer", + item_code=item.name, + qty=5, + company=company, + from_warehouse=from_warehouse, + to_warehouse=target_warehouse, + ) + + pr.submit() + + frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) + + def test_internal_pr_gl_entries(self): + from erpnext.stock import get_warehouse_account_map + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + prepare_data_for_internal_transfer() + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + from_warehouse = create_warehouse("_Test Internal From Warehouse New", company=company) + target_warehouse = create_warehouse("_Test Internal GIT Warehouse New", company=company) + to_warehouse = create_warehouse("_Test Internal To Warehouse New", company=company) + + item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}) + make_stock_entry( + purpose="Material Receipt", + item_code=item.name, + qty=10, + company=company, + to_warehouse=from_warehouse, + posting_date=add_days(today(), -3), + ) + + # Step - 1: Create Delivery Note with Internal Customer + dn = create_delivery_note( + item_code=item.name, + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=10, + rate=100, + warehouse=from_warehouse, + target_warehouse=target_warehouse, + posting_date=add_days(today(), -2), + ) + + # Step - 2: Create Internal Purchase Receipt + pr = make_inter_company_purchase_receipt(dn.name) + pr.items[0].qty = 10 + pr.items[0].from_warehouse = target_warehouse + pr.items[0].warehouse = to_warehouse + pr.items[0].rejected_warehouse = from_warehouse + pr.save() + pr.submit() + + # Step - 3: Create back-date Stock Reconciliation [After DN and Before PR] + create_stock_reconciliation( + item_code=item.name, + warehouse=target_warehouse, + qty=10, + rate=50, + company=company, + posting_date=add_days(today(), -1), + ) + + warehouse_account = get_warehouse_account_map(company) + stock_account_value = frappe.db.get_value( + "GL Entry", + { + "account": warehouse_account[target_warehouse]["account"], + "voucher_type": "Purchase Receipt", + "voucher_no": pr.name, + "is_cancelled": 0, + }, + fieldname=["credit"], + ) + stock_diff = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Purchase Receipt", + "voucher_no": pr.name, + "is_cancelled": 0, + }, + fieldname=["sum(stock_value_difference)"], + ) + + # Value of Stock Account should be equal to the sum of Stock Value Difference + self.assertEqual(stock_account_value, stock_diff) + + def test_internal_pr_reference(self): + item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}) + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + from_warehouse = create_warehouse("_Test Internal From Warehouse New 1", company=company) + target_warehouse = create_warehouse("_Test Internal GIT Warehouse New 1", company=company) + to_warehouse = create_warehouse("_Test Internal To Warehouse New 1", company=company) + + # Step 2: Create Stock Entry (Material Receipt) + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + make_stock_entry( + purpose="Material Receipt", + item_code=item.name, + qty=15, + company=company, + to_warehouse=from_warehouse, + ) + + # Step 3: Create Delivery Note with Internal Customer + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + dn = create_delivery_note( + item_code=item.name, + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=10, + rate=100, + warehouse=from_warehouse, + target_warehouse=target_warehouse, + ) + + # Step 4: Create Internal Purchase Receipt + from erpnext.controllers.status_updater import OverAllowanceError + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + + pr = make_inter_company_purchase_receipt(dn.name) + pr.inter_company_reference = "" + self.assertRaises(frappe.ValidationError, pr.save) + + pr.inter_company_reference = dn.name + pr.items[0].qty = 10 + pr.items[0].from_warehouse = target_warehouse + pr.items[0].warehouse = to_warehouse + pr.items[0].rejected_warehouse = from_warehouse + pr.save() + + delivery_note_item = pr.items[0].delivery_note_item + pr.items[0].delivery_note_item = "" + + self.assertRaises(frappe.ValidationError, pr.save) + + pr.load_from_db() + pr.items[0].delivery_note_item = delivery_note_item + pr.save() + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index bbed099da96..aabc6fcfe30 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -6,7 +6,7 @@ from frappe import _ from frappe.exceptions import QueryDeadlockError, QueryTimeoutError from frappe.model.document import Document from frappe.query_builder import DocType, Interval -from frappe.query_builder.functions import Now +from frappe.query_builder.functions import Max, Now from frappe.utils import cint, get_link_to_form, get_weekday, getdate, now, nowtime from frappe.utils.user import get_users_with_role from rq.timeouts import JobTimeoutException @@ -36,11 +36,38 @@ class RepostItemValuation(Document): ) def validate(self): + self.validate_period_closing_voucher() self.set_status(write=False) self.reset_field_values() self.set_company() self.validate_accounts_freeze() + def validate_period_closing_voucher(self): + year_end_date = self.get_max_year_end_date(self.company) + if year_end_date and getdate(self.posting_date) <= getdate(year_end_date): + msg = f"Due to period closing, you cannot repost item valuation before {year_end_date}" + frappe.throw(_(msg)) + + @staticmethod + def get_max_year_end_date(company): + data = frappe.get_all( + "Period Closing Voucher", fields=["fiscal_year"], filters={"docstatus": 1, "company": company} + ) + + if not data: + return + + fiscal_years = [d.fiscal_year for d in data] + table = frappe.qb.DocType("Fiscal Year") + + query = ( + frappe.qb.from_(table) + .select(Max(table.year_end_date)) + .where((table.name.isin(fiscal_years)) & (table.disabled == 0)) + ).run() + + return query[0][0] if query else None + def validate_accounts_freeze(self): acc_settings = frappe.db.get_value( "Accounts Settings", diff --git a/erpnext/stock/doctype/serial_no/serial_no.json b/erpnext/stock/doctype/serial_no/serial_no.json index 6e1e0d461ab..7989b1ac75b 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.json +++ b/erpnext/stock/doctype/serial_no/serial_no.json @@ -410,10 +410,10 @@ "fieldtype": "Link", "label": "Company", "options": "Company", - "read_only": 1, "remember_last_selected_value": 1, "reqd": 1, - "search_index": 1 + "search_index": 1, + "set_only_once": 1 }, { "fieldname": "status", @@ -433,7 +433,7 @@ "icon": "fa fa-barcode", "idx": 1, "links": [], - "modified": "2021-12-23 10:44:30.299450", + "modified": "2023-04-14 15:58:46.139887", "modified_by": "Administrator", "module": "Stock", "name": "Serial No", @@ -461,7 +461,6 @@ "read": 1, "report": 1, "role": "Stock Manager", - "set_user_permissions": 1, "write": 1 }, { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 9c0f1fc03f4..bc5533fd2de 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -27,7 +27,6 @@ "set_posting_time", "inspection_required", "apply_putaway_rule", - "items_tab", "bom_info_section", "from_bom", "use_multi_level_bom", @@ -256,7 +255,7 @@ "description": "As per Stock UOM", "fieldname": "fg_completed_qty", "fieldtype": "Float", - "label": "For Quantity", + "label": "Finished Good Quantity ", "oldfieldname": "fg_completed_qty", "oldfieldtype": "Currency", "print_hide": 1 @@ -612,11 +611,7 @@ "read_only": 1 }, { - "fieldname": "items_tab", - "fieldtype": "Tab Break", - "label": "Items" - }, - { + "collapsible": 1, "fieldname": "bom_info_section", "fieldtype": "Section Break", "label": "BOM Info" @@ -644,8 +639,10 @@ "oldfieldtype": "Section Break" }, { + "collapsible": 1, "fieldname": "section_break_7qsm", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Process Loss" }, { "depends_on": "process_loss_percentage", @@ -677,7 +674,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-01-03 16:02:50.741816", + "modified": "2023-04-06 12:42:56.673180", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 7e39cb92f70..5d6e45013d8 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2349,7 +2349,7 @@ def move_sample_to_retention_warehouse(company, items): @frappe.whitelist() def make_stock_in_entry(source_name, target_doc=None): def set_missing_values(source, target): - target.set_stock_entry_type() + target.stock_entry_type = "Material Transfer" target.set_missing_values() def update_item(source_doc, target_doc, source_parent): diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index cc06bd709ad..c43a1b1b81e 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -202,6 +202,9 @@ class TestStockEntry(FrappeTestCase): ) end_transit_entry = make_stock_in_entry(transit_entry.name) + + self.assertEqual(end_transit_entry.stock_entry_type, "Material Transfer") + self.assertEqual(end_transit_entry.purpose, "Material Transfer") self.assertEqual(transit_entry.name, end_transit_entry.outgoing_stock_entry) self.assertEqual(transit_entry.name, end_transit_entry.items[0].against_stock_entry) self.assertEqual(transit_entry.items[0].name, end_transit_entry.items[0].ste_detail) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 04d1a3a5e22..3fd4cec5d88 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -5,6 +5,7 @@ from typing import Optional import frappe from frappe import _, bold, msgprint +from frappe.query_builder.functions import CombineDatetime, Sum from frappe.utils import cint, cstr, flt import erpnext @@ -569,6 +570,76 @@ class StockReconciliation(StockController): else: self._cancel() + def recalculate_current_qty(self, item_code, batch_no): + from erpnext.stock.stock_ledger import get_valuation_rate + + sl_entries = [] + for row in self.items: + if not (row.item_code == item_code and row.batch_no == batch_no): + continue + + current_qty = get_batch_qty_for_stock_reco( + item_code, row.warehouse, batch_no, self.posting_date, self.posting_time, self.name + ) + + precesion = row.precision("current_qty") + if flt(current_qty, precesion) == flt(row.current_qty, precesion): + continue + + val_rate = get_valuation_rate( + item_code, row.warehouse, self.doctype, self.name, company=self.company, batch_no=batch_no + ) + + row.current_valuation_rate = val_rate + if not row.current_qty and current_qty: + sle = self.get_sle_for_items(row) + sle.actual_qty = current_qty * -1 + sle.valuation_rate = val_rate + sl_entries.append(sle) + + row.current_qty = current_qty + row.db_set( + { + "current_qty": row.current_qty, + "current_valuation_rate": row.current_valuation_rate, + "current_amount": flt(row.current_qty * row.current_valuation_rate), + } + ) + + if sl_entries: + self.make_sl_entries(sl_entries) + + +def get_batch_qty_for_stock_reco( + item_code, warehouse, batch_no, posting_date, posting_time, voucher_no +): + ledger = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(ledger) + .select( + Sum(ledger.actual_qty).as_("batch_qty"), + ) + .where( + (ledger.item_code == item_code) + & (ledger.warehouse == warehouse) + & (ledger.docstatus == 1) + & (ledger.is_cancelled == 0) + & (ledger.batch_no == batch_no) + & (ledger.posting_date <= posting_date) + & ( + CombineDatetime(ledger.posting_date, ledger.posting_time) + <= CombineDatetime(posting_date, posting_time) + ) + & (ledger.voucher_no != voucher_no) + ) + .groupby(ledger.batch_no) + ) + + sle = query.run(as_dict=True) + + return flt(sle[0].batch_qty) if sle else 0 + @frappe.whitelist() def get_items( diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index eaea301432e..2e5d2c3aaff 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -530,7 +530,9 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): # check if cancellation of stock reco is blocked self.assertRaises(NegativeStockError, sr.cancel) - repost_exists = bool(frappe.db.exists("Repost Item Valuation", {"voucher_no": sr.name})) + repost_exists = bool( + frappe.db.exists("Repost Item Valuation", {"voucher_no": sr.name, "status": "Queued"}) + ) self.assertFalse(repost_exists, msg="Negative stock validation not working on reco cancellation") def test_intermediate_sr_bin_update(self): @@ -676,6 +678,79 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertEqual(flt(sl_entry.actual_qty), 1.0) self.assertEqual(flt(sl_entry.qty_after_transaction), 1.0) + def test_backdated_stock_reco_entry(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item( + "Test New Batch Item ABCV", + { + "is_stock_item": 1, + "has_batch_no": 1, + "batch_number_series": "BNS9.####", + "create_new_batch": 1, + }, + ).name + + warehouse = "_Test Warehouse - _TC" + + # Added 100 Qty, Balace Qty 100 + se1 = make_stock_entry( + item_code=item_code, posting_time="09:00:00", target=warehouse, qty=100, basic_rate=700 + ) + + # Removed 50 Qty, Balace Qty 50 + se2 = make_stock_entry( + item_code=item_code, + batch_no=se1.items[0].batch_no, + posting_time="10:00:00", + source=warehouse, + qty=50, + basic_rate=700, + ) + + # Stock Reco for 100, Balace Qty 100 + stock_reco = create_stock_reconciliation( + item_code=item_code, + posting_time="11:00:00", + warehouse=warehouse, + batch_no=se1.items[0].batch_no, + qty=100, + rate=100, + ) + + # Removed 50 Qty, Balace Qty 50 + make_stock_entry( + item_code=item_code, + batch_no=se1.items[0].batch_no, + posting_time="12:00:00", + source=warehouse, + qty=50, + basic_rate=700, + ) + + self.assertFalse(frappe.db.exists("Repost Item Valuation", {"voucher_no": stock_reco.name})) + + # Cancel the backdated Stock Entry se2, + # Since Stock Reco entry in the future the Balace Qty should remain as it's (50) + + se2.cancel() + + self.assertTrue(frappe.db.exists("Repost Item Valuation", {"voucher_no": stock_reco.name})) + + self.assertEqual( + frappe.db.get_value("Repost Item Valuation", {"voucher_no": stock_reco.name}, "status"), + "Completed", + ) + + sle = frappe.get_all( + "Stock Ledger Entry", + filters={"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}, + fields=["qty_after_transaction"], + order_by="posting_time desc, creation desc", + ) + + self.assertEqual(flt(sle[0].qty_after_transaction), flt(50.0)) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 489ec6ebecc..f3adefb3e74 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -74,6 +74,7 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru args["bill_date"] = doc.get("bill_date") out = get_basic_details(args, item, overwrite_warehouse) + get_item_tax_template(args, item, out) out["item_tax_rate"] = get_item_tax_map( args.company, @@ -620,7 +621,9 @@ def _get_item_tax_template(args, taxes, out=None, for_validate=False): taxes_with_no_validity.append(tax) if taxes_with_validity: - taxes = sorted(taxes_with_validity, key=lambda i: i.valid_from, reverse=True) + taxes = sorted( + taxes_with_validity, key=lambda i: i.valid_from or tax.maximum_net_rate, reverse=True + ) else: taxes = taxes_with_no_validity @@ -1505,11 +1508,15 @@ def get_so_reservation_for_item(args): elif args.get("against_sales_invoice"): sales_order = frappe.db.get_all( "Sales Invoice Item", - filters={"parent": args.get("against_sales_invoice"), "item_code": args.get("item_code")}, + filters={ + "parent": args.get("against_sales_invoice"), + "item_code": args.get("item_code"), + "docstatus": 1, + }, fields="sales_order", ) if sales_order and sales_order[0]: - if get_reserved_qty_for_so(sales_order[0][0], args.get("item_code")): + if get_reserved_qty_for_so(sales_order[0].sales_order, args.get("item_code")): reserved_so = sales_order[0] elif args.get("sales_order"): if get_reserved_qty_for_so(args.get("sales_order"), args.get("item_code")): diff --git a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py index df01b14d11a..e4f657ca707 100644 --- a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py +++ b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.query_builder import Field -from frappe.query_builder.functions import Min, Timestamp +from frappe.query_builder.functions import CombineDatetime, Min from frappe.utils import add_days, getdate, today import erpnext @@ -75,7 +75,7 @@ def get_data(report_filters): & (sle.company == report_filters.company) & (sle.is_cancelled == 0) ) - .orderby(Timestamp(sle.posting_date, sle.posting_time), sle.creation) + .orderby(CombineDatetime(sle.posting_date, sle.posting_time), sle.creation) ).run(as_dict=True) for d in data: @@ -84,7 +84,7 @@ def get_data(report_filters): closing_date = add_days(from_date, -1) for key, stock_data in voucher_wise_dict.items(): prev_stock_value = get_stock_value_on( - posting_date=closing_date, item_code=key[0], warehouse=key[1] + posting_date=closing_date, item_code=key[0], warehouses=key[1] ) for data in stock_data: expected_stock_value = prev_stock_value + data.stock_value_difference diff --git a/erpnext/stock/report/warehouse_wise_stock_balance/warehouse_wise_stock_balance.js b/erpnext/stock/report/warehouse_wise_stock_balance/warehouse_wise_stock_balance.js index 58a043ec20d..752e464e27c 100644 --- a/erpnext/stock/report/warehouse_wise_stock_balance/warehouse_wise_stock_balance.js +++ b/erpnext/stock/report/warehouse_wise_stock_balance/warehouse_wise_stock_balance.js @@ -11,6 +11,13 @@ frappe.query_reports["Warehouse Wise Stock Balance"] = { "options": "Company", "reqd": 1, "default": frappe.defaults.get_user_default("Company") + }, + { + "fieldname":"show_disabled_warehouses", + "label": __("Show Disabled Warehouses"), + "fieldtype": "Check", + "default": 0 + } ], "initial_depth": 3, diff --git a/erpnext/stock/report/warehouse_wise_stock_balance/warehouse_wise_stock_balance.py b/erpnext/stock/report/warehouse_wise_stock_balance/warehouse_wise_stock_balance.py index d364b577a26..a0e994482f8 100644 --- a/erpnext/stock/report/warehouse_wise_stock_balance/warehouse_wise_stock_balance.py +++ b/erpnext/stock/report/warehouse_wise_stock_balance/warehouse_wise_stock_balance.py @@ -11,6 +11,7 @@ from frappe.query_builder.functions import Sum class StockBalanceFilter(TypedDict): company: Optional[str] warehouse: Optional[str] + show_disabled_warehouses: Optional[int] SLEntry = Dict[str, Any] @@ -18,7 +19,7 @@ SLEntry = Dict[str, Any] def execute(filters=None): columns, data = [], [] - columns = get_columns() + columns = get_columns(filters) data = get_data(filters) return columns, data @@ -42,10 +43,14 @@ def get_warehouse_wise_balance(filters: StockBalanceFilter) -> List[SLEntry]: def get_warehouses(report_filters: StockBalanceFilter): + filters = {"company": report_filters.company, "disabled": 0} + if report_filters.get("show_disabled_warehouses"): + filters["disabled"] = ("in", [0, report_filters.show_disabled_warehouses]) + return frappe.get_all( "Warehouse", - fields=["name", "parent_warehouse", "is_group"], - filters={"company": report_filters.company}, + fields=["name", "parent_warehouse", "is_group", "disabled"], + filters=filters, order_by="lft", ) @@ -90,8 +95,8 @@ def set_balance_in_parent(warehouses): update_balance(warehouse, warehouse.stock_balance) -def get_columns(): - return [ +def get_columns(filters: StockBalanceFilter) -> List[Dict]: + columns = [ { "label": _("Warehouse"), "fieldname": "name", @@ -101,3 +106,15 @@ def get_columns(): }, {"label": _("Stock Balance"), "fieldname": "stock_balance", "fieldtype": "Float", "width": 150}, ] + + if filters.get("show_disabled_warehouses"): + columns.append( + { + "label": _("Warehouse Disabled?"), + "fieldname": "disabled", + "fieldtype": "Check", + "width": 200, + } + ) + + return columns diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 08fc6fbd42f..8b517bf1e0f 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -544,6 +544,14 @@ class update_entries_after(object): if not self.args.get("sle_id"): self.get_dynamic_incoming_outgoing_rate(sle) + if ( + sle.voucher_type == "Stock Reconciliation" + and sle.batch_no + and sle.voucher_detail_no + and sle.actual_qty < 0 + ): + self.reset_actual_qty_for_stock_reco(sle) + if ( sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"] and sle.voucher_detail_no @@ -605,6 +613,16 @@ class update_entries_after(object): if not self.args.get("sle_id"): self.update_outgoing_rate_on_transaction(sle) + def reset_actual_qty_for_stock_reco(self, sle): + current_qty = frappe.get_cached_value( + "Stock Reconciliation Item", sle.voucher_detail_no, "current_qty" + ) + + if current_qty: + sle.actual_qty = current_qty * -1 + elif current_qty == 0: + sle.is_cancelled = 1 + def validate_negative_stock(self, sle): """ validate negative stock for entries current datetime onwards @@ -1337,6 +1355,9 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): next_stock_reco_detail = get_next_stock_reco(args) if next_stock_reco_detail: detail = next_stock_reco_detail[0] + if detail.batch_no: + regenerate_sle_for_batch_stock_reco(detail) + # add condition to update SLEs before this date & time datetime_limit_condition = get_datetime_limit_condition(detail) @@ -1364,6 +1385,16 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): validate_negative_qty_in_future_sle(args, allow_negative_stock) +def regenerate_sle_for_batch_stock_reco(detail): + doc = frappe.get_cached_doc("Stock Reconciliation", detail.voucher_no) + doc.recalculate_current_qty(detail.item_code, detail.batch_no) + + if not frappe.db.exists( + "Repost Item Valuation", {"voucher_no": doc.name, "status": "Queued", "docstatus": "1"} + ): + doc.repost_future_sle_and_gle(force=True) + + def get_stock_reco_qty_shift(args): stock_reco_qty_shift = 0 if args.get("is_cancelled"): @@ -1387,34 +1418,53 @@ def get_stock_reco_qty_shift(args): return stock_reco_qty_shift -def get_next_stock_reco(args): +def get_next_stock_reco(kwargs): """Returns next nearest stock reconciliaton's details.""" - return frappe.db.sql( - """ - select - name, posting_date, posting_time, creation, voucher_no - from - `tabStock Ledger Entry` - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and voucher_type = 'Stock Reconciliation' - and voucher_no != %(voucher_no)s - and is_cancelled = 0 - and (timestamp(posting_date, posting_time) > timestamp(%(posting_date)s, %(posting_time)s) - or ( - timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s) - and creation > %(creation)s + sle = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(sle) + .select( + sle.name, + sle.posting_date, + sle.posting_time, + sle.creation, + sle.voucher_no, + sle.item_code, + sle.batch_no, + sle.actual_qty, + ) + .where( + (sle.item_code == kwargs.get("item_code")) + & (sle.warehouse == kwargs.get("warehouse")) + & (sle.voucher_type == "Stock Reconciliation") + & (sle.voucher_no != kwargs.get("voucher_no")) + & (sle.is_cancelled == 0) + & ( + ( + CombineDatetime(sle.posting_date, sle.posting_time) + > CombineDatetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + ) + | ( + ( + CombineDatetime(sle.posting_date, sle.posting_time) + == CombineDatetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + ) + & (sle.creation > kwargs.get("creation")) ) ) - order by timestamp(posting_date, posting_time) asc, creation asc - limit 1 - """, - args, - as_dict=1, + ) + .orderby(CombineDatetime(sle.posting_date, sle.posting_time)) + .orderby(sle.creation) + .limit(1) ) + if kwargs.get("batch_no"): + query = query.where(sle.batch_no == kwargs.get("batch_no")) + + return query.run(as_dict=True) + def get_datetime_limit_condition(detail): return f""" diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index b8c5187b2c3..fb526971ede 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -7,10 +7,11 @@ from typing import Dict, Optional import frappe from frappe import _ -from frappe.query_builder.functions import CombineDatetime +from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime import erpnext +from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses from erpnext.stock.valuation import FIFOValuation, LIFOValuation BarcodeScanResult = Dict[str, Optional[str]] @@ -53,50 +54,36 @@ def get_stock_value_from_bin(warehouse=None, item_code=None): return stock_value -def get_stock_value_on(warehouse=None, posting_date=None, item_code=None): +def get_stock_value_on( + warehouses: list | str = None, posting_date: str = None, item_code: str = None +) -> float: if not posting_date: posting_date = nowdate() - values, condition = [posting_date], "" - - if warehouse: - - lft, rgt, is_group = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt", "is_group"]) - - if is_group: - values.extend([lft, rgt]) - condition += "and exists (\ - select name from `tabWarehouse` wh where wh.name = sle.warehouse\ - and wh.lft >= %s and wh.rgt <= %s)" - - else: - values.append(warehouse) - condition += " AND warehouse = %s" - - if item_code: - values.append(item_code) - condition += " AND item_code = %s" - - stock_ledger_entries = frappe.db.sql( - """ - SELECT item_code, stock_value, name, warehouse - FROM `tabStock Ledger Entry` sle - WHERE posting_date <= %s {0} - and is_cancelled = 0 - ORDER BY timestamp(posting_date, posting_time) DESC, creation DESC - """.format( - condition - ), - values, - as_dict=1, + sle = frappe.qb.DocType("Stock Ledger Entry") + query = ( + frappe.qb.from_(sle) + .select(IfNull(Sum(sle.stock_value_difference), 0)) + .where((sle.posting_date <= posting_date) & (sle.is_cancelled == 0)) + .orderby(CombineDatetime(sle.posting_date, sle.posting_time), order=frappe.qb.desc) + .orderby(sle.creation, order=frappe.qb.desc) ) - sle_map = {} - for sle in stock_ledger_entries: - if not (sle.item_code, sle.warehouse) in sle_map: - sle_map[(sle.item_code, sle.warehouse)] = flt(sle.stock_value) + if warehouses: + if isinstance(warehouses, str): + warehouses = [warehouses] - return sum(sle_map.values()) + warehouses = set(warehouses) + for wh in list(warehouses): + if frappe.db.get_value("Warehouse", wh, "is_group"): + warehouses.update(get_child_warehouses(wh)) + + query = query.where(sle.warehouse.isin(warehouses)) + + if item_code: + query = query.where(sle.item_code == item_code) + + return query.run(as_list=True)[0][0] @frappe.whitelist() diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 3a2c53f4e44..45289b1dab5 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -67,6 +67,15 @@ frappe.ui.form.on('Subcontracting Receipt', { } }); + frm.set_query('batch_no', 'supplied_items', function(doc, cdt, cdn) { + var row = locals[cdt][cdn]; + return { + filters: { + item: row.rm_item_code + } + } + }); + let batch_no_field = frm.get_docfield("items", "batch_no"); if (batch_no_field) { batch_no_field.get_route_options_for_new_doc = function(row) { diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 95dbc83bf80..4f8e045d706 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -245,17 +245,17 @@ class SubcontractingReceipt(SubcontractingController): item.expense_account = expense_account def update_status(self, status=None, update_modified=False): - if self.docstatus >= 1 and not status: - if self.docstatus == 1: + if not status: + if self.docstatus == 0: + status = "Draft" + elif self.docstatus == 1: + status = "Completed" if self.is_return: status = "Return" return_against = frappe.get_doc("Subcontracting Receipt", self.return_against) return_against.run_method("update_status") - else: - if self.per_returned == 100: - status = "Return Issued" - elif self.status == "Draft": - status = "Completed" + elif self.per_returned == 100: + status = "Return Issued" elif self.docstatus == 2: status = "Cancelled" diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json index 1698e2380f7..1c6f24b23ce 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json @@ -192,7 +192,7 @@ } ], "links": [], - "modified": "2021-11-26 15:45:33.289911", + "modified": "2023-04-21 17:16:56.192560", "modified_by": "Administrator", "module": "Support", "name": "Service Level Agreement", @@ -212,19 +212,12 @@ "write": 1 }, { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, - "role": "All", - "share": 1, - "write": 1 + "role": "All" } ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/templates/generators/item/item_add_to_cart.html b/erpnext/templates/generators/item/item_add_to_cart.html index 8000a2446be..1381dfe3b74 100644 --- a/erpnext/templates/generators/item/item_add_to_cart.html +++ b/erpnext/templates/generators/item/item_add_to_cart.html @@ -11,7 +11,10 @@
- {{ price_info.formatted_price_sales_uom }} + + {{ price_info.formatted_price_sales_uom }} + {{ price_info.currency }} + {% if price_info.formatted_mrp %} diff --git a/erpnext/templates/utils.py b/erpnext/templates/utils.py index 48b44802a8f..57750a56f6f 100644 --- a/erpnext/templates/utils.py +++ b/erpnext/templates/utils.py @@ -6,13 +6,12 @@ import frappe @frappe.whitelist(allow_guest=True) -def send_message(subject="Website Query", message="", sender="", status="Open"): +def send_message(sender, message, subject="Website Query"): from frappe.www.contact import send_message as website_send_message + website_send_message(sender, message, subject) + lead = customer = None - - website_send_message(subject, message, sender) - customer = frappe.db.sql( """select distinct dl.link_name from `tabDynamic Link` dl left join `tabContact` c on dl.parent=c.name where dl.link_doctype='Customer' @@ -58,5 +57,3 @@ def send_message(subject="Website Query", message="", sender="", status="Open"): } ) comm.insert(ignore_permissions=True) - - return "okay" diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index 8472d04f1ca..ac6ccf99c6e 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -1875,6 +1875,7 @@ Parents Teacher Meeting Attendance,Eltern Lehrer Treffen Teilnahme, Part-time,Teilzeit, Partially Depreciated,Teilweise abgeschrieben, Partially Received,Teilweise erhalten, +Partly Paid,Teilweise bezahlt, Party,Partei, Party Name,Name der Partei, Party Type,Partei-Typ, diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py index c1579b3cbcc..5e57b317937 100644 --- a/erpnext/utilities/bulk_transaction.py +++ b/erpnext/utilities/bulk_transaction.py @@ -69,7 +69,7 @@ def task(doc_name, from_doctype, to_doctype): "Sales Order": { "Sales Invoice": sales_order.make_sales_invoice, "Delivery Note": sales_order.make_delivery_note, - "Advance Payment": payment_entry.get_payment_entry, + "Payment Entry": payment_entry.get_payment_entry, }, "Sales Invoice": { "Delivery Note": sales_invoice.make_delivery_note, @@ -86,11 +86,11 @@ def task(doc_name, from_doctype, to_doctype): "Supplier Quotation": { "Purchase Order": supplier_quotation.make_purchase_order, "Purchase Invoice": supplier_quotation.make_purchase_invoice, - "Advance Payment": payment_entry.get_payment_entry, }, "Purchase Order": { "Purchase Invoice": purchase_order.make_purchase_invoice, "Purchase Receipt": purchase_order.make_purchase_receipt, + "Payment Entry": payment_entry.get_payment_entry, }, "Purchase Invoice": { "Purchase Receipt": purchase_invoice.make_purchase_receipt, @@ -98,12 +98,13 @@ def task(doc_name, from_doctype, to_doctype): }, "Purchase Receipt": {"Purchase Invoice": purchase_receipt.make_purchase_invoice}, } - if to_doctype in ["Advance Payment", "Payment Entry"]: + if to_doctype in ["Payment Entry"]: obj = mapper[from_doctype][to_doctype](from_doctype, doc_name) else: obj = mapper[from_doctype][to_doctype](doc_name) obj.flags.ignore_validate = True + obj.set_title_field() obj.insert(ignore_mandatory=True) diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 21a0a551b62..7eba35dedd9 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -58,11 +58,11 @@ class TransactionBase(StatusUpdater): def compare_values(self, ref_doc, fields, doc=None): for reference_doctype, ref_dn_list in ref_doc.items(): + prev_doc_detail_map = self.get_prev_doc_reference_details( + ref_dn_list, reference_doctype, fields + ) for reference_name in ref_dn_list: - prevdoc_values = frappe.db.get_value( - reference_doctype, reference_name, [d[0] for d in fields], as_dict=1 - ) - + prevdoc_values = prev_doc_detail_map.get(reference_name) if not prevdoc_values: frappe.throw(_("Invalid reference {0} {1}").format(reference_doctype, reference_name)) @@ -70,6 +70,19 @@ class TransactionBase(StatusUpdater): if prevdoc_values[field] is not None and field not in self.exclude_fields: self.validate_value(field, condition, prevdoc_values[field], doc) + def get_prev_doc_reference_details(self, reference_names, reference_doctype, fields): + prev_doc_detail_map = {} + details = frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name"] + [d[0] for d in fields], + ) + + for d in details: + prev_doc_detail_map.setdefault(d.name, d) + + return prev_doc_detail_map + def validate_rate_with_reference_doc(self, ref_details): if self.get("is_internal_supplier"): return @@ -77,23 +90,23 @@ class TransactionBase(StatusUpdater): buying_doctypes = ["Purchase Order", "Purchase Invoice", "Purchase Receipt"] if self.doctype in buying_doctypes: - action = frappe.db.get_single_value("Buying Settings", "maintain_same_rate_action") - settings_doc = "Buying Settings" + action, role_allowed_to_override = frappe.get_cached_value( + "Buying Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] + ) else: - action = frappe.db.get_single_value("Selling Settings", "maintain_same_rate_action") - settings_doc = "Selling Settings" + action, role_allowed_to_override = frappe.get_cached_value( + "Selling Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] + ) for ref_dt, ref_dn_field, ref_link_field in ref_details: + reference_names = [d.get(ref_link_field) for d in self.get("items") if d.get(ref_link_field)] + reference_details = self.get_reference_details(reference_names, ref_dt + " Item") for d in self.get("items"): if d.get(ref_link_field): - ref_rate = frappe.db.get_value(ref_dt + " Item", d.get(ref_link_field), "rate") + ref_rate = reference_details.get(d.get(ref_link_field)) if abs(flt(d.rate - ref_rate, d.precision("rate"))) >= 0.01: if action == "Stop": - role_allowed_to_override = frappe.db.get_single_value( - settings_doc, "role_to_override_stop_action" - ) - if role_allowed_to_override not in frappe.get_roles(): frappe.throw( _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( @@ -109,6 +122,16 @@ class TransactionBase(StatusUpdater): indicator="orange", ) + def get_reference_details(self, reference_names, reference_doctype): + return frappe._dict( + frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name", "rate"], + as_list=1, + ) + ) + def get_link_filters(self, for_doctype): if hasattr(self, "prev_link_mapper") and self.prev_link_mapper.get(for_doctype): fieldname = self.prev_link_mapper[for_doctype]["fieldname"] @@ -186,12 +209,15 @@ def validate_uom_is_integer(doc, uom_field, qty_fields, child_dt=None): for f in qty_fields: qty = d.get(f) if qty: - if abs(cint(qty) - flt(qty)) > 0.0000001: + if abs(cint(qty) - flt(qty, d.precision(f))) > 0.0000001: frappe.throw( _( "Row {1}: Quantity ({0}) cannot be a fraction. To allow this, disable '{2}' in UOM {3}." ).format( - qty, d.idx, frappe.bold(_("Must be Whole Number")), frappe.bold(d.get(uom_field)) + flt(qty, d.precision(f)), + d.idx, + frappe.bold(_("Must be Whole Number")), + frappe.bold(d.get(uom_field)), ), UOMMustBeIntegerError, ) diff --git a/erpnext/www/shop-by-category/index.py b/erpnext/www/shop-by-category/index.py index 219747c9f8a..913c1836acd 100644 --- a/erpnext/www/shop-by-category/index.py +++ b/erpnext/www/shop-by-category/index.py @@ -53,6 +53,7 @@ def get_tabs(categories): def get_category_records(categories: list): categorical_data = {} + website_item_meta = frappe.get_meta("Website Item", cached=True) for c in categories: if c == "item_group": @@ -64,7 +65,16 @@ def get_category_records(categories: list): continue - doctype = frappe.unscrub(c) + field_type = website_item_meta.get_field(c).fieldtype + + if field_type == "Table MultiSelect": + child_doc = website_item_meta.get_field(c).options + for field in frappe.get_meta(child_doc, cached=True).fields: + if field.fieldtype == "Link" and field.reqd: + doctype = field.options + else: + doctype = website_item_meta.get_field(c).options + fields = ["name"] try: