diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 67700c52cd4a..31f1fa066e42 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -3,7 +3,7 @@ import frappe -__version__ = "14.32.1" +__version__ = "14.44.1" def get_default_company(user=None): diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py index fb49ef3a4234..d0940c7df213 100644 --- a/erpnext/accounts/deferred_revenue.py +++ b/erpnext/accounts/deferred_revenue.py @@ -341,7 +341,7 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None): "enable_deferred_revenue" if doc.doctype == "Sales Invoice" else "enable_deferred_expense" ) - accounts_frozen_upto = frappe.get_cached_value("Accounts Settings", "None", "acc_frozen_upto") + accounts_frozen_upto = frappe.db.get_single_value("Accounts Settings", "acc_frozen_upto") def _book_deferred_revenue_or_expense( item, diff --git a/erpnext/accounts/doctype/account/account.js b/erpnext/accounts/doctype/account/account.js index 320e1cab7c3d..7d63b257faff 100644 --- a/erpnext/accounts/doctype/account/account.js +++ b/erpnext/accounts/doctype/account/account.js @@ -117,9 +117,6 @@ frappe.ui.form.on('Account', { args: { old: frm.doc.name, new: data.name, - is_group: frm.doc.is_group, - root_type: frm.doc.root_type, - company: frm.doc.company }, callback: function(r) { if(!r.exc) { diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index bbe4c54a71ad..22ddc2ffae3b 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -18,6 +18,10 @@ class BalanceMismatchError(frappe.ValidationError): pass +class InvalidAccountMergeError(frappe.ValidationError): + pass + + class Account(NestedSet): nsm_parent_field = "parent_account" @@ -444,24 +448,35 @@ def update_account_number(name, account_name, account_number=None, from_descenda @frappe.whitelist() -def merge_account(old, new, is_group, root_type, company): +def merge_account(old, new): # Validate properties before merging - if not frappe.db.exists("Account", new): - throw(_("Account {0} does not exist").format(new)) + new_account = frappe.get_cached_doc("Account", new) + old_account = frappe.get_cached_doc("Account", old) - val = list(frappe.db.get_value("Account", new, ["is_group", "root_type", "company"])) + if not new_account: + throw(_("Account {0} does not exist").format(new)) - if val != [cint(is_group), root_type, company]: + if ( + cint(new_account.is_group), + new_account.root_type, + new_account.company, + cstr(new_account.account_currency), + ) != ( + cint(old_account.is_group), + old_account.root_type, + old_account.company, + cstr(old_account.account_currency), + ): throw( - _( - """Merging is only possible if following properties are same in both records. Is Group, Root Type, Company""" - ) + msg=_( + """Merging is only possible if following properties are same in both records. Is Group, Root Type, Company and Account Currency""" + ), + title=("Invalid Accounts"), + exc=InvalidAccountMergeError, ) - if is_group and frappe.db.get_value("Account", new, "parent_account") == old: - frappe.db.set_value( - "Account", new, "parent_account", frappe.db.get_value("Account", old, "parent_account") - ) + if old_account.is_group and new_account.parent_account == old: + new_account.db_set("parent_account", frappe.get_cached_value("Account", old, "parent_account")) frappe.rename_doc("Account", old, new, merge=1, force=1) diff --git a/erpnext/accounts/doctype/account/account_tree.js b/erpnext/accounts/doctype/account/account_tree.js index 8ae90ceb3834..d537adfcbfdc 100644 --- a/erpnext/accounts/doctype/account/account_tree.js +++ b/erpnext/accounts/doctype/account/account_tree.js @@ -56,36 +56,41 @@ frappe.treeview_settings["Account"] = { accounts = nodes; } - const get_balances = frappe.call({ - method: 'erpnext.accounts.utils.get_account_balances', - args: { - accounts: accounts, - company: cur_tree.args.company - }, - }); + frappe.db.get_single_value("Accounts Settings", "show_balance_in_coa").then((value) => { + if(value) { + + const get_balances = frappe.call({ + method: 'erpnext.accounts.utils.get_account_balances', + args: { + accounts: accounts, + company: cur_tree.args.company + }, + }); - get_balances.then(r => { - if (!r.message || r.message.length == 0) return; + get_balances.then(r => { + if (!r.message || r.message.length == 0) return; - for (let account of r.message) { + for (let account of r.message) { - const node = cur_tree.nodes && cur_tree.nodes[account.value]; - if (!node || node.is_root) continue; + const node = cur_tree.nodes && cur_tree.nodes[account.value]; + if (!node || node.is_root) continue; - // show Dr if positive since balance is calculated as debit - credit else show Cr - const balance = account.balance_in_account_currency || account.balance; - const dr_or_cr = balance > 0 ? "Dr": "Cr"; - const format = (value, currency) => format_currency(Math.abs(value), currency); + // show Dr if positive since balance is calculated as debit - credit else show Cr + const balance = account.balance_in_account_currency || account.balance; + const dr_or_cr = balance > 0 ? "Dr": "Cr"; + const format = (value, currency) => format_currency(Math.abs(value), currency); - if (account.balance!==undefined) { - node.parent && node.parent.find('.balance-area').remove(); - $('' - + (account.balance_in_account_currency ? - (format(account.balance_in_account_currency, account.account_currency) + " / ") : "") - + format(account.balance, account.company_currency) - + " " + dr_or_cr - + '').insertBefore(node.$ul); - } + if (account.balance!==undefined) { + node.parent && node.parent.find('.balance-area').remove(); + $('' + + (account.balance_in_account_currency ? + (format(account.balance_in_account_currency, account.account_currency) + " / ") : "") + + format(account.balance, account.company_currency) + + " " + dr_or_cr + + '').insertBefore(node.$ul); + } + } + }); } }); }, diff --git a/erpnext/accounts/doctype/account/test_account.py b/erpnext/accounts/doctype/account/test_account.py index 62303bd723f6..30eebef7fba4 100644 --- a/erpnext/accounts/doctype/account/test_account.py +++ b/erpnext/accounts/doctype/account/test_account.py @@ -7,7 +7,11 @@ import frappe from frappe.test_runner import make_test_records -from erpnext.accounts.doctype.account.account import merge_account, update_account_number +from erpnext.accounts.doctype.account.account import ( + InvalidAccountMergeError, + merge_account, + update_account_number, +) from erpnext.stock import get_company_default_inventory_account, get_warehouse_account test_dependencies = ["Company"] @@ -47,49 +51,53 @@ def test_rename_account(self): frappe.delete_doc("Account", "1211-11-4 - 6 - Debtors 1 - Test - - _TC") def test_merge_account(self): - if not frappe.db.exists("Account", "Current Assets - _TC"): - acc = frappe.new_doc("Account") - acc.account_name = "Current Assets" - acc.is_group = 1 - acc.parent_account = "Application of Funds (Assets) - _TC" - acc.company = "_Test Company" - acc.insert() - if not frappe.db.exists("Account", "Securities and Deposits - _TC"): - acc = frappe.new_doc("Account") - acc.account_name = "Securities and Deposits" - acc.parent_account = "Current Assets - _TC" - acc.is_group = 1 - acc.company = "_Test Company" - acc.insert() - if not frappe.db.exists("Account", "Earnest Money - _TC"): - acc = frappe.new_doc("Account") - acc.account_name = "Earnest Money" - acc.parent_account = "Securities and Deposits - _TC" - acc.company = "_Test Company" - acc.insert() - if not frappe.db.exists("Account", "Cash In Hand - _TC"): - acc = frappe.new_doc("Account") - acc.account_name = "Cash In Hand" - acc.is_group = 1 - acc.parent_account = "Current Assets - _TC" - acc.company = "_Test Company" - acc.insert() - if not frappe.db.exists("Account", "Accumulated Depreciation - _TC"): - acc = frappe.new_doc("Account") - acc.account_name = "Accumulated Depreciation" - acc.parent_account = "Fixed Assets - _TC" - acc.company = "_Test Company" - acc.account_type = "Accumulated Depreciation" - acc.insert() + create_account( + account_name="Current Assets", + is_group=1, + parent_account="Application of Funds (Assets) - _TC", + company="_Test Company", + ) + + create_account( + account_name="Securities and Deposits", + is_group=1, + parent_account="Current Assets - _TC", + company="_Test Company", + ) + + create_account( + account_name="Earnest Money", + parent_account="Securities and Deposits - _TC", + company="_Test Company", + ) + + create_account( + account_name="Cash In Hand", + is_group=1, + parent_account="Current Assets - _TC", + company="_Test Company", + ) + + create_account( + account_name="Receivable INR", + parent_account="Current Assets - _TC", + company="_Test Company", + account_currency="INR", + ) + + create_account( + account_name="Receivable USD", + parent_account="Current Assets - _TC", + company="_Test Company", + account_currency="USD", + ) - doc = frappe.get_doc("Account", "Securities and Deposits - _TC") parent = frappe.db.get_value("Account", "Earnest Money - _TC", "parent_account") self.assertEqual(parent, "Securities and Deposits - _TC") - merge_account( - "Securities and Deposits - _TC", "Cash In Hand - _TC", doc.is_group, doc.root_type, doc.company - ) + merge_account("Securities and Deposits - _TC", "Cash In Hand - _TC") + parent = frappe.db.get_value("Account", "Earnest Money - _TC", "parent_account") # Parent account of the child account changes after merging @@ -98,30 +106,28 @@ def test_merge_account(self): # Old account doesn't exist after merging self.assertFalse(frappe.db.exists("Account", "Securities and Deposits - _TC")) - doc = frappe.get_doc("Account", "Current Assets - _TC") - # Raise error as is_group property doesn't match self.assertRaises( - frappe.ValidationError, + InvalidAccountMergeError, merge_account, "Current Assets - _TC", "Accumulated Depreciation - _TC", - doc.is_group, - doc.root_type, - doc.company, ) - doc = frappe.get_doc("Account", "Capital Stock - _TC") - # Raise error as root_type property doesn't match self.assertRaises( - frappe.ValidationError, + InvalidAccountMergeError, merge_account, "Capital Stock - _TC", "Softwares - _TC", - doc.is_group, - doc.root_type, - doc.company, + ) + + # Raise error as currency doesn't match + self.assertRaises( + InvalidAccountMergeError, + merge_account, + "Receivable INR - _TC", + "Receivable USD - _TC", ) def test_account_sync(self): @@ -400,11 +406,20 @@ def create_account(**kwargs): "Account", filters={"account_name": kwargs.get("account_name"), "company": kwargs.get("company")} ) if account: - return account + account = frappe.get_doc("Account", account) + account.update( + dict( + is_group=kwargs.get("is_group", 0), + parent_account=kwargs.get("parent_account"), + ) + ) + account.save() + return account.name else: account = frappe.get_doc( dict( doctype="Account", + is_group=kwargs.get("is_group", 0), account_name=kwargs.get("account_name"), account_type=kwargs.get("account_type"), parent_account=kwargs.get("parent_account"), diff --git a/erpnext/accounts/doctype/account_closing_balance/account_closing_balance.py b/erpnext/accounts/doctype/account_closing_balance/account_closing_balance.py index e75af7047f19..d06bd833c8bc 100644 --- a/erpnext/accounts/doctype/account_closing_balance/account_closing_balance.py +++ b/erpnext/accounts/doctype/account_closing_balance/account_closing_balance.py @@ -37,6 +37,7 @@ def make_closing_entries(closing_entries, voucher_name, company, closing_date): } ) cle.flags.ignore_permissions = True + cle.flags.ignore_links = True cle.submit() diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js index 2fa1d53c60c7..2f53f7b640d8 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js @@ -15,6 +15,17 @@ frappe.ui.form.on('Accounting Dimension', { }; }); + frm.set_query("offsetting_account", "dimension_defaults", function(doc, cdt, cdn) { + let d = locals[cdt][cdn]; + return { + filters: { + company: d.company, + root_type: ["in", ["Asset", "Liability"]], + is_group: 0 + } + } + }); + if (!frm.is_new()) { frm.add_custom_button(__('Show {0}', [frm.doc.document_type]), function () { frappe.set_route("List", frm.doc.document_type); diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index 15c84d462f17..8afd313322ec 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -39,6 +39,8 @@ def validate(self): if not self.is_new(): self.validate_document_type_change() + self.validate_dimension_defaults() + def validate_document_type_change(self): doctype_before_save = frappe.db.get_value("Accounting Dimension", self.name, "document_type") if doctype_before_save != self.document_type: @@ -46,6 +48,14 @@ def validate_document_type_change(self): message += _("Please create a new Accounting Dimension if required.") frappe.throw(message) + def validate_dimension_defaults(self): + companies = [] + for default in self.get("dimension_defaults"): + if default.company not in companies: + companies.append(default.company) + else: + frappe.throw(_("Company {0} is added more than once").format(frappe.bold(default.company))) + def after_insert(self): if frappe.flags.in_test: make_dimension_in_accounting_doctypes(doc=self) @@ -291,3 +301,30 @@ def get_dimensions(with_cost_center_and_project=False): default_dimensions_map[dimension.company][dimension.fieldname] = dimension.default_dimension return dimension_filters, default_dimensions_map + + +def create_accounting_dimensions_for_doctype(doctype): + accounting_dimensions = frappe.db.get_all( + "Accounting Dimension", fields=["fieldname", "label", "document_type", "disabled"] + ) + + if not accounting_dimensions: + return + + for d in accounting_dimensions: + field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": d.fieldname}) + + if field: + continue + + df = { + "fieldname": d.fieldname, + "label": d.label, + "fieldtype": "Link", + "options": d.document_type, + "insert_after": "accounting_dimensions_section", + } + + create_custom_field(doctype, df, ignore_validate=True) + + frappe.clear_cache(doctype=doctype) diff --git a/erpnext/accounts/doctype/accounting_dimension_detail/accounting_dimension_detail.json b/erpnext/accounts/doctype/accounting_dimension_detail/accounting_dimension_detail.json index e9e1f43f9908..7b6120a583b6 100644 --- a/erpnext/accounts/doctype/accounting_dimension_detail/accounting_dimension_detail.json +++ b/erpnext/accounts/doctype/accounting_dimension_detail/accounting_dimension_detail.json @@ -8,7 +8,10 @@ "reference_document", "default_dimension", "mandatory_for_bs", - "mandatory_for_pl" + "mandatory_for_pl", + "column_break_lqns", + "automatically_post_balancing_accounting_entry", + "offsetting_account" ], "fields": [ { @@ -50,6 +53,23 @@ "fieldtype": "Check", "in_list_view": 1, "label": "Mandatory For Profit and Loss Account" + }, + { + "default": "0", + "fieldname": "automatically_post_balancing_accounting_entry", + "fieldtype": "Check", + "label": "Automatically post balancing accounting entry" + }, + { + "fieldname": "offsetting_account", + "fieldtype": "Link", + "label": "Offsetting Account", + "mandatory_depends_on": "eval: doc.automatically_post_balancing_accounting_entry", + "options": "Account" + }, + { + "fieldname": "column_break_lqns", + "fieldtype": "Column Break" } ], "istable": 1, diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 47c4396e102b..3ab9d2b60d5b 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -60,12 +60,15 @@ "closing_settings_tab", "period_closing_settings_section", "acc_frozen_upto", + "ignore_account_closing_balance", "column_break_25", "frozen_accounts_modifier", "report_settings_sb", "banking_tab", "enable_party_matching", - "enable_fuzzy_matching" + "enable_fuzzy_matching", + "tab_break_dpet", + "show_balance_in_coa" ], "fields": [ { @@ -408,6 +411,24 @@ "fieldname": "enable_fuzzy_matching", "fieldtype": "Check", "label": "Enable Fuzzy Matching" + }, + { + "default": "0", + "description": "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) ", + "fieldname": "ignore_account_closing_balance", + "fieldtype": "Check", + "label": "Ignore Account Closing Balance" + }, + { + "fieldname": "tab_break_dpet", + "fieldtype": "Tab Break", + "label": "Chart Of Accounts" + }, + { + "default": "1", + "fieldname": "show_balance_in_coa", + "fieldtype": "Check", + "label": "Show Balances in Chart Of Accounts" } ], "icon": "icon-cog", @@ -415,7 +436,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-06-15 18:47:46.430291", + "modified": "2023-07-27 15:05:34.000264", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 3b125a29862f..ac3d44bb5e70 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -14,21 +14,32 @@ class AccountsSettings(Document): - def on_update(self): - frappe.clear_cache() - def validate(self): - frappe.db.set_default( - "add_taxes_from_item_tax_template", self.get("add_taxes_from_item_tax_template", 0) - ) + old_doc = self.get_doc_before_save() + clear_cache = False + + if old_doc.add_taxes_from_item_tax_template != self.add_taxes_from_item_tax_template: + frappe.db.set_default( + "add_taxes_from_item_tax_template", self.get("add_taxes_from_item_tax_template", 0) + ) + clear_cache = True - frappe.db.set_default( - "enable_common_party_accounting", self.get("enable_common_party_accounting", 0) - ) + if old_doc.enable_common_party_accounting != self.enable_common_party_accounting: + frappe.db.set_default( + "enable_common_party_accounting", self.get("enable_common_party_accounting", 0) + ) + clear_cache = True self.validate_stale_days() - self.enable_payment_schedule_in_print() - self.validate_pending_reposts() + + if old_doc.show_payment_schedule_in_print != self.show_payment_schedule_in_print: + self.enable_payment_schedule_in_print() + + if old_doc.acc_frozen_upto != self.acc_frozen_upto: + self.validate_pending_reposts() + + if clear_cache: + frappe.clear_cache() def validate_stale_days(self): if not self.allow_stale and cint(self.stale_days) <= 0: diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js index 04af32346bbd..a70af7a90e3d 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js @@ -352,10 +352,11 @@ frappe.ui.form.on("Bank Statement Import", { export_errored_rows(frm) { open_url_post( - "/api/method/frappe.core.doctype.data_import.data_import.download_errored_template", + "/api/method/erpnext.accounts.doctype.bank_statement_import.bank_statement_import.download_errored_template", { data_import_name: frm.doc.name, - } + }, + true ); }, diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js index e548b4c7e9a8..b3cc1cbb1beb 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js @@ -13,10 +13,11 @@ frappe.ui.form.on("Bank Transaction", { }); }, refresh(frm) { - frm.add_custom_button(__('Unreconcile Transaction'), () => { - frm.call('remove_payment_entries') - .then( () => frm.refresh() ); - }); + if (!frm.is_dirty() && frm.doc.payment_entries.length > 0) { + frm.add_custom_button(__("Unreconcile Transaction"), () => { + frm.call("remove_payment_entries").then(() => frm.refresh()); + }); + } }, bank_account: function (frm) { set_bank_statement_filter(frm); diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json index 7921fcc2b961..df232a5848ca 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json @@ -6,8 +6,10 @@ "engine": "InnoDB", "field_order": [ "api_details_section", + "disabled", "service_provider", "api_endpoint", + "access_key", "url", "column_break_3", "help", @@ -77,12 +79,24 @@ "label": "Service Provider", "options": "frankfurter.app\nexchangerate.host\nCustom", "reqd": 1 + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + }, + { + "depends_on": "eval:doc.service_provider == 'exchangerate.host';", + "fieldname": "access_key", + "fieldtype": "Data", + "label": "Access Key" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-01-10 15:51:14.521174", + "modified": "2023-10-04 15:30:25.333860", "modified_by": "Administrator", "module": "Accounts", "name": "Currency Exchange Settings", diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py index d618c5ca1173..117d5ff21e84 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py @@ -18,11 +18,21 @@ def validate(self): def set_parameters_and_result(self): if self.service_provider == "exchangerate.host": + + if not self.access_key: + frappe.throw( + _("Access Key is required for Service Provider: {0}").format( + frappe.bold(self.service_provider) + ) + ) + self.set("result_key", []) self.set("req_params", []) self.api_endpoint = "https://api.exchangerate.host/convert" self.append("result_key", {"key": "result"}) + self.append("req_params", {"key": "access_key", "value": self.access_key}) + self.append("req_params", {"key": "amount", "value": "1"}) self.append("req_params", {"key": "date", "value": "{transaction_date}"}) self.append("req_params", {"key": "from", "value": "{from_currency}"}) self.append("req_params", {"key": "to", "value": "{to_currency}"}) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index ec55e60fd1f4..ced04ced3fdd 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -3,6 +3,296 @@ import unittest +import frappe +from frappe import qb +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import add_days, flt, today -class TestExchangeRateRevaluation(unittest.TestCase): - pass +from erpnext import get_default_cost_center +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry +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.accounts.party import get_party_account +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.stock.doctype.item.test_item import create_item + + +class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.create_company() + self.create_usd_receivable_account() + self.create_item() + self.create_customer() + self.clear_old_entries() + self.set_system_and_company_settings() + + def tearDown(self): + frappe.db.rollback() + + def set_system_and_company_settings(self): + # set number and currency precision + system_settings = frappe.get_doc("System Settings") + system_settings.float_precision = 2 + system_settings.currency_precision = 2 + system_settings.save() + + # Using Exchange Gain/Loss account for unrealized as well. + company_doc = frappe.get_doc("Company", self.company) + company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account + company_doc.save() + + @change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, + ) + def test_01_revaluation_of_forex_balance(self): + """ + Test Forex account balance and Journal creation post Revaluation + """ + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debtors_usd, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=1, + ) + si.currency = "USD" + si.conversion_rate = 80 + si.save().submit() + + err = frappe.new_doc("Exchange Rate Revaluation") + err.company = (self.company,) + err.posting_date = today() + accounts = err.get_accounts_data() + err.extend("accounts", accounts) + row = err.accounts[0] + row.new_exchange_rate = 85 + row.new_balance_in_base_currency = flt( + row.new_exchange_rate * flt(row.balance_in_account_currency) + ) + row.gain_loss = row.new_balance_in_base_currency - flt(row.balance_in_base_currency) + err.set_total_gain_loss() + err = err.save().submit() + + # Create JV for ERR + err_journals = err.make_jv_entries() + je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv")) + je = je.submit() + + je.reload() + self.assertEqual(je.voucher_type, "Exchange Rate Revaluation") + self.assertEqual(je.total_debit, 8500.0) + self.assertEqual(je.total_credit, 8500.0) + + acc_balance = frappe.db.get_all( + "GL Entry", + filters={"account": self.debtors_usd, "is_cancelled": 0}, + fields=["sum(debit)-sum(credit) as balance"], + )[0] + self.assertEqual(acc_balance.balance, 8500.0) + + @change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, + ) + def test_02_accounts_only_with_base_currency_balance(self): + """ + Test Revaluation on Forex account with balance only in base currency + """ + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debtors_usd, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=1, + ) + si.currency = "USD" + si.conversion_rate = 80 + si.save().submit() + + pe = get_payment_entry(si.doctype, si.name) + pe.source_exchange_rate = 85 + pe.received_amount = 8500 + pe.save().submit() + + # Cancel the auto created gain/loss JE to simulate balance only in base currency + je = frappe.db.get_all( + "Journal Entry Account", filters={"reference_name": si.name}, pluck="parent" + )[0] + frappe.get_doc("Journal Entry", je).cancel() + + err = frappe.new_doc("Exchange Rate Revaluation") + err.company = (self.company,) + err.posting_date = today() + err.fetch_and_calculate_accounts_data() + err = err.save().submit() + + # Create JV for ERR + self.assertTrue(err.check_journal_entry_condition()) + err_journals = err.make_jv_entries() + je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) + je = je.submit() + + je.reload() + self.assertEqual(je.voucher_type, "Exchange Gain Or Loss") + self.assertEqual(len(je.accounts), 2) + # Only base currency fields will be posted to + for acc in je.accounts: + self.assertEqual(acc.debit_in_account_currency, 0) + self.assertEqual(acc.credit_in_account_currency, 0) + + self.assertEqual(je.total_debit, 500.0) + self.assertEqual(je.total_credit, 500.0) + + acc_balance = frappe.db.get_all( + "GL Entry", + filters={"account": self.debtors_usd, "is_cancelled": 0}, + fields=[ + "sum(debit)-sum(credit) as balance", + "sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency", + ], + )[0] + # account shouldn't have balance in base and account currency + self.assertEqual(acc_balance.balance, 0.0) + self.assertEqual(acc_balance.balance_in_account_currency, 0.0) + + @change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, + ) + def test_03_accounts_only_with_account_currency_balance(self): + """ + Test Revaluation on Forex account with balance only in account currency + """ + precision = frappe.db.get_single_value("System Settings", "currency_precision") + + # posting on previous date to make sure that ERR picks up the Payment entry's exchange + # rate while calculating gain/loss for account currency balance + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debtors_usd, + posting_date=add_days(today(), -1), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=1, + ) + si.currency = "USD" + si.conversion_rate = 80 + si.save().submit() + + pe = get_payment_entry(si.doctype, si.name) + pe.paid_amount = 95 + pe.source_exchange_rate = 84.211 + pe.received_amount = 8000 + pe.references = [] + pe.save().submit() + + acc_balance = frappe.db.get_all( + "GL Entry", + filters={"account": self.debtors_usd, "is_cancelled": 0}, + fields=[ + "sum(debit)-sum(credit) as balance", + "sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency", + ], + )[0] + # account should have balance only in account currency + self.assertEqual(flt(acc_balance.balance, precision), 0.0) + self.assertEqual(flt(acc_balance.balance_in_account_currency, precision), 5.0) # in USD + + err = frappe.new_doc("Exchange Rate Revaluation") + err.company = (self.company,) + err.posting_date = today() + err.fetch_and_calculate_accounts_data() + err.set_total_gain_loss() + err = err.save().submit() + + # Create JV for ERR + self.assertTrue(err.check_journal_entry_condition()) + err_journals = err.make_jv_entries() + je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) + je = je.submit() + + je.reload() + self.assertEqual(je.voucher_type, "Exchange Gain Or Loss") + self.assertEqual(len(je.accounts), 2) + # Only account currency fields will be posted to + for acc in je.accounts: + self.assertEqual(flt(acc.debit, precision), 0.0) + self.assertEqual(flt(acc.credit, precision), 0.0) + + row = [x for x in je.accounts if x.account == self.debtors_usd][0] + self.assertEqual(flt(row.credit_in_account_currency, precision), 5.0) # in USD + row = [x for x in je.accounts if x.account != self.debtors_usd][0] + self.assertEqual(flt(row.debit_in_account_currency, precision), 421.06) # in INR + + # total_debit and total_credit will be 0.0, as JV is posting only to account currency fields + self.assertEqual(flt(je.total_debit, precision), 0.0) + self.assertEqual(flt(je.total_credit, precision), 0.0) + + acc_balance = frappe.db.get_all( + "GL Entry", + filters={"account": self.debtors_usd, "is_cancelled": 0}, + fields=[ + "sum(debit)-sum(credit) as balance", + "sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency", + ], + )[0] + # account shouldn't have balance in base and account currency post revaluation + self.assertEqual(flt(acc_balance.balance, precision), 0.0) + self.assertEqual(flt(acc_balance.balance_in_account_currency, precision), 0.0) + + @change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, + ) + def test_04_get_account_details_function(self): + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debtors_usd, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=1, + ) + si.currency = "USD" + si.conversion_rate = 80 + si.save().submit() + + from erpnext.accounts.doctype.exchange_rate_revaluation.exchange_rate_revaluation import ( + get_account_details, + ) + + account_details = get_account_details( + self.company, si.posting_date, self.debtors_usd, "Customer", self.customer, 0.05 + ) + # not checking for new exchange rate and balances as it is dependent on live exchange rates + expected_data = { + "account_currency": "USD", + "balance_in_base_currency": 8000.0, + "balance_in_account_currency": 100.0, + "current_exchange_rate": 80.0, + "zero_balance": False, + "new_balance_in_account_currency": 100.0, + } + + for key, val in expected_data.items(): + self.assertEqual(expected_data.get(key), account_details.get(key)) diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index fa4a66aaacfb..3a564825b55a 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -58,7 +58,14 @@ def on_update(self): validate_balance_type(self.account, adv_adj) validate_frozen_account(self.account, adv_adj) - if frappe.db.get_value("Account", self.account, "account_type") not in [ + if ( + self.voucher_type == "Journal Entry" + and frappe.get_cached_value("Journal Entry", self.voucher_no, "voucher_type") + == "Exchange Gain Or Loss" + ): + return + + if frappe.get_cached_value("Account", self.account, "account_type") not in [ "Receivable", "Payable", ]: diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 302acc4f1f76..fe570a5ba8aa 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", 'Asset', 'Asset Movement']; + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger', 'Asset', 'Asset Movement', 'Repost Accounting Ledger']; }, refresh: function(frm) { @@ -50,8 +50,18 @@ frappe.ui.form.on("Journal Entry", { frm.trigger("make_inter_company_journal_entry"); }, __('Make')); } - }, + erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm); + }, + before_save: function(frm) { + if ((frm.doc.docstatus == 0) && (!frm.doc.is_system_generated)) { + let payment_entry_references = frm.doc.accounts.filter(elem => (elem.reference_type == "Payment Entry")); + if (payment_entry_references.length > 0) { + let rows = payment_entry_references.map(x => "#"+x.idx); + frappe.throw(__("Rows: {0} have 'Payment Entry' as reference_type. This should not be set manually.", [frappe.utils.comma_and(rows)])); + } + } + }, make_inter_company_journal_entry: function(frm) { var d = new frappe.ui.Dialog({ title: __("Select Company"), diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 80e72226d3d3..2eb54a54d541 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -9,6 +9,7 @@ "engine": "InnoDB", "field_order": [ "entry_type_and_date", + "is_system_generated", "title", "voucher_type", "naming_series", @@ -533,13 +534,22 @@ "label": "Process Deferred Accounting", "options": "Process Deferred Accounting", "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.is_system_generated == 1;", + "fieldname": "is_system_generated", + "fieldtype": "Check", + "label": "Is System Generated", + "no_copy": 1, + "read_only": 1 } ], "icon": "fa fa-file-text", "idx": 176, "is_submittable": 1, "links": [], - "modified": "2023-03-01 14:58:59.286591", + "modified": "2023-08-10 14:32:22.366895", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 2e8ec6d3b6b7..edcfdef1f2e1 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -18,6 +18,7 @@ ) from erpnext.accounts.party import get_party_account from erpnext.accounts.utils import ( + cancel_exchange_gain_loss_journal, get_account_currency, get_balance_on, get_stock_accounts, @@ -87,15 +88,16 @@ def on_submit(self): self.update_invoice_discounting() def on_cancel(self): - from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries - - unlink_ref_doc_from_payment_entries(self) + # References for this Journal are removed on the `on_cancel` event in accounts_controller + super(JournalEntry, self).on_cancel() self.ignore_linked_doctypes = ( "GL Entry", "Stock Ledger Entry", "Payment Ledger Entry", "Repost Payment Ledger", "Repost Payment Ledger Items", + "Repost Accounting Ledger", + "Repost Accounting Ledger Items", ) self.make_gl_entries(1) self.update_advance_paid() @@ -487,11 +489,12 @@ def validate_against_jv(self): ) if not against_entries: - frappe.throw( - _( - "Journal Entry {0} does not have account {1} or already matched against other voucher" - ).format(d.reference_name, d.account) - ) + if self.voucher_type != "Exchange Gain Or Loss": + frappe.throw( + _( + "Journal Entry {0} does not have account {1} or already matched against other voucher" + ).format(d.reference_name, d.account) + ) else: dr_or_cr = "debit" if d.credit > 0 else "credit" valid = False @@ -574,7 +577,9 @@ def validate_reference_doc(self): else: party_account = against_voucher[1] - if against_voucher[0] != cstr(d.party) or party_account != d.account: + if ( + against_voucher[0] != cstr(d.party) or party_account != d.account + ) and self.voucher_type != "Exchange Gain Or Loss": frappe.throw( _("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format( d.idx, @@ -758,18 +763,23 @@ def set_exchange_rate(self): ) ): - # Modified to include the posting date for which to retreive the exchange rate - d.exchange_rate = get_exchange_rate( - self.posting_date, - d.account, - d.account_currency, - self.company, - d.reference_type, - d.reference_name, - d.debit, - d.credit, - d.exchange_rate, - ) + ignore_exchange_rate = False + if self.get("flags") and self.flags.get("ignore_exchange_rate"): + ignore_exchange_rate = True + + if not ignore_exchange_rate: + # Modified to include the posting date for which to retreive the exchange rate + d.exchange_rate = get_exchange_rate( + self.posting_date, + d.account, + d.account_currency, + self.company, + d.reference_type, + d.reference_name, + d.debit, + d.credit, + d.exchange_rate, + ) if not d.exchange_rate: frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx)) @@ -777,6 +787,9 @@ def set_exchange_rate(self): def create_remarks(self): r = [] + if self.flags.skip_remarks_creation: + return + if self.user_remark: r.append(_("Note: {0}").format(self.user_remark)) @@ -925,6 +938,8 @@ def make_gl_entries(self, cancel=0, adv_adj=0): merge_entries=merge_entries, update_outstanding=update_outstanding, ) + if cancel: + cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) @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 f7297d19e0f9..e44ebc6afce3 100644 --- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py @@ -5,6 +5,7 @@ import unittest import frappe +from frappe.tests.utils import change_settings from frappe.utils import flt, nowdate from erpnext.accounts.doctype.account.test_account import get_inventory_account @@ -13,6 +14,7 @@ class TestJournalEntry(unittest.TestCase): + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_journal_entry_with_against_jv(self): jv_invoice = frappe.copy_doc(test_records[2]) base_jv = frappe.copy_doc(test_records[0]) diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json index 47ad19e0f98a..3ba8cea94bbf 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json @@ -203,7 +203,7 @@ "fieldtype": "Select", "label": "Reference Type", "no_copy": 1, - "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement" + "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry" }, { "fieldname": "reference_name", @@ -284,7 +284,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-10-26 20:03:10.906259", + "modified": "2023-06-16 14:11:13.507807", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry Account", diff --git a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py index 18e5a1ac85bd..6e89d039932a 100644 --- a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py +++ b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py @@ -49,9 +49,6 @@ def start_merge(docname): merge_account( row.account, ledger_merge.account, - ledger_merge.is_group, - ledger_merge.root_type, - ledger_merge.company, ) row.db_set("merged", 1) frappe.db.commit() diff --git a/erpnext/accounts/doctype/loyalty_program/loyalty_program.py b/erpnext/accounts/doctype/loyalty_program/loyalty_program.py index 48a25ad6b81e..a134f7466351 100644 --- a/erpnext/accounts/doctype/loyalty_program/loyalty_program.py +++ b/erpnext/accounts/doctype/loyalty_program/loyalty_program.py @@ -141,7 +141,7 @@ def validate_loyalty_points(ref_doc, points_to_redeem): ) if points_to_redeem > loyalty_program_details.loyalty_points: - frappe.throw(_("You don't have enought Loyalty Points to redeem")) + frappe.throw(_("You don't have enough Loyalty Points to redeem")) loyalty_amount = flt(points_to_redeem * loyalty_program_details.conversion_factor) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index f91d29a6b68b..d5b047389edb 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -7,7 +7,7 @@ cur_frm.cscript.tax_table = "Advance Taxes and Charges"; frappe.ui.form.on('Payment Entry', { onload: function(frm) { - frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Repost Payment Ledger"]; + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger', 'Unreconcile Payments', 'Unreconcile Payment Entries']; if(frm.doc.__islocal) { if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); @@ -152,6 +152,7 @@ frappe.ui.form.on('Payment Entry', { frm.events.hide_unhide_fields(frm); frm.events.set_dynamic_labels(frm); frm.events.show_general_ledger(frm); + erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm); }, validate_company: (frm) => { @@ -526,15 +527,21 @@ frappe.ui.form.on('Payment Entry', { }, source_exchange_rate: function(frm) { + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; if (frm.doc.paid_amount) { frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); // target exchange rate should always be same as source if both account currencies is same if(frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate); frm.set_value("base_received_amount", frm.doc.base_paid_amount); + } else if (company_currency == frm.doc.paid_to_account_currency) { + frm.set_value("received_amount", frm.doc.base_paid_amount); + frm.set_value("base_received_amount", frm.doc.base_paid_amount); } - frm.events.set_unallocated_amount(frm); + // set_unallocated_amount is called by below method, + // no need trigger separately + frm.events.set_total_allocated_amount(frm); } // Make read only if Accounts Settings doesn't allow stale rates @@ -543,6 +550,7 @@ frappe.ui.form.on('Payment Entry', { target_exchange_rate: function(frm) { frm.set_paid_amount_based_on_received_amount = true; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; if (frm.doc.received_amount) { frm.set_value("base_received_amount", @@ -552,9 +560,14 @@ frappe.ui.form.on('Payment Entry', { (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency)) { frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate); frm.set_value("base_paid_amount", frm.doc.base_received_amount); + } else if (company_currency == frm.doc.paid_from_account_currency) { + frm.set_value("paid_amount", frm.doc.base_received_amount); + frm.set_value("base_paid_amount", frm.doc.base_received_amount); } - frm.events.set_unallocated_amount(frm); + // set_unallocated_amount is called by below method, + // no need trigger separately + frm.events.set_total_allocated_amount(frm); } frm.set_paid_amount_based_on_received_amount = false; @@ -870,12 +883,18 @@ frappe.ui.form.on('Payment Entry', { }, set_total_allocated_amount: function(frm) { + let exchange_rate = 1; + if (frm.doc.payment_type == "Receive") { + exchange_rate = frm.doc.source_exchange_rate; + } else if (frm.doc.payment_type == "Pay") { + exchange_rate = frm.doc.target_exchange_rate; + } var total_allocated_amount = 0.0; var base_total_allocated_amount = 0.0; $.each(frm.doc.references || [], function(i, row) { if (row.allocated_amount) { total_allocated_amount += flt(row.allocated_amount); - base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(row.exchange_rate), + base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(exchange_rate), precision("base_paid_amount")); } }); @@ -894,12 +913,12 @@ frappe.ui.form.on('Payment Entry', { if(frm.doc.payment_type == "Receive" && frm.doc.base_total_allocated_amount < frm.doc.base_received_amount + total_deductions && frm.doc.total_allocated_amount < frm.doc.paid_amount + (total_deductions / frm.doc.source_exchange_rate)) { - unallocated_amount = (frm.doc.base_received_amount + total_deductions + frm.doc.base_total_taxes_and_charges + unallocated_amount = (frm.doc.base_received_amount + total_deductions + flt(frm.doc.base_total_taxes_and_charges) - frm.doc.base_total_allocated_amount) / frm.doc.source_exchange_rate; } else if (frm.doc.payment_type == "Pay" && frm.doc.base_total_allocated_amount < frm.doc.base_paid_amount - total_deductions && frm.doc.total_allocated_amount < frm.doc.received_amount + (total_deductions / frm.doc.target_exchange_rate)) { - unallocated_amount = (frm.doc.base_paid_amount + frm.doc.base_total_taxes_and_charges - (total_deductions + unallocated_amount = (frm.doc.base_paid_amount + flt(frm.doc.base_total_taxes_and_charges) - (total_deductions + frm.doc.base_total_allocated_amount)) / frm.doc.target_exchange_rate; } } diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 83736bd68e91..35207eae0e50 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -24,7 +24,12 @@ ) from erpnext.accounts.general_ledger import make_gl_entries, process_gl_map from erpnext.accounts.party import get_party_account -from erpnext.accounts.utils import get_account_currency, get_balance_on, get_outstanding_invoices +from erpnext.accounts.utils import ( + cancel_exchange_gain_loss_journal, + get_account_currency, + get_balance_on, + get_outstanding_invoices, +) from erpnext.controllers.accounts_controller import ( AccountsController, get_supplier_block_status, @@ -61,7 +66,7 @@ def setup_party_account_field(self): def validate(self): self.setup_party_account_field() self.set_missing_values() - self.set_missing_ref_details() + self.set_missing_ref_details(force=True) self.validate_payment_type() self.validate_party_details() self.set_exchange_rate() @@ -100,7 +105,12 @@ def on_cancel(self): "Payment Ledger Entry", "Repost Payment Ledger", "Repost Payment Ledger Items", + "Repost Accounting Ledger", + "Repost Accounting Ledger Items", + "Unreconcile Payments", + "Unreconcile Payment Entries", ) + super(PaymentEntry, self).on_cancel() self.make_gl_entries(cancel=1) self.update_outstanding_amounts() self.update_advance_paid() @@ -179,79 +189,89 @@ def term_based_allocation_enabled_for_reference( return False def validate_allocated_amount_with_latest_data(self): - latest_references = get_outstanding_reference_documents( - { - "posting_date": self.posting_date, - "company": self.company, - "party_type": self.party_type, - "payment_type": self.payment_type, - "party": self.party, - "party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to, - "get_outstanding_invoices": True, - "get_orders_to_be_billed": True, - } - ) - - # Group latest_references by (voucher_type, voucher_no) - latest_lookup = {} - for d in latest_references: - d = frappe._dict(d) - latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d - - for idx, d in enumerate(self.get("references"), start=1): - latest = latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict() - - # If term based allocation is enabled, throw - if ( - d.payment_term is None or d.payment_term == "" - ) and self.term_based_allocation_enabled_for_reference( - d.reference_doctype, d.reference_name - ): - frappe.throw( - _( - "{0} has Payment Term based allocation enabled. Select a Payment Term for Row #{1} in Payment References section" - ).format(frappe.bold(d.reference_name), frappe.bold(idx)) - ) + if self.references: + uniq_vouchers = set([(x.reference_doctype, x.reference_name) for x in self.references]) + vouchers = [frappe._dict({"voucher_type": x[0], "voucher_no": x[1]}) for x in uniq_vouchers] + latest_references = get_outstanding_reference_documents( + { + "posting_date": self.posting_date, + "company": self.company, + "party_type": self.party_type, + "payment_type": self.payment_type, + "party": self.party, + "party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to, + "get_outstanding_invoices": True, + "get_orders_to_be_billed": True, + "vouchers": vouchers, + } + ) - # if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key - latest = latest.get(d.payment_term) or latest.get(None) + # Group latest_references by (voucher_type, voucher_no) + latest_lookup = {} + for d in latest_references: + d = frappe._dict(d) + latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d - # The reference has already been fully paid - if not latest: - frappe.throw( - _("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name) - ) - # The reference has already been partly paid - elif latest.outstanding_amount < latest.invoice_amount and flt( - d.outstanding_amount, d.precision("outstanding_amount") - ) != flt(latest.outstanding_amount, d.precision("outstanding_amount")): + for idx, d in enumerate(self.get("references"), start=1): + latest = latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict() - frappe.throw( - _( - "{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' or the 'Get Outstanding Orders' button to get the latest outstanding amounts." - ).format(_(d.reference_doctype), d.reference_name) - ) + # If term based allocation is enabled, throw + if ( + d.payment_term is None or d.payment_term == "" + ) and self.term_based_allocation_enabled_for_reference( + d.reference_doctype, d.reference_name + ): + frappe.throw( + _( + "{0} has Payment Term based allocation enabled. Select a Payment Term for Row #{1} in Payment References section" + ).format(frappe.bold(d.reference_name), frappe.bold(idx)) + ) - fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.") + # if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key + latest = latest.get(d.payment_term) or latest.get(None) + # The reference has already been fully paid + if not latest: + frappe.throw( + _("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name) + ) + # The reference has already been partly paid + elif ( + latest.outstanding_amount < latest.invoice_amount + and flt(d.outstanding_amount, d.precision("outstanding_amount")) + != flt(latest.outstanding_amount, d.precision("outstanding_amount")) + and d.payment_term == "" + ): + frappe.throw( + _( + "{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' or the 'Get Outstanding Orders' button to get the latest outstanding amounts." + ).format(_(d.reference_doctype), d.reference_name) + ) - if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount): - frappe.throw(fail_message.format(d.idx)) + fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.") - if d.payment_term and ( - (flt(d.allocated_amount)) > 0 - and flt(d.allocated_amount) > flt(latest.payment_term_outstanding) - ): - frappe.throw( - _( - "Row #{0}: Allocated amount:{1} is greater than outstanding amount:{2} for Payment Term {3}" - ).format( - d.idx, d.allocated_amount, latest.payment_term_outstanding, d.payment_term + if ( + d.payment_term + and ( + (flt(d.allocated_amount)) > 0 + and latest.payment_term_outstanding + and (flt(d.allocated_amount) > flt(latest.payment_term_outstanding)) + ) + and self.term_based_allocation_enabled_for_reference(d.reference_doctype, d.reference_name) + ): + frappe.throw( + _( + "Row #{0}: Allocated amount:{1} is greater than outstanding amount:{2} for Payment Term {3}" + ).format( + d.idx, d.allocated_amount, latest.payment_term_outstanding, d.payment_term + ) ) - ) - # Check for negative outstanding invoices as well - if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(latest.outstanding_amount): - frappe.throw(fail_message.format(d.idx)) + if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount): + frappe.throw(fail_message.format(d.idx)) + + # Check for negative outstanding invoices as well + if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(latest.outstanding_amount): + frappe.throw(fail_message.format(d.idx)) def delink_advance_entry_references(self): for reference in self.references: @@ -356,7 +376,7 @@ def set_source_exchange_rate(self, ref_doc=None): else: if ref_doc: if self.paid_from_account_currency == ref_doc.currency: - self.source_exchange_rate = ref_doc.get("exchange_rate") + self.source_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate") if not self.source_exchange_rate: self.source_exchange_rate = get_exchange_rate( @@ -369,7 +389,7 @@ def set_target_exchange_rate(self, ref_doc=None): elif self.paid_to and not self.target_exchange_rate: if ref_doc: if self.paid_to_account_currency == ref_doc.currency: - self.target_exchange_rate = ref_doc.get("exchange_rate") + self.target_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate") if not self.target_exchange_rate: self.target_exchange_rate = get_exchange_rate( @@ -631,7 +651,9 @@ def set_tax_withholding(self): if not self.apply_tax_withholding_amount: return - net_total = self.paid_amount + order_amount = self.get_order_net_total() + + net_total = flt(order_amount) + flt(self.unallocated_amount) # Adding args as purchase invoice to get TDS amount args = frappe._dict( @@ -676,6 +698,20 @@ def set_tax_withholding(self): for d in to_remove: self.remove(d) + def get_order_net_total(self): + if self.party_type == "Supplier": + doctype = "Purchase Order" + else: + doctype = "Sales Order" + + docnames = [d.reference_name for d in self.references if d.reference_doctype == doctype] + + tax_withholding_net_total = frappe.db.get_value( + doctype, {"name": ["in", docnames]}, ["sum(base_tax_withholding_net_total)"] + ) + + return tax_withholding_net_total + def apply_taxes(self): self.initialize_taxes() self.determine_exclusive_rate() @@ -762,10 +798,30 @@ def calculate_base_allocated_amount_for_reference(self, d) -> float: flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount") ) else: + + # Use source/target exchange rate, so no difference amount is calculated. + # then update exchange gain/loss amount in reference table + # if there is an exchange gain/loss amount in reference table, submit a JE for that + + 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(d.exchange_rate), self.precision("base_paid_amount") + flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount") ) + # on rare case, when `exchange_rate` is unset, gain/loss amount is incorrectly calculated + # for base currency transactions + if d.exchange_rate is None: + d.exchange_rate = 1 + + allocated_amount_in_pe_exchange_rate = flt( + flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount") + ) + d.exchange_gain_loss = base_allocated_amount - allocated_amount_in_pe_exchange_rate return base_allocated_amount def set_total_allocated_amount(self): @@ -956,6 +1012,10 @@ def make_gl_entries(self, cancel=0, adv_adj=0): gl_entries = self.build_gl_map() gl_entries = process_gl_map(gl_entries) make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj) + if cancel: + cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) + else: + self.make_exchange_gain_loss_journal() def add_party_gl_entries(self, gl_entries): if self.party_account: @@ -1399,6 +1459,14 @@ def get_outstanding_reference_documents(args): fieldname, args.get(date_fields[0]), args.get(date_fields[1]) ) posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])]) + elif args.get(date_fields[0]): + # if only from date is supplied + condition += " and {0} >= '{1}'".format(fieldname, args.get(date_fields[0])) + posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0]))) + elif args.get(date_fields[1]): + # if only to date is supplied + condition += " and {0} <= '{1}'".format(fieldname, args.get(date_fields[1])) + posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1]))) if args.get("company"): condition += " and company = {0}".format(frappe.db.escape(args.get("company"))) @@ -1417,6 +1485,7 @@ def get_outstanding_reference_documents(args): min_outstanding=args.get("outstanding_amt_greater_than"), max_outstanding=args.get("outstanding_amt_less_than"), accounting_dimensions=accounting_dimensions_filter, + vouchers=args.get("vouchers") or None, ) outstanding_invoices = split_invoices_based_on_payment_terms( @@ -1535,11 +1604,10 @@ def split_invoices_based_on_payment_terms(outstanding_invoices, company): "voucher_type": d.voucher_type, "posting_date": d.posting_date, "invoice_amount": flt(d.invoice_amount), - "outstanding_amount": flt(d.outstanding_amount), - "payment_term_outstanding": payment_term_outstanding, - "allocated_amount": payment_term_outstanding + "outstanding_amount": payment_term_outstanding if payment_term_outstanding else d.outstanding_amount, + "payment_term_outstanding": payment_term_outstanding, "payment_amount": payment_term.payment_amount, "payment_term": payment_term.payment_term, } @@ -1815,10 +1883,15 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre if not total_amount: if party_account_currency == company_currency: # for handling cases that don't have multi-currency (base field) - total_amount = ref_doc.get("base_grand_total") or ref_doc.get("grand_total") + total_amount = ( + ref_doc.get("base_rounded_total") + or ref_doc.get("rounded_total") + or ref_doc.get("base_grand_total") + or ref_doc.get("grand_total") + ) exchange_rate = 1 else: - total_amount = ref_doc.get("grand_total") + total_amount = ref_doc.get("rounded_total") or ref_doc.get("grand_total") if not exchange_rate: # Get the exchange rate from the original ref doc # or get it based on the posting date of the ref doc. @@ -1857,7 +1930,6 @@ def get_payment_entry( payment_type=None, reference_date=None, ): - reference_doc = None doc = frappe.get_doc(dt, dn) 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) >= ( @@ -1998,7 +2070,7 @@ def get_payment_entry( update_accounting_dimensions(pe, doc) if party_account and bank: - pe.set_exchange_rate(ref_doc=reference_doc) + pe.set_exchange_rate(ref_doc=doc) pe.set_amounts() if discount_amount: @@ -2111,7 +2183,7 @@ def set_paid_amount_and_received_amount( if bank_amount: received_amount = bank_amount else: - if company_currency != bank.account_currency: + if bank and company_currency != bank.account_currency: received_amount = paid_amount / doc.get("conversion_rate", 1) else: received_amount = paid_amount * doc.get("conversion_rate", 1) @@ -2120,7 +2192,7 @@ def set_paid_amount_and_received_amount( if bank_amount: paid_amount = bank_amount else: - if company_currency != bank.account_currency: + if bank and 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 diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 785b8a180b14..2de009f8c438 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -31,6 +31,16 @@ class TestPaymentEntry(FrappeTestCase): def tearDown(self): frappe.db.rollback() + def get_journals_for(self, voucher_type: str, voucher_no: str) -> list: + journals = [] + if voucher_type and voucher_no: + journals = frappe.db.get_all( + "Journal Entry Account", + filters={"reference_type": voucher_type, "reference_name": voucher_no, "docstatus": 1}, + fields=["parent"], + ) + return journals + def test_payment_entry_against_order(self): so = make_sales_order() pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") @@ -591,21 +601,15 @@ def test_payment_entry_against_si_usd_to_usd_with_deduction_in_base_currency(sel pe.target_exchange_rate = 45.263 pe.reference_no = "1" pe.reference_date = "2016-01-01" - - pe.append( - "deductions", - { - "account": "_Test Exchange Gain/Loss - _TC", - "cost_center": "_Test Cost Center - _TC", - "amount": 94.80, - }, - ) - pe.save() self.assertEqual(flt(pe.difference_amount, 2), 0.0) self.assertEqual(flt(pe.unallocated_amount, 2), 0.0) + # the exchange gain/loss amount is captured in reference table and a separate Journal will be submitted for them + # payment entry will not be generating difference amount + self.assertEqual(flt(pe.references[0].exchange_gain_loss, 2), -94.74) + def test_payment_entry_retrieves_last_exchange_rate(self): from erpnext.setup.doctype.currency_exchange.test_currency_exchange import ( save_new_records, @@ -792,33 +796,28 @@ def test_payment_entry_exchange_gain_loss(self): pe.reference_no = "1" pe.reference_date = "2016-01-01" pe.source_exchange_rate = 55 - - pe.append( - "deductions", - { - "account": "_Test Exchange Gain/Loss - _TC", - "cost_center": "_Test Cost Center - _TC", - "amount": -500, - }, - ) pe.save() self.assertEqual(pe.unallocated_amount, 0) self.assertEqual(pe.difference_amount, 0) - + self.assertEqual(pe.references[0].exchange_gain_loss, 500) pe.submit() expected_gle = dict( (d[0], d) for d in [ - ["_Test Receivable USD - _TC", 0, 5000, si.name], + ["_Test Receivable USD - _TC", 0, 5500, si.name], ["_Test Bank USD - _TC", 5500, 0, None], - ["_Test Exchange Gain/Loss - _TC", 0, 500, None], ] ) self.validate_gl_entries(pe.name, expected_gle) + # Exchange gain/loss should have been posted through a journal + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + + self.assertEqual(exc_je_for_si, exc_je_for_pe) outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) self.assertEqual(outstanding_amount, 0) @@ -1156,6 +1155,70 @@ def test_overallocation_validation_on_payment_terms(self): si3.cancel() si3.delete() + @change_settings( + "Accounts Settings", + { + "unlink_payment_on_cancellation_of_invoice": 1, + "delete_linked_ledger_entries": 1, + "allow_multi_currency_invoices_against_single_party_account": 1, + }, + ) + def test_overallocation_validation_shouldnt_misfire(self): + """ + Overallocation validation shouldn't fire for Template without "Allocate Payment based on Payment Terms" enabled + + """ + customer = create_customer() + create_payment_terms_template() + + template = frappe.get_doc("Payment Terms Template", "Test Receivable Template") + template.allocate_payment_based_on_payment_terms = 0 + template.save() + + # Validate allocation on base/company currency + si = create_sales_invoice(do_not_save=1, qty=1, rate=200) + si.payment_terms_template = "Test Receivable Template" + si.save().submit() + + si.reload() + pe = get_payment_entry(si.doctype, si.name).save() + # There will no term based allocation + self.assertEqual(len(pe.references), 1) + self.assertEqual(pe.references[0].payment_term, None) + self.assertEqual(flt(pe.references[0].allocated_amount), flt(si.grand_total)) + pe.save() + + # specify a term + pe.references[0].payment_term = template.terms[0].payment_term + # no validation error should be thrown + pe.save() + + pe.paid_amount = si.grand_total + 1 + pe.references[0].allocated_amount = si.grand_total + 1 + self.assertRaises(frappe.ValidationError, pe.save) + + template = frappe.get_doc("Payment Terms Template", "Test Receivable Template") + template.allocate_payment_based_on_payment_terms = 1 + template.save() + + def test_allocation_validation_for_sales_order(self): + so = make_sales_order(do_not_save=True) + so.items[0].rate = 99.55 + so.save().submit() + self.assertGreater(so.rounded_total, 0.0) + pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") + pe.paid_from = "Debtors - _TC" + pe.paid_amount = 45.55 + pe.references[0].allocated_amount = 45.55 + pe.save().submit() + pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") + pe.paid_from = "Debtors - _TC" + # No validation error should be thrown here. + pe.save().submit() + + so.reload() + self.assertEqual(so.advance_paid, so.rounded_total) + def create_payment_entry(**args): payment_entry = frappe.new_doc("Payment Entry") diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index 07f35c9fe11b..6f1f34bc9f33 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -151,6 +151,15 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo this.frm.refresh(); } + invoice_name() { + this.frm.trigger("get_unreconciled_entries"); + } + + payment_name() { + this.frm.trigger("get_unreconciled_entries"); + } + + clear_child_tables() { this.frm.clear_table("invoices"); this.frm.clear_table("payments"); diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json index 18d34850850f..0dc9c135b8c9 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json @@ -26,8 +26,10 @@ "bank_cash_account", "cost_center", "sec_break1", + "invoice_name", "invoices", "column_break_15", + "payment_name", "payments", "sec_break2", "allocation" @@ -136,6 +138,7 @@ "label": "Minimum Invoice Amount" }, { + "default": "50", "description": "System will fetch all the entries if limit value is zero.", "fieldname": "invoice_limit", "fieldtype": "Int", @@ -166,6 +169,7 @@ "label": "Maximum Payment Amount" }, { + "default": "50", "description": "System will fetch all the entries if limit value is zero.", "fieldname": "payment_limit", "fieldtype": "Int", @@ -185,13 +189,23 @@ "fieldtype": "Link", "label": "Cost Center", "options": "Cost Center" + }, + { + "fieldname": "invoice_name", + "fieldtype": "Data", + "label": "Filter on Invoice" + }, + { + "fieldname": "payment_name", + "fieldtype": "Data", + "label": "Filter on Payment" } ], "hide_toolbar": 1, "icon": "icon-resize-horizontal", "issingle": 1, "links": [], - "modified": "2022-04-29 15:37:10.246831", + "modified": "2023-08-15 05:35:50.109290", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation", @@ -218,4 +232,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 216d4eccac7f..08923e742665 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -5,8 +5,9 @@ import frappe from frappe import _, msgprint, qb from frappe.model.document import Document +from frappe.query_builder import Criterion from frappe.query_builder.custom import ConstantColumn -from frappe.utils import flt, get_link_to_form, getdate, nowdate, today +from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today import erpnext from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import ( @@ -14,10 +15,11 @@ ) from erpnext.accounts.utils import ( QueryPaymentLedger, + create_gain_loss_journal, get_outstanding_invoices, reconcile_against_document, ) -from erpnext.controllers.accounts_controller import get_advance_payment_entries +from erpnext.controllers.accounts_controller import get_advance_payment_entries_for_regional class PaymentReconciliation(Document): @@ -57,7 +59,10 @@ def get_nonreconciled_payment_entries(self): def get_payment_entries(self): order_doctype = "Sales Order" if self.party_type == "Customer" else "Purchase Order" condition = self.get_conditions(get_payments=True) - payment_entries = get_advance_payment_entries( + if self.payment_name: + condition += "name like '%%{0}%%'".format(self.payment_name) + + payment_entries = get_advance_payment_entries_for_regional( self.party_type, self.party, self.receivable_payable_account, @@ -72,6 +77,9 @@ def get_payment_entries(self): def get_jv_entries(self): condition = self.get_conditions() + if self.payment_name: + condition += f" and t1.name like '%%{self.payment_name}%%'" + if self.get("cost_center"): condition += f" and t2.cost_center = '{self.cost_center}' " @@ -92,7 +100,7 @@ def get_jv_entries(self): "Journal Entry" as reference_type, t1.name as reference_name, t1.posting_date, t1.remark as remarks, t2.name as reference_row, {dr_or_cr} as amount, t2.is_advance, t2.exchange_rate, - t2.account_currency as currency + t2.account_currency as currency, t2.cost_center as cost_center from `tabJournal Entry` t1, `tabJournal Entry Account` t2 where @@ -129,6 +137,15 @@ def get_jv_entries(self): def get_return_invoices(self): voucher_type = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" doc = qb.DocType(voucher_type) + + conditions = [] + conditions.append(doc.docstatus == 1) + conditions.append(doc[frappe.scrub(self.party_type)] == self.party) + conditions.append(doc.is_return == 1) + + if self.payment_name: + conditions.append(doc.name.like(f"%{self.payment_name}%")) + self.return_invoices = ( qb.from_(doc) .select( @@ -136,11 +153,7 @@ def get_return_invoices(self): doc.name.as_("voucher_no"), doc.return_against, ) - .where( - (doc.docstatus == 1) - & (doc[frappe.scrub(self.party_type)] == self.party) - & (doc.is_return == 1) - ) + .where(Criterion.all(conditions)) .run(as_dict=True) ) @@ -183,6 +196,7 @@ def get_dr_or_cr_notes(self): "amount": -(inv.outstanding_in_account_currency), "posting_date": inv.posting_date, "currency": inv.currency, + "cost_center": inv.cost_center, } ) ) @@ -209,6 +223,8 @@ def get_invoice_entries(self): min_outstanding=self.minimum_invoice_amount if self.minimum_invoice_amount else None, max_outstanding=self.maximum_invoice_amount if self.maximum_invoice_amount else None, accounting_dimensions=self.accounting_dimension_filter_conditions, + limit=self.invoice_limit, + voucher_no=self.invoice_name, ) cr_dr_notes = ( @@ -260,6 +276,11 @@ def is_auto_process_enabled(self): 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")) + if payment_entry[0].get("reference_type") in ["Sales Invoice", "Purchase Invoice"]: + payment_entry[0]["exchange_rate"] = invoice_exchange_map.get( + payment_entry[0].get("reference_name") + ) + new_difference_amount = self.get_difference_amount( payment_entry[0], invoice[0], allocated_amount ) @@ -324,10 +345,12 @@ def get_allocated_entry(self, pay, inv, allocated_amount): "allocated_amount": allocated_amount, "difference_amount": pay.get("difference_amount"), "currency": inv.get("currency"), + "cost_center": pay.get("cost_center"), } ) def reconcile_allocations(self, skip_ref_details_update_for_pe=False): + adjust_allocations_for_taxes(self) dr_or_cr = ( "credit_in_account_currency" if erpnext.get_party_account_type(self.party_type) == "Receivable" @@ -347,12 +370,6 @@ def reconcile_allocations(self, skip_ref_details_update_for_pe=False): payment_details = self.get_payment_details(row, dr_or_cr) reconciled_entry.append(payment_details) - if payment_details.difference_amount and row.reference_type not in [ - "Sales Invoice", - "Purchase Invoice", - ]: - self.make_difference_entry(payment_details) - if entry_list: reconcile_against_document(entry_list, skip_ref_details_update_for_pe) @@ -385,59 +402,6 @@ def reconcile(self): self.get_unreconciled_entries() - def make_difference_entry(self, row): - journal_entry = frappe.new_doc("Journal Entry") - journal_entry.voucher_type = "Exchange Gain Or Loss" - journal_entry.company = self.company - journal_entry.posting_date = nowdate() - journal_entry.multi_currency = 1 - - party_account_currency = frappe.get_cached_value( - "Account", self.receivable_payable_account, "account_currency" - ) - difference_account_currency = frappe.get_cached_value( - "Account", row.difference_account, "account_currency" - ) - - # Account Currency has balance - dr_or_cr = "debit" if self.party_type == "Customer" else "credit" - reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - - journal_account = frappe._dict( - { - "account": self.receivable_payable_account, - "party_type": self.party_type, - "party": self.party, - "account_currency": party_account_currency, - "exchange_rate": 0, - "cost_center": erpnext.get_default_cost_center(self.company), - "reference_type": row.against_voucher_type, - "reference_name": row.against_voucher, - dr_or_cr: flt(row.difference_amount), - dr_or_cr + "_in_account_currency": 0, - } - ) - - journal_entry.append("accounts", journal_account) - - journal_account = frappe._dict( - { - "account": row.difference_account, - "account_currency": difference_account_currency, - "exchange_rate": 1, - "cost_center": erpnext.get_default_cost_center(self.company), - reverse_dr_or_cr + "_in_account_currency": flt(row.difference_amount), - reverse_dr_or_cr: flt(row.difference_amount), - } - ) - - journal_entry.append("accounts", journal_account) - - journal_entry.save() - journal_entry.submit() - - return journal_entry - def get_payment_details(self, row, dr_or_cr): return frappe._dict( { @@ -457,6 +421,7 @@ def get_payment_details(self, row, dr_or_cr): "allocated_amount": flt(row.get("allocated_amount")), "difference_amount": flt(row.get("difference_amount")), "difference_account": row.get("difference_account"), + "cost_center": row.get("cost_center"), } ) @@ -603,16 +568,6 @@ def get_conditions(self, get_payments=False): def reconcile_dr_cr_note(dr_cr_notes, company): - def get_difference_row(inv): - if inv.difference_amount != 0 and inv.difference_account: - difference_row = { - "account": inv.difference_account, - inv.dr_or_cr: abs(inv.difference_amount) if inv.difference_amount > 0 else 0, - reconcile_dr_or_cr: abs(inv.difference_amount) if inv.difference_amount < 0 else 0, - "cost_center": erpnext.get_default_cost_center(company), - } - return difference_row - for inv in dr_cr_notes: voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note" @@ -639,7 +594,9 @@ def get_difference_row(inv): inv.dr_or_cr: abs(inv.allocated_amount), "reference_type": inv.against_voucher_type, "reference_name": inv.against_voucher, - "cost_center": erpnext.get_default_cost_center(company), + "cost_center": inv.cost_center or erpnext.get_default_cost_center(company), + "user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} against {inv.against_voucher}", + "exchange_rate": inv.exchange_rate, }, { "account": inv.account, @@ -652,14 +609,50 @@ def get_difference_row(inv): ), "reference_type": inv.voucher_type, "reference_name": inv.voucher_no, - "cost_center": erpnext.get_default_cost_center(company), + "cost_center": inv.cost_center or erpnext.get_default_cost_center(company), + "user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} from {inv.voucher_no}", + "exchange_rate": inv.exchange_rate, }, ], } ) - if difference_entry := get_difference_row(inv): - jv.append("accounts", difference_entry) - jv.flags.ignore_mandatory = True + jv.flags.skip_remarks_creation = True + jv.flags.ignore_exchange_rate = True + jv.is_system_generated = True + jv.remark = None jv.submit() + + if inv.difference_amount != 0: + # make gain/loss journal + if inv.party_type == "Customer": + dr_or_cr = "credit" if inv.difference_amount < 0 else "debit" + else: + dr_or_cr = "debit" if inv.difference_amount < 0 else "credit" + + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + create_gain_loss_journal( + company, + today(), + inv.party_type, + inv.party, + inv.account, + inv.difference_account, + inv.difference_amount, + dr_or_cr, + reverse_dr_or_cr, + inv.voucher_type, + inv.voucher_no, + None, + inv.against_voucher_type, + inv.against_voucher, + None, + inv.cost_center, + ) + + +@erpnext.allow_regional +def adjust_allocations_for_taxes(doc): + pass diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index 2ac7df0e39bc..1d843abde1d9 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -686,14 +686,24 @@ def test_difference_amount_via_journal_entry(self): # Check if difference journal entry gets generated for difference amount after reconciliation pr.reconcile() - total_debit_amount = frappe.db.get_all( + total_credit_amount = frappe.db.get_all( "Journal Entry Account", {"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name}, - "sum(debit) as amount", + "sum(credit) as amount", group_by="reference_name", )[0].amount - self.assertEqual(flt(total_debit_amount, 2), -500) + # total credit includes the exchange gain/loss amount + self.assertEqual(flt(total_credit_amount, 2), 8500) + + jea_parent = frappe.db.get_all( + "Journal Entry Account", + filters={"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name, "credit": 500}, + fields=["parent"], + )[0] + self.assertEqual( + frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss" + ) def test_difference_amount_via_payment_entry(self): # Make Sale Invoice diff --git a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json index 0f7e47acfee4..ec718aa70d31 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json +++ b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json @@ -22,7 +22,8 @@ "column_break_7", "difference_account", "exchange_rate", - "currency" + "currency", + "cost_center" ], "fields": [ { @@ -144,11 +145,17 @@ "fieldtype": "Float", "label": "Exchange Rate", "read_only": 1 + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" } ], "istable": 1, "links": [], - "modified": "2022-12-24 21:01:14.882747", + "modified": "2023-09-03 07:52:33.684217", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Allocation", diff --git a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json index d300ea97abc4..17f3900880c6 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json +++ b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json @@ -16,7 +16,8 @@ "sec_break1", "remark", "currency", - "exchange_rate" + "exchange_rate", + "cost_center" ], "fields": [ { @@ -98,11 +99,17 @@ "fieldtype": "Float", "hidden": 1, "label": "Exchange Rate" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" } ], "istable": 1, "links": [], - "modified": "2022-11-08 18:18:36.268760", + "modified": "2023-09-03 07:43:29.965353", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Payment", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 0955664d98be..f6653f87f0f9 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -249,7 +249,7 @@ def create_payment_entry(self, submit=True): if ( party_account_currency == ref_doc.company_currency and party_account_currency != self.currency ): - party_amount = ref_doc.base_grand_total + party_amount = ref_doc.get("base_rounded_total") or ref_doc.get("base_grand_total") else: party_amount = self.grand_total diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index e17a846dd814..feb2fdffc95b 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -144,8 +144,7 @@ def test_payment_entry(self): (d[0], d) for d in [ ["_Test Receivable USD - _TC", 0, 5000, si_usd.name], - [pr.payment_account, 6290.0, 0, None], - ["_Test Exchange Gain/Loss - _TC", 0, 1290, None], + [pr.payment_account, 5000.0, 0, None], ] ) diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json index 54a76b341963..624b5f82f64c 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.json @@ -8,6 +8,7 @@ "transaction_date", "posting_date", "fiscal_year", + "year_start_date", "amended_from", "company", "column_break1", @@ -100,16 +101,22 @@ "fieldtype": "Text", "label": "Error Message", "read_only": 1 + }, + { + "fieldname": "year_start_date", + "fieldtype": "Date", + "label": "Year Start Date" } ], "icon": "fa fa-file-text", "idx": 1, "is_submittable": 1, "links": [], - "modified": "2022-07-20 14:51:04.714154", + "modified": "2023-09-11 20:19:11.810533", "modified_by": "Administrator", "module": "Accounts", "name": "Period Closing Voucher", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -144,5 +151,6 @@ "search_fields": "posting_date, fiscal_year", "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "closing_account_head" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index 49472484ef44..674db6c2e430 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -33,7 +33,7 @@ def on_submit(self): def on_cancel(self): self.validate_future_closing_vouchers() self.db_set("gle_processing_status", "In Progress") - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") gle_count = frappe.db.count( "GL Entry", {"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0}, @@ -95,15 +95,23 @@ def validate_posting_date(self): self.check_if_previous_year_closed() - pce = frappe.db.sql( - """select name from `tabPeriod Closing Voucher` - where posting_date > %s and fiscal_year = %s and docstatus = 1 and company = %s""", - (self.posting_date, self.fiscal_year, self.company), + pcv = frappe.qb.DocType("Period Closing Voucher") + existing_entry = ( + frappe.qb.from_(pcv) + .select(pcv.name) + .where( + (pcv.posting_date >= self.posting_date) + & (pcv.fiscal_year == self.fiscal_year) + & (pcv.docstatus == 1) + & (pcv.company == self.company) + ) + .run() ) - if pce and pce[0][0]: + + if existing_entry and existing_entry[0][0]: frappe.throw( _("Another Period Closing Entry {0} has been made after {1}").format( - pce[0][0], self.posting_date + existing_entry[0][0], self.posting_date ) ) @@ -126,22 +134,31 @@ def check_if_previous_year_closed(self): def make_gl_entries(self, get_opening_entries=False): gl_entries = self.get_gl_entries() closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries) - if len(gl_entries) > 5000: + if len(gl_entries + closing_entries) > 3000: frappe.enqueue( process_gl_entries, gl_entries=gl_entries, + voucher_name=self.name, + timeout=3000, + ) + + frappe.enqueue( + process_closing_entries, + gl_entries=gl_entries, closing_entries=closing_entries, voucher_name=self.name, company=self.company, closing_date=self.posting_date, - queue="long", + timeout=3000, ) + frappe.msgprint( _("The GL Entries will be processed in the background, it can take a few minutes."), alert=True, ) else: - process_gl_entries(gl_entries, closing_entries, self.name, self.company, self.posting_date) + process_gl_entries(gl_entries, self.name) + process_closing_entries(gl_entries, closing_entries, self.name, self.company, self.posting_date) def get_grouped_gl_entries(self, get_opening_entries=False): closing_entries = [] @@ -322,17 +339,12 @@ def get_balances_based_on_dimensions( return query.run(as_dict=1) -def process_gl_entries(gl_entries, closing_entries, voucher_name, company, closing_date): - from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import ( - make_closing_entries, - ) +def process_gl_entries(gl_entries, voucher_name): from erpnext.accounts.general_ledger import make_gl_entries try: if gl_entries: make_gl_entries(gl_entries, merge_entries=False) - - make_closing_entries(gl_entries + closing_entries, voucher_name, company, closing_date) frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Completed") except Exception as e: frappe.db.rollback() @@ -340,6 +352,19 @@ def process_gl_entries(gl_entries, closing_entries, voucher_name, company, closi frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Failed") +def process_closing_entries(gl_entries, closing_entries, voucher_name, company, closing_date): + from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import ( + make_closing_entries, + ) + + try: + if gl_entries + closing_entries: + make_closing_entries(gl_entries + closing_entries, voucher_name, company, closing_date) + except Exception as e: + frappe.db.rollback() + frappe.log_error(e) + + def make_reverse_gl_entries(voucher_type, voucher_no): from erpnext.accounts.general_ledger import make_reverse_gl_entries 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 2ea0971ee72d..103aea965bb3 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 @@ -10,7 +10,7 @@ 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 from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice -from erpnext.accounts.utils import get_fiscal_year, now +from erpnext.accounts.utils import get_fiscal_year class TestPeriodClosingVoucher(unittest.TestCase): diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js index a6c0102a7f91..91e71e90dd80 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js @@ -153,7 +153,7 @@ frappe.ui.form.on('POS Closing Entry', { frappe.ui.form.on('POS Closing Entry Detail', { closing_amount: (frm, cdt, cdn) => { const row = locals[cdt][cdn]; - frappe.model.set_value(cdt, cdn, "difference", flt(row.expected_amount - row.closing_amount)); + frappe.model.set_value(cdt, cdn, "difference", flt(row.closing_amount - row.expected_amount)); } }) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index cced37589bad..720b549ccb34 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -130,6 +130,7 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex args: { "pos_profile": frm.pos_profile }, callback: ({ message: profile }) => { this.update_customer_groups_settings(profile?.customer_groups); + this.frm.set_value("company", profile?.company); }, }); } diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index aea801af9dd5..d3058e44509a 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -2,6 +2,8 @@ # For license information, please see license.txt +import collections + import frappe from frappe import _ from frappe.query_builder.functions import IfNull, Sum @@ -54,6 +56,8 @@ def validate(self): self.validate_pos() self.validate_payment_amount() self.validate_loyalty_transaction() + self.validate_company_with_pos_company() + self.validate_duplicate_serial_no() if self.coupon_code: from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code @@ -154,6 +158,18 @@ def validate_pos_reserved_serial_nos(self, item): title=_("Item Unavailable"), ) + def validate_duplicate_serial_no(self): + serial_nos = [] + + for row in self.get("items"): + if row.serial_no: + serial_nos = row.serial_no.split("\n") + + if serial_nos: + for key, value in collections.Counter(serial_nos).items(): + if value > 1: + frappe.throw(_("Duplicate Serial No {0} found").format("key")) + def validate_pos_reserved_batch_qty(self, item): filters = {"item_code": item.item_code, "warehouse": item.warehouse, "batch_no": item.batch_no} @@ -370,6 +386,14 @@ def validate_payment_amount(self): if total_amount_in_payments and total_amount_in_payments < invoice_total: frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total)) + def validate_company_with_pos_company(self): + if self.company != frappe.db.get_value("POS Profile", self.pos_profile, "company"): + frappe.throw( + _("Company {} does not match with POS Profile Company {}").format( + self.company, frappe.db.get_value("POS Profile", self.pos_profile, "company") + ) + ) + def validate_loyalty_transaction(self): if self.redeem_loyalty_points and ( not self.loyalty_redemption_account or not self.loyalty_redemption_cost_center @@ -448,6 +472,7 @@ def set_pos_fields(self, for_validate=False): profile = {} if self.pos_profile: profile = frappe.get_doc("POS Profile", self.pos_profile) + self.company = profile.get("company") if not self.get("payments") and not for_validate: update_multi_mode_option(self, profile) @@ -493,7 +518,7 @@ def set_pos_fields(self, for_validate=False): selling_price_list = ( customer_price_list or customer_group_price_list or profile.get("selling_price_list") ) - if customer_currency != profile.get("currency"): + if customer_currency and customer_currency != profile.get("currency"): self.set("currency", customer_currency) else: @@ -651,7 +676,7 @@ def get_bundle_availability(bundle_item_code, warehouse): item_pos_reserved_qty = get_pos_reserved_qty(item.item_code, warehouse) available_qty = item_bin_qty - item_pos_reserved_qty - max_available_bundles = available_qty / item.stock_qty + max_available_bundles = available_qty / item.qty if bundle_bin_qty > max_available_bundles and frappe.get_value( "Item", item.item_code, "is_stock_item" ): diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 3132fdd259a0..bd2fee3b0ad9 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -464,6 +464,37 @@ def test_delivered_serialized_item_transaction(self): pos2.insert() self.assertRaises(frappe.ValidationError, pos2.submit) + def test_pos_invoice_with_duplicate_serial_no(self): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item + + se = make_serialized_item( + company="_Test Company", + target_warehouse="Stores - _TC", + cost_center="Main - _TC", + expense_account="Cost of Goods Sold - _TC", + ) + + serial_nos = get_serial_nos(se.get("items")[0].serial_no) + + pos = create_pos_invoice( + company="_Test Company", + debit_to="Debtors - _TC", + account_for_change_amount="Cash - _TC", + warehouse="Stores - _TC", + income_account="Sales - _TC", + expense_account="Cost of Goods Sold - _TC", + cost_center="Main - _TC", + item=se.get("items")[0].item_code, + rate=1000, + qty=2, + do_not_save=1, + ) + + pos.get("items")[0].has_serial_no = 1 + pos.get("items")[0].serial_no = serial_nos[0] + "\n" + serial_nos[0] + self.assertRaises(frappe.ValidationError, pos.submit) + def test_invalid_serial_no_validation(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json index 8bb7092dc502..1a1ab4d800e8 100644 --- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.json @@ -146,7 +146,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-04-21 17:19:30.912953", + "modified": "2023-08-11 10:56:51.699137", "modified_by": "Administrator", "module": "Accounts", "name": "Process Payment Reconciliation", @@ -154,15 +154,25 @@ "owner": "Administrator", "permissions": [ { + "amend": 1, + "cancel": 1, "create": 1, "delete": 1, - "email": 1, - "export": 1, - "print": 1, "read": 1, - "report": 1, - "role": "System Manager", + "role": "Accounts Manager", "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "read": 1, + "role": "Accounts User", + "share": 1, + "submit": 1, "write": 1 } ], 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 45373741f39c..e711ae0de2bb 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 @@ -49,6 +49,7 @@ "column_break_21", "start_date", "section_break_33", + "pdf_name", "subject", "column_break_28", "cc_to", @@ -273,7 +274,7 @@ "fieldname": "help_text", "fieldtype": "HTML", "label": "Help Text", - "options": "
\n

Note

\n\n

Examples

\n\n\n" + "options": "
\n

Note

\n\n

Examples

\n\n\n" }, { "fieldname": "subject", @@ -368,10 +369,15 @@ "fieldname": "based_on_payment_terms", "fieldtype": "Check", "label": "Based On Payment Terms" + }, + { + "fieldname": "pdf_name", + "fieldtype": "Data", + "label": "PDF Name" } ], "links": [], - "modified": "2023-06-23 10:13:15.051950", + "modified": "2023-08-28 12:59:53.071334", "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 6cd601f663d3..b7d6827f64c5 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 @@ -26,7 +26,13 @@ def validate(self): if not self.subject: self.subject = "Statement Of Accounts for {{ customer.customer_name }}" if not self.body: - self.body = "Hello {{ customer.name }},
PFA your Statement Of Accounts from {{ doc.from_date }} to {{ doc.to_date }}." + if self.report == "General Ledger": + body_str = " from {{ doc.from_date }} to {{ doc.to_date }}." + else: + body_str = " until {{ doc.posting_date }}." + self.body = "Hello {{ customer.customer_name }},
PFA your Statement Of Accounts" + body_str + if not self.pdf_name: + self.pdf_name = "{{ customer.customer_name }}" validate_template(self.subject) validate_template(self.body) @@ -41,6 +47,20 @@ def validate(self): def get_report_pdf(doc, consolidated=True): + statement_dict = get_statement_dict(doc) + if not bool(statement_dict): + return False + elif consolidated: + delimiter = '
' if doc.include_break else "" + result = delimiter.join(list(statement_dict.values())) + return get_pdf(result, {"orientation": doc.orientation}) + else: + for customer, statement_html in statement_dict.items(): + statement_dict[customer] = get_pdf(statement_html, {"orientation": doc.orientation}) + return statement_dict + + +def get_statement_dict(doc, get_statement_dict=False): statement_dict = {} ageing = "" @@ -59,30 +79,23 @@ def get_report_pdf(doc, consolidated=True): if doc.report == "General Ledger": filters.update(get_gl_filters(doc, entry, tax_id, presentation_currency)) - else: - filters.update(get_ar_filters(doc, entry)) - - if doc.report == "General Ledger": col, res = get_soa(filters) for x in [0, -2, -1]: res[x]["account"] = res[x]["account"].replace("'", "") if len(res) == 3: continue else: + filters.update(get_ar_filters(doc, entry)) ar_res = get_ar_soa(filters) col, res = ar_res[0], ar_res[1] + if not res: + continue - statement_dict[entry.customer] = get_html(doc, filters, entry, col, res, ageing) + statement_dict[entry.customer] = ( + [res, ageing] if get_statement_dict else get_html(doc, filters, entry, col, res, ageing) + ) - if not bool(statement_dict): - return False - elif consolidated: - result = "".join(list(statement_dict.values())) - return get_pdf(result, {"orientation": doc.orientation}) - else: - for customer, statement_html in statement_dict.items(): - statement_dict[customer] = get_pdf(statement_html, {"orientation": doc.orientation}) - return statement_dict + return statement_dict def set_ageing(doc, entry): @@ -95,7 +108,8 @@ def set_ageing(doc, entry): "range2": 60, "range3": 90, "range4": 120, - "customer": entry.customer, + "party_type": "Customer", + "party": [entry.customer], } ) col1, ageing = get_ageing(ageing_filters) @@ -138,7 +152,9 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency): def get_ar_filters(doc, entry): return { "report_date": doc.posting_date if doc.posting_date else None, - "customer": entry.customer, + "party_type": "Customer", + "party": [entry.customer], + "customer_name": entry.customer_name if entry.customer_name else None, "payment_terms_template": doc.payment_terms_template if doc.payment_terms_template else None, "sales_partner": doc.sales_partner if doc.sales_partner else None, "sales_person": doc.sales_person if doc.sales_person else None, @@ -362,16 +378,20 @@ def download_statements(document_name): @frappe.whitelist() -def send_emails(document_name, from_scheduler=False): +def send_emails(document_name, from_scheduler=False, posting_date=None): doc = frappe.get_doc("Process Statement Of Accounts", document_name) report = get_report_pdf(doc, consolidated=False) if report: for customer, report_pdf in report.items(): - attachments = [{"fname": customer + ".pdf", "fcontent": report_pdf}] + context = get_context(customer, doc) + filename = frappe.render_template(doc.pdf_name, context) + attachments = [{"fname": filename + ".pdf", "fcontent": report_pdf}] recipients, cc = get_recipients_and_cc(customer, doc) - context = get_context(customer, doc) + if not recipients: + continue + subject = frappe.render_template(doc.subject, context) message = frappe.render_template(doc.body, context) @@ -390,7 +410,7 @@ def send_emails(document_name, from_scheduler=False): ) if doc.enable_auto_email and from_scheduler: - new_to_date = getdate(today()) + new_to_date = getdate(posting_date or today()) if doc.frequency == "Weekly": new_to_date = add_days(new_to_date, 7) else: @@ -399,8 +419,11 @@ def send_emails(document_name, from_scheduler=False): doc.add_comment( "Comment", "Emails sent on: " + frappe.utils.format_datetime(frappe.utils.now()) ) - doc.db_set("to_date", new_to_date, commit=True) - doc.db_set("from_date", new_from_date, commit=True) + if doc.report == "General Ledger": + doc.db_set("to_date", new_to_date, commit=True) + doc.db_set("from_date", new_from_date, commit=True) + else: + doc.db_set("posting_date", new_to_date, commit=True) return True else: return False @@ -410,7 +433,8 @@ def send_emails(document_name, from_scheduler=False): def send_auto_email(): selected = frappe.get_list( "Process Statement Of Accounts", - filters={"to_date": format_date(today()), "enable_auto_email": 1}, + filters={"enable_auto_email": 1}, + or_filters={"to_date": format_date(today()), "posting_date": format_date(today())}, ) for entry in selected: send_emails(entry.name, from_scheduler=True) diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts_accounts_receivable.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts_accounts_receivable.html index 259526f8c43a..647600a9fea0 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts_accounts_receivable.html +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts_accounts_receivable.html @@ -8,9 +8,24 @@ } +
+ {% if letter_head.content %} +
{{ letter_head.content }}
+
+ {% endif %} +
+ +

{{ _(report.report_name) }}

- {{ filters.customer }} + {{ filters.customer_name }}

{% if (filters.tax_id) %} @@ -341,4 +356,9 @@

{{ _("Ageing Report based on ") }} {{ ageing.ageing_base {% endif %} + {% if terms_and_conditions %} +
+ {{ terms_and_conditions }} +
+ {% endif %}

{{ _("Printed On ") }}{{ frappe.utils.now() }}

diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py index c281040aaf2d..a3a74df40291 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py @@ -1,9 +1,110 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe import unittest +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days, getdate, today -class TestProcessStatementOfAccounts(unittest.TestCase): - pass +from erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts import ( + get_statement_dict, + send_emails, +) +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin + + +class TestProcessStatementOfAccounts(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.create_company() + self.create_customer() + self.create_customer(customer_name="Other Customer") + self.clear_old_entries() + self.si = create_sales_invoice() + create_sales_invoice(customer="Other Customer") + + def test_process_soa_for_gl(self): + """Tests the utils for Statement of Accounts(General Ledger)""" + process_soa = create_process_soa( + name="_Test Process SOA for GL", + customers=[{"customer": "_Test Customer"}, {"customer": "Other Customer"}], + ) + statement_dict = get_statement_dict(process_soa, get_statement_dict=True) + + # Checks if the statements are filtered based on the Customer + self.assertIn("Other Customer", statement_dict) + self.assertIn("_Test Customer", statement_dict) + + # Checks if the correct number of receivable entries exist + # 3 rows for opening and closing and 1 row for SI + receivable_entries = statement_dict["_Test Customer"][0] + self.assertEqual(len(receivable_entries), 4) + + # Checks the amount for the receivable entry + self.assertEqual(receivable_entries[1].voucher_no, self.si.name) + self.assertEqual(receivable_entries[1].balance, 100) + + def test_process_soa_for_ar(self): + """Tests the utils for Statement of Accounts(Accounts Receivable)""" + process_soa = create_process_soa(name="_Test Process SOA for AR", report="Accounts Receivable") + statement_dict = get_statement_dict(process_soa, get_statement_dict=True) + + # Checks if the statements are filtered based on the Customer + self.assertNotIn("Other Customer", statement_dict) + self.assertIn("_Test Customer", statement_dict) + + # Checks if the correct number of receivable entries exist + receivable_entries = statement_dict["_Test Customer"][0] + self.assertEqual(len(receivable_entries), 1) + + # Checks the amount for the receivable entry + self.assertEqual(receivable_entries[0].voucher_no, self.si.name) + self.assertEqual(receivable_entries[0].total_due, 100) + + # Checks the ageing summary for AR + ageing_summary = statement_dict["_Test Customer"][1][0] + expected_summary = frappe._dict( + range1=100, + range2=0, + range3=0, + range4=0, + range5=0, + ) + self.check_ageing_summary(ageing_summary, expected_summary) + + def test_auto_email_for_process_soa_ar(self): + process_soa = create_process_soa( + name="_Test Process SOA", enable_auto_email=1, report="Accounts Receivable" + ) + send_emails(process_soa.name, from_scheduler=True) + process_soa.load_from_db() + self.assertEqual(process_soa.posting_date, getdate(add_days(today(), 7))) + + def check_ageing_summary(self, ageing, expected_ageing): + for age_range in expected_ageing: + self.assertEqual(expected_ageing[age_range], ageing.get(age_range)) + + def tearDown(self): + frappe.db.rollback() + + +def create_process_soa(**args): + args = frappe._dict(args) + frappe.delete_doc_if_exists("Process Statement Of Accounts", args.name) + process_soa = frappe.new_doc("Process Statement Of Accounts") + soa_dict = frappe._dict( + name=args.name, + company=args.company or "_Test Company", + customers=args.customers or [{"customer": "_Test Customer"}], + enable_auto_email=1 if args.enable_auto_email else 0, + frequency=args.frequency or "Weekly", + report=args.report or "General Ledger", + from_date=args.from_date or getdate(today()), + to_date=args.to_date or getdate(today()), + posting_date=args.posting_date or getdate(today()), + include_ageing=1, + ) + process_soa.update(soa_dict) + process_soa.save() + return process_soa diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index ab7884d52092..04b00e09a44c 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -31,7 +31,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. super.onload(); // Ignore linked advances - this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger"]; + this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger", "Repost Accounting Ledger"]; if(!this.frm.doc.__islocal) { // show credit_to in print format @@ -59,6 +59,25 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. this.show_stock_ledger(); } + if (this.frm.doc.repost_required && this.frm.doc.docstatus===1) { + this.frm.set_intro(__("Accounting entries for this invoice need to be reposted. Please click on 'Repost' button to update.")); + this.frm.add_custom_button(__('Repost Accounting Entries'), + () => { + this.frm.call({ + doc: this.frm.doc, + method: 'repost_accounting_entries', + freeze: true, + freeze_message: __('Reposting...'), + callback: (r) => { + if (!r.exc) { + frappe.msgprint(__('Accounting Entries are reposted.')); + me.frm.refresh(); + } + } + }); + }).removeClass('btn-default').addClass('btn-warning'); + } + if(!doc.is_return && doc.docstatus == 1 && doc.outstanding_amount != 0){ if(doc.on_hold) { this.frm.add_custom_button( @@ -162,6 +181,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } this.frm.set_df_property("tax_withholding_category", "hidden", doc.apply_tds ? 0 : 1); + erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm); } unblock_invoice() { @@ -460,6 +480,12 @@ cur_frm.set_query("expense_account", "items", function(doc) { } }); +cur_frm.set_query("wip_composite_asset", "items", function() { + return { + filters: {'is_composite_asset': 1, 'docstatus': 0 } + } +}); + cur_frm.cscript.expense_account = function(doc, cdt, cdn){ var d = locals[cdt][cdn]; if(d.idx == 1 && d.expense_account){ diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 938d077053c7..7406b0540147 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -36,6 +36,7 @@ "currency_and_price_list", "currency", "conversion_rate", + "use_transaction_date_exchange_rate", "column_break2", "buying_price_list", "price_list_currency", @@ -167,6 +168,7 @@ "against_expense_account", "column_break_63", "unrealized_profit_loss_account", + "repost_required", "subscription_section", "auto_repeat", "update_auto_repeat_reference", @@ -191,8 +193,7 @@ "inter_company_invoice_reference", "is_old_subcontracting_flow", "remarks", - "connections_tab", - "column_break_38" + "connections_tab" ], "fields": [ { @@ -988,6 +989,7 @@ "print_hide": 1 }, { + "allow_on_submit": 1, "fieldname": "cash_bank_account", "fieldtype": "Link", "label": "Cash/Bank Account", @@ -1051,6 +1053,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "depends_on": "eval:flt(doc.write_off_amount)!=0", "fieldname": "write_off_account", "fieldtype": "Link", @@ -1214,6 +1217,7 @@ "read_only": 1 }, { + "allow_on_submit": 1, "default": "No", "fieldname": "is_opening", "fieldtype": "Select", @@ -1346,6 +1350,7 @@ "options": "Project" }, { + "allow_on_submit": 1, "depends_on": "eval:doc.is_internal_supplier", "description": "Unrealized Profit/Loss account for intra-company transfers", "fieldname": "unrealized_profit_loss_account", @@ -1378,6 +1383,7 @@ "depends_on": "eval:doc.is_subcontracted", "fieldname": "supplier_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Supplier Warehouse", "no_copy": 1, "options": "Warehouse", @@ -1495,10 +1501,6 @@ "fieldname": "column_break_6", "fieldtype": "Column Break" }, - { - "fieldname": "column_break_38", - "fieldtype": "Column Break" - }, { "fieldname": "column_break_50", "fieldtype": "Column Break" @@ -1570,6 +1572,22 @@ "fieldtype": "Check", "label": "Use Company Default Round Off Cost Center" }, + { + "default": "0", + "fieldname": "repost_required", + "fieldtype": "Check", + "hidden": 1, + "label": "Repost Required", + "options": "Account", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "use_transaction_date_exchange_rate", + "fieldtype": "Check", + "label": "Use Transaction Date Exchange Rate", + "read_only": 1 + }, { "depends_on": "eval: doc.supplier", "fieldname": "filter_items_by_supplier", @@ -1581,7 +1599,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2023-07-04 17:23:59.145031", + "modified": "2023-10-16 16:24:51.886231", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 6d905a37cde3..c5e45d4f3a9e 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -11,6 +11,9 @@ import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt +from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import ( + validate_docs_for_deferred_accounting, +) from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( check_if_return_invoice_linked_with_payment_entry, get_total_in_party_account_currency, @@ -232,7 +235,7 @@ def validate_with_previous_doc(self): ) if ( - cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate")) + cint(frappe.db.get_single_value("Buying Settings", "maintain_same_rate")) and not self.is_return and not self.is_internal_supplier ): @@ -269,9 +272,7 @@ def set_expense_account(self, for_validate=False): stock_not_billed_account = self.get_company_default("stock_received_but_not_billed") stock_items = self.get_stock_items() - asset_items = [d.is_fixed_asset for d in self.items if d.is_fixed_asset] - if len(asset_items) > 0: - asset_received_but_not_billed = self.get_company_default("asset_received_but_not_billed") + asset_received_but_not_billed = None if self.update_stock: self.validate_item_code() @@ -365,6 +366,8 @@ def set_expense_account(self, for_validate=False): ) item.expense_account = asset_category_account elif item.is_fixed_asset and item.pr_detail: + if not asset_received_but_not_billed: + asset_received_but_not_billed = self.get_company_default("asset_received_but_not_billed") item.expense_account = asset_received_but_not_billed elif not item.expense_account and for_validate: throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name)) @@ -487,6 +490,11 @@ def validate_purchase_receipt_if_update_stock(self): _("Stock cannot be updated against Purchase Receipt {0}").format(item.purchase_receipt) ) + def validate_for_repost(self): + self.validate_write_off_account() + self.validate_expense_account() + validate_docs_for_deferred_accounting([], [self.name]) + def on_submit(self): super(PurchaseInvoice, self).on_submit() @@ -529,6 +537,19 @@ def on_submit(self): self.process_common_party_accounting() + def on_update_after_submit(self): + if hasattr(self, "repost_required"): + fields_to_check = [ + "cash_bank_account", + "write_off_account", + "unrealized_profit_loss_account", + ] + child_tables = {"items": ("expense_account",), "taxes": ("account_head",)} + self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables) + if self.needs_repost: + self.validate_for_repost() + self.db_set("repost_required", self.needs_repost) + def make_gl_entries(self, gl_entries=None, from_repost=False): if not gl_entries: gl_entries = self.get_gl_entries() @@ -543,6 +564,7 @@ def make_gl_entries(self, gl_entries=None, from_repost=False): merge_entries=False, from_repost=from_repost, ) + self.make_exchange_gain_loss_journal() elif self.docstatus == 2: provisional_entries = [a for a in gl_entries if a.voucher_type == "Purchase Receipt"] make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) @@ -587,7 +609,6 @@ def get_gl_entries(self, warehouse_account=None): self.get_asset_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) - self.make_exchange_gain_loss_gl_entries(gl_entries) self.make_internal_transfer_gl_entries(gl_entries) gl_entries = make_regional_gl_entries(gl_entries, self) @@ -768,21 +789,22 @@ def make_item_gl_entries(self, gl_entries): # Amount added through landed-cost-voucher if landed_cost_entries: - for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): - gl_entries.append( - self.get_gl_dict( - { - "account": account, - "against": item.expense_account, - "cost_center": item.cost_center, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": flt(amount["base_amount"]), - "credit_in_account_currency": flt(amount["amount"]), - "project": item.project or self.project, - }, - item=item, + if (item.item_code, item.name) in landed_cost_entries: + for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): + gl_entries.append( + self.get_gl_dict( + { + "account": account, + "against": item.expense_account, + "cost_center": item.cost_center, + "remarks": self.get("remarks") or _("Accounting Entry for Stock"), + "credit": flt(amount["base_amount"]), + "credit_in_account_currency": flt(amount["amount"]), + "project": item.project or self.project, + }, + item=item, + ) ) - ) # sub-contracting warehouse if flt(item.rm_supp_cost): @@ -976,33 +998,10 @@ def make_item_gl_entries(self, gl_entries): 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") + arbnb_account = None + eiiav_account = None + asset_eiiav_currency = None for item in self.get("items"): if item.is_fixed_asset: @@ -1014,6 +1013,8 @@ def get_asset_gl_entry(self, gl_entries): "Asset Received But Not Billed", "Fixed Asset", ]: + if not arbnb_account: + arbnb_account = self.get_company_default("asset_received_but_not_billed") item.expense_account = arbnb_account if not self.update_stock: @@ -1036,7 +1037,10 @@ def get_asset_gl_entry(self, gl_entries): ) if item.item_tax_amount: - asset_eiiav_currency = get_account_currency(eiiav_account) + if not eiiav_account or not asset_eiiav_currency: + eiiav_account = self.get_company_default("expenses_included_in_asset_valuation") + asset_eiiav_currency = get_account_currency(eiiav_account) + gl_entries.append( self.get_gl_dict( { @@ -1079,7 +1083,10 @@ def get_asset_gl_entry(self, gl_entries): ) if item.item_tax_amount and not cint(erpnext.is_perpetual_inventory_enabled(self.company)): - asset_eiiav_currency = get_account_currency(eiiav_account) + if not eiiav_account or not asset_eiiav_currency: + eiiav_account = self.get_company_default("expenses_included_in_asset_valuation") + asset_eiiav_currency = get_account_currency(eiiav_account) + gl_entries.append( self.get_gl_dict( { @@ -1099,47 +1106,46 @@ def get_asset_gl_entry(self, gl_entries): ) ) - # When update stock is checked # Assets are bought through this document then it will be linked to this document - if self.update_stock: - if flt(item.landed_cost_voucher_amount): - gl_entries.append( - self.get_gl_dict( - { - "account": eiiav_account, - "against": cwip_account, - "cost_center": item.cost_center, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": flt(item.landed_cost_voucher_amount), - "project": item.project or self.project, - }, - item=item, - ) - ) + if flt(item.landed_cost_voucher_amount): + if not eiiav_account: + eiiav_account = self.get_company_default("expenses_included_in_asset_valuation") - gl_entries.append( - self.get_gl_dict( - { - "account": cwip_account, - "against": eiiav_account, - "cost_center": item.cost_center, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "debit": flt(item.landed_cost_voucher_amount), - "project": item.project or self.project, - }, - item=item, - ) + gl_entries.append( + self.get_gl_dict( + { + "account": eiiav_account, + "against": cwip_account, + "cost_center": item.cost_center, + "remarks": self.get("remarks") or _("Accounting Entry for Stock"), + "credit": flt(item.landed_cost_voucher_amount), + "project": item.project or self.project, + }, + item=item, ) - - # update gross amount of assets bought through this document - assets = frappe.db.get_all( - "Asset", filters={"purchase_invoice": self.name, "item_code": item.item_code} ) - for asset in assets: - frappe.db.set_value("Asset", asset.name, "gross_purchase_amount", flt(item.valuation_rate)) - frappe.db.set_value( - "Asset", asset.name, "purchase_receipt_amount", flt(item.valuation_rate) + + gl_entries.append( + self.get_gl_dict( + { + "account": cwip_account, + "against": eiiav_account, + "cost_center": item.cost_center, + "remarks": self.get("remarks") or _("Accounting Entry for Stock"), + "debit": flt(item.landed_cost_voucher_amount), + "project": item.project or self.project, + }, + item=item, ) + ) + + # update gross amount of assets bought through this document + assets = frappe.db.get_all( + "Asset", filters={"purchase_invoice": self.name, "item_code": item.item_code} + ) + for asset in assets: + frappe.db.set_value("Asset", asset.name, "gross_purchase_amount", flt(item.valuation_rate)) + frappe.db.set_value("Asset", asset.name, "purchase_receipt_amount", flt(item.valuation_rate)) return gl_entries @@ -1446,6 +1452,8 @@ def on_cancel(self): "Repost Item Valuation", "Repost Payment Ledger", "Repost Payment Ledger Items", + "Repost Accounting Ledger", + "Repost Accounting Ledger Items", "Payment Ledger Entry", "Tax Withheld Vouchers", ) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index e8766275f0b1..170a163b45f0 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -5,7 +5,7 @@ import unittest import frappe -from frappe.tests.utils import change_settings +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, cint, flt, getdate, nowdate, today import erpnext @@ -33,7 +33,7 @@ test_ignore = ["Serial No"] -class TestPurchaseInvoice(unittest.TestCase, StockTestMixin): +class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): @classmethod def setUpClass(self): unlink_payment_on_cancel_of_invoice() @@ -43,6 +43,9 @@ def setUpClass(self): def tearDownClass(self): unlink_payment_on_cancel_of_invoice(0) + def tearDown(self): + frappe.db.rollback() + def test_purchase_invoice_received_qty(self): """ 1. Test if received qty is validated against accepted + rejected @@ -417,6 +420,7 @@ def test_purchase_invoice_calculation(self): self.assertEqual(tax.tax_amount, expected_values[i][1]) self.assertEqual(tax.total, expected_values[i][2]) + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_purchase_invoice_with_advance(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import ( test_records as jv_test_records, @@ -471,6 +475,7 @@ def test_purchase_invoice_with_advance(self): ) ) + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_invoice_with_advance_and_multi_payment_terms(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import ( test_records as jv_test_records, @@ -1153,7 +1158,7 @@ def test_deferred_expense_via_journal_entry(self): item = create_item("_Test Item for Deferred Accounting", is_purchase_item=True) item.enable_deferred_expense = 1 - item.deferred_expense_account = deferred_account + item.item_defaults[0].deferred_expense_account = deferred_account item.save() pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True) @@ -1209,6 +1214,7 @@ def test_deferred_expense_via_journal_entry(self): acc_settings.submit_journal_entriessubmit_journal_entries = 0 acc_settings.save() + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_gain_loss_with_advance_entry(self): unlink_enabled = frappe.db.get_value( "Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice" @@ -1264,10 +1270,11 @@ def test_gain_loss_with_advance_entry(self): pi.save() pi.submit() + creditors_account = pi.credit_to + expected_gle = [ ["_Test Account Cost for Goods Sold - _TC", 37500.0], - ["_Test Payable USD - _TC", -35000.0], - ["Exchange Gain/Loss - _TC", -2500.0], + ["_Test Payable USD - _TC", -37500.0], ] gl_entries = frappe.db.sql( @@ -1284,6 +1291,31 @@ def test_gain_loss_with_advance_entry(self): self.assertEqual(expected_gle[i][0], gle.account) self.assertEqual(expected_gle[i][1], gle.balance) + pi.reload() + self.assertEqual(pi.outstanding_amount, 0) + + total_debit_amount = frappe.db.get_all( + "Journal Entry Account", + {"account": creditors_account, "docstatus": 1, "reference_name": pi.name}, + "sum(debit) as amount", + group_by="reference_name", + )[0].amount + self.assertEqual(flt(total_debit_amount, 2), 2500) + jea_parent = frappe.db.get_all( + "Journal Entry Account", + filters={ + "account": creditors_account, + "docstatus": 1, + "reference_name": pi.name, + "debit": 2500, + "debit_in_account_currency": 0, + }, + fields=["parent"], + )[0] + self.assertEqual( + frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss" + ) + pi_2 = make_purchase_invoice( supplier="_Test Supplier USD", currency="USD", @@ -1308,10 +1340,12 @@ def test_gain_loss_with_advance_entry(self): pi_2.save() pi_2.submit() + pi_2.reload() + self.assertEqual(pi_2.outstanding_amount, 0) + expected_gle = [ ["_Test Account Cost for Goods Sold - _TC", 36500.0], - ["_Test Payable USD - _TC", -35000.0], - ["Exchange Gain/Loss - _TC", -1500.0], + ["_Test Payable USD - _TC", -36500.0], ] gl_entries = frappe.db.sql( @@ -1342,12 +1376,39 @@ def test_gain_loss_with_advance_entry(self): self.assertEqual(expected_gle[i][0], gle.account) self.assertEqual(expected_gle[i][1], gle.balance) + total_debit_amount = frappe.db.get_all( + "Journal Entry Account", + {"account": creditors_account, "docstatus": 1, "reference_name": pi_2.name}, + "sum(debit) as amount", + group_by="reference_name", + )[0].amount + self.assertEqual(flt(total_debit_amount, 2), 1500) + jea_parent_2 = frappe.db.get_all( + "Journal Entry Account", + filters={ + "account": creditors_account, + "docstatus": 1, + "reference_name": pi_2.name, + "debit": 1500, + "debit_in_account_currency": 0, + }, + fields=["parent"], + )[0] + self.assertEqual( + frappe.db.get_value("Journal Entry", jea_parent_2.parent, "voucher_type"), + "Exchange Gain Or Loss", + ) + pi.reload() pi.cancel() + self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent.parent, "docstatus"), 2) + pi_2.reload() pi_2.cancel() + self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent_2.parent, "docstatus"), 2) + pay.reload() pay.cancel() @@ -1356,6 +1417,7 @@ def test_gain_loss_with_advance_entry(self): ) frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account) + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_purchase_invoice_advance_taxes(self): from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry @@ -1670,23 +1732,172 @@ def test_gl_entries_for_standalone_debit_note(self): rate = flt(sle.stock_value_difference) / flt(sle.actual_qty) self.assertAlmostEqual(returned_inv.items[0].rate, rate) + def test_payment_allocation_for_payment_terms(self): + from erpnext.buying.doctype.purchase_order.test_purchase_order import ( + create_pr_against_po, + create_purchase_order, + ) + from erpnext.selling.doctype.sales_order.test_sales_order import ( + automatically_fetch_payment_terms, + ) + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + make_purchase_invoice as make_pi_from_pr, + ) + + automatically_fetch_payment_terms() + frappe.db.set_value( + "Payment Terms Template", + "_Test Payment Term Template", + "allocate_payment_based_on_payment_terms", + 0, + ) + + po = create_purchase_order(do_not_save=1) + po.payment_terms_template = "_Test Payment Term Template" + po.save() + po.submit() + + pr = create_pr_against_po(po.name, received_qty=4) + pi = make_pi_from_pr(pr.name) + self.assertEqual(pi.payment_schedule[0].payment_amount, 1000) + + frappe.db.set_value( + "Payment Terms Template", + "_Test Payment Term Template", + "allocate_payment_based_on_payment_terms", + 1, + ) + pi = make_pi_from_pr(pr.name) + self.assertEqual(pi.payment_schedule[0].payment_amount, 2500) -def check_gl_entries(doc, voucher_no, expected_gle, posting_date): - gl_entries = frappe.db.sql( - """select account, debit, credit, posting_date - from `tabGL Entry` - where voucher_type='Purchase Invoice' and voucher_no=%s and posting_date >= %s - order by posting_date asc, account asc""", - (voucher_no, posting_date), - as_dict=1, + automatically_fetch_payment_terms(enable=0) + frappe.db.set_value( + "Payment Terms Template", + "_Test Payment Term Template", + "allocate_payment_based_on_payment_terms", + 0, + ) + + def test_offsetting_entries_for_accounting_dimensions(self): + from erpnext.accounts.doctype.account.test_account import create_account + from erpnext.accounts.report.trial_balance.test_trial_balance import ( + clear_dimension_defaults, + create_accounting_dimension, + disable_dimension, + ) + + create_account( + account_name="Offsetting", + company="_Test Company", + parent_account="Temporary Accounts - _TC", + ) + + create_accounting_dimension(company="_Test Company", offsetting_account="Offsetting - _TC") + + branch1 = frappe.new_doc("Branch") + branch1.branch = "Location 1" + branch1.insert(ignore_if_duplicate=True) + branch2 = frappe.new_doc("Branch") + branch2.branch = "Location 2" + branch2.insert(ignore_if_duplicate=True) + + pi = make_purchase_invoice( + company="_Test Company", + do_not_save=True, + do_not_submit=True, + rate=1000, + price_list_rate=1000, + qty=1, + ) + pi.branch = branch1.branch + pi.items[0].branch = branch2.branch + pi.save() + pi.submit() + + expected_gle = [ + ["_Test Account Cost for Goods Sold - _TC", 1000, 0.0, nowdate(), branch2.branch], + ["Creditors - _TC", 0.0, 1000, nowdate(), branch1.branch], + ["Offsetting - _TC", 1000, 0.0, nowdate(), branch1.branch], + ["Offsetting - _TC", 0.0, 1000, nowdate(), branch2.branch], + ] + + check_gl_entries( + self, + pi.name, + expected_gle, + nowdate(), + voucher_type="Purchase Invoice", + additional_columns=["branch"], + ) + clear_dimension_defaults("Branch") + disable_dimension() + + def test_repost_accounting_entries(self): + pi = make_purchase_invoice( + rate=1000, + price_list_rate=1000, + qty=1, + ) + expected_gle = [ + ["_Test Account Cost for Goods Sold - _TC", 1000, 0.0, nowdate()], + ["Creditors - _TC", 0.0, 1000, nowdate()], + ] + check_gl_entries(self, pi.name, expected_gle, nowdate()) + + pi.items[0].expense_account = "Service - _TC" + pi.save() + pi.load_from_db() + self.assertTrue(pi.repost_required) + pi.repost_accounting_entries() + + expected_gle = [ + ["Creditors - _TC", 0.0, 1000, nowdate()], + ["Service - _TC", 1000, 0.0, nowdate()], + ] + check_gl_entries(self, pi.name, expected_gle, nowdate()) + pi.load_from_db() + self.assertFalse(pi.repost_required) + + +def check_gl_entries( + doc, + voucher_no, + expected_gle, + posting_date, + voucher_type="Purchase Invoice", + additional_columns=None, +): + gl = frappe.qb.DocType("GL Entry") + query = ( + frappe.qb.from_(gl) + .select(gl.account, gl.debit, gl.credit, gl.posting_date) + .where( + (gl.voucher_type == voucher_type) + & (gl.voucher_no == voucher_no) + & (gl.posting_date >= posting_date) + & (gl.is_cancelled == 0) + ) + .orderby(gl.posting_date, gl.account, gl.creation) ) + if additional_columns: + for col in additional_columns: + query = query.select(gl[col]) + + gl_entries = query.run(as_dict=True) + for i, gle in enumerate(gl_entries): doc.assertEqual(expected_gle[i][0], gle.account) doc.assertEqual(expected_gle[i][1], gle.debit) doc.assertEqual(expected_gle[i][2], gle.credit) doc.assertEqual(getdate(expected_gle[i][3]), gle.posting_date) + if additional_columns: + j = 4 + for col in additional_columns: + doc.assertEqual(expected_gle[i][j], gle[col]) + j += 1 + def create_tax_witholding_category(category_name, company, account): from erpnext.accounts.utils import get_fiscal_year diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index 92db6b61bb97..c7357360ec0d 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -75,6 +75,7 @@ "manufacturer_part_no", "accounting", "expense_account", + "wip_composite_asset", "col_break5", "is_fixed_asset", "asset_location", @@ -467,6 +468,7 @@ "label": "Accounting" }, { + "allow_on_submit": 1, "fieldname": "expense_account", "fieldtype": "Link", "label": "Expense Head", @@ -877,12 +879,18 @@ "fieldname": "apply_tds", "fieldtype": "Check", "label": "Apply TDS" + }, + { + "fieldname": "wip_composite_asset", + "fieldtype": "Link", + "label": "WIP Composite Asset", + "options": "Asset" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-07-04 17:22:21.501152", + "modified": "2023-10-03 21:01:01.824892", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Item", @@ -892,4 +900,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json index d86abade924d..347cae05b72b 100644 --- a/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json +++ b/erpnext/accounts/doctype/purchase_taxes_and_charges/purchase_taxes_and_charges.json @@ -86,6 +86,7 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "columns": 2, "fieldname": "account_head", "fieldtype": "Link", @@ -97,6 +98,7 @@ "reqd": 1 }, { + "allow_on_submit": 1, "default": ":Company", "fieldname": "cost_center", "fieldtype": "Link", diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/__init__.py b/erpnext/accounts/doctype/repost_accounting_ledger/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html new file mode 100644 index 000000000000..2dec8f753f2b --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html @@ -0,0 +1,44 @@ + + + + + + {% for col in gl_columns%} + + {% endfor %} + + + + {% for col in gl_columns%} + + {% endfor %} + + +{% for gl in gl_data%} +{% if gl["old"]%} + +{% else %} + +{% endif %} + {% for col in gl_columns %} + + {% endfor %} + +{% endfor %} +
{{ col.label }}
+ {{ gl[col.fieldname] }} +
diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.js b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.js new file mode 100644 index 000000000000..3a87a380d199 --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.js @@ -0,0 +1,50 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Repost Accounting Ledger", { + setup: function(frm) { + frm.fields_dict['vouchers'].grid.get_field('voucher_type').get_query = function(doc) { + return { + filters: { + name: ['in', ['Purchase Invoice', 'Sales Invoice', 'Payment Entry', 'Journal Entry']], + } + } + } + + frm.fields_dict['vouchers'].grid.get_field('voucher_no').get_query = function(doc) { + if (doc.company) { + return { + filters: { + company: doc.company, + docstatus: 1 + } + } + } + } + }, + + refresh: function(frm) { + frm.add_custom_button(__('Show Preview'), () => { + frm.call({ + method: 'generate_preview', + doc: frm.doc, + freeze: true, + freeze_message: __('Generating Preview'), + callback: function(r) { + if (r && r.message) { + let content = r.message; + let opts = { + title: "Preview", + subtitle: "preview", + content: content, + print_settings: {orientation: "landscape"}, + columns: [], + data: [], + } + frappe.render_grid(opts); + } + } + }); + }); + } +}); diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json new file mode 100644 index 000000000000..5b7cd2b0b209 --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json @@ -0,0 +1,82 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:ACC-REPOST-{#####}", + "creation": "2023-07-04 13:07:32.923675", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "column_break_vpup", + "delete_cancelled_entries", + "section_break_metl", + "vouchers", + "amended_from" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Repost Accounting Ledger", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "vouchers", + "fieldtype": "Table", + "label": "Vouchers", + "options": "Repost Accounting Ledger Items" + }, + { + "fieldname": "column_break_vpup", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_metl", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "delete_cancelled_entries", + "fieldtype": "Check", + "label": "Delete Cancelled Ledger Entries" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-09-26 14:21:27.362567", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Repost Accounting Ledger", + "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": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py new file mode 100644 index 000000000000..dbb0971fdea2 --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py @@ -0,0 +1,188 @@ +# 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.data import comma_and + + +class RepostAccountingLedger(Document): + def __init__(self, *args, **kwargs): + super(RepostAccountingLedger, self).__init__(*args, **kwargs) + self._allowed_types = set( + ["Purchase Invoice", "Sales Invoice", "Payment Entry", "Journal Entry"] + ) + + def validate(self): + self.validate_vouchers() + self.validate_for_closed_fiscal_year() + self.validate_for_deferred_accounting() + + def validate_for_deferred_accounting(self): + sales_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Sales Invoice"] + purchase_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Purchase Invoice"] + validate_docs_for_deferred_accounting(sales_docs, purchase_docs) + + def validate_for_closed_fiscal_year(self): + if self.vouchers: + latest_pcv = ( + frappe.db.get_all( + "Period Closing Voucher", + filters={"company": self.company}, + order_by="posting_date desc", + pluck="posting_date", + limit=1, + ) + or None + ) + if not latest_pcv: + return + + for vtype in self._allowed_types: + if names := [x.voucher_no for x in self.vouchers if x.voucher_type == vtype]: + latest_voucher = frappe.db.get_all( + vtype, + filters={"name": ["in", names]}, + pluck="posting_date", + order_by="posting_date desc", + limit=1, + )[0] + if latest_voucher and latest_pcv[0] >= latest_voucher: + frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year.")) + + def validate_vouchers(self): + if self.vouchers: + # Validate voucher types + voucher_types = set([x.voucher_type for x in self.vouchers]) + if disallowed_types := voucher_types.difference(self._allowed_types): + frappe.throw( + _("{0} types are not allowed. Only {1} are.").format( + frappe.bold(comma_and(list(disallowed_types))), + frappe.bold(comma_and(list(self._allowed_types))), + ) + ) + + def get_existing_ledger_entries(self): + vouchers = [x.voucher_no for x in self.vouchers] + gl = qb.DocType("GL Entry") + existing_gles = ( + qb.from_(gl) + .select(gl.star) + .where((gl.voucher_no.isin(vouchers)) & (gl.is_cancelled == 0)) + .run(as_dict=True) + ) + self.gles = frappe._dict({}) + + for gle in existing_gles: + self.gles.setdefault((gle.voucher_type, gle.voucher_no), frappe._dict({})).setdefault( + "existing", [] + ).append(gle.update({"old": True})) + + def generate_preview_data(self): + self.gl_entries = [] + self.get_existing_ledger_entries() + for x in self.vouchers: + doc = frappe.get_doc(x.voucher_type, x.voucher_no) + if doc.doctype in ["Payment Entry", "Journal Entry"]: + gle_map = doc.build_gl_map() + else: + gle_map = doc.get_gl_entries() + + old_entries = self.gles.get((x.voucher_type, x.voucher_no)) + if old_entries: + self.gl_entries.extend(old_entries.existing) + self.gl_entries.extend(gle_map) + + @frappe.whitelist() + def generate_preview(self): + from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns + + gl_columns = [] + gl_data = [] + + self.generate_preview_data() + if self.gl_entries: + filters = {"company": self.company, "include_dimensions": 1} + for x in get_gl_columns(filters): + if x["fieldname"] == "gl_entry": + x["fieldname"] = "name" + gl_columns.append(x) + + gl_data = self.gl_entries + rendered_page = frappe.render_template( + "erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html", + {"gl_columns": gl_columns, "gl_data": gl_data}, + ) + + return rendered_page + + def on_submit(self): + if len(self.vouchers) > 1: + job_name = "repost_accounting_ledger_" + self.name + frappe.enqueue( + method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost", + account_repost_doc=self.name, + is_async=True, + job_name=job_name, + ) + frappe.msgprint(_("Repost has started in the background")) + else: + start_repost(self.name) + + +@frappe.whitelist() +def start_repost(account_repost_doc=str) -> None: + if account_repost_doc: + repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc) + + if repost_doc.docstatus == 1: + # Prevent repost on invoices with deferred accounting + repost_doc.validate_for_deferred_accounting() + + for x in repost_doc.vouchers: + doc = frappe.get_doc(x.voucher_type, x.voucher_no) + + if repost_doc.delete_cancelled_entries: + frappe.db.delete("GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}) + frappe.db.delete( + "Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name} + ) + + if doc.doctype in ["Sales Invoice", "Purchase Invoice"]: + if not repost_doc.delete_cancelled_entries: + doc.docstatus = 2 + doc.make_gl_entries_on_cancel() + + doc.docstatus = 1 + doc.make_gl_entries() + + elif doc.doctype in ["Payment Entry", "Journal Entry"]: + if not repost_doc.delete_cancelled_entries: + doc.make_gl_entries(1) + doc.make_gl_entries() + + frappe.db.commit() + + +def validate_docs_for_deferred_accounting(sales_docs, purchase_docs): + docs_with_deferred_revenue = frappe.db.get_all( + "Sales Invoice Item", + filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True}, + fields=["parent"], + as_list=1, + ) + + docs_with_deferred_expense = frappe.db.get_all( + "Purchase Invoice Item", + filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1}, + fields=["parent"], + as_list=1, + ) + + if docs_with_deferred_revenue or docs_with_deferred_expense: + frappe.throw( + _("Documents: {0} have deferred revenue/expense enabled for them. Cannot repost.").format( + frappe.bold(comma_and([x[0] for x in docs_with_deferred_expense + docs_with_deferred_revenue])) + ) + ) diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py new file mode 100644 index 000000000000..0e75dd2e3e17 --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py @@ -0,0 +1,202 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe import qb +from frappe.query_builder.functions import Sum +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import add_days, nowdate, today + +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request +from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import start_repost +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.accounts.utils import get_fiscal_year + + +class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.create_company() + self.create_customer() + self.create_item() + + def teadDown(self): + frappe.db.rollback() + + def test_01_basic_functions(self): + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + ) + + preq = frappe.get_doc( + make_payment_request( + dt=si.doctype, + dn=si.name, + payment_request_type="Inward", + party_type="Customer", + party=si.customer, + ) + ) + preq.save().submit() + + # Test Validation Error + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.delete_cancelled_entries = True + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + ral.append( + "vouchers", {"voucher_type": preq.doctype, "voucher_no": preq.name} + ) # this should throw validation error + self.assertRaises(frappe.ValidationError, ral.save) + ral.vouchers.pop() + preq.cancel() + preq.delete() + + pe = get_payment_entry(si.doctype, si.name) + pe.save().submit() + ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name}) + ral.save() + + # manually set an incorrect debit amount in DB + gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": self.debit_to}) + frappe.db.set_value("GL Entry", gle[0], "debit", 90) + + gl = qb.DocType("GL Entry") + res = ( + qb.from_(gl) + .select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit")) + .where((gl.voucher_no == si.name) & (gl.is_cancelled == 0)) + .run() + ) + + # Assert incorrect ledger balance + self.assertNotEqual(res[0], (si.name, 100, 100)) + + # Submit repost document + ral.save().submit() + + # background jobs don't run on test cases. Manually triggering repost function. + start_repost(ral.name) + + res = ( + qb.from_(gl) + .select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit")) + .where((gl.voucher_no == si.name) & (gl.is_cancelled == 0)) + .run() + ) + + # Ledger should reflect correct amount post repost + self.assertEqual(res[0], (si.name, 100, 100)) + + def test_02_deferred_accounting_valiations(self): + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + do_not_submit=True, + ) + si.items[0].enable_deferred_revenue = True + si.items[0].deferred_revenue_account = self.deferred_revenue + si.items[0].service_start_date = nowdate() + si.items[0].service_end_date = add_days(nowdate(), 90) + si.save().submit() + + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + self.assertRaises(frappe.ValidationError, ral.save) + + @change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1}) + def test_04_pcv_validation(self): + # Clear old GL entries so PCV can be submitted. + gl = frappe.qb.DocType("GL Entry") + qb.from_(gl).delete().where(gl.company == self.company).run() + + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + ) + pcv = frappe.get_doc( + { + "doctype": "Period Closing Voucher", + "transaction_date": today(), + "posting_date": today(), + "company": self.company, + "fiscal_year": get_fiscal_year(today(), company=self.company)[0], + "cost_center": self.cost_center, + "closing_account_head": self.retained_earnings, + "remarks": "test", + } + ) + pcv.save().submit() + + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + self.assertRaises(frappe.ValidationError, ral.save) + + pcv.reload() + pcv.cancel() + pcv.delete() + + def test_03_deletion_flag_and_preview_function(self): + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + ) + + pe = get_payment_entry(si.doctype, si.name) + pe.save().submit() + + # without deletion flag set + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.delete_cancelled_entries = False + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name}) + ral.save() + + # assert preview data is generated + preview = ral.generate_preview() + self.assertIsNotNone(preview) + + ral.save().submit() + + # background jobs don't run on test cases. Manually triggering repost function. + start_repost(ral.name) + + self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1})) + self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1})) + + # with deletion flag set + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = self.company + ral.delete_cancelled_entries = True + ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name}) + ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name}) + ral.save().submit() + + start_repost(ral.name) + self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1})) + self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1})) diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_items/__init__.py b/erpnext/accounts/doctype/repost_accounting_ledger_items/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.json b/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.json new file mode 100644 index 000000000000..4a2041f88c6b --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-07-04 14:14:01.243848", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "voucher_type", + "voucher_no" + ], + "fields": [ + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Voucher Type", + "options": "DocType" + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Voucher No", + "options": "voucher_type" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-07-04 14:15:51.165584", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Repost Accounting Ledger Items", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.py b/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.py new file mode 100644 index 000000000000..9221f447355c --- /dev/null +++ b/erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.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 RepostAccountingLedgerItems(Document): + pass diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json index 5175fd169ffe..ed8d395a0eca 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json +++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json @@ -99,7 +99,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-11-08 07:38:40.079038", + "modified": "2023-09-26 14:21:35.719727", "modified_by": "Administrator", "module": "Accounts", "name": "Repost Payment Ledger", @@ -155,5 +155,6 @@ ], "sort_field": "modified", "sort_order": "DESC", - "states": [] + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index df3db37bc650..a411889fbddb 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -34,7 +34,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e super.onload(); this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', - 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger"]; + 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payments", "Unreconcile Payment Entries"]; if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { // show debit_to in print format @@ -177,8 +177,11 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e }, __('Create')); } } + + erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm); } + make_maintenance_schedule() { frappe.model.open_mapped_doc({ method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index fb60dd58724b..5fc711d893b9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -714,6 +714,7 @@ "fieldtype": "Table", "hide_days": 1, "hide_seconds": 1, + "label": "Items", "oldfieldname": "entries", "oldfieldtype": "Table", "options": "Sales Invoice Item", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 15774331272e..7f124f541f31 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -11,19 +11,19 @@ import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date -from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( - get_accounting_dimensions, -) from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( get_loyalty_program_details_with_points, validate_loyalty_points, ) +from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import ( + validate_docs_for_deferred_accounting, +) from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( get_party_tax_withholding_details, ) from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.party import get_due_date, get_party_account, get_party_details -from erpnext.accounts.utils import get_account_currency +from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_account_currency from erpnext.assets.doctype.asset.depreciation import ( depreciate_asset, get_disposal_account_and_cost_center, @@ -176,6 +176,12 @@ def validate_accounts(self): self.validate_account_for_change_amount() self.validate_income_account() + def validate_for_repost(self): + self.validate_write_off_account() + self.validate_account_for_change_amount() + self.validate_income_account() + validate_docs_for_deferred_accounting([self.name], []) + def validate_fixed_asset(self): for d in self.get("items"): if d.is_fixed_asset and d.meta.get_field("asset") and d.asset: @@ -399,6 +405,10 @@ def on_cancel(self): "Repost Item Valuation", "Repost Payment Ledger", "Repost Payment Ledger Items", + "Repost Accounting Ledger", + "Repost Accounting Ledger Items", + "Unreconcile Payments", + "Unreconcile Payment Entries", "Payment Ledger Entry", ) @@ -525,89 +535,22 @@ def on_update(self): def on_update_after_submit(self): if hasattr(self, "repost_required"): - needs_repost = 0 - - # Check if any field affecting accounting entry is altered - doc_before_update = self.get_doc_before_save() - accounting_dimensions = get_accounting_dimensions() + ["cost_center", "project"] - - # Check if opening entry check updated - if doc_before_update.get("is_opening") != self.is_opening: - needs_repost = 1 - - if not needs_repost: - # Parent Level Accounts excluding party account - for field in ( - "additional_discount_account", - "cash_bank_account", - "account_for_change_amount", - "write_off_account", - "loyalty_redemption_account", - "unrealized_profit_loss_account", - ): - if doc_before_update.get(field) != self.get(field): - needs_repost = 1 - break - - # Check for parent accounting dimensions - for dimension in accounting_dimensions: - if doc_before_update.get(dimension) != self.get(dimension): - needs_repost = 1 - break - - # Check for child tables - if self.check_if_child_table_updated( - "items", - doc_before_update, - ("income_account", "expense_account", "discount_account"), - accounting_dimensions, - ): - needs_repost = 1 - - if self.check_if_child_table_updated( - "taxes", doc_before_update, ("account_head",), accounting_dimensions - ): - needs_repost = 1 - - self.validate_accounts() - - # validate if deferred revenue is enabled for any item - # Don't allow to update the invoice if deferred revenue is enabled - for item in self.get("items"): - if item.enable_deferred_revenue: - frappe.throw( - _( - "Deferred Revenue is enabled for item {0}. You cannot update the invoice after submission." - ).format(item.item_code) - ) - - self.db_set("repost_required", needs_repost) - - def check_if_child_table_updated( - self, child_table, doc_before_update, fields_to_check, accounting_dimensions - ): - # Check if any field affecting accounting entry is altered - for index, item in enumerate(self.get(child_table)): - for field in fields_to_check: - if doc_before_update.get(child_table)[index].get(field) != item.get(field): - return True - - for dimension in accounting_dimensions: - if doc_before_update.get(child_table)[index].get(dimension) != item.get(dimension): - return True - - return False - - @frappe.whitelist() - def repost_accounting_entries(self): - if self.repost_required: - self.docstatus = 2 - self.make_gl_entries_on_cancel() - self.docstatus = 1 - self.make_gl_entries() - self.db_set("repost_required", 0) - else: - frappe.throw(_("No updates pending for reposting")) + fields_to_check = [ + "additional_discount_account", + "cash_bank_account", + "account_for_change_amount", + "write_off_account", + "loyalty_redemption_account", + "unrealized_profit_loss_account", + ] + child_tables = { + "items": ("income_account", "expense_account", "discount_account"), + "taxes": ("account_head",), + } + self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables) + if self.needs_repost: + self.validate_for_repost() + self.db_set("repost_required", self.needs_repost) def set_paid_amount(self): paid_amount = 0.0 @@ -1046,7 +989,10 @@ def make_gl_entries(self, gl_entries=None, from_repost=False): merge_entries=False, from_repost=from_repost, ) + + self.make_exchange_gain_loss_journal() elif self.docstatus == 2: + cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) if update_outstanding == "No": @@ -1071,10 +1017,10 @@ def get_gl_entries(self, warehouse_account=None): self.make_customer_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) - self.make_exchange_gain_loss_gl_entries(gl_entries) self.make_internal_transfer_gl_entries(gl_entries) self.make_item_gl_entries(gl_entries) + self.make_precision_loss_gl_entry(gl_entries) self.make_discount_gl_entries(gl_entries) # merge gl entries before adding pos entries @@ -1664,15 +1610,13 @@ def set_loyalty_program_tier(self): frappe.db.set_value("Customer", self.customer, "loyalty_program_tier", lp_details.tier_name) def get_returned_amount(self): - from frappe.query_builder.functions import Coalesce, Sum + from frappe.query_builder.functions import Sum doc = frappe.qb.DocType(self.doctype) returned_amount = ( frappe.qb.from_(doc) .select(Sum(doc.grand_total)) - .where( - (doc.docstatus == 1) & (doc.is_return == 1) & (Coalesce(doc.return_against, "") == self.name) - ) + .where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.name)) ).run() return abs(returned_amount[0][0]) if returned_amount[0][0] else 0 diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py index 0a765f3f46fd..fd95c1fe0e55 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py @@ -17,6 +17,9 @@ def get_data(): "Sales Order": ["items", "sales_order"], "Timesheet": ["timesheets", "time_sheet"], }, + "internal_and_external_links": { + "Delivery Note": ["items", "delivery_note"], + }, "transactions": [ { "label": _("Payment"), diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 856631ee6576..272382e8c182 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -7,7 +7,7 @@ import frappe from frappe.model.dynamic_links import get_dynamic_link_map from frappe.model.naming import make_autoname -from frappe.tests.utils import change_settings +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, flt, getdate, nowdate, today import erpnext @@ -38,13 +38,17 @@ from erpnext.stock.utils import get_incoming_rate, get_stock_balance -class TestSalesInvoice(unittest.TestCase): +class TestSalesInvoice(FrappeTestCase): def setUp(self): from erpnext.stock.doctype.stock_ledger_entry.test_stock_ledger_entry import create_items create_items(["_Test Internal Transfer Item"], uoms=[{"uom": "Box", "conversion_factor": 10}]) create_internal_parties() setup_accounts() + frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None) + + def tearDown(self): + frappe.db.rollback() def make(self): w = frappe.copy_doc(test_records[0]) @@ -172,6 +176,7 @@ def test_payment_entry_unlink_against_invoice(self): self.assertRaises(frappe.LinkExistsError, si.cancel) unlink_payment_on_cancel_of_invoice() + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_payment_entry_unlink_against_standalone_credit_note(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry @@ -1293,6 +1298,7 @@ def _insert_delivery_note(self): dn.submit() return dn + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_sales_invoice_with_advance(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import ( test_records as jv_test_records, @@ -1801,6 +1807,10 @@ def test_outstanding_amount_after_advance_jv_cancellation(self): ) def test_outstanding_amount_after_advance_payment_entry_cancellation(self): + """Test impact of advance PE submission/cancellation on SI and SO.""" + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + sales_order = make_sales_order(item_code="138-CMS Shoe", qty=1, price_list_rate=500) pe = frappe.get_doc( { "doctype": "Payment Entry", @@ -1820,10 +1830,25 @@ def test_outstanding_amount_after_advance_payment_entry_cancellation(self): "paid_to": "_Test Cash - _TC", } ) + pe.append( + "references", + { + "reference_doctype": "Sales Order", + "reference_name": sales_order.name, + "total_amount": sales_order.grand_total, + "outstanding_amount": sales_order.grand_total, + "allocated_amount": 300, + }, + ) pe.insert() pe.submit() + sales_order.reload() + self.assertEqual(sales_order.advance_paid, 300) + si = frappe.copy_doc(test_records[0]) + si.items[0].sales_order = sales_order.name + si.items[0].so_detail = sales_order.get("items")[0].name si.is_pos = 0 si.append( "advances", @@ -1831,6 +1856,7 @@ def test_outstanding_amount_after_advance_payment_entry_cancellation(self): "doctype": "Sales Invoice Advance", "reference_type": "Payment Entry", "reference_name": pe.name, + "reference_row": pe.references[0].name, "advance_amount": 300, "allocated_amount": 300, "remarks": pe.remarks, @@ -1839,7 +1865,13 @@ def test_outstanding_amount_after_advance_payment_entry_cancellation(self): si.insert() si.submit() - si.load_from_db() + si.reload() + pe.reload() + sales_order.reload() + + # Check if SO is unlinked/replaced by SI in PE & if SO advance paid is 0 + self.assertEqual(pe.references[0].reference_name, si.name) + self.assertEqual(sales_order.advance_paid, 0.0) # check outstanding after advance allocation self.assertEqual( @@ -1847,11 +1879,9 @@ def test_outstanding_amount_after_advance_payment_entry_cancellation(self): flt(si.rounded_total - si.total_advance, si.precision("outstanding_amount")), ) - # added to avoid Document has been modified exception - pe = frappe.get_doc("Payment Entry", pe.name) pe.cancel() + si.reload() - si.load_from_db() # check outstanding after advance cancellation self.assertEqual( flt(si.outstanding_amount), @@ -2049,28 +2079,27 @@ def test_rounding_adjustment_2(self): self.assertEqual(si.total_taxes_and_charges, 228.82) self.assertEqual(si.rounding_adjustment, -0.01) - expected_values = dict( - (d[0], d) - for d in [ - [si.debit_to, 1500, 0.0], - ["_Test Account Service Tax - _TC", 0.0, 114.41], - ["_Test Account VAT - _TC", 0.0, 114.41], - ["Sales - _TC", 0.0, 1271.18], - ] - ) + expected_values = [ + ["_Test Account Service Tax - _TC", 0.0, 114.41], + ["_Test Account VAT - _TC", 0.0, 114.41], + [si.debit_to, 1500, 0.0], + ["Round Off - _TC", 0.01, 0.01], + ["Sales - _TC", 0.0, 1271.18], + ] gl_entries = frappe.db.sql( - """select account, debit, credit + """select account, sum(debit) as debit, sum(credit) as credit from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s + group by account order by account asc""", si.name, as_dict=1, ) - for gle in gl_entries: - self.assertEqual(expected_values[gle.account][0], gle.account) - self.assertEqual(expected_values[gle.account][1], gle.debit) - self.assertEqual(expected_values[gle.account][2], gle.credit) + for i, gle in enumerate(gl_entries): + self.assertEqual(expected_values[i][0], gle.account) + self.assertEqual(expected_values[i][1], gle.debit) + self.assertEqual(expected_values[i][2], gle.credit) def test_rounding_adjustment_3(self): from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( @@ -2125,13 +2154,14 @@ def test_rounding_adjustment_3(self): ["_Test Account Service Tax - _TC", 0.0, 240.43], ["_Test Account VAT - _TC", 0.0, 240.43], ["Sales - _TC", 0.0, 4007.15], - ["Round Off - _TC", 0.01, 0], + ["Round Off - _TC", 0.02, 0.01], ] ) gl_entries = frappe.db.sql( - """select account, debit, credit + """select account, sum(debit) as debit, sum(credit) as credit from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s + group by account order by account asc""", si.name, as_dict=1, @@ -2322,7 +2352,7 @@ def test_deferred_revenue(self): item = create_item("_Test Item for Deferred Accounting") item.enable_deferred_revenue = 1 - item.deferred_revenue_account = deferred_account + item.item_defaults[0].deferred_revenue_account = deferred_account item.no_of_months = 12 item.save() @@ -2750,6 +2780,13 @@ def test_additional_discount_for_sales_invoice_with_discount_accounting_enabled( company="_Test Company", ) + tds_payable_account = create_account( + account_name="TDS Payable", + account_type="Tax", + parent_account="Duties and Taxes - _TC", + company="_Test Company", + ) + si = create_sales_invoice(parent_cost_center="Main - _TC", do_not_save=1) si.apply_discount_on = "Grand Total" si.additional_discount_account = additional_discount_account @@ -3048,8 +3085,8 @@ def test_sales_commission(self): si.commission_rate = commission_rate self.assertRaises(frappe.ValidationError, si.save) + @change_settings("Accounts Settings", {"acc_frozen_upto": add_days(getdate(), 1)}) def test_sales_invoice_submission_post_account_freezing_date(self): - frappe.db.set_value("Accounts Settings", None, "acc_frozen_upto", add_days(getdate(), 1)) si = create_sales_invoice(do_not_save=True) si.posting_date = add_days(getdate(), 1) si.save() @@ -3058,8 +3095,6 @@ def test_sales_invoice_submission_post_account_freezing_date(self): si.posting_date = getdate() si.submit() - frappe.db.set_value("Accounts Settings", None, "acc_frozen_upto", None) - def test_over_billing_case_against_delivery_note(self): """ Test a case where duplicating the item with qty = 1 in the invoice @@ -3088,6 +3123,13 @@ def test_over_billing_case_against_delivery_note(self): frappe.db.set_value("Accounts Settings", None, "over_billing_allowance", over_billing_allowance) + @change_settings( + "Accounts Settings", + { + "book_deferred_entries_via_journal_entry": 1, + "submit_journal_entries": 1, + }, + ) def test_multi_currency_deferred_revenue_via_journal_entry(self): deferred_account = create_account( account_name="Deferred Revenue", @@ -3095,14 +3137,9 @@ def test_multi_currency_deferred_revenue_via_journal_entry(self): company="_Test Company", ) - acc_settings = frappe.get_single("Accounts Settings") - acc_settings.book_deferred_entries_via_journal_entry = 1 - acc_settings.submit_journal_entries = 1 - acc_settings.save() - item = create_item("_Test Item for Deferred Accounting") item.enable_deferred_expense = 1 - item.deferred_revenue_account = deferred_account + item.item_defaults[0].deferred_revenue_account = deferred_account item.save() si = create_sales_invoice( @@ -3165,13 +3202,6 @@ def test_multi_currency_deferred_revenue_via_journal_entry(self): self.assertEqual(expected_gle[i][2], gle.debit) self.assertEqual(getdate(expected_gle[i][3]), gle.posting_date) - acc_settings = frappe.get_single("Accounts Settings") - acc_settings.book_deferred_entries_via_journal_entry = 0 - acc_settings.submit_journal_entries = 0 - acc_settings.save() - - frappe.db.set_value("Accounts Settings", None, "acc_frozen_upto", None) - def test_standalone_serial_no_return(self): si = create_sales_invoice( item_code="_Test Serialized Item With Series", update_stock=True, is_return=True, qty=-1 @@ -3213,17 +3243,10 @@ def test_sales_invoice_with_disabled_account(self): account.disabled = 0 account.save() + @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1}) def test_gain_loss_with_advance_entry(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry - unlink_enabled = frappe.db.get_value( - "Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice" - ) - - frappe.db.set_value( - "Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", 1 - ) - jv = make_journal_entry("_Test Receivable USD - _TC", "_Test Bank - _TC", -7000, save=False) jv.accounts[0].exchange_rate = 70 @@ -3256,17 +3279,28 @@ def test_gain_loss_with_advance_entry(self): ) si.save() si.submit() - expected_gle = [ - ["_Test Receivable USD - _TC", 7500.0, 500], - ["Exchange Gain/Loss - _TC", 500.0, 0.0], - ["Sales - _TC", 0.0, 7500.0], + ["_Test Receivable USD - _TC", 7500.0, 0.0, nowdate()], + ["Sales - _TC", 0.0, 7500.0, nowdate()], ] - check_gl_entries(self, si.name, expected_gle, nowdate()) - frappe.db.set_value( - "Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled + si.reload() + self.assertEqual(si.outstanding_amount, 0) + journals = frappe.db.get_all( + "Journal Entry Account", + filters={"reference_type": "Sales Invoice", "reference_name": si.name, "docstatus": 1}, + pluck="parent", + ) + journals = [x for x in journals if x != jv.name] + self.assertEqual(len(journals), 1) + je_type = frappe.get_cached_value("Journal Entry", journals[0], "voucher_type") + self.assertEqual(je_type, "Exchange Gain Or Loss") + ledger_outstanding = frappe.db.get_all( + "Payment Ledger Entry", + filters={"against_voucher_no": si.name, "delinked": 0}, + fields=["sum(amount), sum(amount_in_account_currency)"], + as_list=1, ) def test_batch_expiry_for_sales_invoice_return(self): @@ -3316,6 +3350,14 @@ def test_sales_invoice_with_payable_tax_account(self): ) self.assertRaises(frappe.ValidationError, si.submit) + @change_settings("Selling Settings", {"allow_negative_rates_for_items": 0}) + def test_sales_return_negative_rate(self): + si = create_sales_invoice(is_return=1, qty=-2, rate=-10, do_not_save=True) + self.assertRaises(frappe.ValidationError, si.save) + + si.items[0].rate = 10 + si.save() + def get_sales_invoice_for_e_invoice(): si = make_sales_invoice_for_ewaybill() diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index eb17daa282f7..89ba0c8055e6 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -4,6 +4,7 @@ import unittest import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils.data import ( add_days, add_months, @@ -90,10 +91,14 @@ def create_parties(): customer.insert() -class TestSubscription(unittest.TestCase): +class TestSubscription(FrappeTestCase): def setUp(self): create_plan() create_parties() + frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None) + + def tearDown(self): + frappe.db.rollback() def test_create_subscription_with_trial_with_correct_period(self): subscription = frappe.new_doc("Subscription") @@ -694,3 +699,23 @@ def test_multicurrency_subscription(self): # Check the currency of the created invoice currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].invoice, "currency") self.assertEqual(currency, "USD") + + def test_plan_rate_for_midmonth_start_date(self): + subscription = frappe.new_doc("Subscription") + subscription.party_type = "Supplier" + subscription.party = "_Test Supplier" + subscription.generate_invoice_at_period_start = 1 + subscription.follow_calendar_months = 1 + subscription.generate_new_invoices_past_due_date = 1 + subscription.start_date = "2023-04-08" + subscription.end_date = "2024-02-27" + subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) + subscription.save() + + subscription.process() + + self.assertEqual(len(subscription.invoices), 1) + pi = frappe.get_doc("Purchase Invoice", subscription.invoices[0].invoice) + self.assertEqual(pi.total, 55333.33) + + subscription.delete() diff --git a/erpnext/accounts/doctype/subscription_plan/subscription_plan.py b/erpnext/accounts/doctype/subscription_plan/subscription_plan.py index f3acdc5aa87f..75223c2ccca2 100644 --- a/erpnext/accounts/doctype/subscription_plan/subscription_plan.py +++ b/erpnext/accounts/doctype/subscription_plan/subscription_plan.py @@ -57,18 +57,17 @@ def get_plan_rate( prorate = frappe.db.get_single_value("Subscription Settings", "prorate") if prorate: - prorate_factor = flt( - date_diff(start_date, get_first_day(start_date)) - / date_diff(get_last_day(start_date), get_first_day(start_date)), - 1, - ) + cost -= plan.cost * get_prorate_factor(start_date, end_date) + return cost - prorate_factor += flt( - date_diff(get_last_day(end_date), end_date) - / date_diff(get_last_day(end_date), get_first_day(end_date)), - 1, - ) - cost -= plan.cost * prorate_factor +def get_prorate_factor(start_date, end_date): + total_days_to_skip = date_diff(start_date, get_first_day(start_date)) + total_days_in_month = int(get_last_day(start_date).strftime("%d")) + prorate_factor = flt(total_days_to_skip / total_days_in_month) - return cost + total_days_to_skip = date_diff(get_last_day(end_date), end_date) + total_days_in_month = int(get_last_day(end_date).strftime("%d")) + prorate_factor += flt(total_days_to_skip / total_days_in_month) + + return prorate_factor 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 58792d1d8ad9..943c0057f995 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -262,14 +262,20 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N if tax_deducted: net_total = inv.tax_withholding_net_total if ldc: - tax_amount = get_tds_amount_from_ldc(ldc, parties, tax_details, posting_date, net_total) + limit_consumed = get_limit_consumed(ldc, parties) + if is_valid_certificate(ldc, posting_date, limit_consumed): + tax_amount = get_lower_deduction_amount( + net_total, limit_consumed, ldc.certificate_limit, ldc.rate, tax_details + ) + else: + tax_amount = net_total * tax_details.rate / 100 else: - tax_amount = net_total * tax_details.rate / 100 if net_total > 0 else 0 + tax_amount = net_total * tax_details.rate / 100 # once tds is deducted, not need to add vouchers in the invoice voucher_wise_amount = {} else: - tax_amount = get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers) + tax_amount = get_tds_amount(ldc, parties, inv, tax_details, vouchers) elif party_type == "Customer": if tax_deducted: @@ -416,7 +422,7 @@ def get_deducted_tax(taxable_vouchers, tax_details): return sum(entries) -def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers): +def get_tds_amount(ldc, parties, inv, tax_details, vouchers): tds_amount = 0 invoice_filters = {"name": ("in", vouchers), "docstatus": 1, "apply_tds": 1} @@ -476,7 +482,12 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers): threshold = tax_details.get("threshold", 0) cumulative_threshold = tax_details.get("cumulative_threshold", 0) - if (threshold and inv.tax_withholding_net_total >= threshold) or ( + if inv.doctype != "Payment Entry": + tax_withholding_net_total = inv.base_tax_withholding_net_total + else: + tax_withholding_net_total = inv.tax_withholding_net_total + + if (threshold and tax_withholding_net_total >= threshold) or ( cumulative_threshold and supp_credit_amt >= cumulative_threshold ): if (cumulative_threshold and supp_credit_amt >= cumulative_threshold) and cint( @@ -491,15 +502,10 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers): net_total += inv.tax_withholding_net_total supp_credit_amt = net_total - cumulative_threshold - if ldc and is_valid_certificate( - ldc.valid_from, - ldc.valid_upto, - inv.get("posting_date") or inv.get("transaction_date"), - tax_deducted, - inv.tax_withholding_net_total, - ldc.certificate_limit, - ): - tds_amount = get_ltds_amount(supp_credit_amt, 0, ldc.certificate_limit, ldc.rate, tax_details) + if ldc and is_valid_certificate(ldc, inv.get("posting_date") or inv.get("transaction_date"), 0): + tds_amount = get_lower_deduction_amount( + supp_credit_amt, 0, ldc.certificate_limit, ldc.rate, tax_details + ) else: tds_amount = supp_credit_amt * tax_details.rate / 100 if supp_credit_amt > 0 else 0 @@ -577,8 +583,7 @@ def get_invoice_total_without_tcs(inv, tax_details): return inv.grand_total - tcs_tax_row_amount -def get_tds_amount_from_ldc(ldc, parties, tax_details, posting_date, net_total): - tds_amount = 0 +def get_limit_consumed(ldc, parties): limit_consumed = frappe.db.get_value( "Purchase Invoice", { @@ -592,37 +597,29 @@ def get_tds_amount_from_ldc(ldc, parties, tax_details, posting_date, net_total): "sum(tax_withholding_net_total)", ) - if is_valid_certificate( - ldc.valid_from, ldc.valid_upto, posting_date, limit_consumed, net_total, ldc.certificate_limit - ): - tds_amount = get_ltds_amount( - net_total, limit_consumed, ldc.certificate_limit, ldc.rate, tax_details - ) - - return tds_amount + return limit_consumed -def get_ltds_amount(current_amount, deducted_amount, certificate_limit, rate, tax_details): - if certificate_limit - flt(deducted_amount) - flt(current_amount) >= 0: +def get_lower_deduction_amount( + current_amount, limit_consumed, certificate_limit, rate, tax_details +): + if certificate_limit - flt(limit_consumed) - flt(current_amount) >= 0: return current_amount * rate / 100 else: - ltds_amount = certificate_limit - flt(deducted_amount) + ltds_amount = certificate_limit - flt(limit_consumed) tds_amount = current_amount - ltds_amount return ltds_amount * rate / 100 + tds_amount * tax_details.rate / 100 -def is_valid_certificate( - valid_from, valid_upto, posting_date, deducted_amount, current_amount, certificate_limit -): - valid = False - - available_amount = flt(certificate_limit) - flt(deducted_amount) - - if (getdate(valid_from) <= getdate(posting_date) <= getdate(valid_upto)) and available_amount > 0: - valid = True +def is_valid_certificate(ldc, posting_date, limit_consumed): + available_amount = flt(ldc.certificate_limit) - flt(limit_consumed) + if ( + getdate(ldc.valid_from) <= getdate(posting_date) <= getdate(ldc.valid_upto) + ) and available_amount > 0: + return True - return valid + return False def normal_round(number): diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index ac84217e6f97..0a749f966520 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -4,6 +4,7 @@ import unittest import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.tests.utils import change_settings from frappe.utils import today @@ -18,6 +19,7 @@ def setUpClass(self): # create relevant supplier, etc create_records() create_tax_withholding_category_records() + make_pan_no_field() def tearDown(self): cancel_invoices() @@ -321,6 +323,42 @@ def test_tds_calculation_on_net_total_partial_tds(self): for d in reversed(orders): d.cancel() + def test_tds_deduction_for_po_via_payment_entry(self): + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + + frappe.db.set_value( + "Supplier", "Test TDS Supplier8", "tax_withholding_category", "Cumulative Threshold TDS" + ) + order = create_purchase_order(supplier="Test TDS Supplier8", rate=40000, do_not_save=True) + + # Add some tax on the order + order.append( + "taxes", + { + "category": "Total", + "charge_type": "Actual", + "account_head": "_Test Account VAT - _TC", + "cost_center": "Main - _TC", + "tax_amount": 8000, + "description": "Test", + "add_deduct_tax": "Add", + }, + ) + + order.save() + + order.apply_tds = 1 + order.tax_withholding_category = "Cumulative Threshold TDS" + order.submit() + + self.assertEqual(order.taxes[0].tax_amount, 4000) + + payment = get_payment_entry(order.doctype, order.name) + payment.apply_tax_withholding_amount = 1 + payment.tax_withholding_category = "Cumulative Threshold TDS" + payment.submit() + self.assertEqual(payment.taxes[0].tax_amount, 4000) + def test_multi_category_single_supplier(self): frappe.db.set_value( "Supplier", "Test TDS Supplier5", "tax_withholding_category", "Test Service Category" @@ -420,6 +458,40 @@ def test_tax_withholding_via_payment_entry_for_advances(self): pe2.cancel() pe3.cancel() + def test_lower_deduction_certificate_application(self): + frappe.db.set_value( + "Supplier", + "Test LDC Supplier", + { + "tax_withholding_category": "Test Service Category", + "pan": "ABCTY1234D", + }, + ) + + create_lower_deduction_certificate( + supplier="Test LDC Supplier", + certificate_no="1AE0423AAJ", + tax_withholding_category="Test Service Category", + tax_rate=2, + limit=50000, + ) + + pi1 = create_purchase_invoice(supplier="Test LDC Supplier", rate=35000) + pi1.submit() + self.assertEqual(pi1.taxes[0].tax_amount, 700) + + pi2 = create_purchase_invoice(supplier="Test LDC Supplier", rate=35000) + pi2.submit() + self.assertEqual(pi2.taxes[0].tax_amount, 2300) + + pi3 = create_purchase_invoice(supplier="Test LDC Supplier", rate=35000) + pi3.submit() + self.assertEqual(pi3.taxes[0].tax_amount, 3500) + + pi1.cancel() + pi2.cancel() + pi3.cancel() + def cancel_invoices(): purchase_invoices = frappe.get_all( @@ -578,6 +650,8 @@ def create_records(): "Test TDS Supplier5", "Test TDS Supplier6", "Test TDS Supplier7", + "Test TDS Supplier8", + "Test LDC Supplier", ]: if frappe.db.exists("Supplier", name): continue @@ -774,3 +848,39 @@ def create_tax_withholding_category( "accounts": [{"company": "_Test Company", "account": account}], } ).insert() + + +def create_lower_deduction_certificate( + supplier, tax_withholding_category, tax_rate, certificate_no, limit +): + fiscal_year = get_fiscal_year(today(), company="_Test Company") + if not frappe.db.exists("Lower Deduction Certificate", certificate_no): + frappe.get_doc( + { + "doctype": "Lower Deduction Certificate", + "company": "_Test Company", + "supplier": supplier, + "certificate_no": certificate_no, + "tax_withholding_category": tax_withholding_category, + "fiscal_year": fiscal_year[0], + "valid_from": fiscal_year[1], + "valid_upto": fiscal_year[2], + "rate": tax_rate, + "certificate_limit": limit, + } + ).insert() + + +def make_pan_no_field(): + pan_field = { + "Supplier": [ + { + "fieldname": "pan", + "label": "PAN", + "fieldtype": "Data", + "translatable": 0, + } + ] + } + + create_custom_fields(pan_field, update=1) diff --git a/erpnext/accounts/doctype/unreconcile_payment_entries/__init__.py b/erpnext/accounts/doctype/unreconcile_payment_entries/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.json b/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.json new file mode 100644 index 000000000000..42da669e6500 --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.json @@ -0,0 +1,83 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-08-22 10:28:10.196712", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "account", + "party_type", + "party", + "reference_doctype", + "reference_name", + "allocated_amount", + "account_currency", + "unlinked" + ], + "fields": [ + { + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference Name", + "options": "reference_doctype" + }, + { + "fieldname": "allocated_amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Allocated Amount", + "options": "account_currency" + }, + { + "default": "0", + "fieldname": "unlinked", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Unlinked", + "read_only": 1 + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Reference Type", + "options": "DocType" + }, + { + "fieldname": "account", + "fieldtype": "Data", + "label": "Account" + }, + { + "fieldname": "party_type", + "fieldtype": "Data", + "label": "Party Type" + }, + { + "fieldname": "party", + "fieldtype": "Data", + "label": "Party" + }, + { + "fieldname": "account_currency", + "fieldtype": "Link", + "label": "Account Currency", + "options": "Currency", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-09-05 09:33:28.620149", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Unreconcile Payment Entries", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.py b/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.py new file mode 100644 index 000000000000..c41545c2685b --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payment_entries/unreconcile_payment_entries.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 UnreconcilePaymentEntries(Document): + pass diff --git a/erpnext/accounts/doctype/unreconcile_payments/__init__.py b/erpnext/accounts/doctype/unreconcile_payments/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/accounts/doctype/unreconcile_payments/test_unreconcile_payments.py b/erpnext/accounts/doctype/unreconcile_payments/test_unreconcile_payments.py new file mode 100644 index 000000000000..78e04bff8198 --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payments/test_unreconcile_payments.py @@ -0,0 +1,316 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today + +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin + + +class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.create_company() + self.create_customer() + self.create_usd_receivable_account() + self.create_item() + self.clear_old_entries() + + def tearDown(self): + frappe.db.rollback() + + def create_sales_invoice(self, do_not_submit=False): + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=do_not_submit, + ) + return si + + def create_payment_entry(self): + pe = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer, + paid_from=self.debit_to, + paid_to=self.cash, + paid_amount=200, + save=True, + ) + return pe + + def test_01_unreconcile_invoice(self): + si1 = self.create_sales_invoice() + si2 = self.create_sales_invoice() + + pe = self.create_payment_entry() + pe.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100}, + ) + pe.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100}, + ) + # Allocation payment against both invoices + pe.save().submit() + + # Assert outstanding + [doc.reload() for doc in [si1, si2, pe]] + self.assertEqual(si1.outstanding_amount, 0) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(pe.unallocated_amount, 0) + + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payments", + "company": self.company, + "voucher_type": pe.doctype, + "voucher_no": pe.name, + } + ) + unreconcile.add_references() + self.assertEqual(len(unreconcile.allocations), 2) + allocations = [x.reference_name for x in unreconcile.allocations] + self.assertEquals([si1.name, si2.name], allocations) + # unreconcile si1 + for x in unreconcile.allocations: + if x.reference_name != si1.name: + unreconcile.remove(x) + unreconcile.save().submit() + + # Assert outstanding + [doc.reload() for doc in [si1, si2, pe]] + self.assertEqual(si1.outstanding_amount, 100) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(len(pe.references), 1) + self.assertEqual(pe.unallocated_amount, 100) + + def test_02_unreconcile_one_payment_from_multi_payments(self): + """ + Scenario: 2 payments, both split against 2 different invoices + Unreconcile only one payment from one invoice + """ + si1 = self.create_sales_invoice() + si2 = self.create_sales_invoice() + pe1 = self.create_payment_entry() + pe1.paid_amount = 100 + # Allocate payment against both invoices + pe1.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50}, + ) + pe1.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50}, + ) + pe1.save().submit() + + pe2 = self.create_payment_entry() + pe2.paid_amount = 100 + # Allocate payment against both invoices + pe2.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50}, + ) + pe2.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50}, + ) + pe2.save().submit() + + # Assert outstanding and unallocated + [doc.reload() for doc in [si1, si2, pe1, pe2]] + self.assertEqual(si1.outstanding_amount, 0.0) + self.assertEqual(si2.outstanding_amount, 0.0) + self.assertEqual(pe1.unallocated_amount, 0.0) + self.assertEqual(pe2.unallocated_amount, 0.0) + + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payments", + "company": self.company, + "voucher_type": pe2.doctype, + "voucher_no": pe2.name, + } + ) + unreconcile.add_references() + self.assertEqual(len(unreconcile.allocations), 2) + allocations = [x.reference_name for x in unreconcile.allocations] + self.assertEquals([si1.name, si2.name], allocations) + # unreconcile si1 from pe2 + for x in unreconcile.allocations: + if x.reference_name != si1.name: + unreconcile.remove(x) + unreconcile.save().submit() + + # Assert outstanding and unallocated + [doc.reload() for doc in [si1, si2, pe1, pe2]] + self.assertEqual(si1.outstanding_amount, 50) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(len(pe1.references), 2) + self.assertEqual(len(pe2.references), 1) + self.assertEqual(pe1.unallocated_amount, 0) + self.assertEqual(pe2.unallocated_amount, 50) + + def test_03_unreconciliation_on_multi_currency_invoice(self): + self.create_customer("_Test MC Customer USD", "USD") + si1 = self.create_sales_invoice(do_not_submit=True) + si1.currency = "USD" + si1.debit_to = self.debtors_usd + si1.conversion_rate = 80 + si1.save().submit() + + si2 = self.create_sales_invoice(do_not_submit=True) + si2.currency = "USD" + si2.debit_to = self.debtors_usd + si2.conversion_rate = 80 + si2.save().submit() + + pe = self.create_payment_entry() + pe.paid_from = self.debtors_usd + pe.paid_from_account_currency = "USD" + pe.source_exchange_rate = 75 + pe.received_amount = 75 * 200 + pe.save() + # Allocate payment against both invoices + pe.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100}, + ) + pe.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100}, + ) + pe.save().submit() + + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payments", + "company": self.company, + "voucher_type": pe.doctype, + "voucher_no": pe.name, + } + ) + unreconcile.add_references() + self.assertEqual(len(unreconcile.allocations), 2) + allocations = [x.reference_name for x in unreconcile.allocations] + self.assertEquals([si1.name, si2.name], allocations) + # unreconcile si1 from pe + for x in unreconcile.allocations: + if x.reference_name != si1.name: + unreconcile.remove(x) + unreconcile.save().submit() + + # Assert outstanding and unallocated + [doc.reload() for doc in [si1, si2, pe]] + self.assertEqual(si1.outstanding_amount, 100) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(len(pe.references), 1) + self.assertEqual(pe.unallocated_amount, 100) + + # Exc gain/loss JE should've been cancelled as well + self.assertEqual( + frappe.db.count( + "Journal Entry Account", + filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1}, + ), + 0, + ) + + def test_04_unreconciliation_on_multi_currency_invoice(self): + """ + 2 payments split against 2 foreign currency invoices + """ + self.create_customer("_Test MC Customer USD", "USD") + si1 = self.create_sales_invoice(do_not_submit=True) + si1.currency = "USD" + si1.debit_to = self.debtors_usd + si1.conversion_rate = 80 + si1.save().submit() + + si2 = self.create_sales_invoice(do_not_submit=True) + si2.currency = "USD" + si2.debit_to = self.debtors_usd + si2.conversion_rate = 80 + si2.save().submit() + + pe1 = self.create_payment_entry() + pe1.paid_from = self.debtors_usd + pe1.paid_from_account_currency = "USD" + pe1.source_exchange_rate = 75 + pe1.received_amount = 75 * 100 + pe1.save() + # Allocate payment against both invoices + pe1.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50}, + ) + pe1.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50}, + ) + pe1.save().submit() + + pe2 = self.create_payment_entry() + pe2.paid_from = self.debtors_usd + pe2.paid_from_account_currency = "USD" + pe2.source_exchange_rate = 75 + pe2.received_amount = 75 * 100 + pe2.save() + # Allocate payment against both invoices + pe2.append( + "references", + {"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50}, + ) + pe2.append( + "references", + {"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50}, + ) + pe2.save().submit() + + unreconcile = frappe.get_doc( + { + "doctype": "Unreconcile Payments", + "company": self.company, + "voucher_type": pe2.doctype, + "voucher_no": pe2.name, + } + ) + unreconcile.add_references() + self.assertEqual(len(unreconcile.allocations), 2) + allocations = [x.reference_name for x in unreconcile.allocations] + self.assertEquals([si1.name, si2.name], allocations) + # unreconcile si1 from pe2 + for x in unreconcile.allocations: + if x.reference_name != si1.name: + unreconcile.remove(x) + unreconcile.save().submit() + + # Assert outstanding and unallocated + [doc.reload() for doc in [si1, si2, pe1, pe2]] + self.assertEqual(si1.outstanding_amount, 50) + self.assertEqual(si2.outstanding_amount, 0) + self.assertEqual(len(pe1.references), 2) + self.assertEqual(len(pe2.references), 1) + self.assertEqual(pe1.unallocated_amount, 0) + self.assertEqual(pe2.unallocated_amount, 50) + + # Exc gain/loss JE from PE1 should be available + self.assertEqual( + frappe.db.count( + "Journal Entry Account", + filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1}, + ), + 1, + ) diff --git a/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.js b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.js new file mode 100644 index 000000000000..c522567637fb --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.js @@ -0,0 +1,41 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Unreconcile Payments", { + refresh(frm) { + frm.set_query("voucher_type", function() { + return { + filters: { + name: ["in", ["Payment Entry", "Journal Entry"]] + } + } + }); + + + frm.set_query("voucher_no", function(doc) { + return { + filters: { + company: doc.company, + docstatus: 1 + } + } + }); + + }, + get_allocations: function(frm) { + frm.clear_table("allocations"); + frappe.call({ + method: "get_allocations_from_payment", + doc: frm.doc, + callback: function(r) { + if (r.message) { + r.message.forEach(x => { + frm.add_child("allocations", x) + }) + frm.refresh_fields(); + } + } + }) + + } +}); diff --git a/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.json b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.json new file mode 100644 index 000000000000..f29e61b6ef67 --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.json @@ -0,0 +1,93 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:UNREC-{#####}", + "creation": "2023-08-22 10:26:34.421423", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "voucher_type", + "voucher_no", + "get_allocations", + "allocations", + "amended_from" + ], + "fields": [ + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Unreconcile Payments", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "label": "Voucher Type", + "options": "DocType" + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "label": "Voucher No", + "options": "voucher_type" + }, + { + "fieldname": "get_allocations", + "fieldtype": "Button", + "label": "Get Allocations" + }, + { + "fieldname": "allocations", + "fieldtype": "Table", + "label": "Allocations", + "options": "Unreconcile Payment Entries" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2023-08-28 17:42:50.261377", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Unreconcile Payments", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "Accounts Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "read": 1, + "role": "Accounts User", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.py b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.py new file mode 100644 index 000000000000..4f9fb50d463c --- /dev/null +++ b/erpnext/accounts/doctype/unreconcile_payments/unreconcile_payments.py @@ -0,0 +1,158 @@ +# 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.query_builder import Criterion +from frappe.query_builder.functions import Abs, Sum +from frappe.utils.data import comma_and + +from erpnext.accounts.utils import ( + cancel_exchange_gain_loss_journal, + unlink_ref_doc_from_payment_entries, + update_voucher_outstanding, +) + + +class UnreconcilePayments(Document): + def validate(self): + self.supported_types = ["Payment Entry", "Journal Entry"] + if not self.voucher_type in self.supported_types: + frappe.throw(_("Only {0} are supported").format(comma_and(self.supported_types))) + + @frappe.whitelist() + def get_allocations_from_payment(self): + allocated_references = [] + ple = qb.DocType("Payment Ledger Entry") + allocated_references = ( + qb.from_(ple) + .select( + ple.account, + ple.party_type, + ple.party, + ple.against_voucher_type.as_("reference_doctype"), + ple.against_voucher_no.as_("reference_name"), + Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"), + ple.account_currency, + ) + .where( + (ple.docstatus == 1) + & (ple.voucher_type == self.voucher_type) + & (ple.voucher_no == self.voucher_no) + & (ple.voucher_no != ple.against_voucher_no) + ) + .groupby(ple.against_voucher_type, ple.against_voucher_no) + .run(as_dict=True) + ) + + return allocated_references + + def add_references(self): + allocations = self.get_allocations_from_payment() + + for alloc in allocations: + self.append("allocations", alloc) + + def on_submit(self): + # todo: more granular unreconciliation + for alloc in self.allocations: + doc = frappe.get_doc(alloc.reference_doctype, alloc.reference_name) + unlink_ref_doc_from_payment_entries(doc, self.voucher_no) + cancel_exchange_gain_loss_journal(doc, self.voucher_type, self.voucher_no) + update_voucher_outstanding( + alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party + ) + frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True) + + +@frappe.whitelist() +def doc_has_references(doctype: str = None, docname: str = None): + if doctype in ["Sales Invoice", "Purchase Invoice"]: + return frappe.db.count( + "Payment Ledger Entry", + filters={"delinked": 0, "against_voucher_no": docname, "amount": ["<", 0]}, + ) + else: + return frappe.db.count( + "Payment Ledger Entry", + filters={"delinked": 0, "voucher_no": docname, "against_voucher_no": ["!=", docname]}, + ) + + +@frappe.whitelist() +def get_linked_payments_for_doc( + company: str = None, doctype: str = None, docname: str = None +) -> list: + if company and doctype and docname: + _dt = doctype + _dn = docname + ple = qb.DocType("Payment Ledger Entry") + if _dt in ["Sales Invoice", "Purchase Invoice"]: + criteria = [ + (ple.company == company), + (ple.delinked == 0), + (ple.against_voucher_no == _dn), + (ple.amount < 0), + ] + + res = ( + qb.from_(ple) + .select( + ple.company, + ple.voucher_type, + ple.voucher_no, + Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"), + ple.account_currency, + ) + .where(Criterion.all(criteria)) + .groupby(ple.voucher_no, ple.against_voucher_no) + .having(qb.Field("allocated_amount") > 0) + .run(as_dict=True) + ) + return res + else: + criteria = [ + (ple.company == company), + (ple.delinked == 0), + (ple.voucher_no == _dn), + (ple.against_voucher_no != _dn), + ] + + query = ( + qb.from_(ple) + .select( + ple.company, + ple.against_voucher_type.as_("voucher_type"), + ple.against_voucher_no.as_("voucher_no"), + Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"), + ple.account_currency, + ) + .where(Criterion.all(criteria)) + .groupby(ple.against_voucher_no) + ) + res = query.run(as_dict=True) + return res + return [] + + +@frappe.whitelist() +def create_unreconcile_doc_for_selection(selections=None): + if selections: + selections = frappe.json.loads(selections) + # assuming each row is a unique voucher + for row in selections: + unrecon = frappe.new_doc("Unreconcile Payments") + unrecon.company = row.get("company") + unrecon.voucher_type = row.get("voucher_type") + unrecon.voucher_no = row.get("voucher_no") + unrecon.add_references() + + # remove unselected references + unrecon.allocations = [ + x + for x in unrecon.allocations + if x.reference_doctype == row.get("against_voucher_type") + and x.reference_name == row.get("against_voucher_no") + ] + unrecon.save().submit() diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 16b61236dbaf..ed6d9dbe026e 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -28,6 +28,7 @@ def make_gl_entries( ): if gl_map: if not cancel: + make_acc_dimensions_offsetting_entry(gl_map) validate_accounting_period(gl_map) validate_disabled_accounts(gl_map) gl_map = process_gl_map(gl_map, merge_entries) @@ -51,6 +52,63 @@ def make_gl_entries( make_reverse_gl_entries(gl_map, adv_adj=adv_adj, update_outstanding=update_outstanding) +def make_acc_dimensions_offsetting_entry(gl_map): + accounting_dimensions_to_offset = get_accounting_dimensions_for_offsetting_entry( + gl_map, gl_map[0].company + ) + no_of_dimensions = len(accounting_dimensions_to_offset) + if no_of_dimensions == 0: + return + + offsetting_entries = [] + + for gle in gl_map: + for dimension in accounting_dimensions_to_offset: + offsetting_entry = gle.copy() + debit = flt(gle.credit) / no_of_dimensions if gle.credit != 0 else 0 + credit = flt(gle.debit) / no_of_dimensions if gle.debit != 0 else 0 + offsetting_entry.update( + { + "account": dimension.offsetting_account, + "debit": debit, + "credit": credit, + "debit_in_account_currency": debit, + "credit_in_account_currency": credit, + "remarks": _("Offsetting for Accounting Dimension") + " - {0}".format(dimension.name), + "against_voucher": None, + } + ) + offsetting_entry["against_voucher_type"] = None + offsetting_entries.append(offsetting_entry) + + gl_map += offsetting_entries + + +def get_accounting_dimensions_for_offsetting_entry(gl_map, company): + acc_dimension = frappe.qb.DocType("Accounting Dimension") + dimension_detail = frappe.qb.DocType("Accounting Dimension Detail") + + acc_dimensions = ( + frappe.qb.from_(acc_dimension) + .inner_join(dimension_detail) + .on(acc_dimension.name == dimension_detail.parent) + .select(acc_dimension.fieldname, acc_dimension.name, dimension_detail.offsetting_account) + .where( + (acc_dimension.disabled == 0) + & (dimension_detail.company == company) + & (dimension_detail.automatically_post_balancing_accounting_entry == 1) + ) + ).run(as_dict=True) + + accounting_dimensions_to_offset = [] + for acc_dimension in acc_dimensions: + values = set([entry.get(acc_dimension.fieldname) for entry in gl_map]) + if len(values) > 1: + accounting_dimensions_to_offset.append(acc_dimension) + + return accounting_dimensions_to_offset + + def validate_disabled_accounts(gl_map): accounts = [d.account for d in gl_map if d.account] @@ -105,7 +163,8 @@ def process_gl_map(gl_map, merge_entries=True, precision=None): if not gl_map: return [] - gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision) + if gl_map[0].voucher_type != "Period Closing Voucher": + gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision) if merge_entries: gl_map = merge_similar_entries(gl_map, precision) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 56d33b758b7f..150b56742e43 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -6,14 +6,10 @@ import frappe from frappe import _, msgprint, scrub -from frappe.contacts.doctype.address.address import ( - get_address_display, - get_company_address, - get_default_address, -) -from frappe.contacts.doctype.contact.contact import get_contact_details +from frappe.contacts.doctype.address.address import get_company_address, get_default_address from frappe.core.doctype.user_permission.user_permission import get_permitted_documents from frappe.model.utils import get_fetch_values +from frappe.query_builder.functions import Abs, Date, Sum from frappe.utils import ( add_days, add_months, @@ -132,6 +128,7 @@ def _get_party_details( party_address, company_address, shipping_address, + ignore_permissions=ignore_permissions, ) set_contact_details(party_details, party, party_type) set_other_values(party_details, party, party_type) @@ -192,6 +189,8 @@ def set_address_details( party_address=None, company_address=None, shipping_address=None, + *, + ignore_permissions=False ): billing_address_field = ( "customer_address" if party_type == "Lead" else party_type.lower() + "_address" @@ -204,13 +203,17 @@ def set_address_details( get_fetch_values(doctype, billing_address_field, party_details[billing_address_field]) ) # address display - party_details.address_display = get_address_display(party_details[billing_address_field]) + party_details.address_display = render_address( + party_details[billing_address_field], check_permissions=not ignore_permissions + ) # shipping address if party_type in ["Customer", "Lead"]: party_details.shipping_address_name = shipping_address or get_party_shipping_address( party_type, party.name ) - party_details.shipping_address = get_address_display(party_details["shipping_address_name"]) + party_details.shipping_address = render_address( + party_details["shipping_address_name"], check_permissions=not ignore_permissions + ) if doctype: party_details.update( get_fetch_values(doctype, "shipping_address_name", party_details.shipping_address_name) @@ -228,7 +231,7 @@ def set_address_details( if shipping_address: party_details.update( shipping_address=shipping_address, - shipping_address_display=get_address_display(shipping_address), + shipping_address_display=render_address(shipping_address), **get_fetch_values(doctype, "shipping_address", shipping_address) ) @@ -237,7 +240,8 @@ def set_address_details( party_details.update( billing_address=party_details.company_address, billing_address_display=( - party_details.company_address_display or get_address_display(party_details.company_address) + party_details.company_address_display + or render_address(party_details.company_address, check_permissions=False) ), **get_fetch_values(doctype, "billing_address", party_details.company_address) ) @@ -289,7 +293,34 @@ def set_contact_details(party_details, party, party_type): } ) else: - party_details.update(get_contact_details(party_details.contact_person)) + fields = [ + "name as contact_person", + "salutation", + "first_name", + "last_name", + "email_id as contact_email", + "mobile_no as contact_mobile", + "phone as contact_phone", + "designation as contact_designation", + "department as contact_department", + ] + + contact_details = frappe.db.get_value( + "Contact", party_details.contact_person, fields, as_dict=True + ) + + contact_details.contact_display = " ".join( + filter( + None, + [ + contact_details.get("salutation"), + contact_details.get("first_name"), + contact_details.get("last_name"), + ], + ) + ) + + party_details.update(contact_details) def set_other_values(party_details, party, party_type): @@ -885,30 +916,32 @@ def get_party_shipping_address(doctype: str, name: str) -> Optional[str]: def get_partywise_advanced_payment_amount( party_type, posting_date=None, future_payment=0, company=None, party=None ): - cond = "1=1" + ple = frappe.qb.DocType("Payment Ledger Entry") + query = ( + frappe.qb.from_(ple) + .select(ple.party, Abs(Sum(ple.amount).as_("amount"))) + .where( + (ple.party_type.isin(party_type)) + & (ple.amount < 0) + & (ple.against_voucher_no == ple.voucher_no) + & (ple.delinked == 0) + ) + .groupby(ple.party) + ) + if posting_date: if future_payment: - cond = "(posting_date <= '{0}' OR DATE(creation) <= '{0}')" "".format(posting_date) + query = query.where((ple.posting_date <= posting_date) | (Date(ple.creation) <= posting_date)) else: - cond = "posting_date <= '{0}'".format(posting_date) + query = query.where(ple.posting_date <= posting_date) if company: - cond += "and company = {0}".format(frappe.db.escape(company)) + query = query.where(ple.company == company) if party: - cond += "and party = {0}".format(frappe.db.escape(party)) - - data = frappe.db.sql( - """ SELECT party, sum({0}) as amount - FROM `tabGL Entry` - WHERE - party_type = %s and against_voucher is null - and is_cancelled = 0 - and {1} GROUP BY party""".format( - ("credit") if party_type == "Customer" else "debit", cond - ), - party_type, - ) + query = query.where(ple.party == party) + + data = query.run() if data: return frappe._dict(data) @@ -954,3 +987,13 @@ def add_party_account(party_type, party, company, account): doc.append("accounts", accounts) doc.save() + + +def render_address(address, check_permissions=True): + try: + from frappe.contacts.doctype.address.address import render_address as _render + except ImportError: + # Older frappe versions where this function is not available + from frappe.contacts.doctype.address.address import get_address_display as _render + + return frappe.call(_render, address, check_permissions=check_permissions) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index e1a30a4b77e0..9c73cbb344f6 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -37,24 +37,6 @@ frappe.query_reports["Accounts Payable"] = { } } }, - { - "fieldname": "supplier", - "label": __("Supplier"), - "fieldtype": "Link", - "options": "Supplier", - on_change: () => { - var supplier = frappe.query_report.get_filter_value('supplier'); - if (supplier) { - frappe.db.get_value('Supplier', supplier, "tax_id", function(value) { - frappe.query_report.set_filter_value('tax_id', value["tax_id"]); - }); - } else { - frappe.query_report.set_filter_value('tax_id', ""); - } - - frappe.query_report.refresh(); - } - }, { "fieldname": "party_account", "label": __("Payable Account"), @@ -112,11 +94,35 @@ frappe.query_reports["Accounts Payable"] = { "fieldtype": "Link", "options": "Payment Terms Template" }, + { + "fieldname":"party_type", + "label": __("Party Type"), + "fieldtype": "Autocomplete", + options: get_party_type_options(), + on_change: function() { + frappe.query_report.set_filter_value('party', ""); + frappe.query_report.toggle_filter_display('supplier_group', frappe.query_report.get_filter_value('party_type') !== "Supplier"); + } + }, + { + "fieldname":"party", + "label": __("Party"), + "fieldtype": "MultiSelectList", + get_data: function(txt) { + if (!frappe.query_report.filters) return; + + let party_type = frappe.query_report.get_filter_value('party_type'); + if (!party_type) return; + + return frappe.db.get_link_options(party_type, txt); + }, + }, { "fieldname": "supplier_group", "label": __("Supplier Group"), "fieldtype": "Link", - "options": "Supplier Group" + "options": "Supplier Group", + "hidden": 1 }, { "fieldname": "group_by_party", @@ -133,12 +139,6 @@ frappe.query_reports["Accounts Payable"] = { "label": __("Show Remarks"), "fieldtype": "Check", }, - { - "fieldname": "tax_id", - "label": __("Tax Id"), - "fieldtype": "Data", - "hidden": 1 - }, { "fieldname": "show_future_payments", "label": __("Show Future Payments"), @@ -164,3 +164,15 @@ frappe.query_reports["Accounts Payable"] = { } erpnext.utils.add_dimensions('Accounts Payable', 9); + +function get_party_type_options() { + let options = []; + frappe.db.get_list( + "Party Type", {filters:{"account_type": "Payable"}, fields:['name']} + ).then((res) => { + res.forEach((party_type) => { + options.push(party_type.name); + }); + }); + return options; +} \ No newline at end of file diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.py b/erpnext/accounts/report/accounts_payable/accounts_payable.py index 7b1999491130..8279afbc2bc2 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.py +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.py @@ -7,7 +7,7 @@ def execute(filters=None): args = { - "party_type": "Supplier", + "account_type": "Payable", "naming_by": ["Buying Settings", "supp_master_name"], } return ReceivablePayableReport(filters).run(args) diff --git a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py new file mode 100644 index 000000000000..9f03d92cd508 --- /dev/null +++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py @@ -0,0 +1,67 @@ +import unittest + +import frappe +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import add_days, flt, getdate, today + +from erpnext import get_default_cost_center +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.report.accounts_payable.accounts_payable import execute +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + +class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.create_company() + self.create_customer() + self.create_item() + self.create_supplier(currency="USD", supplier_name="Test Supplier2") + self.create_usd_payable_account() + + def tearDown(self): + frappe.db.rollback() + + def test_accounts_payable_for_foreign_currency_supplier(self): + pi = self.create_purchase_invoice(do_not_submit=True) + pi.currency = "USD" + pi.conversion_rate = 80 + pi.credit_to = self.creditors_usd + pi = pi.save().submit() + + filters = { + "company": self.company, + "party_type": "Supplier", + "party": [self.supplier], + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + } + + data = execute(filters) + self.assertEqual(data[1][0].get("outstanding"), 300) + self.assertEqual(data[1][0].get("currency"), "USD") + + def create_purchase_invoice(self, do_not_submit=False): + frappe.set_user("Administrator") + pi = make_purchase_invoice( + item=self.item, + company=self.company, + supplier=self.supplier, + is_return=False, + update_stock=False, + posting_date=frappe.utils.datetime.date(2021, 5, 1), + do_not_save=1, + rate=300, + price_list_rate=300, + qty=1, + ) + + pi = pi.save() + if not do_not_submit: + pi = pi.submit() + return pi diff --git a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js index ea200720dff9..9e575e669d22 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js @@ -72,10 +72,27 @@ frappe.query_reports["Accounts Payable Summary"] = { } }, { - "fieldname":"supplier", - "label": __("Supplier"), - "fieldtype": "Link", - "options": "Supplier" + "fieldname":"party_type", + "label": __("Party Type"), + "fieldtype": "Autocomplete", + options: get_party_type_options(), + on_change: function() { + frappe.query_report.set_filter_value('party', ""); + frappe.query_report.toggle_filter_display('supplier_group', frappe.query_report.get_filter_value('party_type') !== "Supplier"); + } + }, + { + "fieldname":"party", + "label": __("Party"), + "fieldtype": "MultiSelectList", + get_data: function(txt) { + if (!frappe.query_report.filters) return; + + let party_type = frappe.query_report.get_filter_value('party_type'); + if (!party_type) return; + + return frappe.db.get_link_options(party_type, txt); + }, }, { "fieldname":"payment_terms_template", @@ -105,3 +122,15 @@ frappe.query_reports["Accounts Payable Summary"] = { } erpnext.utils.add_dimensions('Accounts Payable Summary', 9); + +function get_party_type_options() { + let options = []; + frappe.db.get_list( + "Party Type", {filters:{"account_type": "Payable"}, fields:['name']} + ).then((res) => { + res.forEach((party_type) => { + options.push(party_type.name); + }); + }); + return options; +} \ No newline at end of file diff --git a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.py b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.py index 65fe1de5689d..834c83c38e9a 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.py +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.py @@ -9,7 +9,7 @@ def execute(filters=None): args = { - "party_type": "Supplier", + "account_type": "Payable", "naming_by": ["Buying Settings", "supp_master_name"], } return AccountsReceivableSummary(filters).run(args) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index 0b4e577f6cb3..1073be0bdc40 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -1,6 +1,8 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt +frappe.provide("erpnext.utils"); + frappe.query_reports["Accounts Receivable"] = { "filters": [ { @@ -38,34 +40,28 @@ frappe.query_reports["Accounts Receivable"] = { } }, { - "fieldname": "customer", - "label": __("Customer"), - "fieldtype": "Link", - "options": "Customer", - on_change: () => { - var customer = frappe.query_report.get_filter_value('customer'); - var company = frappe.query_report.get_filter_value('company'); - if (customer) { - frappe.db.get_value('Customer', customer, ["tax_id", "customer_name", "payment_terms"], function(value) { - frappe.query_report.set_filter_value('tax_id', value["tax_id"]); - frappe.query_report.set_filter_value('customer_name', value["customer_name"]); - frappe.query_report.set_filter_value('payment_terms', value["payment_terms"]); - }); - - frappe.db.get_value('Customer Credit Limit', {'parent': customer, 'company': company}, - ["credit_limit"], function(value) { - if (value) { - frappe.query_report.set_filter_value('credit_limit', value["credit_limit"]); - } - }, "Customer"); - } else { - frappe.query_report.set_filter_value('tax_id', ""); - frappe.query_report.set_filter_value('customer_name', ""); - frappe.query_report.set_filter_value('credit_limit', ""); - frappe.query_report.set_filter_value('payment_terms', ""); - } + "fieldname":"party_type", + "label": __("Party Type"), + "fieldtype": "Autocomplete", + options: get_party_type_options(), + on_change: function() { + frappe.query_report.set_filter_value('party', ""); + frappe.query_report.toggle_filter_display('customer_group', frappe.query_report.get_filter_value('party_type') !== "Customer"); } }, + { + "fieldname":"party", + "label": __("Party"), + "fieldtype": "MultiSelectList", + get_data: function(txt) { + if (!frappe.query_report.filters) return; + + let party_type = frappe.query_report.get_filter_value('party_type'); + if (!party_type) return; + + return frappe.db.get_link_options(party_type, txt); + }, + }, { "fieldname": "party_account", "label": __("Receivable Account"), @@ -172,34 +168,10 @@ frappe.query_reports["Accounts Receivable"] = { "label": __("Show Sales Person"), "fieldtype": "Check", }, - { - "fieldname": "tax_id", - "label": __("Tax Id"), - "fieldtype": "Data", - "hidden": 1 - }, { "fieldname": "show_remarks", "label": __("Show Remarks"), "fieldtype": "Check", - }, - { - "fieldname": "customer_name", - "label": __("Customer Name"), - "fieldtype": "Data", - "hidden": 1 - }, - { - "fieldname": "payment_terms", - "label": __("Payment Tems"), - "fieldtype": "Data", - "hidden": 1 - }, - { - "fieldname": "credit_limit", - "label": __("Credit Limit"), - "fieldtype": "Currency", - "hidden": 1 } ], @@ -221,3 +193,16 @@ frappe.query_reports["Accounts Receivable"] = { } erpnext.utils.add_dimensions('Accounts Receivable', 9); + + +function get_party_type_options() { + let options = []; + frappe.db.get_list( + "Party Type", {filters:{"account_type": "Receivable"}, fields:['name']} + ).then((res) => { + res.forEach((party_type) => { + options.push(party_type.name); + }); + }); + return options; +} \ No newline at end of file diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 30f7fb38c5f9..b9c7a0bfb877 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -7,7 +7,7 @@ import frappe from frappe import _, qb, scrub from frappe.query_builder import Criterion -from frappe.query_builder.functions import Date +from frappe.query_builder.functions import Date, Sum from frappe.utils import cint, cstr, flt, getdate, nowdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( @@ -34,7 +34,7 @@ def execute(filters=None): args = { - "party_type": "Customer", + "account_type": "Receivable", "naming_by": ["Selling Settings", "cust_master_name"], } return ReceivablePayableReport(filters).run(args) @@ -70,8 +70,11 @@ def set_defaults(self): "Company", self.filters.get("company"), "default_currency" ) self.currency_precision = get_currency_precision() or 2 - self.dr_or_cr = "debit" if self.filters.party_type == "Customer" else "credit" - self.party_type = self.filters.party_type + self.dr_or_cr = "debit" if self.filters.account_type == "Receivable" else "credit" + self.account_type = self.filters.account_type + self.party_type = frappe.db.get_all( + "Party Type", {"account_type": self.account_type}, pluck="name" + ) self.party_details = {} self.invoices = set() self.skip_total_row = 0 @@ -113,7 +116,7 @@ def init_voucher_balance(self): # build all keys, since we want to exclude vouchers beyond the report date for ple in self.ple_entries: # get the balance object for voucher_type - key = (ple.voucher_type, ple.voucher_no, ple.party) + key = (ple.account, ple.voucher_type, ple.voucher_no, ple.party) if not key in self.voucher_balance: self.voucher_balance[key] = frappe._dict( voucher_type=ple.voucher_type, @@ -180,7 +183,7 @@ def get_voucher_balance(self, ple): ): return - key = (ple.against_voucher_type, ple.against_voucher_no, ple.party) + key = (ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party) # If payment is made against credit note # and credit note is made against a Sales Invoice @@ -189,14 +192,15 @@ def get_voucher_balance(self, ple): if ple.against_voucher_no in self.return_entries: return_against = self.return_entries.get(ple.against_voucher_no) if return_against: - key = (ple.against_voucher_type, return_against, ple.party) + key = (ple.account, ple.against_voucher_type, return_against, ple.party) row = self.voucher_balance.get(key) if not row: # no invoice, this is an invoice / stand-alone payment / credit note - row = self.voucher_balance.get((ple.voucher_type, ple.voucher_no, ple.party)) + row = self.voucher_balance.get((ple.account, ple.voucher_type, ple.voucher_no, ple.party)) + row.party_type = ple.party_type return row def update_voucher_balance(self, ple): @@ -207,7 +211,7 @@ def update_voucher_balance(self, ple): return # amount in "Party Currency", if its supplied. If not, amount in company currency - if self.filters.get(scrub(self.party_type)): + if self.filters.get("party_type") and self.filters.get("party"): amount = ple.amount_in_account_currency else: amount = ple.amount @@ -362,7 +366,7 @@ def build_delivery_note_map(self): def get_invoice_details(self): self.invoice_details = frappe._dict() - if self.party_type == "Customer": + if self.account_type == "Receivable": si_list = frappe.db.sql( """ select name, due_date, po_no @@ -390,7 +394,7 @@ def get_invoice_details(self): d.sales_person ) - if self.party_type == "Supplier": + if self.account_type == "Payable": for pi in frappe.db.sql( """ select name, due_date, bill_no, bill_date @@ -421,7 +425,8 @@ def set_party_details(self, row): # customer / supplier name party_details = self.get_party_details(row.party) or {} row.update(party_details) - if self.filters.get(scrub(self.filters.party_type)): + + if self.filters.get("party_type") and self.filters.get("party"): row.currency = row.account_currency else: row.currency = self.company_currency @@ -429,12 +434,11 @@ def set_party_details(self, row): def allocate_outstanding_based_on_payment_terms(self, row): self.get_payment_terms(row) for term in row.payment_terms: - - # update "paid" and "oustanding" for this term + # update "paid" and "outstanding" for this term if not term.paid: self.allocate_closing_to_term(row, term, "paid") - # update "credit_note" and "oustanding" for this term + # update "credit_note" and "outstanding" for this term if term.outstanding: self.allocate_closing_to_term(row, term, "credit_note") @@ -446,7 +450,8 @@ def get_payment_terms(self, row): """ select si.name, si.party_account_currency, si.currency, si.conversion_rate, - ps.due_date, ps.payment_term, ps.payment_amount, ps.description, ps.paid_amount, ps.discounted_amount + si.total_advance, ps.due_date, ps.payment_term, ps.payment_amount, ps.base_payment_amount, + ps.description, ps.paid_amount, ps.discounted_amount from `tab{0}` si, `tabPayment Schedule` ps where si.name = ps.parent and @@ -462,6 +467,14 @@ def get_payment_terms(self, row): original_row = frappe._dict(row) row.payment_terms = [] + # Cr Note's don't have Payment Terms + if not payment_terms_details: + return + + # Advance allocated during invoicing is not considered in payment terms + # Deduct that from paid amount pre allocation + row.paid -= flt(payment_terms_details[0].total_advance) + # If no or single payment terms, no need to split the row if len(payment_terms_details) <= 1: return @@ -476,7 +489,7 @@ def append_payment_term(self, row, d, term): ) and d.currency == d.party_account_currency: invoiced = d.payment_amount else: - invoiced = flt(flt(d.payment_amount) * flt(d.conversion_rate), self.currency_precision) + invoiced = d.base_payment_amount row.payment_terms.append( term.update( @@ -532,65 +545,67 @@ def get_future_payments(self): self.future_payments.setdefault((d.invoice_no, d.party), []).append(d) def get_future_payments_from_payment_entry(self): - return frappe.db.sql( - """ - select - ref.reference_name as invoice_no, - payment_entry.party, - payment_entry.party_type, - payment_entry.posting_date as future_date, - ref.allocated_amount as future_amount, - payment_entry.reference_no as future_ref - from - `tabPayment Entry` as payment_entry inner join `tabPayment Entry Reference` as ref - on - (ref.parent = payment_entry.name) - where - payment_entry.docstatus < 2 - and payment_entry.posting_date > %s - and payment_entry.party_type = %s - """, - (self.filters.report_date, self.party_type), - as_dict=1, - ) - - def get_future_payments_from_journal_entry(self): - if self.filters.get("party"): - amount_field = ( - "jea.debit_in_account_currency - jea.credit_in_account_currency" - if self.party_type == "Supplier" - else "jea.credit_in_account_currency - jea.debit_in_account_currency" + pe = frappe.qb.DocType("Payment Entry") + pe_ref = frappe.qb.DocType("Payment Entry Reference") + return ( + frappe.qb.from_(pe) + .inner_join(pe_ref) + .on(pe_ref.parent == pe.name) + .select( + (pe_ref.reference_name).as_("invoice_no"), + pe.party, + pe.party_type, + (pe.posting_date).as_("future_date"), + (pe_ref.allocated_amount).as_("future_amount"), + (pe.reference_no).as_("future_ref"), ) - else: - amount_field = "jea.debit - " if self.party_type == "Supplier" else "jea.credit" + .where( + (pe.docstatus < 2) + & (pe.posting_date > self.filters.report_date) + & (pe.party_type.isin(self.party_type)) + ) + ).run(as_dict=True) - return frappe.db.sql( - """ - select - jea.reference_name as invoice_no, + def get_future_payments_from_journal_entry(self): + je = frappe.qb.DocType("Journal Entry") + jea = frappe.qb.DocType("Journal Entry Account") + query = ( + frappe.qb.from_(je) + .inner_join(jea) + .on(jea.parent == je.name) + .select( + jea.reference_name.as_("invoice_no"), jea.party, jea.party_type, - je.posting_date as future_date, - sum('{0}') as future_amount, - je.cheque_no as future_ref - from - `tabJournal Entry` as je inner join `tabJournal Entry Account` as jea - on - (jea.parent = je.name) - where - je.docstatus < 2 - and je.posting_date > %s - and jea.party_type = %s - and jea.reference_name is not null and jea.reference_name != '' - group by je.name, jea.reference_name - having future_amount > 0 - """.format( - amount_field - ), - (self.filters.report_date, self.party_type), - as_dict=1, + je.posting_date.as_("future_date"), + je.cheque_no.as_("future_ref"), + ) + .where( + (je.docstatus < 2) + & (je.posting_date > self.filters.report_date) + & (jea.party_type.isin(self.party_type)) + & (jea.reference_name.isnotnull()) + & (jea.reference_name != "") + ) ) + if self.filters.get("party"): + if self.account_type == "Payable": + query = query.select( + Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("future_amount") + ) + else: + query = query.select( + Sum(jea.credit_in_account_currency - jea.debit_in_account_currency).as_("future_amount") + ) + else: + query = query.select( + Sum(jea.debit if self.account_type == "Payable" else jea.credit).as_("future_amount") + ) + + query = query.having(qb.Field("future_amount") > 0) + return query.run(as_dict=True) + def allocate_future_payments(self, row): # future payments are captured in additional columns # this method allocates pending future payments against a voucher to @@ -619,13 +634,17 @@ def allocate_future_payments(self, row): row.future_ref = ", ".join(row.future_ref) def get_return_entries(self): - doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" + doctype = "Sales Invoice" if self.account_type == "Receivable" else "Purchase Invoice" filters = {"is_return": 1, "docstatus": 1, "company": self.filters.company} - party_field = scrub(self.filters.party_type) - if self.filters.get(party_field): - filters.update({party_field: self.filters.get(party_field)}) + or_filters = {} + for party_type in self.party_type: + party_field = scrub(party_type) + if self.filters.get(party_field): + or_filters.update({party_field: self.filters.get(party_field)}) self.return_entries = frappe._dict( - frappe.get_all(doctype, filters, ["name", "return_against"], as_list=1) + frappe.get_all( + doctype, filters=filters, or_filters=or_filters, fields=["name", "return_against"], as_list=1 + ) ) def set_ageing(self, row): @@ -716,6 +735,7 @@ def get_ple_entries(self): ) .where(ple.delinked == 0) .where(Criterion.all(self.qb_selection_filter)) + .where(Criterion.any(self.or_filters)) ) if self.filters.get("group_by_party"): @@ -746,16 +766,16 @@ def get_sales_invoices_or_customers_based_on_sales_person(self): def prepare_conditions(self): self.qb_selection_filter = [] - party_type_field = scrub(self.party_type) - self.qb_selection_filter.append(self.ple.party_type == self.party_type) + self.or_filters = [] - self.add_common_filters(party_type_field=party_type_field) + for party_type in self.party_type: + self.add_common_filters() - if party_type_field == "customer": - self.add_customer_filters() + if self.account_type == "Receivable": + self.add_customer_filters() - elif party_type_field == "supplier": - self.add_supplier_filters() + elif self.account_type == "Payable": + self.add_supplier_filters() if self.filters.cost_center: self.get_cost_center_conditions() @@ -770,25 +790,27 @@ def get_cost_center_conditions(self): ] self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list)) - def add_common_filters(self, party_type_field): + def add_common_filters(self): if self.filters.company: self.qb_selection_filter.append(self.ple.company == self.filters.company) if self.filters.finance_book: self.qb_selection_filter.append(self.ple.finance_book == self.filters.finance_book) - if self.filters.get(party_type_field): - self.qb_selection_filter.append(self.ple.party == self.filters.get(party_type_field)) + if self.filters.get("party_type"): + self.qb_selection_filter.append(self.filters.party_type == self.ple.party_type) + + if self.filters.get("party"): + self.qb_selection_filter.append(self.ple.party.isin(self.filters.party)) if self.filters.party_account: self.qb_selection_filter.append(self.ple.account == self.filters.party_account) else: # get GL with "receivable" or "payable" account_type - account_type = "Receivable" if self.party_type == "Customer" else "Payable" accounts = [ d.name for d in frappe.get_all( - "Account", filters={"account_type": account_type, "company": self.filters.company} + "Account", filters={"account_type": self.account_type, "company": self.filters.company} ) ] @@ -878,7 +900,7 @@ def is_invoice(self, ple): def get_party_details(self, party): if not party in self.party_details: - if self.party_type == "Customer": + if self.account_type == "Receivable": fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"] if self.filters.get("sales_partner"): @@ -901,14 +923,20 @@ def get_columns(self): self.columns = [] self.add_column("Posting Date", fieldtype="Date") self.add_column( - label=_(self.party_type), + label="Party Type", + fieldname="party_type", + fieldtype="Data", + width=100, + ) + self.add_column( + label="Party", fieldname="party", - fieldtype="Link", - options=self.party_type, + fieldtype="Dynamic Link", + options="party_type", width=180, ) self.add_column( - label="Receivable Account" if self.party_type == "Customer" else "Payable Account", + label=self.account_type + " Account", fieldname="party_account", fieldtype="Link", options="Account", @@ -916,19 +944,39 @@ def get_columns(self): ) if self.party_naming_by == "Naming Series": + if self.account_type == "Payable": + label = "Supplier Name" + fieldname = "supplier_name" + else: + label = "Customer Name" + fieldname = "customer_name" self.add_column( - _("{0} Name").format(self.party_type), - fieldname=scrub(self.party_type) + "_name", + label=label, + fieldname=fieldname, fieldtype="Data", ) - if self.party_type == "Customer": + if self.account_type == "Receivable": self.add_column( _("Customer Contact"), fieldname="customer_primary_contact", fieldtype="Link", options="Contact", ) + if self.filters.party_type == "Customer": + self.add_column( + _("Customer Name"), + fieldname="customer_name", + fieldtype="Link", + options="Customer", + ) + elif self.filters.party_type == "Supplier": + self.add_column( + _("Supplier Name"), + fieldname="supplier_name", + fieldtype="Link", + options="Supplier", + ) self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data") self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data") @@ -942,7 +990,7 @@ def get_columns(self): self.add_column(label="Due Date", fieldtype="Date") - if self.party_type == "Supplier": + if self.account_type == "Payable": self.add_column(label=_("Bill No"), fieldname="bill_no", fieldtype="Data") self.add_column(label=_("Bill Date"), fieldname="bill_date", fieldtype="Date") @@ -952,7 +1000,7 @@ def get_columns(self): self.add_column(_("Invoiced Amount"), fieldname="invoiced") self.add_column(_("Paid Amount"), fieldname="paid") - if self.party_type == "Customer": + if self.account_type == "Receivable": self.add_column(_("Credit Note"), fieldname="credit_note") else: # note: fieldname is still `credit_note` @@ -970,7 +1018,7 @@ def get_columns(self): self.add_column(label=_("Future Payment Amount"), fieldname="future_amount") self.add_column(label=_("Remaining Balance"), fieldname="remaining_balance") - if self.filters.party_type == "Customer": + if self.filters.account_type == "Receivable": self.add_column(label=_("Customer LPO"), fieldname="po_no", fieldtype="Data") # comma separated list of linked delivery notes @@ -991,7 +1039,7 @@ def get_columns(self): if self.filters.sales_partner: self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data") - if self.filters.party_type == "Supplier": + if self.filters.account_type == "Payable": self.add_column( label=_("Supplier Group"), fieldname="supplier_group", @@ -1059,7 +1107,10 @@ def get_exchange_rate_revaluations(self): .where( (je.company == self.filters.company) & (je.posting_date.lte(self.filters.report_date)) - & (je.voucher_type == "Exchange Rate Revaluation") + & ( + (je.voucher_type == "Exchange Rate Revaluation") + | (je.voucher_type == "Exchange Gain Or Loss") + ) ) .run() ) diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 6f1889b34e14..cbeb6d3106d4 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -1,6 +1,7 @@ import unittest import frappe +from frappe import qb from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, flt, getdate, today @@ -8,70 +9,99 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order -class TestAccountsReceivable(FrappeTestCase): +class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): def setUp(self): - frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 2'") - frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'") - frappe.db.sql("delete from `tabPayment Entry` where company='_Test Company 2'") - frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 2'") - frappe.db.sql("delete from `tabPayment Ledger Entry` where company='_Test Company 2'") - frappe.db.sql("delete from `tabJournal Entry` where company='_Test Company 2'") - frappe.db.sql("delete from `tabExchange Rate Revaluation` where company='_Test Company 2'") - - self.create_usd_account() + self.create_company() + self.create_customer() + self.create_item() + self.create_usd_receivable_account() + self.clear_old_entries() def tearDown(self): frappe.db.rollback() - def create_usd_account(self): - name = "Debtors USD" - exists = frappe.db.get_list( - "Account", filters={"company": "_Test Company 2", "account_name": "Debtors USD"} + def create_sales_invoice(self, no_payment_schedule=False, do_not_submit=False): + frappe.set_user("Administrator") + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_save=1, ) - if exists: - self.debtors_usd = exists[0].name - else: - debtors = frappe.get_doc( - "Account", - frappe.db.get_list( - "Account", filters={"company": "_Test Company 2", "account_name": "Debtors"} - )[0].name, + if not no_payment_schedule: + si.append( + "payment_schedule", + dict(due_date=getdate(add_days(today(), 30)), invoice_portion=30.00, payment_amount=30), + ) + si.append( + "payment_schedule", + dict(due_date=getdate(add_days(today(), 60)), invoice_portion=50.00, payment_amount=50), ) + si.append( + "payment_schedule", + dict(due_date=getdate(add_days(today(), 90)), invoice_portion=20.00, payment_amount=20), + ) + si = si.save() + if not do_not_submit: + si = si.submit() + return si + + def create_payment_entry(self, docname): + pe = get_payment_entry("Sales Invoice", docname, bank_account=self.cash, party_amount=40) + pe.paid_from = self.debit_to + pe.insert() + pe.submit() + + def create_credit_note(self, docname): + credit_note = create_sales_invoice( + company=self.company, + customer=self.customer, + item=self.item, + qty=-1, + debit_to=self.debit_to, + cost_center=self.cost_center, + is_return=1, + return_against=docname, + ) - debtors_usd = frappe.new_doc("Account") - debtors_usd.company = debtors.company - debtors_usd.account_name = "Debtors USD" - debtors_usd.account_currency = "USD" - debtors_usd.parent_account = debtors.parent_account - debtors_usd.account_type = debtors.account_type - self.debtors_usd = debtors_usd.save().name + return credit_note def test_accounts_receivable(self): filters = { - "company": "_Test Company 2", + "company": self.company, "based_on_payment_terms": 1, "report_date": today(), "range1": 30, "range2": 60, "range3": 90, "range4": 120, + "show_remarks": True, } # check invoice grand total and invoiced column's value for 3 payment terms - name = make_sales_invoice().name + si = self.create_sales_invoice() + name = si.name + report = execute(filters) - expected_data = [[100, 30], [100, 50], [100, 20]] + expected_data = [[100, 30, "No Remarks"], [100, 50, "No Remarks"], [100, 20, "No Remarks"]] for i in range(3): row = report[1][i - 1] - self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced]) + self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced, row.remarks]) # check invoice grand total, invoiced, paid and outstanding column's value after payment - make_payment(name) + self.create_payment_entry(si.name) report = execute(filters) expected_data_after_payment = [[100, 50, 10, 40], [100, 20, 0, 20]] @@ -84,10 +114,10 @@ def test_accounts_receivable(self): ) # check invoice grand total, invoiced, paid and outstanding column's value after credit note - make_credit_note(name) + self.create_credit_note(si.name) report = execute(filters) - expected_data_after_credit_note = [100, 0, 0, 40, -40, "Debtors - _TC2"] + expected_data_after_credit_note = [100, 0, 0, 40, -40, self.debit_to] row = report[1][0] self.assertEqual( @@ -108,21 +138,20 @@ def test_payment_againt_po_in_receivable_report(self): """ so = make_sales_order( - company="_Test Company 2", - customer="_Test Customer 2", - warehouse="Finished Goods - _TC2", - currency="EUR", - debit_to="Debtors - _TC2", - income_account="Sales - _TC2", - expense_account="Cost of Goods Sold - _TC2", - cost_center="Main - _TC2", + company=self.company, + customer=self.customer, + warehouse=self.warehouse, + debit_to=self.debit_to, + income_account=self.income_account, + expense_account=self.expense_account, + cost_center=self.cost_center, ) pe = get_payment_entry(so.doctype, so.name) pe = pe.save().submit() filters = { - "company": "_Test Company 2", + "company": self.company, "based_on_payment_terms": 0, "report_date": today(), "range1": 30, @@ -147,34 +176,32 @@ def test_payment_againt_po_in_receivable_report(self): ) @change_settings( - "Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1} + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, ) def test_exchange_revaluation_for_party(self): """ - Exchange Revaluation for party on Receivable/Payable shoule be included + Exchange Revaluation for party on Receivable/Payable should be included """ - company = "_Test Company 2" - customer = "_Test Customer 2" - # Using Exchange Gain/Loss account for unrealized as well. - company_doc = frappe.get_doc("Company", company) + company_doc = frappe.get_doc("Company", self.company) company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account company_doc.save() - si = make_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) si.currency = "USD" - si.conversion_rate = 0.90 + si.conversion_rate = 80 si.debit_to = self.debtors_usd si = si.save().submit() # Exchange Revaluation err = frappe.new_doc("Exchange Rate Revaluation") - err.company = company + err.company = self.company err.posting_date = today() accounts = err.get_accounts_data() err.extend("accounts", accounts) - err.accounts[0].new_exchange_rate = 0.95 + err.accounts[0].new_exchange_rate = 85 row = err.accounts[0] row.new_balance_in_base_currency = flt( row.new_exchange_rate * flt(row.balance_in_account_currency) @@ -189,7 +216,7 @@ def test_exchange_revaluation_for_party(self): je = je.submit() filters = { - "company": company, + "company": self.company, "report_date": today(), "range1": 30, "range2": 60, @@ -198,7 +225,7 @@ def test_exchange_revaluation_for_party(self): } report = execute(filters) - expected_data_for_err = [0, -5, 0, 5] + expected_data_for_err = [0, -500, 0, 500] row = [x for x in report[1] if x.voucher_type == je.doctype and x.voucher_no == je.name][0] self.assertEqual( expected_data_for_err, @@ -214,46 +241,43 @@ def test_payment_against_credit_note(self): """ Payment against credit/debit note should be considered against the parent invoice """ - company = "_Test Company 2" - customer = "_Test Customer 2" - si1 = make_sales_invoice() + si1 = self.create_sales_invoice() - pe = get_payment_entry("Sales Invoice", si1.name, bank_account="Cash - _TC2") - pe.paid_from = "Debtors - _TC2" + pe = get_payment_entry(si1.doctype, si1.name, bank_account=self.cash) + pe.paid_from = self.debit_to pe.insert() pe.submit() - cr_note = make_credit_note(si1.name) + cr_note = self.create_credit_note(si1.name) - si2 = make_sales_invoice() + si2 = self.create_sales_invoice() # manually link cr_note with si2 using journal entry je = frappe.new_doc("Journal Entry") - je.company = company + je.company = self.company je.voucher_type = "Credit Note" je.posting_date = today() - debit_account = "Debtors - _TC2" debit_entry = { - "account": debit_account, + "account": self.debit_to, "party_type": "Customer", - "party": customer, + "party": self.customer, "debit": 100, "debit_in_account_currency": 100, "reference_type": cr_note.doctype, "reference_name": cr_note.name, - "cost_center": "Main - _TC2", + "cost_center": self.cost_center, } credit_entry = { - "account": debit_account, + "account": self.debit_to, "party_type": "Customer", - "party": customer, + "party": self.customer, "credit": 100, "credit_in_account_currency": 100, "reference_type": si2.doctype, "reference_name": si2.name, - "cost_center": "Main - _TC2", + "cost_center": self.cost_center, } je.append("accounts", debit_entry) @@ -261,7 +285,7 @@ def test_payment_against_credit_note(self): je = je.save().submit() filters = { - "company": company, + "company": self.company, "report_date": today(), "range1": 30, "range2": 60, @@ -271,64 +295,420 @@ def test_payment_against_credit_note(self): report = execute(filters) self.assertEqual(report[1], []) + def test_group_by_party(self): + si1 = self.create_sales_invoice(do_not_submit=True) + si1.posting_date = add_days(today(), -1) + si1.save().submit() + si2 = self.create_sales_invoice(do_not_submit=True) + si2.items[0].rate = 85 + si2.save().submit() -def make_sales_invoice(no_payment_schedule=False, do_not_submit=False): - frappe.set_user("Administrator") + filters = { + "company": self.company, + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + "group_by_party": True, + } + report = execute(filters)[1] + self.assertEqual(len(report), 5) + + # assert voucher rows + expected_voucher_rows = [ + [100.0, 100.0, 100.0, 100.0], + [85.0, 85.0, 85.0, 85.0], + ] + voucher_rows = [] + for x in report[0:2]: + voucher_rows.append( + [x.invoiced, x.outstanding, x.invoiced_in_account_currency, x.outstanding_in_account_currency] + ) + self.assertEqual(expected_voucher_rows, voucher_rows) + + # assert total rows + expected_total_rows = [ + [self.customer, 185.0, 185.0], # party total + {}, # empty row for padding + ["Total", 185.0, 185.0], # grand total + ] + party_total_row = report[2] + self.assertEqual( + expected_total_rows[0], + [ + party_total_row.get("party"), + party_total_row.get("invoiced"), + party_total_row.get("outstanding"), + ], + ) + empty_row = report[3] + self.assertEqual(expected_total_rows[1], empty_row) + grand_total_row = report[4] + self.assertEqual( + expected_total_rows[2], + [ + grand_total_row.get("party"), + grand_total_row.get("invoiced"), + grand_total_row.get("outstanding"), + ], + ) - si = create_sales_invoice( - company="_Test Company 2", - customer="_Test Customer 2", - currency="EUR", - warehouse="Finished Goods - _TC2", - debit_to="Debtors - _TC2", - income_account="Sales - _TC2", - expense_account="Cost of Goods Sold - _TC2", - cost_center="Main - _TC2", - do_not_save=1, - ) + def test_future_payments(self): + si = self.create_sales_invoice() + pe = get_payment_entry(si.doctype, si.name) + pe.posting_date = add_days(today(), 1) + pe.paid_amount = 90.0 + pe.references[0].allocated_amount = 90.0 + pe.save().submit() + filters = { + "company": self.company, + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + "show_future_payments": True, + } + report = execute(filters)[1] + self.assertEqual(len(report), 1) + + expected_data = [100.0, 100.0, 10.0, 90.0] + + row = report[0] + self.assertEqual( + expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount] + ) + + pe.cancel() + # full payment in future date + pe = get_payment_entry(si.doctype, si.name) + pe.posting_date = add_days(today(), 1) + pe.save().submit() + report = execute(filters)[1] + self.assertEqual(len(report), 1) + expected_data = [100.0, 100.0, 0.0, 100.0] + row = report[0] + self.assertEqual( + expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount] + ) + + pe.cancel() + # over payment in future date + pe = get_payment_entry(si.doctype, si.name) + pe.posting_date = add_days(today(), 1) + pe.paid_amount = 110 + pe.save().submit() + report = execute(filters)[1] + self.assertEqual(len(report), 2) + expected_data = [[100.0, 0.0, 100.0, 0.0, 100.0], [0.0, 10.0, -10.0, -10.0, 0.0]] + for idx, row in enumerate(report): + self.assertEqual( + expected_data[idx], + [row.invoiced, row.paid, row.outstanding, row.remaining_balance, row.future_amount], + ) + + def test_sales_person(self): + sales_person = ( + frappe.get_doc({"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True}) + .insert() + .submit() + ) + si = self.create_sales_invoice(do_not_submit=True) + si.append("sales_team", {"sales_person": sales_person.name, "allocated_percentage": 100}) + si.save().submit() + + filters = { + "company": self.company, + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + "sales_person": sales_person.name, + "show_sales_person": True, + } + report = execute(filters)[1] + self.assertEqual(len(report), 1) + + expected_data = [100.0, 100.0, sales_person.name] - if not no_payment_schedule: - si.append( - "payment_schedule", - dict(due_date=getdate(add_days(today(), 30)), invoice_portion=30.00, payment_amount=30), + row = report[0] + self.assertEqual(expected_data, [row.invoiced, row.outstanding, row.sales_person]) + + def test_cost_center_filter(self): + si = self.create_sales_invoice() + filters = { + "company": self.company, + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + "cost_center": self.cost_center, + } + report = execute(filters)[1] + self.assertEqual(len(report), 1) + expected_data = [100.0, 100.0, self.cost_center] + row = report[0] + self.assertEqual(expected_data, [row.invoiced, row.outstanding, row.cost_center]) + + def test_customer_group_filter(self): + si = self.create_sales_invoice() + cus_group = frappe.db.get_value("Customer", self.customer, "customer_group") + filters = { + "company": self.company, + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + "customer_group": cus_group, + } + report = execute(filters)[1] + self.assertEqual(len(report), 1) + expected_data = [100.0, 100.0, cus_group] + row = report[0] + self.assertEqual(expected_data, [row.invoiced, row.outstanding, row.customer_group]) + + filters.update({"customer_group": "Individual"}) + report = execute(filters)[1] + self.assertEqual(len(report), 0) + + def test_party_account_filter(self): + si1 = self.create_sales_invoice() + self.customer2 = ( + frappe.get_doc( + { + "doctype": "Customer", + "customer_name": "Jane Doe", + "type": "Individual", + "default_currency": "USD", + } + ) + .insert() + .submit() ) - si.append( - "payment_schedule", - dict(due_date=getdate(add_days(today(), 60)), invoice_portion=50.00, payment_amount=50), + + si2 = self.create_sales_invoice(do_not_submit=True) + si2.posting_date = add_days(today(), -1) + si2.customer = self.customer2 + si2.currency = "USD" + si2.conversion_rate = 80 + si2.debit_to = self.debtors_usd + si2.save().submit() + + # Filter on company currency receivable account + filters = { + "company": self.company, + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + "party_account": self.debit_to, + } + report = execute(filters)[1] + self.assertEqual(len(report), 1) + expected_data = [100.0, 100.0, self.debit_to, si1.currency] + row = report[0] + self.assertEqual( + expected_data, [row.invoiced, row.outstanding, row.party_account, row.account_currency] ) - si.append( - "payment_schedule", - dict(due_date=getdate(add_days(today(), 90)), invoice_portion=20.00, payment_amount=20), + + # Filter on USD receivable account + filters.update({"party_account": self.debtors_usd}) + report = execute(filters)[1] + self.assertEqual(len(report), 1) + expected_data = [8000.0, 8000.0, self.debtors_usd, si2.currency] + row = report[0] + self.assertEqual( + expected_data, [row.invoiced, row.outstanding, row.party_account, row.account_currency] ) - si = si.save() + # without filter on party account + filters.pop("party_account") + report = execute(filters)[1] + self.assertEqual(len(report), 2) + expected_data = [ + [8000.0, 8000.0, 100.0, 100.0, self.debtors_usd, si2.currency], + [100.0, 100.0, 100.0, 100.0, self.debit_to, si1.currency], + ] + for idx, row in enumerate(report): + self.assertEqual( + expected_data[idx], + [ + row.invoiced, + row.outstanding, + row.invoiced_in_account_currency, + row.outstanding_in_account_currency, + row.party_account, + row.account_currency, + ], + ) + + def test_usd_customer_filter(self): + filters = { + "company": self.company, + "party_type": "Customer", + "party": [self.customer], + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + } + + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si.currency = "USD" + si.conversion_rate = 80 + si.debit_to = self.debtors_usd + si.save().submit() + name = si.name + + # check invoice grand total and invoiced column's value for 3 payment terms + report = execute(filters) + + expected = { + "voucher_type": si.doctype, + "voucher_no": si.name, + "party_account": self.debtors_usd, + "customer_name": self.customer, + "invoiced": 100.0, + "outstanding": 100.0, + "account_currency": "USD", + } + self.assertEqual(len(report[1]), 1) + report_output = report[1][0] + for field in expected: + with self.subTest(field=field): + self.assertEqual(report_output.get(field), expected.get(field)) + + def test_multi_select_party_filter(self): + self.customer1 = self.customer + self.create_customer("_Test Customer 2") + self.customer2 = self.customer + self.create_customer("_Test Customer 3") + self.customer3 = self.customer - if not do_not_submit: - si = si.submit() + filters = { + "company": self.company, + "party_type": "Customer", + "party": [self.customer1, self.customer3], + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + } - return si + si1 = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si1.customer = self.customer1 + si1.save().submit() + si2 = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si2.customer = self.customer2 + si2.save().submit() -def make_payment(docname): - pe = get_payment_entry("Sales Invoice", docname, bank_account="Cash - _TC2", party_amount=40) - pe.paid_from = "Debtors - _TC2" - pe.insert() - pe.submit() + si3 = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si3.customer = self.customer3 + si3.save().submit() + # check invoice grand total and invoiced column's value for 3 payment terms + report = execute(filters) -def make_credit_note(docname): - credit_note = create_sales_invoice( - company="_Test Company 2", - customer="_Test Customer 2", - currency="EUR", - qty=-1, - warehouse="Finished Goods - _TC2", - debit_to="Debtors - _TC2", - income_account="Sales - _TC2", - expense_account="Cost of Goods Sold - _TC2", - cost_center="Main - _TC2", - is_return=1, - return_against=docname, - ) + expected_output = {self.customer1, self.customer3} + self.assertEqual(len(report[1]), 2) + output_for = set([x.party for x in report[1]]) + self.assertEqual(output_for, expected_output) + + def test_report_output_if_party_is_missing(self): + acc_name = "Additional Debtors" + if not frappe.db.get_value( + "Account", filters={"account_name": acc_name, "company": self.company} + ): + additional_receivable_acc = frappe.get_doc( + { + "doctype": "Account", + "account_name": acc_name, + "parent_account": "Accounts Receivable - " + self.company_abbr, + "company": self.company, + "account_type": "Receivable", + } + ).save() + self.debtors2 = additional_receivable_acc.name - return credit_note + je = frappe.new_doc("Journal Entry") + je.company = self.company + je.posting_date = today() + je.append( + "accounts", + { + "account": self.debit_to, + "party_type": "Customer", + "party": self.customer, + "debit_in_account_currency": 150, + "credit_in_account_currency": 0, + "cost_center": self.cost_center, + }, + ) + je.append( + "accounts", + { + "account": self.debtors2, + "party_type": "Customer", + "party": self.customer, + "debit_in_account_currency": 200, + "credit_in_account_currency": 0, + "cost_center": self.cost_center, + }, + ) + je.append( + "accounts", + { + "account": self.cash, + "debit_in_account_currency": 0, + "credit_in_account_currency": 350, + "cost_center": self.cost_center, + }, + ) + je.save().submit() + + # manually remove party from Payment Ledger + ple = qb.DocType("Payment Ledger Entry") + qb.update(ple).set(ple.party, None).where(ple.voucher_no == je.name).run() + + filters = { + "company": self.company, + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + } + + report_ouput = execute(filters)[1] + expected_data = [ + [self.debtors2, je.doctype, je.name, "Customer", self.customer, 200.0, 0.0, 0.0, 200.0], + [self.debit_to, je.doctype, je.name, "Customer", self.customer, 150.0, 0.0, 0.0, 150.0], + ] + self.assertEqual(len(report_ouput), 2) + # fetch only required fields + report_output = [ + [ + x.party_account, + x.voucher_type, + x.voucher_no, + "Customer", + self.customer, + x.invoiced, + x.paid, + x.credit_note, + x.outstanding, + ] + for x in report_ouput + ] + # use account name to sort + # post sorting output should be [[Additional Debtors, ...], [Debtors, ...]] + report_output = sorted(report_output, key=lambda x: x[0]) + self.assertEqual(expected_data, report_output) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js index 715cd6476e8a..5ad10c7890a0 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js @@ -72,10 +72,27 @@ frappe.query_reports["Accounts Receivable Summary"] = { } }, { - "fieldname":"customer", - "label": __("Customer"), - "fieldtype": "Link", - "options": "Customer" + "fieldname":"party_type", + "label": __("Party Type"), + "fieldtype": "Autocomplete", + options: get_party_type_options(), + on_change: function() { + frappe.query_report.set_filter_value('party', ""); + frappe.query_report.toggle_filter_display('customer_group', frappe.query_report.get_filter_value('party_type') !== "Customer"); + } + }, + { + "fieldname":"party", + "label": __("Party"), + "fieldtype": "MultiSelectList", + get_data: function(txt) { + if (!frappe.query_report.filters) return; + + let party_type = frappe.query_report.get_filter_value('party_type'); + if (!party_type) return; + + return frappe.db.get_link_options(party_type, txt); + }, }, { "fieldname":"customer_group", @@ -133,3 +150,15 @@ frappe.query_reports["Accounts Receivable Summary"] = { } erpnext.utils.add_dimensions('Accounts Receivable Summary', 9); + +function get_party_type_options() { + let options = []; + frappe.db.get_list( + "Party Type", {filters:{"account_type": "Receivable"}, fields:['name']} + ).then((res) => { + res.forEach((party_type) => { + options.push(party_type.name); + }); + }); + return options; +} \ No newline at end of file diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 9c01b1a4980e..60274cd8b108 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -12,7 +12,7 @@ def execute(filters=None): args = { - "party_type": "Customer", + "account_type": "Receivable", "naming_by": ["Selling Settings", "cust_master_name"], } @@ -21,7 +21,10 @@ def execute(filters=None): class AccountsReceivableSummary(ReceivablePayableReport): def run(self, args): - self.party_type = args.get("party_type") + self.account_type = args.get("account_type") + self.party_type = frappe.db.get_all( + "Party Type", {"account_type": self.account_type}, pluck="name" + ) self.party_naming_by = frappe.db.get_value( args.get("naming_by")[0], None, args.get("naming_by")[1] ) @@ -35,19 +38,24 @@ def get_data(self, args): self.get_party_total(args) + party = None + for party_type in self.party_type: + if self.filters.get(scrub(party_type)): + party = self.filters.get(scrub(party_type)) + party_advance_amount = ( get_partywise_advanced_payment_amount( self.party_type, self.filters.report_date, self.filters.show_future_payments, self.filters.company, - party=self.filters.get(scrub(self.party_type)), + party=party, ) or {} ) if self.filters.show_gl_balance: - gl_balance_map = get_gl_balance(self.filters.report_date) + gl_balance_map = get_gl_balance(self.filters.report_date, self.filters.company) for party, party_dict in self.party_total.items(): if party_dict.outstanding == 0: @@ -57,9 +65,13 @@ def get_data(self, args): row.party = party if self.party_naming_by == "Naming Series": - row.party_name = frappe.get_cached_value( - self.party_type, party, scrub(self.party_type) + "_name" - ) + if self.account_type == "Payable": + doctype = "Supplier" + fieldname = "supplier_name" + else: + doctype = "Customer" + fieldname = "customer_name" + row.party_name = frappe.get_cached_value(doctype, party, fieldname) row.update(party_dict) @@ -87,9 +99,8 @@ def get_party_total(self, args): # Add all amount columns for k in list(self.party_total[d.party]): - if k not in ["currency", "sales_person"]: - - self.party_total[d.party][k] += d.get(k, 0.0) + if isinstance(self.party_total[d.party][k], float): + self.party_total[d.party][k] += d.get(k) or 0.0 # set territory, customer_group, sales person etc self.set_party_details(d) @@ -111,6 +122,7 @@ def init_party_total(self, row): "total_due": 0.0, "future_amount": 0.0, "sales_person": [], + "party_type": row.party_type, } ), ) @@ -120,28 +132,37 @@ def set_party_details(self, row): for key in ("territory", "customer_group", "supplier_group"): if row.get(key): - self.party_total[row.party][key] = row.get(key) - + self.party_total[row.party][key] = row.get(key, "") if row.sales_person: - self.party_total[row.party].sales_person.append(row.sales_person) + self.party_total[row.party].sales_person.append(row.get("sales_person", "")) if self.filters.sales_partner: - self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner") + self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "") def get_columns(self): self.columns = [] self.add_column( - label=_(self.party_type), + label=_("Party Type"), + fieldname="party_type", + fieldtype="Data", + width=100, + ) + self.add_column( + label=_("Party"), fieldname="party", - fieldtype="Link", - options=self.party_type, + fieldtype="Dynamic Link", + options="party_type", width=180, ) if self.party_naming_by == "Naming Series": - self.add_column(_("{0} Name").format(self.party_type), fieldname="party_name", fieldtype="Data") + self.add_column( + label=_("Supplier Name") if self.account_type == "Payable" else _("Customer Name"), + fieldname="party_name", + fieldtype="Data", + ) - credit_debit_label = "Credit Note" if self.party_type == "Customer" else "Debit Note" + credit_debit_label = "Credit Note" if self.account_type == "Receivable" else "Debit Note" self.add_column(_("Advance Amount"), fieldname="advance") self.add_column(_("Invoiced Amount"), fieldname="invoiced") @@ -159,7 +180,7 @@ def get_columns(self): self.add_column(label=_("Future Payment Amount"), fieldname="future_amount") self.add_column(label=_("Remaining Balance"), fieldname="remaining_balance") - if self.party_type == "Customer": + if self.account_type == "Receivable": self.add_column( label=_("Territory"), fieldname="territory", fieldtype="Link", options="Territory" ) @@ -209,12 +230,12 @@ def setup_ageing_columns(self): self.add_column(label="Total Amount Due", fieldname="total_due") -def get_gl_balance(report_date): +def get_gl_balance(report_date, company): return frappe._dict( frappe.db.get_all( "GL Entry", fields=["party", "sum(debit - credit)"], - filters={"posting_date": ("<=", report_date), "is_cancelled": 0}, + filters={"posting_date": ("<=", report_date), "is_cancelled": 0, "company": company}, group_by="party", as_list=1, ) diff --git a/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py new file mode 100644 index 000000000000..3ee35a114d15 --- /dev/null +++ b/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py @@ -0,0 +1,203 @@ +import unittest + +import frappe +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import today + +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_summary import execute +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin + + +class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.maxDiff = None + self.create_company() + self.create_customer() + self.create_item() + self.clear_old_entries() + + def tearDown(self): + frappe.db.rollback() + + def test_01_receivable_summary_output(self): + """ + Test for Invoices, Paid, Advance and Outstanding + """ + filters = { + "company": self.company, + "customer": self.customer, + "posting_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + } + + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=200, + price_list_rate=200, + ) + + customer_group, customer_territory = frappe.db.get_all( + "Customer", + filters={"name": self.customer}, + fields=["customer_group", "territory"], + as_list=True, + )[0] + + report = execute(filters) + rpt_output = report[1] + expected_data = { + "party_type": "Customer", + "advance": 0, + "party": self.customer, + "invoiced": 200.0, + "paid": 0.0, + "credit_note": 0.0, + "outstanding": 200.0, + "range1": 200.0, + "range2": 0.0, + "range3": 0.0, + "range4": 0.0, + "range5": 0.0, + "total_due": 200.0, + "future_amount": 0.0, + "sales_person": [], + "currency": si.currency, + "territory": customer_territory, + "customer_group": customer_group, + } + + self.assertEqual(len(rpt_output), 1) + self.assertDictEqual(rpt_output[0], expected_data) + + # simulate advance payment + pe = get_payment_entry(si.doctype, si.name) + pe.paid_amount = 50 + pe.references[0].allocated_amount = 0 # this essitially removes the reference + pe.save().submit() + + # update expected data with advance + expected_data.update( + { + "advance": 50.0, + "outstanding": 150.0, + "range1": 150.0, + "total_due": 150.0, + } + ) + + report = execute(filters) + rpt_output = report[1] + self.assertEqual(len(rpt_output), 1) + self.assertDictEqual(rpt_output[0], expected_data) + + # make partial payment + pe = get_payment_entry(si.doctype, si.name) + pe.paid_amount = 125 + pe.references[0].allocated_amount = 125 + pe.save().submit() + + # update expected data after advance and partial payment + expected_data.update( + {"advance": 50.0, "paid": 125.0, "outstanding": 25.0, "range1": 25.0, "total_due": 25.0} + ) + + report = execute(filters) + rpt_output = report[1] + self.assertEqual(len(rpt_output), 1) + self.assertDictEqual(rpt_output[0], expected_data) + + @change_settings("Selling Settings", {"cust_master_name": "Naming Series"}) + def test_02_various_filters_and_output(self): + filters = { + "company": self.company, + "customer": self.customer, + "posting_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + } + + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=200, + price_list_rate=200, + ) + # make partial payment + pe = get_payment_entry(si.doctype, si.name) + pe.paid_amount = 150 + pe.references[0].allocated_amount = 150 + pe.save().submit() + + customer_group, customer_territory = frappe.db.get_all( + "Customer", + filters={"name": self.customer}, + fields=["customer_group", "territory"], + as_list=True, + )[0] + + report = execute(filters) + rpt_output = report[1] + expected_data = { + "party_type": "Customer", + "advance": 0, + "party": self.customer, + "party_name": self.customer, + "invoiced": 200.0, + "paid": 150.0, + "credit_note": 0.0, + "outstanding": 50.0, + "range1": 50.0, + "range2": 0.0, + "range3": 0.0, + "range4": 0.0, + "range5": 0.0, + "total_due": 50.0, + "future_amount": 0.0, + "sales_person": [], + "currency": si.currency, + "territory": customer_territory, + "customer_group": customer_group, + } + + self.assertEqual(len(rpt_output), 1) + self.assertDictEqual(rpt_output[0], expected_data) + + # with gl balance filter + filters.update({"show_gl_balance": True}) + expected_data.update({"gl_balance": 50.0, "diff": 0.0}) + report = execute(filters) + rpt_output = report[1] + self.assertEqual(len(rpt_output), 1) + self.assertDictEqual(rpt_output[0], expected_data) + + # with gl balance and future payments filter + filters.update({"show_future_payments": True}) + expected_data.update({"remaining_balance": 50.0}) + report = execute(filters) + rpt_output = report[1] + self.assertEqual(len(rpt_output), 1) + self.assertDictEqual(rpt_output[0], expected_data) + + # invoice fully paid + pe = get_payment_entry(si.doctype, si.name).save().submit() + report = execute(filters) + rpt_output = report[1] + self.assertEqual(len(rpt_output), 0) diff --git a/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.json b/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.json index bee2829c87ab..0ef9d858dd5b 100644 --- a/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.json +++ b/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.json @@ -1,20 +1,23 @@ { - "add_total_row": 0, - "apply_user_permissions": 1, - "creation": "2016-04-08 14:49:58.133098", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 2, - "is_standard": "Yes", - "modified": "2017-02-24 20:08:26.084484", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Asset Depreciation Ledger", - "owner": "Administrator", - "ref_doctype": "Asset", - "report_name": "Asset Depreciation Ledger", - "report_type": "Script Report", + "add_total_row": 1, + "columns": [], + "creation": "2016-04-08 14:49:58.133098", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 2, + "is_standard": "Yes", + "letterhead": null, + "modified": "2023-07-26 21:05:33.554778", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Asset Depreciation Ledger", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Asset", + "report_name": "Asset Depreciation Ledger", + "report_type": "Script Report", "roles": [ { "role": "Accounts User" diff --git a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.json b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.json index eab95fc73b3f..2ea9af223e5d 100644 --- a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.json +++ b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.json @@ -1,20 +1,23 @@ { - "add_total_row": 0, - "apply_user_permissions": 1, - "creation": "2016-04-08 14:56:37.235981", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 2, - "is_standard": "Yes", - "modified": "2017-02-24 20:08:18.660476", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Asset Depreciations and Balances", - "owner": "Administrator", - "ref_doctype": "Asset", - "report_name": "Asset Depreciations and Balances", - "report_type": "Script Report", + "add_total_row": 1, + "columns": [], + "creation": "2016-04-08 14:56:37.235981", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 2, + "is_standard": "Yes", + "letterhead": null, + "modified": "2023-07-26 21:04:54.751077", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Asset Depreciations and Balances", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Asset", + "report_name": "Asset Depreciations and Balances", + "report_type": "Script Report", "roles": [ { "role": "Accounts User" 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 d67eee3552da..bdc8d8504f87 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 @@ -58,6 +58,9 @@ def get_data(filters): def get_asset_categories(filters): + condition = "" + if filters.get("asset_category"): + condition += " and asset_category = %(asset_category)s" return frappe.db.sql( """ SELECT asset_category, @@ -98,15 +101,25 @@ def get_asset_categories(filters): 0 end), 0) as cost_of_scrapped_asset from `tabAsset` - where docstatus=1 and company=%(company)s and purchase_date <= %(to_date)s + where docstatus=1 and company=%(company)s and purchase_date <= %(to_date)s {} group by asset_category - """, - {"to_date": filters.to_date, "from_date": filters.from_date, "company": filters.company}, + """.format( + condition + ), + { + "to_date": filters.to_date, + "from_date": filters.from_date, + "company": filters.company, + "asset_category": filters.get("asset_category"), + }, as_dict=1, ) def get_assets(filters): + condition = "" + if filters.get("asset_category"): + condition = " and a.asset_category = '{}'".format(filters.get("asset_category")) return frappe.db.sql( """ SELECT results.asset_category, @@ -138,7 +151,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.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) {0} group by a.asset_category union SELECT a.asset_category, @@ -154,10 +167,12 @@ def get_assets(filters): end), 0) as depreciation_eliminated_during_the_period, 0 as depreciation_amount_during_the_period from `tabAsset` a - where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s + where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s {0} group by a.asset_category) as results group by results.asset_category - """, + """.format( + condition + ), {"to_date": filters.to_date, "from_date": filters.from_date, "company": filters.company}, as_dict=1, ) diff --git a/erpnext/accounts/report/balance_sheet/test_balance_sheet.py b/erpnext/accounts/report/balance_sheet/test_balance_sheet.py new file mode 100644 index 000000000000..3cb6efebee35 --- /dev/null +++ b/erpnext/accounts/report/balance_sheet/test_balance_sheet.py @@ -0,0 +1,51 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today + +from erpnext.accounts.report.balance_sheet.balance_sheet import execute + + +class TestBalanceSheet(FrappeTestCase): + def test_balance_sheet(self): + 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, + make_sales_invoice, + ) + from erpnext.accounts.utils import get_fiscal_year + + frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") + frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 6'") + frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") + + pi = make_purchase_invoice( + company="_Test Company 6", + warehouse="Finished Goods - _TC6", + expense_account="Cost of Goods Sold - _TC6", + cost_center="Main - _TC6", + qty=10, + rate=100, + ) + si = create_sales_invoice( + company="_Test Company 6", + debit_to="Debtors - _TC6", + income_account="Sales - _TC6", + cost_center="Main - _TC6", + qty=5, + rate=110, + ) + filters = frappe._dict( + company="_Test Company 6", + period_start_date=today(), + period_end_date=today(), + periodicity="Yearly", + ) + result = execute(filters)[1] + for account_dict in result: + if account_dict.get("account") == "Current Liabilities - _TC6": + self.assertEqual(account_dict.total, 1000) + if account_dict.get("account") == "Current Assets - _TC6": + self.assertEqual(account_dict.total, 550) diff --git a/erpnext/accounts/report/bank_reconciliation_statement/test_bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/test_bank_reconciliation_statement.py index d7c871608ec4..b1be53ba73f5 100644 --- a/erpnext/accounts/report/bank_reconciliation_statement/test_bank_reconciliation_statement.py +++ b/erpnext/accounts/report/bank_reconciliation_statement/test_bank_reconciliation_statement.py @@ -23,6 +23,7 @@ def setUp(self): "Payment Entry", ]: frappe.db.delete(dt) + frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None) def test_loan_entries_in_bank_reco_statement(self): create_loan_accounts() diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 5934fd12e859..fe4b6c71ebc3 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -6,6 +6,7 @@ import frappe from frappe import _ +from frappe.query_builder import Criterion from frappe.utils import cint, flt, getdate import erpnext @@ -364,6 +365,7 @@ def get_data( accounts_by_name, accounts, ignore_closing_entries=False, + root_type=root_type, ) calculate_values(accounts_by_name, gl_entries_by_account, companies, filters, fiscal_year) @@ -608,6 +610,7 @@ def set_gl_entries_by_account( accounts_by_name, accounts, ignore_closing_entries=False, + root_type=None, ): """Returns a dict like { "account": [gl entries], ... }""" @@ -615,7 +618,6 @@ def set_gl_entries_by_account( "Company", filters.get("company"), ["lft", "rgt"] ) - additional_conditions = get_additional_conditions(from_date, ignore_closing_entries, filters) companies = frappe.db.sql( """ select name, default_currency from `tabCompany` where lft >= %(company_lft)s and rgt <= %(company_rgt)s""", @@ -631,27 +633,43 @@ def set_gl_entries_by_account( ) for d in companies: - gl_entries = frappe.db.sql( - """select gl.posting_date, gl.account, gl.debit, gl.credit, gl.is_opening, gl.company, - gl.fiscal_year, gl.debit_in_account_currency, gl.credit_in_account_currency, gl.account_currency, - acc.account_name, acc.account_number - from `tabGL Entry` gl, `tabAccount` acc where acc.name = gl.account and gl.company = %(company)s and gl.is_cancelled = 0 - {additional_conditions} and gl.posting_date <= %(to_date)s and acc.lft >= %(lft)s and acc.rgt <= %(rgt)s - order by gl.account, gl.posting_date""".format( - additional_conditions=additional_conditions - ), - { - "from_date": from_date, - "to_date": to_date, - "lft": root_lft, - "rgt": root_rgt, - "company": d.name, - "finance_book": filters.get("finance_book"), - "company_fb": frappe.db.get_value("Company", d.name, "default_finance_book"), - }, - as_dict=True, + gle = frappe.qb.DocType("GL Entry") + account = frappe.qb.DocType("Account") + query = ( + frappe.qb.from_(gle) + .inner_join(account) + .on(account.name == gle.account) + .select( + gle.posting_date, + gle.account, + gle.debit, + gle.credit, + gle.is_opening, + gle.company, + gle.fiscal_year, + gle.debit_in_account_currency, + gle.credit_in_account_currency, + gle.account_currency, + account.account_name, + account.account_number, + ) + .where( + (gle.company == d.name) + & (gle.is_cancelled == 0) + & (gle.posting_date <= to_date) + & (account.lft >= root_lft) + & (account.rgt <= root_rgt) + ) + .orderby(gle.account, gle.posting_date) ) + if root_type: + query = query.where(account.root_type == root_type) + additional_conditions = get_additional_conditions(from_date, ignore_closing_entries, filters, d) + if additional_conditions: + query = query.where(Criterion.all(additional_conditions)) + gl_entries = query.run(as_dict=True) + if filters and filters.get("presentation_currency") != d.default_currency: currency_info["company"] = d.name currency_info["company_currency"] = d.default_currency @@ -721,23 +739,30 @@ def validate_entries(key, entry, accounts_by_name, accounts): accounts.insert(idx + 1, args) -def get_additional_conditions(from_date, ignore_closing_entries, filters): +def get_additional_conditions(from_date, ignore_closing_entries, filters, d): + gle = frappe.qb.DocType("GL Entry") additional_conditions = [] if ignore_closing_entries: - additional_conditions.append("ifnull(gl.voucher_type, '')!='Period Closing Voucher'") + additional_conditions.append((gle.voucher_type != "Period Closing Voucher")) if from_date: - additional_conditions.append("gl.posting_date >= %(from_date)s") + additional_conditions.append(gle.posting_date >= from_date) + + finance_books = [] + finance_books.append("") + if filter_fb := filters.get("finance_book"): + finance_books.append(filter_fb) 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 company_fb := frappe.get_cached_value("Company", d.name, "default_finance_book"): + finance_books.append(company_fb) + + additional_conditions.append((gle.finance_book.isin(finance_books)) | gle.finance_book.isnull()) else: - additional_conditions.append("(finance_book in (%(finance_book)s, '') OR finance_book IS NULL)") + additional_conditions.append((gle.finance_book.isin(finance_books)) | gle.finance_book.isnull()) - return " and {}".format(" and ".join(additional_conditions)) if additional_conditions else "" + return additional_conditions def add_total_row(out, root_type, balance_must_be, companies, company_currency): diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py index c84b843f1fd8..7b1a9027780e 100644 --- a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py +++ b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py @@ -2,6 +2,7 @@ import frappe from frappe import qb +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import nowdate from erpnext.accounts.doctype.account.test_account import create_account @@ -10,16 +11,15 @@ from erpnext.accounts.report.deferred_revenue_and_expense.deferred_revenue_and_expense import ( Deferred_Revenue_and_Expense_Report, ) +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.accounts.utils import get_fiscal_year from erpnext.buying.doctype.supplier.test_supplier import create_supplier from erpnext.stock.doctype.item.test_item import create_item -class TestDeferredRevenueAndExpense(unittest.TestCase): +class TestDeferredRevenueAndExpense(FrappeTestCase, AccountsTestMixin): @classmethod def setUpClass(self): - clear_accounts_and_items() - create_company() self.maxDiff = None def clear_old_entries(self): @@ -51,55 +51,58 @@ def clear_old_entries(self): if deferred_invoices: qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run() - def test_deferred_revenue(self): - self.clear_old_entries() - + def setup_deferred_accounts_and_items(self): # created deferred expense accounts, if not found - deferred_revenue_account = create_account( + self.deferred_revenue_account = create_account( account_name="Deferred Revenue", - parent_account="Current Liabilities - _CD", - company="_Test Company DR", + parent_account="Current Liabilities - " + self.company_abbr, + company=self.company, ) - acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings") - acc_settings.book_deferred_entries_based_on = "Months" - acc_settings.save() + # created deferred expense accounts, if not found + self.deferred_expense_account = create_account( + account_name="Deferred Expense", + parent_account="Current Assets - " + self.company_abbr, + company=self.company, + ) + + def setUp(self): + self.create_company() + self.create_customer("_Test Customer") + self.create_supplier("_Test Furniture Supplier") + self.setup_deferred_accounts_and_items() + self.clear_old_entries() - customer = frappe.new_doc("Customer") - customer.customer_name = "_Test Customer DR" - customer.type = "Individual" - customer.insert() + def tearDown(self): + frappe.db.rollback() - item = create_item( - "_Test Internet Subscription", - is_stock_item=0, - warehouse="All Warehouses - _CD", - company="_Test Company DR", - ) + @change_settings("Accounts Settings", {"book_deferred_entries_based_on": "Months"}) + def test_deferred_revenue(self): + self.create_item("_Test Internet Subscription", 0, self.warehouse, self.company) + item = frappe.get_doc("Item", self.item) item.enable_deferred_revenue = 1 - item.deferred_revenue_account = deferred_revenue_account + item.item_defaults[0].deferred_revenue_account = self.deferred_revenue_account item.no_of_months = 3 item.save() si = create_sales_invoice( - item=item.name, - company="_Test Company DR", - customer="_Test Customer DR", - debit_to="Debtors - _CD", + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, posting_date="2021-05-01", - parent_cost_center="Main - _CD", - cost_center="Main - _CD", + parent_cost_center=self.cost_center, + cost_center=self.cost_center, do_not_save=True, rate=300, price_list_rate=300, ) - si.items[0].income_account = "Sales - _CD" + si.items[0].income_account = self.income_account si.items[0].enable_deferred_revenue = 1 si.items[0].service_start_date = "2021-05-01" si.items[0].service_end_date = "2021-08-01" - si.items[0].deferred_revenue_account = deferred_revenue_account - si.items[0].income_account = "Sales - _CD" + si.items[0].deferred_revenue_account = self.deferred_revenue_account si.save() si.submit() @@ -110,7 +113,7 @@ def test_deferred_revenue(self): start_date="2021-05-01", end_date="2021-08-01", type="Income", - company="_Test Company DR", + company=self.company, ) ) pda.insert() @@ -120,7 +123,7 @@ def test_deferred_revenue(self): fiscal_year = frappe.get_doc("Fiscal Year", get_fiscal_year(date="2021-05-01")) self.filters = frappe._dict( { - "company": frappe.defaults.get_user_default("Company"), + "company": self.company, "filter_based_on": "Date Range", "period_start_date": "2021-05-01", "period_end_date": "2021-08-01", @@ -142,57 +145,36 @@ def test_deferred_revenue(self): ] self.assertEqual(report.period_total, expected) + @change_settings("Accounts Settings", {"book_deferred_entries_based_on": "Months"}) def test_deferred_expense(self): - self.clear_old_entries() - - # created deferred expense accounts, if not found - deferred_expense_account = create_account( - account_name="Deferred Expense", - parent_account="Current Assets - _CD", - company="_Test Company DR", - ) - - acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings") - acc_settings.book_deferred_entries_based_on = "Months" - acc_settings.save() - - supplier = create_supplier( - supplier_name="_Test Furniture Supplier", supplier_group="Local", supplier_type="Company" - ) - supplier.save() - - item = create_item( - "_Test Office Desk", - is_stock_item=0, - warehouse="All Warehouses - _CD", - company="_Test Company DR", - ) + self.create_item("_Test Office Desk", 0, self.warehouse, self.company) + item = frappe.get_doc("Item", self.item) item.enable_deferred_expense = 1 - item.deferred_expense_account = deferred_expense_account + item.item_defaults[0].deferred_expense_account = self.deferred_expense_account item.no_of_months_exp = 3 item.save() pi = make_purchase_invoice( - item=item.name, - company="_Test Company DR", - supplier="_Test Furniture Supplier", + item=self.item, + company=self.company, + supplier=self.supplier, is_return=False, update_stock=False, posting_date=frappe.utils.datetime.date(2021, 5, 1), - parent_cost_center="Main - _CD", - cost_center="Main - _CD", + parent_cost_center=self.cost_center, + cost_center=self.cost_center, do_not_save=True, rate=300, price_list_rate=300, - warehouse="All Warehouses - _CD", + warehouse=self.warehouse, qty=1, ) pi.set_posting_time = True pi.items[0].enable_deferred_expense = 1 pi.items[0].service_start_date = "2021-05-01" pi.items[0].service_end_date = "2021-08-01" - pi.items[0].deferred_expense_account = deferred_expense_account - pi.items[0].expense_account = "Office Maintenance Expenses - _CD" + pi.items[0].deferred_expense_account = self.deferred_expense_account + pi.items[0].expense_account = self.expense_account pi.save() pi.submit() @@ -203,7 +185,7 @@ def test_deferred_expense(self): start_date="2021-05-01", end_date="2021-08-01", type="Expense", - company="_Test Company DR", + company=self.company, ) ) pda.insert() @@ -213,7 +195,7 @@ def test_deferred_expense(self): fiscal_year = frappe.get_doc("Fiscal Year", get_fiscal_year(date="2021-05-01")) self.filters = frappe._dict( { - "company": frappe.defaults.get_user_default("Company"), + "company": self.company, "filter_based_on": "Date Range", "period_start_date": "2021-05-01", "period_end_date": "2021-08-01", @@ -235,52 +217,31 @@ def test_deferred_expense(self): ] self.assertEqual(report.period_total, expected) + @change_settings("Accounts Settings", {"book_deferred_entries_based_on": "Months"}) def test_zero_months(self): - self.clear_old_entries() - # created deferred expense accounts, if not found - deferred_revenue_account = create_account( - account_name="Deferred Revenue", - parent_account="Current Liabilities - _CD", - company="_Test Company DR", - ) - - acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings") - acc_settings.book_deferred_entries_based_on = "Months" - acc_settings.save() - - customer = frappe.new_doc("Customer") - customer.customer_name = "_Test Customer DR" - customer.type = "Individual" - customer.insert() - - item = create_item( - "_Test Internet Subscription", - is_stock_item=0, - warehouse="All Warehouses - _CD", - company="_Test Company DR", - ) + self.create_item("_Test Internet Subscription", 0, self.warehouse, self.company) + item = frappe.get_doc("Item", self.item) item.enable_deferred_revenue = 1 - item.deferred_revenue_account = deferred_revenue_account + item.deferred_revenue_account = self.deferred_revenue_account item.no_of_months = 0 item.save() si = create_sales_invoice( item=item.name, - company="_Test Company DR", - customer="_Test Customer DR", - debit_to="Debtors - _CD", + company=self.company, + customer=self.customer, + debit_to=self.debit_to, posting_date="2021-05-01", - parent_cost_center="Main - _CD", - cost_center="Main - _CD", + parent_cost_center=self.cost_center, + cost_center=self.cost_center, do_not_save=True, rate=300, price_list_rate=300, ) si.items[0].enable_deferred_revenue = 1 - si.items[0].income_account = "Sales - _CD" - si.items[0].deferred_revenue_account = deferred_revenue_account - si.items[0].income_account = "Sales - _CD" + si.items[0].income_account = self.income_account + si.items[0].deferred_revenue_account = self.deferred_revenue_account si.save() si.submit() @@ -291,7 +252,7 @@ def test_zero_months(self): start_date="2021-05-01", end_date="2021-08-01", type="Income", - company="_Test Company DR", + company=self.company, ) ) pda.insert() @@ -301,7 +262,7 @@ def test_zero_months(self): fiscal_year = frappe.get_doc("Fiscal Year", get_fiscal_year(date="2021-05-01")) self.filters = frappe._dict( { - "company": frappe.defaults.get_user_default("Company"), + "company": self.company, "filter_based_on": "Date Range", "period_start_date": "2021-05-01", "period_end_date": "2021-08-01", @@ -322,30 +283,3 @@ def test_zero_months(self): {"key": "aug_2021", "total": 0, "actual": 0}, ] self.assertEqual(report.period_total, expected) - - -def create_company(): - company = frappe.db.exists("Company", "_Test Company DR") - if not company: - company = frappe.new_doc("Company") - company.company_name = "_Test Company DR" - company.default_currency = "INR" - company.chart_of_accounts = "Standard" - company.insert() - - -def clear_accounts_and_items(): - item = qb.DocType("Item") - account = qb.DocType("Account") - customer = qb.DocType("Customer") - supplier = qb.DocType("Supplier") - - qb.from_(account).delete().where( - (account.account_name == "Deferred Revenue") - | (account.account_name == "Deferred Expense") & (account.company == "_Test Company DR") - ).run() - qb.from_(item).delete().where( - (item.item_code == "_Test Internet Subscription") | (item.item_code == "_Test Office Rent") - ).run() - qb.from_(customer).delete().where(customer.customer_name == "_Test Customer DR").run() - qb.from_(supplier).delete().where(supplier.supplier_name == "_Test Furniture Supplier").run() diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py index db9609debe61..693725d8f504 100644 --- a/erpnext/accounts/report/financial_statements.py +++ b/erpnext/accounts/report/financial_statements.py @@ -188,6 +188,7 @@ def get_data( filters, gl_entries_by_account, ignore_closing_entries=ignore_closing_entries, + root_type=root_type, ) calculate_values( @@ -334,12 +335,10 @@ def add_total_row(out, root_type, balance_must_be, period_list, company_currency for period in period_list: total_row.setdefault(period.key, 0.0) total_row[period.key] += row.get(period.key, 0.0) - row[period.key] = row.get(period.key, 0.0) total_row.setdefault("total", 0.0) total_row["total"] += flt(row["total"]) total_row["opening_balance"] += row["opening_balance"] - row["total"] = "" if "total" in total_row: out.append(total_row) @@ -417,23 +416,44 @@ def set_gl_entries_by_account( gl_entries_by_account, ignore_closing_entries=False, ignore_opening_entries=False, + root_type=None, ): """Returns a dict like { "account": [gl entries], ... }""" gl_entries = [] + account_filters = { + "company": company, + "is_group": 0, + "lft": (">=", root_lft), + "rgt": ("<=", root_rgt), + } + + if root_type: + account_filters.update( + { + "root_type": root_type, + } + ) + accounts_list = frappe.db.get_all( "Account", - filters={"company": company, "is_group": 0, "lft": (">=", root_lft), "rgt": ("<=", root_rgt)}, + filters=account_filters, pluck="name", ) if accounts_list: # For balance sheet - if not from_date: - from_date = filters["period_start_date"] + ignore_closing_balances = frappe.db.get_single_value( + "Accounts Settings", "ignore_account_closing_balance" + ) + if not from_date and not ignore_closing_balances: last_period_closing_voucher = frappe.db.get_all( "Period Closing Voucher", - filters={"docstatus": 1, "company": filters.company, "posting_date": ("<", from_date)}, + filters={ + "docstatus": 1, + "company": filters.company, + "posting_date": ("<", filters["period_start_date"]), + }, fields=["posting_date", "name"], order_by="posting_date desc", limit=1, @@ -617,7 +637,13 @@ def get_columns(periodicity, period_list, accumulated_values=1, company=None): if periodicity != "Yearly": if not accumulated_values: columns.append( - {"fieldname": "total", "label": _("Total"), "fieldtype": "Currency", "width": 150} + { + "fieldname": "total", + "label": _("Total"), + "fieldtype": "Currency", + "width": 150, + "options": "currency", + } ) return columns diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/__init__.py b/erpnext/accounts/report/general_and_payment_ledger_comparison/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js new file mode 100644 index 000000000000..7e6b0537e875 --- /dev/null +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js @@ -0,0 +1,52 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +function get_filters() { + let filters = [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + }, + { + "fieldname":"period_start_date", + "label": __("Start Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1) + }, + { + "fieldname":"period_end_date", + "label": __("End Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.get_today() + }, + { + "fieldname":"account", + "label": __("Account"), + "fieldtype": "MultiSelectList", + "options": "Account", + get_data: function(txt) { + return frappe.db.get_link_options('Account', txt, { + company: frappe.query_report.get_filter_value("company"), + account_type: ['in', ["Receivable", "Payable"]] + }); + } + }, + { + "fieldname":"voucher_no", + "label": __("Voucher No"), + "fieldtype": "Data", + "width": 100, + }, + ] + return filters; +} + +frappe.query_reports["General and Payment Ledger Comparison"] = { + "filters": get_filters() +}; diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.json b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.json new file mode 100644 index 000000000000..1d0d9d134da0 --- /dev/null +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.json @@ -0,0 +1,32 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2023-08-02 17:30:29.494907", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2023-08-02 17:30:29.494907", + "modified_by": "Administrator", + "module": "Accounts", + "name": "General and Payment Ledger Comparison", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "report_name": "General and Payment Ledger Comparison", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Auditor" + } + ] +} \ No newline at end of file diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py new file mode 100644 index 000000000000..099884a48ecc --- /dev/null +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py @@ -0,0 +1,223 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, qb +from frappe.query_builder import Criterion +from frappe.query_builder.functions import Sum + + +class General_Payment_Ledger_Comparison(object): + """ + A Utility report to compare Voucher-wise balance between General and Payment Ledger + """ + + def __init__(self, filters=None): + self.filters = filters + self.gle = [] + self.ple = [] + + def get_accounts(self): + receivable_accounts = [ + x[0] + for x in frappe.db.get_all( + "Account", + filters={"company": self.filters.company, "account_type": "Receivable"}, + as_list=True, + ) + ] + payable_accounts = [ + x[0] + for x in frappe.db.get_all( + "Account", filters={"company": self.filters.company, "account_type": "Payable"}, as_list=True + ) + ] + + self.account_types = frappe._dict( + { + "receivable": frappe._dict({"accounts": receivable_accounts, "gle": [], "ple": []}), + "payable": frappe._dict({"accounts": payable_accounts, "gle": [], "ple": []}), + } + ) + + def generate_filters(self): + if self.filters.account: + self.account_types.receivable.accounts = [] + self.account_types.payable.accounts = [] + + for acc in frappe.db.get_all( + "Account", filters={"name": ["in", self.filters.account]}, fields=["name", "account_type"] + ): + if acc.account_type == "Receivable": + self.account_types.receivable.accounts.append(acc.name) + else: + self.account_types.payable.accounts.append(acc.name) + + def get_gle(self): + gle = qb.DocType("GL Entry") + + for acc_type, val in self.account_types.items(): + if val.accounts: + + filter_criterion = [] + if self.filters.voucher_no: + filter_criterion.append((gle.voucher_no == self.filters.voucher_no)) + + if self.filters.period_start_date: + filter_criterion.append(gle.posting_date.gte(self.filters.period_start_date)) + + if self.filters.period_end_date: + filter_criterion.append(gle.posting_date.lte(self.filters.period_end_date)) + + if acc_type == "receivable": + outstanding = (Sum(gle.debit) - Sum(gle.credit)).as_("outstanding") + else: + outstanding = (Sum(gle.credit) - Sum(gle.debit)).as_("outstanding") + + self.account_types[acc_type].gle = ( + qb.from_(gle) + .select( + gle.company, + gle.account, + gle.voucher_no, + gle.party, + outstanding, + ) + .where( + (gle.company == self.filters.company) + & (gle.is_cancelled == 0) + & (gle.account.isin(val.accounts)) + ) + .where(Criterion.all(filter_criterion)) + .groupby(gle.company, gle.account, gle.voucher_no, gle.party) + .run() + ) + + def get_ple(self): + ple = qb.DocType("Payment Ledger Entry") + + for acc_type, val in self.account_types.items(): + if val.accounts: + + filter_criterion = [] + if self.filters.voucher_no: + filter_criterion.append((ple.voucher_no == self.filters.voucher_no)) + + if self.filters.period_start_date: + filter_criterion.append(ple.posting_date.gte(self.filters.period_start_date)) + + if self.filters.period_end_date: + filter_criterion.append(ple.posting_date.lte(self.filters.period_end_date)) + + self.account_types[acc_type].ple = ( + qb.from_(ple) + .select( + ple.company, ple.account, ple.voucher_no, ple.party, Sum(ple.amount).as_("outstanding") + ) + .where( + (ple.company == self.filters.company) + & (ple.delinked == 0) + & (ple.account.isin(val.accounts)) + ) + .where(Criterion.all(filter_criterion)) + .groupby(ple.company, ple.account, ple.voucher_no, ple.party) + .run() + ) + + def compare(self): + self.gle_balances = set() + self.ple_balances = set() + + # consolidate both receivable and payable balances in one set + for acc_type, val in self.account_types.items(): + self.gle_balances = set(val.gle) | self.gle_balances + self.ple_balances = set(val.ple) | self.ple_balances + + self.variation_in_payment_ledger = self.gle_balances.difference(self.ple_balances) + self.variation_in_general_ledger = self.ple_balances.difference(self.gle_balances) + self.diff = frappe._dict({}) + + for x in self.variation_in_payment_ledger: + self.diff[(x[0], x[1], x[2], x[3])] = frappe._dict({"gl_balance": x[4]}) + + for x in self.variation_in_general_ledger: + self.diff.setdefault((x[0], x[1], x[2], x[3]), frappe._dict({"gl_balance": 0.0})).update( + frappe._dict({"pl_balance": x[4]}) + ) + + def generate_data(self): + self.data = [] + for key, val in self.diff.items(): + self.data.append( + frappe._dict( + { + "voucher_no": key[2], + "party": key[3], + "gl_balance": val.gl_balance, + "pl_balance": val.pl_balance, + } + ) + ) + + def get_columns(self): + self.columns = [] + options = None + self.columns.append( + dict( + label=_("Voucher No"), + fieldname="voucher_no", + fieldtype="Data", + options=options, + width="100", + ) + ) + + self.columns.append( + dict( + label=_("Party"), + fieldname="party", + fieldtype="Data", + options=options, + width="100", + ) + ) + + self.columns.append( + dict( + label=_("GL Balance"), + fieldname="gl_balance", + fieldtype="Currency", + options="Company:company:default_currency", + width="100", + ) + ) + + self.columns.append( + dict( + label=_("Payment Ledger Balance"), + fieldname="pl_balance", + fieldtype="Currency", + options="Company:company:default_currency", + width="100", + ) + ) + + def run(self): + self.get_accounts() + self.generate_filters() + self.get_gle() + self.get_ple() + self.compare() + self.generate_data() + self.get_columns() + + return self.columns, self.data + + +def execute(filters=None): + columns, data = [], [] + + rpt = General_Payment_Ledger_Comparison(filters) + columns, data = rpt.run() + + return columns, data diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/test_general_and_payment_ledger_comparison.py b/erpnext/accounts/report/general_and_payment_ledger_comparison/test_general_and_payment_ledger_comparison.py new file mode 100644 index 000000000000..4b0e99d71254 --- /dev/null +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/test_general_and_payment_ledger_comparison.py @@ -0,0 +1,100 @@ +import unittest + +import frappe +from frappe import qb +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days + +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.report.general_and_payment_ledger_comparison.general_and_payment_ledger_comparison import ( + execute, +) +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin + + +class TestGeneralAndPaymentLedger(FrappeTestCase, AccountsTestMixin): + def setUp(self): + self.create_company() + self.cleanup() + + def tearDown(self): + frappe.db.rollback() + + def cleanup(self): + doctypes = [] + doctypes.append(qb.DocType("GL Entry")) + doctypes.append(qb.DocType("Payment Ledger Entry")) + doctypes.append(qb.DocType("Sales Invoice")) + + for doctype in doctypes: + qb.from_(doctype).delete().where(doctype.company == self.company).run() + + def test_01_basic_report_functionality(self): + sinv = create_sales_invoice( + company=self.company, + debit_to=self.debit_to, + expense_account=self.expense_account, + cost_center=self.cost_center, + income_account=self.income_account, + warehouse=self.warehouse, + ) + + # manually edit the payment ledger entry + ple = frappe.db.get_all( + "Payment Ledger Entry", filters={"voucher_no": sinv.name, "delinked": 0} + )[0] + frappe.db.set_value("Payment Ledger Entry", ple.name, "amount", sinv.grand_total - 1) + + filters = frappe._dict({"company": self.company}) + columns, data = execute(filters=filters) + self.assertEqual(len(data), 1) + + expected = { + "voucher_no": sinv.name, + "party": sinv.customer, + "gl_balance": sinv.grand_total, + "pl_balance": sinv.grand_total - 1, + } + self.assertEqual(expected, data[0]) + + # account filter + filters = frappe._dict({"company": self.company, "account": self.debit_to}) + columns, data = execute(filters=filters) + self.assertEqual(len(data), 1) + self.assertEqual(expected, data[0]) + + filters = frappe._dict({"company": self.company, "account": self.creditors}) + columns, data = execute(filters=filters) + self.assertEqual([], data) + + # voucher_no filter + filters = frappe._dict({"company": self.company, "voucher_no": sinv.name}) + columns, data = execute(filters=filters) + self.assertEqual(len(data), 1) + self.assertEqual(expected, data[0]) + + filters = frappe._dict({"company": self.company, "voucher_no": sinv.name + "-1"}) + columns, data = execute(filters=filters) + self.assertEqual([], data) + + # date range filter + filters = frappe._dict( + { + "company": self.company, + "period_start_date": sinv.posting_date, + "period_end_date": sinv.posting_date, + } + ) + columns, data = execute(filters=filters) + self.assertEqual(len(data), 1) + self.assertEqual(expected, data[0]) + + filters = frappe._dict( + { + "company": self.company, + "period_start_date": add_days(sinv.posting_date, -1), + "period_end_date": add_days(sinv.posting_date, -1), + } + ) + columns, data = execute(filters=filters) + self.assertEqual([], data) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 23403a4b15b2..d670a3569755 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -272,20 +272,19 @@ def get_conditions(filters): if match_conditions: conditions.append(match_conditions) - if filters.get("include_dimensions"): - accounting_dimensions = get_accounting_dimensions(as_list=False) - - if accounting_dimensions: - for dimension in accounting_dimensions: - if not dimension.disabled: - if filters.get(dimension.fieldname): - if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): - filters[dimension.fieldname] = get_dimension_with_children( - dimension.document_type, filters.get(dimension.fieldname) - ) - conditions.append("{0} in %({0})s".format(dimension.fieldname)) - else: - conditions.append("{0} in %({0})s".format(dimension.fieldname)) + accounting_dimensions = get_accounting_dimensions(as_list=False) + + if accounting_dimensions: + for dimension in accounting_dimensions: + if not dimension.disabled: + if filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, filters.get(dimension.fieldname) + ) + conditions.append("{0} in %({0})s".format(dimension.fieldname)) + else: + conditions.append("{0} in %({0})s".format(dimension.fieldname)) return "and {}".format(" and ".join(conditions)) if conditions else "" diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 2bfb4105c1de..de3d57d095a8 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -544,6 +544,8 @@ def get_average_rate_based_on_group_by(self): new_row.qty += flt(row.qty) new_row.buying_amount += flt(row.buying_amount, self.currency_precision) new_row.base_amount += flt(row.base_amount, self.currency_precision) + if self.filters.get("group_by") == "Sales Person": + new_row.allocated_amount += flt(row.allocated_amount, self.currency_precision) new_row = self.set_average_rate(new_row) self.grouped_data.append(new_row) diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py index 050e6bc5d2f3..ce1a62d00659 100644 --- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py +++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py @@ -287,7 +287,7 @@ def get_conditions(filters): conditions = "" for opts in ( - ("company", " and company=%(company)s"), + ("company", " and `tabPurchase Invoice`.company=%(company)s"), ("supplier", " and `tabPurchase Invoice`.supplier = %(supplier)s"), ("item_code", " and `tabPurchase Invoice Item`.item_code = %(item_code)s"), ("from_date", " and `tabPurchase Invoice`.posting_date>=%(from_date)s"), diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index 4d24dd907621..19bb449cd94d 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -332,7 +332,7 @@ def get_conditions(filters, additional_conditions=None): conditions = "" for opts in ( - ("company", " and company=%(company)s"), + ("company", " and `tabSales Invoice`.company=%(company)s"), ("customer", " and `tabSales Invoice`.customer = %(customer)s"), ("item_code", " and `tabSales Invoice Item`.item_code = %(item_code)s"), ("from_date", " and `tabSales Invoice`.posting_date>=%(from_date)s"), diff --git a/erpnext/accounts/report/pos_register/pos_register.py b/erpnext/accounts/report/pos_register/pos_register.py index 9c0aba332efe..488bb9957c96 100644 --- a/erpnext/accounts/report/pos_register/pos_register.py +++ b/erpnext/accounts/report/pos_register/pos_register.py @@ -50,20 +50,20 @@ def get_pos_entries(filters, group_by_field): order_by = "p.posting_date" select_mop_field, from_sales_invoice_payment, group_by_mop_condition = "", "", "" if group_by_field == "mode_of_payment": - select_mop_field = ", sip.mode_of_payment" + select_mop_field = ", sip.mode_of_payment, sip.base_amount - IF(sip.type='Cash', p.change_amount, 0) as paid_amount" from_sales_invoice_payment = ", `tabSales Invoice Payment` sip" - group_by_mop_condition = "sip.parent = p.name AND ifnull(sip.base_amount, 0) != 0 AND" + group_by_mop_condition = "sip.parent = p.name AND ifnull(sip.base_amount - IF(sip.type='Cash', p.change_amount, 0), 0) != 0 AND" order_by += ", sip.mode_of_payment" elif group_by_field: order_by += ", p.{}".format(group_by_field) + select_mop_field = ", p.base_paid_amount - p.change_amount as paid_amount " return frappe.db.sql( """ SELECT p.posting_date, p.name as pos_invoice, p.pos_profile, - p.owner, p.base_grand_total as grand_total, p.base_paid_amount - p.change_amount as paid_amount, - p.customer, p.is_return {select_mop_field} + p.owner, p.customer, p.is_return, p.base_grand_total as grand_total {select_mop_field} FROM `tabPOS Invoice` p {from_sales_invoice_payment} WHERE diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.js b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.js index d3d45b353a6b..c42028b61f50 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.js +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.js @@ -12,17 +12,35 @@ frappe.query_reports["TDS Computation Summary"] = { "default": frappe.defaults.get_default('company') }, { - "fieldname":"supplier", - "label": __("Supplier"), - "fieldtype": "Link", - "options": "Supplier", + "fieldname":"party_type", + "label": __("Party Type"), + "fieldtype": "Select", + "options": ["Supplier", "Customer"], + "reqd": 1, + "default": "Supplier", + "on_change": function(){ + frappe.query_report.set_filter_value("party", ""); + } + }, + { + "fieldname":"party", + "label": __("Party"), + "fieldtype": "Dynamic Link", + "get_options": function() { + var party_type = frappe.query_report.get_filter_value('party_type'); + var party = frappe.query_report.get_filter_value('party'); + if(party && !party_type) { + frappe.throw(__("Please select Party Type first")); + } + return party_type; + }, "get_query": function() { return { "filters": { "tax_withholding_category": ["!=",""], } } - } + }, }, { "fieldname":"from_date", diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index c6aa21cc8624..82f97f189418 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -9,9 +9,14 @@ def execute(filters=None): - validate_filters(filters) + if filters.get("party_type") == "Customer": + party_naming_by = frappe.db.get_single_value("Selling Settings", "cust_master_name") + else: + party_naming_by = frappe.db.get_single_value("Buying Settings", "supp_master_name") + + filters.update({"naming_series": party_naming_by}) - filters.naming_series = frappe.db.get_single_value("Buying Settings", "supp_master_name") + validate_filters(filters) columns = get_columns(filters) ( @@ -25,7 +30,7 @@ def execute(filters=None): res = get_result( filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, invoice_total_map ) - final_result = group_by_supplier_and_category(res) + final_result = group_by_party_and_category(res, filters) return columns, final_result @@ -43,60 +48,67 @@ def validate_filters(filters): filters["fiscal_year"] = from_year -def group_by_supplier_and_category(data): - supplier_category_wise_map = {} +def group_by_party_and_category(data, filters): + party_category_wise_map = {} for row in data: - supplier_category_wise_map.setdefault( - (row.get("supplier"), row.get("section_code")), + party_category_wise_map.setdefault( + (row.get("party"), row.get("section_code")), { "pan": row.get("pan"), - "supplier": row.get("supplier"), - "supplier_name": row.get("supplier_name"), + "tax_id": row.get("tax_id"), + "party": row.get("party"), + "party_name": row.get("party_name"), "section_code": row.get("section_code"), "entity_type": row.get("entity_type"), - "tds_rate": row.get("tds_rate"), - "total_amount_credited": 0.0, - "tds_deducted": 0.0, + "rate": row.get("rate"), + "total_amount": 0.0, + "tax_amount": 0.0, }, ) - supplier_category_wise_map.get((row.get("supplier"), row.get("section_code")))[ - "total_amount_credited" - ] += row.get("total_amount_credited", 0.0) + party_category_wise_map.get((row.get("party"), row.get("section_code")))[ + "total_amount" + ] += row.get("total_amount", 0.0) - supplier_category_wise_map.get((row.get("supplier"), row.get("section_code")))[ - "tds_deducted" - ] += row.get("tds_deducted", 0.0) + party_category_wise_map.get((row.get("party"), row.get("section_code")))[ + "tax_amount" + ] += row.get("tax_amount", 0.0) - final_result = get_final_result(supplier_category_wise_map) + final_result = get_final_result(party_category_wise_map) return final_result -def get_final_result(supplier_category_wise_map): +def get_final_result(party_category_wise_map): out = [] - for key, value in supplier_category_wise_map.items(): + for key, value in party_category_wise_map.items(): out.append(value) return out def get_columns(filters): + pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id" columns = [ - {"label": _("PAN"), "fieldname": "pan", "fieldtype": "Data", "width": 90}, + {"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 90}, { - "label": _("Supplier"), - "options": "Supplier", - "fieldname": "supplier", - "fieldtype": "Link", + "label": _(filters.get("party_type")), + "fieldname": "party", + "fieldtype": "Dynamic Link", + "options": "party_type", "width": 180, }, ] if filters.naming_series == "Naming Series": columns.append( - {"label": _("Supplier Name"), "fieldname": "supplier_name", "fieldtype": "Data", "width": 180} + { + "label": _(filters.party_type + " Name"), + "fieldname": "party_name", + "fieldtype": "Data", + "width": 180, + } ) columns.extend( @@ -109,18 +121,23 @@ def get_columns(filters): "width": 180, }, {"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 180}, - {"label": _("TDS Rate %"), "fieldname": "tds_rate", "fieldtype": "Percent", "width": 90}, { - "label": _("Total Amount Credited"), - "fieldname": "total_amount_credited", + "label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"), + "fieldname": "rate", + "fieldtype": "Percent", + "width": 120, + }, + { + "label": _("Total Amount"), + "fieldname": "total_amount", "fieldtype": "Float", - "width": 90, + "width": 120, }, { - "label": _("Amount of TDS Deducted"), - "fieldname": "tds_deducted", + "label": _("Tax Amount"), + "fieldname": "tax_amount", "fieldtype": "Float", - "width": 90, + "width": 120, }, ] ) diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.js b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.js index ff2aa306017b..6585ea0a293b 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.js +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.js @@ -12,10 +12,35 @@ frappe.query_reports["TDS Payable Monthly"] = { "default": frappe.defaults.get_default('company') }, { - "fieldname":"supplier", - "label": __("Supplier"), - "fieldtype": "Link", - "options": "Supplier", + "fieldname":"party_type", + "label": __("Party Type"), + "fieldtype": "Select", + "options": ["Supplier", "Customer"], + "reqd": 1, + "default": "Supplier", + "on_change": function(){ + frappe.query_report.set_filter_value("party", ""); + } + }, + { + "fieldname":"party", + "label": __("Party"), + "fieldtype": "Dynamic Link", + "get_options": function() { + var party_type = frappe.query_report.get_filter_value('party_type'); + var party = frappe.query_report.get_filter_value('party'); + if(party && !party_type) { + frappe.throw(__("Please select Party Type first")); + } + return party_type; + }, + "get_query": function() { + return { + "filters": { + "tax_withholding_category": ["!=",""], + } + } + }, }, { "fieldname":"from_date", 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 98838907be1f..f2ec31c70e10 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -7,19 +7,26 @@ def execute(filters=None): + if filters.get("party_type") == "Customer": + party_naming_by = frappe.db.get_single_value("Selling Settings", "cust_master_name") + else: + party_naming_by = frappe.db.get_single_value("Buying Settings", "supp_master_name") + + filters.update({"naming_series": party_naming_by}) + validate_filters(filters) ( tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, - invoice_net_total_map, + net_total_map, ) = get_tds_docs(filters) columns = get_columns(filters) res = get_result( - filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, invoice_net_total_map + filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map ) return columns, res @@ -31,79 +38,100 @@ def validate_filters(filters): def get_result( - filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, invoice_net_total_map + filters, tds_docs, tds_accounts, tax_category_map, journal_entry_party_map, net_total_map ): - supplier_map = get_supplier_pan_map() + party_map = get_party_pan_map(filters.get("party_type")) tax_rate_map = get_tax_rate_map(filters) gle_map = get_gle_map(tds_docs) out = [] for name, details in gle_map.items(): - tds_deducted, total_amount_credited = 0, 0 + tax_amount, total_amount, grand_total, base_total = 0, 0, 0, 0 tax_withholding_category = tax_category_map.get(name) rate = tax_rate_map.get(tax_withholding_category) for entry in details: - supplier = entry.party or entry.against + party = entry.party or entry.against posting_date = entry.posting_date voucher_type = entry.voucher_type if voucher_type == "Journal Entry": - suppliers = journal_entry_party_map.get(name) - if suppliers: - supplier = suppliers[0] + party_list = journal_entry_party_map.get(name) + if party_list: + party = party_list[0] if not tax_withholding_category: - tax_withholding_category = supplier_map.get(supplier, {}).get("tax_withholding_category") + tax_withholding_category = party_map.get(party, {}).get("tax_withholding_category") rate = tax_rate_map.get(tax_withholding_category) if entry.account in tds_accounts: - tds_deducted += entry.credit - entry.debit + tax_amount += entry.credit - entry.debit + + if net_total_map.get(name): + if voucher_type == "Journal Entry": + # back calcalute total amount from rate and tax_amount + total_amount = grand_total = base_total = tax_amount / (rate / 100) + else: + total_amount, grand_total, base_total = net_total_map.get(name) + else: + total_amount += entry.credit - if invoice_net_total_map.get(name): - total_amount_credited = invoice_net_total_map.get(name) + if tax_amount: + if party_map.get(party, {}).get("party_type") == "Supplier": + party_name = "supplier_name" + party_type = "supplier_type" else: - total_amount_credited += entry.credit + party_name = "customer_name" + party_type = "customer_type" - if tds_deducted: row = { "pan" - if frappe.db.has_column("Supplier", "pan") - else "tax_id": supplier_map.get(supplier, {}).get("pan"), - "supplier": supplier_map.get(supplier, {}).get("name"), + if frappe.db.has_column(filters.party_type, "pan") + else "tax_id": party_map.get(party, {}).get("pan"), + "party": party_map.get(party, {}).get("name"), } if filters.naming_series == "Naming Series": - row.update({"supplier_name": supplier_map.get(supplier, {}).get("supplier_name")}) + row.update({"party_name": party_map.get(party, {}).get(party_name)}) row.update( { "section_code": tax_withholding_category, - "entity_type": supplier_map.get(supplier, {}).get("supplier_type"), - "tds_rate": rate, - "total_amount_credited": total_amount_credited, - "tds_deducted": tds_deducted, + "entity_type": party_map.get(party, {}).get(party_type), + "rate": rate, + "total_amount": total_amount, + "grand_total": grand_total, + "base_total": base_total, + "tax_amount": tax_amount, "transaction_date": posting_date, "transaction_type": voucher_type, "ref_no": name, } ) - out.append(row) return out -def get_supplier_pan_map(): - supplier_map = frappe._dict() - suppliers = frappe.db.get_all( - "Supplier", fields=["name", "pan", "supplier_type", "supplier_name", "tax_withholding_category"] - ) +def get_party_pan_map(party_type): + party_map = frappe._dict() - for d in suppliers: - supplier_map[d.name] = d + fields = ["name", "tax_withholding_category"] + if party_type == "Supplier": + fields += ["supplier_type", "supplier_name"] + else: + fields += ["customer_type", "customer_name"] + + if frappe.db.has_column(party_type, "pan"): + fields.append("pan") + + party_details = frappe.db.get_all(party_type, fields=fields) - return supplier_map + for party in party_details: + party.party_type = party_type + party_map[party.name] = party + + return party_map def get_gle_map(documents): @@ -127,59 +155,81 @@ def get_gle_map(documents): def get_columns(filters): - pan = "pan" if frappe.db.has_column("Supplier", "pan") else "tax_id" + pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id" columns = [ - {"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 90}, + {"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 60}, { - "label": _("Supplier"), - "options": "Supplier", - "fieldname": "supplier", - "fieldtype": "Link", + "label": _(filters.get("party_type")), + "fieldname": "party", + "fieldtype": "Dynamic Link", + "options": "party_type", "width": 180, }, ] if filters.naming_series == "Naming Series": columns.append( - {"label": _("Supplier Name"), "fieldname": "supplier_name", "fieldtype": "Data", "width": 180} + { + "label": _(filters.party_type + " Name"), + "fieldname": "party_name", + "fieldtype": "Data", + "width": 180, + } ) columns.extend( [ + { + "label": _("Date of Transaction"), + "fieldname": "transaction_date", + "fieldtype": "Date", + "width": 100, + }, { "label": _("Section Code"), "options": "Tax Withholding Category", "fieldname": "section_code", "fieldtype": "Link", - "width": 180, + "width": 90, }, - {"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 180}, - {"label": _("TDS Rate %"), "fieldname": "tds_rate", "fieldtype": "Percent", "width": 90}, + {"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 100}, { - "label": _("Total Amount Credited"), - "fieldname": "total_amount_credited", + "label": _("Total Amount"), + "fieldname": "total_amount", "fieldtype": "Float", "width": 90, }, { - "label": _("Amount of TDS Deducted"), - "fieldname": "tds_deducted", + "label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"), + "fieldname": "rate", + "fieldtype": "Percent", + "width": 90, + }, + { + "label": _("Tax Amount"), + "fieldname": "tax_amount", "fieldtype": "Float", "width": 90, }, { - "label": _("Date of Transaction"), - "fieldname": "transaction_date", - "fieldtype": "Date", + "label": _("Grand Total"), + "fieldname": "grand_total", + "fieldtype": "Float", + "width": 90, + }, + { + "label": _("Base Total"), + "fieldname": "base_total", + "fieldtype": "Float", "width": 90, }, - {"label": _("Transaction Type"), "fieldname": "transaction_type", "width": 90}, + {"label": _("Transaction Type"), "fieldname": "transaction_type", "width": 100}, { "label": _("Reference No."), "fieldname": "ref_no", "fieldtype": "Dynamic Link", "options": "transaction_type", - "width": 90, + "width": 180, }, ] ) @@ -190,10 +240,11 @@ def get_columns(filters): def get_tds_docs(filters): tds_documents = [] purchase_invoices = [] + sales_invoices = [] payment_entries = [] journal_entries = [] tax_category_map = frappe._dict() - invoice_net_total_map = frappe._dict() + net_total_map = frappe._dict() or_filters = frappe._dict() journal_entry_party_map = frappe._dict() bank_accounts = frappe.get_all("Account", {"is_group": 0, "account_type": "Bank"}, pluck="name") @@ -209,10 +260,13 @@ def get_tds_docs(filters): "against": ("not in", bank_accounts), } - if filters.get("supplier"): + party = frappe.get_all(filters.get("party_type"), pluck="name") + or_filters.update({"against": ("in", party), "voucher_type": "Journal Entry"}) + + if filters.get("party"): del query_filters["account"] del query_filters["against"] - or_filters = {"against": filters.get("supplier"), "party": filters.get("supplier")} + or_filters = {"against": filters.get("party"), "party": filters.get("party")} tds_docs = frappe.get_all( "GL Entry", @@ -224,6 +278,8 @@ def get_tds_docs(filters): for d in tds_docs: if d.voucher_type == "Purchase Invoice": purchase_invoices.append(d.voucher_no) + if d.voucher_type == "Sales Invoice": + sales_invoices.append(d.voucher_no) elif d.voucher_type == "Payment Entry": payment_entries.append(d.voucher_no) elif d.voucher_type == "Journal Entry": @@ -232,21 +288,24 @@ def get_tds_docs(filters): tds_documents.append(d.voucher_no) if purchase_invoices: - get_doc_info(purchase_invoices, "Purchase Invoice", tax_category_map, invoice_net_total_map) + get_doc_info(purchase_invoices, "Purchase Invoice", tax_category_map, net_total_map) + + if sales_invoices: + get_doc_info(sales_invoices, "Sales Invoice", tax_category_map, net_total_map) if payment_entries: - get_doc_info(payment_entries, "Payment Entry", tax_category_map) + get_doc_info(payment_entries, "Payment Entry", tax_category_map, net_total_map) if journal_entries: journal_entry_party_map = get_journal_entry_party_map(journal_entries) - get_doc_info(journal_entries, "Journal Entry", tax_category_map) + get_doc_info(journal_entries, "Journal Entry", tax_category_map, net_total_map) return ( tds_documents, tds_accounts, tax_category_map, journal_entry_party_map, - invoice_net_total_map, + net_total_map, ) @@ -254,7 +313,11 @@ def get_journal_entry_party_map(journal_entries): journal_entry_party_map = {} for d in frappe.db.get_all( "Journal Entry Account", - {"parent": ("in", journal_entries), "party_type": "Supplier", "party": ("is", "set")}, + { + "parent": ("in", journal_entries), + "party_type": ("in", ("Supplier", "Customer")), + "party": ("is", "set"), + }, ["parent", "party"], ): if d.parent not in journal_entry_party_map: @@ -264,18 +327,40 @@ def get_journal_entry_party_map(journal_entries): return journal_entry_party_map -def get_doc_info(vouchers, doctype, tax_category_map, invoice_net_total_map=None): - if doctype == "Purchase Invoice": - fields = ["name", "tax_withholding_category", "base_tax_withholding_net_total"] - else: - fields = ["name", "tax_withholding_category"] +def get_doc_info(vouchers, doctype, tax_category_map, net_total_map=None): + common_fields = ["name"] + fields_dict = { + "Purchase Invoice": [ + "tax_withholding_category", + "base_tax_withholding_net_total", + "grand_total", + "base_total", + ], + "Sales Invoice": ["base_net_total", "grand_total", "base_total"], + "Payment Entry": [ + "tax_withholding_category", + "paid_amount", + "paid_amount_after_tax", + "base_paid_amount", + ], + "Journal Entry": ["tax_withholding_category", "total_amount"], + } - entries = frappe.get_all(doctype, filters={"name": ("in", vouchers)}, fields=fields) + entries = frappe.get_all( + doctype, filters={"name": ("in", vouchers)}, fields=common_fields + fields_dict[doctype] + ) for entry in entries: tax_category_map.update({entry.name: entry.tax_withholding_category}) if doctype == "Purchase Invoice": - invoice_net_total_map.update({entry.name: entry.base_tax_withholding_net_total}) + value = [entry.base_tax_withholding_net_total, entry.grand_total, entry.base_total] + elif doctype == "Sales Invoice": + value = [entry.base_net_total, entry.grand_total, entry.base_total] + elif doctype == "Payment Entry": + value = [entry.paid_amount, entry.paid_amount_after_tax, entry.base_paid_amount] + else: + value = [entry.total_amount] * 3 + net_total_map.update({entry.name: value}) def get_tax_rate_map(filters): diff --git a/erpnext/accounts/report/tds_payable_monthly/test_tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/test_tds_payable_monthly.py new file mode 100644 index 000000000000..89ecef1904c0 --- /dev/null +++ b/erpnext/accounts/report/tds_payable_monthly/test_tds_payable_monthly.py @@ -0,0 +1,111 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today + +from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry +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.accounts.doctype.tax_withholding_category.test_tax_withholding_category import ( + create_tax_withholding_category, +) +from erpnext.accounts.report.tds_payable_monthly.tds_payable_monthly import execute +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.accounts.utils import get_fiscal_year + + +class TestTdsPayableMonthly(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.create_company() + self.clear_old_entries() + create_tax_accounts() + create_tcs_category() + + def test_tax_withholding_for_customers(self): + si = create_sales_invoice(rate=1000) + pe = create_tcs_payment_entry() + filters = frappe._dict( + company="_Test Company", party_type="Customer", from_date=today(), to_date=today() + ) + result = execute(filters)[1] + expected_values = [ + [pe.name, "TCS", 0.075, 2550, 0.53, 2550.53], + [si.name, "TCS", 0.075, 1000, 0.53, 1000.53], + ] + self.check_expected_values(result, expected_values) + + def check_expected_values(self, result, expected_values): + for i in range(len(result)): + voucher = frappe._dict(result[i]) + voucher_expected_values = expected_values[i] + self.assertEqual(voucher.ref_no, voucher_expected_values[0]) + self.assertEqual(voucher.section_code, voucher_expected_values[1]) + self.assertEqual(voucher.rate, voucher_expected_values[2]) + self.assertEqual(voucher.base_total, voucher_expected_values[3]) + self.assertEqual(voucher.tax_amount, voucher_expected_values[4]) + self.assertEqual(voucher.grand_total, voucher_expected_values[5]) + + def tearDown(self): + self.clear_old_entries() + + +def create_tax_accounts(): + account_names = ["TCS", "TDS"] + for account in account_names: + frappe.get_doc( + { + "doctype": "Account", + "company": "_Test Company", + "account_name": account, + "parent_account": "Duties and Taxes - _TC", + "report_type": "Balance Sheet", + "root_type": "Liability", + } + ).insert(ignore_if_duplicate=True) + + +def create_tcs_category(): + fiscal_year = get_fiscal_year(today(), company="_Test Company") + from_date = fiscal_year[1] + to_date = fiscal_year[2] + + tax_category = create_tax_withholding_category( + category_name="TCS", + rate=0.075, + from_date=from_date, + to_date=to_date, + account="TCS - _TC", + cumulative_threshold=300, + ) + + customer = frappe.get_doc("Customer", "_Test Customer") + customer.tax_withholding_category = "TCS" + customer.save() + + +def create_tcs_payment_entry(): + payment_entry = create_payment_entry( + payment_type="Receive", + party_type="Customer", + party="_Test Customer", + paid_from="Debtors - _TC", + paid_to="Cash - _TC", + paid_amount=2550, + ) + + payment_entry.append( + "taxes", + { + "account_head": "TCS - _TC", + "charge_type": "Actual", + "tax_amount": 0.53, + "add_deduct_tax": "Add", + "description": "Test", + "cost_center": "Main - _TC", + }, + ) + payment_entry.submit() + return payment_entry diff --git a/erpnext/accounts/report/trial_balance/test_trial_balance.py b/erpnext/accounts/report/trial_balance/test_trial_balance.py new file mode 100644 index 000000000000..4682ac4500a8 --- /dev/null +++ b/erpnext/accounts/report/trial_balance/test_trial_balance.py @@ -0,0 +1,118 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today + +from erpnext.accounts.report.trial_balance.trial_balance import execute + + +class TestTrialBalance(FrappeTestCase): + def setUp(self): + from erpnext.accounts.doctype.account.test_account import create_account + from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center + from erpnext.accounts.utils import get_fiscal_year + + self.company = create_company() + create_cost_center( + cost_center_name="Test Cost Center", + company="Trial Balance Company", + parent_cost_center="Trial Balance Company - TBC", + ) + create_account( + account_name="Offsetting", + company="Trial Balance Company", + parent_account="Temporary Accounts - TBC", + ) + self.fiscal_year = get_fiscal_year(today(), company="Trial Balance Company")[0] + create_accounting_dimension() + + def test_offsetting_entries_for_accounting_dimensions(self): + """ + Checks if Trial Balance Report is balanced when filtered using a particular Accounting Dimension + """ + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + frappe.db.sql("delete from `tabSales Invoice` where company='Trial Balance Company'") + frappe.db.sql("delete from `tabGL Entry` where company='Trial Balance Company'") + + branch1 = frappe.new_doc("Branch") + branch1.branch = "Location 1" + branch1.insert(ignore_if_duplicate=True) + branch2 = frappe.new_doc("Branch") + branch2.branch = "Location 2" + branch2.insert(ignore_if_duplicate=True) + + si = create_sales_invoice( + company=self.company, + debit_to="Debtors - TBC", + cost_center="Test Cost Center - TBC", + income_account="Sales - TBC", + do_not_submit=1, + ) + si.branch = "Location 1" + si.items[0].branch = "Location 2" + si.save() + si.submit() + + filters = frappe._dict( + {"company": self.company, "fiscal_year": self.fiscal_year, "branch": ["Location 1"]} + ) + total_row = execute(filters)[1][-1] + self.assertEqual(total_row["debit"], total_row["credit"]) + + def tearDown(self): + clear_dimension_defaults("Branch") + disable_dimension() + + +def create_company(**args): + args = frappe._dict(args) + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": args.company_name or "Trial Balance Company", + "country": args.country or "India", + "default_currency": args.currency or "INR", + } + ) + company.insert(ignore_if_duplicate=True) + return company.name + + +def create_accounting_dimension(**args): + args = frappe._dict(args) + document_type = args.document_type or "Branch" + if frappe.db.exists("Accounting Dimension", document_type): + accounting_dimension = frappe.get_doc("Accounting Dimension", document_type) + accounting_dimension.disabled = 0 + else: + accounting_dimension = frappe.new_doc("Accounting Dimension") + accounting_dimension.document_type = document_type + accounting_dimension.insert() + + accounting_dimension.set("dimension_defaults", []) + accounting_dimension.append( + "dimension_defaults", + { + "company": args.company or "Trial Balance Company", + "automatically_post_balancing_accounting_entry": 1, + "offsetting_account": args.offsetting_account or "Offsetting - TBC", + }, + ) + accounting_dimension.save() + + +def disable_dimension(**args): + args = frappe._dict(args) + document_type = args.document_type or "Branch" + dimension = frappe.get_doc("Accounting Dimension", document_type) + dimension.disabled = 1 + dimension.save() + + +def clear_dimension_defaults(dimension_name): + accounting_dimension = frappe.get_doc("Accounting Dimension", dimension_name) + accounting_dimension.dimension_defaults = [] + accounting_dimension.save() diff --git a/erpnext/accounts/report/trial_balance/trial_balance.js b/erpnext/accounts/report/trial_balance/trial_balance.js index e45c3adcb6dd..6e233c802f0b 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.js +++ b/erpnext/accounts/report/trial_balance/trial_balance.js @@ -99,6 +99,12 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { "label": __("Include Default Book Entries"), "fieldtype": "Check", "default": 1 + }, + { + "fieldname": "show_net_values", + "label": __("Show net values in opening and closing columns"), + "fieldtype": "Check", + "default": 1 } ], "formatter": erpnext.financial_statements.formatter, diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 3f0e971be6fc..2a8aa0c202fc 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -120,7 +120,9 @@ def get_data(filters): ignore_opening_entries=True, ) - calculate_values(accounts, gl_entries_by_account, opening_balances) + calculate_values( + accounts, gl_entries_by_account, opening_balances, filters.get("show_net_values") + ) accumulate_values_into_parents(accounts, accounts_by_name) data = prepare_data(accounts, filters, parent_children_map, company_currency) @@ -142,14 +144,20 @@ def get_opening_balances(filters): def get_rootwise_opening_balances(filters, report_type): gle = [] - last_period_closing_voucher = frappe.db.get_all( - "Period Closing Voucher", - filters={"docstatus": 1, "company": filters.company, "posting_date": ("<", filters.from_date)}, - fields=["posting_date", "name"], - order_by="posting_date desc", - limit=1, + last_period_closing_voucher = "" + ignore_closing_balances = frappe.db.get_single_value( + "Accounts Settings", "ignore_account_closing_balance" ) + if not ignore_closing_balances: + last_period_closing_voucher = frappe.db.get_all( + "Period Closing Voucher", + filters={"docstatus": 1, "company": filters.company, "posting_date": ("<", filters.from_date)}, + fields=["posting_date", "name"], + order_by="posting_date desc", + limit=1, + ) + accounting_dimensions = get_accounting_dimensions(as_list=False) if last_period_closing_voucher: @@ -304,7 +312,7 @@ def get_opening_balance( return gle -def calculate_values(accounts, gl_entries_by_account, opening_balances): +def calculate_values(accounts, gl_entries_by_account, opening_balances, show_net_values): init = { "opening_debit": 0.0, "opening_credit": 0.0, @@ -329,7 +337,8 @@ def calculate_values(accounts, gl_entries_by_account, opening_balances): d["closing_debit"] = d["opening_debit"] + d["debit"] d["closing_credit"] = d["opening_credit"] + d["credit"] - prepare_opening_closing(d) + if show_net_values: + prepare_opening_closing(d) def calculate_total_row(accounts, company_currency): @@ -369,7 +378,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency): for d in accounts: # Prepare opening closing for group account - if parent_children_map.get(d.account): + if parent_children_map.get(d.account) and filters.get("show_net_values"): prepare_opening_closing(d) has_value = False diff --git a/erpnext/accounts/report/voucher_wise_balance/voucher_wise_balance.py b/erpnext/accounts/report/voucher_wise_balance/voucher_wise_balance.py index 5ab3611b9af8..bd9e9fccadce 100644 --- a/erpnext/accounts/report/voucher_wise_balance/voucher_wise_balance.py +++ b/erpnext/accounts/report/voucher_wise_balance/voucher_wise_balance.py @@ -46,6 +46,7 @@ def get_data(filters): .select( gle.voucher_type, gle.voucher_no, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit") ) + .where(gle.is_cancelled == 0) .groupby(gle.voucher_no) ) query = apply_filters(query, filters, gle) diff --git a/erpnext/accounts/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py new file mode 100644 index 000000000000..08688608f4b1 --- /dev/null +++ b/erpnext/accounts/test/accounts_mixin.py @@ -0,0 +1,163 @@ +import frappe +from frappe import qb + +from erpnext.stock.doctype.item.test_item import create_item + + +class AccountsTestMixin: + def create_customer(self, customer_name="_Test Customer", currency=None): + if not frappe.db.exists("Customer", customer_name): + customer = frappe.new_doc("Customer") + customer.customer_name = customer_name + customer.type = "Individual" + + if currency: + customer.default_currency = currency + customer.save() + self.customer = customer.name + else: + self.customer = customer_name + + def create_supplier(self, supplier_name="_Test Supplier", currency=None): + if not frappe.db.exists("Supplier", supplier_name): + supplier = frappe.new_doc("Supplier") + supplier.supplier_name = supplier_name + supplier.supplier_type = "Individual" + supplier.supplier_group = "Local" + + if currency: + supplier.default_currency = currency + supplier.save() + self.supplier = supplier.name + else: + self.supplier = supplier_name + + def create_item(self, item_name="_Test Item", is_stock=0, warehouse=None, company=None): + item = create_item(item_name, is_stock_item=is_stock, warehouse=warehouse, company=company) + self.item = item.name + + def create_company(self, company_name="_Test Company", abbr="_TC"): + self.company_abbr = abbr + if frappe.db.exists("Company", company_name): + company = frappe.get_doc("Company", company_name) + else: + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": company_name, + "country": "India", + "default_currency": "INR", + "create_chart_of_accounts_based_on": "Standard Template", + "chart_of_accounts": "Standard", + } + ) + company = company.save() + + self.company = company.name + self.cost_center = company.cost_center + self.warehouse = "Stores - " + abbr + self.finished_warehouse = "Finished Goods - " + abbr + self.income_account = "Sales - " + abbr + self.expense_account = "Cost of Goods Sold - " + abbr + self.debit_to = "Debtors - " + abbr + self.cash = "Cash - " + abbr + self.creditors = "Creditors - " + abbr + self.retained_earnings = "Retained Earnings - " + abbr + + # Deferred revenue, expense and bank accounts + other_accounts = [ + frappe._dict( + { + "attribute_name": "deferred_revenue", + "account_name": "Deferred Revenue", + "parent_account": "Current Liabilities - " + abbr, + } + ), + frappe._dict( + { + "attribute_name": "deferred_expense", + "account_name": "Deferred Expense", + "parent_account": "Current Assets - " + abbr, + } + ), + frappe._dict( + { + "attribute_name": "bank", + "account_name": "HDFC", + "parent_account": "Bank Accounts - " + abbr, + } + ), + ] + for acc in other_accounts: + acc_name = acc.account_name + " - " + abbr + if frappe.db.exists("Account", acc_name): + setattr(self, acc.attribute_name, acc_name) + else: + new_acc = frappe.get_doc( + { + "doctype": "Account", + "account_name": acc.account_name, + "parent_account": acc.parent_account, + "company": self.company, + } + ) + new_acc.save() + setattr(self, acc.attribute_name, new_acc.name) + + def create_usd_receivable_account(self): + account_name = "Debtors USD" + if not frappe.db.get_value( + "Account", filters={"account_name": account_name, "company": self.company} + ): + acc = frappe.new_doc("Account") + acc.account_name = account_name + acc.parent_account = "Accounts Receivable - " + self.company_abbr + acc.company = self.company + acc.account_currency = "USD" + acc.account_type = "Receivable" + acc.insert() + else: + name = frappe.db.get_value( + "Account", + filters={"account_name": account_name, "company": self.company}, + fieldname="name", + pluck=True, + ) + acc = frappe.get_doc("Account", name) + self.debtors_usd = acc.name + + def create_usd_payable_account(self): + account_name = "Creditors USD" + if not frappe.db.get_value( + "Account", filters={"account_name": account_name, "company": self.company} + ): + acc = frappe.new_doc("Account") + acc.account_name = account_name + acc.parent_account = "Accounts Payable - " + self.company_abbr + acc.company = self.company + acc.account_currency = "USD" + acc.account_type = "Payable" + acc.insert() + else: + name = frappe.db.get_value( + "Account", + filters={"account_name": account_name, "company": self.company}, + fieldname="name", + pluck=True, + ) + acc = frappe.get_doc("Account", name) + self.creditors_usd = acc.name + + def clear_old_entries(self): + doctype_list = [ + "GL Entry", + "Payment Ledger Entry", + "Sales Invoice", + "Purchase Invoice", + "Payment Entry", + "Journal Entry", + "Sales Order", + "Exchange Rate Revaluation", + ] + for doctype in doctype_list: + qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run() diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py index 882cd694a326..3d5e5fc4ec7e 100644 --- a/erpnext/accounts/test/test_utils.py +++ b/erpnext/accounts/test/test_utils.py @@ -3,6 +3,8 @@ import frappe from frappe.test_runner import make_test_objects +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.party import get_party_shipping_address from erpnext.accounts.utils import ( get_future_stock_vouchers, @@ -73,6 +75,56 @@ def test_stock_voucher_sorting(self): sorted_vouchers = sort_stock_vouchers_by_posting_date(list(reversed(vouchers))) self.assertEqual(sorted_vouchers, vouchers) + def test_update_reference_in_payment_entry(self): + item = make_item().name + + purchase_invoice = make_purchase_invoice( + item=item, supplier="_Test Supplier USD", currency="USD", conversion_rate=82.32, do_not_submit=1 + ) + purchase_invoice.credit_to = "_Test Payable USD - _TC" + purchase_invoice.submit() + + payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name) + payment_entry.paid_amount = 15725 + payment_entry.deductions = [] + payment_entry.save() + + # below is the difference between base_received_amount and base_paid_amount + self.assertEqual(payment_entry.difference_amount, -4855.0) + + payment_entry.target_exchange_rate = 62.9 + payment_entry.save() + + # below is due to change in exchange rate + self.assertEqual(payment_entry.references[0].exchange_gain_loss, -4855.0) + + payment_entry.references = [] + self.assertEqual(payment_entry.difference_amount, 0.0) + payment_entry.submit() + + payment_reconciliation = frappe.new_doc("Payment Reconciliation") + payment_reconciliation.company = payment_entry.company + payment_reconciliation.party_type = "Supplier" + payment_reconciliation.party = purchase_invoice.supplier + payment_reconciliation.receivable_payable_account = payment_entry.paid_to + payment_reconciliation.get_unreconciled_entries() + payment_reconciliation.allocate_entries( + { + "payments": [d.__dict__ for d in payment_reconciliation.payments], + "invoices": [d.__dict__ for d in payment_reconciliation.invoices], + } + ) + for d in payment_reconciliation.invoices: + # Reset invoice outstanding_amount because allocate_entries will zero this value out. + d.outstanding_amount = d.amount + for d in payment_reconciliation.allocation: + d.difference_account = "Exchange Gain/Loss - _TC" + payment_reconciliation.reconcile() + + payment_entry.load_from_db() + self.assertEqual(len(payment_entry.references), 1) + self.assertEqual(payment_entry.difference_amount, 0) + ADDRESS_RECORDS = [ { diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index aa861a48eff3..76339713a223 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -458,7 +458,12 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n # update ref in advance entry if voucher_type == "Journal Entry": - update_reference_in_journal_entry(entry, doc, do_not_save=True) + referenced_row = update_reference_in_journal_entry(entry, doc, do_not_save=False) + # advance section in sales/purchase invoice and reconciliation tool,both pass on exchange gain/loss + # amount and account in args + # referenced_row is used to deduplicate gain/loss journal + entry.update({"referenced_row": referenced_row}) + doc.make_exchange_gain_loss_journal([entry]) else: update_reference_in_payment_entry( entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe @@ -555,6 +560,10 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False): """ jv_detail = journal_entry.get("accounts", {"name": d["voucher_detail_no"]})[0] + # Update Advance Paid in SO/PO since they might be getting unlinked + if jv_detail.get("reference_type") in ("Sales Order", "Purchase Order"): + frappe.get_doc(jv_detail.reference_type, jv_detail.reference_name).set_total_advance_paid() + if flt(d["unadjusted_amount"]) - flt(d["allocated_amount"]) != 0: # adjust the unreconciled balance amount_in_account_currency = flt(d["unadjusted_amount"]) - flt(d["allocated_amount"]) @@ -570,7 +579,11 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False): # new row with references new_row = journal_entry.append("accounts") - new_row.update((frappe.copy_doc(jv_detail)).as_dict()) + # Copy field values into new row + [ + new_row.set(field, jv_detail.get(field)) + for field in frappe.get_meta("Journal Entry Account").get_fieldnames_with_value() + ] new_row.set(d["dr_or_cr"], d["allocated_amount"]) new_row.set( @@ -598,6 +611,8 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False): if not do_not_save: journal_entry.save(ignore_permissions=True) + return new_row.name + def update_reference_in_payment_entry( d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False @@ -608,14 +623,19 @@ def update_reference_in_payment_entry( "total_amount": d.grand_total, "outstanding_amount": d.outstanding_amount, "allocated_amount": d.allocated_amount, - "exchange_rate": d.exchange_rate - if not d.exchange_gain_loss - else payment_entry.get_exchange_rate(), + "exchange_rate": d.exchange_rate if d.exchange_gain_loss else payment_entry.get_exchange_rate(), "exchange_gain_loss": d.exchange_gain_loss, # only populated from invoice in case of advance allocation } if d.voucher_detail_no: existing_row = payment_entry.get("references", {"name": d["voucher_detail_no"]})[0] + + # Update Advance Paid in SO/PO since they are getting unlinked + if existing_row.get("reference_doctype") in ("Sales Order", "Purchase Order"): + frappe.get_doc( + existing_row.reference_doctype, existing_row.reference_name + ).set_total_advance_paid() + original_row = existing_row.as_dict().copy() existing_row.update(reference_details) @@ -631,100 +651,188 @@ def update_reference_in_payment_entry( new_row.docstatus = 1 new_row.update(reference_details) - payment_entry.flags.ignore_validate_update_after_submit = True - payment_entry.setup_party_account_field() - payment_entry.set_missing_values() - payment_entry.set_amounts() - - if d.difference_amount and d.difference_account: - account_details = { - "account": d.difference_account, - "cost_center": payment_entry.cost_center - or frappe.get_cached_value("Company", payment_entry.company, "cost_center"), - } - if d.difference_amount: - account_details["amount"] = d.difference_amount - - 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() + payment_entry.make_exchange_gain_loss_journal() if not do_not_save: payment_entry.save(ignore_permissions=True) -def unlink_ref_doc_from_payment_entries(ref_doc): - remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name) - remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name) +def cancel_exchange_gain_loss_journal( + parent_doc: dict | object, referenced_dt: str = None, referenced_dn: str = None +) -> None: + """ + Cancel Exchange Gain/Loss for Sales/Purchase Invoice, if they have any. + """ + if parent_doc.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]: + journals = frappe.db.get_all( + "Journal Entry Account", + filters={ + "reference_type": parent_doc.doctype, + "reference_name": parent_doc.name, + "docstatus": 1, + }, + fields=["parent"], + as_list=1, + ) - frappe.db.sql( - """update `tabGL Entry` - set against_voucher_type=null, against_voucher=null, - modified=%s, modified_by=%s - where against_voucher_type=%s and against_voucher=%s - and voucher_no != ifnull(against_voucher, '')""", - (now(), frappe.session.user, ref_doc.doctype, ref_doc.name), + if journals: + gain_loss_journals = frappe.db.get_all( + "Journal Entry", + filters={ + "name": ["in", [x[0] for x in journals]], + "voucher_type": "Exchange Gain Or Loss", + "docstatus": 1, + }, + as_list=1, + ) + for doc in gain_loss_journals: + gain_loss_je = frappe.get_doc("Journal Entry", doc[0]) + if referenced_dt and referenced_dn: + references = [(x.reference_type, x.reference_name) for x in gain_loss_je.accounts] + if ( + len(references) == 2 + and (referenced_dt, referenced_dn) in references + and (parent_doc.doctype, parent_doc.name) in references + ): + # only cancel JE generated against parent_doc and referenced_dn + gain_loss_je.cancel() + else: + gain_loss_je.cancel() + + +def update_accounting_ledgers_after_reference_removal( + ref_type: str = None, ref_no: str = None, payment_name: str = None +): + # General Ledger + gle = qb.DocType("GL Entry") + gle_update_query = ( + qb.update(gle) + .set(gle.against_voucher_type, None) + .set(gle.against_voucher, None) + .set(gle.modified, now()) + .set(gle.modified_by, frappe.session.user) + .where((gle.against_voucher_type == ref_type) & (gle.against_voucher == ref_no)) ) + if payment_name: + gle_update_query = gle_update_query.where(gle.voucher_no == payment_name) + gle_update_query.run() + + # Payment Ledger ple = qb.DocType("Payment Ledger Entry") + ple_update_query = ( + qb.update(ple) + .set(ple.against_voucher_type, ple.voucher_type) + .set(ple.against_voucher_no, ple.voucher_no) + .set(ple.modified, now()) + .set(ple.modified_by, frappe.session.user) + .where( + (ple.against_voucher_type == ref_type) + & (ple.against_voucher_no == ref_no) + & (ple.delinked == 0) + ) + ) + + if payment_name: + ple_update_query = ple_update_query.where(ple.voucher_no == payment_name) + ple_update_query.run() - qb.update(ple).set(ple.against_voucher_type, ple.voucher_type).set( - ple.against_voucher_no, ple.voucher_no - ).set(ple.modified, now()).set(ple.modified_by, frappe.session.user).where( - (ple.against_voucher_type == ref_doc.doctype) - & (ple.against_voucher_no == ref_doc.name) - & (ple.delinked == 0) - ).run() +def remove_ref_from_advance_section(ref_doc: object = None): + # TODO: this might need some testing if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"): ref_doc.set("advances", []) + adv_type = qb.DocType(f"{ref_doc.doctype} Advance") + qb.from_(adv_type).delete().where(adv_type.parent == ref_doc.name).run() - frappe.db.sql( - """delete from `tab{0} Advance` where parent = %s""".format(ref_doc.doctype), ref_doc.name - ) + +def unlink_ref_doc_from_payment_entries(ref_doc: object = None, payment_name: str = None): + remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name, payment_name) + remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name, payment_name) + update_accounting_ledgers_after_reference_removal(ref_doc.doctype, ref_doc.name, payment_name) + remove_ref_from_advance_section(ref_doc) -def remove_ref_doc_link_from_jv(ref_type, ref_no): - linked_jv = frappe.db.sql_list( - """select parent from `tabJournal Entry Account` - where reference_type=%s and reference_name=%s and docstatus < 2""", - (ref_type, ref_no), +def remove_ref_doc_link_from_jv( + ref_type: str = None, ref_no: str = None, payment_name: str = None +): + jea = qb.DocType("Journal Entry Account") + + linked_jv = ( + qb.from_(jea) + .select(jea.parent) + .where((jea.reference_type == ref_type) & (jea.reference_name == ref_no) & (jea.docstatus.lt(2))) + .run(as_list=1) ) + linked_jv = convert_to_list(linked_jv) + # remove reference only from specified payment + linked_jv = [x for x in linked_jv if x == payment_name] if payment_name else linked_jv if linked_jv: - frappe.db.sql( - """update `tabJournal Entry Account` - set reference_type=null, reference_name = null, - modified=%s, modified_by=%s - where reference_type=%s and reference_name=%s - and docstatus < 2""", - (now(), frappe.session.user, ref_type, ref_no), + update_query = ( + qb.update(jea) + .set(jea.reference_type, None) + .set(jea.reference_name, None) + .set(jea.modified, now()) + .set(jea.modified_by, frappe.session.user) + .where((jea.reference_type == ref_type) & (jea.reference_name == ref_no)) ) + if payment_name: + update_query = update_query.where(jea.parent == payment_name) + + update_query.run() + frappe.msgprint(_("Journal Entries {0} are un-linked").format("\n".join(linked_jv))) -def remove_ref_doc_link_from_pe(ref_type, ref_no): - linked_pe = frappe.db.sql_list( - """select parent from `tabPayment Entry Reference` - where reference_doctype=%s and reference_name=%s and docstatus < 2""", - (ref_type, ref_no), +def convert_to_list(result): + """ + Convert tuple to list + """ + return [x[0] for x in result] + + +def remove_ref_doc_link_from_pe( + ref_type: str = None, ref_no: str = None, payment_name: str = None +): + per = qb.DocType("Payment Entry Reference") + pay = qb.DocType("Payment Entry") + + linked_pe = ( + qb.from_(per) + .select(per.parent) + .where( + (per.reference_doctype == ref_type) & (per.reference_name == ref_no) & (per.docstatus.lt(2)) + ) + .run(as_list=1) ) + linked_pe = convert_to_list(linked_pe) + # remove reference only from specified payment + linked_pe = [x for x in linked_pe if x == payment_name] if payment_name else linked_pe if linked_pe: - frappe.db.sql( - """update `tabPayment Entry Reference` - set allocated_amount=0, modified=%s, modified_by=%s - where reference_doctype=%s and reference_name=%s - and docstatus < 2""", - (now(), frappe.session.user, ref_type, ref_no), + update_query = ( + qb.update(per) + .set(per.allocated_amount, 0) + .set(per.modified, now()) + .set(per.modified_by, frappe.session.user) + .where( + (per.docstatus.lt(2) & (per.reference_doctype == ref_type) & (per.reference_name == ref_no)) + ) ) + if payment_name: + update_query = update_query.where(per.parent == payment_name) + + update_query.run() + for pe in linked_pe: try: pe_doc = frappe.get_doc("Payment Entry", pe) @@ -737,19 +845,13 @@ def remove_ref_doc_link_from_pe(ref_type, ref_no): msg += _("Please cancel payment entry manually first") frappe.throw(msg, exc=PaymentEntryUnlinkError, title=_("Payment Unlink Error")) - frappe.db.sql( - """update `tabPayment Entry` set total_allocated_amount=%s, - base_total_allocated_amount=%s, unallocated_amount=%s, modified=%s, modified_by=%s - where name=%s""", - ( - pe_doc.total_allocated_amount, - pe_doc.base_total_allocated_amount, - pe_doc.unallocated_amount, - now(), - frappe.session.user, - pe, - ), - ) + qb.update(pay).set(pay.total_allocated_amount, pe_doc.total_allocated_amount).set( + pay.base_total_allocated_amount, pe_doc.base_total_allocated_amount + ).set(pay.unallocated_amount, pe_doc.unallocated_amount).set(pay.modified, now()).set( + pay.modified_by, frappe.session.user + ).where( + pay.name == pe + ).run() frappe.msgprint(_("Payment Entries {0} are un-linked").format("\n".join(linked_pe))) @@ -864,6 +966,9 @@ def get_outstanding_invoices( min_outstanding=None, max_outstanding=None, accounting_dimensions=None, + vouchers=None, # list of dicts [{'voucher_type': '', 'voucher_no': ''}] for filtering + limit=None, # passed by reconciliation tool + voucher_no=None, # filter passed by reconciliation tool ): ple = qb.DocType("Payment Ledger Entry") @@ -889,12 +994,15 @@ def get_outstanding_invoices( ple_query = QueryPaymentLedger() invoice_list = ple_query.get_voucher_outstandings( + vouchers=vouchers, common_filter=common_filter, posting_date=posting_date, min_outstanding=min_outstanding, max_outstanding=max_outstanding, get_invoices=True, accounting_dimensions=accounting_dimensions or [], + limit=limit, + voucher_no=voucher_no, ) for d in invoice_list: @@ -1626,12 +1734,13 @@ def __init__(self): self.voucher_posting_date = [] self.min_outstanding = None self.max_outstanding = None + self.limit = self.voucher_no = None def reset(self): # clear filters self.vouchers.clear() self.common_filter.clear() - self.min_outstanding = self.max_outstanding = None + self.min_outstanding = self.max_outstanding = self.limit = None # clear result self.voucher_outstandings.clear() @@ -1645,6 +1754,7 @@ def query_for_outstanding(self): filter_on_voucher_no = [] filter_on_against_voucher_no = [] + if self.vouchers: voucher_types = set([x.voucher_type for x in self.vouchers]) voucher_nos = set([x.voucher_no for x in self.vouchers]) @@ -1655,6 +1765,10 @@ def query_for_outstanding(self): filter_on_against_voucher_no.append(ple.against_voucher_type.isin(voucher_types)) filter_on_against_voucher_no.append(ple.against_voucher_no.isin(voucher_nos)) + if self.voucher_no: + filter_on_voucher_no.append(ple.voucher_no.like(f"%{self.voucher_no}%")) + filter_on_against_voucher_no.append(ple.against_voucher_no.like(f"%{self.voucher_no}%")) + # build outstanding amount filter filter_on_outstanding_amount = [] if self.min_outstanding: @@ -1688,6 +1802,7 @@ def query_for_outstanding(self): ple.posting_date, ple.due_date, ple.account_currency.as_("currency"), + ple.cost_center.as_("cost_center"), Sum(ple.amount).as_("amount"), Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"), ) @@ -1750,6 +1865,7 @@ def query_for_outstanding(self): ).as_("paid_amount_in_account_currency"), Table("vouchers").due_date, Table("vouchers").currency, + Table("vouchers").cost_center.as_("cost_center"), ) .where(Criterion.all(filter_on_outstanding_amount)) ) @@ -1770,6 +1886,11 @@ def query_for_outstanding(self): ) ) + if self.limit: + self.cte_query_voucher_amount_and_outstanding = ( + self.cte_query_voucher_amount_and_outstanding.limit(self.limit) + ) + # execute SQL self.voucher_outstandings = self.cte_query_voucher_amount_and_outstanding.run(as_dict=True) @@ -1783,6 +1904,8 @@ def get_voucher_outstandings( get_payments=False, get_invoices=False, accounting_dimensions=None, + limit=None, + voucher_no=None, ): """ Fetch voucher amount and outstanding amount from Payment Ledger using Database CTE @@ -1804,6 +1927,82 @@ def get_voucher_outstandings( self.max_outstanding = max_outstanding self.get_payments = get_payments self.get_invoices = get_invoices + self.limit = limit + self.voucher_no = voucher_no self.query_for_outstanding() return self.voucher_outstandings + + +def create_gain_loss_journal( + company, + posting_date, + party_type, + party, + party_account, + gain_loss_account, + exc_gain_loss, + dr_or_cr, + reverse_dr_or_cr, + ref1_dt, + ref1_dn, + ref1_detail_no, + ref2_dt, + ref2_dn, + ref2_detail_no, + cost_center, +) -> str: + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Exchange Gain Or Loss" + journal_entry.company = company + journal_entry.posting_date = posting_date or nowdate() + journal_entry.multi_currency = 1 + journal_entry.is_system_generated = True + + party_account_currency = frappe.get_cached_value("Account", party_account, "account_currency") + + if not gain_loss_account: + frappe.throw(_("Please set default Exchange Gain/Loss Account in Company {}").format(company)) + gain_loss_account_currency = get_account_currency(gain_loss_account) + company_currency = frappe.get_cached_value("Company", company, "default_currency") + + if gain_loss_account_currency != company_currency: + frappe.throw(_("Currency for {0} must be {1}").format(gain_loss_account, company_currency)) + + journal_account = frappe._dict( + { + "account": party_account, + "party_type": party_type, + "party": party, + "account_currency": party_account_currency, + "exchange_rate": 0, + "cost_center": cost_center or erpnext.get_default_cost_center(company), + "reference_type": ref1_dt, + "reference_name": ref1_dn, + "reference_detail_no": ref1_detail_no, + dr_or_cr: abs(exc_gain_loss), + dr_or_cr + "_in_account_currency": 0, + } + ) + + journal_entry.append("accounts", journal_account) + + journal_account = frappe._dict( + { + "account": gain_loss_account, + "account_currency": gain_loss_account_currency, + "exchange_rate": 1, + "cost_center": cost_center or erpnext.get_default_cost_center(company), + "reference_type": ref2_dt, + "reference_name": ref2_dn, + "reference_detail_no": ref2_detail_no, + reverse_dr_or_cr + "_in_account_currency": 0, + reverse_dr_or_cr: abs(exc_gain_loss), + } + ) + + journal_entry.append("accounts", journal_account) + + journal_entry.save() + journal_entry.submit() + return journal_entry.name diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 0923d0093f97..5d7794e1bccb 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -147,6 +147,15 @@ frappe.ui.form.on('Asset', { if (frm.doc.docstatus == 0) { frm.toggle_reqd("finance_books", frm.doc.calculate_depreciation); + + if (frm.doc.is_composite_asset && !frm.doc.capitalized_in) { + $('.primary-action').prop('hidden', true); + $('.form-message').text('Capitalize this asset to confirm'); + + frm.add_custom_button(__("Capitalize Asset"), function() { + frm.trigger("create_asset_capitalization"); + }); + } } }, @@ -168,7 +177,7 @@ frappe.ui.form.on('Asset', { frm.set_df_property('purchase_invoice', 'read_only', 1); frm.set_df_property('purchase_receipt', 'read_only', 1); } - else if (frm.doc.is_existing_asset) { + else if (frm.doc.is_existing_asset || frm.doc.is_composite_asset) { frm.toggle_reqd('purchase_receipt', 0); frm.toggle_reqd('purchase_invoice', 0); } @@ -275,7 +284,7 @@ frappe.ui.form.on('Asset', { item_code: function(frm) { - if(frm.doc.item_code && frm.doc.calculate_depreciation) { + if(frm.doc.item_code && frm.doc.calculate_depreciation && frm.doc.gross_purchase_amount) { frm.trigger('set_finance_book'); } else { frm.set_value('finance_books', []); @@ -287,7 +296,8 @@ frappe.ui.form.on('Asset', { method: "erpnext.assets.doctype.asset.asset.get_item_details", args: { item_code: frm.doc.item_code, - asset_category: frm.doc.asset_category + asset_category: frm.doc.asset_category, + gross_purchase_amount: frm.doc.gross_purchase_amount }, callback: function(r, rt) { if(r.message) { @@ -299,7 +309,17 @@ frappe.ui.form.on('Asset', { is_existing_asset: function(frm) { frm.trigger("toggle_reference_doc"); - // frm.toggle_reqd("next_depreciation_date", (!frm.doc.is_existing_asset && frm.doc.calculate_depreciation)); + }, + + is_composite_asset: function(frm) { + if(frm.doc.is_composite_asset) { + frm.set_value('gross_purchase_amount', 0); + frm.set_df_property('gross_purchase_amount', 'read_only', 1); + } else { + frm.set_df_property('gross_purchase_amount', 'read_only', 0); + } + + frm.trigger("toggle_reference_doc"); }, make_schedules_editable: function(frm) { @@ -360,6 +380,19 @@ frappe.ui.form.on('Asset', { }); }, + create_asset_capitalization: function(frm) { + frappe.call({ + args: { + "asset": frm.doc.name, + }, + method: "erpnext.assets.doctype.asset.asset.create_asset_capitalization", + callback: function(r) { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + } + }); + }, + split_asset: function(frm) { const title = __('Split Asset'); @@ -415,7 +448,7 @@ frappe.ui.form.on('Asset', { calculate_depreciation: function(frm) { frm.toggle_reqd("finance_books", frm.doc.calculate_depreciation); - if (frm.doc.item_code && frm.doc.calculate_depreciation ) { + if (frm.doc.item_code && frm.doc.calculate_depreciation && frm.doc.gross_purchase_amount) { frm.trigger("set_finance_book"); } else { frm.set_value("finance_books", []); @@ -423,9 +456,11 @@ frappe.ui.form.on('Asset', { }, gross_purchase_amount: function(frm) { - frm.doc.finance_books.forEach(d => { - frm.events.set_depreciation_rate(frm, d); - }) + if (frm.doc.finance_books) { + frm.doc.finance_books.forEach(d => { + frm.events.set_depreciation_rate(frm, d); + }) + } }, purchase_receipt: (frm) => { @@ -504,7 +539,21 @@ frappe.ui.form.on('Asset', { } }); } - } + }, + + set_salvage_value_percentage_or_expected_value_after_useful_life: function(frm, row, salvage_value_percentage_changed, expected_value_after_useful_life_changed) { + if (expected_value_after_useful_life_changed) { + frappe.flags.from_set_salvage_value_percentage_or_expected_value_after_useful_life = true; + const new_salvage_value_percentage = flt((row.expected_value_after_useful_life * 100) / frm.doc.gross_purchase_amount, precision("salvage_value_percentage", row)); + frappe.model.set_value(row.doctype, row.name, "salvage_value_percentage", new_salvage_value_percentage); + frappe.flags.from_set_salvage_value_percentage_or_expected_value_after_useful_life = false; + } else if (salvage_value_percentage_changed) { + frappe.flags.from_set_salvage_value_percentage_or_expected_value_after_useful_life = true; + const new_expected_value_after_useful_life = flt(frm.doc.gross_purchase_amount * (row.salvage_value_percentage / 100), precision('gross_purchase_amount')); + frappe.model.set_value(row.doctype, row.name, "expected_value_after_useful_life", new_expected_value_after_useful_life); + frappe.flags.from_set_salvage_value_percentage_or_expected_value_after_useful_life = false; + } + }, }); frappe.ui.form.on('Asset Finance Book', { @@ -516,9 +565,19 @@ frappe.ui.form.on('Asset Finance Book', { expected_value_after_useful_life: function(frm, cdt, cdn) { const row = locals[cdt][cdn]; + if (!frappe.flags.from_set_salvage_value_percentage_or_expected_value_after_useful_life) { + frm.events.set_salvage_value_percentage_or_expected_value_after_useful_life(frm, row, false, true); + } frm.events.set_depreciation_rate(frm, row); }, + salvage_value_percentage: function(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + if (!frappe.flags.from_set_salvage_value_percentage_or_expected_value_after_useful_life) { + frm.events.set_salvage_value_percentage_or_expected_value_after_useful_life(frm, row, true, false); + } + }, + frequency_of_depreciation: function(frm, cdt, cdn) { const row = locals[cdt][cdn]; frm.events.set_depreciation_rate(frm, row); diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index 3e93f0f03e3c..1da3edcc60eb 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -14,6 +14,7 @@ "asset_owner", "asset_owner_company", "is_existing_asset", + "is_composite_asset", "supplier", "customer", "image", @@ -43,6 +44,7 @@ "column_break_33", "opening_accumulated_depreciation", "number_of_depreciations_booked", + "is_fully_depreciated", "section_break_36", "finance_books", "section_break_33", @@ -71,7 +73,8 @@ "purchase_receipt_amount", "default_finance_book", "depr_entry_posting_status", - "amended_from" + "amended_from", + "capitalized_in" ], "fields": [ { @@ -198,13 +201,14 @@ "fieldtype": "Date", "label": "Purchase Date", "read_only": 1, - "read_only_depends_on": "eval:!doc.is_existing_asset", + "read_only_depends_on": "eval:!doc.is_existing_asset && !doc.is_composite_asset", "reqd": 1 }, { "fieldname": "disposal_date", "fieldtype": "Date", "label": "Disposal Date", + "no_copy": 1, "read_only": 1 }, { @@ -235,28 +239,28 @@ "default": "0", "fieldname": "calculate_depreciation", "fieldtype": "Check", - "label": "Calculate Depreciation" + "label": "Calculate Depreciation", + "read_only_depends_on": "eval:doc.is_composite_asset && !doc.gross_purchase_amount" }, { "default": "0", + "depends_on": "eval:!doc.is_composite_asset", "fieldname": "is_existing_asset", "fieldtype": "Check", "label": "Is Existing Asset" }, { - "depends_on": "is_existing_asset", + "depends_on": "eval:(doc.is_existing_asset)", "fieldname": "opening_accumulated_depreciation", "fieldtype": "Currency", "label": "Opening Accumulated Depreciation", - "no_copy": 1, "options": "Company:company:default_currency" }, { - "depends_on": "eval:(doc.is_existing_asset && doc.opening_accumulated_depreciation)", + "depends_on": "eval:(doc.is_existing_asset)", "fieldname": "number_of_depreciations_booked", "fieldtype": "Int", - "label": "Number of Depreciations Booked", - "no_copy": 1 + "label": "Number of Depreciations Booked" }, { "collapsible": 1, @@ -318,6 +322,7 @@ "label": "Depreciation Schedule" }, { + "depends_on": "schedules", "fieldname": "schedules", "fieldtype": "Table", "label": "Depreciation Schedule", @@ -491,7 +496,7 @@ "fieldname": "asset_quantity", "fieldtype": "Int", "label": "Asset Quantity", - "read_only_depends_on": "eval:!doc.is_existing_asset" + "read_only_depends_on": "eval:!doc.is_existing_asset && !doc.is_composite_asset" }, { "fieldname": "depr_entry_posting_status", @@ -502,6 +507,28 @@ "options": "\nSuccessful\nFailed", "print_hide": 1, "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:(doc.is_existing_asset)", + "fieldname": "is_fully_depreciated", + "fieldtype": "Check", + "label": "Is Fully Depreciated" + }, + { + "default": "0", + "depends_on": "eval:!doc.is_existing_asset", + "fieldname": "is_composite_asset", + "fieldtype": "Check", + "label": "Is Composite Asset" + }, + { + "fieldname": "capitalized_in", + "fieldtype": "Link", + "hidden": 1, + "label": "Capitalized In", + "options": "Asset Capitalization", + "read_only": 1 } ], "idx": 72, @@ -530,7 +557,7 @@ "table_fieldname": "accounts" } ], - "modified": "2023-03-30 15:07:41.542374", + "modified": "2023-10-03 23:28:26.732269", "modified_by": "Administrator", "module": "Assets", "name": "Asset", @@ -574,4 +601,4 @@ "states": [], "title_field": "asset_name", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index c6247ea0da3f..d54d15afaf6e 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -40,6 +40,7 @@ def validate(self): self.validate_item() self.validate_cost_center() self.set_missing_values() + self.validate_finance_books() if not self.split_from: self.prepare_depreciation_data() self.validate_gross_and_purchase_amount() @@ -81,18 +82,27 @@ def validate_asset_and_reference(self): _("Purchase Invoice cannot be made against an existing asset {0}").format(self.name) ) - def prepare_depreciation_data(self, date_of_disposal=None, date_of_return=None): + def prepare_depreciation_data( + self, + date_of_disposal=None, + date_of_return=None, + value_after_depreciation=None, + ignore_booked_entry=False, + ): if self.calculate_depreciation: self.value_after_depreciation = 0 self.set_depreciation_rate() if self.should_prepare_depreciation_schedule(): - self.make_depreciation_schedule(date_of_disposal) - self.set_accumulated_depreciation(date_of_disposal, date_of_return) + self.make_depreciation_schedule(date_of_disposal, value_after_depreciation) + self.set_accumulated_depreciation(date_of_disposal, date_of_return, ignore_booked_entry) else: self.finance_books = [] - self.value_after_depreciation = flt(self.gross_purchase_amount) - flt( - self.opening_accumulated_depreciation - ) + if value_after_depreciation: + self.value_after_depreciation = value_after_depreciation + else: + self.value_after_depreciation = flt(self.gross_purchase_amount) - flt( + self.opening_accumulated_depreciation + ) def should_prepare_depreciation_schedule(self): if not self.get("schedules"): @@ -148,17 +158,33 @@ def validate_item(self): frappe.throw(_("Item {0} must be a non-stock item").format(self.item_code)) def validate_cost_center(self): - if not self.cost_center: - return - - cost_center_company = frappe.db.get_value("Cost Center", self.cost_center, "company") - if cost_center_company != self.company: - frappe.throw( - _("Selected Cost Center {} doesn't belongs to {}").format( - frappe.bold(self.cost_center), frappe.bold(self.company) - ), - title=_("Invalid Cost Center"), + if self.cost_center: + cost_center_company, cost_center_is_group = frappe.db.get_value( + "Cost Center", self.cost_center, ["company", "is_group"] ) + if cost_center_company != self.company: + frappe.throw( + _("Cost Center {} doesn't belong to Company {}").format( + frappe.bold(self.cost_center), frappe.bold(self.company) + ), + title=_("Invalid Cost Center"), + ) + if cost_center_is_group: + frappe.throw( + _( + "Cost Center {} is a group cost center and group cost centers cannot be used in transactions" + ).format(frappe.bold(self.cost_center)), + title=_("Invalid Cost Center"), + ) + + else: + if not frappe.get_cached_value("Company", self.company, "depreciation_cost_center"): + frappe.throw( + _( + "Please set a Cost Center for the Asset or set an Asset Depreciation Cost Center for the Company {}" + ).format(frappe.bold(self.company)), + title=_("Missing Cost Center"), + ) def validate_in_use_date(self): if not self.available_for_use_date: @@ -178,14 +204,37 @@ def set_missing_values(self): self.asset_category = frappe.get_cached_value("Item", self.item_code, "asset_category") if self.item_code and not self.get("finance_books"): - finance_books = get_item_details(self.item_code, self.asset_category) + finance_books = get_item_details( + self.item_code, self.asset_category, self.gross_purchase_amount + ) self.set("finance_books", finance_books) + def validate_finance_books(self): + if not self.calculate_depreciation or len(self.finance_books) == 1: + return + + finance_books = set() + + for d in self.finance_books: + if d.finance_book in finance_books: + frappe.throw( + _("Row #{}: Please use a different Finance Book.").format(d.idx), + title=_("Duplicate Finance Book"), + ) + else: + finance_books.add(d.finance_book) + + if not d.finance_book: + frappe.throw( + _("Row #{}: Finance Book should not be empty since you're using multiple.").format(d.idx), + title=_("Missing Finance Book"), + ) + def validate_asset_values(self): if not self.asset_category: self.asset_category = frappe.get_cached_value("Item", self.item_code, "asset_category") - if not flt(self.gross_purchase_amount): + if not flt(self.gross_purchase_amount) and not self.is_composite_asset: frappe.throw(_("Gross Purchase Amount is mandatory"), frappe.MandatoryError) if is_cwip_accounting_enabled(self.asset_category): @@ -207,8 +256,11 @@ def validate_asset_values(self): if not self.calculate_depreciation: return - elif not self.finance_books: - frappe.throw(_("Enter depreciation details")) + else: + if not self.finance_books: + frappe.throw(_("Enter depreciation details")) + if self.is_fully_depreciated: + frappe.throw(_("Depreciation cannot be calculated for fully depreciated assets")) if self.is_existing_asset: return @@ -266,7 +318,7 @@ def set_depreciation_rate(self): self.get_depreciation_rate(d, on_validate=True), d.precision("rate_of_depreciation") ) - def make_depreciation_schedule(self, date_of_disposal): + def make_depreciation_schedule(self, date_of_disposal, value_after_depreciation=None): if not self.get("schedules"): self.schedules = [] @@ -276,24 +328,30 @@ def make_depreciation_schedule(self, date_of_disposal): start = self.clear_depreciation_schedule() for finance_book in self.get("finance_books"): - self._make_depreciation_schedule(finance_book, start, date_of_disposal) + self._make_depreciation_schedule( + finance_book, start, date_of_disposal, value_after_depreciation + ) if len(self.get("finance_books")) > 1 and any(start): self.sort_depreciation_schedule() - def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): + def _make_depreciation_schedule( + self, finance_book, start, date_of_disposal, value_after_depreciation=None + ): self.validate_asset_finance_books(finance_book) - value_after_depreciation = self._get_value_after_depreciation_for_making_schedule(finance_book) + if not value_after_depreciation: + value_after_depreciation = self._get_value_after_depreciation_for_making_schedule(finance_book) + finance_book.value_after_depreciation = value_after_depreciation - number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - cint( + final_number_of_depreciations = cint(finance_book.total_number_of_depreciations) - cint( self.number_of_depreciations_booked ) has_pro_rata = self.check_is_pro_rata(finance_book) if has_pro_rata: - number_of_pending_depreciations += 1 + final_number_of_depreciations += 1 has_wdv_or_dd_non_yearly_pro_rata = False if ( @@ -309,7 +367,9 @@ def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): depreciation_amount = 0 - for n in range(start[finance_book.idx - 1], number_of_pending_depreciations): + number_of_pending_depreciations = final_number_of_depreciations - start[finance_book.idx - 1] + + for n in range(start[finance_book.idx - 1], final_number_of_depreciations): # If depreciation is already completed (for double declining balance) if skip_row: continue @@ -326,10 +386,11 @@ def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): n, prev_depreciation_amount, has_wdv_or_dd_non_yearly_pro_rata, + number_of_pending_depreciations, ) if not has_pro_rata or ( - n < (cint(number_of_pending_depreciations) - 1) or number_of_pending_depreciations == 2 + n < (cint(final_number_of_depreciations) - 1) or final_number_of_depreciations == 2 ): schedule_date = add_months( finance_book.depreciation_start_date, n * cint(finance_book.frequency_of_depreciation) @@ -397,7 +458,7 @@ def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): ) # For last row - elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: + elif has_pro_rata and n == cint(final_number_of_depreciations) - 1: if not self.flags.increase_in_asset_life: # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission self.to_date = add_months( @@ -428,7 +489,7 @@ def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): # Adjust depreciation amount in the last period based on the expected value after useful life if finance_book.expected_value_after_useful_life and ( ( - n == cint(number_of_pending_depreciations) - 1 + n == cint(final_number_of_depreciations) - 1 and value_after_depreciation != finance_book.expected_value_after_useful_life ) or value_after_depreciation < finance_book.expected_value_after_useful_life @@ -588,7 +649,7 @@ def validate_asset_finance_books(self, row): depreciable_amount = flt(self.gross_purchase_amount) - flt(row.expected_value_after_useful_life) if flt(self.opening_accumulated_depreciation) > depreciable_amount: frappe.throw( - _("Opening Accumulated Depreciation must be less than equal to {0}").format( + _("Opening Accumulated Depreciation must be less than or equal to {0}").format( depreciable_amount ) ) @@ -671,7 +732,10 @@ def set_accumulated_depreciation( if s.finance_book_id == d.finance_book_id and (s.depreciation_method == "Straight Line" or s.depreciation_method == "Manual") ] - accumulated_depreciation = flt(self.opening_accumulated_depreciation) + if i > 0 and self.flags.decrease_in_asset_value_due_to_value_adjustment: + accumulated_depreciation = self.get("schedules")[i - 1].accumulated_depreciation_amount + else: + accumulated_depreciation = flt(self.opening_accumulated_depreciation) value_after_depreciation = flt( self.get("finance_books")[cint(d.finance_book_id) - 1].value_after_depreciation ) @@ -793,7 +857,9 @@ def get_status(self): expected_value_after_useful_life = self.finance_books[idx].expected_value_after_useful_life value_after_depreciation = self.finance_books[idx].value_after_depreciation - if flt(value_after_depreciation) <= expected_value_after_useful_life: + if ( + flt(value_after_depreciation) <= expected_value_after_useful_life or self.is_fully_depreciated + ): status = "Fully Depreciated" elif flt(value_after_depreciation) < flt(self.gross_purchase_amount): status = "Partially Depreciated" @@ -941,7 +1007,9 @@ def make_gl_entries(self): @frappe.whitelist() def get_manual_depreciation_entries(self): - (_, _, depreciation_expense_account) = get_depreciation_accounts(self) + (_, _, depreciation_expense_account) = get_depreciation_accounts( + self.asset_category, self.company + ) gle = frappe.qb.DocType("GL Entry") @@ -1098,6 +1166,15 @@ def create_asset_repair(asset, asset_name): return asset_repair +@frappe.whitelist() +def create_asset_capitalization(asset): + asset_capitalization = frappe.new_doc("Asset Capitalization") + asset_capitalization.update( + {"target_asset": asset, "capitalization_method": "Choose a WIP composite asset"} + ) + return asset_capitalization + + @frappe.whitelist() def create_asset_value_adjustment(asset, asset_category, company): asset_value_adjustment = frappe.new_doc("Asset Value Adjustment") @@ -1129,7 +1206,7 @@ def transfer_asset(args): @frappe.whitelist() -def get_item_details(item_code, asset_category): +def get_item_details(item_code, asset_category, gross_purchase_amount): asset_category_doc = frappe.get_doc("Asset Category", asset_category) books = [] for d in asset_category_doc.finance_books: @@ -1139,7 +1216,11 @@ def get_item_details(item_code, asset_category): "depreciation_method": d.depreciation_method, "total_number_of_depreciations": d.total_number_of_depreciations, "frequency_of_depreciation": d.frequency_of_depreciation, - "start_date": nowdate(), + "daily_depreciation": d.daily_depreciation, + "salvage_value_percentage": d.salvage_value_percentage, + "expected_value_after_useful_life": flt(gross_purchase_amount) + * flt(d.salvage_value_percentage / 100), + "depreciation_start_date": d.depreciation_start_date or nowdate(), } ) @@ -1180,10 +1261,10 @@ def get_asset_account(account_name, asset=None, asset_category=None, company=Non def make_journal_entry(asset_name): asset = frappe.get_doc("Asset", asset_name) ( - fixed_asset_account, + _, accumulated_depreciation_account, depreciation_expense_account, - ) = get_depreciation_accounts(asset) + ) = get_depreciation_accounts(asset.asset_category, asset.company) depreciation_cost_center, depreciation_series = frappe.get_cached_value( "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] @@ -1266,29 +1347,43 @@ def get_total_days(date, frequency): return date_diff(date, period_start_date) -@erpnext.allow_regional def get_depreciation_amount( asset, depreciable_value, - row, + fb_row, schedule_idx=0, prev_depreciation_amount=0, has_wdv_or_dd_non_yearly_pro_rata=False, + number_of_pending_depreciations=0, ): - if row.depreciation_method in ("Straight Line", "Manual"): - return get_straight_line_or_manual_depr_amount(asset, row) + frappe.flags.company = asset.company + + if fb_row.depreciation_method in ("Straight Line", "Manual"): + return get_straight_line_or_manual_depr_amount( + asset, fb_row, schedule_idx, number_of_pending_depreciations + ) else: + rate_of_depreciation = get_updated_rate_of_depreciation_for_wdv_and_dd( + asset, depreciable_value, fb_row + ) return get_wdv_or_dd_depr_amount( depreciable_value, - row.rate_of_depreciation, - row.frequency_of_depreciation, + rate_of_depreciation, + fb_row.frequency_of_depreciation, schedule_idx, prev_depreciation_amount, has_wdv_or_dd_non_yearly_pro_rata, ) -def get_straight_line_or_manual_depr_amount(asset, row): +@erpnext.allow_regional +def get_updated_rate_of_depreciation_for_wdv_and_dd(asset, depreciable_value, fb_row): + return fb_row.rate_of_depreciation + + +def get_straight_line_or_manual_depr_amount( + asset, row, schedule_idx, number_of_pending_depreciations +): # 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)) / ( @@ -1299,13 +1394,88 @@ def get_straight_line_or_manual_depr_amount(asset, row): 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 modified after Asset Value Adjustment due to decrease in asset value + elif asset.flags.decrease_in_asset_value_due_to_value_adjustment: + if row.daily_depreciation: + daily_depr_amount = ( + flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life) + ) / date_diff( + get_last_day( + add_months( + row.depreciation_start_date, + flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked - 1) + * row.frequency_of_depreciation, + ) + ), + add_days( + get_last_day( + add_months( + row.depreciation_start_date, + flt( + row.total_number_of_depreciations + - asset.number_of_depreciations_booked + - number_of_pending_depreciations + - 1 + ) + * row.frequency_of_depreciation, + ) + ), + 1, + ), + ) + + to_date = get_last_day( + add_months(row.depreciation_start_date, schedule_idx * row.frequency_of_depreciation) + ) + from_date = add_days( + get_last_day( + add_months(row.depreciation_start_date, (schedule_idx - 1) * row.frequency_of_depreciation) + ), + 1, + ) + + return daily_depr_amount * (date_diff(to_date, from_date) + 1) + else: + return ( + flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life) + ) / number_of_pending_depreciations # if the Depreciation Schedule is being prepared for the first time else: - return ( - flt(asset.gross_purchase_amount) - - flt(asset.opening_accumulated_depreciation) - - flt(row.expected_value_after_useful_life) - ) / flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked) + if row.daily_depreciation: + daily_depr_amount = ( + flt(asset.gross_purchase_amount) + - flt(asset.opening_accumulated_depreciation) + - flt(row.expected_value_after_useful_life) + ) / date_diff( + get_last_day( + add_months( + row.depreciation_start_date, + flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked - 1) + * row.frequency_of_depreciation, + ) + ), + add_days( + get_last_day(add_months(row.depreciation_start_date, -1 * row.frequency_of_depreciation)), 1 + ), + ) + + to_date = get_last_day( + add_months(row.depreciation_start_date, schedule_idx * row.frequency_of_depreciation) + ) + from_date = add_days( + get_last_day( + add_months(row.depreciation_start_date, (schedule_idx - 1) * row.frequency_of_depreciation) + ), + 1, + ) + + return daily_depr_amount * (date_diff(to_date, from_date) + 1) + else: + return ( + flt(asset.gross_purchase_amount) + - flt(asset.opening_accumulated_depreciation) + - flt(row.expected_value_after_useful_life) + ) / flt(row.total_number_of_depreciations - asset.number_of_depreciations_booked) def get_wdv_or_dd_depr_amount( diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index d72c4bd654b6..f5fd5d60221c 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -4,6 +4,8 @@ import frappe from frappe import _ +from frappe.query_builder import Order +from frappe.query_builder.functions import Max, Min from frappe.utils import ( add_months, cint, @@ -36,9 +38,40 @@ def post_depreciation_entries(date=None): failed_asset_names = [] error_log_names = [] - for asset_name in get_depreciable_assets(date): + depreciable_assets = get_depreciable_assets(date) + + credit_and_debit_accounts_for_asset_category_and_company = {} + depreciation_cost_center_and_depreciation_series_for_company = ( + get_depreciation_cost_center_and_depreciation_series_for_company() + ) + + accounting_dimensions = get_checks_for_pl_and_bs_accounts() + + for asset in depreciable_assets: + asset_name, asset_category, asset_company, sch_start_idx, sch_end_idx = asset + + if ( + asset_category, + asset_company, + ) not in credit_and_debit_accounts_for_asset_category_and_company: + credit_and_debit_accounts_for_asset_category_and_company.update( + { + (asset_category, asset_company): get_credit_and_debit_accounts_for_asset_category_and_company( + asset_category, asset_company + ), + } + ) + try: - make_depreciation_entry(asset_name, date) + make_depreciation_entry( + asset_name, + date, + sch_start_idx, + sch_end_idx, + credit_and_debit_accounts_for_asset_category_and_company[(asset_category, asset_company)], + depreciation_cost_center_and_depreciation_series_for_company[asset_company], + accounting_dimensions, + ) frappe.db.commit() except Exception as e: frappe.db.rollback() @@ -54,115 +87,226 @@ def post_depreciation_entries(date=None): def get_depreciable_assets(date): - return frappe.db.sql_list( - """select distinct a.name - from tabAsset a, `tabDepreciation Schedule` ds - where a.name = ds.parent and a.docstatus=1 and ds.schedule_date<=%s and a.calculate_depreciation = 1 - and a.status in ('Submitted', 'Partially Depreciated') - and ifnull(ds.journal_entry, '')=''""", - date, + a = frappe.qb.DocType("Asset") + ds = frappe.qb.DocType("Depreciation Schedule") + + res = ( + frappe.qb.from_(a) + .join(ds) + .on(a.name == ds.parent) + .select(a.name, a.asset_category, a.company, Min(ds.idx) - 1, Max(ds.idx)) + .where(a.calculate_depreciation == 1) + .where(a.docstatus == 1) + .where(a.status.isin(["Submitted", "Partially Depreciated"])) + .where(ds.journal_entry.isnull()) + .where(ds.schedule_date <= date) + .groupby(a.name) + .orderby(a.creation, order=Order.desc) + ) + + acc_frozen_upto = get_acc_frozen_upto() + if acc_frozen_upto: + res = res.where(ds.schedule_date > acc_frozen_upto) + + res = res.run() + + return res + + +def get_acc_frozen_upto(): + acc_frozen_upto = frappe.db.get_single_value("Accounts Settings", "acc_frozen_upto") + + if not acc_frozen_upto: + return + + frozen_accounts_modifier = frappe.db.get_single_value( + "Accounts Settings", "frozen_accounts_modifier" + ) + + if frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator": + return getdate(acc_frozen_upto) + + return + + +def get_credit_and_debit_accounts_for_asset_category_and_company(asset_category, company): + ( + _, + accumulated_depreciation_account, + depreciation_expense_account, + ) = get_depreciation_accounts(asset_category, company) + + credit_account, debit_account = get_credit_and_debit_accounts( + accumulated_depreciation_account, depreciation_expense_account ) + return (credit_account, debit_account) + + +def get_depreciation_cost_center_and_depreciation_series_for_company(): + company_names = frappe.db.get_all("Company", pluck="name") + + res = {} + + for company_name in company_names: + depreciation_cost_center, depreciation_series = frappe.get_cached_value( + "Company", company_name, ["depreciation_cost_center", "series_for_depreciation_entry"] + ) + res.update({company_name: (depreciation_cost_center, depreciation_series)}) + + return res + @frappe.whitelist() -def make_depreciation_entry(asset_name, date=None): +def make_depreciation_entry( + asset_name, + date=None, + sch_start_idx=None, + sch_end_idx=None, + credit_and_debit_accounts=None, + depreciation_cost_center_and_depreciation_series=None, + accounting_dimensions=None, +): frappe.has_permission("Journal Entry", throw=True) if not date: date = today() asset = frappe.get_doc("Asset", asset_name) - ( - fixed_asset_account, - accumulated_depreciation_account, - depreciation_expense_account, - ) = get_depreciation_accounts(asset) - depreciation_cost_center, depreciation_series = frappe.get_cached_value( - "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] - ) + if credit_and_debit_accounts: + credit_account, debit_account = credit_and_debit_accounts + else: + credit_account, debit_account = get_credit_and_debit_accounts_for_asset_category_and_company( + asset.asset_category, asset.company + ) + + if depreciation_cost_center_and_depreciation_series: + depreciation_cost_center, depreciation_series = depreciation_cost_center_and_depreciation_series + else: + depreciation_cost_center, depreciation_series = frappe.get_cached_value( + "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] + ) depreciation_cost_center = asset.cost_center or depreciation_cost_center - accounting_dimensions = get_checks_for_pl_and_bs_accounts() + if not accounting_dimensions: + accounting_dimensions = get_checks_for_pl_and_bs_accounts() + + depreciation_posting_error = None - for d in asset.get("schedules"): - if not d.journal_entry and getdate(d.schedule_date) <= getdate(date): - je = frappe.new_doc("Journal Entry") - je.voucher_type = "Depreciation Entry" - je.naming_series = depreciation_series - je.posting_date = d.schedule_date - je.company = asset.company - je.finance_book = d.finance_book - je.remark = "Depreciation Entry against {0} worth {1}".format(asset_name, d.depreciation_amount) - - credit_account, debit_account = get_credit_and_debit_accounts( - accumulated_depreciation_account, depreciation_expense_account + for d in asset.get("schedules")[sch_start_idx or 0 : sch_end_idx or len(asset.get("schedules"))]: + try: + _make_journal_entry_for_depreciation( + asset, + date, + d, + sch_start_idx, + sch_end_idx, + depreciation_cost_center, + depreciation_series, + credit_account, + debit_account, + accounting_dimensions, ) + frappe.db.commit() + except Exception as e: + frappe.db.rollback() + depreciation_posting_error = e - credit_entry = { - "account": credit_account, - "credit_in_account_currency": d.depreciation_amount, - "reference_type": "Asset", - "reference_name": asset.name, - "cost_center": depreciation_cost_center, - } + asset.set_status() - debit_entry = { - "account": debit_account, - "debit_in_account_currency": d.depreciation_amount, - "reference_type": "Asset", - "reference_name": asset.name, - "cost_center": depreciation_cost_center, - } + if not depreciation_posting_error: + asset.db_set("depr_entry_posting_status", "Successful") + return asset - for dimension in accounting_dimensions: - if asset.get(dimension["fieldname"]) or dimension.get("mandatory_for_bs"): - credit_entry.update( - { - dimension["fieldname"]: asset.get(dimension["fieldname"]) - or dimension.get("default_dimension") - } - ) + raise depreciation_posting_error - if asset.get(dimension["fieldname"]) or dimension.get("mandatory_for_pl"): - debit_entry.update( - { - dimension["fieldname"]: asset.get(dimension["fieldname"]) - or dimension.get("default_dimension") - } - ) - je.append("accounts", credit_entry) +def _make_journal_entry_for_depreciation( + asset, + date, + depr_schedule, + sch_start_idx, + sch_end_idx, + depreciation_cost_center, + depreciation_series, + credit_account, + debit_account, + accounting_dimensions, +): + if not (sch_start_idx and sch_end_idx) and not ( + not depr_schedule.journal_entry and getdate(depr_schedule.schedule_date) <= getdate(date) + ): + return + + je = frappe.new_doc("Journal Entry") + je.voucher_type = "Depreciation Entry" + je.naming_series = depreciation_series + je.posting_date = depr_schedule.schedule_date + je.company = asset.company + je.finance_book = depr_schedule.finance_book + je.remark = "Depreciation Entry against {0} worth {1}".format( + asset.name, depr_schedule.depreciation_amount + ) - je.append("accounts", debit_entry) + credit_entry = { + "account": credit_account, + "credit_in_account_currency": depr_schedule.depreciation_amount, + "reference_type": "Asset", + "reference_name": asset.name, + "cost_center": depreciation_cost_center, + } + + debit_entry = { + "account": debit_account, + "debit_in_account_currency": depr_schedule.depreciation_amount, + "reference_type": "Asset", + "reference_name": asset.name, + "cost_center": depreciation_cost_center, + } + + for dimension in accounting_dimensions: + if asset.get(dimension["fieldname"]) or dimension.get("mandatory_for_bs"): + credit_entry.update( + { + dimension["fieldname"]: asset.get(dimension["fieldname"]) + or dimension.get("default_dimension") + } + ) - je.flags.ignore_permissions = True - je.flags.planned_depr_entry = True - je.save() + if asset.get(dimension["fieldname"]) or dimension.get("mandatory_for_pl"): + debit_entry.update( + { + dimension["fieldname"]: asset.get(dimension["fieldname"]) + or dimension.get("default_dimension") + } + ) - d.db_set("journal_entry", je.name) + je.append("accounts", credit_entry) - if not je.meta.get_workflow(): - je.submit() - idx = cint(d.finance_book_id) - finance_books = asset.get("finance_books")[idx - 1] - finance_books.value_after_depreciation -= d.depreciation_amount - finance_books.db_update() + je.append("accounts", debit_entry) - asset.db_set("depr_entry_posting_status", "Successful") + je.flags.ignore_permissions = True + je.flags.planned_depr_entry = True + je.save() - asset.set_status() + depr_schedule.db_set("journal_entry", je.name) - return asset + if not je.meta.get_workflow(): + je.submit() + idx = cint(depr_schedule.finance_book_id) + finance_books = asset.get("finance_books")[idx - 1] + finance_books.value_after_depreciation -= depr_schedule.depreciation_amount + finance_books.db_update() -def get_depreciation_accounts(asset): +def get_depreciation_accounts(asset_category, company): fixed_asset_account = accumulated_depreciation_account = depreciation_expense_account = None accounts = frappe.db.get_value( "Asset Category Account", - filters={"parent": asset.asset_category, "company_name": asset.company}, + filters={"parent": asset_category, "company_name": company}, fieldname=[ "fixed_asset_account", "accumulated_depreciation_account", @@ -178,7 +322,7 @@ def get_depreciation_accounts(asset): if not accumulated_depreciation_account or not depreciation_expense_account: accounts = frappe.get_cached_value( - "Company", asset.company, ["accumulated_depreciation_account", "depreciation_expense_account"] + "Company", company, ["accumulated_depreciation_account", "depreciation_expense_account"] ) if not accumulated_depreciation_account: @@ -193,7 +337,7 @@ def get_depreciation_accounts(asset): ): frappe.throw( _("Please set Depreciation related Accounts in Asset Category {0} or Company {1}").format( - asset.asset_category, asset.company + asset_category, company ) ) @@ -369,6 +513,15 @@ def reverse_depreciation_entry_made_after_disposal(asset, date): reverse_journal_entry = make_reverse_journal_entry(schedule.journal_entry) reverse_journal_entry.posting_date = nowdate() + + for account in reverse_journal_entry.accounts: + account.update( + { + "reference_type": "Asset", + "reference_name": asset.name, + } + ) + frappe.flags.is_reverse_depr_entry = True reverse_journal_entry.submit() @@ -524,8 +677,8 @@ def get_gl_entries_on_asset_disposal( def get_asset_details(asset, finance_book=None): - fixed_asset_account, accumulated_depr_account, depr_expense_account = get_depreciation_accounts( - asset + fixed_asset_account, accumulated_depr_account, _ = get_depreciation_accounts( + asset.asset_category, asset.company ) disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company) depreciation_cost_center = asset.cost_center or depreciation_cost_center diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index fea6ed3d2bd0..fc36df8aec52 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -730,6 +730,40 @@ def test_schedule_for_straight_line_method_for_existing_asset(self): self.assertEqual(schedules, expected_schedules) + def test_schedule_for_straight_line_method_with_daily_depreciation(self): + asset = create_asset( + calculate_depreciation=1, + available_for_use_date="2023-01-01", + purchase_date="2023-01-01", + gross_purchase_amount=12000, + depreciation_start_date="2023-01-31", + total_number_of_depreciations=12, + frequency_of_depreciation=1, + daily_depreciation=1, + ) + + expected_schedules = [ + ["2023-01-31", 1021.98, 1021.98], + ["2023-02-28", 923.08, 1945.06], + ["2023-03-31", 1021.98, 2967.04], + ["2023-04-30", 989.01, 3956.05], + ["2023-05-31", 1021.98, 4978.03], + ["2023-06-30", 989.01, 5967.04], + ["2023-07-31", 1021.98, 6989.02], + ["2023-08-31", 1021.98, 8011.0], + ["2023-09-30", 989.01, 9000.01], + ["2023-10-31", 1021.98, 10021.99], + ["2023-11-30", 989.01, 11011.0], + ["2023-12-31", 989.0, 12000.0], + ] + + schedules = [ + [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount] + for d in asset.get("schedules") + ] + + self.assertEqual(schedules, expected_schedules) + def test_schedule_for_double_declining_method(self): asset = create_asset( calculate_depreciation=1, @@ -1298,6 +1332,7 @@ def test_clear_depreciation_schedule_for_multiple_finance_books(self): asset.append( "finance_books", { + "finance_book": "Test Finance Book 1", "depreciation_method": "Straight Line", "frequency_of_depreciation": 1, "total_number_of_depreciations": 3, @@ -1308,6 +1343,7 @@ def test_clear_depreciation_schedule_for_multiple_finance_books(self): asset.append( "finance_books", { + "finance_book": "Test Finance Book 2", "depreciation_method": "Straight Line", "frequency_of_depreciation": 1, "total_number_of_depreciations": 6, @@ -1318,6 +1354,7 @@ def test_clear_depreciation_schedule_for_multiple_finance_books(self): asset.append( "finance_books", { + "finance_book": "Test Finance Book 3", "depreciation_method": "Straight Line", "frequency_of_depreciation": 12, "total_number_of_depreciations": 3, @@ -1347,6 +1384,7 @@ def test_depreciation_schedules_are_set_up_for_multiple_finance_books(self): asset.append( "finance_books", { + "finance_book": "Test Finance Book 1", "depreciation_method": "Straight Line", "frequency_of_depreciation": 12, "total_number_of_depreciations": 3, @@ -1357,6 +1395,7 @@ def test_depreciation_schedules_are_set_up_for_multiple_finance_books(self): asset.append( "finance_books", { + "finance_book": "Test Finance Book 2", "depreciation_method": "Straight Line", "frequency_of_depreciation": 12, "total_number_of_depreciations": 6, @@ -1613,6 +1652,15 @@ def create_asset_data(): if not frappe.db.exists("Location", "Test Location"): frappe.get_doc({"doctype": "Location", "location_name": "Test Location"}).insert() + if not frappe.db.exists("Finance Book", "Test Finance Book 1"): + frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "Test Finance Book 1"}).insert() + + if not frappe.db.exists("Finance Book", "Test Finance Book 2"): + frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "Test Finance Book 2"}).insert() + + if not frappe.db.exists("Finance Book", "Test Finance Book 3"): + frappe.get_doc({"doctype": "Finance Book", "finance_book_name": "Test Finance Book 3"}).insert() + def create_asset(**args): args = frappe._dict(args) @@ -1638,6 +1686,7 @@ def create_asset(**args): "location": args.location or "Test Location", "asset_owner": args.asset_owner or "Company", "is_existing_asset": args.is_existing_asset or 1, + "is_composite_asset": args.is_composite_asset or 0, "asset_quantity": args.get("asset_quantity") or 1, "depr_entry_posting_status": args.depr_entry_posting_status or "", } @@ -1653,6 +1702,7 @@ def create_asset(**args): "total_number_of_depreciations": args.total_number_of_depreciations or 5, "expected_value_after_useful_life": args.expected_value_after_useful_life or 0, "depreciation_start_date": args.depreciation_start_date, + "daily_depreciation": args.daily_depreciation or 0, }, ) diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js index b312f93d319c..304bdf26dee8 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js @@ -15,9 +15,15 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s refresh() { this.show_general_ledger(); + if ((this.frm.doc.stock_items && this.frm.doc.stock_items.length) || !this.frm.doc.target_is_fixed_asset) { this.show_stock_ledger(); } + + if (this.frm.doc.stock_items && !this.frm.doc.stock_items.length && this.frm.doc.target_asset && this.frm.doc.capitalization_method === "Choose a WIP composite asset") { + this.set_consumed_stock_items_tagged_to_wip_composite_asset(this.frm.doc.target_asset); + this.get_target_asset_details(); + } } setup_queries() { @@ -34,18 +40,9 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s }); me.frm.set_query("target_asset", function() { - var filters = {}; - - if (me.frm.doc.target_item_code) { - filters['item_code'] = me.frm.doc.target_item_code; - } - - filters['status'] = ["not in", ["Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"]]; - filters['docstatus'] = 1; - return { - filters: filters - }; + filters: {'is_composite_asset': 1, 'docstatus': 0 } + } }); me.frm.set_query("asset", "asset_items", function() { @@ -104,6 +101,39 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s return this.get_target_item_details(); } + target_asset() { + if (this.frm.doc.target_asset && this.frm.doc.capitalization_method === "Choose a WIP composite asset") { + this.set_consumed_stock_items_tagged_to_wip_composite_asset(this.frm.doc.target_asset); + this.get_target_asset_details(); + } + } + + set_consumed_stock_items_tagged_to_wip_composite_asset(asset) { + var me = this; + + if (asset) { + return me.frm.call({ + method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_items_tagged_to_wip_composite_asset", + args: { + asset: asset, + }, + callback: function (r) { + if (!r.exc && r.message) { + me.frm.clear_table("stock_items"); + + for (let item of r.message) { + me.frm.add_child("stock_items", item); + } + + refresh_field("stock_items"); + + me.calculate_totals(); + } + } + }); + } + } + item_code(doc, cdt, cdn) { var row = frappe.get_doc(cdt, cdn); if (cdt === "Asset Capitalization Stock Item") { @@ -218,6 +248,26 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s } } + get_target_asset_details() { + var me = this; + + if (me.frm.doc.target_asset) { + return me.frm.call({ + method: "erpnext.assets.doctype.asset_capitalization.asset_capitalization.get_target_asset_details", + child: me.frm.doc, + args: { + asset: me.frm.doc.target_asset, + company: me.frm.doc.company, + }, + callback: function (r) { + if (!r.exc) { + me.frm.refresh_fields(); + } + } + }); + } + } + get_consumed_stock_item_details(row) { var me = this; diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json index 04b0c4e5132c..9ddc44212f6d 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.json @@ -8,24 +8,25 @@ "engine": "InnoDB", "field_order": [ "title", + "company", "naming_series", "entry_type", - "target_item_code", - "target_asset", "target_item_name", "target_is_fixed_asset", "target_has_batch_no", "target_has_serial_no", "column_break_9", - "target_asset_name", + "capitalization_method", + "target_item_code", "target_asset_location", + "target_asset", + "target_asset_name", "target_warehouse", "target_qty", "target_stock_uom", "target_batch_no", "target_serial_no", "column_break_5", - "company", "finance_book", "posting_date", "posting_time", @@ -57,12 +58,13 @@ "label": "Title" }, { + "depends_on": "eval:(doc.target_item_code && !doc.__islocal && doc.capitalization_method !== 'Choose a WIP composite asset') || ((doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset') || doc.entry_type=='Decapitalization')", "fieldname": "target_item_code", "fieldtype": "Link", "in_standard_filter": 1, "label": "Target Item Code", - "options": "Item", - "reqd": 1 + "mandatory_depends_on": "eval:(doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset') || doc.entry_type=='Decapitalization'", + "options": "Item" }, { "depends_on": "eval:doc.target_item_code && doc.target_item_name != doc.target_item_code", @@ -86,16 +88,18 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:(doc.target_asset && !doc.__islocal) || (doc.entry_type=='Capitalization' && doc.capitalization_method=='Choose a WIP composite asset')", "fieldname": "target_asset", "fieldtype": "Link", "in_standard_filter": 1, "label": "Target Asset", + "mandatory_depends_on": "eval:doc.entry_type=='Capitalization' && doc.capitalization_method=='Choose a WIP composite asset'", "no_copy": 1, "options": "Asset", - "read_only": 1 + "read_only_depends_on": "eval:(doc.entry_type=='Decapitalization') || (doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset')" }, { - "depends_on": "eval:doc.entry_type=='Capitalization'", + "depends_on": "eval:(doc.target_asset_name && !doc.__islocal) || (doc.target_asset && doc.entry_type=='Capitalization' && doc.capitalization_method=='Choose a WIP composite asset')", "fetch_from": "target_asset.asset_name", "fieldname": "target_asset_name", "fieldtype": "Data", @@ -186,12 +190,14 @@ }, { "default": "1", + "depends_on": "eval:doc.entry_type=='Decapitalization'", "fieldname": "target_qty", "fieldtype": "Float", "label": "Target Qty", "read_only_depends_on": "eval:doc.entry_type=='Capitalization'" }, { + "depends_on": "eval:doc.entry_type=='Decapitalization'", "fetch_from": "target_item_code.stock_uom", "fieldname": "target_stock_uom", "fieldtype": "Link", @@ -331,18 +337,26 @@ "read_only": 1 }, { - "depends_on": "eval:doc.entry_type=='Capitalization'", + "depends_on": "eval:doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset'", "fieldname": "target_asset_location", "fieldtype": "Link", "label": "Target Asset Location", - "mandatory_depends_on": "eval:doc.entry_type=='Capitalization'", + "mandatory_depends_on": "eval:doc.entry_type=='Capitalization' && doc.capitalization_method=='Create a new composite asset'", "options": "Location" + }, + { + "depends_on": "eval:doc.entry_type=='Capitalization'", + "fieldname": "capitalization_method", + "fieldtype": "Select", + "label": "Capitalization Method", + "mandatory_depends_on": "eval:doc.entry_type=='Capitalization'", + "options": "\nCreate a new composite asset\nChoose a WIP composite asset" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-06-22 14:17:07.995120", + "modified": "2023-10-03 22:55:59.461456", "modified_by": "Administrator", "module": "Assets", "name": "Asset Capitalization", diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index 5625fbb523ba..04654104c7ab 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -53,6 +53,7 @@ def validate(self): self.validate_posting_time() self.set_missing_values(for_validate=True) self.validate_target_item() + self.validate_target_asset() self.validate_consumed_stock_item() self.validate_consumed_asset_item() self.validate_service_item() @@ -63,12 +64,12 @@ def validate(self): def before_submit(self): self.validate_source_mandatory() - if self.entry_type == "Capitalization": - self.create_target_asset() + self.create_target_asset() def on_submit(self): self.update_stock_ledger() self.make_gl_entries() + self.update_target_asset() def on_cancel(self): self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") @@ -85,6 +86,11 @@ def set_missing_values(self, for_validate=False): if self.meta.has_field(k) and (not self.get(k) or k in force_fields): self.set(k, v) + target_asset_details = get_target_asset_details(self.target_asset, self.company) + for k, v in target_asset_details.items(): + if self.meta.has_field(k) and (not self.get(k) or k in force_fields): + self.set(k, v) + for d in self.stock_items: args = self.as_dict() args.update(d.as_dict()) @@ -146,6 +152,33 @@ def validate_target_item(self): self.validate_item(target_item) + def validate_target_asset(self): + if self.target_asset: + target_asset = self.get_asset_for_validation(self.target_asset) + + if not target_asset.is_composite_asset: + frappe.throw(_("Target Asset {0} needs to be composite asset").format(target_asset.name)) + + if target_asset.item_code != self.target_item_code: + frappe.throw( + _("Asset {0} does not belong to Item {1}").format(self.target_asset, self.target_item_code) + ) + + if target_asset.status in ("Scrapped", "Sold", "Capitalized", "Decapitalized"): + frappe.throw( + _("Target Asset {0} cannot be {1}").format(target_asset.name, target_asset.status) + ) + + if target_asset.docstatus == 1: + frappe.throw(_("Target Asset {0} cannot be submitted").format(target_asset.name)) + elif target_asset.docstatus == 2: + frappe.throw(_("Target Asset {0} cannot be cancelled").format(target_asset.name)) + + if target_asset.company != self.company: + frappe.throw( + _("Target Asset {0} does not belong to company {1}").format(target_asset.name, self.company) + ) + def validate_consumed_stock_item(self): for d in self.stock_items: if d.item_code: @@ -170,7 +203,23 @@ def validate_consumed_asset_item(self): ) asset = self.get_asset_for_validation(d.asset) - self.validate_asset(asset) + + if asset.status in ("Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"): + frappe.throw( + _("Row #{0}: Consumed Asset {1} cannot be {2}").format(d.idx, asset.name, asset.status) + ) + + if asset.docstatus == 0: + frappe.throw(_("Row #{0}: Consumed Asset {1} cannot be Draft").format(d.idx, asset.name)) + elif asset.docstatus == 2: + frappe.throw(_("Row #{0}: Consumed Asset {1} cannot be cancelled").format(d.idx, asset.name)) + + if asset.company != self.company: + frappe.throw( + _("Row #{0}: Consumed Asset {1} does not belong to company {2}").format( + d.idx, asset.name, self.company + ) + ) def validate_service_item(self): for d in self.service_items: @@ -205,21 +254,12 @@ def validate_item(self, item): def get_asset_for_validation(self, asset): return frappe.db.get_value( - "Asset", asset, ["name", "item_code", "company", "status", "docstatus"], as_dict=1 + "Asset", + asset, + ["name", "item_code", "company", "status", "docstatus", "is_composite_asset"], + as_dict=1, ) - def validate_asset(self, asset): - if asset.status in ("Draft", "Scrapped", "Sold", "Capitalized", "Decapitalized"): - frappe.throw(_("Asset {0} is {1}").format(asset.name, asset.status)) - - if asset.docstatus == 0: - frappe.throw(_("Asset {0} is Draft").format(asset.name)) - if asset.docstatus == 2: - frappe.throw(_("Asset {0} is cancelled").format(asset.name)) - - if asset.company != self.company: - frappe.throw(_("Asset {0} does not belong to company {1}").format(asset.name, self.company)) - @frappe.whitelist() def set_warehouse_details(self): for d in self.get("stock_items"): @@ -325,7 +365,7 @@ def make_gl_entries(self, gl_entries=None, from_repost=False): gl_entries = self.get_gl_entries() if gl_entries: - make_gl_entries(gl_entries, from_repost=from_repost) + make_gl_entries(gl_entries, merge_entries=False, from_repost=from_repost) elif self.docstatus == 2: make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) @@ -355,9 +395,6 @@ def get_gl_entries( gl_entries, target_account, target_against, precision ) - if not self.stock_items and not self.service_items and self.are_all_asset_items_non_depreciable: - return [] - self.get_gl_entries_for_target_item(gl_entries, target_against, precision) return gl_entries @@ -402,14 +439,11 @@ def get_gl_entries_for_consumed_stock_items( def get_gl_entries_for_consumed_asset_items( self, gl_entries, target_account, target_against, precision ): - self.are_all_asset_items_non_depreciable = True - # Consumed Assets for item in self.asset_items: asset = frappe.get_doc("Asset", item.asset) if asset.calculate_depreciation: - self.are_all_asset_items_non_depreciable = False depreciate_asset(asset, self.posting_date) asset.reload() @@ -491,16 +525,25 @@ def get_gl_entries_for_target_item(self, gl_entries, target_against, precision): ) def create_target_asset(self): + if ( + self.entry_type != "Capitalization" + or self.capitalization_method != "Create a new composite asset" + ): + return + total_target_asset_value = flt(self.total_value, self.precision("total_value")) + asset_doc = frappe.new_doc("Asset") asset_doc.company = self.company asset_doc.item_code = self.target_item_code - asset_doc.is_existing_asset = 1 + asset_doc.is_composite_asset = 1 asset_doc.location = self.target_asset_location asset_doc.available_for_use_date = self.posting_date asset_doc.purchase_date = self.posting_date asset_doc.gross_purchase_amount = total_target_asset_value asset_doc.purchase_receipt_amount = total_target_asset_value + asset_doc.purchase_receipt_amount = total_target_asset_value + asset_doc.capitalized_in = self.name asset_doc.flags.ignore_validate = True asset_doc.insert() @@ -516,6 +559,28 @@ def create_target_asset(self): ).format(get_link_to_form("Asset", asset_doc.name)) ) + def update_target_asset(self): + if ( + self.entry_type != "Capitalization" + or self.capitalization_method != "Choose a WIP composite asset" + ): + return + + total_target_asset_value = flt(self.total_value, self.precision("total_value")) + + asset_doc = frappe.get_doc("Asset", self.target_asset) + asset_doc.gross_purchase_amount = total_target_asset_value + asset_doc.purchase_receipt_amount = total_target_asset_value + asset_doc.capitalized_in = self.name + asset_doc.flags.ignore_validate = True + asset_doc.save() + + frappe.msgprint( + _( + "Asset {0} has been updated. Please set the depreciation details if any and submit it." + ).format(get_link_to_form("Asset", asset_doc.name)) + ) + def restore_consumed_asset_items(self): for item in self.asset_items: asset = frappe.get_doc("Asset", item.asset) @@ -574,6 +639,33 @@ def get_target_item_details(item_code=None, company=None): return out +@frappe.whitelist() +def get_target_asset_details(asset=None, company=None): + out = frappe._dict() + + # Get Asset Details + asset_details = frappe._dict() + if asset: + asset_details = frappe.db.get_value("Asset", asset, ["asset_name", "item_code"], as_dict=1) + if not asset_details: + frappe.throw(_("Asset {0} does not exist").format(asset)) + + # Re-set item code from Asset + out.target_item_code = asset_details.item_code + + # Set Asset Details + out.asset_name = asset_details.asset_name + + if asset_details.item_code: + out.target_fixed_asset_account = get_asset_category_account( + "fixed_asset_account", item=asset_details.item_code, company=company + ) + else: + out.target_fixed_asset_account = None + + return out + + @frappe.whitelist() def get_consumed_stock_item_details(args): if isinstance(args, string_types): @@ -722,3 +814,30 @@ def get_service_item_details(args): ) return out + + +@frappe.whitelist() +def get_items_tagged_to_wip_composite_asset(asset): + fields = [ + "item_code", + "item_name", + "batch_no", + "serial_no", + "stock_qty", + "stock_uom", + "warehouse", + "cost_center", + "qty", + "valuation_rate", + "amount", + ] + + pi_items = frappe.get_all( + "Purchase Invoice Item", filters={"wip_composite_asset": asset}, fields=fields + ) + + pr_items = frappe.get_all( + "Purchase Receipt Item", filters={"wip_composite_asset": asset}, fields=fields + ) + + return pi_items + pr_items diff --git a/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py index ead7abbf3406..59b65ec3fd02 100644 --- a/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py @@ -50,6 +50,7 @@ def test_capitalization_with_perpetual_inventory(self): # Create and submit Asset Captitalization asset_capitalization = create_asset_capitalization( entry_type="Capitalization", + capitalization_method="Create a new composite asset", target_item_code="Macbook Pro", target_asset_location="Test Location", stock_qty=stock_qty, @@ -139,6 +140,7 @@ def test_capitalization_with_periodical_inventory(self): # Create and submit Asset Captitalization asset_capitalization = create_asset_capitalization( entry_type="Capitalization", + capitalization_method="Create a new composite asset", target_item_code="Macbook Pro", target_asset_location="Test Location", stock_qty=stock_qty, @@ -203,6 +205,77 @@ def test_capitalization_with_periodical_inventory(self): self.assertFalse(get_actual_gle_dict(asset_capitalization.name)) self.assertFalse(get_actual_sle_dict(asset_capitalization.name)) + def test_capitalization_with_wip_composite_asset(self): + company = "_Test Company with perpetual inventory" + set_depreciation_settings_in_company(company=company) + + stock_rate = 1000 + stock_qty = 2 + stock_amount = 2000 + + total_amount = 2000 + + wip_composite_asset = create_asset( + asset_name="Asset Capitalization WIP Composite Asset", + is_composite_asset=1, + warehouse="Stores - TCP1", + company=company, + ) + + # Create and submit Asset Captitalization + asset_capitalization = create_asset_capitalization( + entry_type="Capitalization", + capitalization_method="Choose a WIP composite asset", + target_asset=wip_composite_asset.name, + target_asset_location="Test Location", + stock_qty=stock_qty, + stock_rate=stock_rate, + service_expense_account="Expenses Included In Asset Valuation - TCP1", + company=company, + submit=1, + ) + + # Test Asset Capitalization values + self.assertEqual(asset_capitalization.entry_type, "Capitalization") + self.assertEqual(asset_capitalization.capitalization_method, "Choose a WIP composite asset") + self.assertEqual(asset_capitalization.target_qty, 1) + + self.assertEqual(asset_capitalization.stock_items[0].valuation_rate, stock_rate) + self.assertEqual(asset_capitalization.stock_items[0].amount, stock_amount) + self.assertEqual(asset_capitalization.stock_items_total, stock_amount) + + self.assertEqual(asset_capitalization.total_value, total_amount) + self.assertEqual(asset_capitalization.target_incoming_rate, total_amount) + + # Test Target Asset values + target_asset = frappe.get_doc("Asset", asset_capitalization.target_asset) + self.assertEqual(target_asset.gross_purchase_amount, total_amount) + self.assertEqual(target_asset.purchase_receipt_amount, total_amount) + + # Test General Ledger Entries + expected_gle = { + "_Test Fixed Asset - TCP1": 2000, + "_Test Warehouse - TCP1": -2000, + } + actual_gle = get_actual_gle_dict(asset_capitalization.name) + + self.assertEqual(actual_gle, expected_gle) + + # Test Stock Ledger Entries + expected_sle = { + ("Capitalization Source Stock Item", "_Test Warehouse - TCP1"): { + "actual_qty": -stock_qty, + "stock_value_difference": -stock_amount, + } + } + actual_sle = get_actual_sle_dict(asset_capitalization.name) + self.assertEqual(actual_sle, expected_sle) + + # Cancel Asset Capitalization and make test entries and status are reversed + asset_capitalization.cancel() + self.assertFalse(get_actual_gle_dict(asset_capitalization.name)) + self.assertFalse(get_actual_sle_dict(asset_capitalization.name)) + def test_decapitalization_with_depreciation(self): # Variables purchase_date = "2020-01-01" @@ -326,6 +399,7 @@ def create_asset_capitalization(**args): asset_capitalization.update( { "entry_type": args.entry_type or "Capitalization", + "capitalization_method": args.capitalization_method or None, "company": company, "posting_date": args.posting_date or now.strftime("%Y-%m-%d"), "posting_time": args.posting_time or now.strftime("%H:%M:%S.%f"), diff --git a/erpnext/assets/doctype/asset_category/asset_category.js b/erpnext/assets/doctype/asset_category/asset_category.js index c702687072d9..7dde14ea0e61 100644 --- a/erpnext/assets/doctype/asset_category/asset_category.js +++ b/erpnext/assets/doctype/asset_category/asset_category.js @@ -33,6 +33,7 @@ frappe.ui.form.on('Asset Category', { var d = locals[cdt][cdn]; return { "filters": { + "account_type": "Depreciation", "root_type": ["in", ["Expense", "Income"]], "is_group": 0, "company": d.company_name diff --git a/erpnext/assets/doctype/asset_category/asset_category.py b/erpnext/assets/doctype/asset_category/asset_category.py index 2e1def98fc3f..8d351412ca81 100644 --- a/erpnext/assets/doctype/asset_category/asset_category.py +++ b/erpnext/assets/doctype/asset_category/asset_category.py @@ -53,7 +53,7 @@ def validate_account_types(self): account_type_map = { "fixed_asset_account": {"account_type": ["Fixed Asset"]}, "accumulated_depreciation_account": {"account_type": ["Accumulated Depreciation"]}, - "depreciation_expense_account": {"root_type": ["Expense", "Income"]}, + "depreciation_expense_account": {"account_type": ["Depreciation"]}, "capital_work_in_progress_account": {"account_type": ["Capital Work in Progress"]}, } for d in self.accounts: diff --git a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json index e5a5f194c1bc..ea1a81128437 100644 --- a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json +++ b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json @@ -8,9 +8,11 @@ "finance_book", "depreciation_method", "total_number_of_depreciations", + "daily_depreciation", "column_break_5", "frequency_of_depreciation", "depreciation_start_date", + "salvage_value_percentage", "expected_value_after_useful_life", "value_after_depreciation", "rate_of_depreciation" @@ -79,12 +81,24 @@ "fieldname": "rate_of_depreciation", "fieldtype": "Percent", "label": "Rate of Depreciation" + }, + { + "default": "0", + "depends_on": "eval:doc.depreciation_method == \"Straight Line\" || doc.depreciation_method == \"Manual\"", + "fieldname": "daily_depreciation", + "fieldtype": "Check", + "label": "Daily Depreciation" + }, + { + "fieldname": "salvage_value_percentage", + "fieldtype": "Percent", + "label": "Salvage Value Percentage" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-06-17 12:59:05.743683", + "modified": "2023-09-29 15:39:52.740594", "modified_by": "Administrator", "module": "Assets", "name": "Asset Finance Book", @@ -93,5 +107,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py index 83031415ec3d..5c40072086ea 100644 --- a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py +++ b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py @@ -80,14 +80,16 @@ def calculate_next_due_date( next_due_date = add_days(start_date, 7) if periodicity == "Monthly": next_due_date = add_months(start_date, 1) + if periodicity == "Quarterly": + next_due_date = add_months(start_date, 3) + if periodicity == "Half-yearly": + next_due_date = add_months(start_date, 6) if periodicity == "Yearly": next_due_date = add_years(start_date, 1) if periodicity == "2 Yearly": next_due_date = add_years(start_date, 2) if periodicity == "3 Yearly": next_due_date = add_years(start_date, 3) - if periodicity == "Quarterly": - next_due_date = add_months(start_date, 3) if end_date and ( (start_date and start_date >= end_date) or (last_completion_date and last_completion_date >= end_date) diff --git a/erpnext/assets/doctype/asset_maintenance_task/asset_maintenance_task.json b/erpnext/assets/doctype/asset_maintenance_task/asset_maintenance_task.json index b7cb23e66878..80d90c63473c 100644 --- a/erpnext/assets/doctype/asset_maintenance_task/asset_maintenance_task.json +++ b/erpnext/assets/doctype/asset_maintenance_task/asset_maintenance_task.json @@ -71,7 +71,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Periodicity", - "options": "\nDaily\nWeekly\nMonthly\nQuarterly\nYearly\n2 Yearly\n3 Yearly", + "options": "\nDaily\nWeekly\nMonthly\nQuarterly\nHalf-yearly\nYearly\n2 Yearly\n3 Yearly", "reqd": 1 }, { @@ -153,4 +153,4 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC" -} \ No newline at end of file +} 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 ee75b83af396..29e7a9bdfd6c 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -5,15 +5,12 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, date_diff, flt, formatdate, getdate +from frappe.utils import flt, formatdate, getdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_checks_for_pl_and_bs_accounts, ) -from erpnext.assets.doctype.asset.asset import ( - get_asset_value_after_depreciation, - get_depreciation_amount, -) +from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts @@ -25,10 +22,10 @@ def validate(self): def on_submit(self): self.make_depreciation_entry() - self.reschedule_depreciations(self.new_asset_value) + self.update_asset(self.new_asset_value) def on_cancel(self): - self.reschedule_depreciations(self.current_asset_value) + self.update_asset(self.current_asset_value) def validate_date(self): asset_purchase_date = frappe.db.get_value("Asset", self.asset, "purchase_date") @@ -50,10 +47,10 @@ def set_current_asset_value(self): def make_depreciation_entry(self): asset = frappe.get_doc("Asset", self.asset) ( - fixed_asset_account, + _, accumulated_depreciation_account, depreciation_expense_account, - ) = get_depreciation_accounts(asset) + ) = get_depreciation_accounts(asset.asset_category, asset.company) depreciation_cost_center, depreciation_series = frappe.get_cached_value( "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] @@ -71,12 +68,16 @@ def make_depreciation_entry(self): "account": accumulated_depreciation_account, "credit_in_account_currency": self.difference_amount, "cost_center": depreciation_cost_center or self.cost_center, + "reference_type": "Asset", + "reference_name": asset.name, } debit_entry = { "account": depreciation_expense_account, "debit_in_account_currency": self.difference_amount, "cost_center": depreciation_cost_center or self.cost_center, + "reference_type": "Asset", + "reference_name": asset.name, } accounting_dimensions = get_checks_for_pl_and_bs_accounts() @@ -106,44 +107,11 @@ def make_depreciation_entry(self): self.db_set("journal_entry", je.name) - def reschedule_depreciations(self, asset_value): + def update_asset(self, asset_value): asset = frappe.get_doc("Asset", self.asset) - country = frappe.get_value("Company", self.company, "country") - - for d in asset.finance_books: - d.value_after_depreciation = asset_value - 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 - d.expected_value_after_useful_life) / flt( - total_days - ) - from_date = self.date - else: - no_of_depreciations = len( - [ - s.name for s in asset.schedules if (cint(s.finance_book_id) == d.idx and not s.journal_entry) - ] - ) + asset.flags.decrease_in_asset_value_due_to_value_adjustment = True - value_after_depreciation = d.value_after_depreciation - for data in asset.schedules: - if cint(data.finance_book_id) == d.idx and not data.journal_entry: - if d.depreciation_method in ("Straight Line", "Manual"): - days = date_diff(data.schedule_date, from_date) - depreciation_amount = days * rate_per_day - from_date = data.schedule_date - else: - depreciation_amount = get_depreciation_amount(asset, value_after_depreciation, d) - - if depreciation_amount: - value_after_depreciation -= flt(depreciation_amount) - data.depreciation_amount = depreciation_amount - - d.db_update() - - asset.set_accumulated_depreciation(ignore_booked_entry=True) - for asset_data in asset.schedules: - if not asset_data.journal_entry: - asset_data.db_update() + asset.prepare_depreciation_data(value_after_depreciation=asset_value, ignore_booked_entry=True) + asset.flags.ignore_validate_update_after_submit = True + asset.save() diff --git a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py index b2aa3958080b..977a9b3714be 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py @@ -4,9 +4,10 @@ import unittest import frappe -from frappe.utils import add_days, get_last_day, nowdate +from frappe.utils import add_days, cstr, get_last_day, getdate, nowdate from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation +from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries from erpnext.assets.doctype.asset.test_asset import create_asset_data from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -46,40 +47,44 @@ def test_current_asset_value(self): def test_asset_depreciation_value_adjustment(self): pr = make_purchase_receipt( - item_code="Macbook Pro", qty=1, rate=100000.0, location="Test Location" + item_code="Macbook Pro", qty=1, rate=120000.0, location="Test Location" ) asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, "name") asset_doc = frappe.get_doc("Asset", asset_name) asset_doc.calculate_depreciation = 1 - month_end_date = get_last_day(nowdate()) - purchase_date = nowdate() if nowdate() != month_end_date else add_days(nowdate(), -15) - - asset_doc.available_for_use_date = purchase_date - asset_doc.purchase_date = purchase_date + asset_doc.available_for_use_date = "2023-01-15" + asset_doc.purchase_date = "2023-01-15" asset_doc.calculate_depreciation = 1 asset_doc.append( "finance_books", { "expected_value_after_useful_life": 200, "depreciation_method": "Straight Line", - "total_number_of_depreciations": 3, - "frequency_of_depreciation": 10, - "depreciation_start_date": month_end_date, + "total_number_of_depreciations": 12, + "frequency_of_depreciation": 1, + "depreciation_start_date": "2023-01-31", }, ) asset_doc.submit() + post_depreciation_entries(getdate("2023-08-21")) + current_value = get_asset_value_after_depreciation(asset_doc.name) adj_doc = make_asset_value_adjustment( - asset=asset_doc.name, current_asset_value=current_value, new_asset_value=50000.0 + asset=asset_doc.name, + current_asset_value=current_value, + new_asset_value=50000.0, + date="2023-08-21", ) adj_doc.submit() + asset_doc.reload() + expected_gle = ( - ("_Test Accumulated Depreciations - _TC", 0.0, 50000.0), - ("_Test Depreciations - _TC", 50000.0, 0.0), + ("_Test Accumulated Depreciations - _TC", 0.0, 4625.29), + ("_Test Depreciations - _TC", 4625.29, 0.0), ) gle = frappe.db.sql( @@ -91,6 +96,29 @@ def test_asset_depreciation_value_adjustment(self): self.assertSequenceEqual(gle, expected_gle) + expected_schedules = [ + ["2023-01-31", 5474.73, 5474.73], + ["2023-02-28", 9983.33, 15458.06], + ["2023-03-31", 9983.33, 25441.39], + ["2023-04-30", 9983.33, 35424.72], + ["2023-05-31", 9983.33, 45408.05], + ["2023-06-30", 9983.33, 55391.38], + ["2023-07-31", 9983.33, 65374.71], + ["2023-08-31", 8300.0, 73674.71], + ["2023-09-30", 8300.0, 81974.71], + ["2023-10-31", 8300.0, 90274.71], + ["2023-11-30", 8300.0, 98574.71], + ["2023-12-31", 8300.0, 106874.71], + ["2024-01-15", 8300.0, 115174.71], + ] + + schedules = [ + [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount] + for d in asset_doc.get("schedules") + ] + + self.assertEqual(schedules, expected_schedules) + def make_asset_value_adjustment(**args): args = frappe._dict(args) diff --git a/erpnext/assets/doctype/location/location.json b/erpnext/assets/doctype/location/location.json index f56fd05d98c8..9202fb9d95f6 100644 --- a/erpnext/assets/doctype/location/location.json +++ b/erpnext/assets/doctype/location/location.json @@ -39,6 +39,7 @@ { "fieldname": "parent_location", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Parent Location", "options": "Location", "search_index": 1 @@ -141,11 +142,11 @@ ], "is_tree": 1, "links": [], - "modified": "2020-05-08 16:11:11.375701", + "modified": "2023-08-29 12:49:33.290527", "modified_by": "Administrator", "module": "Assets", "name": "Location", - "name_case": "Title Case", + "naming_rule": "By fieldname", "nsm_parent_field": "parent_location", "owner": "Administrator", "permissions": [ @@ -224,5 +225,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.json b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.json index b40243cb75a3..9074ba1cc164 100644 --- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.json +++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.json @@ -1,13 +1,15 @@ { - "add_total_row": 0, + "add_total_row": 1, + "columns": [], "creation": "2019-09-23 16:35:02.836134", - "disable_prepared_report": 1, "disabled": 0, "docstatus": 0, "doctype": "Report", + "filters": [], "idx": 0, "is_standard": "Yes", - "modified": "2019-10-22 13:00:31.539726", + "letterhead": null, + "modified": "2023-07-26 21:03:20.722628", "modified_by": "Administrator", "module": "Assets", "name": "Fixed Asset Register", 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 94c77ea517cd..383be973477d 100644 --- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py +++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py @@ -7,13 +7,14 @@ import frappe from frappe import _ from frappe.query_builder.functions import IfNull, Sum -from frappe.utils import cstr, flt, formatdate, getdate +from frappe.utils import add_months, cstr, flt, formatdate, getdate, nowdate, today from erpnext.accounts.report.financial_statements import ( get_fiscal_year_data, get_period_list, validate_fiscal_year, ) +from erpnext.accounts.utils import get_fiscal_year from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation @@ -37,15 +38,26 @@ def get_conditions(filters): if filters.get("company"): conditions["company"] = filters.company + if filters.filter_based_on == "Date Range": + if not filters.from_date and not filters.to_date: + filters.from_date = add_months(nowdate(), -12) + filters.to_date = nowdate() + conditions[date_field] = ["between", [filters.from_date, filters.to_date]] - if filters.filter_based_on == "Fiscal Year": + elif filters.filter_based_on == "Fiscal Year": + if not filters.from_fiscal_year and not filters.to_fiscal_year: + default_fiscal_year = get_fiscal_year(today())[0] + filters.from_fiscal_year = default_fiscal_year + filters.to_fiscal_year = default_fiscal_year + fiscal_year = get_fiscal_year_data(filters.from_fiscal_year, filters.to_fiscal_year) validate_fiscal_year(fiscal_year, filters.from_fiscal_year, filters.to_fiscal_year) filters.year_start_date = getdate(fiscal_year.year_start_date) 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("only_existing_assets"): conditions["is_existing_asset"] = filters.get("only_existing_assets") if filters.get("asset_category"): @@ -144,6 +156,8 @@ def get_data(filters): def prepare_chart_data(data, filters): + if not data: + return labels_values_map = {} if filters.filter_based_on not in ("Date Range", "Fiscal Year"): filters_filter_based_on = "Date Range" diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 8c73e56a99e2..71cb01b188f6 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -24,6 +24,7 @@ "bill_for_rejected_quantity_in_purchase_invoice", "disable_last_purchase_rate", "show_pay_button", + "use_transaction_date_exchange_rate", "subcontract", "backflush_raw_materials_of_subcontract_based_on", "column_break_11", @@ -164,6 +165,13 @@ "fieldname": "over_order_allowance", "fieldtype": "Float", "label": "Over Order Allowance (%)" + }, + { + "default": "0", + "description": "While making Purchase Invoice from Purchase Order, use Exchange Rate on Invoice's transaction date rather than inheriting it from Purchase Order. Only applies for Purchase Invoice.", + "fieldname": "use_transaction_date_exchange_rate", + "fieldtype": "Check", + "label": "Use Transaction Date Exchange Rate" } ], "icon": "fa fa-cog", @@ -171,7 +179,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-03-02 17:02:14.404622", + "modified": "2023-10-16 16:22:03.201078", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 8fa8f305549d..52a0f4ac2a28 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -369,7 +369,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e }, allow_child_item_selection: true, child_fieldname: "items", - child_columns: ["item_code", "qty"] + child_columns: ["item_code", "qty", "ordered_qty"] }) }, __("Get Items From")); diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index e9c2314762f2..abe0651c8db2 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -476,6 +476,7 @@ "depends_on": "eval:doc.is_subcontracted", "fieldname": "supplier_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Supplier Warehouse", "options": "Warehouse" }, @@ -1172,6 +1173,7 @@ "depends_on": "is_internal_supplier", "fieldname": "set_from_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Set From Warehouse", "options": "Warehouse" }, @@ -1279,7 +1281,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2023-05-24 11:16:41.195340", + "modified": "2023-10-01 20:58:07.851037", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index c645b04e1294..fe1b97025394 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -878,6 +878,7 @@ "depends_on": "eval:parent.is_internal_supplier", "fieldname": "from_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "From Warehouse", "options": "Warehouse" }, @@ -902,7 +903,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-11-29 16:47:41.364387", + "modified": "2023-09-13 16:22:40.825092", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 2f0b7862a82d..30abad528bfc 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -245,19 +245,21 @@ frappe.ui.form.on("Request for Quotation",{ ] }); - dialog.fields_dict['supplier'].df.onchange = () => { - var supplier = dialog.get_value('supplier'); - frm.call('get_supplier_email_preview', {supplier: supplier}).then(result => { + dialog.fields_dict["supplier"].df.onchange = () => { + frm.call("get_supplier_email_preview", { + supplier: dialog.get_value("supplier"), + }).then(({ message }) => { dialog.fields_dict.email_preview.$wrapper.empty(); - dialog.fields_dict.email_preview.$wrapper.append(result.message); + dialog.fields_dict.email_preview.$wrapper.append( + message.message + ); + dialog.set_value("subject", message.subject); }); - - } + }; dialog.fields_dict.note.$wrapper.append(`

This is a preview of the email to be sent. A PDF of the document will automatically be attached with the email.

`); - dialog.set_value("subject", frm.doc.subject); dialog.show(); } }) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index bd65b0c805e8..06dbd86ba126 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -20,11 +20,12 @@ "items_section", "items", "supplier_response_section", - "salutation", - "subject", - "col_break_email_1", "email_template", "preview", + "col_break_email_1", + "html_llwp", + "send_attached_files", + "send_document_print", "sec_break_email_2", "message_for_supplier", "terms_section_break", @@ -236,23 +237,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fetch_from": "email_template.subject", - "fetch_if_empty": 1, - "fieldname": "subject", - "fieldtype": "Data", - "label": "Subject", - "print_hide": 1 - }, - { - "description": "Select a greeting for the receiver. E.g. Mr., Ms., etc.", - "fieldname": "salutation", - "fieldtype": "Link", - "label": "Salutation", - "no_copy": 1, - "options": "Salutation", - "print_hide": 1 - }, { "fieldname": "col_break_email_1", "fieldtype": "Column Break" @@ -285,13 +269,36 @@ "fieldname": "named_place", "fieldtype": "Data", "label": "Named Place" + }, + { + "fieldname": "html_llwp", + "fieldtype": "HTML", + "options": "

In your Email Template, you can use the following special variables:\n

\n\n

\n

Apart from these, you can access all values in this RFQ, like {{ message_for_supplier }} or {{ terms }}.

", + "print_hide": 1, + "read_only": 1, + "report_hide": 1 + }, + { + "default": "1", + "description": "If enabled, all files attached to this document will be attached to each email", + "fieldname": "send_attached_files", + "fieldtype": "Check", + "label": "Send Attached Files" + }, + { + "default": "0", + "description": "If enabled, a print of this document will be attached to each email", + "fieldname": "send_document_print", + "fieldtype": "Check", + "label": "Send Document Print", + "print_hide": 1 } ], "icon": "fa fa-shopping-cart", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-01-31 23:22:06.684694", + "modified": "2023-08-09 12:20:26.850623", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", 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 4590f8c3d937..6b39982bb816 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -116,7 +116,10 @@ def get_link(self): route = frappe.db.get_value( "Portal Menu Item", {"reference_doctype": "Request for Quotation"}, ["route"] ) - return get_url("/app/{0}/".format(route) + self.name) + if not route: + frappe.throw(_("Please add Request for Quotation to the sidebar in Portal Settings.")) + + return get_url(f"{route}/{self.name}") def update_supplier_part_no(self, supplier): self.vendor = supplier @@ -179,37 +182,46 @@ def supplier_rfq_mail(self, data, update_password_link, rfq_link, preview=False) if full_name == "Guest": full_name = "Administrator" - # send document dict and some important data from suppliers row - # to render message_for_supplier from any template doc_args = self.as_dict() - doc_args.update({"supplier": data.get("supplier"), "supplier_name": data.get("supplier_name")}) - # Get Contact Full Name - supplier_name = None if data.get("contact"): - contact_name = frappe.db.get_value( - "Contact", data.get("contact"), ["first_name", "middle_name", "last_name"] - ) - supplier_name = (" ").join(x for x in contact_name if x) # remove any blank values - - args = { - "update_password_link": update_password_link, - "message": frappe.render_template(self.message_for_supplier, doc_args), - "rfq_link": rfq_link, - "user_fullname": full_name, - "supplier_name": supplier_name or data.get("supplier_name"), - "supplier_salutation": self.salutation or "Dear Mx.", - } + contact = frappe.get_doc("Contact", data.get("contact")) + doc_args["contact"] = contact.as_dict() - subject = self.subject or _("Request for Quotation") - template = "templates/emails/request_for_quotation.html" + doc_args.update( + { + "supplier": data.get("supplier"), + "supplier_name": data.get("supplier_name"), + "update_password_link": f'{_("Set Password")}', + "portal_link": f' {_("Submit your Quotation")} ', + "user_fullname": full_name, + } + ) + email_template = frappe.get_doc("Email Template", self.email_template) + message = frappe.render_template(email_template.response_, doc_args) + subject = frappe.render_template(email_template.subject, doc_args) sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None - message = frappe.get_template(template).render(args) if preview: - return message - - attachments = self.get_attachments() + return {"message": message, "subject": subject} + + attachments = [] + if self.send_attached_files: + attachments = self.get_attachments() + + if self.send_document_print: + supplier_language = frappe.db.get_value("Supplier", data.supplier, "language") + system_language = frappe.db.get_single_value("System Settings", "language") + attachments.append( + frappe.attach_print( + self.doctype, + self.name, + doc=self, + print_format=self.meta.default_print_format or "Standard", + lang=supplier_language or system_language, + letterhead=self.letter_head, + ) + ) self.send_email(data, sender, subject, message, attachments) @@ -220,7 +232,6 @@ def send_email(self, data, sender, subject, message, attachments): recipients=data.email_id, sender=sender, attachments=attachments, - print_format=self.meta.default_print_format or "Standard", send_email=True, doctype=self.doctype, name=self.name, diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index d250e6f18a96..42fa1d923e16 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -2,11 +2,14 @@ # See license.txt +from urllib.parse import urlparse + import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import nowdate from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( + RequestforQuotation, create_supplier_quotation, get_pdf, make_supplier_quotation_from_rfq, @@ -125,13 +128,18 @@ def test_make_rfq_from_opportunity(self): rfq.status = "Draft" rfq.submit() + def test_get_link(self): + rfq = make_request_for_quotation() + parsed_link = urlparse(rfq.get_link()) + self.assertEqual(parsed_link.path, f"/rfq/{rfq.name}") + def test_get_pdf(self): rfq = make_request_for_quotation() get_pdf(rfq.name, rfq.get("suppliers")[0].supplier) self.assertEqual(frappe.local.response.type, "pdf") -def make_request_for_quotation(**args): +def make_request_for_quotation(**args) -> "RequestforQuotation": """ :param supplier_data: List containing supplier data """ diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js index 1ae6f0364746..e0e33b6848bd 100644 --- a/erpnext/buying/doctype/supplier/supplier.js +++ b/erpnext/buying/doctype/supplier/supplier.js @@ -68,7 +68,7 @@ frappe.ui.form.on("Supplier", { }, __("View")); frm.add_custom_button(__('Accounts Payable'), function () { - frappe.set_route('query-report', 'Accounts Payable', { supplier: frm.doc.name }); + frappe.set_route('query-report', 'Accounts Payable', { party_type: "Supplier", party: frm.doc.name }); }, __("View")); frm.add_custom_button(__('Bank Account'), function () { diff --git a/erpnext/buying/doctype/supplier/supplier_dashboard.py b/erpnext/buying/doctype/supplier/supplier_dashboard.py index 11bb06e0caa0..3bd306e65919 100644 --- a/erpnext/buying/doctype/supplier/supplier_dashboard.py +++ b/erpnext/buying/doctype/supplier/supplier_dashboard.py @@ -8,7 +8,7 @@ def get_data(): "This is based on transactions against this Supplier. See timeline below for details" ), "fieldname": "supplier", - "non_standard_fieldnames": {"Payment Entry": "party_name", "Bank Account": "party"}, + "non_standard_fieldnames": {"Payment Entry": "party", "Bank Account": "party"}, "transactions": [ {"label": _("Procurement"), "items": ["Request for Quotation", "Supplier Quotation"]}, {"label": _("Orders"), "items": ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]}, diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index b9fc344647b7..7c7467e6f649 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -195,6 +195,9 @@ def test_serach_fields_for_supplier(self): def create_supplier(**args): args = frappe._dict(args) + if not args.supplier_name: + args.supplier_name = frappe.generate_hash() + if frappe.db.exists("Supplier", args.supplier_name): return frappe.get_doc("Supplier", args.supplier_name) @@ -202,6 +205,7 @@ def create_supplier(**args): { "doctype": "Supplier", "supplier_name": args.supplier_name, + "default_currency": args.default_currency, "supplier_group": args.supplier_group or "Services", "supplier_type": args.supplier_type or "Company", "tax_withholding_category": args.tax_withholding_category, diff --git a/erpnext/buying/report/procurement_tracker/procurement_tracker.py b/erpnext/buying/report/procurement_tracker/procurement_tracker.py index 71019e803775..a7e03c08face 100644 --- a/erpnext/buying/report/procurement_tracker/procurement_tracker.py +++ b/erpnext/buying/report/procurement_tracker/procurement_tracker.py @@ -154,31 +154,35 @@ def get_data(filters): procurement_record = [] if procurement_record_against_mr: procurement_record += procurement_record_against_mr + for po in purchase_order_entry: # fetch material records linked to the purchase order item - mr_record = mr_records.get(po.material_request_item, [{}])[0] - procurement_detail = { - "material_request_date": mr_record.get("transaction_date"), - "cost_center": po.cost_center, - "project": po.project, - "requesting_site": po.warehouse, - "requestor": po.owner, - "material_request_no": po.material_request, - "item_code": po.item_code, - "quantity": flt(po.qty), - "unit_of_measurement": po.stock_uom, - "status": po.status, - "purchase_order_date": po.transaction_date, - "purchase_order": po.parent, - "supplier": po.supplier, - "estimated_cost": flt(mr_record.get("amount")), - "actual_cost": flt(pi_records.get(po.name)), - "purchase_order_amt": flt(po.amount), - "purchase_order_amt_in_company_currency": flt(po.base_amount), - "expected_delivery_date": po.schedule_date, - "actual_delivery_date": pr_records.get(po.name), - } - procurement_record.append(procurement_detail) + material_requests = mr_records.get(po.material_request_item, [{}]) + + for mr_record in material_requests: + procurement_detail = { + "material_request_date": mr_record.get("transaction_date"), + "cost_center": po.cost_center, + "project": po.project, + "requesting_site": po.warehouse, + "requestor": po.owner, + "material_request_no": po.material_request, + "item_code": po.item_code, + "quantity": flt(po.qty), + "unit_of_measurement": po.stock_uom, + "status": po.status, + "purchase_order_date": po.transaction_date, + "purchase_order": po.parent, + "supplier": po.supplier, + "estimated_cost": flt(mr_record.get("amount")), + "actual_cost": flt(pi_records.get(po.name)), + "purchase_order_amt": flt(po.amount), + "purchase_order_amt_in_company_currency": flt(po.base_amount), + "expected_delivery_date": po.schedule_date, + "actual_delivery_date": pr_records.get(po.name), + } + procurement_record.append(procurement_detail) + return procurement_record @@ -301,7 +305,7 @@ def get_po_entries(filters): & (parent.name == child.parent) & (parent.status.notin(("Closed", "Completed", "Cancelled"))) ) - .groupby(parent.name, child.item_code) + .groupby(parent.name, child.material_request_item) ) query = apply_filters_on_query(filters, parent, child, query) diff --git a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py index e10c0e2fccf8..b6e46302ffe8 100644 --- a/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py +++ b/erpnext/buying/report/purchase_order_analysis/purchase_order_analysis.py @@ -6,7 +6,7 @@ import frappe from frappe import _ -from frappe.query_builder.functions import IfNull +from frappe.query_builder.functions import IfNull, Sum from frappe.utils import date_diff, flt, getdate @@ -57,7 +57,7 @@ def get_data(filters): po_item.qty, po_item.received_qty, (po_item.qty - po_item.received_qty).as_("pending_qty"), - IfNull(pi_item.qty, 0).as_("billed_qty"), + Sum(IfNull(pi_item.qty, 0)).as_("billed_qty"), po_item.base_amount.as_("amount"), (po_item.received_qty * po_item.base_rate).as_("received_qty_amount"), (po_item.billed_amt * IfNull(po.conversion_rate, 1)).as_("billed_amount"), diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py index 21241e086036..07187352eb70 100644 --- a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py +++ b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py @@ -7,7 +7,7 @@ import frappe from frappe import _ from frappe.query_builder.functions import Coalesce, Sum -from frappe.utils import date_diff, flt, getdate +from frappe.utils import cint, date_diff, flt, getdate def execute(filters=None): @@ -47,8 +47,10 @@ def get_data(filters): mr.transaction_date.as_("date"), mr_item.schedule_date.as_("required_date"), mr_item.item_code.as_("item_code"), - Sum(Coalesce(mr_item.stock_qty, 0)).as_("qty"), - Coalesce(mr_item.stock_uom, "").as_("uom"), + Sum(Coalesce(mr_item.qty, 0)).as_("qty"), + Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"), + Coalesce(mr_item.uom, "").as_("uom"), + Coalesce(mr_item.stock_uom, "").as_("stock_uom"), Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"), Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"), (Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.received_qty, 0))).as_( @@ -96,7 +98,7 @@ def get_conditions(filters, query, mr, mr_item): def update_qty_columns(row_to_update, data_row): - fields = ["qty", "ordered_qty", "received_qty", "qty_to_receive", "qty_to_order"] + fields = ["qty", "stock_qty", "ordered_qty", "received_qty", "qty_to_receive", "qty_to_order"] for field in fields: row_to_update[field] += flt(data_row[field]) @@ -104,16 +106,20 @@ def update_qty_columns(row_to_update, data_row): def prepare_data(data, filters): """Prepare consolidated Report data and Chart data""" material_request_map, item_qty_map = {}, {} + precision = cint(frappe.db.get_default("float_precision")) or 2 for row in data: # item wise map for charts if not row["item_code"] in item_qty_map: item_qty_map[row["item_code"]] = { - "qty": row["qty"], - "ordered_qty": row["ordered_qty"], - "received_qty": row["received_qty"], - "qty_to_receive": row["qty_to_receive"], - "qty_to_order": row["qty_to_order"], + "qty": flt(row["stock_qty"], precision), + "stock_qty": flt(row["stock_qty"], precision), + "stock_uom": row["stock_uom"], + "uom": row["uom"], + "ordered_qty": flt(row["ordered_qty"], precision), + "received_qty": flt(row["received_qty"], precision), + "qty_to_receive": flt(row["qty_to_receive"], precision), + "qty_to_order": flt(row["qty_to_order"], precision), } else: item_entry = item_qty_map[row["item_code"]] @@ -200,21 +206,34 @@ def get_columns(filters): {"label": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 100}, {"label": _("Description"), "fieldname": "description", "fieldtype": "Data", "width": 200}, { - "label": _("Stock UOM"), + "label": _("UOM"), "fieldname": "uom", "fieldtype": "Data", "width": 100, }, + { + "label": _("Stock UOM"), + "fieldname": "stock_uom", + "fieldtype": "Data", + "width": 100, + }, ] ) columns.extend( [ { - "label": _("Stock Qty"), + "label": _("Qty"), "fieldname": "qty", "fieldtype": "Float", - "width": 120, + "width": 140, + "convertible": "qty", + }, + { + "label": _("Qty in Stock UOM"), + "fieldname": "stock_qty", + "fieldtype": "Float", + "width": 140, "convertible": "qty", }, { diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py index a72829096181..01ff28d8103d 100644 --- a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py +++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py @@ -35,8 +35,12 @@ def get_data(filters): sq_item.parent, sq_item.item_code, sq_item.qty, + sq.currency, sq_item.stock_qty, sq_item.amount, + sq_item.base_rate, + sq_item.base_amount, + sq.price_list_currency, sq_item.uom, sq_item.stock_uom, sq_item.request_for_quotation, @@ -105,7 +109,11 @@ def prepare_data(supplier_quotation_data, filters): "qty": data.get("qty"), "price": flt(data.get("amount") * exchange_rate, float_precision), "uom": data.get("uom"), + "price_list_currency": data.get("price_list_currency"), + "currency": data.get("currency"), "stock_uom": data.get("stock_uom"), + "base_amount": flt(data.get("base_amount"), float_precision), + "base_rate": flt(data.get("base_rate"), float_precision), "request_for_quotation": data.get("request_for_quotation"), "valid_till": data.get("valid_till"), "lead_time_days": data.get("lead_time_days"), @@ -183,6 +191,8 @@ def prepare_chart_data(suppliers, qty_list, supplier_qty_price_map): def get_columns(filters): + currency = frappe.get_cached_value("Company", filters.get("company"), "default_currency") + group_by_columns = [ { "fieldname": "supplier_name", @@ -203,11 +213,18 @@ def get_columns(filters): columns = [ {"fieldname": "uom", "label": _("UOM"), "fieldtype": "Link", "options": "UOM", "width": 90}, {"fieldname": "qty", "label": _("Quantity"), "fieldtype": "Float", "width": 80}, + { + "fieldname": "currency", + "label": _("Currency"), + "fieldtype": "Link", + "options": "Currency", + "width": 110, + }, { "fieldname": "price", "label": _("Price"), "fieldtype": "Currency", - "options": "Company:company:default_currency", + "options": "currency", "width": 110, }, { @@ -221,9 +238,23 @@ def get_columns(filters): "fieldname": "price_per_unit", "label": _("Price per Unit (Stock UOM)"), "fieldtype": "Currency", - "options": "Company:company:default_currency", + "options": "currency", "width": 120, }, + { + "fieldname": "base_amount", + "label": _("Price ({0})").format(currency), + "fieldtype": "Currency", + "options": "price_list_currency", + "width": 180, + }, + { + "fieldname": "base_rate", + "label": _("Price Per Unit ({0})").format(currency), + "fieldtype": "Currency", + "options": "price_list_currency", + "width": 180, + }, { "fieldname": "quotation", "label": _("Supplier Quotation"), diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 4080c4b0a5ae..fd1d199e7d17 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -5,13 +5,14 @@ import json import frappe -from frappe import _, bold, throw +from frappe import _, bold, qb, 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 ( add_days, add_months, cint, + comma_and, flt, fmt_money, formatdate, @@ -31,13 +32,19 @@ apply_pricing_rule_on_transaction, get_applied_pricing_rules, ) +from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.party import ( get_party_account, get_party_account_currency, get_party_gle_currency, validate_party_frozen_disabled, ) -from erpnext.accounts.utils import get_account_currency, get_fiscal_years, validate_fiscal_year +from erpnext.accounts.utils import ( + create_gain_loss_journal, + get_account_currency, + get_fiscal_years, + validate_fiscal_year, +) from erpnext.buying.utils import update_last_purchase_rate from erpnext.controllers.print_settings import ( set_print_templates_for_item_table, @@ -158,7 +165,6 @@ def validate(self): if self.meta.get_field("currency"): self.calculate_taxes_and_totals() validate_return(self) - self.set_total_in_words() self.validate_all_documents_schedule() @@ -171,6 +177,17 @@ def validate(self): self.validate_party_account_currency() if self.doctype in ["Purchase Invoice", "Sales Invoice"]: + if invalid_advances := [ + x for x in self.advances if not x.reference_type or not x.reference_name + ]: + frappe.throw( + _( + "Rows: {0} in {1} section are Invalid. Reference Name should point to a valid Payment Entry or Journal Entry." + ).format( + frappe.bold(comma_and([x.idx for x in invalid_advances])), frappe.bold(_("Advance Payments")) + ) + ) + pos_check_field = "is_pos" if self.doctype == "Sales Invoice" else "is_paid" if cint(self.allocate_advances_automatically) and not cint(self.get(pos_check_field)): self.set_advances() @@ -190,22 +207,82 @@ def validate(self): # apply tax withholding only if checked and applicable self.set_tax_withholding() - validate_regional(self) - - validate_einvoice_fields(self) + with temporary_flag("company", self.company): + validate_regional(self) + validate_einvoice_fields(self) if self.doctype != "Material Request" and not self.ignore_pricing_rule: apply_pricing_rule_on_transaction(self) + self.set_total_in_words() + def before_cancel(self): validate_einvoice_fields(self) + def _remove_references_in_unreconcile(self): + upe = frappe.qb.DocType("Unreconcile Payment Entries") + rows = ( + frappe.qb.from_(upe) + .select(upe.name, upe.parent) + .where((upe.reference_doctype == self.doctype) & (upe.reference_name == self.name)) + .run(as_dict=True) + ) + + if rows: + references_map = frappe._dict() + for x in rows: + references_map.setdefault(x.parent, []).append(x.name) + + for doc, rows in references_map.items(): + unreconcile_doc = frappe.get_doc("Unreconcile Payments", doc) + for row in rows: + unreconcile_doc.remove(unreconcile_doc.get("allocations", {"name": row})[0]) + + unreconcile_doc.flags.ignore_validate_update_after_submit = True + unreconcile_doc.flags.ignore_links = True + unreconcile_doc.save(ignore_permissions=True) + + # delete docs upon parent doc deletion + unreconcile_docs = frappe.db.get_all("Unreconcile Payments", filters={"voucher_no": self.name}) + for x in unreconcile_docs: + _doc = frappe.get_doc("Unreconcile Payments", x.name) + if _doc.docstatus == 1: + _doc.cancel() + _doc.delete() + + def _remove_references_in_repost_doctypes(self): + repost_doctypes = ["Repost Payment Ledger Items", "Repost Accounting Ledger Items"] + + for _doctype in repost_doctypes: + dt = frappe.qb.DocType(_doctype) + rows = ( + frappe.qb.from_(dt) + .select(dt.name, dt.parent, dt.parenttype) + .where((dt.voucher_type == self.doctype) & (dt.voucher_no == self.name)) + .run(as_dict=True) + ) + + if rows: + references_map = frappe._dict() + for x in rows: + references_map.setdefault((x.parenttype, x.parent), []).append(x.name) + + for doc, rows in references_map.items(): + repost_doc = frappe.get_doc(doc[0], doc[1]) + + for row in rows: + if _doctype == "Repost Payment Ledger Items": + repost_doc.remove(repost_doc.get("repost_vouchers", {"name": row})[0]) + else: + repost_doc.remove(repost_doc.get("vouchers", {"name": row})[0]) + + repost_doc.flags.ignore_validate_update_after_submit = True + repost_doc.flags.ignore_links = True + repost_doc.save(ignore_permissions=True) + def on_trash(self): - # delete references in 'Repost Payment Ledger' - rpi = frappe.qb.DocType("Repost Payment Ledger Items") - frappe.qb.from_(rpi).delete().where( - (rpi.voucher_type == self.doctype) & (rpi.voucher_no == self.name) - ).run() + self._remove_references_in_repost_doctypes() + self._remove_references_in_unreconcile() # delete sl and gl entries on deletion of transaction if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"): @@ -502,6 +579,17 @@ def set_price_list_currency(self, buying_or_selling): self.currency, self.company_currency, transaction_date, args ) + if ( + self.currency + and buying_or_selling == "Buying" + and frappe.db.get_single_value("Buying Settings", "use_transaction_date_exchange_rate") + and self.doctype == "Purchase Invoice" + ): + self.use_transaction_date_exchange_rate = True + self.conversion_rate = get_exchange_rate( + self.currency, self.company_currency, transaction_date, args + ) + def set_missing_item_details(self, for_validate=False): """set missing item values""" from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -525,6 +613,7 @@ def set_missing_item_details(self, for_validate=False): self.pricing_rules = [] + selected_serial_nos_map = {} for item in self.get("items"): if item.get("item_code"): args = parent_dict.copy() @@ -536,6 +625,7 @@ def set_missing_item_details(self, for_validate=False): args["ignore_pricing_rule"] = ( self.ignore_pricing_rule if hasattr(self, "ignore_pricing_rule") else 0 ) + args["ignore_serial_nos"] = selected_serial_nos_map.get(item.get("item_code")) if not args.get("transaction_date"): args["transaction_date"] = args.get("posting_date") @@ -592,6 +682,11 @@ def set_missing_item_details(self, for_validate=False): if ret.get("pricing_rules"): self.apply_pricing_rule_on_items(item, ret) self.set_pricing_rule_details(item, ret) + + if ret.get("serial_no"): + selected_serial_nos_map.setdefault(item.get("item_code"), []).extend( + ret.get("serial_no").split("\n") + ) else: # Transactions line item without item code @@ -705,7 +800,9 @@ def set_other_charges(self): def validate_enabled_taxes_and_charges(self): taxes_and_charges_doctype = self.meta.get_options("taxes_and_charges") - if frappe.get_cached_value(taxes_and_charges_doctype, self.taxes_and_charges, "disabled"): + if self.taxes_and_charges and frappe.get_cached_value( + taxes_and_charges_doctype, self.taxes_and_charges, "disabled" + ): frappe.throw( _("{0} '{1}' is disabled").format(taxes_and_charges_doctype, self.taxes_and_charges) ) @@ -896,7 +993,7 @@ def get_advance_entries(self, include_unallocated=True): party_type, party, party_account, amount_field, order_doctype, order_list, include_unallocated ) - payment_entries = get_advance_payment_entries( + payment_entries = get_advance_payment_entries_for_regional( party_type, party, party_account, order_doctype, order_list, include_unallocated ) @@ -957,67 +1054,210 @@ def set_advance_gain_or_loss(self): d.exchange_gain_loss = difference - def make_exchange_gain_loss_gl_entries(self, gl_entries): - if self.get("doctype") in ["Purchase Invoice", "Sales Invoice"]: - for d in self.get("advances"): - if d.exchange_gain_loss: - is_purchase_invoice = self.get("doctype") == "Purchase Invoice" - party = self.supplier if is_purchase_invoice else self.customer - party_account = self.credit_to if is_purchase_invoice else self.debit_to - party_type = "Supplier" if is_purchase_invoice else "Customer" - - gain_loss_account = frappe.get_cached_value( - "Company", self.company, "exchange_gain_loss_account" - ) - if not gain_loss_account: - frappe.throw( - _("Please set default Exchange Gain/Loss Account in Company {}").format(self.get("company")) + def gain_loss_journal_already_booked( + self, + gain_loss_account, + exc_gain_loss, + ref2_dt, + ref2_dn, + ref2_detail_no, + ) -> bool: + """ + Check if gain/loss is booked + """ + if res := frappe.db.get_all( + "Journal Entry Account", + filters={ + "docstatus": 1, + "account": gain_loss_account, + "reference_type": ref2_dt, # this will be Journal Entry + "reference_name": ref2_dn, + "reference_detail_no": ref2_detail_no, + }, + pluck="parent", + ): + # deduplicate + res = list({x for x in res}) + if exc_vouchers := frappe.db.get_all( + "Journal Entry", + filters={"name": ["in", res], "voucher_type": "Exchange Gain Or Loss"}, + fields=["voucher_type", "total_debit", "total_credit"], + ): + booked_voucher = exc_vouchers[0] + if ( + booked_voucher.total_debit == exc_gain_loss + and booked_voucher.total_credit == exc_gain_loss + and booked_voucher.voucher_type == "Exchange Gain Or Loss" + ): + return True + return False + + def make_exchange_gain_loss_journal(self, args: dict = None) -> None: + """ + Make Exchange Gain/Loss journal for Invoices and Payments + """ + # Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event. + # see accounts/utils.py:cancel_exchange_gain_loss_journal() + if self.docstatus == 1: + if self.get("doctype") == "Journal Entry": + # 'args' is populated with exchange gain/loss account and the amount to be booked. + # These are generated by Sales/Purchase Invoice during reconciliation and advance allocation. + # and below logic is only for such scenarios + if args: + for arg in args: + # Advance section uses `exchange_gain_loss` and reconciliation uses `difference_amount` + if ( + arg.get("difference_amount", 0) != 0 or arg.get("exchange_gain_loss", 0) != 0 + ) and arg.get("difference_account"): + + party_account = arg.get("account") + gain_loss_account = arg.get("difference_account") + difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss") + if difference_amount > 0: + dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit" + else: + dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit" + + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + if not self.gain_loss_journal_already_booked( + gain_loss_account, + difference_amount, + self.doctype, + self.name, + arg.get("referenced_row"), + ): + posting_date = frappe.db.get_value(arg.voucher_type, arg.voucher_no, "posting_date") + je = create_gain_loss_journal( + self.company, + posting_date, + arg.get("party_type"), + arg.get("party"), + party_account, + gain_loss_account, + difference_amount, + dr_or_cr, + reverse_dr_or_cr, + arg.get("against_voucher_type"), + arg.get("against_voucher"), + arg.get("idx"), + self.doctype, + self.name, + arg.get("referenced_row"), + arg.get("cost_center"), + ) + frappe.msgprint( + _("Exchange Gain/Loss amount has been booked through {0}").format( + get_link_to_form("Journal Entry", je) + ) + ) + + if self.get("doctype") == "Payment Entry": + # For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation + gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0] + booked = [] + if gain_loss_to_book: + vtypes = [x.reference_doctype for x in gain_loss_to_book] + vnames = [x.reference_name for x in gain_loss_to_book] + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + parents = ( + qb.from_(jea) + .select(jea.parent) + .where( + (jea.reference_type == "Payment Entry") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) ) - account_currency = get_account_currency(gain_loss_account) - if account_currency != self.company_currency: - frappe.throw( - _("Currency for {0} must be {1}").format(gain_loss_account, self.company_currency) + .run() + ) + + booked = [] + if parents: + booked = ( + qb.from_(je) + .inner_join(jea) + .on(je.name == jea.parent) + .select(jea.reference_type, jea.reference_name, jea.reference_detail_no) + .where( + (je.docstatus == 1) + & (je.name.isin(parents)) + & (je.voucher_type == "Exchange Gain or Loss") + ) + .run() ) - # for purchase - dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" - if not is_purchase_invoice: - # just reverse for sales? - dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + for d in gain_loss_to_book: + # Filter out References for which Gain/Loss is already booked + if d.exchange_gain_loss and ( + (d.reference_doctype, d.reference_name, str(d.idx)) not in booked + ): + if self.payment_type == "Receive": + party_account = self.paid_from + elif self.payment_type == "Pay": + party_account = self.paid_to - gl_entries.append( - self.get_gl_dict( - { - "account": gain_loss_account, - "account_currency": account_currency, - "against": party, - dr_or_cr + "_in_account_currency": abs(d.exchange_gain_loss), - dr_or_cr: abs(d.exchange_gain_loss), - "cost_center": self.cost_center or erpnext.get_default_cost_center(self.company), - "project": self.project, - }, - item=d, - ) - ) + dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" - dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + if d.reference_doctype == "Purchase Invoice": + dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - gl_entries.append( - self.get_gl_dict( - { - "account": party_account, - "party_type": party_type, - "party": party, - "against": gain_loss_account, - dr_or_cr + "_in_account_currency": flt(abs(d.exchange_gain_loss) / self.conversion_rate), - dr_or_cr: abs(d.exchange_gain_loss), - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + gain_loss_account = frappe.get_cached_value( + "Company", self.company, "exchange_gain_loss_account" + ) + + je = create_gain_loss_journal( + self.company, + self.posting_date, + self.party_type, + self.party, + party_account, + gain_loss_account, + d.exchange_gain_loss, + dr_or_cr, + reverse_dr_or_cr, + d.reference_doctype, + d.reference_name, + d.idx, + self.doctype, + self.name, + d.idx, + self.cost_center, + ) + frappe.msgprint( + _("Exchange Gain/Loss amount has been booked through {0}").format( + get_link_to_form("Journal Entry", je) + ) ) - ) + + 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") + ) + + credit_or_debit = "credit" if self.doctype == "Purchase Invoice" else "debit" + against = self.supplier if self.doctype == "Purchase Invoice" else self.customer + + if precision_loss: + gl_entries.append( + self.get_gl_dict( + { + "account": round_off_account, + "against": against, + credit_or_debit: 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 update_against_document_in_jv(self): """ @@ -1079,9 +1319,15 @@ def update_against_document_in_jv(self): reconcile_against_document(lst) def on_cancel(self): - from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries + from erpnext.accounts.utils import ( + cancel_exchange_gain_loss_journal, + unlink_ref_doc_from_payment_entries, + ) + + if self.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]: + # Cancel Exchange Gain/Loss Journal before unlinking + cancel_exchange_gain_loss_journal(self) - if self.doctype in ["Sales Invoice", "Purchase Invoice"]: if frappe.db.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"): unlink_ref_doc_from_payment_entries(self) @@ -1243,8 +1489,8 @@ def make_discount_gl_entries(self, gl_entries): { "account": self.additional_discount_account, "against": supplier_or_customer, - dr_or_cr: self.discount_amount, - "cost_center": self.cost_center, + dr_or_cr: self.base_discount_amount, + "cost_center": self.cost_center or erpnext.get_default_cost_center(self.company), }, item=self, ) @@ -1515,6 +1761,7 @@ def validate_currency(self): and party_account_currency != self.company_currency and self.currency != party_account_currency ): + frappe.throw( _("Accounting Entry for {0}: {1} can only be made in currency: {2}").format( party_type, party, party_account_currency @@ -1668,8 +1915,13 @@ def set_payment_schedule(self): ) self.append("payment_schedule", data) + allocate_payment_based_on_payment_terms = frappe.db.get_value( + "Payment Terms Template", self.payment_terms_template, "allocate_payment_based_on_payment_terms" + ) + if not ( automatically_fetch_payment_terms + and allocate_payment_based_on_payment_terms and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) ): for d in self.get("payment_schedule"): @@ -1958,6 +2210,45 @@ def check_finance_books(self, item, asset): _("Select finance book for the item {0} at row {1}").format(item.item_code, item.idx) ) + def check_if_fields_updated(self, fields_to_check, child_tables): + # Check if any field affecting accounting entry is altered + doc_before_update = self.get_doc_before_save() + accounting_dimensions = get_accounting_dimensions() + ["cost_center", "project"] + + # Check if opening entry check updated + needs_repost = doc_before_update.get("is_opening") != self.is_opening + + if not needs_repost: + # Parent Level Accounts excluding party account + fields_to_check += accounting_dimensions + for field in fields_to_check: + if doc_before_update.get(field) != self.get(field): + needs_repost = 1 + break + + if not needs_repost: + # Check for child tables + for table in child_tables: + needs_repost = check_if_child_table_updated( + doc_before_update.get(table), self.get(table), child_tables[table] + ) + if needs_repost: + break + + return needs_repost + + @frappe.whitelist() + def repost_accounting_entries(self): + if self.repost_required: + repost_ledger = frappe.new_doc("Repost Accounting Ledger") + repost_ledger.company = self.company + repost_ledger.append("vouchers", {"voucher_type": self.doctype, "voucher_no": self.name}) + repost_ledger.insert() + repost_ledger.submit() + self.db_set("repost_required", 0) + else: + frappe.throw(_("No updates pending for reposting")) + @frappe.whitelist() def get_tax_rate(account_head): @@ -2181,6 +2472,11 @@ def get_advance_journal_entries( return list(journal_entries) +@erpnext.allow_regional +def get_advance_payment_entries_for_regional(*args, **kwargs): + return get_advance_payment_entries(*args, **kwargs) + + def get_advance_payment_entries( party_type, party, @@ -2213,21 +2509,29 @@ def get_advance_payment_entries( reference_condition = "" order_list = [] + if not condition: + condition = "" + payment_entries_against_order = frappe.db.sql( """ select 'Payment Entry' as reference_type, t1.name as reference_name, t1.remarks, t2.allocated_amount as amount, t2.name as reference_row, t2.reference_name as against_order, t1.posting_date, - t1.{0} as currency, t1.{4} as exchange_rate + t1.{0} as currency, t1.{5} as exchange_rate from `tabPayment Entry` t1, `tabPayment Entry Reference` t2 where t1.name = t2.parent and t1.{1} = %s and t1.payment_type = %s and t1.party_type = %s and t1.party = %s and t1.docstatus = 1 - and t2.reference_doctype = %s {2} - order by t1.posting_date {3} + and t2.reference_doctype = %s {2} {3} + order by t1.posting_date {4} """.format( - currency_field, party_account_field, reference_condition, limit_cond, exchange_rate_field + currency_field, + party_account_field, + reference_condition, + condition, + limit_cond, + exchange_rate_field, ), [party_account, payment_type, party_type, party, order_doctype] + order_list, as_dict=1, @@ -2830,6 +3134,23 @@ def should_update_supplied_items(doc) -> bool: parent.set_status() +def check_if_child_table_updated( + child_table_before_update, child_table_after_update, fields_to_check +): + accounting_dimensions = get_accounting_dimensions() + ["cost_center", "project"] + # Check if any field affecting accounting entry is altered + for index, item in enumerate(child_table_after_update): + for field in fields_to_check: + if child_table_before_update[index].get(field) != item.get(field): + return True + + for dimension in accounting_dimensions: + if child_table_before_update[index].get(dimension) != item.get(dimension): + return True + + return False + + @erpnext.allow_regional def validate_regional(doc): pass diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index fa94a4a88d40..a38905c7e2bd 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -14,7 +14,8 @@ from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.stock.get_item_details import get_conversion_factor -from erpnext.stock.utils import get_incoming_rate +from erpnext.stock.stock_ledger import get_previous_sle +from erpnext.stock.utils import get_incoming_rate, get_valuation_method class QtyMismatchError(ValidationError): @@ -162,10 +163,13 @@ def validate_asset_return(self): purchase_doc_field = ( "purchase_receipt" if self.doctype == "Purchase Receipt" else "purchase_invoice" ) - not_cancelled_asset = [ - d.name - for d in frappe.db.get_all("Asset", {purchase_doc_field: self.return_against, "docstatus": 1}) - ] + not_cancelled_asset = [] + if self.return_against: + not_cancelled_asset = [ + d.name + for d in frappe.db.get_all("Asset", {purchase_doc_field: self.return_against, "docstatus": 1}) + ] + if self.is_return and len(not_cancelled_asset): frappe.throw( _( @@ -475,6 +479,10 @@ def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_vouche if d.item_code not in stock_items: continue + rejected_qty = 0.0 + if flt(d.rejected_qty) != 0: + rejected_qty = flt(flt(d.rejected_qty) * flt(d.conversion_factor), d.precision("stock_qty")) + if d.warehouse: pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty")) @@ -495,6 +503,11 @@ def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_vouche }, ) + if flt(rejected_qty) != 0: + from_warehouse_sle["actual_qty"] += -1 * rejected_qty + if d.rejected_serial_no: + from_warehouse_sle["serial_no"] += "\n" + cstr(d.rejected_serial_no).strip() + sl_entries.append(from_warehouse_sle) sle = self.get_sl_entries( @@ -502,9 +515,20 @@ def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_vouche ) if self.is_return: - outgoing_rate = get_rate_for_return( - self.doctype, self.name, d.item_code, self.return_against, item_row=d - ) + if get_valuation_method(d.item_code) == "Moving Average": + previous_sle = get_previous_sle( + { + "item_code": d.item_code, + "warehouse": d.warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + } + ) + outgoing_rate = flt(previous_sle.get("valuation_rate")) + else: + outgoing_rate = get_rate_for_return( + self.doctype, self.name, d.item_code, self.return_against, item_row=d + ) sle.update({"outgoing_rate": outgoing_rate, "recalculate_rate": 1}) if d.from_warehouse: @@ -520,6 +544,7 @@ def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_vouche else 0, } ) + sl_entries.append(sle) if d.from_warehouse and ( @@ -530,23 +555,30 @@ def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_vouche d, {"actual_qty": -1 * pr_qty, "warehouse": d.from_warehouse, "recalculate_rate": 1} ) + if flt(rejected_qty) != 0: + from_warehouse_sle["actual_qty"] += -1 * rejected_qty + if d.rejected_serial_no: + from_warehouse_sle["serial_no"] += "\n" + cstr(d.rejected_serial_no).strip() + sl_entries.append(from_warehouse_sle) - if flt(d.rejected_qty) != 0: + if flt(rejected_qty) != 0: sl_entries.append( self.get_sl_entries( d, { "warehouse": d.rejected_warehouse, - "actual_qty": flt(flt(d.rejected_qty) * flt(d.conversion_factor), d.precision("stock_qty")), + "actual_qty": rejected_qty, "serial_no": cstr(d.rejected_serial_no).strip(), "incoming_rate": 0.0, + "allow_zero_valuation_rate": True, }, ) ) if self.get("is_old_subcontracting_flow"): self.make_sl_entries_for_supplier_warehouse(sl_entries) + self.make_sl_entries( sl_entries, allow_negative_stock=allow_negative_stock, diff --git a/erpnext/controllers/print_settings.py b/erpnext/controllers/print_settings.py index c951154a9e04..b906a8a79872 100644 --- a/erpnext/controllers/print_settings.py +++ b/erpnext/controllers/print_settings.py @@ -13,9 +13,6 @@ def set_print_templates_for_item_table(doc, settings): } } - if doc.meta.get_field("items"): - doc.meta.get_field("items").hide_in_print_layout = ["uom", "stock_uom"] - doc.flags.compact_item_fields = ["description", "qty", "rate", "amount"] if settings.compact_item_print: diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index b5d8246c1165..a69b21c7c1d9 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -345,6 +345,8 @@ def set_missing_values(source, target): elif doctype == "Purchase Invoice": # look for Print Heading "Debit Note" doc.select_print_heading = frappe.get_cached_value("Print Heading", _("Debit Note")) + if source.tax_withholding_category: + doc.set_onload("supplier_tds", source.tax_withholding_category) for tax in doc.get("taxes") or []: if tax.charge_type == "Actual": diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index ef289ff6a674..f005a7f7a391 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -4,9 +4,9 @@ import frappe from frappe import _, bold, throw -from frappe.contacts.doctype.address.address import get_address_display from frappe.utils import cint, cstr, flt, get_link_to_form, nowtime +from erpnext.accounts.party import render_address from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.controllers.stock_controller import StockController @@ -282,7 +282,9 @@ def throw_message(idx, item_name, rate, ref_rate_field): last_valuation_rate_in_sales_uom = last_valuation_rate * (item.conversion_factor or 1) if flt(item.base_net_rate) < flt(last_valuation_rate_in_sales_uom): - throw_message(item.idx, item.item_name, last_valuation_rate_in_sales_uom, "valuation rate") + throw_message( + item.idx, item.item_name, last_valuation_rate_in_sales_uom, "valuation rate (Moving Average)" + ) def get_item_list(self): il = [] @@ -388,7 +390,7 @@ def check_sales_order_on_hold_or_close(self, ref_fieldname): for d in self.get("items"): if d.get(ref_fieldname): status = frappe.db.get_value("Sales Order", d.get(ref_fieldname), "status") - if status in ("Closed", "On Hold"): + if status in ("Closed", "On Hold") and not self.is_return: frappe.throw(_("Sales Order {0} is {1}").format(d.get(ref_fieldname), status)) def update_reserved_qty(self): @@ -404,7 +406,9 @@ def update_reserved_qty(self): if so and so_item_rows: sales_order = frappe.get_doc("Sales Order", so) - if sales_order.status in ["Closed", "Cancelled"]: + if (sales_order.status == "Closed" and not self.is_return) or sales_order.status in [ + "Cancelled" + ]: frappe.throw( _("{0} {1} is cancelled or closed").format(_("Sales Order"), so), frappe.InvalidStatusError ) @@ -587,7 +591,9 @@ def set_customer_address(self): for address_field, address_display_field in address_dict.items(): if self.get(address_field): - self.set(address_display_field, get_address_display(self.get(address_field))) + self.set( + address_display_field, render_address(self.get(address_field), check_permissions=False) + ) def validate_for_duplicate_items(self): check_list, chk_dupl_itm = [], [] diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 58cab147a477..73a248fb531d 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import comma_or, flt, getdate, now, nowdate +from frappe.utils import comma_or, flt, get_link_to_form, getdate, now, nowdate class OverAllowanceError(frappe.ValidationError): @@ -233,6 +233,18 @@ def validate_qty(self): if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"): frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code)) + if not frappe.db.get_single_value("Selling Settings", "allow_negative_rates_for_items"): + if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0: + frappe.throw( + _( + "For item {0}, rate must be a positive number. To Allow negative rates, enable {1} in {2}" + ).format( + frappe.bold(d.item_code), + frappe.bold(_("`Allow Negative rates for Items`")), + get_link_to_form("Selling Settings", "Selling Settings"), + ), + ) + if d.doctype == args["source_dt"] and d.get(args["join_field"]): args["name"] = d.get(args["join_field"]) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 4f0c8a9a54f0..e24d8fb661f6 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -15,7 +15,7 @@ make_reverse_gl_entries, process_gl_map, ) -from erpnext.accounts.utils import get_fiscal_year +from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( @@ -513,6 +513,7 @@ def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cos make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher) def make_gl_entries_on_cancel(self): + cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) if frappe.db.sql( """select name from `tabGL Entry` where voucher_type=%s and voucher_no=%s""", diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 566135d75b93..6faddd2a8be9 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -460,7 +460,7 @@ def __add_supplied_item(self, item_row, bom_item, qty): "allow_zero_valuation": 1, } ) - rm_obj.rate = bom_item.rate if self.backflush_based_on == "BOM" else get_incoming_rate(args) + rm_obj.rate = get_incoming_rate(args) if self.doctype == self.subcontract_data.order_doctype: rm_obj.required_qty = qty @@ -655,7 +655,7 @@ def make_sl_entries_for_supplier_warehouse(self, sl_entries): { "item_code": item.rm_item_code, "warehouse": self.supplier_warehouse, - "actual_qty": -1 * flt(item.consumed_qty), + "actual_qty": -1 * flt(item.consumed_qty, item.precision("consumed_qty")), "dependant_sle_voucher_detail_no": item.reference_name, }, ) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 62d4c5386828..95bf0e4688ef 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -190,7 +190,9 @@ def calculate_item_values(self): item.net_rate = item.rate - if not item.qty and self.doc.get("is_return"): + if ( + not item.qty and self.doc.get("is_return") and self.doc.get("doctype") != "Purchase Receipt" + ): item.amount = flt(-1 * item.rate, item.precision("amount")) elif not item.qty and self.doc.get("is_debit_note"): item.amount = flt(item.rate, item.precision("amount")) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py new file mode 100644 index 000000000000..391258fde778 --- /dev/null +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -0,0 +1,1190 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import unittest + +import frappe +from frappe import qb +from frappe.query_builder.functions import Sum +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import add_days, flt, nowdate + +from erpnext import get_default_cost_center +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry +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.accounts.party import get_party_account +from erpnext.stock.doctype.item.test_item import create_item + + +def make_customer(customer_name, currency=None): + if not frappe.db.exists("Customer", customer_name): + customer = frappe.new_doc("Customer") + customer.customer_name = customer_name + customer.customer_type = "Individual" + + if currency: + customer.default_currency = currency + customer.save() + return customer.name + else: + return customer_name + + +def make_supplier(supplier_name, currency=None): + if not frappe.db.exists("Supplier", supplier_name): + supplier = frappe.new_doc("Supplier") + supplier.supplier_name = supplier_name + supplier.supplier_type = "Individual" + supplier.supplier_group = "All Supplier Groups" + + if currency: + supplier.default_currency = currency + supplier.save() + return supplier.name + else: + return supplier_name + + +class TestAccountsController(FrappeTestCase): + """ + Test Exchange Gain/Loss booking on various scenarios. + Test Cases are numbered for better organization + + 10 series - Sales Invoice against Payment Entries + 20 series - Sales Invoice against Journals + 30 series - Sales Invoice against Credit Notes + 40 series - Company default Cost center is unset + """ + + def setUp(self): + self.create_company() + self.create_account() + self.create_item() + self.create_parties() + self.clear_old_entries() + + def tearDown(self): + frappe.db.rollback() + + def create_company(self): + company_name = "_Test Company" + self.company_abbr = abbr = "_TC" + if frappe.db.exists("Company", company_name): + company = frappe.get_doc("Company", company_name) + else: + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": company_name, + "country": "India", + "default_currency": "INR", + "create_chart_of_accounts_based_on": "Standard Template", + "chart_of_accounts": "Standard", + } + ) + company = company.save() + + self.company = company.name + self.cost_center = company.cost_center + self.warehouse = "Stores - " + abbr + self.finished_warehouse = "Finished Goods - " + abbr + self.income_account = "Sales - " + abbr + self.expense_account = "Cost of Goods Sold - " + abbr + self.debit_to = "Debtors - " + abbr + self.debit_usd = "Debtors USD - " + abbr + self.cash = "Cash - " + abbr + self.creditors = "Creditors - " + abbr + + def create_item(self): + item = create_item( + item_code="_Test Notebook", is_stock_item=0, company=self.company, warehouse=self.warehouse + ) + self.item = item if isinstance(item, str) else item.item_code + + def create_parties(self): + self.create_customer() + self.create_supplier() + + def create_customer(self): + self.customer = make_customer("_Test MC Customer USD", "USD") + + def create_supplier(self): + self.supplier = make_supplier("_Test MC Supplier USD", "USD") + + def create_account(self): + account_name = "Debtors USD" + if not frappe.db.get_value( + "Account", filters={"account_name": account_name, "company": self.company} + ): + acc = frappe.new_doc("Account") + acc.account_name = account_name + acc.parent_account = "Accounts Receivable - " + self.company_abbr + acc.company = self.company + acc.account_currency = "USD" + acc.account_type = "Receivable" + acc.insert() + else: + name = frappe.db.get_value( + "Account", + filters={"account_name": account_name, "company": self.company}, + fieldname="name", + pluck=True, + ) + acc = frappe.get_doc("Account", name) + self.debtors_usd = acc.name + + def create_sales_invoice( + self, + qty=1, + rate=1, + conversion_rate=80, + posting_date=nowdate(), + do_not_save=False, + do_not_submit=False, + ): + """ + Helper function to populate default values in sales invoice + """ + sinv = create_sales_invoice( + qty=qty, + rate=rate, + company=self.company, + customer=self.customer, + item_code=self.item, + item_name=self.item, + cost_center=self.cost_center, + warehouse=self.warehouse, + debit_to=self.debit_usd, + parent_cost_center=self.cost_center, + update_stock=0, + currency="USD", + conversion_rate=conversion_rate, + is_pos=0, + is_return=0, + return_against=None, + income_account=self.income_account, + expense_account=self.expense_account, + do_not_save=do_not_save, + do_not_submit=do_not_submit, + ) + return sinv + + def create_payment_entry( + self, amount=1, source_exc_rate=75, posting_date=nowdate(), customer=None + ): + """ + Helper function to populate default values in payment entry + """ + payment = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=customer or self.customer, + paid_from=self.debit_usd, + paid_to=self.cash, + paid_amount=amount, + ) + payment.source_exchange_rate = source_exc_rate + payment.received_amount = source_exc_rate * amount + payment.posting_date = posting_date + return payment + + def clear_old_entries(self): + doctype_list = [ + "GL Entry", + "Payment Ledger Entry", + "Sales Invoice", + "Purchase Invoice", + "Payment Entry", + "Journal Entry", + ] + for doctype in doctype_list: + qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run() + + def create_payment_reconciliation(self): + pr = frappe.new_doc("Payment Reconciliation") + pr.company = self.company + pr.party_type = "Customer" + pr.party = self.customer + pr.receivable_payable_account = get_party_account(pr.party_type, pr.party, pr.company) + pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = nowdate() + return pr + + def create_journal_entry( + self, + acc1=None, + acc1_exc_rate=None, + acc2_exc_rate=None, + acc2=None, + acc1_amount=0, + acc2_amount=0, + posting_date=None, + cost_center=None, + ): + je = frappe.new_doc("Journal Entry") + je.posting_date = posting_date or nowdate() + je.company = self.company + je.user_remark = "test" + je.multi_currency = True + if not cost_center: + cost_center = self.cost_center + je.set( + "accounts", + [ + { + "account": acc1, + "exchange_rate": acc1_exc_rate or 1, + "cost_center": cost_center, + "debit_in_account_currency": acc1_amount if acc1_amount > 0 else 0, + "credit_in_account_currency": abs(acc1_amount) if acc1_amount < 0 else 0, + "debit": acc1_amount * acc1_exc_rate if acc1_amount > 0 else 0, + "credit": abs(acc1_amount * acc1_exc_rate) if acc1_amount < 0 else 0, + }, + { + "account": acc2, + "exchange_rate": acc2_exc_rate or 1, + "cost_center": cost_center, + "credit_in_account_currency": acc2_amount if acc2_amount > 0 else 0, + "debit_in_account_currency": abs(acc2_amount) if acc2_amount < 0 else 0, + "credit": acc2_amount * acc2_exc_rate if acc2_amount > 0 else 0, + "debit": abs(acc2_amount * acc2_exc_rate) if acc2_amount < 0 else 0, + }, + ], + ) + return je + + def get_journals_for(self, voucher_type: str, voucher_no: str) -> list: + journals = [] + if voucher_type and voucher_no: + journals = frappe.db.get_all( + "Journal Entry Account", + filters={"reference_type": voucher_type, "reference_name": voucher_no, "docstatus": 1}, + fields=["parent"], + ) + return journals + + def assert_ledger_outstanding( + self, + voucher_type: str, + voucher_no: str, + outstanding: float, + outstanding_in_account_currency: float, + ) -> None: + """ + Assert outstanding amount based on ledger on both company/base currency and account currency + """ + + ple = qb.DocType("Payment Ledger Entry") + current_outstanding = ( + qb.from_(ple) + .select( + Sum(ple.amount).as_("outstanding"), + Sum(ple.amount_in_account_currency).as_("outstanding_in_account_currency"), + ) + .where( + (ple.against_voucher_type == voucher_type) + & (ple.against_voucher_no == voucher_no) + & (ple.delinked == 0) + ) + .run(as_dict=True)[0] + ) + self.assertEqual(outstanding, current_outstanding.outstanding) + self.assertEqual( + outstanding_in_account_currency, current_outstanding.outstanding_in_account_currency + ) + + def test_10_payment_against_sales_invoice(self): + # Sales Invoice in Foreign Currency + rate = 80 + rate_in_account_currency = 1 + + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency) + + # Test payments with different exchange rates + for exc_rate in [75.9, 83.1, 80.01]: + with self.subTest(exc_rate=exc_rate): + pe = self.create_payment_entry(amount=1, source_exc_rate=exc_rate).save() + pe.append( + "references", + {"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1}, + ) + pe = pe.save().submit() + + # Outstanding in both currencies should be '0' + si.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_pe), 1) + self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0]) + + # Cancel Payment + pe.cancel() + + # outstanding should be same as grand total + si.reload() + self.assertEqual(si.outstanding_amount, rate_in_account_currency) + self.assert_ledger_outstanding(si.doctype, si.name, rate, rate_in_account_currency) + + # Exchange Gain/Loss Journal should've been cancelled + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + self.assertEqual(exc_je_for_si, []) + self.assertEqual(exc_je_for_pe, []) + + def test_11_advance_against_sales_invoice(self): + # Advance Payment + adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit() + adv.reload() + + # Sales Invoices in different exchange rates + for exc_rate in [75.9, 83.1, 80.01]: + with self.subTest(exc_rate=exc_rate): + si = self.create_sales_invoice(qty=1, conversion_rate=exc_rate, rate=1, do_not_submit=True) + advances = si.get_advance_entries() + self.assertEqual(len(advances), 1) + self.assertEqual(advances[0].reference_name, adv.name) + si.append( + "advances", + { + "doctype": "Sales Invoice Advance", + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "reference_row": advances[0].reference_row, + "advance_amount": 1, + "allocated_amount": 1, + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, + }, + ) + + si = si.save() + si = si.submit() + + # Outstanding in both currencies should be '0' + adv.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_adv), 1) + self.assertEqual(exc_je_for_si, exc_je_for_adv) + + # Cancel Invoice + si.cancel() + + # Exchange Gain/Loss Journal should've been cancelled + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + self.assertEqual(exc_je_for_si, []) + self.assertEqual(exc_je_for_adv, []) + + def test_12_partial_advance_and_payment_for_sales_invoice(self): + """ + Sales invoice with partial advance payment, and a normal payment reconciled + """ + # Partial Advance + adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit() + adv.reload() + + # sales invoice with advance(partial amount) + rate = 80 + rate_in_account_currency = 1 + si = self.create_sales_invoice( + qty=2, conversion_rate=80, rate=rate_in_account_currency, do_not_submit=True + ) + advances = si.get_advance_entries() + self.assertEqual(len(advances), 1) + self.assertEqual(advances[0].reference_name, adv.name) + si.append( + "advances", + { + "doctype": "Sales Invoice Advance", + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "advance_amount": 1, + "allocated_amount": 1, + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, + }, + ) + si = si.save() + si = si.submit() + + # Outstanding should be there in both currencies + si.reload() + self.assertEqual(si.outstanding_amount, 1) # account currency + self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + # Exchange Gain/Loss Journal should've been created for the partial advance + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_adv), 1) + self.assertEqual(exc_je_for_si, exc_je_for_adv) + + # Payment for remaining amount + pe = self.create_payment_entry(amount=1, source_exc_rate=75).save() + pe.append( + "references", + {"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1}, + ) + pe = pe.save().submit() + + # Outstanding in both currencies should be '0' + si.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Exchange Gain/Loss Journal should've been created for the payment + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + self.assertNotEqual(exc_je_for_si, []) + # There should be 2 JE's now. One for the advance and one for the payment + self.assertEqual(len(exc_je_for_si), 2) + self.assertEqual(len(exc_je_for_pe), 1) + self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv) + + # Cancel Invoice + si.reload() + si.cancel() + + # Exchange Gain/Loss Journal should been cancelled + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + self.assertEqual(exc_je_for_si, []) + self.assertEqual(exc_je_for_pe, []) + self.assertEqual(exc_je_for_adv, []) + + def test_13_partial_advance_and_payment_for_invoice_with_cancellation(self): + """ + Invoice with partial advance payment, and a normal payment. Then cancel advance and payment. + """ + # Partial Advance + adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit() + adv.reload() + + # invoice with advance(partial amount) + si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1, do_not_submit=True) + advances = si.get_advance_entries() + self.assertEqual(len(advances), 1) + self.assertEqual(advances[0].reference_name, adv.name) + si.append( + "advances", + { + "doctype": "Sales Invoice Advance", + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "advance_amount": 1, + "allocated_amount": 1, + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, + }, + ) + si = si.save() + si = si.submit() + + # Outstanding should be there in both currencies + si.reload() + self.assertEqual(si.outstanding_amount, 1) # account currency + self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + # Exchange Gain/Loss Journal should've been created for the partial advance + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_adv), 1) + self.assertEqual(exc_je_for_si, exc_je_for_adv) + + # Payment(remaining amount) + pe = self.create_payment_entry(amount=1, source_exc_rate=75).save() + pe.append( + "references", + {"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1}, + ) + pe = pe.save().submit() + + # Outstanding should be '0' in both currencies + si.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Exchange Gain/Loss Journal should've been created for the payment + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + self.assertNotEqual(exc_je_for_si, []) + # There should be 2 JE's now. One for the advance and one for the payment + self.assertEqual(len(exc_je_for_si), 2) + self.assertEqual(len(exc_je_for_pe), 1) + self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv) + + adv.reload() + adv.cancel() + + # Outstanding should be there in both currencies, since advance is cancelled. + si.reload() + self.assertEqual(si.outstanding_amount, 1) # account currency + self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + # Exchange Gain/Loss Journal for advance should been cancelled + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_pe), 1) + self.assertEqual(exc_je_for_adv, []) + + def test_14_same_payment_split_against_invoice(self): + # Invoice in Foreign Currency + si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1) + # Payment + pe = self.create_payment_entry(amount=2, source_exc_rate=75).save() + pe.append( + "references", + {"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1}, + ) + pe = pe.save().submit() + + # There should be outstanding in both currencies + si.reload() + self.assertEqual(si.outstanding_amount, 1) + self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_pe), 1) + self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0]) + + # Reconcile the remaining amount + pr = frappe.get_doc("Payment Reconciliation") + pr.company = self.company + pr.party_type = "Customer" + pr.party = self.customer + pr.receivable_payable_account = self.debit_usd + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 0) + + # Exc gain/loss journal should have been creaetd for the reconciled amount + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + self.assertEqual(len(exc_je_for_si), 2) + self.assertEqual(len(exc_je_for_pe), 2) + self.assertEqual(exc_je_for_si, exc_je_for_pe) + + # There should be no outstanding + si.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Cancel Payment + pe.reload() + pe.cancel() + + si.reload() + self.assertEqual(si.outstanding_amount, 2) + self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0) + + # Exchange Gain/Loss Journal should've been cancelled + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + self.assertEqual(exc_je_for_si, []) + self.assertEqual(exc_je_for_pe, []) + + def test_20_journal_against_sales_invoice(self): + # Invoice in Foreign Currency + si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) + # Payment + je = self.create_journal_entry( + acc1=self.debit_usd, + acc1_exc_rate=75, + acc2=self.cash, + acc1_amount=-1, + acc2_amount=-75, + acc2_exc_rate=1, + ) + je.accounts[0].party_type = "Customer" + je.accounts[0].party = self.customer + je = je.save().submit() + + # Reconcile the remaining amount + pr = self.create_payment_reconciliation() + # pr.receivable_payable_account = self.debit_usd + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 0) + + # There should be no outstanding in both currencies + si.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_je = self.get_journals_for(je.doctype, je.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual( + len(exc_je_for_si), 2 + ) # payment also has reference. so, there are 2 journals referencing invoice + self.assertEqual(len(exc_je_for_je), 1) + self.assertIn(exc_je_for_je[0], exc_je_for_si) + + # Cancel Payment + je.reload() + je.cancel() + + si.reload() + self.assertEqual(si.outstanding_amount, 1) + self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + # Exchange Gain/Loss Journal should've been cancelled + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_je = self.get_journals_for(je.doctype, je.name) + self.assertEqual(exc_je_for_si, []) + self.assertEqual(exc_je_for_je, []) + + def test_21_advance_journal_against_sales_invoice(self): + # Advance Payment + adv_exc_rate = 80 + adv = self.create_journal_entry( + acc1=self.debit_usd, + acc1_exc_rate=adv_exc_rate, + acc2=self.cash, + acc1_amount=-1, + acc2_amount=adv_exc_rate * -1, + acc2_exc_rate=1, + ) + adv.accounts[0].party_type = "Customer" + adv.accounts[0].party = self.customer + adv.accounts[0].is_advance = "Yes" + adv = adv.save().submit() + adv.reload() + + # Sales Invoices in different exchange rates + for exc_rate in [75.9, 83.1]: + with self.subTest(exc_rate=exc_rate): + si = self.create_sales_invoice(qty=1, conversion_rate=exc_rate, rate=1, do_not_submit=True) + advances = si.get_advance_entries() + self.assertEqual(len(advances), 1) + self.assertEqual(advances[0].reference_name, adv.name) + si.append( + "advances", + { + "doctype": "Sales Invoice Advance", + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "reference_row": advances[0].reference_row, + "advance_amount": 1, + "allocated_amount": 1, + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, + }, + ) + + si = si.save() + si = si.submit() + + # Outstanding in both currencies should be '0' + adv.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.name] + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_adv), 1) + self.assertEqual(exc_je_for_si, exc_je_for_adv) + + # Cancel Invoice + si.cancel() + + # Exchange Gain/Loss Journal should've been cancelled + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + self.assertEqual(exc_je_for_si, []) + self.assertEqual(exc_je_for_adv, []) + + def test_22_partial_advance_and_payment_for_invoice_with_cancellation(self): + """ + Invoice with partial advance payment as Journal, and a normal payment. Then cancel advance and payment. + """ + # Partial Advance + adv_exc_rate = 75 + adv = self.create_journal_entry( + acc1=self.debit_usd, + acc1_exc_rate=adv_exc_rate, + acc2=self.cash, + acc1_amount=-1, + acc2_amount=adv_exc_rate * -1, + acc2_exc_rate=1, + ) + adv.accounts[0].party_type = "Customer" + adv.accounts[0].party = self.customer + adv.accounts[0].is_advance = "Yes" + adv = adv.save().submit() + adv.reload() + + # invoice with advance(partial amount) + si = self.create_sales_invoice(qty=3, conversion_rate=80, rate=1, do_not_submit=True) + advances = si.get_advance_entries() + self.assertEqual(len(advances), 1) + self.assertEqual(advances[0].reference_name, adv.name) + si.append( + "advances", + { + "doctype": "Sales Invoice Advance", + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "reference_row": advances[0].reference_row, + "advance_amount": 1, + "allocated_amount": 1, + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, + }, + ) + + si = si.save() + si = si.submit() + + # Outstanding should be there in both currencies + si.reload() + self.assertEqual(si.outstanding_amount, 2) # account currency + self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0) + + # Exchange Gain/Loss Journal should've been created for the partial advance + exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.name] + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_adv), 1) + self.assertEqual(exc_je_for_si, exc_je_for_adv) + + # Payment + adv2_exc_rate = 83 + pay = self.create_journal_entry( + acc1=self.debit_usd, + acc1_exc_rate=adv2_exc_rate, + acc2=self.cash, + acc1_amount=-2, + acc2_amount=adv2_exc_rate * -2, + acc2_exc_rate=1, + ) + pay.accounts[0].party_type = "Customer" + pay.accounts[0].party = self.customer + pay.accounts[0].is_advance = "Yes" + pay = pay.save().submit() + pay.reload() + + # Reconcile the remaining amount + pr = self.create_payment_reconciliation() + # pr.receivable_payable_account = self.debit_usd + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 0) + + # Outstanding should be '0' in both currencies + si.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Exchange Gain/Loss Journal should've been created for the payment + exc_je_for_si = [ + x + for x in self.get_journals_for(si.doctype, si.name) + if x.parent != adv.name and x.parent != pay.name + ] + exc_je_for_pe = self.get_journals_for(pay.doctype, pay.name) + self.assertNotEqual(exc_je_for_si, []) + # There should be 2 JE's now. One for the advance and one for the payment + self.assertEqual(len(exc_je_for_si), 2) + self.assertEqual(len(exc_je_for_pe), 1) + self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv) + + adv.reload() + adv.cancel() + + # Outstanding should be there in both currencies, since advance is cancelled. + si.reload() + self.assertEqual(si.outstanding_amount, 1) # account currency + self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + exc_je_for_si = [ + x + for x in self.get_journals_for(si.doctype, si.name) + if x.parent != adv.name and x.parent != pay.name + ] + exc_je_for_pe = self.get_journals_for(pay.doctype, pay.name) + exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name) + # Exchange Gain/Loss Journal for advance should been cancelled + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_pe), 1) + self.assertEqual(exc_je_for_adv, []) + + def test_23_same_journal_split_against_single_invoice(self): + # Invoice in Foreign Currency + si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1) + # Payment + je = self.create_journal_entry( + acc1=self.debit_usd, + acc1_exc_rate=75, + acc2=self.cash, + acc1_amount=-2, + acc2_amount=-150, + acc2_exc_rate=1, + ) + je.accounts[0].party_type = "Customer" + je.accounts[0].party = self.customer + je = je.save().submit() + + # Reconcile the first half + pr = self.create_payment_reconciliation() + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + difference_amount = pr.calculate_difference_on_allocation_change( + [x.as_dict() for x in pr.payments], [x.as_dict() for x in pr.invoices], 1 + ) + pr.allocation[0].allocated_amount = 1 + pr.allocation[0].difference_amount = difference_amount + pr.reconcile() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + + # There should be outstanding in both currencies + si.reload() + self.assertEqual(si.outstanding_amount, 1) + self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name] + exc_je_for_je = self.get_journals_for(je.doctype, je.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_je), 1) + self.assertIn(exc_je_for_je[0], exc_je_for_si) + + # reconcile remaining half + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.allocation[0].allocated_amount = 1 + pr.allocation[0].difference_amount = difference_amount + pr.reconcile() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 0) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name] + exc_je_for_je = self.get_journals_for(je.doctype, je.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 2) + self.assertEqual(len(exc_je_for_je), 2) + self.assertIn(exc_je_for_je[0], exc_je_for_si) + + si.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Cancel Payment + je.reload() + je.cancel() + + si.reload() + self.assertEqual(si.outstanding_amount, 2) + self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0) + + # Exchange Gain/Loss Journal should've been cancelled + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_je = self.get_journals_for(je.doctype, je.name) + self.assertEqual(exc_je_for_si, []) + self.assertEqual(exc_je_for_je, []) + + def test_24_journal_against_multiple_invoices(self): + si1 = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) + si2 = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) + + # Payment + je = self.create_journal_entry( + acc1=self.debit_usd, + acc1_exc_rate=75, + acc2=self.cash, + acc1_amount=-2, + acc2_amount=-150, + acc2_exc_rate=1, + ) + je.accounts[0].party_type = "Customer" + je.accounts[0].party = self.customer + je = je.save().submit() + + pr = self.create_payment_reconciliation() + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 2) + self.assertEqual(len(pr.payments), 1) + 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})) + pr.reconcile() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 0) + + si1.reload() + si2.reload() + + self.assertEqual(si1.outstanding_amount, 0) + self.assertEqual(si2.outstanding_amount, 0) + self.assert_ledger_outstanding(si1.doctype, si1.name, 0.0, 0.0) + self.assert_ledger_outstanding(si2.doctype, si2.name, 0.0, 0.0) + + # Exchange Gain/Loss Journal should've been created + # remove payment JE from list + exc_je_for_si1 = [x for x in self.get_journals_for(si1.doctype, si1.name) if x.parent != je.name] + exc_je_for_si2 = [x for x in self.get_journals_for(si2.doctype, si2.name) if x.parent != je.name] + exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name] + self.assertEqual(len(exc_je_for_si1), 1) + self.assertEqual(len(exc_je_for_si2), 1) + self.assertEqual(len(exc_je_for_je), 2) + + si1.cancel() + # Gain/Loss JE of si1 should've been cancelled + exc_je_for_si1 = [x for x in self.get_journals_for(si1.doctype, si1.name) if x.parent != je.name] + exc_je_for_si2 = [x for x in self.get_journals_for(si2.doctype, si2.name) if x.parent != je.name] + exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name] + self.assertEqual(len(exc_je_for_si1), 0) + self.assertEqual(len(exc_je_for_si2), 1) + self.assertEqual(len(exc_je_for_je), 1) + + def test_30_cr_note_against_sales_invoice(self): + """ + Reconciling Cr Note against Sales Invoice, both having different exchange rates + """ + # Invoice in Foreign currency + si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1) + + # Cr Note in Foreign currency of different exchange rate + cr_note = self.create_sales_invoice(qty=-2, conversion_rate=75, rate=1, do_not_save=True) + cr_note.is_return = 1 + cr_note.save().submit() + + # Reconcile the first half + pr = self.create_payment_reconciliation() + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + difference_amount = pr.calculate_difference_on_allocation_change( + [x.as_dict() for x in pr.payments], [x.as_dict() for x in pr.invoices], 1 + ) + pr.allocation[0].allocated_amount = 1 + pr.allocation[0].difference_amount = difference_amount + pr.reconcile() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_cr = self.get_journals_for(cr_note.doctype, cr_note.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 2) + self.assertEqual(len(exc_je_for_cr), 2) + self.assertEqual(exc_je_for_cr, exc_je_for_si) + + si.reload() + self.assertEqual(si.outstanding_amount, 1) + self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + cr_note.reload() + cr_note.cancel() + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_cr = self.get_journals_for(cr_note.doctype, cr_note.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_cr), 0) + + # The Credit Note JE is still active and is referencing the sales invoice + # So, outstanding stays the same + si.reload() + self.assertEqual(si.outstanding_amount, 1) + self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + def test_40_cost_center_from_payment_entry(self): + """ + Gain/Loss JE should inherit cost center from payment if company default is unset + """ + # remove default cost center + cc = frappe.db.get_value("Company", self.company, "cost_center") + frappe.db.set_value("Company", self.company, "cost_center", None) + + rate_in_account_currency = 1 + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) + si.cost_center = None + si.save().submit() + + pe = get_payment_entry(si.doctype, si.name) + pe.source_exchange_rate = 75 + pe.received_amount = 75 + pe.cost_center = self.cost_center + pe = pe.save().submit() + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_pe), 1) + self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0]) + + self.assertEqual( + [self.cost_center, self.cost_center], + frappe.db.get_all( + "Journal Entry Account", filters={"parent": exc_je_for_si[0].parent}, pluck="cost_center" + ), + ) + frappe.db.set_value("Company", self.company, "cost_center", cc) + + def test_41_cost_center_from_journal_entry(self): + """ + Gain/Loss JE should inherit cost center from payment if company default is unset + """ + # remove default cost center + cc = frappe.db.get_value("Company", self.company, "cost_center") + frappe.db.set_value("Company", self.company, "cost_center", None) + + rate_in_account_currency = 1 + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) + si.cost_center = None + si.save().submit() + + je = self.create_journal_entry( + acc1=self.debit_usd, + acc1_exc_rate=75, + acc2=self.cash, + acc1_amount=-1, + acc2_amount=-75, + acc2_exc_rate=1, + ) + je.accounts[0].party_type = "Customer" + je.accounts[0].party = self.customer + je.accounts[0].cost_center = self.cost_center + je = je.save().submit() + + # Reconcile + pr = self.create_payment_reconciliation() + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 0) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name] + exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name] + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_je), 1) + self.assertEqual(exc_je_for_si[0], exc_je_for_je[0]) + + self.assertEqual( + [self.cost_center, self.cost_center], + frappe.db.get_all( + "Journal Entry Account", filters={"parent": exc_je_for_si[0].parent}, pluck="cost_center" + ), + ) + frappe.db.set_value("Company", self.company, "cost_center", cc) + + def test_42_cost_center_from_cr_note(self): + """ + Gain/Loss JE should inherit cost center from payment if company default is unset + """ + # remove default cost center + cc = frappe.db.get_value("Company", self.company, "cost_center") + frappe.db.set_value("Company", self.company, "cost_center", None) + + rate_in_account_currency = 1 + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) + si.cost_center = None + si.save().submit() + + cr_note = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True) + cr_note.cost_center = self.cost_center + cr_note.is_return = 1 + cr_note.save().submit() + + # Reconcile + pr = self.create_payment_reconciliation() + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 0) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_cr_note = self.get_journals_for(cr_note.doctype, cr_note.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 2) + self.assertEqual(len(exc_je_for_cr_note), 2) + self.assertEqual(exc_je_for_si, exc_je_for_cr_note) + + for x in exc_je_for_si + exc_je_for_cr_note: + with self.subTest(x=x): + self.assertEqual( + [self.cost_center, self.cost_center], + frappe.db.get_all("Journal Entry Account", filters={"parent": x.parent}, pluck="cost_center"), + ) + + frappe.db.set_value("Company", self.company, "cost_center", cc) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 08ea4b06e7c3..294c41b93416 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -185,7 +185,7 @@ def create_contact(self): "last_name": self.last_name, "salutation": self.salutation, "gender": self.gender, - "job_title": self.job_title, + "designation": self.job_title, "company_name": self.company_name, } ) @@ -382,7 +382,7 @@ def get_lead_details(lead, posting_date=None, company=None): } ) - set_address_details(out, lead, "Lead") + set_address_details(out, lead, "Lead", company=company) taxes_and_charges = set_taxes( None, diff --git a/erpnext/crm/report/lead_details/lead_details.py b/erpnext/crm/report/lead_details/lead_details.py index 7b8c43b2d65f..98dfbec18bee 100644 --- a/erpnext/crm/report/lead_details/lead_details.py +++ b/erpnext/crm/report/lead_details/lead_details.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import Concat_ws, Date def execute(filters=None): @@ -69,53 +70,41 @@ def get_columns(): def get_data(filters): - return frappe.db.sql( - """ - SELECT - `tabLead`.name, - `tabLead`.lead_name, - `tabLead`.status, - `tabLead`.lead_owner, - `tabLead`.territory, - `tabLead`.source, - `tabLead`.email_id, - `tabLead`.mobile_no, - `tabLead`.phone, - `tabLead`.owner, - `tabLead`.company, - concat_ws(', ', - trim(',' from `tabAddress`.address_line1), - trim(',' from tabAddress.address_line2) - ) AS address, - `tabAddress`.state, - `tabAddress`.pincode, - `tabAddress`.country - FROM - `tabLead` left join `tabDynamic Link` on ( - `tabLead`.name = `tabDynamic Link`.link_name and - `tabDynamic Link`.parenttype = 'Address') - left join `tabAddress` on ( - `tabAddress`.name=`tabDynamic Link`.parent) - WHERE - company = %(company)s - AND DATE(`tabLead`.creation) BETWEEN %(from_date)s AND %(to_date)s - {conditions} - ORDER BY - `tabLead`.creation asc """.format( - conditions=get_conditions(filters) - ), - filters, - as_dict=1, - ) - + lead = frappe.qb.DocType("Lead") + address = frappe.qb.DocType("Address") + dynamic_link = frappe.qb.DocType("Dynamic Link") -def get_conditions(filters): - conditions = [] + query = ( + frappe.qb.from_(lead) + .left_join(dynamic_link) + .on((lead.name == dynamic_link.link_name) & (dynamic_link.parenttype == "Address")) + .left_join(address) + .on(address.name == dynamic_link.parent) + .select( + lead.name, + lead.lead_name, + lead.status, + lead.lead_owner, + lead.territory, + lead.source, + lead.email_id, + lead.mobile_no, + lead.phone, + lead.owner, + lead.company, + (Concat_ws(", ", address.address_line1, address.address_line2)).as_("address"), + address.state, + address.pincode, + address.country, + ) + .where(lead.company == filters.company) + .where(Date(lead.creation).between(filters.from_date, filters.to_date)) + ) if filters.get("territory"): - conditions.append(" and `tabLead`.territory=%(territory)s") + query = query.where(lead.territory == filters.get("territory")) if filters.get("status"): - conditions.append(" and `tabLead`.status=%(status)s") + query = query.where(lead.status == filters.get("status")) - return " ".join(conditions) if conditions else "" + return query.run(as_dict=1) 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 019a5f9ee4f4..8eebfdb83afa 100644 --- a/erpnext/e_commerce/doctype/website_item/test_website_item.py +++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py @@ -312,7 +312,7 @@ def test_website_item_stock_when_out_of_stock(self): # check if stock details are fetched and item not in stock with warehouse set data = get_product_info_for_website(item_code, skip_quotation_creation=True) self.assertFalse(bool(data.product_info["in_stock"])) - self.assertEqual(data.product_info["stock_qty"][0][0], 0) + self.assertEqual(data.product_info["stock_qty"], 0) # disable show stock availability setup_e_commerce_settings({"show_stock_availability": 0}) @@ -355,7 +355,7 @@ def test_website_item_stock_when_in_stock(self): # check if stock details are fetched and item is in stock with warehouse set data = get_product_info_for_website(item_code, skip_quotation_creation=True) self.assertTrue(bool(data.product_info["in_stock"])) - self.assertEqual(data.product_info["stock_qty"][0][0], 2) + self.assertEqual(data.product_info["stock_qty"], 2) # unset warehouse frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "") @@ -364,7 +364,7 @@ def test_website_item_stock_when_in_stock(self): # (even though it has stock in some warehouse) data = get_product_info_for_website(item_code, skip_quotation_creation=True) self.assertFalse(bool(data.product_info["in_stock"])) - self.assertFalse(bool(data.product_info["stock_qty"])) + self.assertFalse(data.product_info["stock_qty"]) # disable show stock availability setup_e_commerce_settings({"show_stock_availability": 0}) diff --git a/erpnext/e_commerce/doctype/website_item/website_item.js b/erpnext/e_commerce/doctype/website_item/website_item.js index 7b7193e833aa..b6595cce8a9c 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.js +++ b/erpnext/e_commerce/doctype/website_item/website_item.js @@ -5,12 +5,6 @@ frappe.ui.form.on('Website Item', { onload: (frm) => { // should never check Private frm.fields_dict["website_image"].df.is_private = 0; - - frm.set_query("website_warehouse", () => { - return { - filters: {"is_group": 0} - }; - }); }, refresh: (frm) => { diff --git a/erpnext/e_commerce/doctype/website_item/website_item.json b/erpnext/e_commerce/doctype/website_item/website_item.json index 6556eabf4abf..6f551a0b42d6 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.json +++ b/erpnext/e_commerce/doctype/website_item/website_item.json @@ -135,7 +135,7 @@ "fieldtype": "Column Break" }, { - "description": "Show Stock availability based on this warehouse.", + "description": "Show Stock availability based on this warehouse. If the parent warehouse is selected, then the system will display the consolidated available quantity of all child warehouses.", "fieldname": "website_warehouse", "fieldtype": "Link", "ignore_user_permissions": 1, @@ -348,7 +348,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2022-09-30 04:01:52.090732", + "modified": "2023-09-12 14:19:22.822689", "modified_by": "Administrator", "module": "E-commerce", "name": "Website Item", diff --git a/erpnext/e_commerce/product_data_engine/query.py b/erpnext/e_commerce/product_data_engine/query.py index e6a595a03447..975f87608a63 100644 --- a/erpnext/e_commerce/product_data_engine/query.py +++ b/erpnext/e_commerce/product_data_engine/query.py @@ -259,6 +259,10 @@ def get_price_discount_info(self, item, price_object, discount_list): ) def get_stock_availability(self, item): + from erpnext.templates.pages.wishlist import ( + get_stock_availability as get_stock_availability_from_template, + ) + """Modify item object and add stock details.""" item.in_stock = False warehouse = item.get("website_warehouse") @@ -274,11 +278,7 @@ def get_stock_availability(self, item): else: item.in_stock = True elif warehouse: - # stock item and has warehouse - actual_qty = frappe.db.get_value( - "Bin", {"item_code": item.item_code, "warehouse": item.get("website_warehouse")}, "actual_qty" - ) - item.in_stock = bool(flt(actual_qty)) + item.in_stock = get_stock_availability_from_template(item.item_code, warehouse) def get_cart_items(self): customer = get_customer(silent=True) diff --git a/erpnext/e_commerce/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py index 4f9088e8c083..030b439ae4b8 100644 --- a/erpnext/e_commerce/shopping_cart/cart.py +++ b/erpnext/e_commerce/shopping_cart/cart.py @@ -111,8 +111,8 @@ def place_order(): item_stock = get_web_item_qty_in_stock(item.item_code, "website_warehouse") if not cint(item_stock.in_stock): throw(_("{0} Not in Stock").format(item.item_code)) - if item.qty > item_stock.stock_qty[0][0]: - throw(_("Only {0} in Stock for item {1}").format(item_stock.stock_qty[0][0], item.item_code)) + if item.qty > item_stock.stock_qty: + throw(_("Only {0} in Stock for item {1}").format(item_stock.stock_qty, item.item_code)) sales_order.flags.ignore_permissions = True sales_order.insert() @@ -150,6 +150,10 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False): empty_card = True else: + warehouse = frappe.get_cached_value( + "Website Item", {"item_code": item_code}, "website_warehouse" + ) + quotation_items = quotation.get("items", {"item_code": item_code}) if not quotation_items: quotation.append( @@ -159,11 +163,13 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False): "item_code": item_code, "qty": qty, "additional_notes": additional_notes, + "warehouse": warehouse, }, ) else: quotation_items[0].qty = qty quotation_items[0].additional_notes = additional_notes + quotation_items[0].warehouse = warehouse apply_cart_settings(quotation=quotation) @@ -322,6 +328,10 @@ def decorate_quotation_doc(doc): fields = fields[2:] d.update(frappe.db.get_value("Website Item", {"item_code": item_code}, fields, as_dict=True)) + website_warehouse = frappe.get_cached_value( + "Website Item", {"item_code": item_code}, "website_warehouse" + ) + d.warehouse = website_warehouse return doc @@ -634,7 +644,6 @@ def get_applicable_shipping_rules(party=None, quotation=None): shipping_rules = get_shipping_rules(quotation) if shipping_rules: - rule_label_map = frappe.db.get_values("Shipping Rule", shipping_rules, "label") # we need this in sorted order as per the position of the rule in the settings page return [[rule, rule] for rule in shipping_rules] diff --git a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py index f44f8fe2984c..363a80545b0f 100644 --- a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py +++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py @@ -17,7 +17,6 @@ request_for_quotation, update_cart, ) -from erpnext.tests.utils import create_test_contact_and_address class TestShoppingCart(unittest.TestCase): @@ -28,7 +27,6 @@ class TestShoppingCart(unittest.TestCase): def setUp(self): frappe.set_user("Administrator") - create_test_contact_and_address() self.enable_shopping_cart() if not frappe.db.exists("Website Item", {"item_code": "_Test Item"}): make_website_item(frappe.get_cached_doc("Item", "_Test Item")) @@ -46,48 +44,57 @@ def tearDownClass(cls): frappe.db.sql("delete from `tabTax Rule`") def test_get_cart_new_user(self): - self.login_as_new_user() - + self.login_as_customer( + "test_contact_two_customer@example.com", "_Test Contact 2 For _Test Customer" + ) + create_address_and_contact( + address_title="_Test Address for Customer 2", + first_name="_Test Contact for Customer 2", + email="test_contact_two_customer@example.com", + customer="_Test Customer 2", + ) # test if lead is created and quotation with new lead is fetched - quotation = _get_cart_quotation() + customer = frappe.get_doc("Customer", "_Test Customer 2") + quotation = _get_cart_quotation(party=customer) self.assertEqual(quotation.quotation_to, "Customer") self.assertEqual( quotation.contact_person, - frappe.db.get_value("Contact", dict(email_id="test_cart_user@example.com")), + frappe.db.get_value("Contact", dict(email_id="test_contact_two_customer@example.com")), ) self.assertEqual(quotation.contact_email, frappe.session.user) return quotation - def test_get_cart_customer(self): - def validate_quotation(): + def test_get_cart_customer(self, customer="_Test Customer 2"): + def validate_quotation(customer_name): # test if quotation with customer is fetched - quotation = _get_cart_quotation() + party = frappe.get_doc("Customer", customer_name) + quotation = _get_cart_quotation(party=party) self.assertEqual(quotation.quotation_to, "Customer") - self.assertEqual(quotation.party_name, "_Test Customer") + self.assertEqual(quotation.party_name, customer_name) self.assertEqual(quotation.contact_email, frappe.session.user) return quotation - self.login_as_customer( - "test_contact_two_customer@example.com", "_Test Contact 2 For _Test Customer" - ) - validate_quotation() - - self.login_as_customer() - quotation = validate_quotation() - + quotation = validate_quotation(customer) return quotation def test_add_to_cart(self): - self.login_as_customer() - + self.login_as_customer( + "test_contact_two_customer@example.com", "_Test Contact 2 For _Test Customer" + ) + create_address_and_contact( + address_title="_Test Address for Customer 2", + first_name="_Test Contact for Customer 2", + email="test_contact_two_customer@example.com", + customer="_Test Customer 2", + ) # clear existing quotations self.clear_existing_quotations() # add first item update_cart("_Test Item", 1) - quotation = self.test_get_cart_customer() + quotation = self.test_get_cart_customer("_Test Customer 2") self.assertEqual(quotation.get("items")[0].item_code, "_Test Item") self.assertEqual(quotation.get("items")[0].qty, 1) @@ -95,7 +102,7 @@ def test_add_to_cart(self): # add second item update_cart("_Test Item 2", 1) - quotation = self.test_get_cart_customer() + quotation = self.test_get_cart_customer("_Test Customer 2") self.assertEqual(quotation.get("items")[1].item_code, "_Test Item 2") self.assertEqual(quotation.get("items")[1].qty, 1) self.assertEqual(quotation.get("items")[1].amount, 20) @@ -108,7 +115,7 @@ def test_update_cart(self): # update first item update_cart("_Test Item", 5) - quotation = self.test_get_cart_customer() + quotation = self.test_get_cart_customer("_Test Customer 2") self.assertEqual(quotation.get("items")[0].item_code, "_Test Item") self.assertEqual(quotation.get("items")[0].qty, 5) self.assertEqual(quotation.get("items")[0].amount, 50) @@ -121,7 +128,7 @@ def test_remove_from_cart(self): # remove first item update_cart("_Test Item", 0) - quotation = self.test_get_cart_customer() + quotation = self.test_get_cart_customer("_Test Customer 2") self.assertEqual(quotation.get("items")[0].item_code, "_Test Item 2") self.assertEqual(quotation.get("items")[0].qty, 1) @@ -132,7 +139,17 @@ def test_remove_from_cart(self): @unittest.skip("Flaky in CI") def test_tax_rule(self): self.create_tax_rule() - self.login_as_customer() + + self.login_as_customer( + "test_contact_two_customer@example.com", "_Test Contact 2 For _Test Customer" + ) + create_address_and_contact( + address_title="_Test Address for Customer 2", + first_name="_Test Contact for Customer 2", + email="test_contact_two_customer@example.com", + customer="_Test Customer 2", + ) + quotation = self.create_quotation() from erpnext.accounts.party import set_taxes @@ -320,7 +337,7 @@ def create_user_if_not_exists(self, email, first_name=None): if frappe.db.exists("User", email): return - frappe.get_doc( + user = frappe.get_doc( { "doctype": "User", "user_type": "Website User", @@ -330,6 +347,40 @@ def create_user_if_not_exists(self, email, first_name=None): } ).insert(ignore_permissions=True) + user.add_roles("Customer") + + +def create_address_and_contact(**kwargs): + if not frappe.db.get_value("Address", {"address_title": kwargs.get("address_title")}): + frappe.get_doc( + { + "doctype": "Address", + "address_title": kwargs.get("address_title"), + "address_type": kwargs.get("address_type") or "Office", + "address_line1": kwargs.get("address_line1") or "Station Road", + "city": kwargs.get("city") or "_Test City", + "state": kwargs.get("state") or "Test State", + "country": kwargs.get("country") or "India", + "links": [ + {"link_doctype": "Customer", "link_name": kwargs.get("customer") or "_Test Customer"} + ], + } + ).insert() + + if not frappe.db.get_value("Contact", {"first_name": kwargs.get("first_name")}): + contact = frappe.get_doc( + { + "doctype": "Contact", + "first_name": kwargs.get("first_name"), + "links": [ + {"link_doctype": "Customer", "link_name": kwargs.get("customer") or "_Test Customer"} + ], + } + ) + contact.add_email(kwargs.get("email") or "test_contact_customer@example.com", is_primary=True) + contact.add_phone(kwargs.get("phone") or "+91 0000000000", is_primary_phone=True) + contact.insert() + test_dependencies = [ "Sales Taxes and Charges Template", diff --git a/erpnext/e_commerce/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py index 4466c4574368..88356f5e9096 100644 --- a/erpnext/e_commerce/variant_selector/utils.py +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -104,6 +104,8 @@ def get_attributes_and_values(item_code): @frappe.whitelist(allow_guest=True) def get_next_attribute_and_values(item_code, selected_attributes): + from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses + """Find the count of Items that match the selected attributes. Also, find the attribute values that are not applicable for further searching. If less than equal to 10 items are found, return item_codes of those items. @@ -168,7 +170,7 @@ def get_next_attribute_and_values(item_code, selected_attributes): product_info = None product_id = "" - website_warehouse = "" + warehouse = "" if exact_match or filtered_items: if exact_match and len(exact_match) == 1: product_id = exact_match[0] @@ -176,16 +178,19 @@ def get_next_attribute_and_values(item_code, selected_attributes): product_id = list(filtered_items)[0] if product_id: - website_warehouse = frappe.get_cached_value( + warehouse = frappe.get_cached_value( "Website Item", {"item_code": product_id}, "website_warehouse" ) available_qty = 0.0 - if website_warehouse: - available_qty = flt( - frappe.db.get_value( - "Bin", {"item_code": product_id, "warehouse": website_warehouse}, "actual_qty" - ) + if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1: + warehouses = get_child_warehouses(warehouse) + else: + warehouses = [warehouse] if warehouse else [] + + for warehouse in warehouses: + available_qty += flt( + frappe.db.get_value("Bin", {"item_code": product_id, "warehouse": warehouse}, "actual_qty") ) return { diff --git a/erpnext/e_commerce/web_template/hero_slider/hero_slider.html b/erpnext/e_commerce/web_template/hero_slider/hero_slider.html index e560f4ad7deb..fe4fee375bd8 100644 --- a/erpnext/e_commerce/web_template/hero_slider/hero_slider.html +++ b/erpnext/e_commerce/web_template/hero_slider/hero_slider.html @@ -1,7 +1,7 @@ {%- macro slide(image, title, subtitle, action, label, index, align="Left", theme="Dark") -%} {%- set align_class = resolve_class({ 'text-right': align == 'Right', - 'text-centre': align == 'Centre', + 'text-center': align == 'Centre', 'text-left': align == 'Left', }) -%} diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 16c488d90476..6d5dddd9e976 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -413,11 +413,11 @@ ], }, "all": [ - "erpnext.projects.doctype.project.project.project_status_update_reminder", "erpnext.crm.doctype.social_media_post.social_media_post.process_scheduled_social_media_posts", ], "hourly": [ "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", + "erpnext.projects.doctype.project.project.project_status_update_reminder", "erpnext.projects.doctype.project.project.hourly_reminder", "erpnext.projects.doctype.project.project.collect_project_status", ], @@ -432,7 +432,6 @@ "erpnext.controllers.accounts_controller.update_invoice_status", "erpnext.accounts.doctype.fiscal_year.fiscal_year.auto_create_fiscal_year", "erpnext.projects.doctype.task.task.set_tasks_as_overdue", - "erpnext.assets.doctype.asset.depreciation.post_depreciation_entries", "erpnext.stock.doctype.serial_no.serial_no.update_maintenance_status", "erpnext.buying.doctype.supplier_scorecard.supplier_scorecard.refresh_scorecards", "erpnext.setup.doctype.company.company.cache_companies_monthly_sales_history", @@ -459,6 +458,7 @@ "erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall.create_process_loan_security_shortfall", "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_term_loans", "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event", + "erpnext.assets.doctype.asset.depreciation.post_depreciation_entries", ], "monthly_long": [ "erpnext.accounts.deferred_revenue.process_deferred_accounting", @@ -517,6 +517,7 @@ "Sales Invoice Item", "Purchase Invoice Item", "Purchase Order Item", + "Sales Order Item", "Journal Entry Account", "Material Request Item", "Delivery Note Item", diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.js b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.js index 82a2d802b80d..53581339faef 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.js +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.js @@ -6,7 +6,14 @@ frappe.ui.form.on('Loan Repayment', { // refresh: function(frm) { - // } + // }, + + setup: function(frm) { + if (frappe.meta.has_field("Loan Repayment", "repay_from_salary")) { + frm.add_fetch("against_loan", "repay_from_salary", "repay_from_salary"); + } + }, + onload: function(frm) { frm.set_query('against_loan', function() { return { diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json index 76dc8b462e31..4d2c45d029ff 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json @@ -263,12 +263,13 @@ "label": "Accounting Details" }, { + "depends_on": "eval:!doc.repay_from_salary", "fetch_from": "against_loan.payment_account", + "fetch_if_empty": 1, "fieldname": "payment_account", "fieldtype": "Link", "label": "Repayment Account", - "options": "Account", - "read_only": 1 + "options": "Account" }, { "fieldname": "column_break_36", @@ -294,7 +295,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-06-21 10:10:07.742298", + "modified": "2023-09-04 15:44:29.148766", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment", diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index d7e11aafa81c..0d16c7f0c50c 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -80,6 +80,12 @@ def set_missing_values(self, amounts): if amounts.get("due_date"): self.due_date = amounts.get("due_date") + if hasattr(self, "repay_from_salary") and hasattr(self, "payroll_payable_account"): + if self.repay_from_salary and not self.payroll_payable_account: + frappe.throw(_("Please set Payroll Payable Account in Loan Repayment")) + elif not self.repay_from_salary and self.payroll_payable_account: + self.repay_from_salary = 1 + def check_future_entries(self): future_repayment_date = frappe.db.get_value( "Loan Repayment", @@ -246,6 +252,9 @@ def mark_as_unpaid(self): ) def check_future_accruals(self): + if self.is_term_loan: + return + future_accrual_date = frappe.db.get_value( "Loan Interest Accrual", {"posting_date": (">", self.posting_date), "docstatus": 1, "loan": self.against_loan}, @@ -405,6 +414,16 @@ def make_gl_entries(self, cancel=0, adv_adj=0): else: payment_account = self.payment_account + payment_party_type = "" + payment_party = "" + + if ( + hasattr(self, "process_payroll_accounting_entry_based_on_employee") + and self.process_payroll_accounting_entry_based_on_employee + ): + payment_party_type = "Employee" + payment_party = self.applicant + if self.total_penalty_paid: gle_map.append( self.get_gl_dict( @@ -452,6 +471,8 @@ def make_gl_entries(self, cancel=0, adv_adj=0): "remarks": _(remarks), "cost_center": self.cost_center, "posting_date": getdate(self.posting_date), + "party_type": payment_party_type, + "party": payment_party, } ) ) @@ -490,6 +511,7 @@ def create_repayment_entry( amount_paid, penalty_amount=None, payroll_payable_account=None, + process_payroll_accounting_entry_based_on_employee=0, ): lr = frappe.get_doc( @@ -506,6 +528,7 @@ def create_repayment_entry( "amount_paid": amount_paid, "loan_type": loan_type, "payroll_payable_account": payroll_payable_account, + "process_payroll_accounting_entry_based_on_employee": process_payroll_accounting_entry_based_on_employee, } ).insert() diff --git a/erpnext/loan_management/workspace/loans/loans.json b/erpnext/loan_management/workspace/loans/loans.json index c25f4d35d0be..f431b85aa714 100644 --- a/erpnext/loan_management/workspace/loans/loans.json +++ b/erpnext/loan_management/workspace/loans/loans.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"id\":\"_38WStznya\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"t7o_K__1jB\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Loan Application\",\"col\":3}},{\"id\":\"IRiNDC6w1p\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Loan\",\"col\":3}},{\"id\":\"xbbo0FYbq0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"7ZL4Bro-Vi\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"yhyioTViZ3\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"id\":\"oYFn4b1kSw\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan\",\"col\":4}},{\"id\":\"vZepJF5tl9\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan Processes\",\"col\":4}},{\"id\":\"k-393Mjhqe\",\"type\":\"card\",\"data\":{\"card_name\":\"Disbursement and Repayment\",\"col\":4}},{\"id\":\"6crJ0DBiBJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan Security\",\"col\":4}},{\"id\":\"Um5YwxVLRJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]", + "content": "[{\"id\":\"_38WStznya\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"t7o_K__1jB\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Loan Application\",\"col\":3}},{\"id\":\"IRiNDC6w1p\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Loan\",\"col\":3}},{\"id\":\"xbbo0FYbq0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"7ZL4Bro-Vi\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"yhyioTViZ3\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"id\":\"oYFn4b1kSw\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan\",\"col\":4}},{\"id\":\"vZepJF5tl9\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan Processes\",\"col\":4}},{\"id\":\"k-393Mjhqe\",\"type\":\"card\",\"data\":{\"card_name\":\"Disbursement and Repayment\",\"col\":4}},{\"id\":\"6crJ0DBiBJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan Security\",\"col\":4}},{\"id\":\"Um5YwxVLRJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"id\":\"g2NbPxffmo\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"UKb6Ko91Ju\",\"type\":\"paragraph\",\"data\":{\"text\":\"Loan Management module will be removed from ERPNext in Version 15. Please install the Lending app to continue using it.\",\"col\":12}}]", "creation": "2020-03-12 16:35:55.299820", "custom_blocks": [], "docstatus": 0, @@ -280,7 +280,7 @@ "type": "Link" } ], - "modified": "2023-05-24 14:47:24.109945", + "modified": "2023-08-09 19:45:02.748408", "modified_by": "Administrator", "module": "Loan Management", "name": "Loans", 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 17b5aae96665..e9867468f98f 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -93,6 +93,7 @@ def on_submit(self): else: frappe.enqueue( method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.process_boms_cost_level_wise", + queue="long", update_doc=self, now=frappe.flags.in_test, enqueue_after_commit=True, diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py index af115e3e4210..a2919b79b806 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_updation_utils.py @@ -157,12 +157,19 @@ def _all_children_are_processed(parent_bom): def get_leaf_boms() -> List[str]: "Get BOMs that have no dependencies." - return frappe.db.sql_list( - """select name from `tabBOM` bom - where docstatus=1 and is_active=1 - and not exists(select bom_no from `tabBOM Item` - where parent=bom.name and ifnull(bom_no, '')!='')""" - ) + bom = frappe.qb.DocType("BOM") + bom_item = frappe.qb.DocType("BOM Item") + + boms = ( + frappe.qb.from_(bom) + .left_join(bom_item) + .on((bom.name == bom_item.parent) & (bom_item.bom_no != "")) + .select(bom.name) + .where((bom.docstatus == 1) & (bom.is_active == 1) & (bom_item.bom_no.isnull())) + .distinct() + ).run(pluck=True) + + return boms def _generate_dependence_map() -> defaultdict: diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 2c17568d1b4f..d26006f8ce89 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -498,12 +498,12 @@ def validate_job_card(self): if self.for_quantity and flt(total_completed_qty, precision) != flt( self.for_quantity, precision ): - total_completed_qty = bold(_("Total Completed Qty")) + total_completed_qty_label = bold(_("Total Completed Qty")) qty_to_manufacture = bold(_("Qty to Manufacture")) frappe.throw( _("The {0} ({1}) must be equal to {2} ({3})").format( - total_completed_qty, + total_completed_qty_label, bold(flt(total_completed_qty, precision)), qty_to_manufacture, bold(self.for_quantity), diff --git a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json index 09bf1d8a7368..d07bf0fa66bb 100644 --- a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json +++ b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json @@ -10,22 +10,25 @@ "warehouse", "item_name", "material_request_type", - "actual_qty", - "ordered_qty", + "quantity", "required_bom_qty", "column_break_4", - "quantity", + "schedule_date", "uom", "conversion_factor", - "projected_qty", - "reserved_qty_for_production", - "safety_stock", "item_details", "description", "min_order_qty", "section_break_8", "sales_order", - "requested_qty" + "bin_qty_section", + "actual_qty", + "requested_qty", + "reserved_qty_for_production", + "column_break_yhelv", + "ordered_qty", + "projected_qty", + "safety_stock" ], "fields": [ { @@ -65,7 +68,7 @@ "fieldtype": "Column Break" }, { - "columns": 1, + "columns": 2, "fieldname": "quantity", "fieldtype": "Float", "in_list_view": 1, @@ -80,12 +83,12 @@ "read_only": 1 }, { - "columns": 2, + "columns": 1, "default": "0", "fieldname": "actual_qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Available Qty", + "label": "Qty In Stock", "no_copy": 1, "read_only": 1 }, @@ -176,11 +179,27 @@ "fieldtype": "Float", "label": "Conversion Factor", "read_only": 1 + }, + { + "columns": 1, + "fieldname": "schedule_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Required By" + }, + { + "fieldname": "bin_qty_section", + "fieldtype": "Section Break", + "label": "BIN Qty" + }, + { + "fieldname": "column_break_yhelv", + "fieldtype": "Column Break" } ], "istable": 1, "links": [], - "modified": "2023-05-03 12:43:29.895754", + "modified": "2023-09-12 12:09:08.358326", "modified_by": "Administrator", "module": "Manufacturing", "name": "Material Request Plan Item", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index 48986910b07c..72438ddceeae 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -9,19 +9,25 @@ frappe.ui.form.on('Production Plan', { item.temporary_name = item.name; }); }, + setup(frm) { + frm.trigger("setup_queries"); + frm.custom_make_buttons = { 'Work Order': 'Work Order / Subcontract PO', 'Material Request': 'Material Request', }; + }, - frm.fields_dict['po_items'].grid.get_field('warehouse').get_query = function(doc) { + setup_queries(frm) { + frm.set_query("sales_order", "sales_orders", () => { return { + query: "erpnext.manufacturing.doctype.production_plan.production_plan.sales_order_query", filters: { - company: doc.company + company: frm.doc.company, } } - } + }); frm.set_query('for_warehouse', function(doc) { return { @@ -42,32 +48,40 @@ frappe.ui.form.on('Production Plan', { }; }); - frm.fields_dict['po_items'].grid.get_field('item_code').get_query = function(doc) { + frm.set_query("item_code", "po_items", (doc, cdt, cdn) => { return { query: "erpnext.controllers.queries.item_query", filters:{ 'is_stock_item': 1, } } - } + }); - frm.fields_dict['po_items'].grid.get_field('bom_no').get_query = function(doc, cdt, cdn) { + frm.set_query("bom_no", "po_items", (doc, cdt, cdn) => { var d = locals[cdt][cdn]; if (d.item_code) { return { query: "erpnext.controllers.queries.bom", - filters:{'item': cstr(d.item_code), 'docstatus': 1} + filters:{'item': d.item_code, 'docstatus': 1} } } else frappe.msgprint(__("Please enter Item first")); - } + }); - frm.fields_dict['mr_items'].grid.get_field('warehouse').get_query = function(doc) { + frm.set_query("warehouse", "mr_items", (doc) => { return { filters: { company: doc.company } } - } + }); + + frm.set_query("warehouse", "po_items", (doc) => { + return { + filters: { + company: doc.company + } + } + }); }, refresh(frm) { @@ -436,7 +450,7 @@ frappe.ui.form.on("Production Plan Item", { } }); } - } + }, }); frappe.ui.form.on("Material Request Plan Item", { @@ -462,35 +476,49 @@ frappe.ui.form.on("Material Request Plan Item", { } }) } + }, + + material_request_type(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + + if (row.from_warehouse && + row.material_request_type !== "Material Transfer") { + frappe.model.set_value(cdt, cdn, 'from_warehouse', ''); + } } }); frappe.ui.form.on("Production Plan Sales Order", { sales_order(frm, cdt, cdn) { - const { sales_order } = locals[cdt][cdn]; + let row = locals[cdt][cdn]; + const sales_order = row.sales_order; if (!sales_order) { return; } - frappe.call({ - method: "erpnext.manufacturing.doctype.production_plan.production_plan.get_so_details", - args: { sales_order }, - callback(r) { - const {transaction_date, customer, grand_total} = r.message; - frappe.model.set_value(cdt, cdn, 'sales_order_date', transaction_date); - frappe.model.set_value(cdt, cdn, 'customer', customer); - frappe.model.set_value(cdt, cdn, 'grand_total', grand_total); - } - }); - } -}); -cur_frm.fields_dict['sales_orders'].grid.get_field("sales_order").get_query = function() { - return{ - filters: [ - ['Sales Order','docstatus', '=' ,1] - ] + if (row.sales_order) { + frm.call({ + method: "validate_sales_orders", + doc: frm.doc, + args: { + sales_order: row.sales_order, + }, + callback(r) { + frappe.call({ + method: "erpnext.manufacturing.doctype.production_plan.production_plan.get_so_details", + args: { sales_order }, + callback(r) { + const {transaction_date, customer, grand_total} = r.message; + frappe.model.set_value(cdt, cdn, 'sales_order_date', transaction_date); + frappe.model.set_value(cdt, cdn, 'customer', customer); + frappe.model.set_value(cdt, cdn, 'grand_total', grand_total); + } + }); + } + }); + } } -}; +}); frappe.tour['Production Plan'] = [ { diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 232f1cb2c446..4a0041662bc4 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -228,10 +228,10 @@ }, { "default": "0", - "description": "To know more about projected quantity, click here.", + "description": "If enabled, the system will create material requests even if the stock exists in the 'Raw Materials Warehouse'.", "fieldname": "ignore_existing_ordered_qty", "fieldtype": "Check", - "label": "Ignore Existing Projected Quantity" + "label": "Ignore Available Stock" }, { "fieldname": "column_break_25", @@ -339,7 +339,7 @@ "depends_on": "eval:doc.get_items_from == 'Sales Order'", "fieldname": "combine_items", "fieldtype": "Check", - "label": "Consolidate Items" + "label": "Consolidate Sales Order Items" }, { "fieldname": "section_break_25", @@ -399,7 +399,7 @@ }, { "default": "0", - "description": "System consider the projected quantity to check available or will be available sub-assembly items ", + "description": "If this checkbox is enabled, then the system won\u2019t run the MRP for the available sub-assembly items.", "fieldname": "skip_available_sub_assembly_item", "fieldtype": "Check", "label": "Skip Available Sub Assembly Items" @@ -422,7 +422,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-05-22 23:36:31.770517", + "modified": "2023-09-29 11:41:03.246059", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index d8cc8f6d3953..5fc764fb6f7f 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -39,6 +39,42 @@ def validate(self): self.set_status() self._rename_temporary_references() validate_uom_is_integer(self, "stock_uom", "planned_qty") + self.validate_sales_orders() + self.validate_material_request_type() + + def validate_material_request_type(self): + for row in self.get("mr_items"): + if row.from_warehouse and row.material_request_type != "Material Transfer": + row.from_warehouse = "" + + @frappe.whitelist() + def validate_sales_orders(self, sales_order=None): + sales_orders = [] + + if sales_order: + sales_orders.append(sales_order) + else: + sales_orders = [row.sales_order for row in self.sales_orders if row.sales_order] + + data = sales_order_query(filters={"company": self.company, "sales_orders": sales_orders}) + + title = _("Production Plan Already Submitted") + if not data and sales_orders: + msg = _("No items are available in the sales order {0} for production").format(sales_orders[0]) + if len(sales_orders) > 1: + sales_orders = ", ".join(sales_orders) + msg = _("No items are available in sales orders {0} for production").format(sales_orders) + + frappe.throw(msg, title=title) + + data = [d[0] for d in data] + + for sales_order in sales_orders: + if sales_order not in data: + frappe.throw( + _("No items are available in the sales order {0} for production").format(sales_order), + title=title, + ) def set_pending_qty_in_row_without_reference(self): "Set Pending Qty in independent rows (not from SO or MR)." @@ -205,6 +241,7 @@ def get_so_items(self): ).as_("pending_qty"), so_item.description, so_item.name, + so_item.bom_no, ) .distinct() .where( @@ -316,7 +353,7 @@ def add_items(self, items): if not data.pending_qty: continue - item_details = get_item_details(data.item_code) + item_details = get_item_details(data.item_code, throw=False) if self.combine_items: if item_details.bom_no in refs: refs[item_details.bom_no]["so_details"].append( @@ -342,7 +379,7 @@ def add_items(self, items): "item_code": data.item_code, "description": data.description or item_details.description, "stock_uom": item_details and item_details.stock_uom or "", - "bom_no": item_details and item_details.bom_no or "", + "bom_no": data.bom_no or item_details and item_details.bom_no or "", "planned_qty": data.pending_qty, "pending_qty": data.pending_qty, "planned_start_date": now_datetime(), @@ -401,11 +438,50 @@ def update_produced_pending_qty(self, produced_qty, production_plan_item): def on_submit(self): self.update_bin_qty() + self.update_sales_order() def on_cancel(self): self.db_set("status", "Cancelled") self.delete_draft_work_order() self.update_bin_qty() + self.update_sales_order() + + def update_sales_order(self): + sales_orders = [row.sales_order for row in self.po_items if row.sales_order] + if sales_orders: + so_wise_planned_qty = self.get_so_wise_planned_qty(sales_orders) + + for row in self.po_items: + if not row.sales_order and not row.sales_order_item: + continue + + key = (row.sales_order, row.sales_order_item) + frappe.db.set_value( + "Sales Order Item", + row.sales_order_item, + "production_plan_qty", + flt(so_wise_planned_qty.get(key)), + ) + + @staticmethod + def get_so_wise_planned_qty(sales_orders): + so_wise_planned_qty = frappe._dict() + data = frappe.get_all( + "Production Plan Item", + fields=["sales_order", "sales_order_item", "SUM(planned_qty) as qty"], + filters={ + "sales_order": ("in", sales_orders), + "docstatus": 1, + "sales_order_item": ("is", "set"), + }, + group_by="sales_order, sales_order_item", + ) + + for row in data: + key = (row.sales_order, row.sales_order_item) + so_wise_planned_qty[key] = row.qty + + return so_wise_planned_qty def update_bin_qty(self): for d in self.mr_items: @@ -655,7 +731,7 @@ def make_material_request(self): # key for Sales Order:Material Request Type:Customer key = "{}:{}:{}".format(item.sales_order, material_request_type, item_doc.customer or "") - schedule_date = add_days(nowdate(), cint(item_doc.lead_time_days)) + schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days)) if not key in material_request_map: # make a new MR for the combination @@ -679,7 +755,9 @@ def make_material_request(self): "items", { "item_code": item.item_code, - "from_warehouse": item.from_warehouse, + "from_warehouse": item.from_warehouse + if material_request_type == "Material Transfer" + else None, "qty": item.quantity, "schedule_date": schedule_date, "warehouse": item.warehouse, @@ -719,9 +797,15 @@ def get_sub_assembly_items(self, manufacturing_type=None): sub_assembly_items_store = [] # temporary store to process all subassembly items for row in self.po_items: + if self.skip_available_sub_assembly_item and not row.warehouse: + frappe.throw(_("Row #{0}: Please select the FG Warehouse in Assembly Items").format(row.idx)) + if not row.item_code: frappe.throw(_("Row #{0}: Please select Item Code in Assembly Items").format(row.idx)) + if not row.bom_no: + frappe.throw(_("Row #{0}: Please select the BOM No in Assembly Items").format(row.idx)) + bom_data = [] warehouse = row.warehouse if self.skip_available_sub_assembly_item else None @@ -1142,7 +1226,7 @@ def get_sales_orders(self): & (so.docstatus == 1) & (so.status.notin(["Stopped", "Closed"])) & (so.company == self.company) - & (so_item.qty > so_item.work_order_qty) + & (so_item.qty > so_item.production_plan_qty) ) ) @@ -1424,6 +1508,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d def get_materials_from_other_locations(item, warehouses, new_mr_items, company): from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations + stock_uom, purchase_uom = frappe.db.get_value( + "Item", item.get("item_code"), ["stock_uom", "purchase_uom"] + ) + locations = get_available_item_locations( item.get("item_code"), warehouses, item.get("quantity"), company, ignore_validation=True ) @@ -1434,6 +1522,10 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company): if required_qty <= 0: return + conversion_factor = 1.0 + if purchase_uom != stock_uom and purchase_uom == item["uom"]: + conversion_factor = get_uom_conversion_factor(item["item_code"], item["uom"]) + new_dict = copy.deepcopy(item) quantity = required_qty if d.get("qty") > required_qty else d.get("qty") @@ -1446,25 +1538,14 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company): } ) - required_qty -= quantity + required_qty -= quantity / conversion_factor new_mr_items.append(new_dict) # raise purchase request for remaining qty - if required_qty: - stock_uom, purchase_uom = frappe.db.get_value( - "Item", item["item_code"], ["stock_uom", "purchase_uom"] - ) - - if purchase_uom != stock_uom and purchase_uom == item["uom"]: - conversion_factor = get_uom_conversion_factor(item["item_code"], item["uom"]) - if not (conversion_factor or frappe.flags.show_qty_in_stock_uom): - frappe.throw( - _("UOM Conversion factor ({0} -> {1}) not found for item: {2}").format( - purchase_uom, stock_uom, item["item_code"] - ) - ) - required_qty = required_qty / conversion_factor + precision = frappe.get_precision("Material Request Plan Item", "quantity") + if flt(required_qty, precision) > 0: + required_qty = required_qty if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"): required_qty = ceil(required_qty) @@ -1535,18 +1616,25 @@ def get_reserved_qty_for_production_plan(item_code, warehouse): table = frappe.qb.DocType("Production Plan") child = frappe.qb.DocType("Material Request Plan Item") + non_completed_production_plans = get_non_completed_production_plans() + query = ( frappe.qb.from_(table) .inner_join(child) .on(table.name == child.parent) - .select(Sum(child.quantity * IfNull(child.conversion_factor, 1.0))) + .select(Sum(child.required_bom_qty)) .where( (table.docstatus == 1) & (child.item_code == item_code) & (child.warehouse == warehouse) & (table.status.notin(["Completed", "Closed"])) ) - ).run() + ) + + if non_completed_production_plans: + query = query.where(table.name.isin(non_completed_production_plans)) + + query = query.run() if not query: return 0.0 @@ -1554,7 +1642,9 @@ def get_reserved_qty_for_production_plan(item_code, warehouse): reserved_qty_for_production_plan = flt(query[0][0]) reserved_qty_for_production = flt( - get_reserved_qty_for_production(item_code, warehouse, check_production_plan=True) + get_reserved_qty_for_production( + item_code, warehouse, non_completed_production_plans, check_production_plan=True + ) ) if reserved_qty_for_production > reserved_qty_for_production_plan: @@ -1563,10 +1653,28 @@ def get_reserved_qty_for_production_plan(item_code, warehouse): return reserved_qty_for_production_plan - reserved_qty_for_production +def get_non_completed_production_plans(): + table = frappe.qb.DocType("Production Plan") + child = frappe.qb.DocType("Production Plan Item") + + query = ( + frappe.qb.from_(table) + .inner_join(child) + .on(table.name == child.parent) + .select(table.name) + .where( + (table.docstatus == 1) + & (table.status.notin(["Completed", "Closed"])) + & (child.planned_qty > child.ordered_qty) + ) + ).run(as_dict=True) + + return list(set([d.name for d in query])) + + def get_raw_materials_of_sub_assembly_items( item_details, company, bom_no, include_non_stock_items, sub_assembly_items, planned_qty=1 ): - bei = frappe.qb.DocType("BOM Item") bom = frappe.qb.DocType("BOM") item = frappe.qb.DocType("Item") @@ -1609,7 +1717,10 @@ def get_raw_materials_of_sub_assembly_items( for item in items: key = (item.item_code, item.bom_no) - if item.bom_no and key in sub_assembly_items: + if item.bom_no and key not in sub_assembly_items: + continue + + if item.bom_no: planned_qty = flt(sub_assembly_items[key]) get_raw_materials_of_sub_assembly_items( item_details, @@ -1626,3 +1737,42 @@ def get_raw_materials_of_sub_assembly_items( item_details.setdefault(item.get("item_code"), item) return item_details + + +@frappe.whitelist() +def sales_order_query( + doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None +): + frappe.has_permission("Production Plan", throw=True) + + if not filters: + filters = {} + + so_table = frappe.qb.DocType("Sales Order") + table = frappe.qb.DocType("Sales Order Item") + + query = ( + frappe.qb.from_(so_table) + .join(table) + .on(table.parent == so_table.name) + .select(table.parent) + .distinct() + .where((table.qty > table.production_plan_qty) & (table.docstatus == 1)) + ) + + if filters.get("company"): + query = query.where(so_table.company == filters.get("company")) + + if filters.get("sales_orders"): + query = query.where(so_table.name.isin(filters.get("sales_orders"))) + + if txt: + query = query.where(table.parent.like(f"%{txt}%")) + + if page_len: + query = query.limit(page_len) + + if start: + query = query.offset(start) + + return query.run() diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index f60dbfc3f55b..dbd3083ab583 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -2,11 +2,12 @@ # See license.txt import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import add_to_date, flt, now_datetime, nowdate +from frappe.utils import add_to_date, flt, getdate, now_datetime, nowdate from erpnext.controllers.item_variant import create_variant from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_items_for_material_requests, + get_non_completed_production_plans, get_sales_orders, get_warehouse_list, ) @@ -58,6 +59,9 @@ def test_production_plan_mr_creation(self): pln = create_production_plan(item_code="Test Production Item 1") self.assertTrue(len(pln.mr_items), 2) + for row in pln.mr_items: + row.schedule_date = add_to_date(nowdate(), days=10) + pln.make_material_request() pln.reload() self.assertTrue(pln.status, "Material Requested") @@ -71,6 +75,13 @@ def test_production_plan_mr_creation(self): self.assertTrue(len(material_requests), 2) + for row in material_requests: + mr_schedule_date = getdate(frappe.db.get_value("Material Request", row[0], "schedule_date")) + + expected_date = getdate(add_to_date(nowdate(), days=10)) + + self.assertEqual(mr_schedule_date, expected_date) + pln.make_work_order() work_orders = frappe.get_all( "Work Order", fields=["name"], filters={"production_plan": pln.name}, as_list=1 @@ -225,6 +236,102 @@ def test_production_plan_sales_orders(self): self.assertEqual(sales_orders, []) + def test_donot_allow_to_make_multiple_pp_against_same_so(self): + item = "Test SO Production Item 1" + create_item(item) + + raw_material = "Test SO RM Production Item 1" + create_item(raw_material) + + if not frappe.db.get_value("BOM", {"item": item}): + make_bom(item=item, raw_materials=[raw_material]) + + so = make_sales_order(item_code=item, qty=4) + pln = frappe.new_doc("Production Plan") + pln.company = so.company + pln.get_items_from = "Sales Order" + + pln.append( + "sales_orders", + { + "sales_order": so.name, + "sales_order_date": so.transaction_date, + "customer": so.customer, + "grand_total": so.grand_total, + }, + ) + + pln.get_so_items() + pln.submit() + + pln = frappe.new_doc("Production Plan") + pln.company = so.company + pln.get_items_from = "Sales Order" + + pln.append( + "sales_orders", + { + "sales_order": so.name, + "sales_order_date": so.transaction_date, + "customer": so.customer, + "grand_total": so.grand_total, + }, + ) + + pln.get_so_items() + self.assertRaises(frappe.ValidationError, pln.save) + + def test_so_based_bill_of_material(self): + item = "Test SO Production Item 1" + create_item(item) + + raw_material = "Test SO RM Production Item 1" + create_item(raw_material) + + bom1 = make_bom(item=item, raw_materials=[raw_material]) + + so = make_sales_order(item_code=item, qty=4) + + # Create new BOM and assign to new sales order + bom2 = make_bom(item=item, raw_materials=[raw_material]) + so2 = make_sales_order(item_code=item, qty=4) + + pln1 = frappe.new_doc("Production Plan") + pln1.company = so.company + pln1.get_items_from = "Sales Order" + + pln1.append( + "sales_orders", + { + "sales_order": so.name, + "sales_order_date": so.transaction_date, + "customer": so.customer, + "grand_total": so.grand_total, + }, + ) + + pln1.get_so_items() + + self.assertEqual(pln1.po_items[0].bom_no, bom1.name) + + pln2 = frappe.new_doc("Production Plan") + pln2.company = so2.company + pln2.get_items_from = "Sales Order" + + pln2.append( + "sales_orders", + { + "sales_order": so2.name, + "sales_order_date": so2.transaction_date, + "customer": so2.customer, + "grand_total": so2.grand_total, + }, + ) + + pln2.get_so_items() + + self.assertEqual(pln2.po_items[0].bom_no, bom2.name) + def test_production_plan_combine_items(self): "Test combining FG items in Production Plan." item = "Test Production Item 1" @@ -933,6 +1040,102 @@ def test_resered_qty_for_production_plan_for_material_requests(self): self.assertEqual(after_qty, before_qty) + def test_resered_qty_for_production_plan_for_work_order(self): + from erpnext.stock.utils import get_or_make_bin + + bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC") + before_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + + pln = create_production_plan(item_code="Test Production Item 1") + + bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC") + after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + + self.assertEqual(after_qty - before_qty, 1) + + pln.make_work_order() + + work_orders = [] + for row in frappe.get_all("Work Order", filters={"production_plan": pln.name}, fields=["name"]): + wo_doc = frappe.get_doc("Work Order", row.name) + wo_doc.source_warehouse = "_Test Warehouse - _TC" + wo_doc.wip_warehouse = "_Test Warehouse 1 - _TC" + wo_doc.fg_warehouse = "_Test Warehouse - _TC" + for d in wo_doc.required_items: + d.source_warehouse = "_Test Warehouse - _TC" + make_stock_entry( + item_code=d.item_code, + qty=d.required_qty, + rate=100, + target="_Test Warehouse - _TC", + ) + + wo_doc.submit() + work_orders.append(wo_doc) + + bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC") + after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + + self.assertEqual(after_qty, before_qty) + + rm_work_order = None + for wo_doc in work_orders: + for d in wo_doc.required_items: + if d.item_code == "Raw Material Item 1": + rm_work_order = wo_doc + break + + if rm_work_order: + s = frappe.get_doc(make_se_from_wo(rm_work_order.name, "Material Transfer for Manufacture", 1)) + s.submit() + bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC") + after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + + self.assertEqual(after_qty, before_qty) + + def test_resered_qty_for_production_plan_for_less_rm_qty(self): + from erpnext.stock.utils import get_or_make_bin + + bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC") + before_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + + pln = create_production_plan(item_code="Test Production Item 1", planned_qty=10) + + bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC") + after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + + self.assertEqual(after_qty - before_qty, 10) + + pln.make_work_order() + + plans = [] + for row in frappe.get_all("Work Order", filters={"production_plan": pln.name}, fields=["name"]): + wo_doc = frappe.get_doc("Work Order", row.name) + wo_doc.source_warehouse = "_Test Warehouse - _TC" + wo_doc.wip_warehouse = "_Test Warehouse 1 - _TC" + wo_doc.fg_warehouse = "_Test Warehouse - _TC" + for d in wo_doc.required_items: + d.source_warehouse = "_Test Warehouse - _TC" + d.required_qty -= 5 + make_stock_entry( + item_code=d.item_code, + qty=d.required_qty, + rate=100, + target="_Test Warehouse - _TC", + ) + + wo_doc.submit() + plans.append(pln.name) + + bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC") + after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + + self.assertEqual(after_qty, before_qty) + + completed_plans = get_non_completed_production_plans() + for plan in plans: + self.assertFalse(plan in completed_plans) + def test_resered_qty_for_production_plan_for_material_requests_with_multi_UOM(self): from erpnext.stock.utils import get_or_make_bin @@ -981,6 +1184,41 @@ def test_resered_qty_for_production_plan_for_material_requests_with_multi_UOM(se ) self.assertEqual(reserved_qty_after_mr, before_qty) + def test_from_warehouse_for_purchase_material_request(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + from erpnext.stock.utils import get_or_make_bin + + create_item("RM-TEST-123 For Purchase", valuation_rate=100) + bin_name = get_or_make_bin("RM-TEST-123 For Purchase", "_Test Warehouse - _TC") + t_warehouse = create_warehouse("_Test Store - _TC") + make_stock_entry( + item_code="Raw Material Item 1", + qty=5, + rate=100, + target=t_warehouse, + ) + + plan = create_production_plan(item_code="Test Production Item 1", do_not_save=1) + mr_items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": t_warehouse}] + ) + + for d in mr_items: + plan.append("mr_items", d) + + plan.save() + + for row in plan.mr_items: + if row.material_request_type == "Material Transfer": + self.assertEqual(row.from_warehouse, t_warehouse) + + row.material_request_type = "Purchase" + + plan.save() + + for row in plan.mr_items: + self.assertFalse(row.from_warehouse) + def test_skip_available_qty_for_sub_assembly_items(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom @@ -1025,6 +1263,64 @@ def test_skip_available_qty_for_sub_assembly_items(self): if row.item_code == "SubAssembly2 For SUB Test": self.assertEqual(row.quantity, 10) + def test_transfer_and_purchase_mrp_for_purchase_uom(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + bom_tree = { + "Test FG Item INK PEN": { + "Test RM Item INK": {}, + } + } + + parent_bom = create_nested_bom(bom_tree, prefix="") + if not frappe.db.exists("UOM Conversion Detail", {"parent": "Test RM Item INK", "uom": "Kg"}): + doc = frappe.get_doc("Item", "Test RM Item INK") + doc.purchase_uom = "Kg" + doc.append("uoms", {"uom": "Kg", "conversion_factor": 0.5}) + doc.save() + + wh1 = create_warehouse("PNE Warehouse", company="_Test Company") + wh2 = create_warehouse("MBE Warehouse", company="_Test Company") + mrp_warhouse = create_warehouse("MRPBE Warehouse", company="_Test Company") + + make_stock_entry( + item_code="Test RM Item INK", + qty=2, + rate=100, + target=wh1, + ) + + make_stock_entry( + item_code="Test RM Item INK", + qty=2, + rate=100, + target=wh2, + ) + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=10, + do_not_submit=1, + warehouse="_Test Warehouse - _TC", + ) + + plan.for_warehouse = mrp_warhouse + + items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": wh1}, {"warehouse": wh2}] + ) + + for row in items: + row = frappe._dict(row) + if row.material_request_type == "Material Transfer": + self.assertTrue(row.from_warehouse in [wh1, wh2]) + self.assertEqual(row.quantity, 2) + + if row.material_request_type == "Purchase": + self.assertTrue(row.warehouse == mrp_warhouse) + self.assertEqual(row.quantity, 12) + def create_production_plan(**args): """ diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 38e72533ba0c..fb44dfdffbb6 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -405,6 +405,8 @@ "read_only": 1 }, { + "fetch_from": "production_item.stock_uom", + "fetch_if_empty": 1, "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", @@ -598,7 +600,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2023-06-09 13:20:09.154362", + "modified": "2023-08-11 18:35:49.852069", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", @@ -618,7 +620,6 @@ "read": 1, "report": 1, "role": "Manufacturing User", - "set_user_permissions": 1, "share": 1, "submit": 1, "write": 1 diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 92678e44c844..93d015dc93ba 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -362,10 +362,10 @@ def on_submit(self): else: self.update_work_order_qty_in_so() + self.update_ordered_qty() self.update_reserved_qty_for_production() self.update_completed_qty_in_material_request() self.update_planned_qty() - self.update_ordered_qty() self.create_job_card() def on_cancel(self): @@ -1075,7 +1075,7 @@ def get_bom_operations(doctype, txt, searchfield, start, page_len, filters): @frappe.whitelist() -def get_item_details(item, project=None, skip_bom_info=False): +def get_item_details(item, project=None, skip_bom_info=False, throw=True): res = frappe.db.sql( """ select stock_uom, description, item_name, allow_alternative_item, @@ -1111,12 +1111,15 @@ def get_item_details(item, project=None, skip_bom_info=False): if not res["bom_no"]: if project: - res = get_item_details(item) + res = get_item_details(item, throw=throw) frappe.msgprint( _("Default BOM not found for Item {0} and Project {1}").format(item, project), alert=1 ) else: - frappe.throw(_("Default BOM for {0} not found").format(item)) + msg = _("Default BOM for {0} not found").format(item) + frappe.msgprint(msg, raise_exception=throw, indicator="yellow", alert=(not throw)) + + return res bom_data = frappe.db.get_value( "BOM", @@ -1488,37 +1491,47 @@ def update_item_quantity(source, target, source_parent): def get_reserved_qty_for_production( - item_code: str, warehouse: str, check_production_plan: bool = False + item_code: str, + warehouse: str, + non_completed_production_plans: list = None, + check_production_plan: bool = False, ) -> float: """Get total reserved quantity for any item in specified warehouse""" wo = frappe.qb.DocType("Work Order") wo_item = frappe.qb.DocType("Work Order Item") + if check_production_plan: + qty_field = wo_item.required_qty + else: + qty_field = Case() + qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) + qty_field = qty_field.else_(wo_item.required_qty - wo_item.consumed_qty) + query = ( frappe.qb.from_(wo) .from_(wo_item) - .select( - Sum( - Case() - .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty) - .else_(wo_item.required_qty - wo_item.consumed_qty) - ) - ) + .select(Sum(qty_field)) .where( (wo_item.item_code == item_code) & (wo_item.parent == wo.name) & (wo.docstatus == 1) & (wo_item.source_warehouse == warehouse) - & (wo.status.notin(["Stopped", "Completed", "Closed"])) + ) + ) + + if check_production_plan: + query = query.where(wo.production_plan.isnotnull()) + else: + query = query.where( + (wo.status.notin(["Stopped", "Completed", "Closed"])) & ( (wo_item.required_qty > wo_item.transferred_qty) | (wo_item.required_qty > wo_item.consumed_qty) ) ) - ) - if check_production_plan: - query = query.where(wo.production_plan.isnotnull()) + if non_completed_production_plans: + query = query.where(wo.production_plan.isin(non_completed_production_plans)) return query.run()[0][0] or 0.0 diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index fabd254d8f65..0eb9906a2ab4 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -114,7 +114,7 @@ def validate_workstation_holiday(self, schedule_date, skip_holiday_list_check=Fa if schedule_date in tuple(get_holidays(self.holiday_list)): schedule_date = add_days(schedule_date, 1) - self.validate_workstation_holiday(schedule_date, skip_holiday_list_check=True) + return self.validate_workstation_holiday(schedule_date, skip_holiday_list_check=True) return schedule_date diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 75f728afa88b..abdd09383bb1 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -269,6 +269,7 @@ erpnext.patches.v13_0.show_hr_payroll_deprecation_warning erpnext.patches.v13_0.reset_corrupt_defaults erpnext.patches.v13_0.create_accounting_dimensions_for_asset_repair erpnext.patches.v14_0.update_reference_due_date_in_journal_entry +erpnext.patches.v14_0.france_depreciation_warning [post_model_sync] execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') @@ -328,13 +329,18 @@ erpnext.patches.v14_0.set_pick_list_status erpnext.patches.v13_0.update_docs_link erpnext.patches.v14_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_reference_type_in_journal_entry_accounts erpnext.patches.v14_0.update_company_in_ldc erpnext.patches.v14_0.set_packed_qty_in_draft_delivery_notes erpnext.patches.v14_0.cleanup_workspaces erpnext.patches.v14_0.enable_allow_existing_serial_no -erpnext.patches.v14_0.set_report_in_process_SOA +erpnext.patches.v14_0.set_report_in_process_SOA erpnext.patches.v14_0.create_accounting_dimensions_for_closing_balance erpnext.patches.v14_0.update_closing_balances #15-07-2023 -execute:frappe.defaults.clear_default("fiscal_year") \ No newline at end of file +execute:frappe.defaults.clear_default("fiscal_year") +execute:frappe.db.set_single_value('Selling Settings', 'allow_negative_rates_for_items', 0) +erpnext.patches.v14_0.correct_asset_value_if_je_with_workflow +erpnext.patches.v14_0.migrate_deferred_accounts_to_item_defaults +erpnext.patches.v14_0.create_accounting_dimensions_in_sales_order_item +# below migration patch should always run last +erpnext.patches.v14_0.migrate_gl_to_payment_ledger diff --git a/erpnext/patches/v14_0/correct_asset_value_if_je_with_workflow.py b/erpnext/patches/v14_0/correct_asset_value_if_je_with_workflow.py new file mode 100644 index 000000000000..aededa2287d6 --- /dev/null +++ b/erpnext/patches/v14_0/correct_asset_value_if_je_with_workflow.py @@ -0,0 +1,119 @@ +import frappe +from frappe.model.workflow import get_workflow_name +from frappe.query_builder.functions import IfNull, Sum + + +def execute(): + active_je_workflow = get_workflow_name("Journal Entry") + if not active_je_workflow: + return + + correct_value_for_assets_with_manual_depr_entries() + + finance_books = frappe.db.get_all("Finance Book", pluck="name") + + if finance_books: + for fb_name in finance_books: + correct_value_for_assets_with_auto_depr(fb_name) + + correct_value_for_assets_with_auto_depr() + + +def correct_value_for_assets_with_manual_depr_entries(): + asset = frappe.qb.DocType("Asset") + gle = frappe.qb.DocType("GL Entry") + aca = frappe.qb.DocType("Asset Category Account") + company = frappe.qb.DocType("Company") + + asset_details_and_depr_amount_map = ( + frappe.qb.from_(gle) + .join(asset) + .on(gle.against_voucher == asset.name) + .join(aca) + .on((aca.parent == asset.asset_category) & (aca.company_name == asset.company)) + .join(company) + .on(company.name == asset.company) + .select( + asset.name.as_("asset_name"), + asset.gross_purchase_amount.as_("gross_purchase_amount"), + asset.opening_accumulated_depreciation.as_("opening_accumulated_depreciation"), + Sum(gle.debit).as_("depr_amount"), + ) + .where( + gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account) + ) + .where(gle.debit != 0) + .where(gle.is_cancelled == 0) + .where(asset.docstatus == 1) + .where(asset.calculate_depreciation == 0) + .groupby(asset.name) + ) + + frappe.qb.update(asset).join(asset_details_and_depr_amount_map).on( + asset_details_and_depr_amount_map.asset_name == asset.name + ).set( + asset.value_after_depreciation, + asset_details_and_depr_amount_map.gross_purchase_amount + - asset_details_and_depr_amount_map.opening_accumulated_depreciation + - asset_details_and_depr_amount_map.depr_amount, + ).run() + + +def correct_value_for_assets_with_auto_depr(fb_name=None): + asset = frappe.qb.DocType("Asset") + gle = frappe.qb.DocType("GL Entry") + aca = frappe.qb.DocType("Asset Category Account") + company = frappe.qb.DocType("Company") + afb = frappe.qb.DocType("Asset Finance Book") + + asset_details_and_depr_amount_map = ( + frappe.qb.from_(gle) + .join(asset) + .on(gle.against_voucher == asset.name) + .join(aca) + .on((aca.parent == asset.asset_category) & (aca.company_name == asset.company)) + .join(company) + .on(company.name == asset.company) + .select( + asset.name.as_("asset_name"), + asset.gross_purchase_amount.as_("gross_purchase_amount"), + asset.opening_accumulated_depreciation.as_("opening_accumulated_depreciation"), + Sum(gle.debit).as_("depr_amount"), + ) + .where( + gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account) + ) + .where(gle.debit != 0) + .where(gle.is_cancelled == 0) + .where(asset.docstatus == 1) + .where(asset.calculate_depreciation == 1) + .groupby(asset.name) + ) + + if fb_name: + asset_details_and_depr_amount_map = asset_details_and_depr_amount_map.where( + gle.finance_book == fb_name + ) + else: + asset_details_and_depr_amount_map = asset_details_and_depr_amount_map.where( + (gle.finance_book.isin([""])) | (gle.finance_book.isnull()) + ) + + query = ( + frappe.qb.update(afb) + .join(asset_details_and_depr_amount_map) + .on(asset_details_and_depr_amount_map.asset_name == afb.parent) + .set( + afb.value_after_depreciation, + asset_details_and_depr_amount_map.gross_purchase_amount + - asset_details_and_depr_amount_map.opening_accumulated_depreciation + - asset_details_and_depr_amount_map.depr_amount, + ) + ) + + if fb_name: + query = query.where(afb.finance_book == fb_name) + else: + query = query.where((afb.finance_book.isin([""])) | (afb.finance_book.isnull())) + + query.run() diff --git a/erpnext/patches/v14_0/create_accounting_dimensions_in_sales_order_item.py b/erpnext/patches/v14_0/create_accounting_dimensions_in_sales_order_item.py new file mode 100644 index 000000000000..8f77c35b1290 --- /dev/null +++ b/erpnext/patches/v14_0/create_accounting_dimensions_in_sales_order_item.py @@ -0,0 +1,7 @@ +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + create_accounting_dimensions_for_doctype, +) + + +def execute(): + create_accounting_dimensions_for_doctype(doctype="Sales Order Item") diff --git a/erpnext/patches/v14_0/delete_education_doctypes.py b/erpnext/patches/v14_0/delete_education_doctypes.py index 76b2300fd2a1..55b64eaabd87 100644 --- a/erpnext/patches/v14_0/delete_education_doctypes.py +++ b/erpnext/patches/v14_0/delete_education_doctypes.py @@ -46,6 +46,17 @@ def execute(): for doctype in doctypes: frappe.delete_doc("DocType", doctype, ignore_missing=True) + titles = [ + "Fees", + "Student Admission", + "Grant Application", + "Chapter", + "Certification Application", + ] + items = frappe.get_all("Portal Menu Item", filters=[["title", "in", titles]], pluck="name") + for item in items: + frappe.delete_doc("Portal Menu Item", item, ignore_missing=True, force=True) + frappe.delete_doc("Module Def", "Education", ignore_missing=True, force=True) click.secho( diff --git a/erpnext/patches/v14_0/delete_healthcare_doctypes.py b/erpnext/patches/v14_0/delete_healthcare_doctypes.py index 2c699e4a9f21..896a4409507e 100644 --- a/erpnext/patches/v14_0/delete_healthcare_doctypes.py +++ b/erpnext/patches/v14_0/delete_healthcare_doctypes.py @@ -41,7 +41,7 @@ def execute(): for card in cards: frappe.delete_doc("Number Card", card, ignore_missing=True, force=True) - titles = ["Lab Test", "Prescription", "Patient Appointment"] + titles = ["Lab Test", "Prescription", "Patient Appointment", "Patient"] items = frappe.get_all("Portal Menu Item", filters=[["title", "in", titles]], pluck="name") for item in items: frappe.delete_doc("Portal Menu Item", item, ignore_missing=True, force=True) diff --git a/erpnext/patches/v14_0/france_depreciation_warning.py b/erpnext/patches/v14_0/france_depreciation_warning.py new file mode 100644 index 000000000000..5efbc5bd7fbb --- /dev/null +++ b/erpnext/patches/v14_0/france_depreciation_warning.py @@ -0,0 +1,12 @@ +import click +import frappe + + +def execute(): + if "erpnext_france" in frappe.get_installed_apps(): + return + click.secho( + "Feature for region France will be remove in version-15 and moved to a separate app\n" + "Please install the app to continue using the regionnal France features: https://github.com/scopen-coop/erpnext_france.git", + fg="yellow", + ) diff --git a/erpnext/patches/v14_0/migrate_deferred_accounts_to_item_defaults.py b/erpnext/patches/v14_0/migrate_deferred_accounts_to_item_defaults.py new file mode 100644 index 000000000000..44b830babb20 --- /dev/null +++ b/erpnext/patches/v14_0/migrate_deferred_accounts_to_item_defaults.py @@ -0,0 +1,39 @@ +import frappe + + +def execute(): + try: + item_dict = get_deferred_accounts() + add_to_item_defaults(item_dict) + except Exception: + frappe.db.rollback() + frappe.log_error("Failed to migrate deferred accounts in Item Defaults.") + + +def get_deferred_accounts(): + item = frappe.qb.DocType("Item") + return ( + frappe.qb.from_(item) + .select(item.name, item.deferred_expense_account, item.deferred_revenue_account) + .where((item.enable_deferred_expense == 1) | (item.enable_deferred_revenue == 1)) + .run(as_dict=True) + ) + + +def add_to_item_defaults(item_dict): + for item in item_dict: + add_company_wise_item_default(item, "deferred_expense_account") + add_company_wise_item_default(item, "deferred_revenue_account") + + +def add_company_wise_item_default(item, account_type): + company = frappe.get_cached_value("Account", item[account_type], "company") + if company and item[account_type]: + item_defaults = frappe.get_cached_value("Item", item["name"], "item_defaults") + for item_row in item_defaults: + if item_row.company == company: + frappe.set_value("Item Default", item_row.name, account_type, item[account_type]) + break + else: + item_defaults.append({"company": company, account_type: item[account_type]}) + frappe.set_value("Item", item["name"], "item_defaults", item_defaults) diff --git a/erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py b/erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py new file mode 100644 index 000000000000..48b6bcf755f2 --- /dev/null +++ b/erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py @@ -0,0 +1,22 @@ +import frappe + + +def execute(): + """ + Update Propery Setters for Journal Entry with new 'Entry Type' + """ + new_reference_type = "Payment Entry" + prop_setter = frappe.db.get_list( + "Property Setter", + filters={ + "doc_type": "Journal Entry Account", + "field_name": "reference_type", + "property": "options", + }, + ) + if prop_setter: + property_setter_doc = frappe.get_doc("Property Setter", prop_setter[0].get("name")) + + if new_reference_type not in property_setter_doc.value.split("\n"): + property_setter_doc.value += "\n" + new_reference_type + property_setter_doc.save() diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index d80133c988a3..5c351faae1a1 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -67,6 +67,7 @@ def copy_from_template(self): tmp_task_details.append(template_task_details) task = self.create_task_from_template(template_task_details) project_tasks.append(task) + self.dependency_mapping(tmp_task_details, project_tasks) def create_task_from_template(self, task_details): @@ -84,6 +85,7 @@ def create_task_from_template(self, task_details): issue=task_details.issue, is_group=task_details.is_group, color=task_details.color, + template_task=task_details.name, ) ).insert() @@ -103,32 +105,29 @@ def update_if_holiday(self, date): return date def dependency_mapping(self, template_tasks, project_tasks): - for template_task in template_tasks: - project_task = list(filter(lambda x: x.subject == template_task.subject, project_tasks))[0] - project_task = frappe.get_doc("Task", project_task.name) + for project_task in project_tasks: + template_task = frappe.get_doc("Task", project_task.template_task) + self.check_depends_on_value(template_task, project_task, project_tasks) self.check_for_parent_tasks(template_task, project_task, project_tasks) def check_depends_on_value(self, template_task, project_task, project_tasks): if template_task.get("depends_on") and not project_task.get("depends_on"): + project_template_map = {pt.template_task: pt for pt in project_tasks} + for child_task in template_task.get("depends_on"): - child_task_subject = frappe.db.get_value("Task", child_task.task, "subject") - corresponding_project_task = list( - filter(lambda x: x.subject == child_task_subject, project_tasks) - ) - if len(corresponding_project_task): - project_task.append("depends_on", {"task": corresponding_project_task[0].name}) + if project_template_map and project_template_map.get(child_task.task): + project_task.reload() # reload, as it might have been updated in the previous iteration + project_task.append("depends_on", {"task": project_template_map.get(child_task.task).name}) project_task.save() def check_for_parent_tasks(self, template_task, project_task, project_tasks): if template_task.get("parent_task") and not project_task.get("parent_task"): - parent_task_subject = frappe.db.get_value("Task", template_task.get("parent_task"), "subject") - corresponding_project_task = list( - filter(lambda x: x.subject == parent_task_subject, project_tasks) - ) - if len(corresponding_project_task): - project_task.parent_task = corresponding_project_task[0].name - project_task.save() + for pt in project_tasks: + if pt.template_task == template_task.parent_task: + project_task.parent_task = pt.name + project_task.save() + break def is_row_updated(self, row, existing_task_data, fields): if self.get("__islocal") or not existing_task_data: diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index 8a599cef7538..e49fecd1f470 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -1,9 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -import unittest - import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, getdate, nowdate from erpnext.projects.doctype.project_template.test_project_template import make_project_template @@ -15,7 +14,7 @@ test_ignore = ["Sales Order"] -class TestProject(unittest.TestCase): +class TestProject(FrappeTestCase): def test_project_with_template_having_no_parent_and_depend_tasks(self): project_name = "Test Project with Template - No Parent and Dependend Tasks" frappe.db.sql(""" delete from tabTask where project = %s """, project_name) @@ -155,6 +154,50 @@ def test_project_linking_with_sales_order(self): so.reload() self.assertFalse(so.project) + def test_project_with_template_tasks_having_common_name(self): + # Step - 1: Create Template Parent Tasks + template_parent_task1 = create_task(subject="Parent Task - 1", is_template=1, is_group=1) + template_parent_task2 = create_task(subject="Parent Task - 2", is_template=1, is_group=1) + template_parent_task3 = create_task(subject="Parent Task - 1", is_template=1, is_group=1) + + # Step - 2: Create Template Child Tasks + template_task1 = create_task( + subject="Task - 1", is_template=1, parent_task=template_parent_task1.name + ) + template_task2 = create_task( + subject="Task - 2", is_template=1, parent_task=template_parent_task2.name + ) + template_task3 = create_task( + subject="Task - 1", is_template=1, parent_task=template_parent_task3.name + ) + + # Step - 3: Create Project Template + template_tasks = [ + template_parent_task1, + template_task1, + template_parent_task2, + template_task2, + template_parent_task3, + template_task3, + ] + project_template = make_project_template( + "Project template with common Task Subject", template_tasks + ) + + # Step - 4: Create Project against the Project Template + project = get_project("Project with common Task Subject", project_template) + project_tasks = frappe.get_all( + "Task", {"project": project.name}, ["subject", "parent_task", "is_group"] + ) + + # Test - 1: No. of Project Tasks should be equal to No. of Template Tasks + self.assertEquals(len(project_tasks), len(template_tasks)) + + # Test - 2: All child Project Tasks should have Parent Task linked + for pt in project_tasks: + if not pt.is_group: + self.assertIsNotNone(pt.parent_task) + def get_project(name, template): diff --git a/erpnext/projects/doctype/task/task.json b/erpnext/projects/doctype/task/task.json index 141a99e612b4..3d3a463ec0dd 100644 --- a/erpnext/projects/doctype/task/task.json +++ b/erpnext/projects/doctype/task/task.json @@ -52,7 +52,8 @@ "company", "lft", "rgt", - "old_parent" + "old_parent", + "template_task" ], "fields": [ { @@ -382,6 +383,12 @@ "fieldtype": "Date", "label": "Completed On", "mandatory_depends_on": "eval: doc.status == \"Completed\"" + }, + { + "fieldname": "template_task", + "fieldtype": "Data", + "hidden": 1, + "label": "Template Task" } ], "icon": "fa fa-check", @@ -389,7 +396,7 @@ "is_tree": 1, "links": [], "max_attachments": 5, - "modified": "2022-06-23 16:58:47.005241", + "modified": "2023-09-28 13:52:05.861175", "modified_by": "Administrator", "module": "Projects", "name": "Task", diff --git a/erpnext/projects/doctype/task_depends_on/task_depends_on.json b/erpnext/projects/doctype/task_depends_on/task_depends_on.json index dbbe9d3c7b54..3300b7eb9057 100644 --- a/erpnext/projects/doctype/task_depends_on/task_depends_on.json +++ b/erpnext/projects/doctype/task_depends_on/task_depends_on.json @@ -1,156 +1,52 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2015-04-29 04:52:48.868079", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, + "actions": [], + "creation": "2015-04-29 04:52:48.868079", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "task", + "column_break_2", + "subject", + "project" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "task", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Task", - "length": 0, - "no_copy": 0, - "options": "Task", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "task", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Task", + "options": "Task" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_2", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "subject", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Subject", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fetch_from": "task.subject", + "fetch_if_empty": 1, + "fieldname": "subject", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Subject", + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "project", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Project", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "project", + "fieldtype": "Text", + "label": "Project", + "read_only": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2017-02-24 04:56:04.862502", - "modified_by": "Administrator", - "module": "Projects", - "name": "Task Depends On", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "istable": 1, + "links": [], + "modified": "2023-10-17 12:45:21.536165", + "modified_by": "Administrator", + "module": "Projects", + "name": "Task Depends On", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/projects/report/billing_summary.py b/erpnext/projects/report/billing_summary.py index bc8f2afb8c90..ac1524a49ddc 100644 --- a/erpnext/projects/report/billing_summary.py +++ b/erpnext/projects/report/billing_summary.py @@ -98,9 +98,11 @@ def get_timesheets(filters): record_filters = [ ["start_date", "<=", filters.to_date], ["end_date", ">=", filters.from_date], - ["docstatus", "=", 1], ] - + if not filters.get("include_draft_timesheets"): + record_filters.append(["docstatus", "=", 1]) + else: + record_filters.append(["docstatus", "!=", 2]) if "employee" in filters: record_filters.append(["employee", "=", filters.employee]) diff --git a/erpnext/projects/report/employee_billing_summary/employee_billing_summary.js b/erpnext/projects/report/employee_billing_summary/employee_billing_summary.js index 13f49ed6bed4..9c904c57872a 100644 --- a/erpnext/projects/report/employee_billing_summary/employee_billing_summary.js +++ b/erpnext/projects/report/employee_billing_summary/employee_billing_summary.js @@ -25,5 +25,10 @@ frappe.query_reports["Employee Billing Summary"] = { default: frappe.datetime.add_days(frappe.datetime.month_start(), -1), reqd: 1 }, + { + fieldname:"include_draft_timesheets", + label: __("Include Timesheets in Draft Status"), + fieldtype: "Check", + }, ] } diff --git a/erpnext/projects/report/project_billing_summary/project_billing_summary.js b/erpnext/projects/report/project_billing_summary/project_billing_summary.js index caac1d86b458..6a6f3677e3fb 100644 --- a/erpnext/projects/report/project_billing_summary/project_billing_summary.js +++ b/erpnext/projects/report/project_billing_summary/project_billing_summary.js @@ -25,5 +25,10 @@ frappe.query_reports["Project Billing Summary"] = { default: frappe.datetime.add_days(frappe.datetime.month_start(),-1), reqd: 1 }, + { + fieldname:"include_draft_timesheets", + label: __("Include Timesheets in Draft Status"), + fieldtype: "Check", + }, ] } diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index 1271e38049a0..ddc105305816 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -117,6 +117,9 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { name: __("Document Name"), editable: false, width: 1, + format: (value, row) => { + return frappe.form.formatters.Link(value, {options: row[2].content}); + }, }, { name: __("Reference Date"), diff --git a/erpnext/public/js/controllers/stock_controller.js b/erpnext/public/js/controllers/stock_controller.js index d346357a8f82..2674df9c4af2 100644 --- a/erpnext/public/js/controllers/stock_controller.js +++ b/erpnext/public/js/controllers/stock_controller.js @@ -57,7 +57,8 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con from_date: me.frm.doc.posting_date, to_date: moment(me.frm.doc.modified).format('YYYY-MM-DD'), company: me.frm.doc.company, - show_cancelled_entries: me.frm.doc.docstatus === 2 + show_cancelled_entries: me.frm.doc.docstatus === 2, + ignore_prepared_report: true }; frappe.set_route("query-report", "Stock Ledger"); }, __("View")); @@ -75,7 +76,8 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con to_date: moment(me.frm.doc.modified).format('YYYY-MM-DD'), company: me.frm.doc.company, group_by: "Group by Voucher (Consolidated)", - show_cancelled_entries: me.frm.doc.docstatus === 2 + show_cancelled_entries: me.frm.doc.docstatus === 2, + ignore_prepared_report: true }; frappe.set_route("query-report", "General Ledger"); }, __("View")); diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 6f4e602abb60..b7ed22346b45 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -135,7 +135,15 @@ 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 = flt(item.qty); + if (!qty) { + qty = (me.frm.doc.is_debit_note ? 1 : -1); + if (me.frm.doc.doctype !== "Purchase Receipt" && me.frm.doc.is_return === 1) { + // In case of Purchase Receipt, qty can be 0 if all items are rejected + qty = flt(item.qty); + } + } + 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 b6b6e2e5ad60..fe24b18098a1 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -119,19 +119,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } }); - if(this.frm.fields_dict["items"].grid.get_field('batch_no')) { - this.frm.set_query("batch_no", "items", function(doc, cdt, cdn) { + if(this.frm.fields_dict['items'].grid.get_field('batch_no')) { + this.frm.set_query('batch_no', 'items', function(doc, cdt, cdn) { return me.set_query_for_batch(doc, cdt, cdn); }); - - let batch_field = this.frm.get_docfield('items', 'batch_no'); - if (batch_field) { - batch_field.get_route_options_for_new_doc = (row) => { - return { - 'item': row.doc.item_code - } - }; - } } if( @@ -196,14 +187,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }); } - let batch_no_field = this.frm.get_docfield("items", "batch_no"); - if (batch_no_field) { - batch_no_field.get_route_options_for_new_doc = function(row) { - return { - "item": row.doc.item_code - } - }; - } if (this.frm.fields_dict["items"].grid.get_field('blanket_order')) { this.frm.set_query("blanket_order", "items", function(doc, cdt, cdn) { @@ -257,6 +240,17 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } ]); } + + if(this.frm.fields_dict['items'].grid.get_field('batch_no')) { + let batch_field = this.frm.get_docfield('items', 'batch_no'); + if (batch_field) { + batch_field.get_route_options_for_new_doc = (row) => { + return { + 'item': row.doc.item_code + } + }; + } + } } is_return() { diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 7b230af26990..730ee23173db 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -18,6 +18,7 @@ import "./utils/customer_quick_entry"; import "./utils/supplier_quick_entry"; import "./call_popup/call_popup"; import "./utils/dimension_tree_filter"; +import "./utils/unreconcile.js"; import "./utils/barcode_scanner"; import "./telephony"; import "./templates/call_link.html"; diff --git a/erpnext/public/js/financial_statements.js b/erpnext/public/js/financial_statements.js index 959cf507d53d..907a775bfa53 100644 --- a/erpnext/public/js/financial_statements.js +++ b/erpnext/public/js/financial_statements.js @@ -6,8 +6,10 @@ erpnext.financial_statements = { if (data && column.fieldname=="account") { value = data.account_name || value; - column.link_onclick = - "erpnext.financial_statements.open_general_ledger(" + JSON.stringify(data) + ")"; + if (data.account) { + column.link_onclick = + "erpnext.financial_statements.open_general_ledger(" + JSON.stringify(data) + ")"; + } column.is_tree = true; } diff --git a/erpnext/public/js/purchase_trends_filters.js b/erpnext/public/js/purchase_trends_filters.js index c786a8674e6b..77f1d2b496ae 100644 --- a/erpnext/public/js/purchase_trends_filters.js +++ b/erpnext/public/js/purchase_trends_filters.js @@ -28,7 +28,7 @@ erpnext.get_purchase_trends_filters = function() { "label": __("Fiscal Year"), "fieldtype": "Link", "options":'Fiscal Year', - "default": frappe.sys_defaults.fiscal_year + "default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today()) }, { "fieldname":"period_based_on", diff --git a/erpnext/public/js/sales_trends_filters.js b/erpnext/public/js/sales_trends_filters.js index b9c4dca91309..9a70a3da4c61 100644 --- a/erpnext/public/js/sales_trends_filters.js +++ b/erpnext/public/js/sales_trends_filters.js @@ -48,7 +48,7 @@ erpnext.get_sales_trends_filters = function() { "label": __("Fiscal Year"), "fieldtype": "Link", "options":'Fiscal Year', - "default": frappe.sys_defaults.fiscal_year + "default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today()) }, { "fieldname":"company", diff --git a/erpnext/public/js/setup_wizard.js b/erpnext/public/js/setup_wizard.js index a913844e1867..934fd1f88ae2 100644 --- a/erpnext/public/js/setup_wizard.js +++ b/erpnext/public/js/setup_wizard.js @@ -24,12 +24,14 @@ erpnext.setup.slides_settings = [ fieldtype: 'Data', reqd: 1 }, + { fieldtype: "Column Break" }, { fieldname: 'company_abbr', label: __('Company Abbreviation'), fieldtype: 'Data', - hidden: 1 + reqd: 1 }, + { fieldtype: "Section Break" }, { fieldname: 'chart_of_accounts', label: __('Chart of Accounts'), options: "", fieldtype: 'Select' @@ -134,18 +136,20 @@ erpnext.setup.slides_settings = [ me.charts_modal(slide, chart_template); }); - slide.get_input("company_name").on("change", function () { + slide.get_input("company_name").on("input", 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) { + let abbr = slide.get_input("company_abbr").val(); + if (abbr.length > 10) { frappe.msgprint(__("Company Abbreviation cannot have more than 5 characters")); - slide.get_field("company_abbr").set_value(""); + abbr = abbr.slice(0, 10); } - }); + slide.get_field("company_abbr").set_value(abbr); + }).val(frappe.boot.sysdefaults.company_abbr || "").trigger("change"); }, charts_modal: function(slide, chart_template) { diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index a6b4ea12bbe5..eafc1ed70e63 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -666,6 +666,9 @@ erpnext.utils.update_child_items = function(opts) { }).show(); } + + + erpnext.utils.map_current_doc = function(opts) { function _map() { if($.isArray(cur_frm.doc.items) && cur_frm.doc.items.length > 0) { diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 64c5ee59dc81..22120988ad0f 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -510,30 +510,41 @@ erpnext.SerialNoBatchSelector = class SerialNoBatchSelector { if(!list_value) { new_line = ''; } else { - me.serial_list = list_value.replace(/\n/g, ' ').match(/\S+/g) || []; + me.serial_list = list_value.split(/\n/g) || []; } if(!me.serial_list.includes(new_number)) { this.set_new_description(''); serial_no_list_field.set_value(me.serial_list.join('\n') + new_line + new_number); - me.serial_list = serial_no_list_field.get_value().replace(/\n/g, ' ').match(/\S+/g) || []; + me.serial_list = serial_no_list_field.get_value().split(/\n/g) || []; } else { this.set_new_description(new_number + ' is already selected.'); } + me.serial_list = me.serial_list.filter(serial => { + if (serial) { + return true; + } + }); + qty_field.set_input(me.serial_list.length); this.$input.val(""); this.in_local_change = 0; } }, - {fieldtype: 'Column Break'}, + {fieldtype: 'Section Break'}, { fieldname: 'serial_no', - fieldtype: 'Small Text', + fieldtype: 'Text', label: __(me.has_batch && !me.has_serial_no ? 'Selected Batch Numbers' : 'Selected Serial Numbers'), onchange: function() { - me.serial_list = this.get_value() - .replace(/\n/g, ' ').match(/\S+/g) || []; + me.serial_list = this.get_value().split(/\n/g); + me.serial_list = me.serial_list.filter(serial => { + if (serial) { + return true; + } + }); + this.layout.fields_dict.qty.set_input(me.serial_list.length); } } diff --git a/erpnext/public/js/utils/unreconcile.js b/erpnext/public/js/utils/unreconcile.js new file mode 100644 index 000000000000..bbdd51d6e54a --- /dev/null +++ b/erpnext/public/js/utils/unreconcile.js @@ -0,0 +1,127 @@ +frappe.provide('erpnext.accounts'); + +erpnext.accounts.unreconcile_payments = { + add_unreconcile_btn(frm) { + if (frm.doc.docstatus == 1) { + if(((frm.doc.doctype == "Journal Entry") && (frm.doc.voucher_type != "Journal Entry")) + || !["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"].includes(frm.doc.doctype) + ) { + return; + } + + frappe.call({ + "method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.doc_has_references", + "args": { + "doctype": frm.doc.doctype, + "docname": frm.doc.name + }, + callback: function(r) { + if (r.message) { + frm.add_custom_button(__("Un-Reconcile"), function() { + erpnext.accounts.unreconcile_payments.build_unreconcile_dialog(frm); + }, __('Actions')); + } + } + }); + } + }, + + build_selection_map(frm, selections) { + // assuming each row is an individual voucher + // pass this to server side method that creates unreconcile doc for each row + let selection_map = []; + if (['Sales Invoice', 'Purchase Invoice'].includes(frm.doc.doctype)) { + selection_map = selections.map(function(elem) { + return { + company: elem.company, + voucher_type: elem.voucher_type, + voucher_no: elem.voucher_no, + against_voucher_type: frm.doc.doctype, + against_voucher_no: frm.doc.name + }; + }); + } else if (['Payment Entry', 'Journal Entry'].includes(frm.doc.doctype)) { + selection_map = selections.map(function(elem) { + return { + company: elem.company, + voucher_type: frm.doc.doctype, + voucher_no: frm.doc.name, + against_voucher_type: elem.voucher_type, + against_voucher_no: elem.voucher_no, + }; + }); + } + return selection_map; + }, + + build_unreconcile_dialog(frm) { + if (['Sales Invoice', 'Purchase Invoice', 'Payment Entry', 'Journal Entry'].includes(frm.doc.doctype)) { + let child_table_fields = [ + { label: __("Voucher Type"), fieldname: "voucher_type", fieldtype: "Dynamic Link", options: "DocType", in_list_view: 1, read_only: 1}, + { label: __("Voucher No"), fieldname: "voucher_no", fieldtype: "Link", options: "voucher_type", in_list_view: 1, read_only: 1 }, + { label: __("Allocated Amount"), fieldname: "allocated_amount", fieldtype: "Currency", in_list_view: 1, read_only: 1 , options: "account_currency"}, + { label: __("Currency"), fieldname: "account_currency", fieldtype: "Currency", read_only: 1}, + ] + let unreconcile_dialog_fields = [ + { + label: __('Allocations'), + fieldname: 'allocations', + fieldtype: 'Table', + read_only: 1, + fields: child_table_fields, + }, + ]; + + // get linked payments + frappe.call({ + "method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.get_linked_payments_for_doc", + "args": { + "company": frm.doc.company, + "doctype": frm.doc.doctype, + "docname": frm.doc.name + }, + callback: function(r) { + if (r.message) { + // populate child table with allocations + unreconcile_dialog_fields[0].data = r.message; + unreconcile_dialog_fields[0].get_data = function(){ return r.message}; + + let d = new frappe.ui.Dialog({ + title: 'Un-Reconcile Allocations', + fields: unreconcile_dialog_fields, + size: 'large', + cannot_add_rows: true, + primary_action_label: 'Un-Reconcile', + primary_action(values) { + + let selected_allocations = values.allocations.filter(x=>x.__checked); + if (selected_allocations.length > 0) { + let selection_map = erpnext.accounts.unreconcile_payments.build_selection_map(frm, selected_allocations); + erpnext.accounts.unreconcile_payments.create_unreconcile_docs(selection_map); + d.hide(); + + } else { + frappe.msgprint("No Selection"); + } + } + }); + + d.show(); + } + } + }); + } + }, + + create_unreconcile_docs(selection_map) { + frappe.call({ + "method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.create_unreconcile_doc_for_selection", + "args": { + "selections": selection_map + }, + }); + } + + + +} diff --git a/erpnext/quality_management/doctype/non_conformance/non_conformance.json b/erpnext/quality_management/doctype/non_conformance/non_conformance.json index 8dfe2d6859d4..e6b87449ced3 100644 --- a/erpnext/quality_management/doctype/non_conformance/non_conformance.json +++ b/erpnext/quality_management/doctype/non_conformance/non_conformance.json @@ -62,10 +62,10 @@ "fieldtype": "Column Break" }, { - "fetch_from": "process_owner.full_name", + "fetch_from": "procedure.process_owner_full_name", "fieldname": "full_name", "fieldtype": "Data", - "hidden": 1, + "read_only": 1, "label": "Full Name" }, { @@ -81,7 +81,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-02-26 15:27:47.247814", + "modified": "2023-07-31 08:10:47.247814", "modified_by": "Administrator", "module": "Quality Management", "name": "Non Conformance", diff --git a/erpnext/quality_management/doctype/quality_goal_objective/quality_goal_objective.json b/erpnext/quality_management/doctype/quality_goal_objective/quality_goal_objective.json index e3dbd660b543..010888dd31fc 100644 --- a/erpnext/quality_management/doctype/quality_goal_objective/quality_goal_objective.json +++ b/erpnext/quality_management/doctype/quality_goal_objective/quality_goal_objective.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "format:{####}", "creation": "2019-05-26 15:03:43.996455", "doctype": "DocType", @@ -12,7 +13,6 @@ ], "fields": [ { - "fetch_from": "goal.objective", "fieldname": "objective", "fieldtype": "Text", "in_list_view": 1, @@ -38,14 +38,17 @@ } ], "istable": 1, - "modified": "2019-05-26 16:12:54.832058", + "links": [], + "modified": "2023-07-28 18:10:23.351246", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Goal Objective", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [], "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json index f588f9aea1a4..f5d7a6dd0c52 100644 --- a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json +++ b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json @@ -23,6 +23,7 @@ { "fieldname": "parent_quality_procedure", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Parent Procedure", "options": "Quality Procedure" }, @@ -115,7 +116,7 @@ "link_fieldname": "procedure" } ], - "modified": "2020-10-26 15:25:39.316088", + "modified": "2023-08-29 12:49:53.963370", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Procedure", @@ -149,5 +150,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_review_objective/quality_review_objective.json b/erpnext/quality_management/doctype/quality_review_objective/quality_review_objective.json index 3a750c21d6e2..5ddf0f2a0b45 100644 --- a/erpnext/quality_management/doctype/quality_review_objective/quality_review_objective.json +++ b/erpnext/quality_management/doctype/quality_review_objective/quality_review_objective.json @@ -56,6 +56,7 @@ "fieldtype": "Column Break" }, { + "default": "Open", "columns": 2, "fieldname": "status", "fieldtype": "Select", @@ -67,7 +68,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-27 16:28:20.908637", + "modified": "2023-07-31 09:20:20.908637", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Review Objective", @@ -76,4 +77,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py index cc223e91bc84..6ae04c165c45 100644 --- a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py +++ b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py @@ -34,6 +34,7 @@ def validate_supplier_against_tax_category(self): "supplier": self.supplier, "tax_withholding_category": self.tax_withholding_category, "name": ("!=", self.name), + "company": self.company, }, ["name", "valid_from", "valid_upto"], as_dict=True, diff --git a/erpnext/regional/report/ksa_vat/ksa_vat.py b/erpnext/regional/report/ksa_vat/ksa_vat.py index 3571f962667c..8c1cf3c80fec 100644 --- a/erpnext/regional/report/ksa_vat/ksa_vat.py +++ b/erpnext/regional/report/ksa_vat/ksa_vat.py @@ -177,7 +177,8 @@ def get_tax_data_for_each_vat_setting(vat_setting, filters, doctype): "parent": invoice.name, "item_tax_template": vat_setting.item_tax_template, }, - fields=["item_code", "base_net_amount"], + fields=["item_code", "sum(base_net_amount) as base_net_amount"], + group_by="item_code, item_tax_template", ) for item in invoice_items: diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js index b53f339229b2..432abfce73b2 100644 --- a/erpnext/selling/doctype/customer/customer.js +++ b/erpnext/selling/doctype/customer/customer.js @@ -118,7 +118,7 @@ frappe.ui.form.on("Customer", { // custom buttons frm.add_custom_button(__('Accounts Receivable'), function () { - frappe.set_route('query-report', 'Accounts Receivable', {customer:frm.doc.name}); + frappe.set_route('query-report', 'Accounts Receivable', { party_type: "Customer", party: frm.doc.name }); }, __('View')); frm.add_custom_button(__('Accounting Ledger'), function () { diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index fb810318e930..f5e641dceb47 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -236,6 +236,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount", "fieldname": "discount_and_margin", "fieldtype": "Section Break", "label": "Discount and Margin" @@ -667,7 +668,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2023-02-06 11:00:07.042364", + "modified": "2023-09-27 14:02:12.332407", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 97cccb136202..25ea9189553f 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -95,18 +95,26 @@ def validate_po(self): and customer = %s", (self.po_no, self.name, self.customer), ) - if ( - so - and so[0][0] - and not cint( + if so and so[0][0]: + if cint( frappe.db.get_single_value("Selling Settings", "allow_against_multiple_purchase_orders") - ) - ): - frappe.msgprint( - _("Warning: Sales Order {0} already exists against Customer's Purchase Order {1}").format( - so[0][0], self.po_no + ): + frappe.msgprint( + _("Warning: Sales Order {0} already exists against Customer's Purchase Order {1}").format( + frappe.bold(so[0][0]), frappe.bold(self.po_no) + ) + ) + else: + frappe.throw( + _( + "Sales Order {0} already exists against Customer's Purchase Order {1}. To allow multiple Sales Orders, Enable {2} in {3}" + ).format( + frappe.bold(so[0][0]), + frappe.bold(self.po_no), + frappe.bold(_("'Allow Multiple Sales Orders Against a Customer's Purchase Order'")), + get_link_to_form("Selling Settings", "Selling Settings"), + ) ) - ) def validate_for_items(self): for d in self.get("items"): @@ -534,29 +542,37 @@ def close_or_unclose_sales_orders(names, status): def get_requested_item_qty(sales_order): - return frappe._dict( - frappe.db.sql( - """ - select sales_order_item, sum(qty) - from `tabMaterial Request Item` - where docstatus = 1 - and sales_order = %s - group by sales_order_item - """, - sales_order, - ) - ) + result = {} + for d in frappe.db.get_all( + "Material Request Item", + filters={"docstatus": 1, "sales_order": sales_order}, + fields=["sales_order_item", "sum(qty) as qty", "sum(received_qty) as received_qty"], + group_by="sales_order_item", + ): + result[d.sales_order_item] = frappe._dict({"qty": d.qty, "received_qty": d.received_qty}) + + return result @frappe.whitelist() def make_material_request(source_name, target_doc=None): requested_item_qty = get_requested_item_qty(source_name) + def get_remaining_qty(so_item): + return flt( + flt(so_item.qty) + - flt(requested_item_qty.get(so_item.name, {}).get("qty")) + - max( + flt(so_item.get("delivered_qty")) + - flt(requested_item_qty.get(so_item.name, {}).get("received_qty")), + 0, + ) + ) + def update_item(source, target, source_parent): # 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) - source.delivered_qty + target.qty = get_remaining_qty(source) target.stock_qty = flt(target.qty) * flt(target.conversion_factor) args = target.as_dict().copy() @@ -589,8 +605,8 @@ def update_item(source, target, source_parent): "Sales Order Item": { "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 - doc.delivered_qty) > requested_item_qty.get(doc.name, 0), + "condition": lambda item: not frappe.db.exists("Product Bundle", item.item_code) + and get_remaining_qty(item) > 0, "postprocess": update_item, }, }, diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 9854f159cfef..799ad555a526 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -552,6 +552,26 @@ def test_update_child_qty_rate_with_workflow(self): workflow.is_active = 0 workflow.save() + def test_material_request_for_product_bundle(self): + # Create the Material Request from the sales order for the Packing Items + # Check whether the material request has the correct packing item or not. + if not frappe.db.exists("Item", "_Test Product Bundle Item New 1"): + bundle_item = make_item("_Test Product Bundle Item New 1", {"is_stock_item": 0}) + bundle_item.append( + "item_defaults", {"company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC"} + ) + bundle_item.save(ignore_permissions=True) + + make_item("_Packed Item New 2", {"is_stock_item": 1}) + make_product_bundle("_Test Product Bundle Item New 1", ["_Packed Item New 2"], 2) + + so = make_sales_order( + item_code="_Test Product Bundle Item New 1", + ) + + mr = make_material_request(so.name) + self.assertEqual(mr.items[0].item_code, "_Packed Item New 2") + def test_bin_details_of_packed_item(self): # test Update Items with product bundle if not frappe.db.exists("Item", "_Test Product Bundle Item New"): @@ -1978,6 +1998,61 @@ def test_packed_items_for_partial_sales_order(self): self.assertEqual(len(dn.packed_items), 1) self.assertEqual(dn.items[0].item_code, "_Test Product Bundle Item Partial 2") + @change_settings("Selling Settings", {"editable_bundle_item_rates": 1}) + def test_expired_rate_for_packed_item(self): + bundle = "_Test Product Bundle 1" + packed_item = "_Packed Item 1" + + # test Update Items with product bundle + for product_bundle in [bundle]: + if not frappe.db.exists("Item", product_bundle): + bundle_item = make_item(product_bundle, {"is_stock_item": 0}) + bundle_item.append( + "item_defaults", {"company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC"} + ) + bundle_item.save(ignore_permissions=True) + + for product_bundle in [packed_item]: + if not frappe.db.exists("Item", product_bundle): + make_item(product_bundle, {"is_stock_item": 0, "stock_uom": "Nos"}) + + make_product_bundle(bundle, [packed_item], 1) + + for scenario in [ + {"valid_upto": add_days(nowdate(), -1), "expected_rate": 0.0}, + {"valid_upto": add_days(nowdate(), 1), "expected_rate": 111.0}, + ]: + with self.subTest(scenario=scenario): + frappe.get_doc( + { + "doctype": "Item Price", + "item_code": packed_item, + "selling": 1, + "price_list": "_Test Price List", + "valid_from": add_days(nowdate(), -1), + "valid_upto": scenario.get("valid_upto"), + "price_list_rate": 111, + } + ).save() + + so = frappe.new_doc("Sales Order") + so.transaction_date = nowdate() + so.delivery_date = nowdate() + so.set_warehouse = "" + so.company = "_Test Company" + so.customer = "_Test Customer" + so.currency = "INR" + so.selling_price_list = "_Test Price List" + so.append("items", {"item_code": bundle, "qty": 1}) + so.save() + + self.assertEqual(len(so.items), 1) + self.assertEqual(len(so.packed_items), 1) + self.assertEqual(so.items[0].item_code, bundle) + self.assertEqual(so.packed_items[0].item_code, packed_item) + self.assertEqual(so.items[0].rate, scenario.get("expected_rate")) + self.assertEqual(so.packed_items[0].rate, scenario.get("expected_rate")) + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") @@ -2003,7 +2078,7 @@ def make_sales_order(**args): so.company = args.company or "_Test Company" so.customer = args.customer or "_Test Customer" so.currency = args.currency or "INR" - so.po_no = args.po_no or "12345" + so.po_no = args.po_no or "" if args.selling_price_list: so.selling_price_list = args.selling_price_list diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 50ae3a3f1a92..e97141130950 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -66,6 +66,7 @@ "total_weight", "column_break_21", "weight_uom", + "accounting_dimensions_section", "warehouse_and_reference", "warehouse", "target_warehouse", @@ -82,6 +83,7 @@ "actual_qty", "ordered_qty", "planned_qty", + "production_plan_qty", "column_break_69", "work_order_qty", "delivered_qty", @@ -860,12 +862,25 @@ "fieldname": "material_request_item", "fieldtype": "Data", "label": "Material Request Item" + }, + { + "fieldname": "production_plan_qty", + "fieldtype": "Float", + "label": "Production Plan Qty", + "no_copy": 1, + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2022-12-25 02:51:10.247569", + "modified": "2023-10-17 18:18:26.475259", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", @@ -876,4 +891,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 45ad7d95a155..46bdcfa5f15c 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -20,6 +20,7 @@ "editable_price_list_rate", "validate_selling_price", "editable_bundle_item_rates", + "allow_negative_rates_for_items", "sales_transactions_settings_section", "so_required", "dn_required", @@ -84,7 +85,7 @@ "fieldname": "sales_update_frequency", "fieldtype": "Select", "label": "Sales Update Frequency in Company and Project", - "options": "Each Transaction\nDaily\nMonthly", + "options": "Monthly\nEach Transaction\nDaily", "reqd": 1 }, { @@ -186,6 +187,12 @@ "fieldname": "over_order_allowance", "fieldtype": "Float", "label": "Over Order Allowance (%)" + }, + { + "default": "0", + "fieldname": "allow_negative_rates_for_items", + "fieldtype": "Check", + "label": "Allow Negative rates for Items" } ], "icon": "fa fa-cog", @@ -193,7 +200,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-03-03 11:16:54.333615", + "modified": "2023-08-14 20:33:05.693667", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", @@ -222,4 +229,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index e50ce449e45f..6aa400a53c70 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -18,6 +18,7 @@ frappe.ui.form.on("Company", { }); }, setup: function(frm) { + frm.__rename_queue = "long"; erpnext.company.setup_queries(frm); frm.set_query("parent_company", function() { diff --git a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py index e3d281a56456..d4defdf88de6 100644 --- a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py +++ b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py @@ -121,6 +121,7 @@ def test_exchange_rate_via_exchangerate_host(self, mock_get): # Update Currency Exchange Rate settings = frappe.get_single("Currency Exchange Settings") settings.service_provider = "exchangerate.host" + settings.access_key = "12345667890" settings.save() # Update exchange diff --git a/erpnext/setup/doctype/department/department.json b/erpnext/setup/doctype/department/department.json index 5a16bae0f2e6..99deca5c19d4 100644 --- a/erpnext/setup/doctype/department/department.json +++ b/erpnext/setup/doctype/department/department.json @@ -25,18 +25,15 @@ "label": "Department", "oldfieldname": "department_name", "oldfieldtype": "Data", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "parent_department", "fieldtype": "Link", + "ignore_user_permissions": 1, "in_list_view": 1, "label": "Parent Department", - "options": "Department", - "show_days": 1, - "show_seconds": 1 + "options": "Department" }, { "fieldname": "company", @@ -44,9 +41,7 @@ "in_standard_filter": 1, "label": "Company", "options": "Company", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "bold": 1, @@ -54,17 +49,13 @@ "fieldname": "is_group", "fieldtype": "Check", "in_list_view": 1, - "label": "Is Group", - "show_days": 1, - "show_seconds": 1 + "label": "Is Group" }, { "default": "0", "fieldname": "disabled", "fieldtype": "Check", - "label": "Disabled", - "show_days": 1, - "show_seconds": 1 + "label": "Disabled" }, { "fieldname": "lft", @@ -72,9 +63,7 @@ "hidden": 1, "label": "lft", "print_hide": 1, - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "fieldname": "rgt", @@ -82,9 +71,7 @@ "hidden": 1, "label": "rgt", "print_hide": 1, - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "fieldname": "old_parent", @@ -92,22 +79,18 @@ "hidden": 1, "ignore_user_permissions": 1, "label": "Old Parent", - "print_hide": 1, - "show_days": 1, - "show_seconds": 1 + "print_hide": 1 }, { "fieldname": "column_break_3", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" } ], "icon": "fa fa-sitemap", "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-06-10 12:28:00.563272", + "modified": "2023-08-28 17:26:46.826501", "modified_by": "Administrator", "module": "Setup", "name": "Department", @@ -147,12 +130,12 @@ "read": 1, "report": 1, "role": "HR Manager", - "set_user_permissions": 1, "share": 1, "write": 1 } ], "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/setup/doctype/employee/employee.json b/erpnext/setup/doctype/employee/employee.json index 6cb4292226c5..1143ccb7b10e 100644 --- a/erpnext/setup/doctype/employee/employee.json +++ b/erpnext/setup/doctype/employee/employee.json @@ -616,6 +616,7 @@ "fieldname": "relieving_date", "fieldtype": "Date", "label": "Relieving Date", + "no_copy": 1, "mandatory_depends_on": "eval:doc.status == \"Left\"", "oldfieldname": "relieving_date", "oldfieldtype": "Date" @@ -822,7 +823,7 @@ "idx": 24, "image_field": "image", "links": [], - "modified": "2023-03-30 15:57:05.174592", + "modified": "2023-10-04 10:57:05.174592", "modified_by": "Administrator", "module": "Setup", "name": "Employee", @@ -870,4 +871,4 @@ "sort_order": "DESC", "states": [], "title_field": "employee_name" -} \ No newline at end of file +} diff --git a/erpnext/setup/doctype/employee/test_employee.py b/erpnext/setup/doctype/employee/test_employee.py index 071c336326f5..5a693c5effb9 100644 --- a/erpnext/setup/doctype/employee/test_employee.py +++ b/erpnext/setup/doctype/employee/test_employee.py @@ -66,5 +66,8 @@ def make_employee(user, company=None, **kwargs): employee.insert() return employee.name else: - frappe.db.set_value("Employee", {"employee_name": user}, "status", "Active") - return frappe.get_value("Employee", {"employee_name": user}, "name") + employee = frappe.get_doc("Employee", {"employee_name": user}) + employee.update(kwargs) + employee.status = "Active" + employee.save() + return employee.name diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.py b/erpnext/setup/doctype/holiday_list/holiday_list.py index 2ef4e655b2d7..526bc2ba4ac2 100644 --- a/erpnext/setup/doctype/holiday_list/holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/holiday_list.py @@ -6,12 +6,9 @@ from datetime import date import frappe -from babel import Locale from frappe import _, throw from frappe.model.document import Document from frappe.utils import formatdate, getdate, today -from holidays import country_holidays -from holidays.utils import list_supported_countries class OverlapError(frappe.ValidationError): @@ -40,6 +37,8 @@ def get_weekly_off_dates(self): @frappe.whitelist() def get_supported_countries(self): + from holidays.utils import list_supported_countries + subdivisions_by_country = list_supported_countries() countries = [ {"value": country, "label": local_country_name(country)} @@ -52,6 +51,8 @@ def get_supported_countries(self): @frappe.whitelist() def get_local_holidays(self): + from holidays import country_holidays + if not self.country: throw(_("Please select a country")) @@ -169,4 +170,6 @@ def is_holiday(holiday_list, date=None): def local_country_name(country_code: str) -> str: """Return the localized country name for the given country code.""" - return Locale.parse(frappe.local.lang).territories.get(country_code, country_code) + from babel import Locale + + return Locale.parse(frappe.local.lang, sep="-").territories.get(country_code, country_code) diff --git a/erpnext/setup/doctype/holiday_list/test_holiday_list.py b/erpnext/setup/doctype/holiday_list/test_holiday_list.py index 23b08fd11709..7eeb27d864ed 100644 --- a/erpnext/setup/doctype/holiday_list/test_holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/test_holiday_list.py @@ -8,6 +8,8 @@ import frappe from frappe.utils import getdate +from erpnext.setup.doctype.holiday_list.holiday_list import local_country_name + class TestHolidayList(unittest.TestCase): def test_holiday_list(self): @@ -58,6 +60,16 @@ def test_local_holidays(self): self.assertIn(date(2023, 4, 10), holidays) self.assertNotIn(date(2023, 5, 1), holidays) + def test_localized_country_names(self): + lang = frappe.local.lang + frappe.local.lang = "en-gb" + self.assertEqual(local_country_name("IN"), "India") + self.assertEqual(local_country_name("DE"), "Germany") + + frappe.local.lang = "de" + self.assertEqual(local_country_name("DE"), "Deutschland") + frappe.local.lang = lang + def make_holiday_list( name, from_date=getdate() - timedelta(days=10), to_date=getdate(), holiday_dates=None diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index f5432c182582..cc67c696b437 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -24,9 +24,6 @@ class ItemGroup(NestedSet, WebsiteGenerator): no_breadcrumbs=1, ) - def autoname(self): - self.name = self.item_group_name - def validate(self): super(ItemGroup, self).validate() @@ -76,7 +73,7 @@ def make_route(self): return self.route def on_trash(self): - NestedSet.on_trash(self) + NestedSet.on_trash(self, allow_root_deletion=True) WebsiteGenerator.on_trash(self) self.delete_child_item_groups_key() diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 1e047d1e4dd3..71b1ca7c0586 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -33,6 +33,7 @@ def after_install(): add_app_name() setup_log_settings() hide_workspaces() + update_roles() frappe.db.commit() @@ -214,6 +215,12 @@ def hide_workspaces(): frappe.db.set_value("Workspace", ws, "public", 0) +def update_roles(): + website_user_roles = ("Customer", "Supplier") + for role in website_user_roles: + frappe.db.set_value("Role", role, "desk_access", 0) + + def create_default_role_profiles(): for role_profile_name, roles in DEFAULT_ROLE_PROFILES.items(): role_profile = frappe.new_doc("Role Profile") diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index 54bd8c355d6b..bab57fe267ab 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -81,6 +81,11 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No if entries: return flt(entries[0].exchange_rate) + if frappe.get_cached_value( + "Currency Exchange Settings", "Currency Exchange Settings", "disabled" + ): + return 0.00 + try: cache = frappe.cache() key = "currency_exchange_rate_{0}:{1}:{2}".format(transaction_date, from_currency, to_currency) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 1843c6e7975b..f377f94a8cf8 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -297,7 +297,9 @@ def get_batch_no(item_code, warehouse, qty=1, throw=False, serial_no=None): frappe.msgprint( _( "Please select a Batch for Item {0}. Unable to find a single batch that fulfills this requirement" - ).format(frappe.bold(item_code)) + ).format(frappe.bold(item_code)), + indicator="yellow", + alert=(not throw), ) if throw: raise UnableToSelectBatchError diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index ff0e1b66bf8b..11f2cafc35d5 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -1252,6 +1252,7 @@ "depends_on": "eval: doc.is_internal_customer", "fieldname": "set_target_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "in_standard_filter": 1, "label": "Set Target Warehouse", "no_copy": 1, @@ -1399,7 +1400,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2023-06-16 14:58:55.066602", + "modified": "2023-09-04 14:15:28.363184", "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 3a056500b542..b18ee9943c7e 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -139,8 +139,12 @@ def validate(self): self.validate_uom_is_integer("uom", "qty") self.validate_with_previous_doc() + if self.get("_action") == "submit": + self.validate_duplicate_serial_nos() + from erpnext.stock.doctype.packed_item.packed_item import make_packing_list + self.set_product_bundle_reference_in_packed_items() # should be called before `make_packing_list` make_packing_list(self) if self._action != "submit" and not self.is_return: @@ -412,6 +416,32 @@ def get_product_bundle_list(self): pluck="name", ) + def validate_duplicate_serial_nos(self): + serial_nos = [] + for item in self.items: + if not item.serial_no: + continue + + for serial_no in item.serial_no.split("\n"): + if serial_no in serial_nos: + frappe.throw( + _("Row #{0}: Serial No {1} is already selected.").format(item.idx, serial_no), + title=_("Duplicate Serial No"), + ) + else: + serial_nos.append(serial_no) + + def set_product_bundle_reference_in_packed_items(self): + if self.packed_items and ((self.is_return and self.return_against) or self.amended_from): + if items_ref_map := { + item.dn_detail or item.get("_amended_from"): item.name + for item in self.items + if item.dn_detail or item.get("_amended_from") + }: + for item in self.packed_items: + if item.parent_detail_docname in items_ref_map: + item.parent_detail_docname = items_ref_map[item.parent_detail_docname] + def update_billed_amount_based_on_so(so_detail, update_modified=True): from frappe.query_builder.functions import Sum diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py index b6b5ff4296f0..d4a574da73f2 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py @@ -14,6 +14,9 @@ def get_data(): "Material Request": ["items", "material_request"], "Purchase Order": ["items", "purchase_order"], }, + "internal_and_external_links": { + "Sales Invoice": ["items", "against_sales_invoice"], + }, "transactions": [ {"label": _("Related"), "items": ["Sales Invoice", "Packing Slip", "Delivery Trip"]}, {"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]}, diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 5d8efd5d9dc7..c6f3197a6686 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -5,11 +5,12 @@ import json import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, cstr, flt, nowdate, nowtime, today from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.accounts.utils import get_balance_on +from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.selling.doctype.sales_order.test_sales_order import ( automatically_fetch_payment_terms, @@ -268,8 +269,6 @@ def test_sales_return_for_non_bundled_items_partial(self): self.assertEqual(dn.items[0].returned_qty, 2) self.assertEqual(dn.per_returned, 40) - from erpnext.controllers.sales_and_purchase_return import make_return_doc - return_dn_2 = make_return_doc("Delivery Note", dn.name) # Check if unreturned amount is mapped in 2nd return @@ -361,8 +360,6 @@ def test_delivery_note_return_valuation_on_different_warehuose(self): dn.submit() self.assertEqual(dn.items[0].incoming_rate, 150) - from erpnext.controllers.sales_and_purchase_return import make_return_doc - return_dn = make_return_doc(dn.doctype, dn.name) return_dn.items[0].warehouse = return_warehouse return_dn.save().submit() @@ -728,7 +725,7 @@ def test_closed_delivery_note(self): def test_dn_billing_status_case1(self): # SO -> DN -> SI - so = make_sales_order() + so = make_sales_order(po_no="12345") dn = create_dn_against_so(so.name, delivered_qty=2) self.assertEqual(dn.status, "To Bill") @@ -755,7 +752,7 @@ def test_dn_billing_status_case2(self): make_sales_invoice, ) - so = make_sales_order() + so = make_sales_order(po_no="12345") si = make_sales_invoice(so.name) si.get("items")[0].qty = 5 @@ -799,7 +796,7 @@ def test_dn_billing_status_case3(self): frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) - so = make_sales_order() + so = make_sales_order(po_no="12345") dn1 = make_delivery_note(so.name) dn1.get("items")[0].qty = 2 @@ -845,7 +842,7 @@ def test_dn_billing_status_case4(self): from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice - so = make_sales_order() + so = make_sales_order(po_no="12345") si = make_sales_invoice(so.name) si.submit() @@ -1182,7 +1179,6 @@ def test_internal_transfer_precision_gle(self): ) def test_batch_expiry_for_delivery_note(self): - from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt item = make_item( @@ -1211,6 +1207,114 @@ def test_batch_expiry_for_delivery_note(self): self.assertTrue(return_dn.docstatus == 1) + def test_duplicate_serial_no_in_delivery_note(self): + # Step - 1: Create Serial Item + serial_item = make_item( + properties={ + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": frappe.generate_hash("", 10) + ".###", + } + ).name + + # Step - 2: Inward Stock + se = make_stock_entry(item_code=serial_item, target="_Test Warehouse - _TC", qty=4) + + # Step - 3: Create Delivery Note with Duplicare Serial Nos + serial_nos = se.items[0].serial_no.split("\n") + dn = create_delivery_note( + item_code=serial_item, + warehouse="_Test Warehouse - _TC", + qty=2, + do_not_save=True, + ) + dn.items[0].serial_no = "\n".join(serial_nos[:2]) + dn.append("items", dn.items[0].as_dict()) + dn.save() + + # Test - 1: ValidationError should be raised + self.assertRaises(frappe.ValidationError, dn.submit) + + def test_packed_items_for_return_delivery_note(self): + # Step - 1: Create Items + product_bundle_item = make_item(properties={"is_stock_item": 0}).name + batch_item = make_item( + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-.#####", + } + ).name + serial_item = make_item( + properties={"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TEST-SERIAL-.#####"} + ).name + + # Step - 2: Inward Stock + se1 = make_stock_entry(item_code=batch_item, target="_Test Warehouse - _TC", qty=3) + serial_nos = ( + make_stock_entry(item_code=serial_item, target="_Test Warehouse - _TC", qty=3) + .items[0] + .serial_no + ) + + # Step - 3: Create a Product Bundle + from erpnext.stock.doctype.stock_ledger_entry.test_stock_ledger_entry import ( + create_product_bundle_item, + ) + + create_product_bundle_item(product_bundle_item, packed_items=[[batch_item, 1], [serial_item, 1]]) + + # Step - 4: Create a Delivery Note for the Product Bundle + dn = create_delivery_note( + item_code=product_bundle_item, + warehouse="_Test Warehouse - _TC", + qty=3, + do_not_submit=True, + ) + dn.packed_items[1].serial_no = serial_nos + dn.save() + dn.submit() + + # Step - 5: Create a Return Delivery Note(Sales Return) + return_dn = make_return_doc(dn.doctype, dn.name) + return_dn.save() + return_dn.submit() + + self.assertEqual(return_dn.packed_items[0].batch_no, dn.packed_items[0].batch_no) + self.assertEqual(return_dn.packed_items[1].serial_no, dn.packed_items[1].serial_no) + + @change_settings("Stock Settings", {"automatically_set_serial_nos_based_on_fifo": 1}) + def test_delivery_note_for_repetitive_serial_item(self): + # Step - 1: Create Serial Item + item, warehouse = ( + make_item( + properties={"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TEST-SERIAL-.###"} + ).name, + "_Test Warehouse - _TC", + ) + + # Step - 2: Inward Stock + make_stock_entry(item_code=item, target=warehouse, qty=5) + + # Step - 3: Create Delivery Note with repetitive Serial Item + dn = create_delivery_note(item_code=item, warehouse=warehouse, qty=2, do_not_save=True) + dn.append("items", dn.items[0].as_dict()) + dn.items[1].qty = 3 + dn.save() + dn.submit() + + # Test - 1: Serial Nos should be different for each line item + serial_nos = [] + for item in dn.items: + for serial_no in item.serial_no.split("\n"): + self.assertNotIn(serial_no, serial_nos) + serial_nos.append(serial_no) + + def tearDown(self): + frappe.db.rollback() + frappe.db.set_single_value("Selling Settings", "dont_reserve_sales_order_qty_on_sales_return", 0) + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") 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 edfb269da9ae..237088f64a74 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -741,7 +741,8 @@ "label": "Against Delivery Note Item", "no_copy": 1, "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "stock_qty_sec_break", @@ -868,7 +869,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-07-25 11:58:28.101919", + "modified": "2023-10-16 16:18:18.013379", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js index 0310682a2c17..35d1c02719cc 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js @@ -37,7 +37,7 @@ frappe.ui.form.on('Inventory Dimension', { if (frm.doc.__onload && frm.doc.__onload.has_stock_ledger && frm.doc.__onload.has_stock_ledger.length) { let allow_to_edit_fields = ['disabled', 'fetch_from_parent', - 'type_of_transaction', 'condition', 'mandatory_depends_on']; + 'type_of_transaction', 'condition', 'mandatory_depends_on', 'validate_negative_stock']; frm.fields.forEach((field) => { if (!in_list(allow_to_edit_fields, field.df.fieldname)) { diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json index eb6102a436e3..0e4055251f0c 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json @@ -17,6 +17,8 @@ "target_fieldname", "applicable_for_documents_tab", "apply_to_all_doctypes", + "column_break_niy2u", + "validate_negative_stock", "column_break_13", "document_type", "type_of_transaction", @@ -173,11 +175,21 @@ "fieldname": "reqd", "fieldtype": "Check", "label": "Mandatory" + }, + { + "fieldname": "column_break_niy2u", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "validate_negative_stock", + "fieldtype": "Check", + "label": "Validate Negative Stock" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-01-31 13:44:38.507698", + "modified": "2023-10-05 12:52:18.705431", "modified_by": "Administrator", "module": "Stock", "name": "Inventory Dimension", diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index 8bff4d514709..257d18fc33a2 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -60,6 +60,7 @@ def do_not_update_document(self): "fetch_from_parent", "type_of_transaction", "condition", + "validate_negative_stock", ] for field in frappe.get_meta("Inventory Dimension").fields: @@ -160,6 +161,7 @@ def get_dimension_fields(self, doctype=None): insert_after="inventory_dimension", options=self.reference_document, label=label, + search_index=1, reqd=self.reqd, mandatory_depends_on=self.mandatory_depends_on, ), @@ -255,7 +257,7 @@ def field_exists(doctype, fieldname) -> str or None: def get_inventory_documents( doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None ): - and_filters = [["DocField", "parent", "not in", ["Batch", "Serial No"]]] + and_filters = [["DocField", "parent", "not in", ["Batch", "Serial No", "Item Price"]]] or_filters = [ ["DocField", "options", "in", ["Batch", "Serial No"]], ["DocField", "parent", "in", ["Putaway Rule"]], @@ -340,6 +342,7 @@ def get_inventory_dimensions(): fields=[ "distinct target_fieldname as fieldname", "reference_document as doctype", + "validate_negative_stock", ], filters={"disabled": 0}, ) diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index b1d7f8f00c6f..531bc3f109f1 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -414,6 +414,53 @@ def test_inter_transfer_return_against_inventory_dimension(self): else: self.assertEqual(d.store, "Inter Transfer Store 2") + def test_validate_negative_stock_for_inventory_dimension(self): + frappe.local.inventory_dimensions = {} + item_code = "Test Negative Inventory Dimension Item" + frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1) + create_item(item_code) + + inv_dimension = create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Inv Site", + reference_document="Inv Site", + document_type="Inv Site", + validate_negative_stock=1, + ) + + warehouse = create_warehouse("Negative Stock Warehouse") + doc = make_stock_entry(item_code=item_code, target=warehouse, qty=10, do_not_submit=True) + + doc.items[0].to_inv_site = "Site 1" + doc.submit() + + site_name = frappe.get_all( + "Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"] + )[0].inv_site + + self.assertEqual(site_name, "Site 1") + + doc = make_stock_entry(item_code=item_code, source=warehouse, qty=100, do_not_submit=True) + + doc.items[0].inv_site = "Site 1" + self.assertRaises(frappe.ValidationError, doc.submit) + + inv_dimension.reload() + inv_dimension.db_set("validate_negative_stock", 0) + frappe.local.inventory_dimensions = {} + + doc = make_stock_entry(item_code=item_code, source=warehouse, qty=100, do_not_submit=True) + + doc.items[0].inv_site = "Site 1" + doc.submit() + self.assertEqual(doc.docstatus, 1) + + site_name = frappe.get_all( + "Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"] + )[0].inv_site + + self.assertEqual(site_name, "Site 1") + def get_voucher_sl_entries(voucher_no, fields): return frappe.get_all( @@ -504,6 +551,26 @@ def prepare_test_data(): } ).insert(ignore_permissions=True) + if not frappe.db.exists("DocType", "Inv Site"): + frappe.get_doc( + { + "doctype": "DocType", + "name": "Inv Site", + "module": "Stock", + "custom": 1, + "naming_rule": "By fieldname", + "autoname": "field:site_name", + "fields": [{"label": "Site Name", "fieldname": "site_name", "fieldtype": "Data"}], + "permissions": [ + {"role": "System Manager", "permlevel": 0, "read": 1, "write": 1, "create": 1, "delete": 1} + ], + } + ).insert(ignore_permissions=True) + + for site in ["Site 1", "Site 2"]: + if not frappe.db.exists("Inv Site", site): + frappe.get_doc({"doctype": "Inv Site", "site_name": site}).insert(ignore_permissions=True) + def create_inventory_dimension(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 9a9ddf440443..b306a41bb83d 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -3,6 +3,9 @@ frappe.provide("erpnext.item"); +const SALES_DOCTYPES = ['Quotation', 'Sales Order', 'Delivery Note', 'Sales Invoice']; +const PURCHASE_DOCTYPES = ['Purchase Order', 'Purchase Receipt', 'Purchase Invoice']; + frappe.ui.form.on("Item", { setup: function(frm) { frm.add_fetch('attribute', 'numeric_values', 'numeric_values'); @@ -347,18 +350,20 @@ $.extend(erpnext.item, { } } - frm.fields_dict['deferred_revenue_account'].get_query = function() { + frm.fields_dict["item_defaults"].grid.get_field("deferred_revenue_account").get_query = function(doc, cdt, cdn) { return { filters: { + "company": locals[cdt][cdn].company, 'root_type': 'Liability', "is_group": 0 } } } - frm.fields_dict['deferred_expense_account'].get_query = function() { + frm.fields_dict["item_defaults"].grid.get_field("deferred_expense_account").get_query = function(doc, cdt, cdn) { return { filters: { + "company": locals[cdt][cdn].company, 'root_type': 'Asset', "is_group": 0 } @@ -894,7 +899,13 @@ function open_form(frm, doctype, child_doctype, parentfield) { let new_child_doc = frappe.model.add_child(new_doc, child_doctype, parentfield); new_child_doc.item_code = frm.doc.name; new_child_doc.item_name = frm.doc.item_name; - new_child_doc.uom = frm.doc.stock_uom; + if (in_list(SALES_DOCTYPES, doctype) && frm.doc.sales_uom) { + new_child_doc.uom = frm.doc.sales_uom; + } else if (in_list(PURCHASE_DOCTYPES, doctype) && frm.doc.purchase_uom) { + new_child_doc.uom = frm.doc.purchase_uom; + } else { + new_child_doc.uom = frm.doc.stock_uom; + } new_child_doc.description = frm.doc.description; if (!new_child_doc.qty) { new_child_doc.qty = 1.0; diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 7f4ba032e860..7d0a387f43ef 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -70,6 +70,13 @@ "variant_based_on", "attributes", "accounting", + "deferred_accounting_section", + "enable_deferred_expense", + "no_of_months_exp", + "column_break_9s9o", + "enable_deferred_revenue", + "no_of_months", + "section_break_avcp", "item_defaults", "purchasing_tab", "purchase_uom", @@ -85,10 +92,6 @@ "delivered_by_supplier", "column_break2", "supplier_items", - "deferred_expense_section", - "enable_deferred_expense", - "deferred_expense_account", - "no_of_months_exp", "foreign_trade_details", "country_of_origin", "column_break_59", @@ -99,10 +102,6 @@ "is_sales_item", "column_break3", "max_discount", - "deferred_revenue", - "enable_deferred_revenue", - "deferred_revenue_account", - "no_of_months", "customer_details", "customer_items", "item_tax_section_break", @@ -657,20 +656,6 @@ "oldfieldname": "max_discount", "oldfieldtype": "Currency" }, - { - "collapsible": 1, - "fieldname": "deferred_revenue", - "fieldtype": "Section Break", - "label": "Deferred Revenue" - }, - { - "depends_on": "enable_deferred_revenue", - "fieldname": "deferred_revenue_account", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "label": "Deferred Revenue Account", - "options": "Account" - }, { "default": "0", "fieldname": "enable_deferred_revenue", @@ -681,21 +666,7 @@ "depends_on": "enable_deferred_revenue", "fieldname": "no_of_months", "fieldtype": "Int", - "label": "No of Months" - }, - { - "collapsible": 1, - "fieldname": "deferred_expense_section", - "fieldtype": "Section Break", - "label": "Deferred Expense" - }, - { - "depends_on": "enable_deferred_expense", - "fieldname": "deferred_expense_account", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "label": "Deferred Expense Account", - "options": "Account" + "label": "No of Months (Revenue)" }, { "default": "0", @@ -904,6 +875,20 @@ "fieldname": "accounting", "fieldtype": "Tab Break", "label": "Accounting" + }, + { + "fieldname": "column_break_9s9o", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_avcp", + "fieldtype": "Section Break" + }, + { + "collapsible": 1, + "fieldname": "deferred_accounting_section", + "fieldtype": "Section Break", + "label": "Deferred Accounting" } ], "icon": "fa fa-tag", @@ -912,7 +897,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2023-07-14 17:18:18.658942", + "modified": "2023-09-11 13:46:32.688051", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index ba1c04fe27ed..693d33ffb713 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -395,16 +395,16 @@ def validate_barcode(self): def validate_warehouse_for_reorder(self): """Validate Reorder level table for duplicate and conditional mandatory""" - warehouse = [] + warehouse_material_request_type: list[tuple[str, str]] = [] for d in self.get("reorder_levels"): if not d.warehouse_group: d.warehouse_group = d.warehouse - if d.get("warehouse") and d.get("warehouse") not in warehouse: - warehouse += [d.get("warehouse")] + if (d.get("warehouse"), d.get("material_request_type")) not in warehouse_material_request_type: + warehouse_material_request_type += [(d.get("warehouse"), d.get("material_request_type"))] else: frappe.throw( - _("Row {0}: An Reorder entry already exists for this warehouse {1}").format( - d.idx, d.warehouse + _("Row #{0}: A reorder entry already exists for warehouse {1} with reorder type {2}.").format( + d.idx, d.warehouse, d.material_request_type ), DuplicateReorderRows, ) diff --git a/erpnext/stock/doctype/item_default/item_default.json b/erpnext/stock/doctype/item_default/item_default.json index 042d398256a5..28956612762b 100644 --- a/erpnext/stock/doctype/item_default/item_default.json +++ b/erpnext/stock/doctype/item_default/item_default.json @@ -19,7 +19,11 @@ "selling_defaults", "selling_cost_center", "column_break_12", - "income_account" + "income_account", + "deferred_accounting_defaults_section", + "deferred_expense_account", + "column_break_kwad", + "deferred_revenue_account" ], "fields": [ { @@ -108,11 +112,34 @@ "fieldtype": "Link", "label": "Default Provisional Account", "options": "Account" + }, + { + "fieldname": "deferred_accounting_defaults_section", + "fieldtype": "Section Break", + "label": "Deferred Accounting Defaults" + }, + { + "depends_on": "eval: parent.enable_deferred_expense", + "fieldname": "deferred_expense_account", + "fieldtype": "Link", + "label": "Deferred Expense Account", + "options": "Account" + }, + { + "depends_on": "eval: parent.enable_deferred_revenue", + "fieldname": "deferred_revenue_account", + "fieldtype": "Link", + "label": "Deferred Revenue Account", + "options": "Account" + }, + { + "fieldname": "column_break_kwad", + "fieldtype": "Column Break" } ], "istable": 1, "links": [], - "modified": "2022-04-10 20:18:54.148195", + "modified": "2023-09-04 12:33:14.607267", "modified_by": "Administrator", "module": "Stock", "name": "Item Default", diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 111a0861b719..7f0dc2df9f32 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -6,6 +6,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.meta import get_field_precision +from frappe.query_builder.custom import ConstantColumn from frappe.utils import flt import erpnext @@ -19,19 +20,7 @@ def get_items_from_purchase_receipts(self): self.set("items", []) for pr in self.get("purchase_receipts"): if pr.receipt_document_type and pr.receipt_document: - pr_items = frappe.db.sql( - """select pr_item.item_code, pr_item.description, - pr_item.qty, pr_item.base_rate, pr_item.base_amount, pr_item.name, - pr_item.cost_center, pr_item.is_fixed_asset - from `tab{doctype} Item` pr_item where parent = %s - and exists(select name from tabItem - where name = pr_item.item_code and (is_stock_item = 1 or is_fixed_asset=1)) - """.format( - doctype=pr.receipt_document_type - ), - pr.receipt_document, - as_dict=True, - ) + pr_items = get_pr_items(pr) for d in pr_items: item = self.append("items") @@ -247,3 +236,30 @@ def update_rate_in_serial_no_for_non_asset_items(self, receipt_document): ), tuple([item.valuation_rate] + serial_nos), ) + + +def get_pr_items(purchase_receipt): + item = frappe.qb.DocType("Item") + pr_item = frappe.qb.DocType(purchase_receipt.receipt_document_type + " Item") + return ( + frappe.qb.from_(pr_item) + .inner_join(item) + .on(item.name == pr_item.item_code) + .select( + pr_item.item_code, + pr_item.description, + pr_item.qty, + pr_item.base_rate, + pr_item.base_amount, + pr_item.name, + pr_item.cost_center, + pr_item.is_fixed_asset, + ConstantColumn(purchase_receipt.receipt_document_type).as_("receipt_document_type"), + ConstantColumn(purchase_receipt.receipt_document).as_("receipt_document"), + ) + .where( + (pr_item.parent == purchase_receipt.receipt_document) + & ((item.is_stock_item == 1) | (item.is_fixed_asset == 1)) + ) + .run(as_dict=True) + ) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index b096b024f44c..ec075bb6bad8 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -102,6 +102,12 @@ frappe.ui.form.on('Material Request', { if (frm.doc.docstatus == 1 && frm.doc.status != 'Stopped') { let precision = frappe.defaults.get_default("float_precision"); + + if (flt(frm.doc.per_received, precision) < 100) { + frm.add_custom_button(__('Stop'), + () => frm.events.update_status(frm, 'Stopped')); + } + if (flt(frm.doc.per_ordered, precision) < 100) { let add_create_pick_list_button = () => { frm.add_custom_button(__('Pick List'), @@ -148,11 +154,6 @@ frappe.ui.form.on('Material Request', { } frm.page.set_inner_btn_group_as_primary(__('Create')); - - // stop - frm.add_custom_button(__('Stop'), - () => frm.events.update_status(frm, 'Stopped')); - } } @@ -218,7 +219,8 @@ frappe.ui.form.on('Material Request', { plc_conversion_rate: 1, rate: item.rate, uom: item.uom, - conversion_factor: item.conversion_factor + conversion_factor: item.conversion_factor, + project: item.project, }, overwrite_warehouse: overwrite_warehouse }, diff --git a/erpnext/stock/doctype/material_request/material_request.json b/erpnext/stock/doctype/material_request/material_request.json index ffec57ca1dfc..25c765bbced3 100644 --- a/erpnext/stock/doctype/material_request/material_request.json +++ b/erpnext/stock/doctype/material_request/material_request.json @@ -296,6 +296,7 @@ "depends_on": "eval:doc.material_request_type == 'Material Transfer'", "fieldname": "set_from_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Set Source Warehouse", "options": "Warehouse" }, @@ -356,7 +357,7 @@ "idx": 70, "is_submittable": 1, "links": [], - "modified": "2023-07-25 17:19:31.662662", + "modified": "2023-09-15 12:07:24.789471", "modified_by": "Administrator", "module": "Stock", "name": "Material Request", diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index b430d03d92b8..311b0ed8ce9a 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -223,12 +223,14 @@ def update_completed_qty(self, mr_items=None, update_modified=True): mr_qty_allowance = frappe.db.get_single_value("Stock Settings", "mr_qty_allowance") for d in self.get("items"): + precision = d.precision("ordered_qty") if d.name in mr_items: if self.material_request_type in ("Material Issue", "Material Transfer", "Customer Provided"): d.ordered_qty = flt(mr_items_ordered_qty.get(d.name)) if mr_qty_allowance: - allowed_qty = d.qty + (d.qty * (mr_qty_allowance / 100)) + allowed_qty = flt((d.qty + (d.qty * (mr_qty_allowance / 100))), precision) + if d.ordered_qty and d.ordered_qty > allowed_qty: frappe.throw( _( @@ -236,11 +238,11 @@ def update_completed_qty(self, mr_items=None, update_modified=True): ).format(d.ordered_qty, d.parent, allowed_qty, d.item_code) ) - elif d.ordered_qty and d.ordered_qty > d.stock_qty: + elif d.ordered_qty and flt(d.ordered_qty, precision) > flt(d.stock_qty, precision): frappe.throw( _( "The total Issue / Transfer quantity {0} in Material Request {1} cannot be greater than requested quantity {2} for Item {3}" - ).format(d.ordered_qty, d.parent, d.qty, d.item_code) + ).format(d.ordered_qty, d.parent, d.stock_qty, d.item_code) ) elif self.material_request_type == "Manufacture": @@ -658,7 +660,10 @@ def set_missing_values(source, target): "job_card_item": "job_card_item", }, "postprocess": update_item, - "condition": lambda doc: doc.ordered_qty < doc.stock_qty, + "condition": lambda doc: ( + flt(doc.ordered_qty, doc.precision("ordered_qty")) + < flt(doc.stock_qty, doc.precision("ordered_qty")) + ), }, }, target_doc, @@ -701,6 +706,7 @@ def raise_work_orders(material_request): ) wo_order.set_work_order_operations() + wo_order.flags.ignore_mandatory = True wo_order.save() work_orders.append(wo_order.name) diff --git a/erpnext/stock/doctype/material_request/material_request_dashboard.py b/erpnext/stock/doctype/material_request/material_request_dashboard.py index 2bba52a4e253..f91ea6a0bba5 100644 --- a/erpnext/stock/doctype/material_request/material_request_dashboard.py +++ b/erpnext/stock/doctype/material_request/material_request_dashboard.py @@ -6,6 +6,8 @@ def get_data(): "fieldname": "material_request", "internal_links": { "Sales Order": ["items", "sales_order"], + "Project": ["items", "project"], + "Cost Center": ["items", "cost_center"], }, "transactions": [ { @@ -15,5 +17,6 @@ def get_data(): {"label": _("Stock"), "items": ["Stock Entry", "Purchase Receipt", "Pick List"]}, {"label": _("Manufacturing"), "items": ["Work Order"]}, {"label": _("Internal Transfer"), "items": ["Sales Order"]}, + {"label": _("Accounting Dimensions"), "items": ["Project", "Cost Center"]}, ], } diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index c5fb2411c281..679c6c149e96 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -192,7 +192,6 @@ "fieldtype": "Data", "hidden": 1, "label": "Parent Detail docname", - "no_copy": 1, "oldfieldname": "parent_detail_docname", "oldfieldtype": "Data", "print_hide": 1, @@ -259,7 +258,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-04-28 13:16:38.460806", + "modified": "2023-10-14 23:26:11.755425", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index dbd8de4fcb0e..a9e9ad1a639f 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -207,6 +207,9 @@ def update_packed_item_price_data(pi_row, item_data, doc): "conversion_rate": doc.get("conversion_rate"), } ) + if not row_data.get("transaction_date"): + row_data.update({"transaction_date": doc.get("transaction_date")}) + rate = get_price_list_rate(row_data, item_doc).get("price_list_rate") pi_row.rate = rate or item_data.get("valuation_rate") or 0.0 diff --git a/erpnext/stock/doctype/price_list/price_list.py b/erpnext/stock/doctype/price_list/price_list.py index 554055fd839a..e77d53a36712 100644 --- a/erpnext/stock/doctype/price_list/price_list.py +++ b/erpnext/stock/doctype/price_list/price_list.py @@ -45,7 +45,7 @@ def check_impact_on_shopping_cart(self): doc_before_save = self.get_doc_before_save() currency_changed = self.currency != doc_before_save.currency - affects_cart = self.name == frappe.get_cached_value("E Commerce Settings", None, "price_list") + affects_cart = self.name == frappe.db.get_single_value("E Commerce Settings", "price_list") if currency_changed and affects_cart: validate_cart_settings() diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 312c166f8b7f..8966fbcbb3c3 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -35,6 +35,12 @@ frappe.ui.form.on("Purchase Receipt", { } }); + frm.set_query("wip_composite_asset", "items", function() { + return { + filters: {'is_composite_asset': 1, 'docstatus': 0 } + } + }); + frm.set_query("taxes_and_charges", function() { return { filters: {'company': frm.doc.company } diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index fec98c2102c9..6eb4a559490e 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -464,6 +464,7 @@ "depends_on": "eval:doc.is_subcontracted", "fieldname": "supplier_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Supplier Warehouse", "no_copy": 1, "oldfieldname": "supplier_warehouse", @@ -1248,7 +1249,7 @@ "idx": 261, "is_submittable": 1, "links": [], - "modified": "2023-07-04 17:24:17.025390", + "modified": "2023-10-01 21:00:44.556816", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index c793529e843c..da534b245c65 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -341,7 +341,7 @@ def make_item_gl_entries(self, gl_entries, warehouse_account=None): exchange_rate_map, net_rate_map = get_purchase_document_details(self) for d in self.get("items"): - if d.item_code in stock_items and flt(d.valuation_rate) and flt(d.qty): + if d.item_code in stock_items and flt(d.qty) and (flt(d.valuation_rate) or self.is_return): if warehouse_account.get(d.warehouse): stock_value_diff = frappe.db.get_value( "Stock Ledger Entry", @@ -472,27 +472,28 @@ def make_item_gl_entries(self, gl_entries, warehouse_account=None): # Amount added through landed-cos-voucher if d.landed_cost_voucher_amount and landed_cost_entries: - for account, amount in landed_cost_entries[(d.item_code, d.name)].items(): - account_currency = get_account_currency(account) - credit_amount = ( - flt(amount["base_amount"]) - if (amount["base_amount"] or account_currency != self.company_currency) - else flt(amount["amount"]) - ) + if (d.item_code, d.name) in landed_cost_entries: + for account, amount in landed_cost_entries[(d.item_code, d.name)].items(): + account_currency = get_account_currency(account) + credit_amount = ( + flt(amount["base_amount"]) + if (amount["base_amount"] or account_currency != self.company_currency) + else flt(amount["amount"]) + ) - self.add_gl_entry( - gl_entries=gl_entries, - account=account, - cost_center=d.cost_center, - debit=0.0, - credit=credit_amount, - remarks=remarks, - against_account=warehouse_account_name, - credit_in_account_currency=flt(amount["amount"]), - account_currency=account_currency, - project=d.project, - item=d, - ) + self.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=d.cost_center, + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=warehouse_account_name, + credit_in_account_currency=flt(amount["amount"]), + account_currency=account_currency, + project=d.project, + item=d, + ) if d.rate_difference_with_purchase_invoice and stock_rbnb: account_currency = get_account_currency(stock_rbnb) @@ -604,7 +605,7 @@ def add_provisional_gl_entry( account=provisional_account, cost_center=item.cost_center, debit=0.0, - credit=multiplication_factor * item.amount, + credit=multiplication_factor * item.base_amount, remarks=remarks, against_account=expense_account, account_currency=credit_currency, @@ -618,7 +619,7 @@ def add_provisional_gl_entry( gl_entries=gl_entries, account=expense_account, cost_center=item.cost_center, - debit=multiplication_factor * item.amount, + debit=multiplication_factor * item.base_amount, credit=0.0, remarks=remarks, against_account=provisional_account, @@ -957,6 +958,10 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate total_amount += total_billable_amount total_billed_amount += flt(item.billed_amt) + + if pr_doc.get("is_return") and not total_amount and total_billed_amount: + total_amount = total_billed_amount + if adjust_incoming_rate: adjusted_amt = 0.0 if item.billed_amt and item.amount: diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 585871cf3910..a93d5b1bbbed 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1258,6 +1258,70 @@ def test_backdated_transaction_for_internal_transfer(self): self.assertEqual(query[0].value, 0) + def test_rejected_qty_for_internal_transfer(self): + 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 + + 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) + to_warehouse = create_warehouse("_Test Internal To Warehouse New", company=company) + rejected_warehouse = create_warehouse( + "_Test Rejected Internal To Warehouse New", company=company + ) + item_doc = make_item( + "Test Internal Transfer Item DS", + { + "is_purchase_item": 1, + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "SBNS.#####", + }, + ) + + target_warehouse = create_warehouse("_Test Internal GIT Warehouse New", company=company) + + pr = make_purchase_receipt( + item_code=item_doc.name, + company=company, + posting_date=add_days(today(), -1), + warehouse=from_warehouse, + qty=2, + rate=100, + ) + + dn1 = create_delivery_note( + item_code=item_doc.name, + company=company, + customer=customer, + serial_no=pr.items[0].serial_no, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=2, + rate=500, + warehouse=from_warehouse, + target_warehouse=target_warehouse, + ) + + sns = get_serial_nos(dn1.items[0].serial_no) + + self.assertEqual(len(sns), 2) + + pr1 = make_inter_company_purchase_receipt(dn1.name) + pr1.items[0].qty = 1.0 + pr1.items[0].rejected_qty = 1.0 + pr1.items[0].serial_no = sns[0] + pr1.items[0].rejected_serial_no = sns[1] + pr1.items[0].warehouse = to_warehouse + pr1.items[0].rejected_warehouse = rejected_warehouse + pr1.submit() + + rejected_serial_no_wh = frappe.get_cached_value("Serial No", sns[1], "warehouse") + + self.assertEqual(rejected_warehouse, rejected_serial_no_wh) + def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_receipt( self, ): @@ -1960,6 +2024,185 @@ def test_purchase_receipt_with_backdated_landed_cost_voucher(self): ste7.reload() self.assertEqual(ste7.items[0].valuation_rate, valuation_rate) + def test_purchase_receipt_provisional_accounting(self): + # Step - 1: Create Supplier with Default Currency as USD + from erpnext.buying.doctype.supplier.test_supplier import create_supplier + + supplier = create_supplier(default_currency="USD") + + # Step - 2: Setup Company for Provisional Accounting + from erpnext.accounts.doctype.account.test_account import create_account + + provisional_account = create_account( + account_name="Provision Account", + parent_account="Current Liabilities - _TC", + company="_Test Company", + ) + company = frappe.get_doc("Company", "_Test Company") + company.enable_provisional_accounting_for_non_stock_items = 1 + company.default_provisional_account = provisional_account + company.save() + + # Step - 3: Create Non-Stock Item + item = make_item(properties={"is_stock_item": 0}) + + # Step - 4: Create Purchase Receipt + pr = make_purchase_receipt( + qty=2, + item_code=item.name, + company=company.name, + supplier=supplier.name, + currency=supplier.default_currency, + ) + + # Test - 1: Total and Base Total should not be the same as the currency is different + self.assertNotEqual(flt(pr.total, 2), flt(pr.base_total, 2)) + self.assertEqual(flt(pr.total * pr.conversion_rate, 2), flt(pr.base_total, 2)) + + # Test - 2: Sum of Debit or Credit should be equal to Purchase Receipt Base Total + amount = frappe.db.get_value("GL Entry", {"docstatus": 1, "voucher_no": pr.name}, ["sum(debit)"]) + expected_amount = pr.base_total + self.assertEqual(amount, expected_amount) + + company.enable_provisional_accounting_for_non_stock_items = 0 + company.save() + + def test_purchase_return_status_with_debit_note(self): + pr = make_purchase_receipt(rejected_qty=10, received_qty=10, rate=100, do_not_save=1) + pr.items[0].qty = 0 + pr.items[0].stock_qty = 0 + pr.submit() + + return_pr = make_purchase_receipt( + is_return=1, + return_against=pr.name, + qty=0, + rejected_qty=10 * -1, + received_qty=10 * -1, + do_not_save=1, + ) + return_pr.items[0].qty = 0.0 + return_pr.items[0].stock_qty = 0.0 + return_pr.submit() + + self.assertEqual(return_pr.status, "To Bill") + + pi = make_purchase_invoice(return_pr.name) + pi.submit() + + return_pr.reload() + self.assertEqual(return_pr.status, "Completed") + + def test_valuation_rate_in_return_purchase_receipt_for_moving_average(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + from erpnext.stock.stock_ledger import get_previous_sle + + # Step - 1: Create an Item (Valuation Method = Moving Average) + item_code = make_item(properties={"is_stock_item": 1, "valuation_method": "Moving Average"}).name + + # Step - 2: Create a Purchase Receipt (Qty = 10, Rate = 100) + pr = make_purchase_receipt(qty=10, rate=100, item_code=item_code) + + # Step - 3: Create a Material Receipt Stock Entry (Qty = 100, Basic Rate = 10) + warehouse = "_Test Warehouse - _TC" + make_stock_entry( + purpose="Material Receipt", + item_code=item_code, + to_warehouse=warehouse, + qty=100, + rate=10, + ) + + # Step - 4: Create a Material Issue Stock Entry (Qty = 100, Basic Rate = 18.18 [Auto Fetched]) + make_stock_entry( + purpose="Material Issue", item_code=item_code, from_warehouse=warehouse, qty=100 + ) + + # Step - 5: Create a Return Purchase Return (Qty = -8, Rate = 100 [Auto fetched]) + return_pr = make_purchase_receipt( + is_return=1, + return_against=pr.name, + item_code=item_code, + qty=-8, + ) + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": return_pr.name, "voucher_detail_no": return_pr.items[0].name}, + ["posting_date", "posting_time", "outgoing_rate", "valuation_rate"], + as_dict=1, + ) + previous_sle_valuation_rate = get_previous_sle( + { + "item_code": item_code, + "warehouse": warehouse, + "posting_date": sle.posting_date, + "posting_time": sle.posting_time, + } + ).get("valuation_rate") + + # Test - 1: Valuation Rate should be equal to Outgoing Rate + self.assertEqual(flt(sle.outgoing_rate, 2), flt(sle.valuation_rate, 2)) + + # Test - 2: Valuation Rate should be equal to Previous SLE Valuation Rate + self.assertEqual(flt(sle.valuation_rate, 2), flt(previous_sle_valuation_rate, 2)) + + def test_purchase_return_with_zero_rate(self): + company = "_Test Company with perpetual inventory" + + # Step - 1: Create Item + item, warehouse = ( + make_item(properties={"is_stock_item": 1, "valuation_method": "Moving Average"}).name, + "Stores - TCP1", + ) + + # Step - 2: Create Stock Entry (Material Receipt) + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + se = make_stock_entry( + purpose="Material Receipt", + item_code=item, + qty=100, + basic_rate=100, + to_warehouse=warehouse, + company=company, + ) + + # Step - 3: Create Purchase Receipt + pr = make_purchase_receipt( + item_code=item, + qty=5, + rate=0, + warehouse=warehouse, + company=company, + ) + + # Step - 4: Create Purchase Return + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + pr_return = make_return_doc("Purchase Receipt", pr.name) + pr_return.save() + pr_return.submit() + + sl_entries = get_sl_entries(pr_return.doctype, pr_return.name) + gl_entries = get_gl_entries(pr_return.doctype, pr_return.name) + + # Test - 1: SLE Stock Value Difference should be equal to Qty * Average Rate + average_rate = ( + (se.items[0].qty * se.items[0].basic_rate) + (pr.items[0].qty * pr.items[0].rate) + ) / (se.items[0].qty + pr.items[0].qty) + expected_stock_value_difference = pr_return.items[0].qty * average_rate + self.assertEqual( + flt(sl_entries[0].stock_value_difference, 2), flt(expected_stock_value_difference, 2) + ) + + # Test - 2: GL Entries should be created for Stock Value Difference + self.assertEqual(len(gl_entries), 2) + + # Test - 3: SLE Stock Value Difference should be equal to Debit or Credit of GL Entries. + for entry in gl_entries: + self.assertEqual(abs(entry.debit + entry.credit), abs(sl_entries[0].stock_value_difference)) + 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/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 76f476edf8a0..4911523e7ed4 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -110,6 +110,7 @@ "manufacturer_part_no", "accounting_details_section", "expense_account", + "wip_composite_asset", "column_break_102", "provisional_expense_account", "accounting_dimensions_section", @@ -1018,12 +1019,18 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "wip_composite_asset", + "fieldtype": "Link", + "label": "WIP Composite Asset", + "options": "Asset" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-07-04 17:22:02.830029", + "modified": "2023-10-03 21:11:50.547261", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", @@ -1034,4 +1041,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} +} \ No newline at end of file diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.js b/erpnext/stock/doctype/quality_inspection/quality_inspection.js index eea28791a9ff..05fa2324dd47 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.js +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.js @@ -6,6 +6,14 @@ cur_frm.cscript.refresh = cur_frm.cscript.inspection_type; frappe.ui.form.on("Quality Inspection", { setup: function(frm) { + frm.set_query("reference_name", function() { + return { + filters: { + "docstatus": ["!=", 2], + } + } + }); + frm.set_query("batch_no", function() { return { filters: { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index dd08ef4d1e39..6dd0a58645c8 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -101,15 +101,6 @@ frappe.ui.form.on('Stock Entry', { } }); - let batch_field = frm.get_docfield('items', 'batch_no'); - if (batch_field) { - batch_field.get_route_options_for_new_doc = (row) => { - return { - 'item': row.doc.item_code - } - }; - } - frm.add_fetch("bom_no", "inspection_required", "inspection_required"); erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); @@ -345,6 +336,15 @@ frappe.ui.form.on('Stock Entry', { if(!check_should_not_attach_bom_items(frm.doc.bom_no)) { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); } + + let batch_field = frm.get_docfield('items', 'batch_no'); + if (batch_field) { + batch_field.get_route_options_for_new_doc = (row) => { + return { + 'item': row.doc.item_code + } + }; + } }, get_items_from_transit_entry: function(frm) { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b93ffc437cb5..9560b52f59c7 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -194,7 +194,7 @@ def is_enqueue_action(self, force=False) -> bool: return False # If line items are more than 100 or record is older than 6 months - if len(self.items) > 100 or month_diff(nowdate(), self.posting_date) > 6: + if len(self.items) > 50 or month_diff(nowdate(), self.posting_date) > 6: return True return False diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 052f7781c130..921b04aab8c8 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -5,13 +5,15 @@ from datetime import date import frappe -from frappe import _ +from frappe import _, bold from frappe.core.doctype.role.role import get_users from frappe.model.document import Document -from frappe.utils import add_days, cint, formatdate, get_datetime, getdate +from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate from erpnext.accounts.utils import get_fiscal_year from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions +from erpnext.stock.stock_ledger import get_previous_sle class StockFreezeError(frappe.ValidationError): @@ -48,6 +50,69 @@ def validate(self): self.validate_and_set_fiscal_year() self.block_transactions_against_group_warehouse() self.validate_with_last_transaction_posting_time() + self.validate_inventory_dimension_negative_stock() + + def validate_inventory_dimension_negative_stock(self): + extra_cond = "" + kwargs = {} + + dimensions = self._get_inventory_dimensions() + if not dimensions: + return + + for dimension, values in dimensions.items(): + kwargs[dimension] = values.get("value") + extra_cond += f" and {dimension} = %({dimension})s" + + kwargs.update( + { + "item_code": self.item_code, + "warehouse": self.warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "company": self.company, + } + ) + + sle = get_previous_sle(kwargs, extra_cond=extra_cond) + if sle: + flt_precision = cint(frappe.db.get_default("float_precision")) or 2 + diff = sle.qty_after_transaction + flt(self.actual_qty) + diff = flt(diff, flt_precision) + if diff < 0 and abs(diff) > 0.0001: + self.throw_validation_error(diff, dimensions) + + def throw_validation_error(self, diff, dimensions): + dimension_msg = _(", with the inventory {0}: {1}").format( + "dimensions" if len(dimensions) > 1 else "dimension", + ", ".join(f"{bold(d.doctype)} ({d.value})" for k, d in dimensions.items()), + ) + + msg = _( + "{0} units of {1} are required in {2}{3}, on {4} {5} for {6} to complete the transaction." + ).format( + abs(diff), + frappe.get_desk_link("Item", self.item_code), + frappe.get_desk_link("Warehouse", self.warehouse), + dimension_msg, + self.posting_date, + self.posting_time, + frappe.get_desk_link(self.voucher_type, self.voucher_no), + ) + + frappe.throw(msg, title=_("Inventory Dimension Negative Stock")) + + def _get_inventory_dimensions(self): + inv_dimensions = get_inventory_dimensions() + inv_dimension_dict = {} + for dimension in inv_dimensions: + if not dimension.get("validate_negative_stock") or not self.get(dimension.fieldname): + continue + + dimension["value"] = self.get(dimension.fieldname) + inv_dimension_dict.setdefault(dimension.fieldname, dimension) + + return inv_dimension_dict def on_submit(self): self.check_stock_frozen_date() diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 3fd4cec5d884..e469291eac99 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -6,12 +6,13 @@ import frappe from frappe import _, bold, msgprint from frappe.query_builder.functions import CombineDatetime, Sum -from frappe.utils import cint, cstr, flt +from frappe.utils import add_to_date, cint, cstr, flt import erpnext from erpnext.accounts.utils import get_company_default from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.batch.batch import get_batch_qty +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.utils import get_stock_balance @@ -45,10 +46,22 @@ def validate(self): self.clean_serial_nos() self.set_total_qty_and_amount() self.validate_putaway_capacity() + self.validate_inventory_dimension() if self._action == "submit": self.make_batches("warehouse") + def validate_inventory_dimension(self): + dimensions = get_inventory_dimensions() + for dimension in dimensions: + for row in self.items: + if not row.batch_no and row.current_qty and row.get(dimension.get("fieldname")): + frappe.throw( + _( + "Row #{0}: You cannot use the inventory dimension '{1}' in Stock Reconciliation to modify the quantity or valuation rate. Stock reconciliation with inventory dimensions is intended solely for performing opening entries." + ).format(row.idx, bold(dimension.get("doctype"))) + ) + def on_submit(self): self.update_stock_ledger() self.make_gl_entries() @@ -70,8 +83,19 @@ def remove_items_with_no_change(self): self.difference_amount = 0.0 def _changed(item): + inventory_dimensions_dict = {} + if not item.batch_no and not item.serial_no: + for dimension in get_inventory_dimensions(): + if item.get(dimension.get("fieldname")): + inventory_dimensions_dict[dimension.get("fieldname")] = item.get(dimension.get("fieldname")) + item_dict = get_stock_balance_for( - item.item_code, item.warehouse, self.posting_date, self.posting_time, batch_no=item.batch_no + item.item_code, + item.warehouse, + self.posting_date, + self.posting_time, + batch_no=item.batch_no, + inventory_dimensions_dict=inventory_dimensions_dict, ) if ( @@ -167,6 +191,14 @@ def _get_msg(row_num, msg): if flt(row.valuation_rate) < 0: self.validation_messages.append(_get_msg(row_num, _("Negative Valuation Rate is not allowed"))) + if row.batch_no and frappe.get_cached_value("Batch", row.batch_no, "item") != row.item_code: + self.validation_messages.append( + _get_msg( + row_num, + _("Batch {0} does not belong to item {1}").format(bold(row.batch_no), bold(row.item_code)), + ) + ) + if row.qty and row.valuation_rate in ["", None]: row.valuation_rate = get_stock_balance( row.item_code, row.warehouse, self.posting_date, self.posting_time, with_valuation_rate=True @@ -282,11 +314,7 @@ def update_stock_ledger(self): if has_serial_no: sl_entries = self.merge_similar_item_serial_nos(sl_entries) - allow_negative_stock = False - if has_batch_no: - allow_negative_stock = True - - self.make_sl_entries(sl_entries, allow_negative_stock=allow_negative_stock) + self.make_sl_entries(sl_entries, allow_negative_stock=self.has_negative_stock_allowed()) if has_serial_no and sl_entries: self.update_valuation_rate_for_serial_no() @@ -419,6 +447,12 @@ def get_sle_for_items(self, row, serial_nos=None): if not row.batch_no: data.qty_after_transaction = flt(row.qty, row.precision("qty")) + dimensions = get_inventory_dimensions() + has_dimensions = False + for dimension in dimensions: + if row.get(dimension.get("fieldname")): + has_dimensions = True + if self.docstatus == 2 and not row.batch_no: if row.current_qty: data.actual_qty = -1 * row.current_qty @@ -433,6 +467,11 @@ def get_sle_for_items(self, row, serial_nos=None): data.valuation_rate = flt(row.valuation_rate) data.stock_value_difference = -1 * flt(row.amount_difference) + elif self.docstatus == 1 and has_dimensions and not row.batch_no: + data.actual_qty = row.qty + data.qty_after_transaction = 0.0 + data.incoming_rate = flt(row.valuation_rate) + self.update_inventory_dimensions(row, data) return data @@ -457,10 +496,7 @@ def make_sle_on_cancel(self): sl_entries = self.merge_similar_item_serial_nos(sl_entries) sl_entries.reverse() - allow_negative_stock = cint( - frappe.db.get_single_value("Stock Settings", "allow_negative_stock") - ) - self.make_sl_entries(sl_entries, allow_negative_stock=allow_negative_stock) + self.make_sl_entries(sl_entries, allow_negative_stock=self.has_negative_stock_allowed()) def merge_similar_item_serial_nos(self, sl_entries): # If user has put the same item in multiple row with different serial no @@ -570,44 +606,67 @@ def cancel(self): else: self._cancel() - def recalculate_current_qty(self, item_code, batch_no): + def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False): 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): + if voucher_detail_no != row.name: continue current_qty = get_batch_qty_for_stock_reco( - item_code, row.warehouse, batch_no, self.posting_date, self.posting_time, self.name + row.item_code, row.warehouse, row.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 - ) + if flt(current_qty, precesion) != flt(row.current_qty, precesion): + val_rate = get_valuation_rate( + row.item_code, + row.warehouse, + self.doctype, + self.name, + company=self.company, + batch_no=row.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_valuation_rate = val_rate + 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), + } + ) - 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 ( + add_new_sle + and not frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0}, + "name", + ) + and current_qty + ): + new_sle = self.get_sle_for_items(row) + new_sle.actual_qty = current_qty * -1 + new_sle.valuation_rate = row.current_valuation_rate + new_sle.creation_time = add_to_date(sle_creation, seconds=-1) + sl_entries.append(new_sle) if sl_entries: - self.make_sl_entries(sl_entries) + self.make_sl_entries(sl_entries, allow_negative_stock=self.has_negative_stock_allowed()) + if not frappe.db.exists("Repost Item Valuation", {"voucher_no": self.name, "status": "Queued"}): + self.repost_future_sle_and_gle(force=True) + + def has_negative_stock_allowed(self): + allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) + + if all(d.batch_no and flt(d.qty) == flt(d.current_qty) for d in self.items): + allow_negative_stock = True + + return allow_negative_stock def get_batch_qty_for_stock_reco( @@ -801,6 +860,7 @@ def get_stock_balance_for( posting_time, batch_no: Optional[str] = None, with_valuation_rate: bool = True, + inventory_dimensions_dict=None, ): frappe.has_permission("Stock Reconciliation", "write", throw=True) @@ -829,6 +889,7 @@ def get_stock_balance_for( posting_time, with_valuation_rate=with_valuation_rate, with_serial_no=has_serial_no, + inventory_dimensions_dict=inventory_dimensions_dict, ) if has_serial_no: diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 88d4e4689772..c913af3301a6 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -604,9 +604,9 @@ def test_valid_batch(self): create_batch_item_with_batch("Testing Batch Item 1", "001") create_batch_item_with_batch("Testing Batch Item 2", "002") sr = create_stock_reconciliation( - item_code="Testing Batch Item 1", qty=1, rate=100, batch_no="002", do_not_submit=True + item_code="Testing Batch Item 1", qty=1, rate=100, batch_no="002", do_not_save=True ) - self.assertRaises(frappe.ValidationError, sr.submit) + self.assertRaises(frappe.ValidationError, sr.save) def test_serial_no_cancellation(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -759,13 +759,6 @@ def test_backdated_stock_reco_entry(self): 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}, @@ -775,6 +768,60 @@ def test_backdated_stock_reco_entry(self): self.assertEqual(flt(sle[0].qty_after_transaction), flt(50.0)) + def test_backdated_stock_reco_entry_with_batch(self): + item_code = self.make_item( + "Test New Batch Item ABCVSD", + { + "is_stock_item": 1, + "has_batch_no": 1, + "batch_number_series": "BNS9.####", + "create_new_batch": 1, + }, + ).name + + warehouse = "_Test Warehouse - _TC" + + # Stock Reco for 100, Balace Qty 100 + stock_reco = create_stock_reconciliation( + item_code=item_code, + posting_date=nowdate(), + posting_time="11:00:00", + warehouse=warehouse, + qty=100, + rate=100, + ) + + sles = frappe.get_all( + "Stock Ledger Entry", + fields=["actual_qty", "batch_no"], + filters={"voucher_no": stock_reco.name, "is_cancelled": 0}, + ) + + self.assertEqual(len(sles), 1) + + # Stock Reco for 100, Balace Qty 100 + create_stock_reconciliation( + item_code=item_code, + posting_date=add_days(nowdate(), -1), + posting_time="11:00:00", + batch_no=sles[0].batch_no, + warehouse=warehouse, + qty=60, + rate=100, + ) + + sles = frappe.get_all( + "Stock Ledger Entry", + fields=["actual_qty"], + filters={"voucher_no": stock_reco.name, "is_cancelled": 0}, + ) + + self.assertEqual(len(sles), 2) + + for row in sles: + if row.actual_qty < 0: + self.assertEqual(row.actual_qty, -60) + def test_update_stock_reconciliation_while_reposting(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -819,6 +866,148 @@ def test_update_stock_reconciliation_while_reposting(self): sr1.load_from_db() self.assertEqual(sr1.difference_amount, 10000) + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_negative_stock_reco_for_batch(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item( + "Test New Batch Item ABCVSD", + { + "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 + se = make_stock_entry( + item_code=item_code, + target=warehouse, + qty=100, + basic_rate=100, + posting_date=add_days(nowdate(), -2), + ) + + # Removed 100 Qty, Balace Qty 0 + make_stock_entry( + item_code=item_code, + source=warehouse, + qty=100, + batch_no=se.items[0].batch_no, + basic_rate=100, + posting_date=nowdate(), + ) + + # Remove 100 qty, Balace Qty -100 + sr = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=0, + rate=0, + batch_no=se.items[0].batch_no, + posting_date=add_days(nowdate(), -1), + posting_time="11:00:00", + do_not_submit=True, + ) + + # Check if Negative Stock is blocked + self.assertRaises(frappe.ValidationError, sr.submit) + + def test_batch_item_validation(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item( + "Test Batch Item Original", + { + "is_stock_item": 1, + "has_batch_no": 1, + "batch_number_series": "BNS9.####", + "create_new_batch": 1, + }, + ).name + + sr = make_stock_entry( + item_code=item_code, + target="_Test Warehouse - _TC", + qty=100, + basic_rate=100, + posting_date=nowdate(), + ) + + new_item_code = self.make_item( + "Test Batch Item New 1", + { + "is_stock_item": 1, + "has_batch_no": 1, + }, + ).name + + sr = create_stock_reconciliation( + item_code=new_item_code, + warehouse="_Test Warehouse - _TC", + qty=10, + rate=100, + batch_no=sr.items[0].batch_no, + do_not_save=True, + ) + + self.assertRaises(frappe.ValidationError, sr.save) + + @change_settings("Stock Settings", {"allow_negative_stock": 0}) + def test_backdated_stock_reco_for_batch_item_dont_have_future_sle(self): + # Step - 1: Create a Batch Item + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item( + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-.###", + } + ).name + + # Step - 2: Create Opening Stock Reconciliation + sr1 = create_stock_reconciliation( + item_code=item, + warehouse="_Test Warehouse - _TC", + qty=10, + purpose="Opening Stock", + posting_date=add_days(nowdate(), -2), + ) + + # Step - 3: Create Stock Entry (Material Receipt) + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + se1 = make_stock_entry( + item_code=item, + target="_Test Warehouse - _TC", + qty=100, + ) + + # Step - 4: Create Stock Entry (Material Issue) + make_stock_entry( + item_code=item, + source="_Test Warehouse - _TC", + qty=100, + batch_no=se1.items[0].batch_no, + purpose="Material Issue", + ) + + # Step - 5: Create Stock Reconciliation (Backdated) after the Stock Reconciliation 1 (Step - 2) + sr2 = create_stock_reconciliation( + item_code=item, + warehouse="_Test Warehouse - _TC", + qty=5, + batch_no=sr1.items[0].batch_no, + posting_date=add_days(nowdate(), -1), + ) + + self.assertEqual(sr2.docstatus, 1) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) @@ -842,7 +1031,7 @@ def insert_existing_sle(warehouse, item_code="_Test Item"): posting_time="02:00", item_code=item_code, target=warehouse, - qty=10, + qty=15, basic_rate=700, ) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index f3adefb3e74c..92c945b254b8 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -160,6 +160,7 @@ def update_stock(args, out): and out.warehouse and out.stock_qty > 0 ): + out["ignore_serial_nos"] = args.get("ignore_serial_nos") if out.has_batch_no and not args.get("batch_no"): out.batch_no = get_batch_no(out.item_code, out.warehouse, out.qty) @@ -729,7 +730,11 @@ def get_default_discount_account(args, item): def get_default_deferred_account(args, item, fieldname=None): if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"): return ( - item.get(fieldname) + frappe.get_cached_value( + "Item Default", + {"parent": args.item_code, "company": args.get("company")}, + fieldname, + ) or args.get(fieldname) or frappe.get_cached_value("Company", args.company, "default_" + fieldname) ) @@ -1136,6 +1141,8 @@ def get_serial_nos_by_fifo(args, sales_order=None): query = query.where(sn.sales_order == sales_order) if args.batch_no: query = query.where(sn.batch_no == args.batch_no) + if args.ignore_serial_nos: + query = query.where(sn.name.notin(args.ignore_serial_nos)) serial_nos = query.run(as_list=True) serial_nos = [s[0] for s in serial_nos] @@ -1388,6 +1395,9 @@ def _get_bom(item): @frappe.whitelist() def get_valuation_rate(item_code, company, warehouse=None): + if frappe.get_cached_value("Warehouse", warehouse, "is_group"): + return {"valuation_rate": 0.0} + item = get_item_defaults(item_code, company) item_group = get_item_group_defaults(item_code, company) brand = get_brand_defaults(item_code, company) @@ -1443,6 +1453,7 @@ def get_serial_no(args, serial_nos=None, sales_order=None): "item_code": args.get("item_code"), "warehouse": args.get("warehouse"), "stock_qty": args.get("stock_qty"), + "ignore_serial_nos": args.get("ignore_serial_nos"), } ) args = process_args(args) diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index 0f319554763a..b38dba8bb17c 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -16,7 +16,7 @@ def execute(filters=None): if not filters: filters = {} - sle_count = frappe.db.count("Stock Ledger Entry", {"is_cancelled": 0}) + sle_count = frappe.db.count("Stock Ledger Entry") if sle_count > SLE_COUNT_LIMIT and not filters.get("item_code") and not filters.get("warehouse"): frappe.throw(_("Please select either the Item or Warehouse filter to generate the report.")) diff --git a/erpnext/stock/report/item_shortage_report/item_shortage_report.py b/erpnext/stock/report/item_shortage_report/item_shortage_report.py index 9fafe91c3f96..4bd9a107e2cc 100644 --- a/erpnext/stock/report/item_shortage_report/item_shortage_report.py +++ b/erpnext/stock/report/item_shortage_report/item_shortage_report.py @@ -40,7 +40,12 @@ def get_data(filters): item.item_name, item.description, ) - .where((bin.projected_qty < 0) & (wh.name == bin.warehouse) & (bin.item_code == item.name)) + .where( + (item.disabled == 0) + & (bin.projected_qty < 0) + & (wh.name == bin.warehouse) + & (bin.item_code == item.name) + ) .orderby(bin.projected_qty) ) diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index 33ed955a5c48..6de5f00ece84 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -71,6 +71,14 @@ frappe.query_reports["Stock Balance"] = { "width": "80", "options": "Warehouse Type" }, + { + "fieldname": "valuation_field_type", + "label": __("Valuation Field Type"), + "fieldtype": "Select", + "width": "80", + "options": "Currency\nFloat", + "default": "Currency" + }, { "fieldname":"include_uom", "label": __("Include UOM"), diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 7c821700df3d..80bf8508cf31 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -323,8 +323,10 @@ def apply_items_filters(self, query, item_table) -> str: for field in ["item_code", "brand"]: if not self.filters.get(field): continue - - query = query.where(item_table[field] == self.filters.get(field)) + elif field == "item_code": + query = query.where(item_table.name == self.filters.get(field)) + else: + query = query.where(item_table[field] == self.filters.get(field)) return query @@ -430,10 +432,12 @@ def get_columns(self): { "label": _("Valuation Rate"), "fieldname": "val_rate", - "fieldtype": "Currency", + "fieldtype": self.filters.valuation_field_type or "Currency", "width": 90, "convertible": "rate", - "options": "currency", + "options": "Company:company:default_currency" + if self.filters.valuation_field_type == "Currency" + else None, }, { "label": _("Company"), diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index 0def161d2833..b00b422a67a3 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -82,7 +82,15 @@ frappe.query_reports["Stock Ledger"] = { "label": __("Include UOM"), "fieldtype": "Link", "options": "UOM" - } + }, + { + "fieldname": "valuation_field_type", + "label": __("Valuation Field Type"), + "fieldtype": "Select", + "width": "80", + "options": "Currency\nFloat", + "default": "Currency" + }, ], "formatter": function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 77bc4e004de7..eeef39641b01 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -196,17 +196,21 @@ def get_columns(filters): { "label": _("Avg Rate (Balance Stock)"), "fieldname": "valuation_rate", - "fieldtype": "Currency", + "fieldtype": filters.valuation_field_type, "width": 180, - "options": "Company:company:default_currency", + "options": "Company:company:default_currency" + if filters.valuation_field_type == "Currency" + else None, "convertible": "rate", }, { "label": _("Valuation Rate"), "fieldname": "in_out_rate", - "fieldtype": "Currency", + "fieldtype": filters.valuation_field_type, "width": 140, - "options": "Company:company:default_currency", + "options": "Company:company:default_currency" + if filters.valuation_field_type == "Currency" + else None, "convertible": "rate", }, { diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js index 31f389f236e7..94e0b2dce3b4 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js @@ -2,24 +2,24 @@ // For license information, please see license.txt /* eslint-disable */ -const DIFFERNCE_FIELD_NAMES = [ - "difference_in_qty", - "fifo_qty_diff", - "fifo_value_diff", - "fifo_valuation_diff", - "valuation_diff", - "fifo_difference_diff", - "diff_value_diff" +const DIFFERENCE_FIELD_NAMES = [ + 'difference_in_qty', + 'fifo_qty_diff', + 'fifo_value_diff', + 'fifo_valuation_diff', + 'valuation_diff', + 'fifo_difference_diff', + 'diff_value_diff' ]; -frappe.query_reports["Stock Ledger Invariant Check"] = { - "filters": [ +frappe.query_reports['Stock Ledger Invariant Check'] = { + 'filters': [ { - "fieldname": "item_code", - "fieldtype": "Link", - "label": "Item", - "mandatory": 1, - "options": "Item", + 'fieldname': 'item_code', + 'fieldtype': 'Link', + 'label': 'Item', + 'mandatory': 1, + 'options': 'Item', get_query: function() { return { filters: {is_stock_item: 1, has_serial_no: 0} @@ -27,18 +27,61 @@ frappe.query_reports["Stock Ledger Invariant Check"] = { } }, { - "fieldname": "warehouse", - "fieldtype": "Link", - "label": "Warehouse", - "mandatory": 1, - "options": "Warehouse", + 'fieldname': 'warehouse', + 'fieldtype': 'Link', + 'label': 'Warehouse', + 'mandatory': 1, + 'options': 'Warehouse', } ], + formatter (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); - if (DIFFERNCE_FIELD_NAMES.includes(column.fieldname) && Math.abs(data[column.fieldname]) > 0.001) { - value = "" + value + ""; + if (DIFFERENCE_FIELD_NAMES.includes(column.fieldname) && Math.abs(data[column.fieldname]) > 0.001) { + value = '' + value + ''; } return value; }, + + get_datatable_options(options) { + return Object.assign(options, { + checkboxColumn: true, + }); + }, + + onload(report) { + report.page.add_inner_button(__('Create Reposting Entry'), () => { + let message = ` +
+

+ Reposting Entry will change the value of + accounts Stock In Hand, and Stock Expenses + in the Trial Balance report and will also change + the Balance Value in the Stock Balance report. +

+

Are you sure you want to create a Reposting Entry?

+
`; + let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows(); + let selected_rows = indexes.map(i => frappe.query_report.data[i]); + + if (!selected_rows.length) { + frappe.throw(__('Please select a row to create a Reposting Entry')); + } + else if (selected_rows.length > 1) { + frappe.throw(__('Please select only one row to create a Reposting Entry')); + } + else { + frappe.confirm(__(message), () => { + frappe.call({ + method: 'erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check.create_reposting_entries', + args: { + rows: selected_rows, + item_code: frappe.query_report.get_filter_values().item_code, + warehouse: frappe.query_report.get_filter_values().warehouse, + } + }); + }); + } + }); + }, }; diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index ed0e2fc31bd1..ca15afe444d5 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -5,6 +5,7 @@ import frappe from frappe import _ +from frappe.utils import get_link_to_form, parse_json SLE_FIELDS = ( "name", @@ -185,7 +186,7 @@ def get_columns(): { "fieldname": "fifo_queue_qty", "fieldtype": "Float", - "label": _("(C) Total qty in queue"), + "label": _("(C) Total Qty in Queue"), }, { "fieldname": "fifo_qty_diff", @@ -210,51 +211,83 @@ def get_columns(): { "fieldname": "stock_value_difference", "fieldtype": "Float", - "label": _("(F) Stock Value Difference"), + "label": _("(F) Change in Stock Value"), }, { "fieldname": "stock_value_from_diff", "fieldtype": "Float", - "label": _("Balance Stock Value using (F)"), + "label": _("(G) Sum of Change in Stock Value"), }, { "fieldname": "diff_value_diff", "fieldtype": "Float", - "label": _("K - D"), + "label": _("G - D"), }, { "fieldname": "fifo_stock_diff", "fieldtype": "Float", - "label": _("(G) Stock Value difference (FIFO queue)"), + "label": _("(H) Change in Stock Value (FIFO Queue)"), }, { "fieldname": "fifo_difference_diff", "fieldtype": "Float", - "label": _("F - G"), + "label": _("H - F"), }, { "fieldname": "valuation_rate", "fieldtype": "Float", - "label": _("(H) Valuation Rate"), + "label": _("(I) Valuation Rate"), }, { "fieldname": "fifo_valuation_rate", "fieldtype": "Float", - "label": _("(I) Valuation Rate as per FIFO"), + "label": _("(J) Valuation Rate as per FIFO"), }, { "fieldname": "fifo_valuation_diff", "fieldtype": "Float", - "label": _("H - I"), + "label": _("I - J"), }, { "fieldname": "balance_value_by_qty", "fieldtype": "Float", - "label": _("(J) Valuation = Value (D) ÷ Qty (A)"), + "label": _("(K) Valuation = Value (D) ÷ Qty (A)"), }, { "fieldname": "valuation_diff", "fieldtype": "Float", - "label": _("H - J"), + "label": _("I - K"), }, ] + + +@frappe.whitelist() +def create_reposting_entries(rows, item_code=None, warehouse=None): + if isinstance(rows, str): + rows = parse_json(rows) + + entries = [] + for row in rows: + row = frappe._dict(row) + + try: + doc = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Item and Warehouse", + "status": "Queued", + "item_code": item_code or row.item_code, + "warehouse": warehouse or row.warehouse, + "posting_date": row.posting_date, + "posting_time": row.posting_time, + "allow_nagative_stock": 1, + } + ).submit() + + entries.append(get_link_to_form("Repost Item Valuation", doc.name)) + except frappe.DuplicateEntryError: + continue + + if entries: + entries = ", ".join(entries) + frappe.msgprint(_("Reposting entries created: {0}").format(entries)) diff --git a/erpnext/stock/report/stock_ledger_variance/__init__.py b/erpnext/stock/report/stock_ledger_variance/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.js b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.js new file mode 100644 index 000000000000..b1e4a74571ea --- /dev/null +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.js @@ -0,0 +1,101 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +const DIFFERENCE_FIELD_NAMES = [ + "difference_in_qty", + "fifo_qty_diff", + "fifo_value_diff", + "fifo_valuation_diff", + "valuation_diff", + "fifo_difference_diff", + "diff_value_diff" +]; + +frappe.query_reports["Stock Ledger Variance"] = { + "filters": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + get_query: function() { + return { + filters: {is_stock_item: 1, has_serial_no: 0} + } + } + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "options": "Warehouse", + get_query: function() { + return { + filters: {is_group: 0, disabled: 0} + } + } + }, + { + "fieldname": "difference_in", + "fieldtype": "Select", + "label": "Difference In", + "options": [ + "", + "Qty", + "Value", + "Valuation", + ], + }, + { + "fieldname": "include_disabled", + "fieldtype": "Check", + "label": "Include Disabled", + } + ], + + formatter (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + + if (DIFFERENCE_FIELD_NAMES.includes(column.fieldname) && Math.abs(data[column.fieldname]) > 0.001) { + value = "" + value + ""; + } + + return value; + }, + + get_datatable_options(options) { + return Object.assign(options, { + checkboxColumn: true, + }); + }, + + onload(report) { + report.page.add_inner_button(__('Create Reposting Entries'), () => { + let message = ` +
+

+ Reposting Entries will change the value of + accounts Stock In Hand, and Stock Expenses + in the Trial Balance report and will also change + the Balance Value in the Stock Balance report. +

+

Are you sure you want to create Reposting Entries?

+
`; + let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows(); + let selected_rows = indexes.map(i => frappe.query_report.data[i]); + + if (!selected_rows.length) { + frappe.throw(__("Please select rows to create Reposting Entries")); + } + + frappe.confirm(__(message), () => { + frappe.call({ + method: 'erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check.create_reposting_entries', + args: { + rows: selected_rows, + } + }); + }); + }); + }, +}; diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.json b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.json new file mode 100644 index 000000000000..f36ed1b9ca6c --- /dev/null +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.json @@ -0,0 +1,22 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2023-09-20 10:44:19.414449", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2023-09-20 10:44:19.414449", + "modified_by": "Administrator", + "module": "Stock", + "name": "Stock Ledger Variance", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Ledger Entry", + "report_name": "Stock Ledger Variance", + "report_type": "Script Report", + "roles": [] +} \ No newline at end of file diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py new file mode 100644 index 000000000000..732f108ac413 --- /dev/null +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py @@ -0,0 +1,279 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.utils import cint, flt + +from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import ( + get_data as stock_ledger_invariant_check, +) + + +def execute(filters=None): + columns, data = [], [] + + filters = frappe._dict(filters or {}) + columns = get_columns() + data = get_data(filters) + + return columns, data + + +def get_columns(): + return [ + { + "fieldname": "name", + "fieldtype": "Link", + "label": _("Stock Ledger Entry"), + "options": "Stock Ledger Entry", + }, + { + "fieldname": "posting_date", + "fieldtype": "Data", + "label": _("Posting Date"), + }, + { + "fieldname": "posting_time", + "fieldtype": "Data", + "label": _("Posting Time"), + }, + { + "fieldname": "creation", + "fieldtype": "Data", + "label": _("Creation"), + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": _("Item"), + "options": "Item", + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "label": _("Warehouse"), + "options": "Warehouse", + }, + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "label": _("Voucher Type"), + "options": "DocType", + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "label": _("Voucher No"), + "options": "voucher_type", + }, + { + "fieldname": "batch_no", + "fieldtype": "Link", + "label": _("Batch"), + "options": "Batch", + }, + { + "fieldname": "use_batchwise_valuation", + "fieldtype": "Check", + "label": _("Batchwise Valuation"), + }, + { + "fieldname": "actual_qty", + "fieldtype": "Float", + "label": _("Qty Change"), + }, + { + "fieldname": "incoming_rate", + "fieldtype": "Float", + "label": _("Incoming Rate"), + }, + { + "fieldname": "consumption_rate", + "fieldtype": "Float", + "label": _("Consumption Rate"), + }, + { + "fieldname": "qty_after_transaction", + "fieldtype": "Float", + "label": _("(A) Qty After Transaction"), + }, + { + "fieldname": "expected_qty_after_transaction", + "fieldtype": "Float", + "label": _("(B) Expected Qty After Transaction"), + }, + { + "fieldname": "difference_in_qty", + "fieldtype": "Float", + "label": _("A - B"), + }, + { + "fieldname": "stock_queue", + "fieldtype": "Data", + "label": _("FIFO/LIFO Queue"), + }, + { + "fieldname": "fifo_queue_qty", + "fieldtype": "Float", + "label": _("(C) Total Qty in Queue"), + }, + { + "fieldname": "fifo_qty_diff", + "fieldtype": "Float", + "label": _("A - C"), + }, + { + "fieldname": "stock_value", + "fieldtype": "Float", + "label": _("(D) Balance Stock Value"), + }, + { + "fieldname": "fifo_stock_value", + "fieldtype": "Float", + "label": _("(E) Balance Stock Value in Queue"), + }, + { + "fieldname": "fifo_value_diff", + "fieldtype": "Float", + "label": _("D - E"), + }, + { + "fieldname": "stock_value_difference", + "fieldtype": "Float", + "label": _("(F) Change in Stock Value"), + }, + { + "fieldname": "stock_value_from_diff", + "fieldtype": "Float", + "label": _("(G) Sum of Change in Stock Value"), + }, + { + "fieldname": "diff_value_diff", + "fieldtype": "Float", + "label": _("G - D"), + }, + { + "fieldname": "fifo_stock_diff", + "fieldtype": "Float", + "label": _("(H) Change in Stock Value (FIFO Queue)"), + }, + { + "fieldname": "fifo_difference_diff", + "fieldtype": "Float", + "label": _("H - F"), + }, + { + "fieldname": "valuation_rate", + "fieldtype": "Float", + "label": _("(I) Valuation Rate"), + }, + { + "fieldname": "fifo_valuation_rate", + "fieldtype": "Float", + "label": _("(J) Valuation Rate as per FIFO"), + }, + { + "fieldname": "fifo_valuation_diff", + "fieldtype": "Float", + "label": _("I - J"), + }, + { + "fieldname": "balance_value_by_qty", + "fieldtype": "Float", + "label": _("(K) Valuation = Value (D) ÷ Qty (A)"), + }, + { + "fieldname": "valuation_diff", + "fieldtype": "Float", + "label": _("I - K"), + }, + ] + + +def get_data(filters=None): + filters = frappe._dict(filters or {}) + item_warehouse_map = get_item_warehouse_combinations(filters) + + data = [] + if item_warehouse_map: + precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) + + for item_warehouse in item_warehouse_map: + report_data = stock_ledger_invariant_check(item_warehouse) + + if not report_data: + continue + + for row in report_data: + if has_difference(row, precision, filters.difference_in): + data.append(add_item_warehouse_details(row, item_warehouse)) + break + + return data + + +def get_item_warehouse_combinations(filters: dict = None) -> dict: + filters = frappe._dict(filters or {}) + + bin = frappe.qb.DocType("Bin") + item = frappe.qb.DocType("Item") + warehouse = frappe.qb.DocType("Warehouse") + + query = ( + frappe.qb.from_(bin) + .inner_join(item) + .on(bin.item_code == item.name) + .inner_join(warehouse) + .on(bin.warehouse == warehouse.name) + .select( + bin.item_code, + bin.warehouse, + ) + .where((item.is_stock_item == 1) & (item.has_serial_no == 0) & (warehouse.is_group == 0)) + ) + + if filters.item_code: + query = query.where(item.name == filters.item_code) + if filters.warehouse: + query = query.where(warehouse.name == filters.warehouse) + if not filters.include_disabled: + query = query.where((item.disabled == 0) & (warehouse.disabled == 0)) + + return query.run(as_dict=1) + + +def has_difference(row, precision, difference_in): + has_qty_difference = flt(row.difference_in_qty, precision) or flt(row.fifo_qty_diff, precision) + has_value_difference = ( + flt(row.diff_value_diff, precision) + or flt(row.fifo_value_diff, precision) + or flt(row.fifo_difference_diff, precision) + ) + has_valuation_difference = flt(row.valuation_diff, precision) or flt( + row.fifo_valuation_diff, precision + ) + + if difference_in == "Qty" and has_qty_difference: + return True + elif difference_in == "Value" and has_value_difference: + return True + elif difference_in == "Valuation" and has_valuation_difference: + return True + elif difference_in not in ["Qty", "Value", "Valuation"] and ( + has_qty_difference or has_value_difference or has_valuation_difference + ): + return True + + return False + + +def add_item_warehouse_details(row, item_warehouse): + row.update( + { + "item_code": item_warehouse.item_code, + "warehouse": item_warehouse.warehouse, + } + ) + + return row diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 4f8f06023deb..bdae87c7ee7e 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -13,6 +13,7 @@ import erpnext from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.utils import ( get_incoming_outgoing_rate_for_cancel, get_or_make_bin, @@ -74,6 +75,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher) args = sle_doc.as_dict() + args["allow_zero_valuation_rate"] = sle.get("allow_zero_valuation_rate") or False if sle.get("voucher_type") == "Stock Reconciliation": # preserve previous_qty_after_transaction for qty reposting @@ -109,6 +111,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou "sle_id": args.get("name"), "creation": args.get("creation"), }, + allow_zero_rate=args.get("allow_zero_valuation_rate") or False, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher, ) @@ -197,6 +200,11 @@ def make_entry(args, allow_negative_stock=False, via_landed_cost_voucher=False): sle.allow_negative_stock = allow_negative_stock sle.via_landed_cost_voucher = via_landed_cost_voucher sle.submit() + + # Added to handle the case when the stock ledger entry is created from the repostig + if args.get("creation_time") and args.get("voucher_type") == "Stock Reconciliation": + sle.db_set("creation", args.get("creation_time")) + return sle @@ -288,6 +296,8 @@ def update_args_in_repost_item_valuation( frappe.publish_realtime( "item_reposting_progress", {"name": doc.name, "items_to_be_repost": json.dumps(args, default=str), "current_index": index}, + doctype=doc.doctype, + docname=doc.name, ) @@ -512,7 +522,7 @@ def get_dependent_entries_to_fix(self, entries_to_fix, sle): def update_distinct_item_warehouses(self, dependant_sle): key = (dependant_sle.item_code, dependant_sle.warehouse) - val = frappe._dict({"sle": dependant_sle, "dependent_voucher_detail_nos": []}) + val = frappe._dict({"sle": dependant_sle}) if key not in self.distinct_item_warehouses: self.distinct_item_warehouses[key] = val @@ -526,6 +536,8 @@ def update_distinct_item_warehouses(self, dependant_sle): if getdate(dependant_sle.posting_date) < getdate(existing_sle_posting_date): val.sle_changed = True + dependent_voucher_detail_nos.append(dependant_sle.voucher_detail_no) + val.dependent_voucher_detail_nos = dependent_voucher_detail_nos self.distinct_item_warehouses[key] = val self.new_items_found = True elif dependant_sle.voucher_detail_no not in set(dependent_voucher_detail_nos): @@ -560,12 +572,7 @@ def process_sle(self, sle): 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 - ): + if sle.voucher_type == "Stock Reconciliation" and sle.batch_no and sle.voucher_detail_no: self.reset_actual_qty_for_stock_reco(sle) if ( @@ -576,6 +583,13 @@ def process_sle(self, sle): ): sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle) + dimensions = get_inventory_dimensions() + has_dimensions = False + if dimensions: + for dimension in dimensions: + if sle.get(dimension.get("fieldname")): + has_dimensions = True + if get_serial_nos(sle.serial_no): self.get_serialized_values(sle) self.wh_data.qty_after_transaction += flt(sle.actual_qty) @@ -590,7 +604,7 @@ def process_sle(self, sle): ): self.update_batched_values(sle) else: - if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: + if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and not has_dimensions: # assert self.wh_data.valuation_rate = sle.valuation_rate self.wh_data.qty_after_transaction = sle.qty_after_transaction @@ -630,14 +644,14 @@ def process_sle(self, sle): 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" - ) + doc = frappe.get_cached_doc("Stock Reconciliation", sle.voucher_no) + doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty >= 0) - if current_qty: - sle.actual_qty = current_qty * -1 - elif current_qty == 0: - sle.is_cancelled = 1 + if sle.actual_qty < 0: + sle.actual_qty = ( + flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty")) + * -1 + ) def validate_negative_stock(self, sle): """ @@ -684,14 +698,16 @@ def get_incoming_outgoing_rate_from_transaction(self, sle): get_rate_for_return, # don't move this import to top ) - rate = get_rate_for_return( - sle.voucher_type, - sle.voucher_no, - sle.item_code, - voucher_detail_no=sle.voucher_detail_no, - sle=sle, - ) - + if self.valuation_method == "Moving Average": + rate = flt(self.data[self.args.warehouse].previous_sle.valuation_rate) + else: + rate = get_rate_for_return( + sle.voucher_type, + sle.voucher_no, + sle.item_code, + voucher_detail_no=sle.voucher_detail_no, + sle=sle, + ) elif ( sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"] and sle.voucher_detail_no @@ -1180,7 +1196,7 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc return sle[0] if sle else frappe._dict() -def get_previous_sle(args, for_update=False): +def get_previous_sle(args, for_update=False, extra_cond=None): """ get the last sle on or before the current time-bucket, to get actual qty before transaction, this function @@ -1195,7 +1211,9 @@ def get_previous_sle(args, for_update=False): } """ args["name"] = args.get("sle", None) or "" - sle = get_stock_ledger_entries(args, "<=", "desc", "limit 1", for_update=for_update) + sle = get_stock_ledger_entries( + args, "<=", "desc", "limit 1", for_update=for_update, extra_cond=extra_cond + ) return sle and sle[0] or {} @@ -1207,6 +1225,7 @@ def get_stock_ledger_entries( for_update=False, debug=False, check_serial_no=True, + extra_cond=None, ): """get stock ledger entries filtered by specific posting datetime conditions""" conditions = " and timestamp(posting_date, posting_time) {0} timestamp(%(posting_date)s, %(posting_time)s)".format( @@ -1244,6 +1263,9 @@ def get_stock_ledger_entries( if operator in (">", "<=") and previous_sle.get("name"): conditions += " and name!=%(name)s" + if extra_cond: + conditions += f"{extra_cond}" + return frappe.db.sql( """ select *, timestamp(posting_date, posting_time) as "timestamp" @@ -1429,8 +1451,6 @@ 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) @@ -1459,16 +1479,6 @@ 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"): @@ -1607,27 +1617,33 @@ def is_negative_with_precision(neg_sle, is_batch=False): return qty_deficit < 0 and abs(qty_deficit) > 0.0001 -def get_future_sle_with_negative_qty(args): - return frappe.db.sql( - """ - select - qty_after_transaction, posting_date, posting_time, - voucher_type, voucher_no - from `tabStock Ledger Entry` - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and voucher_no != %(voucher_no)s - and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) - and is_cancelled = 0 - and qty_after_transaction < 0 - order by timestamp(posting_date, posting_time) asc - limit 1 - """, - args, - as_dict=1, +def get_future_sle_with_negative_qty(sle): + SLE = frappe.qb.DocType("Stock Ledger Entry") + query = ( + frappe.qb.from_(SLE) + .select( + SLE.qty_after_transaction, SLE.posting_date, SLE.posting_time, SLE.voucher_type, SLE.voucher_no + ) + .where( + (SLE.item_code == sle.item_code) + & (SLE.warehouse == sle.warehouse) + & (SLE.voucher_no != sle.voucher_no) + & ( + CombineDatetime(SLE.posting_date, SLE.posting_time) + >= CombineDatetime(sle.posting_date, sle.posting_time) + ) + & (SLE.is_cancelled == 0) + & (SLE.qty_after_transaction < 0) + ) + .orderby(CombineDatetime(SLE.posting_date, SLE.posting_time)) + .limit(1) ) + if sle.voucher_type == "Stock Reconciliation" and sle.batch_no: + query = query.where(SLE.batch_no == sle.batch_no) + + return query.run(as_dict=True) + def get_future_sle_with_negative_batch_qty(args): return frappe.db.sql( diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index a7e37d5961aa..9f654fc6632b 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -94,6 +94,7 @@ def get_stock_balance( posting_time=None, with_valuation_rate=False, with_serial_no=False, + inventory_dimensions_dict=None, ): """Returns stock balance quantity at given warehouse on given posting date or current date. @@ -113,7 +114,13 @@ def get_stock_balance( "posting_time": posting_time, } - last_entry = get_previous_sle(args) + extra_cond = "" + if inventory_dimensions_dict: + for field, value in inventory_dimensions_dict.items(): + args[field] = value + extra_cond += f" and {field} = %({field})s" + + last_entry = get_previous_sle(args, extra_cond=extra_cond) if with_valuation_rate: if with_serial_no: diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 4bf008ac406f..e335c6ba7a05 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -75,15 +75,6 @@ frappe.ui.form.on('Subcontracting Receipt', { } } }); - - 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) { - return { - 'item': row.doc.item_code - } - }; - } }, refresh: (frm) => { @@ -148,6 +139,15 @@ frappe.ui.form.on('Subcontracting Receipt', { frm.fields_dict.supplied_items.grid.update_docfield_property('consumed_qty', 'read_only', frm.doc.__onload && frm.doc.__onload.backflush_based_on === 'BOM'); } + + 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) { + return { + 'item': row.doc.item_code + } + }; + } }, set_warehouse: (frm) => { @@ -202,4 +202,4 @@ let set_missing_values = (frm) => { if (!r.exc) frm.refresh(); }, }); -}; \ No newline at end of file +}; diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index ecec73e265c2..130f38fb8091 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -176,10 +176,9 @@ def calculate_items_qty_and_amount(self): item.rm_cost_per_qty = item.rm_supp_cost / item.qty rm_supp_cost.pop(item.name) - if item.recalculate_rate: - item.rate = ( - flt(item.rm_cost_per_qty) + flt(item.service_cost_per_qty) + flt(item.additional_cost_per_qty) - ) + item.rate = ( + flt(item.rm_cost_per_qty) + flt(item.service_cost_per_qty) + flt(item.additional_cost_per_qty) + ) item.received_qty = item.qty + flt(item.rejected_qty) item.amount = item.qty * item.rate @@ -268,17 +267,24 @@ def update_status(self, status=None, update_modified=False): 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") elif self.per_returned == 100: status = "Return Issued" + elif self.docstatus == 2: status = "Cancelled" + if self.is_return: + frappe.get_doc("Subcontracting Receipt", self.return_against).update_status( + update_modified=update_modified + ) + if status: - frappe.db.set_value("Subcontracting Receipt", self.name, "status", status, update_modified) + frappe.db.set_value( + "Subcontracting Receipt", self.name, "status", status, update_modified=update_modified + ) def get_gl_entries(self, warehouse_account=None): from erpnext.accounts.general_ledger import process_gl_map diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index dfb72c335670..6c962531dfa1 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -594,6 +594,67 @@ def test_supplied_items_cost_after_reposting(self): self.assertNotEqual(scr.supplied_items[0].rate, prev_cost) self.assertEqual(scr.supplied_items[0].rate, sr.items[0].valuation_rate) + def test_subcontracting_receipt_raw_material_rate(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + # Step - 1: Set Backflush Based On as "BOM" + set_backflush_based_on("BOM") + + # Step - 2: Create FG and RM Items + fg_item = make_item(properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name + rm_item1 = make_item(properties={"is_stock_item": 1}).name + rm_item2 = make_item(properties={"is_stock_item": 1}).name + + # Step - 3: Create BOM for FG Item + bom = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]) + for rm_item in bom.items: + self.assertEqual(rm_item.rate, 0) + self.assertEqual(rm_item.amount, 0) + bom = bom.name + + # Step - 4: Create PO and SCO + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 100, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 100, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + for rm_item in sco.supplied_items: + self.assertEqual(rm_item.rate, 0) + self.assertEqual(rm_item.amount, 0) + + # Step - 5: Inward Raw Materials + rm_items = get_rm_items(sco.supplied_items) + for rm_item in rm_items: + rm_item["rate"] = 100 + itemwise_details = make_stock_in_entry(rm_items=rm_items) + + # Step - 6: Transfer RM's to Subcontractor + se = make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + for item in se.items: + self.assertEqual(item.qty, 100) + self.assertEqual(item.basic_rate, 100) + self.assertEqual(item.amount, item.qty * item.basic_rate) + + # Step - 7: Create Subcontracting Receipt + scr = make_subcontracting_receipt(sco.name) + scr.save() + scr.submit() + scr.load_from_db() + for rm_item in scr.supplied_items: + self.assertEqual(rm_item.consumed_qty, 100) + self.assertEqual(rm_item.rate, 100) + self.assertEqual(rm_item.amount, rm_item.consumed_qty * rm_item.rate) + def make_return_subcontracting_receipt(**args): args = frappe._dict(args) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json index f6cf3402ad24..f7e88d0fb7fe 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -29,7 +29,6 @@ "rate_and_amount", "rate", "amount", - "recalculate_rate", "column_break_19", "rm_cost_per_qty", "service_cost_per_qty", @@ -196,7 +195,6 @@ "options": "currency", "print_width": "100px", "read_only": 1, - "read_only_depends_on": "eval: doc.recalculate_rate", "width": "100px" }, { @@ -466,18 +464,12 @@ "fieldname": "accounting_details_section", "fieldtype": "Section Break", "label": "Accounting Details" - }, - { - "default": "1", - "fieldname": "recalculate_rate", - "fieldtype": "Check", - "label": "Recalculate Rate" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2023-07-06 18:44:45.599761", + "modified": "2023-09-03 17:04:21.214316", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt Item", diff --git a/erpnext/templates/emails/request_for_quotation.html b/erpnext/templates/emails/request_for_quotation.html deleted file mode 100644 index 5b073e604ff9..000000000000 --- a/erpnext/templates/emails/request_for_quotation.html +++ /dev/null @@ -1,29 +0,0 @@ -

{{_("Request for Quotation")}}

-

{{ supplier_salutation if supplier_salutation else ''}} {{ supplier_name }},

-

{{ message }}

-

{{_("The Request for Quotation can be accessed by clicking on the following button")}}:

-
- - {{ _("Submit your Quotation") }} - -
-
-{% if update_password_link %} -
-

{{_("Please click on the following button to set your new password")}}:

- - {{_("Set Password") }} - -
-
-{% endif %} -

- {{_("Regards")}},
- {{ user_fullname }} -

diff --git a/erpnext/templates/generators/item/item_add_to_cart.html b/erpnext/templates/generators/item/item_add_to_cart.html index 1381dfe3b743..9bd3f7514c95 100644 --- a/erpnext/templates/generators/item/item_add_to_cart.html +++ b/erpnext/templates/generators/item/item_add_to_cart.html @@ -49,7 +49,7 @@ {{ _('In stock') }} {% if product_info.show_stock_qty and product_info.stock_qty %} - ({{ product_info.stock_qty[0][0] }}) + ({{ product_info.stock_qty }}) {% endif %} {% endif %} diff --git a/erpnext/templates/includes/order/order_macros.html b/erpnext/templates/includes/order/order_macros.html index d95b28961c6b..8799a3b1eab6 100644 --- a/erpnext/templates/includes/order/order_macros.html +++ b/erpnext/templates/includes/order/order_macros.html @@ -7,7 +7,7 @@ {% if d.thumbnail or d.image %} {{ product_image(d.thumbnail or d.image, no_border=True) }} {% else %} -
+
{{ frappe.utils.get_abbr(d.item_name) or "NA" }}
{% endif %} diff --git a/erpnext/templates/includes/rfq.js b/erpnext/templates/includes/rfq.js index 37beb5a584ba..e78776fd29f5 100644 --- a/erpnext/templates/includes/rfq.js +++ b/erpnext/templates/includes/rfq.js @@ -72,7 +72,7 @@ rfq = class rfq { } submit_rfq(){ - $('.btn-sm').click(function(){ + $('.btn-sm').click(function() { frappe.freeze(); frappe.call({ type: "POST", @@ -81,7 +81,7 @@ rfq = class rfq { doc: doc }, btn: this, - callback: function(r){ + callback: function(r) { frappe.unfreeze(); if(r.message){ $('.btn-sm').hide() diff --git a/erpnext/templates/includes/rfq/rfq_macros.html b/erpnext/templates/includes/rfq/rfq_macros.html index 88724c30de60..78ec6ff5f8b3 100644 --- a/erpnext/templates/includes/rfq/rfq_macros.html +++ b/erpnext/templates/includes/rfq/rfq_macros.html @@ -1,19 +1,25 @@ {% from "erpnext/templates/includes/macros.html" import product_image_square, product_image %} {% macro item_name_and_description(d, doc) %} -
-
- {{ product_image(d.image) }} -
-
- {{ d.item_code }} -

{{ d.description }}

+
+
+ {% if d.image %} + {{ product_image(d.image) }} + {% else %} +
+ {{ frappe.utils.get_abbr(d.item_name)}} +
+ {% endif %} +
+
+ {{ d.item_code }} +

{{ d.description }}

{% set supplier_part_no = frappe.db.get_value("Item Supplier", {'parent': d.item_code, 'supplier': doc.supplier}, "supplier_part_no") %}

{% if supplier_part_no %} {{_("Supplier Part No") + ": "+ supplier_part_no}} {% endif %}

-
-
+
+
{% endmacro %} diff --git a/erpnext/templates/pages/order.html b/erpnext/templates/pages/order.html index bc34ad5ac500..d9cb75ac5ac2 100644 --- a/erpnext/templates/pages/order.html +++ b/erpnext/templates/pages/order.html @@ -165,7 +165,6 @@

{{ doc.name }}

{% endif %} - {% if attachments %}
@@ -193,6 +192,7 @@

{{ doc.name }}

{% endif %} {% endblock %} + {% block script %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/erpnext/templates/pages/rfq.html b/erpnext/templates/pages/rfq.html index 6516482c230e..d371bf2161de 100644 --- a/erpnext/templates/pages/rfq.html +++ b/erpnext/templates/pages/rfq.html @@ -1,7 +1,7 @@ {% extends "templates/web.html" %} {% block header %} -

{{ doc.name }}

+

{{ doc.name }}

{% endblock %} {% block script %} @@ -16,7 +16,7 @@

{{ doc.name }}

{% if doc.items %} + {{ _("Make Quotation") }} {% endif %} {% endblock %} diff --git a/erpnext/templates/pages/wishlist.py b/erpnext/templates/pages/wishlist.py index d70f27c9d9d1..17607e45f919 100644 --- a/erpnext/templates/pages/wishlist.py +++ b/erpnext/templates/pages/wishlist.py @@ -25,9 +25,19 @@ def get_context(context): def get_stock_availability(item_code, warehouse): - stock_qty = frappe.utils.flt( - frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty") - ) + from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses + + if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1: + warehouses = get_child_warehouses(warehouse) + else: + warehouses = [warehouse] if warehouse else [] + + stock_qty = 0.0 + for warehouse in warehouses: + stock_qty += frappe.utils.flt( + frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty") + ) + return bool(stock_qty) diff --git a/erpnext/translations/af.csv b/erpnext/translations/af.csv index f2458e3c9658..10e1cba349e4 100644 --- a/erpnext/translations/af.csv +++ b/erpnext/translations/af.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Jy kan nie projektipe 'eksterne' uitvee nie, You cannot edit root node.,U kan nie wortelknoop wysig nie., You cannot restart a Subscription that is not cancelled.,U kan nie 'n intekening herlaai wat nie gekanselleer is nie., -You don't have enought Loyalty Points to redeem,U het nie genoeg lojaliteitspunte om te verkoop nie, +You don't have enough Loyalty Points to redeem,U het nie genoeg lojaliteitspunte om te verkoop nie, You have already assessed for the assessment criteria {}.,U het reeds geassesseer vir die assesseringskriteria ()., You have already selected items from {0} {1},Jy het reeds items gekies van {0} {1}, You have been invited to collaborate on the project: {0},U is genooi om saam te werk aan die projek: {0}, diff --git a/erpnext/translations/am.csv b/erpnext/translations/am.csv index d4db28586716..f1d979205eca 100644 --- a/erpnext/translations/am.csv +++ b/erpnext/translations/am.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',የፕሮጀክት አይነት «ውጫዊ» ን መሰረዝ አይችሉም., You cannot edit root node.,የስር ሥፍራ ማረም አይችሉም., You cannot restart a Subscription that is not cancelled.,የማይሰረዝ የደንበኝነት ምዝገባን ዳግም ማስጀመር አይችሉም., -You don't have enought Loyalty Points to redeem,ለማስመለስ በቂ የታማኝነት ነጥቦች የሉዎትም, +You don't have enough Loyalty Points to redeem,ለማስመለስ በቂ የታማኝነት ነጥቦች የሉዎትም, You have already assessed for the assessment criteria {}.,ቀድሞውንም ግምገማ መስፈርት ከገመገምን {}., You have already selected items from {0} {1},ከዚህ ቀደም ከ ንጥሎች ተመርጠዋል ሊሆን {0} {1}, You have been invited to collaborate on the project: {0},እርስዎ ፕሮጀክት ላይ ተባበር ተጋብዘዋል: {0}, diff --git a/erpnext/translations/ar.csv b/erpnext/translations/ar.csv index ea2777faf03b..d4df088c778a 100644 --- a/erpnext/translations/ar.csv +++ b/erpnext/translations/ar.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',لا يمكنك حذف مشروع من نوع 'خارجي', You cannot edit root node.,لا يمكنك تحرير عقدة الجذر., You cannot restart a Subscription that is not cancelled.,لا يمكنك إعادة تشغيل اشتراك غير ملغى., -You don't have enought Loyalty Points to redeem,ليس لديك ما يكفي من نقاط الولاء لاستردادها, +You don't have enough Loyalty Points to redeem,ليس لديك ما يكفي من نقاط الولاء لاستردادها, You have already assessed for the assessment criteria {}.,لقد سبق أن قيمت معايير التقييم {}., You have already selected items from {0} {1},لقد حددت العناصر من {0} {1}, You have been invited to collaborate on the project: {0},لقد وجهت الدعوة إلى التعاون في هذا المشروع: {0}, diff --git a/erpnext/translations/bg.csv b/erpnext/translations/bg.csv index 6839129a319b..24a397210e1d 100644 --- a/erpnext/translations/bg.csv +++ b/erpnext/translations/bg.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Не можете да изтриете Тип на проекта "Външен", You cannot edit root node.,Не можете да редактирате корен възел., You cannot restart a Subscription that is not cancelled.,"Не можете да рестартирате абонамент, който не е анулиран.", -You don't have enought Loyalty Points to redeem,"Нямате достатъчно точки за лоялност, за да осребрите", +You don't have enough Loyalty Points to redeem,"Нямате достатъчно точки за лоялност, за да осребрите", You have already assessed for the assessment criteria {}.,Вече оценихте критериите за оценка {}., You have already selected items from {0} {1},Вие вече сте избрали елементи от {0} {1}, You have been invited to collaborate on the project: {0},Вие сте били поканени да си сътрудничат по проекта: {0}, diff --git a/erpnext/translations/bn.csv b/erpnext/translations/bn.csv index a944d99ecbf2..75532418ea40 100644 --- a/erpnext/translations/bn.csv +++ b/erpnext/translations/bn.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',আপনি প্রকল্প প্রকার 'বহিরাগত' মুছে ফেলতে পারবেন না, You cannot edit root node.,আপনি রুট নোড সম্পাদনা করতে পারবেন না।, You cannot restart a Subscription that is not cancelled.,আপনি সাবস্ক্রিপশনটি বাতিল না করা পুনরায় শুরু করতে পারবেন না, -You don't have enought Loyalty Points to redeem,আপনি বিক্রি করার জন্য আনুগত্য পয়েন্ট enought না, +You don't have enough Loyalty Points to redeem,আপনি বিক্রি করার জন্য আনুগত্য পয়েন্ট enough না, You have already assessed for the assessment criteria {}.,"আপনি ইতিমধ্যে মূল্যায়ন মানদণ্ডের জন্য মূল্যায়ন করে নিলে, {}।", You have already selected items from {0} {1},আপনি ইতিমধ্যে থেকে আইটেম নির্বাচন করা আছে {0} {1}, You have been invited to collaborate on the project: {0},আপনি প্রকল্পের সহযোগীতা করার জন্য আমন্ত্রণ জানানো হয়েছে: {0}, diff --git a/erpnext/translations/bs.csv b/erpnext/translations/bs.csv index 2d9c26d15b12..ab82d0fc1072 100644 --- a/erpnext/translations/bs.csv +++ b/erpnext/translations/bs.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Ne možete obrisati tip projekta 'Spoljni', You cannot edit root node.,Ne možete uređivati root čvor., You cannot restart a Subscription that is not cancelled.,Ne možete ponovo pokrenuti pretplatu koja nije otkazana., -You don't have enought Loyalty Points to redeem,Ne iskoristite Loyalty Points za otkup, +You don't have enough Loyalty Points to redeem,Ne iskoristite Loyalty Points za otkup, You have already assessed for the assessment criteria {}.,Ste već ocijenili za kriterije procjene {}., You have already selected items from {0} {1},Vi ste već odabrane stavke iz {0} {1}, You have been invited to collaborate on the project: {0},Vi ste pozvani da surađuju na projektu: {0}, diff --git a/erpnext/translations/ca.csv b/erpnext/translations/ca.csv index 85c62851c8d5..afac395efa94 100644 --- a/erpnext/translations/ca.csv +++ b/erpnext/translations/ca.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',No es pot eliminar el tipus de projecte 'Extern', You cannot edit root node.,No podeu editar el node arrel., You cannot restart a Subscription that is not cancelled.,No podeu reiniciar una subscripció que no es cancel·la., -You don't have enought Loyalty Points to redeem,No teniu punts de fidelització previstos per bescanviar, +You don't have enough Loyalty Points to redeem,No teniu punts de fidelització previstos per bescanviar, You have already assessed for the assessment criteria {}.,Vostè ja ha avaluat pels criteris d'avaluació {}., You have already selected items from {0} {1},Ja ha seleccionat articles de {0} {1}, You have been invited to collaborate on the project: {0},Se li ha convidat a col·laborar en el projecte: {0}, diff --git a/erpnext/translations/cs.csv b/erpnext/translations/cs.csv index 3fb67e787087..64dec565400b 100644 --- a/erpnext/translations/cs.csv +++ b/erpnext/translations/cs.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Nelze odstranit typ projektu "Externí", You cannot edit root node.,Nelze upravit kořenový uzel., You cannot restart a Subscription that is not cancelled.,"Nelze znovu spustit odběr, který není zrušen.", -You don't have enought Loyalty Points to redeem,Nemáte dostatečné věrnostní body k uplatnění, +You don't have enough Loyalty Points to redeem,Nemáte dostatečné věrnostní body k uplatnění, You have already assessed for the assessment criteria {}.,Již jste hodnotili kritéria hodnocení {}., You have already selected items from {0} {1},Již jste vybrané položky z {0} {1}, You have been invited to collaborate on the project: {0},Byli jste pozváni ke spolupráci na projektu: {0}, diff --git a/erpnext/translations/da.csv b/erpnext/translations/da.csv index f0654b998da4..8a735b1e2714 100644 --- a/erpnext/translations/da.csv +++ b/erpnext/translations/da.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Du kan ikke slette Project Type 'Ekstern', You cannot edit root node.,Du kan ikke redigere root node., You cannot restart a Subscription that is not cancelled.,"Du kan ikke genstarte en abonnement, der ikke annulleres.", -You don't have enought Loyalty Points to redeem,Du har ikke nok loyalitetspoint til at indløse, +You don't have enough Loyalty Points to redeem,Du har ikke nok loyalitetspoint til at indløse, You have already assessed for the assessment criteria {}.,Du har allerede vurderet for bedømmelseskriterierne {}., You have already selected items from {0} {1},Du har allerede valgt elementer fra {0} {1}, You have been invited to collaborate on the project: {0},Du er blevet inviteret til at samarbejde om sag: {0}, diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index 840ba6b9d0d3..0c840f44ea89 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -1147,6 +1147,7 @@ Get Items from Prescriptions,Holen Sie sich Artikel aus Verordnungen, Get Items from Product Bundle,Artikel aus dem Produkt-Bundle übernehmen, Get Suppliers,Holen Sie sich Lieferanten, Get Suppliers By,Holen Sie sich Lieferanten durch, +Get Supplier Group Details,Werte aus Lieferantengruppe übernehmen, Get Updates,Newsletter abonnieren, Get customers from,Holen Sie Kunden von, Get from Patient Encounter,Von der Patientenbegegnung erhalten, @@ -1311,7 +1312,7 @@ Invalid GSTIN! A GSTIN must have 15 characters.,Ungültige GSTIN! Eine GSTIN mus Invalid GSTIN! First 2 digits of GSTIN should match with State number {0}.,Ungültige GSTIN! Die ersten beiden Ziffern von GSTIN sollten mit der Statusnummer {0} übereinstimmen., Invalid GSTIN! The input you've entered doesn't match the format of GSTIN.,Ungültige GSTIN! Die von Ihnen eingegebene Eingabe stimmt nicht mit dem Format von GSTIN überein., Invalid Posting Time,Ungültige Buchungszeit, -Invalid Purchase Invoice,Ungültige Einkaufsrechnung, +Invalid Purchase Invoice,Ungültige Eingangsrechnung, Invalid attribute {0} {1},Ungültiges Attribut {0} {1}, Invalid quantity specified for item {0}. Quantity should be greater than 0.,Ungültzige Anzahl für Artikel {0} angegeben. Anzahl sollte größer als 0 sein., Invalid reference {0} {1},Ungültige Referenz {0} {1}, @@ -1969,7 +1970,7 @@ Please click on 'Generate Schedule',"Bitte auf ""Zeitplan generieren"" klicken", Please click on 'Generate Schedule' to fetch Serial No added for Item {0},"Bitte auf ""Zeitplan generieren"" klicken, um die Seriennummer für Artikel {0} abzurufen", Please click on 'Generate Schedule' to get schedule,"Bitte auf ""Zeitplan generieren"" klicken, um den Zeitplan zu erhalten", Please confirm once you have completed your training,"Bitte bestätigen Sie, sobald Sie Ihre Ausbildung abgeschlossen haben", -Please create purchase receipt or purchase invoice for the item {0},Bitte erstellen Sie eine Kaufquittung oder eine Kaufrechnung für den Artikel {0}, +Please create purchase receipt or purchase invoice for the item {0},Bitte erstellen Sie eine Kaufquittung oder eine Eingangsrechnungen für den Artikel {0}, Please define grade for Threshold 0%,Bitte definieren Sie Grade for Threshold 0%, Please enable Applicable on Booking Actual Expenses,Bitte aktivieren Sie Anwendbar bei der Buchung von tatsächlichen Ausgaben, Please enable Applicable on Purchase Order and Applicable on Booking Actual Expenses,Bitte aktivieren Sie Anwendbar bei Bestellung und Anwendbar bei Buchung von tatsächlichen Ausgaben, @@ -3351,7 +3352,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Sie können den Projekttyp 'Extern' nicht löschen, You cannot edit root node.,Sie können den Stammknoten nicht bearbeiten., You cannot restart a Subscription that is not cancelled.,Sie können ein nicht abgebrochenes Abonnement nicht neu starten., -You don't have enought Loyalty Points to redeem,Sie haben nicht genügend Treuepunkte zum Einlösen, +You don't have enough Loyalty Points to redeem,Sie haben nicht genügend Treuepunkte zum Einlösen, You have already assessed for the assessment criteria {}.,Sie haben bereits für die Bewertungskriterien beurteilt., You have already selected items from {0} {1},Sie haben bereits Elemente aus {0} {1} gewählt, You have been invited to collaborate on the project: {0},Sie wurden zur Zusammenarbeit für das Projekt {0} eingeladen., @@ -4936,7 +4937,7 @@ POS Customer Group,POS Kundengruppe, POS Field,POS-Feld, POS Item Group,POS Artikelgruppe, Company Address,Anschrift des Unternehmens, -Update Stock,Lagerbestand aktualisieren, +Update Stock,Lagerbestand aktualisieren, Ignore Pricing Rule,Preisregel ignorieren, Applicable for Users,Anwendbar für Benutzer, Sales Invoice Payment,Ausgangsrechnung-Zahlungen, @@ -5006,7 +5007,7 @@ ACC-PINV-.YYYY.-,ACC-PINV-.JJJJ.-, Tax Withholding Category,Steuereinbehalt Kategorie, Edit Posting Date and Time,Buchungsdatum und -uhrzeit bearbeiten, Is Paid,Ist bezahlt, -Is Return (Debit Note),ist Rücklieferung (Lastschrift), +Is Return (Debit Note),Ist Rechnungskorrektur (Retoure), Apply Tax Withholding Amount,Steuereinbehaltungsbetrag anwenden, Accounting Dimensions ,Buchhaltung Dimensionen, Supplier Invoice Details,Lieferant Rechnungsdetails, @@ -5066,6 +5067,7 @@ Credit To,Gutschreiben auf, Party Account Currency,Währung des Kontos der Partei, Against Expense Account,Zu Aufwandskonto, Inter Company Invoice Reference,Unternehmensübergreifende Rechnungsreferenz, +Internal Supplier,Interner Lieferant, Is Internal Supplier,Ist interner Lieferant, Start date of current invoice's period,Startdatum der laufenden Rechnungsperiode, End date of current invoice's period,Schlußdatum der laufenden Eingangsrechnungsperiode, @@ -5131,8 +5133,7 @@ Default Bank / Cash account will be automatically updated in Salary Journal Entr ACC-SINV-.YYYY.-,ACC-SINV-.JJJJ.-, Include Payment (POS),(POS) Zahlung einschließen, Offline POS Name,Offline-Verkaufsstellen-Name, -Is Return (Credit Note),ist Rücklieferung (Gutschrift), -Return Against Sales Invoice,Zurück zur Kundenrechnung, +Is Return (Credit Note),Ist Rechnungskorrektur (Retoure), Update Billed Amount in Sales Order,Aktualisierung des Rechnungsbetrags im Auftrag, Customer PO Details,Auftragsdetails, Customer's Purchase Order,Bestellung des Kunden, @@ -5514,6 +5515,8 @@ Tracking,Verfolgung, Ref SQ,Ref-SQ, Inter Company Order Reference,Inter Company Bestellreferenz, Supplier Part Number,Lieferanten-Artikelnummer, +Supplier Primary Contact,Hauptkontakt des Lieferanten, +Supplier Primary Address,Hauptadresse des Lieferanten, Billed Amt,Rechnungsbetrag, Warehouse and Reference,Lager und Referenz, To be delivered to customer,Zur Auslieferung an den Kunden, @@ -7669,10 +7672,10 @@ Default Company Bank Account,Standard-Bankkonto des Unternehmens, From Lead,Aus Lead, Account Manager,Kundenberater, Accounts Manager,Buchhalter, -Allow Sales Invoice Creation Without Sales Order,Ermöglichen Sie die Erstellung von Kundenrechnungen ohne Auftrag, -Allow Sales Invoice Creation Without Delivery Note,Ermöglichen Sie die Erstellung einer Ausgangsrechnung ohne Lieferschein, +Allow Sales Invoice Creation Without Sales Order,Ermöglichen Sie die Erstellung von Ausgangsrechnungen ohne Auftrag, +Allow Sales Invoice Creation Without Delivery Note,Ermöglichen Sie die Erstellung von Ausgangsrechnungen ohne Lieferschein, Default Price List,Standardpreisliste, -Primary Address and Contact Detail,Primäre Adresse und Kontaktdetails, +Primary Address and Contact,Hauptadresse und -kontakt, "Select, to make the customer searchable with these fields","Wählen Sie, um den Kunden mit diesen Feldern durchsuchbar zu machen", Customer Primary Contact,Hauptkontakt des Kunden, "Reselect, if the chosen contact is edited after save","Wählen Sie erneut, wenn der ausgewählte Kontakt nach dem Speichern bearbeitet wird", @@ -7990,7 +7993,7 @@ Customs Tariff Number,Zolltarifnummer, Tariff Number,Tarifnummer, Delivery To,Lieferung an, MAT-DN-.YYYY.-,MAT-DN-.YYYY.-, -Is Return,Ist Rückgabe, +Is Return,Ist Retoure, Issue Credit Note,Gutschrift ausgeben, Return Against Delivery Note,Zurück zum Lieferschein, Customer's Purchase Order No,Bestellnummer des Kunden, @@ -9594,8 +9597,8 @@ This role is allowed to submit transactions that exceed credit limits,"Diese Rol Show Inclusive Tax in Print,Inklusive Steuern im Druck anzeigen, Only select this if you have set up the Cash Flow Mapper documents,"Wählen Sie diese Option nur, wenn Sie die Cash Flow Mapper-Dokumente eingerichtet haben", Payment Channel,Zahlungskanal, -Is Purchase Order Required for Purchase Invoice & Receipt Creation?,Ist für die Erstellung von Kaufrechnungen und Quittungen eine Bestellung erforderlich?, -Is Purchase Receipt Required for Purchase Invoice Creation?,Ist für die Erstellung der Kaufrechnung ein Kaufbeleg erforderlich?, +Is Purchase Order Required for Purchase Invoice & Receipt Creation?,Ist für die Erstellung von Eingangsrechnungen und Quittungen eine Bestellung erforderlich?, +Is Purchase Receipt Required for Purchase Invoice Creation?,Ist für die Erstellung der Eingangsrechnungen ein Kaufbeleg erforderlich?, Maintain Same Rate Throughout the Purchase Cycle,Behalten Sie den gleichen Preis während des gesamten Kaufzyklus bei, Allow Item To Be Added Multiple Times in a Transaction,"Zulassen, dass ein Element in einer Transaktion mehrmals hinzugefügt wird", Suppliers,Lieferanten, @@ -9653,8 +9656,8 @@ Purchase Order already created for all Sales Order items,Bestellung bereits für Select Items,Gegenstände auswählen, Against Default Supplier,Gegen Standardlieferanten, Auto close Opportunity after the no. of days mentioned above,Gelegenheit zum automatischen Schließen nach der Nr. der oben genannten Tage, -Is Sales Order Required for Sales Invoice & Delivery Note Creation?,Ist ein Auftrag für die Erstellung von Kundenrechnungen und Lieferscheinen erforderlich?, -Is Delivery Note Required for Sales Invoice Creation?,Ist für die Erstellung der Ausgangsrechnung ein Lieferschein erforderlich?, +Is Sales Order Required for Sales Invoice & Delivery Note Creation?,Ist ein Auftrag für die Erstellung von Ausgangsrechnungen und Lieferscheinen erforderlich?, +Is Delivery Note Required for Sales Invoice Creation?,Ist ein Lieferschein für die Erstellung von Ausgangsrechnungen erforderlich?, How often should Project and Company be updated based on Sales Transactions?,Wie oft sollten Projekt und Unternehmen basierend auf Verkaufstransaktionen aktualisiert werden?, Allow User to Edit Price List Rate in Transactions,Benutzer darf Preisliste in Transaktionen bearbeiten, Allow Item to Be Added Multiple Times in a Transaction,"Zulassen, dass ein Element in einer Transaktion mehrmals hinzugefügt wird", @@ -9800,7 +9803,7 @@ or it is not the default inventory account,oder es ist nicht das Standard-Invent Expense Head Changed,Ausgabenkopf geändert, because expense is booked against this account in Purchase Receipt {},weil die Kosten für dieses Konto im Kaufbeleg {} gebucht werden, as no Purchase Receipt is created against Item {}. ,da für Artikel {} kein Kaufbeleg erstellt wird., -This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice,"Dies erfolgt zur Abrechnung von Fällen, in denen der Kaufbeleg nach der Kaufrechnung erstellt wird", +This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice,"Dies erfolgt zur Abrechnung von Fällen, in denen der Kaufbeleg nach der Eingangsrechnung erstellt wird", Purchase Order Required for item {},Bestellung erforderlich für Artikel {}, To submit the invoice without purchase order please set {} ,"Um die Rechnung ohne Bestellung einzureichen, setzen Sie bitte {}", as {} in {},wie in {}, diff --git a/erpnext/translations/el.csv b/erpnext/translations/el.csv index c241558e31c7..cacce1efadac 100644 --- a/erpnext/translations/el.csv +++ b/erpnext/translations/el.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Δεν μπορείτε να διαγράψετε τον τύπο έργου 'Εξωτερικό', You cannot edit root node.,Δεν μπορείτε να επεξεργαστείτε τον κόμβο ρίζας., You cannot restart a Subscription that is not cancelled.,Δεν μπορείτε να κάνετε επανεκκίνηση μιας συνδρομής που δεν ακυρώνεται., -You don't have enought Loyalty Points to redeem,Δεν διαθέτετε σημεία αφοσίωσης για εξαργύρωση, +You don't have enough Loyalty Points to redeem,Δεν διαθέτετε σημεία αφοσίωσης για εξαργύρωση, You have already assessed for the assessment criteria {}.,Έχετε ήδη αξιολογήσει τα κριτήρια αξιολόγησης {}., You have already selected items from {0} {1},Έχετε ήδη επιλεγμένα αντικείμενα από {0} {1}, You have been invited to collaborate on the project: {0},Έχετε προσκληθεί να συνεργαστούν για το έργο: {0}, diff --git a/erpnext/translations/es.csv b/erpnext/translations/es.csv index 9996fe54c15d..8999d90ae876 100644 --- a/erpnext/translations/es.csv +++ b/erpnext/translations/es.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',No puede eliminar Tipo de proyecto 'Externo', You cannot edit root node.,No puedes editar el nodo raíz., You cannot restart a Subscription that is not cancelled.,No puede reiniciar una suscripción que no está cancelada., -You don't have enought Loyalty Points to redeem,No tienes suficientes puntos de lealtad para canjear, +You don't have enough Loyalty Points to redeem,No tienes suficientes puntos de lealtad para canjear, You have already assessed for the assessment criteria {}.,Ya ha evaluado los criterios de evaluación {}., You have already selected items from {0} {1},Ya ha seleccionado artículos de {0} {1}, You have been invited to collaborate on the project: {0},Se le ha invitado a colaborar en el proyecto: {0}, diff --git a/erpnext/translations/et.csv b/erpnext/translations/et.csv index 6e60809fe7b9..0d509deda1aa 100644 --- a/erpnext/translations/et.csv +++ b/erpnext/translations/et.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Te ei saa projekti tüübi "Väline" kustutada, You cannot edit root node.,Sa ei saa redigeerida juursõlme., You cannot restart a Subscription that is not cancelled.,Te ei saa tellimust uuesti katkestada., -You don't have enought Loyalty Points to redeem,"Teil pole lojaalsuspunkte, mida soovite lunastada", +You don't have enough Loyalty Points to redeem,"Teil pole lojaalsuspunkte, mida soovite lunastada", You have already assessed for the assessment criteria {}.,Olete juba hinnanud hindamise kriteeriumid {}., You have already selected items from {0} {1},Olete juba valitud objektide {0} {1}, You have been invited to collaborate on the project: {0},Sind on kutsutud koostööd projekti: {0}, diff --git a/erpnext/translations/fa.csv b/erpnext/translations/fa.csv index 7d18e27ad422..3e054606fc74 100644 --- a/erpnext/translations/fa.csv +++ b/erpnext/translations/fa.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',شما نمیتوانید نوع پروژه «خارجی» را حذف کنید, You cannot edit root node.,نمی توانید گره ریشه را ویرایش کنید, You cannot restart a Subscription that is not cancelled.,شما نمی توانید اشتراک را لغو کنید., -You don't have enought Loyalty Points to redeem,شما نمیتوانید امتیازات وفاداری خود را به دست آورید, +You don't have enough Loyalty Points to redeem,شما نمیتوانید امتیازات وفاداری خود را به دست آورید, You have already assessed for the assessment criteria {}.,شما در حال حاضر برای معیارهای ارزیابی ارزیابی {}., You have already selected items from {0} {1},شما در حال حاضر اقلام از انتخاب {0} {1}, You have been invited to collaborate on the project: {0},از شما دعوت شده برای همکاری در این پروژه: {0}, diff --git a/erpnext/translations/fi.csv b/erpnext/translations/fi.csv index c700f60d15c0..892261d01784 100644 --- a/erpnext/translations/fi.csv +++ b/erpnext/translations/fi.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Et voi poistaa projektityyppiä "Ulkoinen", You cannot edit root node.,Et voi muokata juurisolmua., You cannot restart a Subscription that is not cancelled.,"Et voi uudelleenkäynnistää tilausta, jota ei peruuteta.", -You don't have enought Loyalty Points to redeem,Sinulla ei ole tarpeeksi Loyalty Pointsia lunastettavaksi, +You don't have enough Loyalty Points to redeem,Sinulla ei ole tarpeeksi Loyalty Pointsia lunastettavaksi, You have already assessed for the assessment criteria {}.,Olet jo arvioitu arviointikriteerit {}., You have already selected items from {0} {1},Olet jo valitut kohteet {0} {1}, You have been invited to collaborate on the project: {0},Sinut on kutsuttu yhteistyöhön projektissa {0}, diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv index ab9bf7d9c147..0865e2f7a7d9 100644 --- a/erpnext/translations/fr.csv +++ b/erpnext/translations/fr.csv @@ -115,7 +115,7 @@ Add Customers,Ajouter des clients, Add Employees,Ajouter des employés, Add Item,Ajouter un Article, Add Items,Ajouter des articles, -Add Leads,Créer des Prospects, +Add Leads,Créer des Leads, Add Multiple Tasks,Ajouter plusieurs tâches, Add Row,Ajouter une Ligne, Add Sales Partners,Ajouter des partenaires commerciaux, @@ -658,8 +658,8 @@ Create Invoice,Créer une facture, Create Invoices,Créer des factures, Create Job Card,Créer une carte de travail, Create Journal Entry,Créer une entrée de journal, -Create Lead,Créer un Prospect, -Create Leads,Créer des Prospects, +Create Lead,Créer un Lead, +Create Leads,Créer des Lead, Create Maintenance Visit,Créer une visite de maintenance, Create Material Request,Créer une demande de matériel, Create Multiple,Créer plusieurs, @@ -951,7 +951,7 @@ End time cannot be before start time,L'heure de fin ne peut pas être avant l'he Ends On date cannot be before Next Contact Date.,La date de fin ne peut pas être avant la prochaine date de contact, Energy,Énergie, Engineer,Ingénieur, -Enough Parts to Build,Pièces Suffisantes pour Construire +Enough Parts to Build,Pièces Suffisantes pour Construire, Enroll,Inscrire, Enrolling student,Inscrire un étudiant, Enrolling students,Inscription des étudiants, @@ -1426,13 +1426,12 @@ Last Purchase Price,Dernier prix d'achat, Last Purchase Rate,Dernier Prix d'Achat, Latest,Dernier, Latest price updated in all BOMs,Prix les plus récents mis à jour dans toutes les nomenclatures, -Lead,Prospect, -Lead Count,Nombre de Prospects, +Lead Count,Nombre de Lead, Lead Owner,Responsable du Prospect, -Lead Owner cannot be same as the Lead,Le Responsable du Prospect ne peut pas être identique au Prospect, +Lead Owner cannot be same as the Lead,Le Responsable du Prospect ne peut pas être identique au Lead, Lead Time Days,Jours de Délai, Lead to Quotation,Du Prospect au Devis, -"Leads help you get business, add all your contacts and more as your leads","Les prospects vous aident à obtenir des contrats, ajoutez tous vos contacts et plus dans votre liste de prospects", +"Leads help you get business, add all your contacts and more as your leads","Les lead vous aident à obtenir des contrats, ajoutez tous vos contacts et plus dans votre liste de lead", Learn,Apprendre, Leave Approval Notification,Notification d'approbation de congés, Leave Blocked,Laisser Verrouillé, @@ -1596,7 +1595,7 @@ Middle Name,Deuxième Nom, Middle Name (Optional),Deuxième Prénom (Optionnel), Min Amt can not be greater than Max Amt,Min Amt ne peut pas être supérieur à Max Amt, Min Qty can not be greater than Max Qty,Qté Min ne peut pas être supérieure à Qté Max, -Minimum Lead Age (Days),Âge Minimum du Prospect (Jours), +Minimum Lead Age (Days),Âge Minimum du lead (Jours), Miscellaneous Expenses,Charges Diverses, Missing Currency Exchange Rates for {0},Taux de Change Manquant pour {0}, Missing email template for dispatch. Please set one in Delivery Settings.,Modèle de courrier électronique manquant pour l'envoi. Veuillez en définir un dans les paramètres de livraison., @@ -1676,7 +1675,7 @@ New {0} pricing rules are created,De nouvelles règles de tarification {0} sont Newsletters,Newsletters, Newspaper Publishers,Éditeurs de journaux, Next,Suivant, -Next Contact By cannot be same as the Lead Email Address,Prochain Contact Par ne peut être identique à l’Adresse Email du Prospect, +Next Contact By cannot be same as the Lead Email Address,Prochain Contact Par ne peut être identique à l’Adresse Email du Lead, Next Contact Date cannot be in the past,La Date de Prochain Contact ne peut pas être dans le passé, Next Steps,Prochaines étapes, No Action,Pas d'action, @@ -1808,9 +1807,9 @@ Operation Time must be greater than 0 for Operation {0},Temps de l'Opération do Operations,Opérations, Operations cannot be left blank,Les opérations ne peuvent pas être laissées vides, Opp Count,Compte d'Opportunités, -Opp/Lead %,Opp / Prospect %, +Opp/Lead %,Opp / Lead %, Opportunities,Opportunités, -Opportunities by lead source,Opportunités par source de plomb, +Opportunities by lead source,Opportunités par source de lead, Opportunity,Opportunité, Opportunity Amount,Montant de l'opportunité, Optional Holiday List not set for leave period {0},Une liste de vacances facultative n'est pas définie pour la période de congé {0}, @@ -2007,7 +2006,7 @@ Please mention Basic and HRA component in Company,Veuillez mentionner les compos Please mention Round Off Account in Company,Veuillez indiquer le Compte d’Arrondi de la Société, Please mention Round Off Cost Center in Company,Veuillez indiquer le Centre de Coûts d’Arrondi de la Société, Please mention no of visits required,Veuillez indiquer le nb de visites requises, -Please mention the Lead Name in Lead {0},Veuillez mentionner le nom du Prospect dans le Prospect {0}, +Please mention the Lead Name in Lead {0},Veuillez mentionner le nom du Lead dans le Lead {0}, Please pull items from Delivery Note,Veuillez récupérer les articles des Bons de Livraison, Please register the SIREN number in the company information file,Veuillez enregistrer le numéro SIREN dans la fiche d'information de la société, Please remove this Invoice {0} from C-Form {1},Veuillez retirez cette Facture {0} du C-Form {1}, @@ -2277,7 +2276,7 @@ Queued for replacing the BOM. It may take a few minutes.,En file d'attente pour Queued for updating latest price in all Bill of Materials. It may take a few minutes.,Mise à jour des prix les plus récents dans toutes les nomenclatures en file d'attente. Cela peut prendre quelques minutes., Quick Journal Entry,Écriture Rapide dans le Journal, Quot Count,Compte de Devis, -Quot/Lead %,Devis / Prospects %, +Quot/Lead %,Devis / Lead %, Quotation,Devis, Quotation {0} is cancelled,Devis {0} est annulée, Quotation {0} not of type {1},Le devis {0} n'est pas du type {1}, @@ -2285,7 +2284,7 @@ Quotations,Devis, "Quotations are proposals, bids you have sent to your customers","Les devis sont des propositions, offres que vous avez envoyées à vos clients", Quotations received from Suppliers.,Devis reçus des Fournisseurs., Quotations: ,Devis :, -Quotes to Leads or Customers.,Devis de Prospects ou Clients., +Quotes to Leads or Customers.,Devis de Lead ou Clients., RFQs are not allowed for {0} due to a scorecard standing of {1},Les Appels d'Offres ne sont pas autorisés pour {0} en raison d'une note de {1} sur la fiche d'évaluation, Range,Plage, Rate,Prix, @@ -2888,7 +2887,7 @@ Supplies made to UIN holders,Fournitures faites aux titulaires de l'UIN, Supplies made to Unregistered Persons,Fournitures faites à des personnes non inscrites, Suppliies made to Composition Taxable Persons,Suppleies à des personnes assujetties à la composition, Supply Type,Type d'approvisionnement, -Support,"Assistance/Support", +Support,Assistance/Support, Support Analytics,Analyse de l'assistance, Support Settings,Paramètres du module Assistance, Support Tickets,Ticket d'assistance, @@ -3037,7 +3036,7 @@ To Date must be greater than From Date,La date de fin doit être supérieure à To Date should be within the Fiscal Year. Assuming To Date = {0},La Date Finale doit être dans l'exercice. En supposant Date Finale = {0}, To Datetime,À la Date, To Deliver,À Livrer, -{} To Deliver,{} à livrer +{} To Deliver,{} à livrer, To Deliver and Bill,À Livrer et Facturer, To Fiscal Year,À l'année fiscale, To GSTIN,GSTIN (Destination), @@ -3122,7 +3121,7 @@ Total(Amt),Total (Mnt), Total(Qty),Total (Qté), Traceability,Traçabilité, Traceback,Retraçage, -Track Leads by Lead Source.,Suivre les prospects par sources, +Track Leads by Lead Source.,Suivre les leads par sources, Training,Formation, Training Event,Événement de formation, Training Events,Événements de formation, @@ -3243,8 +3242,8 @@ View Chart of Accounts,Voir le plan comptable, View Fees Records,Voir les honoraires, View Form,Voir le formulaire, View Lab Tests,Afficher les tests de laboratoire, -View Leads,Voir Prospects, -View Ledger,Voir le Livre, +View Leads,Voir Lead, +View Ledger,Voir le Journal, View Now,Voir maintenant, View a list of all the help videos,Afficher la liste de toutes les vidéos d'aide, View in Cart,Voir Panier, @@ -3338,7 +3337,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Vous ne pouvez pas supprimer le Type de Projet 'Externe', You cannot edit root node.,Vous ne pouvez pas modifier le nœud racine., You cannot restart a Subscription that is not cancelled.,Vous ne pouvez pas redémarrer un abonnement qui n'est pas annulé., -You don't have enought Loyalty Points to redeem,Vous n'avez pas assez de points de fidélité à échanger, +You don't have enough Loyalty Points to redeem,Vous n'avez pas assez de points de fidélité à échanger, You have already assessed for the assessment criteria {}.,Vous avez déjà évalué les critères d'évaluation {}., You have already selected items from {0} {1},Vous avez déjà choisi des articles de {0} {1}, You have been invited to collaborate on the project: {0},Vous avez été invité à collaborer sur le projet : {0}, @@ -3536,7 +3535,7 @@ Quality Feedback Template,Modèle de commentaires sur la qualité, Rules for applying different promotional schemes.,Règles d'application de différents programmes promotionnels., Shift,Décalage, Show {0},Montrer {0}, -"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Caractères spéciaux sauf "-", "#", ".", "/", "{{" Et "}}" non autorisés dans les séries de nommage {0}", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Caractères spéciaux sauf "-", "#", ".", "/", "{{" Et "}}" non autorisés dans les masques de numérotation {0}", Target Details,Détails de la cible, {0} already has a Parent Procedure {1}.,{0} a déjà une procédure parent {1}., API,API, @@ -3551,7 +3550,7 @@ Importing {0} of {1},Importer {0} de {1}, Invalid URL,URL invalide, Landscape,Paysage, Last Sync On,Dernière synchronisation le, -Naming Series,Nom de série, +Naming Series,Masque de numérotation, No data to export,Aucune donnée à exporter, Portrait,Portrait, Print Heading,Imprimer Titre, @@ -3677,7 +3676,7 @@ Couldn't Set Service Level Agreement {0}.,Impossible de définir le contrat de s Country,Pays, Country Code in File does not match with country code set up in the system,Le code de pays dans le fichier ne correspond pas au code de pays configuré dans le système, Create New Contact,Créer un nouveau contact, -Create New Lead,Créer une nouvelle piste, +Create New Lead,Créer une nouvelle lead, Create Pick List,Créer une liste de choix, Create Quality Inspection for Item {0},Créer un contrôle qualité pour l'article {0}, Creating Accounts...,Création de comptes ..., @@ -3784,7 +3783,7 @@ Group Warehouses cannot be used in transactions. Please change the value of {0}, Help,Aidez-moi, Help Article,Article d’Aide, "Helps you keep tracks of Contracts based on Supplier, Customer and Employee","Vous aide à garder une trace des contrats en fonction du fournisseur, client et employé", -Helps you manage appointments with your leads,Vous aide à gérer les rendez-vous avec vos prospects, +Helps you manage appointments with your leads,Vous aide à gérer les rendez-vous avec vos leads, Home,Accueil, IBAN is not valid,IBAN n'est pas valide, Import Data from CSV / Excel files.,Importer des données à partir de fichiers CSV / Excel, @@ -3880,7 +3879,7 @@ Only expired allocation can be cancelled,Seule l'allocation expirée peut être Only users with the {0} role can create backdated leave applications,Seuls les utilisateurs avec le rôle {0} peuvent créer des demandes de congé antidatées, Open,Ouvert, Open Contact,Contact ouvert, -Open Lead,Ouvrir le Prospect, +Open Lead,Ouvrir le Lead, Opening and Closing,Ouverture et fermeture, Operating Cost as per Work Order / BOM,Coût d'exploitation selon l'ordre de fabrication / nomenclature, Order Amount,Montant de la commande, @@ -3926,7 +3925,7 @@ Please select another payment method. Stripe does not support transactions in cu Please select the customer.,S'il vous plaît sélectionner le client., Please set a Supplier against the Items to be considered in the Purchase Order.,Veuillez définir un fournisseur par rapport aux articles à prendre en compte dans la Commande d'Achat., Please set account heads in GST Settings for Compnay {0},Définissez les en-têtes de compte dans les paramètres de la TPS pour le service {0}., -Please set an email id for the Lead {0},Veuillez définir un identifiant de messagerie pour le prospect {0}., +Please set an email id for the Lead {0},Veuillez définir un identifiant de messagerie pour le lead {0}., Please set default UOM in Stock Settings,Veuillez définir l'UdM par défaut dans les paramètres de stock, Please set filter based on Item or Warehouse due to a large amount of entries.,Veuillez définir le filtre en fonction de l'article ou de l'entrepôt en raison d'une grande quantité d'entrées., Please set up the Campaign Schedule in the Campaign {0},Configurez le calendrier de la campagne dans la campagne {0}., @@ -4282,7 +4281,7 @@ Please set {0},Veuillez définir {0},supplier Draft,Brouillon,"docstatus,=,0" Cancelled,Annulé,"docstatus,=,2" Please setup Instructor Naming System in Education > Education Settings,Veuillez configurer le système de dénomination de l'instructeur dans Éducation> Paramètres de l'éducation, -Please set Naming Series for {0} via Setup > Settings > Naming Series,Veuillez définir la série de noms pour {0} via Configuration> Paramètres> Série de noms, +Please set Naming Series for {0} via Setup > Settings > Naming Series,Veuillez définir le masque de numérotation pour {0} via Configuration> Paramètres> Série de noms, UOM Conversion factor ({0} -> {1}) not found for item: {2},Facteur de conversion UdM ({0} -> {1}) introuvable pour l'article: {2}, Item Code > Item Group > Brand,Code article> Groupe d'articles> Marque, Customer > Customer Group > Territory,Client> Groupe de clients> Territoire, @@ -4297,7 +4296,7 @@ Fetch Serial Numbers based on FIFO,Récupérer les numéros de série basés sur Current Odometer Value should be greater than Last Odometer Value {0},La valeur actuelle de l'odomètre doit être supérieure à la dernière valeur de l'odomètre {0}, No additional expenses has been added,Aucune dépense supplémentaire n'a été ajoutée, Asset{} {assets_link} created for {},Élément {} {assets_link} créé pour {}, -Row {}: Asset Naming Series is mandatory for the auto creation for item {},Ligne {}: la série de noms d'éléments est obligatoire pour la création automatique de l'élément {}, +Row {}: Asset Naming Series is mandatory for the auto creation for item {},Ligne {}: Le masque de numérotation d'éléments est obligatoire pour la création automatique de l'élément {}, Assets not created for {0}. You will have to create asset manually.,Éléments non créés pour {0}. Vous devrez créer un actif manuellement., {0} {1} has accounting entries in currency {2} for company {3}. Please select a receivable or payable account with currency {2}.,{0} {1} a des écritures comptables dans la devise {2} pour l'entreprise {3}. Veuillez sélectionner un compte à recevoir ou à payer avec la devise {2}., Invalid Account,Compte invalide, @@ -4321,7 +4320,7 @@ Advanced Settings,Réglages avancés, Path,Chemin, Components,Composants, Verified By,Vérifié Par, -Invalid naming series (. missing) for {0},Série de noms non valide (. Manquante) pour {0}, +Invalid naming series (. missing) for {0},Masque de numérotation non valide (. Manquante) pour {0}, Filter Based On,Filtre basé sur, Reqd by date,Reqd par date, Manufacturer Part Number {0} is invalid,Le numéro de pièce du fabricant {0} n'est pas valide, @@ -4943,8 +4942,8 @@ Min Qty,Qté Min, Max Qty,Qté Max, Min Amt,Montant Min, Max Amt,Montant Max, -"If rate is zero them item will be treated as ""Free Item""",Si le prix est à 0 alors l'article sera traité comme article gratuit -Is Recursive,Est récursif +"If rate is zero them item will be treated as ""Free Item""",Si le prix est à 0 alors l'article sera traité comme article gratuit, +Is Recursive,Est récursif, "Discounts to be applied in sequential ranges like buy 1 get 1, buy 2 get 2, buy 3 get 3 and so on","La remise sera appliquée séquentiellement telque : acheter 1 => recupérer 1, acheter 2 => recupérer 2, acheter 3 => recupérer 3, etc..." Period Settings,Paramètres de période, Margin,Marge, @@ -5600,7 +5599,7 @@ Call Log,Journal d'appel, Received By,Reçu par, Caller Information,Informations sur l'appelant, Contact Name,Nom du Contact, -Lead Name,Nom du Prospect, +Lead Name,Nom du Lead, Ringing,Sonnerie, Missed,Manqué, Call Duration in seconds,Durée d'appel en secondes, @@ -5668,7 +5667,7 @@ Fulfilment Terms and Conditions,Termes et conditions d'exécution, Contract Template Fulfilment Terms,Conditions d'exécution du modèle de contrat, Email Campaign,Campagne Email, Email Campaign For ,Campagne d'email pour, -Lead is an Organization,Le prospect est une organisation, +Lead is an Organization,Le Lead est une organisation, CRM-LEAD-.YYYY.-,CRM-LEAD-.YYYY.-, Person Name,Nom de la Personne, Lost Quotation,Devis Perdu, @@ -5683,7 +5682,7 @@ Next Contact Date,Date du Prochain Contact, Ends On,Se termine le, Address & Contact,Adresse & Contact, Mobile No.,N° Mobile., -Lead Type,Type de Prospect, +Lead Type,Type de Lead, Channel Partner,Partenaire de Canal, Consultant,Consultant, Market Segment,Part de Marché, @@ -5706,7 +5705,7 @@ Opportunity Lost Reason,Raison perdue, Potential Sales Deal,Ventes Potentielles, CRM-OPP-.YYYY.-,CRM-OPP-YYYY.-, Opportunity From,Opportunité De, -Customer / Lead Name,Nom du Client / Prospect, +Customer / Lead Name,Nom du Client / Lead, Opportunity Type,Type d'Opportunité, Converted By,Converti par, Sales Stage,Stade de vente, @@ -5716,7 +5715,7 @@ To Discuss,À Discuter, With Items,Avec Articles, Probability (%),Probabilité (%), Contact Info,Information du Contact, -Customer / Lead Address,Adresse du Client / Prospect, +Customer / Lead Address,Adresse du Lead / Prospect, Contact Mobile No,N° de Portable du Contact, Enter name of campaign if source of enquiry is campaign,Entrez le nom de la campagne si la source de l'enquête est une campagne, Opportunity Date,Date d'Opportunité, @@ -5933,7 +5932,7 @@ Student Admission Program,Programme d'admission des étudiants, Minimum Age,Âge Minimum, Maximum Age,Âge Maximum, Application Fee,Frais de Dossier, -Naming Series (for Student Applicant),Nom de série (pour un candidat étudiant), +Naming Series (for Student Applicant),Masque de numérotation (pour un candidat étudiant), LMS Only,LMS seulement, EDU-APP-.YYYY.-,EDU-APP-YYYY.-, Application Status,État de la Demande, @@ -6424,7 +6423,7 @@ Hotel Reservation User,Utilisateur chargé des réservations d'hôtel, Hotel Room Reservation Item,Article de réservation de la chambre d'hôtel, Hotel Settings,Paramètres d'Hotel, Default Taxes and Charges,Taxes et frais par défaut, -Default Invoice Naming Series,Numéro de série par défaut pour les factures, +Default Invoice Naming Series,Masque de numérotation par défaut pour les factures, Additional Salary,Salaire supplémentaire, HR,RH, HR-ADS-.YY.-.MM.-,HR-ADS-.YY .-. MM.-, @@ -7240,7 +7239,7 @@ Replace,Remplacer, Update latest price in all BOMs,Mettre à jour le prix le plus récent dans toutes les nomenclatures, BOM Website Item,Article de nomenclature du Site Internet, BOM Website Operation,Opération de nomenclature du Site Internet, -Operation Time,Durée de l'Opération +Operation Time,Durée de l'Opération, PO-JOB.#####,PO-JOB. #####, Timing Detail,Détail du timing, Time Logs,Time Logs, @@ -7645,7 +7644,7 @@ Campaign Schedules,Horaires de campagne, Buyer of Goods and Services.,Acheteur des Biens et Services., CUST-.YYYY.-,CUST-.YYYY.-, Default Company Bank Account,Compte bancaire d'entreprise par défaut, -From Lead,Du Prospect, +From Lead,Du Lead, Account Manager,Gestionnaire de compte, Allow Sales Invoice Creation Without Sales Order,Autoriser la création de factures de vente sans commande client, Allow Sales Invoice Creation Without Delivery Note,Autoriser la création de factures de vente sans bon de livraison, @@ -7672,7 +7671,7 @@ Installation Date,Date d'Installation, Installation Time,Temps d'Installation, Installation Note Item,Article Remarque d'Installation, Installed Qty,Qté Installée, -Lead Source,Source du Prospect, +Lead Source,Source du Lead, Period Start Date,Date de début de la période, Period End Date,Date de fin de la période, Cashier,Caissier, @@ -8034,7 +8033,7 @@ Default Unit of Measure,Unité de Mesure par Défaut, Maintain Stock,Maintenir Stock, Standard Selling Rate,Prix de Vente Standard, Auto Create Assets on Purchase,Création automatique d'actifs à l'achat, -Asset Naming Series,Nom de série de l'actif, +Asset Naming Series,Masque de numérotation de l'actif, Over Delivery/Receipt Allowance (%),Surlivrance / indemnité de réception (%), Barcodes,Codes-barres, Shelf Life In Days,Durée de conservation en jours, @@ -8053,7 +8052,7 @@ Serial Nos and Batches,N° de Série et Lots, Has Batch No,A un Numéro de Lot, Automatically Create New Batch,Créer un Nouveau Lot Automatiquement, Batch Number Series,Série de numéros de lots, -"Example: ABCD.#####. If series is set and Batch No is not mentioned in transactions, then automatic batch number will be created based on this series. If you always want to explicitly mention Batch No for this item, leave this blank. Note: this setting will take priority over the Naming Series Prefix in Stock Settings.","Exemple: ABCD. #####. Si la série est définie et que le numéro de lot n'est pas mentionné dans les transactions, un numéro de lot sera automatiquement créé en avec cette série. Si vous préferez mentionner explicitement et systématiquement le numéro de lot pour cet article, laissez ce champ vide. Remarque: ce paramètre aura la priorité sur le préfixe de la série dans les paramètres de stock.", +"Example: ABCD.#####. If series is set and Batch No is not mentioned in transactions, then automatic batch number will be created based on this series. If you always want to explicitly mention Batch No for this item, leave this blank. Note: this setting will take priority over the Naming Series Prefix in Stock Settings.","Exemple: ABCD. #####. Si le masque est définie et que le numéro de lot n'est pas mentionné dans les transactions, un numéro de lot sera automatiquement créé en avec ce masque. Si vous préferez mentionner explicitement et systématiquement le numéro de lot pour cet article, laissez ce champ vide. Remarque: ce paramètre aura la priorité sur le préfixe du masque dans les paramètres de stock.", Has Expiry Date,A une date d'expiration, Retain Sample,Conserver l'échantillon, Max Sample Quantity,Quantité maximum d'échantillon, @@ -8353,8 +8352,8 @@ Inter Warehouse Transfer Settings,Paramètres de transfert entre entrepôts, Freeze Stock Entries,Geler les Entrées de Stocks, Stock Frozen Upto,Stock Gelé Jusqu'au, Batch Identification,Identification par lots, -Use Naming Series,Utiliser la série de noms, -Naming Series Prefix,Préfix du nom de série, +Use Naming Series,Utiliser le masque de numérotation, +Naming Series Prefix,Préfix du masque de numérotation, UOM Category,Catégorie d'unité de mesure (UdM), UOM Conversion Detail,Détails de Conversion de l'UdM, Variant Field,Champ de Variante, @@ -8517,8 +8516,8 @@ Item-wise Sales Register,Registre des Ventes par Article, Items To Be Requested,Articles À Demander, Reserved,Réservé, Itemwise Recommended Reorder Level,Renouvellement Recommandé par Article, -Lead Details,Détails du Prospect, -Lead Owner Efficiency,Efficacité des Responsables des Prospects, +Lead Details,Détails du Lead, +Lead Owner Efficiency,Efficacité des Responsables des Leads, Loan Repayment and Closure,Remboursement et clôture de prêts, Loan Security Status,État de la sécurité du prêt, Lost Opportunity,Occasion perdue, @@ -8824,7 +8823,7 @@ Is Inter State,Est Inter State, Purchase Details,Détails d'achat, Depreciation Posting Date,Date comptable de l'amortissement, "By default, the Supplier Name is set as per the Supplier Name entered. If you want Suppliers to be named by a ","Par défaut, le nom du fournisseur est défini selon le nom du fournisseur saisi. Si vous souhaitez que les fournisseurs soient nommés par un", - choose the 'Naming Series' option.,choisissez l'option 'Naming Series'., + choose the 'Naming Series' option.,choisissez l'option 'Masque de numérotation'., Configure the default Price List when creating a new Purchase transaction. Item prices will be fetched from this Price List.,Configurez la liste de prix par défaut lors de la création d'une nouvelle transaction d'achat. Les prix des articles seront extraits de cette liste de prix., "If this option is configured 'Yes', ERPNext will prevent you from creating a Purchase Invoice or Receipt without creating a Purchase Order first. This configuration can be overridden for a particular supplier by enabling the 'Allow Purchase Invoice Creation Without Purchase Order' checkbox in the Supplier master.","Si cette option est configurée «Oui», ERPNext vous empêchera de créer une facture d'achat ou un reçu sans créer d'abord une Commande d'Achat. Cette configuration peut être remplacée pour un fournisseur particulier en cochant la case «Autoriser la création de facture d'achat sans commmande d'achat» dans la fiche fournisseur.", "If this option is configured 'Yes', ERPNext will prevent you from creating a Purchase Invoice without creating a Purchase Receipt first. This configuration can be overridden for a particular supplier by enabling the 'Allow Purchase Invoice Creation Without Purchase Receipt' checkbox in the Supplier master.","Si cette option est configurée «Oui», ERPNext vous empêchera de créer une facture d'achat sans créer d'abord un reçu d'achat. Cette configuration peut être remplacée pour un fournisseur particulier en cochant la case "Autoriser la création de facture d'achat sans reçu d'achat" dans la fiche fournisseur.", @@ -9207,7 +9206,7 @@ Time Required (In Mins),Temps requis (en minutes), From Posting Date,À partir de la date de publication, To Posting Date,À la date de publication, No records found,Aucun enregistrement trouvé, -Customer/Lead Name,Nom du client / prospect, +Customer/Lead Name,Nom du client / lead, Unmarked Days,Jours non marqués, Jan,Jan, Feb,fév, @@ -9471,7 +9470,7 @@ Row {0}: Loan Security {1} added multiple times,Ligne {0}: Garantie de prêt {1} Row #{0}: Child Item should not be a Product Bundle. Please remove Item {1} and Save,Ligne n ° {0}: l'élément enfant ne doit pas être un ensemble de produits. Veuillez supprimer l'élément {1} et enregistrer, Credit limit reached for customer {0},Limite de crédit atteinte pour le client {0}, Could not auto create Customer due to the following missing mandatory field(s):,Impossible de créer automatiquement le client en raison du ou des champs obligatoires manquants suivants:, -Please create Customer from Lead {0}.,Veuillez créer un client à partir du prospect {0}., +Please create Customer from Lead {0}.,Veuillez créer un client à partir du lead {0}., Mandatory Missing,Obligatoire manquant, Please set Payroll based on in Payroll settings,Veuillez définir la paie en fonction des paramètres de paie, Additional Salary: {0} already exist for Salary Component: {1} for period {2} and {3},Salaire supplémentaire: {0} existe déjà pour le composant de salaire: {1} pour la période {2} et {3}, @@ -9834,92 +9833,104 @@ Enable European Access,Activer l'accès européen, Creating Purchase Order ...,Création d'une commande d'achat ..., "Select a Supplier from the Default Suppliers of the items below. On selection, a Purchase Order will be made against items belonging to the selected Supplier only.","Sélectionnez un fournisseur parmi les fournisseurs par défaut des articles ci-dessous. Lors de la sélection, une commande d'achat sera effectué contre des articles appartenant uniquement au fournisseur sélectionné.", Row #{}: You must select {} serial numbers for item {}.,Ligne n ° {}: vous devez sélectionner {} numéros de série pour l'article {}., -Update Rate as per Last Purchase,Mettre à jour avec les derniers prix d'achats -Company Shipping Address,Adresse d'expédition -Shipping Address Details,Détail d'adresse d'expédition -Company Billing Address,Adresse de la société de facturation +Update Rate as per Last Purchase,Mettre à jour avec les derniers prix d'achats, +Company Shipping Address,Adresse d'expédition, +Shipping Address Details,Détail d'adresse d'expédition, +Company Billing Address,Adresse de la société de facturation, Supplier Address Details, -Bank Reconciliation Tool,Outil de réconcialiation d'écritures bancaires -Supplier Contact,Contact fournisseur -Subcontracting,Sous traitance -Order Status,Statut de la commande -Build,Personnalisations avancées -Dispatch Address Name,Adresse de livraison intermédiaire -Amount Eligible for Commission,Montant éligible à comission -Grant Commission,Eligible aux commissions -Stock Transactions Settings, Paramétre des transactions -Role Allowed to Over Deliver/Receive, Rôle autorisé à dépasser cette limite -Users with this role are allowed to over deliver/receive against orders above the allowance percentage,Rôle Utilisateur qui sont autorisé à livrée/commandé au-delà de la limite -Over Transfer Allowance,Autorisation de limite de transfert -Quality Inspection Settings,Paramétre de l'inspection qualité -Action If Quality Inspection Is Rejected,Action si l'inspection qualité est rejetée -Disable Serial No And Batch Selector,Désactiver le sélecteur de numéro de lot/série -Is Rate Adjustment Entry (Debit Note),Est un justement du prix de la note de débit -Issue a debit note with 0 qty against an existing Sales Invoice,Creer une note de débit avec une quatité à O pour la facture -Control Historical Stock Transactions,Controle de l'historique des stransaction de stock +Bank Reconciliation Tool,Outil de réconcialiation d'écritures bancaires, +Supplier Contact,Contact fournisseur, +Subcontracting,Sous traitance, +Order Status,Statut de la commande, +Build,Personnalisations avancées, +Dispatch Address Name,Adresse de livraison intermédiaire, +Amount Eligible for Commission,Montant éligible à comission, +Grant Commission,Eligible aux commissions, +Stock Transactions Settings, Paramétre des transactions, +Role Allowed to Over Deliver/Receive, Rôle autorisé à dépasser cette limite, +Users with this role are allowed to over deliver/receive against orders above the allowance percentage,Rôle Utilisateur qui sont autorisé à livrée/commandé au-delà de la limite, +Over Transfer Allowance,Autorisation de limite de transfert, +Quality Inspection Settings,Paramétre de l'inspection qualité, +Action If Quality Inspection Is Rejected,Action si l'inspection qualité est rejetée, +Disable Serial No And Batch Selector,Désactiver le sélecteur de numéro de lot/série, +Is Rate Adjustment Entry (Debit Note),Est un justement du prix de la note de débit, +Issue a debit note with 0 qty against an existing Sales Invoice,Creer une note de débit avec une quatité à O pour la facture, +Control Historical Stock Transactions,Controle de l'historique des stransaction de stock, No stock transactions can be created or modified before this date.,Aucune transaction ne peux être créée ou modifié avant cette date. -Stock transactions that are older than the mentioned days cannot be modified.,Les transactions de stock plus ancienne que le nombre de jours ci-dessus ne peuvent être modifiées -Role Allowed to Create/Edit Back-dated Transactions,Rôle autorisé à créer et modifier des transactions anti-datée -"If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.","Les utilisateur de ce role pourront creer et modifier des transactions dans le passé. Si vide tout les utilisateurs pourrons le faire" -Auto Insert Item Price If Missing,Création du prix de l'article dans les listes de prix si abscent -Update Existing Price List Rate,Mise a jour automatique du prix dans les listes de prix -Show Barcode Field in Stock Transactions,Afficher le champ Code Barre dans les transactions de stock -Convert Item Description to Clean HTML in Transactions,Convertir les descriptions d'articles en HTML valide lors des transactions -Have Default Naming Series for Batch ID?,Nom de série par défaut pour les Lots ou Séries +Stock transactions that are older than the mentioned days cannot be modified.,Les transactions de stock plus ancienne que le nombre de jours ci-dessus ne peuvent être modifiées, +Role Allowed to Create/Edit Back-dated Transactions,Rôle autorisé à créer et modifier des transactions anti-datée, +"If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.",Les utilisateur de ce role pourront creer et modifier des transactions dans le passé. Si vide tout les utilisateurs pourrons le faire +Auto Insert Item Price If Missing,Création du prix de l'article dans les listes de prix si abscent, +Update Existing Price List Rate,Mise a jour automatique du prix dans les listes de prix, +Show Barcode Field in Stock Transactions,Afficher le champ Code Barre dans les transactions de stock, +Convert Item Description to Clean HTML in Transactions,Convertir les descriptions d'articles en HTML valide lors des transactions, +Have Default Naming Series for Batch ID?,Masque de numérotation par défaut pour les Lots ou Séries, "The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units","Le pourcentage de quantité que vous pourrez réceptionner en plus de la quantité commandée. Par exemple, vous avez commandé 100 unités, votre pourcentage de dépassement est de 10%, vous pourrez réceptionner 110 unités" -Allowed Items,Articles autorisés -Party Specific Item,Restriction d'article disponible -Restrict Items Based On,Type de critére de restriction -Based On Value,critére de restriction +Allowed Items,Articles autorisés, +Party Specific Item,Restriction d'article disponible, +Restrict Items Based On,Type de critére de restriction, +Based On Value,critére de restriction, Unit of Measure (UOM),Unité de mesure (UdM), Unit Of Measure (UOM),Unité de mesure (UdM), -CRM Settings,Paramètres CRM -Do Not Explode,Ne pas décomposer -Quick Access, Accés rapides -{} Available,{} Disponible.s -{} Pending,{} En attente.s -{} To Bill,{} à facturer -{} To Receive,{} A recevoir +CRM Settings,Paramètres CRM, +Do Not Explode,Ne pas décomposer, +Quick Access, Accés rapides, +{} Available,{} Disponible.s, +{} Pending,{} En attente.s, +{} To Bill,{} à facturer, +{} To Receive,{} A recevoir, {} Active,{} Actif.ve(s) {} Open,{} Ouvert.e(s) -Incorrect Data Report,Rapport de données incohérentes -Incorrect Serial No Valuation,Valorisation inccorecte par Num. Série / Lots -Incorrect Balance Qty After Transaction,Equilibre des quantités aprés une transaction +Incorrect Data Report,Rapport de données incohérentes, +Incorrect Serial No Valuation,Valorisation inccorecte par Num. Série / Lots, +Incorrect Balance Qty After Transaction,Equilibre des quantités aprés une transaction, Interview Type,Type d'entretien Interview Round,Cycle d'entretien Interview,Entretien Interview Feedback,Retour d'entretien -Journal Energy Point,Historique des points d'énergies +Journal Energy Point,Historique des points d'énergies, Billing Address Details,Adresse de facturation (détails) Supplier Address Details,Adresse Fournisseur (détails) -Retail,Commerce -Users,Utilisateurs -Permission Manager,Gestion des permissions -Fetch Timesheet,Récuprer les temps saisis -Get Supplier Group Details,Appliquer les informations depuis le Groupe de fournisseur -Quality Inspection(s),Inspection(s) Qualité -Set Advances and Allocate (FIFO),Affecter les encours au réglement -Apply Putaway Rule,Appliquer la régle de routage d'entrepot -Delete Transactions,Supprimer les transactions -Default Payment Discount Account,Compte par défaut des paiements de remise -Unrealized Profit / Loss Account,Compte de perte -Enable Provisional Accounting For Non Stock Items,Activer la provision pour les articles non stockés -Publish in Website,Publier sur le Site Web -List View,Vue en liste -Allow Excess Material Transfer,Autoriser les transfert de stock supérieurs à l'attendue -Allow transferring raw materials even after the Required Quantity is fulfilled,Autoriser les transfert de matiéres premiére mais si la quantité requise est atteinte -Add Corrective Operation Cost in Finished Good Valuation,Ajouter des opérations de correction de coût pour la valorisation des produits finis -Make Serial No / Batch from Work Order,Générer des numéros de séries / lots depuis les Ordres de Fabrications -System will automatically create the serial numbers / batch for the Finished Good on submission of work order,le systéme va créer des numéros de séries / lots à la validation des produit finis depuis les Ordres de Fabrications -Allow material consumptions without immediately manufacturing finished goods against a Work Order,Autoriser la consommation sans immédiatement fabriqué les produit fini dans les ordres de fabrication -Quality Inspection Parameter,Paramétre des Inspection Qualité -Parameter Group,Groupe de paramétre -E Commerce Settings,Paramétrage E-Commerce -Follow these steps to create a landing page for your store:,Suivez les intructions suivantes pour créer votre page d'accueil de boutique en ligne -Show Price in Quotation,Afficher les prix sur les devis -Add-ons,Extensions -Enable Wishlist,Activer la liste de souhaits -Enable Reviews and Ratings,Activer les avis et notes -Enable Recommendations,Activer les recommendations -Item Search Settings,Paramétrage de la recherche d'article -Purchase demande,Demande de materiel +Retail,Commerce, +Users,Utilisateurs, +Permission Manager,Gestion des permissions, +Fetch Timesheet,Récuprer les temps saisis, +Get Supplier Group Details,Appliquer les informations depuis le Groupe de fournisseur, +Quality Inspection(s),Inspection(s) Qualite, +Set Advances and Allocate (FIFO),Affecter les encours au réglement, +Apply Putaway Rule,Appliquer la régle de routage d'entrepot, +Delete Transactions,Supprimer les transactions, +Default Payment Discount Account,Compte par défaut des paiements de remise, +Unrealized Profit / Loss Account,Compte de perte, +Enable Provisional Accounting For Non Stock Items,Activer la provision pour les articles non stockés, +Publish in Website,Publier sur le Site Web, +List View,Vue en liste, +Allow Excess Material Transfer,Autoriser les transfert de stock supérieurs à l'attendue, +Allow transferring raw materials even after the Required Quantity is fulfilled,Autoriser les transfert de matiéres premiére mais si la quantité requise est atteinte, +Add Corrective Operation Cost in Finished Good Valuation,Ajouter des opérations de correction de coût pour la valorisation des produits finis, +Make Serial No / Batch from Work Order,Générer des numéros de séries / lots depuis les Ordres de Fabrications, +System will automatically create the serial numbers / batch for the Finished Good on submission of work order,le systéme va créer des numéros de séries / lots à la validation des produit finis depuis les Ordres de Fabrications, +Allow material consumptions without immediately manufacturing finished goods against a Work Order,Autoriser la consommation sans immédiatement fabriqué les produit fini dans les ordres de fabrication, +Quality Inspection Parameter,Paramétre des Inspection Qualite, +Parameter Group,Groupe de paramétre, +E Commerce Settings,Paramétrage E-Commerce, +Follow these steps to create a landing page for your store:,Suivez les intructions suivantes pour créer votre page d'accueil de boutique en ligne, +Show Price in Quotation,Afficher les prix sur les devis, +Add-ons,Extensions, +Enable Wishlist,Activer la liste de souhaits, +Enable Reviews and Ratings,Activer les avis et notes, +Enable Recommendations,Activer les recommendations, +Item Search Settings,Paramétrage de la recherche d'article, +Purchase demande,Demande de materiel, +Internal Customer,Client interne +Internal Supplier,Fournisseur interne +Contact & Address,Contact et Adresse +Primary Address and Contact,Adresse et contact principal +Supplier Primary Contact,Contact fournisseur principal +Supplier Primary Address,Adresse fournisseur principal +From Opportunity,Depuis l'opportunité +Default Receivable Accounts,Compte de débit par défaut +Receivable Accounts,Compte de débit +Mention if a non-standard receivable account,Veuillez mentionner s'il s'agit d'un compte débiteur non standard +Allow Purchase,Autoriser à l'achat +Inventory Settings,Paramétrage de l'inventaire diff --git a/erpnext/translations/gu.csv b/erpnext/translations/gu.csv index b26d2f3d0768..787e29d62192 100644 --- a/erpnext/translations/gu.csv +++ b/erpnext/translations/gu.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',તમે 'બાહ્ય' પ્રોજેક્ટ પ્રકારને કાઢી શકતા નથી, You cannot edit root node.,તમે રૂટ નોડને સંપાદિત કરી શકતા નથી., You cannot restart a Subscription that is not cancelled.,તમે સબ્સ્ક્રિપ્શન ફરીથી શરૂ કરી શકતા નથી કે જે રદ કરવામાં આવી નથી., -You don't have enought Loyalty Points to redeem,તમારી પાસે રિડીમ કરવા માટે વફાદારીના પોઇંટ્સ નથી, +You don't have enough Loyalty Points to redeem,તમારી પાસે રિડીમ કરવા માટે વફાદારીના પોઇંટ્સ નથી, You have already assessed for the assessment criteria {}.,જો તમે પહેલાથી જ આકારણી માપદંડ માટે આકારણી છે {}., You have already selected items from {0} {1},જો તમે પહેલાથી જ વસ્તુઓ પસંદ કરેલ {0} {1}, You have been invited to collaborate on the project: {0},તમે આ પ્રોજેક્ટ પર સહયોગ કરવા માટે આમંત્રિત કરવામાં આવ્યા છે: {0}, diff --git a/erpnext/translations/he.csv b/erpnext/translations/he.csv index e40b68e504f9..c8a9c7546e10 100644 --- a/erpnext/translations/he.csv +++ b/erpnext/translations/he.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',אינך יכול למחוק את סוג הפרויקט 'חיצוני', You cannot edit root node.,אינך יכול לערוך צומת שורש., You cannot restart a Subscription that is not cancelled.,אינך יכול להפעיל מחדש מנוי שאינו מבוטל., -You don't have enought Loyalty Points to redeem,אין לך מספיק נקודות נאמנות למימוש, +You don't have enough Loyalty Points to redeem,אין לך מספיק נקודות נאמנות למימוש, You have already assessed for the assessment criteria {}.,כבר הערכת את קריטריוני ההערכה {}., You have already selected items from {0} {1},בחרת כבר פריטים מ- {0} {1}, You have been invited to collaborate on the project: {0},הוזמנת לשתף פעולה על הפרויקט: {0}, diff --git a/erpnext/translations/hi.csv b/erpnext/translations/hi.csv index 78094d735af0..21e659aabec6 100644 --- a/erpnext/translations/hi.csv +++ b/erpnext/translations/hi.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',आप परियोजना प्रकार 'बाहरी' को नहीं हटा सकते, You cannot edit root node.,आप रूट नोड संपादित नहीं कर सकते हैं।, You cannot restart a Subscription that is not cancelled.,आप एक सदस्यता को पुनरारंभ नहीं कर सकते जो रद्द नहीं किया गया है।, -You don't have enought Loyalty Points to redeem,आपने रिडीम करने के लिए वफादारी अंक नहीं खरीदे हैं, +You don't have enough Loyalty Points to redeem,आपने रिडीम करने के लिए वफादारी अंक नहीं खरीदे हैं, You have already assessed for the assessment criteria {}.,आप मूल्यांकन मानदंड के लिए पहले से ही मूल्यांकन कर चुके हैं {}, You have already selected items from {0} {1},आप पहले से ही से आइटम का चयन किया है {0} {1}, You have been invited to collaborate on the project: {0},आप इस परियोजना पर सहयोग करने के लिए आमंत्रित किया गया है: {0}, diff --git a/erpnext/translations/hr.csv b/erpnext/translations/hr.csv index 232832f3f97d..9d0b9526f797 100644 --- a/erpnext/translations/hr.csv +++ b/erpnext/translations/hr.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Ne možete izbrisati vrstu projekta 'Vanjski', You cannot edit root node.,Ne možete uređivati root čvor., You cannot restart a Subscription that is not cancelled.,Ne možete ponovo pokrenuti pretplatu koja nije otkazana., -You don't have enought Loyalty Points to redeem,Nemate dovoljno bodova lojalnosti za otkup, +You don't have enough Loyalty Points to redeem,Nemate dovoljno bodova lojalnosti za otkup, You have already assessed for the assessment criteria {}.,Već ste ocijenili kriterije procjene {}., You have already selected items from {0} {1},Već ste odabrali stavke iz {0} {1}, You have been invited to collaborate on the project: {0},Pozvani ste za suradnju na projektu: {0}, diff --git a/erpnext/translations/hu.csv b/erpnext/translations/hu.csv index e3dcd61fb2f5..c7966b5113e2 100644 --- a/erpnext/translations/hu.csv +++ b/erpnext/translations/hu.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',"A ""Külső"" projekttípust nem törölheti", You cannot edit root node.,Nem szerkesztheti a fő csomópontot., You cannot restart a Subscription that is not cancelled.,"Nem indíthatja el az Előfizetést, amelyet nem zárt le.", -You don't have enought Loyalty Points to redeem,Nincs elegendő hűségpontjaid megváltáshoz, +You don't have enough Loyalty Points to redeem,Nincs elegendő hűségpontjaid megváltáshoz, You have already assessed for the assessment criteria {}.,Már értékelte ezekkel az értékelési kritériumokkal: {}., You have already selected items from {0} {1},Már választott ki elemeket innen {0} {1}, You have been invited to collaborate on the project: {0},Ön meghívást kapott ennek a projeknek a közreműködéséhez: {0}, diff --git a/erpnext/translations/id.csv b/erpnext/translations/id.csv index ccbb002370f3..de44c6fd7826 100644 --- a/erpnext/translations/id.csv +++ b/erpnext/translations/id.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Anda tidak bisa menghapus Jenis Proyek 'External', You cannot edit root node.,Anda tidak dapat mengedit simpul root., You cannot restart a Subscription that is not cancelled.,Anda tidak dapat memulai ulang Langganan yang tidak dibatalkan., -You don't have enought Loyalty Points to redeem,Anda tidak memiliki Poin Loyalitas yang cukup untuk ditukarkan, +You don't have enough Loyalty Points to redeem,Anda tidak memiliki Poin Loyalitas yang cukup untuk ditukarkan, You have already assessed for the assessment criteria {}.,Anda telah memberikan penilaian terhadap kriteria penilaian {}., You have already selected items from {0} {1},Anda sudah memilih item dari {0} {1}, You have been invited to collaborate on the project: {0},Anda telah diundang untuk berkolaborasi pada proyek: {0}, diff --git a/erpnext/translations/is.csv b/erpnext/translations/is.csv index 5f11c63abafd..b9174e26e51f 100644 --- a/erpnext/translations/is.csv +++ b/erpnext/translations/is.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Þú getur ekki eytt verkefnisgerðinni 'ytri', You cannot edit root node.,Þú getur ekki breytt rótarkóði., You cannot restart a Subscription that is not cancelled.,Þú getur ekki endurræst áskrift sem ekki er lokað., -You don't have enought Loyalty Points to redeem,Þú hefur ekki nóg hollusta stig til að innleysa, +You don't have enough Loyalty Points to redeem,Þú hefur ekki nóg hollusta stig til að innleysa, You have already assessed for the assessment criteria {}.,Þú hefur nú þegar metið mat á viðmiðunum {}., You have already selected items from {0} {1},Þú hefur nú þegar valið hluti úr {0} {1}, You have been invited to collaborate on the project: {0},Þér hefur verið boðið að vinna að verkefninu: {0}, diff --git a/erpnext/translations/it.csv b/erpnext/translations/it.csv index 0dbde45778a7..d432eaf1e96c 100644 --- a/erpnext/translations/it.csv +++ b/erpnext/translations/it.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Non è possibile eliminare il tipo di progetto 'Esterno', You cannot edit root node.,Non è possibile modificare il nodo principale., You cannot restart a Subscription that is not cancelled.,Non è possibile riavviare una sottoscrizione che non è stata annullata., -You don't have enought Loyalty Points to redeem,Non hai abbastanza Punti fedeltà da riscattare, +You don't have enough Loyalty Points to redeem,Non hai abbastanza Punti fedeltà da riscattare, You have already assessed for the assessment criteria {}.,Hai già valutato i criteri di valutazione {}., You have already selected items from {0} {1},Hai già selezionato elementi da {0} {1}, You have been invited to collaborate on the project: {0},Sei stato invitato a collaborare al progetto: {0}, diff --git a/erpnext/translations/ja.csv b/erpnext/translations/ja.csv index 210c78ee5df5..2bf91fbe2515 100644 --- a/erpnext/translations/ja.csv +++ b/erpnext/translations/ja.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',プロジェクトタイプ「外部」を削除することはできません, You cannot edit root node.,ルートノードは編集できません。, You cannot restart a Subscription that is not cancelled.,キャンセルされていないサブスクリプションを再起動することはできません。, -You don't have enought Loyalty Points to redeem,あなたは交換するのに十分なロイヤリティポイントがありません, +You don't have enough Loyalty Points to redeem,あなたは交換するのに十分なロイヤリティポイントがありません, You have already assessed for the assessment criteria {}.,評価基準{}は評価済です。, You have already selected items from {0} {1},項目を選択済みです {0} {1}, You have been invited to collaborate on the project: {0},プロジェクト:{0} の共同作業に招待されました, diff --git a/erpnext/translations/km.csv b/erpnext/translations/km.csv index 1eb85cca924e..8b25f9983560 100644 --- a/erpnext/translations/km.csv +++ b/erpnext/translations/km.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',អ្នកមិនអាចលុបប្រភេទគម្រោង 'ខាងក្រៅ', You cannot edit root node.,អ្នកមិនអាចកែថ្នាំង root បានទេ។, You cannot restart a Subscription that is not cancelled.,អ្នកមិនអាចចាប់ផ្តើមឡើងវិញនូវការជាវដែលមិនត្រូវបានលុបចោលទេ។, -You don't have enought Loyalty Points to redeem,អ្នកមិនមានពិន្ទុភាពស្មោះត្រង់គ្រប់គ្រាន់ដើម្បីលោះទេ, +You don't have enough Loyalty Points to redeem,អ្នកមិនមានពិន្ទុភាពស្មោះត្រង់គ្រប់គ្រាន់ដើម្បីលោះទេ, You have already assessed for the assessment criteria {}.,អ្នកបានវាយតម្លែរួចទៅហើយសម្រាប់លក្ខណៈវិនិច្ឆ័យវាយតម្លៃនេះ {} ។, You have already selected items from {0} {1},អ្នកបានជ្រើសរួចហើយចេញពីធាតុ {0} {1}, You have been invited to collaborate on the project: {0},អ្នកបានត្រូវអញ្ជើញដើម្បីសហការគ្នាលើគម្រោងនេះ: {0}, diff --git a/erpnext/translations/kn.csv b/erpnext/translations/kn.csv index 4e40c63e576b..944a59ef3362 100644 --- a/erpnext/translations/kn.csv +++ b/erpnext/translations/kn.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',ನೀವು ಪ್ರಾಜೆಕ್ಟ್ ಕೌಟುಂಬಿಕತೆ 'ಬಾಹ್ಯ' ಅನ್ನು ಅಳಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ, You cannot edit root node.,ನೀವು ರೂಟ್ ನೋಡ್ ಅನ್ನು ಸಂಪಾದಿಸಲಾಗುವುದಿಲ್ಲ., You cannot restart a Subscription that is not cancelled.,ರದ್ದುಪಡಿಸದ ಚಂದಾದಾರಿಕೆಯನ್ನು ನೀವು ಮರುಪ್ರಾರಂಭಿಸಬಾರದು., -You don't have enought Loyalty Points to redeem,ರಿಡೀಮ್ ಮಾಡಲು ನೀವು ಲಾಯಲ್ಟಿ ಪಾಯಿಂಟುಗಳನ್ನು ಹೊಂದಿದ್ದೀರಿ, +You don't have enough Loyalty Points to redeem,ರಿಡೀಮ್ ಮಾಡಲು ನೀವು ಲಾಯಲ್ಟಿ ಪಾಯಿಂಟುಗಳನ್ನು ಹೊಂದಿದ್ದೀರಿ, You have already assessed for the assessment criteria {}.,ನೀವು ಈಗಾಗಲೇ ಮೌಲ್ಯಮಾಪನ ಮಾನದಂಡದ ನಿರ್ಣಯಿಸುವ {}., You have already selected items from {0} {1},ನೀವು ಈಗಾಗಲೇ ಆಯ್ಕೆ ಐಟಂಗಳನ್ನು ಎಂದು {0} {1}, You have been invited to collaborate on the project: {0},ನೀವು ಯೋಜನೆಯ ಸಹಯೋಗಿಸಲು ಆಮಂತ್ರಿಸಲಾಗಿದೆ: {0}, diff --git a/erpnext/translations/ko.csv b/erpnext/translations/ko.csv index 36ec3affcec6..66b8692afe8c 100644 --- a/erpnext/translations/ko.csv +++ b/erpnext/translations/ko.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',프로젝트 유형 '외부'를 삭제할 수 없습니다., You cannot edit root node.,루트 노드는 편집 할 수 없습니다., You cannot restart a Subscription that is not cancelled.,취소되지 않은 구독은 다시 시작할 수 없습니다., -You don't have enought Loyalty Points to redeem,사용하기에 충성도 포인트가 충분하지 않습니다., +You don't have enough Loyalty Points to redeem,사용하기에 충성도 포인트가 충분하지 않습니다., You have already assessed for the assessment criteria {}.,이미 평가 기준 {}을 (를) 평가했습니다., You have already selected items from {0} {1},이미에서 항목을 선택한 {0} {1}, You have been invited to collaborate on the project: {0},당신은 프로젝트 공동 작업에 초대되었습니다 : {0}, diff --git a/erpnext/translations/ku.csv b/erpnext/translations/ku.csv index 28927a08d105..325a0a7d2560 100644 --- a/erpnext/translations/ku.csv +++ b/erpnext/translations/ku.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Hûn nikarin jêbirinê hilbijêre 'External', You cannot edit root node.,Hûn nikarin node root root biguherînin., You cannot restart a Subscription that is not cancelled.,Hûn nikarin endamê peymana ku destûr nabe., -You don't have enought Loyalty Points to redeem,Hûn pisporên dilsozî ne ku hûn bistînin, +You don't have enough Loyalty Points to redeem,Hûn pisporên dilsozî ne ku hûn bistînin, You have already assessed for the assessment criteria {}.,Tu niha ji bo nirxandina nirxandin {}., You have already selected items from {0} {1},Jixwe te tomar ji hilbijartî {0} {1}, You have been invited to collaborate on the project: {0},Hûn hatine vexwendin ji bo hevkariyê li ser vê projeyê: {0}, diff --git a/erpnext/translations/lo.csv b/erpnext/translations/lo.csv index 3904308af2fd..b8b7a5d623ba 100644 --- a/erpnext/translations/lo.csv +++ b/erpnext/translations/lo.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',ທ່ານບໍ່ສາມາດລຶບປະເພດໂຄງການ 'ພາຍນອກ', You cannot edit root node.,ທ່ານບໍ່ສາມາດແກ້ໄຂຮາກຮາກ., You cannot restart a Subscription that is not cancelled.,ທ່ານບໍ່ສາມາດເລີ່ມຕົ້ນລະບົບຈອງໃຫມ່ທີ່ບໍ່ໄດ້ຖືກຍົກເລີກ., -You don't have enought Loyalty Points to redeem,ທ່ານບໍ່ມີຈຸດປະສົງອັນຄົບຖ້ວນພໍທີ່ຈະຊື້, +You don't have enough Loyalty Points to redeem,ທ່ານບໍ່ມີຈຸດປະສົງອັນຄົບຖ້ວນພໍທີ່ຈະຊື້, You have already assessed for the assessment criteria {}.,ທ່ານໄດ້ປະເມີນແລ້ວສໍາລັບມາດຕະຖານການປະເມີນຜົນ {}., You have already selected items from {0} {1},ທ່ານໄດ້ຄັດເລືອກເອົາແລ້ວລາຍການຈາກ {0} {1}, You have been invited to collaborate on the project: {0},ທ່ານໄດ້ຖືກເຊື້ອເຊີນເພື່ອເຮັດວຽກຮ່ວມກັນກ່ຽວກັບໂຄງການ: {0}, diff --git a/erpnext/translations/lt.csv b/erpnext/translations/lt.csv index d05688c6537a..5f249a742df3 100644 --- a/erpnext/translations/lt.csv +++ b/erpnext/translations/lt.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Negalite ištrinti projekto tipo "Išorinis", You cannot edit root node.,Jūs negalite redaguoti šakninis mazgas., You cannot restart a Subscription that is not cancelled.,"Jūs negalite iš naujo paleisti Prenumeratos, kuri nėra atšaukta.", -You don't have enought Loyalty Points to redeem,Jūs neturite nusipirkti lojalumo taškų išpirkti, +You don't have enough Loyalty Points to redeem,Jūs neturite nusipirkti lojalumo taškų išpirkti, You have already assessed for the assessment criteria {}.,Jūs jau įvertintas vertinimo kriterijus {}., You have already selected items from {0} {1},Jūs jau pasirinkote elementus iš {0} {1}, You have been invited to collaborate on the project: {0},Jūs buvote pakviestas bendradarbiauti su projektu: {0}, diff --git a/erpnext/translations/lv.csv b/erpnext/translations/lv.csv index d5cf852bc6b9..f7a3d356f803 100644 --- a/erpnext/translations/lv.csv +++ b/erpnext/translations/lv.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Jūs nevarat izdzēst projekta veidu "Ārējais", You cannot edit root node.,Jūs nevarat rediģēt saknes mezglu., You cannot restart a Subscription that is not cancelled.,"Jūs nevarat atsākt Abonementu, kas nav atcelts.", -You don't have enought Loyalty Points to redeem,Jums nav lojalitātes punktu atpirkt, +You don't have enough Loyalty Points to redeem,Jums nav lojalitātes punktu atpirkt, You have already assessed for the assessment criteria {}.,Jūs jau izvērtēta vērtēšanas kritērijiem {}., You have already selected items from {0} {1},Jūs jau atsevišķus posteņus {0} {1}, You have been invited to collaborate on the project: {0},Jūs esat uzaicināts sadarboties projektam: {0}, diff --git a/erpnext/translations/mk.csv b/erpnext/translations/mk.csv index e01cb70e926d..06c2aff0b442 100644 --- a/erpnext/translations/mk.csv +++ b/erpnext/translations/mk.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Не можете да го избришете Типот на проектот 'External', You cannot edit root node.,Не можете да уредувате корен јазол., You cannot restart a Subscription that is not cancelled.,Не можете да ја рестартирате претплатата која не е откажана., -You don't have enought Loyalty Points to redeem,Вие не сте донеле лојални точки за откуп, +You don't have enough Loyalty Points to redeem,Вие не сте донеле лојални точки за откуп, You have already assessed for the assessment criteria {}.,Веќе сте се проценува за критериумите за оценување {}., You have already selected items from {0} {1},Веќе сте одбрале предмети од {0} {1}, You have been invited to collaborate on the project: {0},Вие сте поканети да соработуваат на проектот: {0}, diff --git a/erpnext/translations/ml.csv b/erpnext/translations/ml.csv index c5a98b6d2560..f28ea330402e 100644 --- a/erpnext/translations/ml.csv +++ b/erpnext/translations/ml.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',നിങ്ങൾക്ക് പദ്ധതി തരം 'ബാഹ്യ' ഇല്ലാതാക്കാൻ കഴിയില്ല, You cannot edit root node.,നിങ്ങൾക്ക് റൂട്ട് നോഡ് എഡിറ്റുചെയ്യാൻ കഴിയില്ല., You cannot restart a Subscription that is not cancelled.,നിങ്ങൾക്ക് റദ്ദാക്കാത്ത ഒരു സബ്സ്ക്രിപ്ഷൻ പുനഃരാരംഭിക്കാൻ കഴിയില്ല., -You don't have enought Loyalty Points to redeem,നിങ്ങൾക്ക് വീണ്ടെടുക്കാനുള്ള വിശ്വസ്ത ടയറുകൾ ആവശ്യമില്ല, +You don't have enough Loyalty Points to redeem,നിങ്ങൾക്ക് വീണ്ടെടുക്കാനുള്ള വിശ്വസ്ത ടയറുകൾ ആവശ്യമില്ല, You have already assessed for the assessment criteria {}.,ഇതിനകം നിങ്ങൾ വിലയിരുത്തൽ മാനദണ്ഡങ്ങൾ {} വേണ്ടി വിലയിരുത്തി ചെയ്തു., You have already selected items from {0} {1},നിങ്ങൾ ഇതിനകം നിന്ന് {0} {1} ഇനങ്ങൾ തിരഞ്ഞെടുത്തു, You have been invited to collaborate on the project: {0},നിങ്ങൾ പദ്ധതി സഹകരിക്കുക ക്ഷണിച്ചു: {0}, diff --git a/erpnext/translations/mr.csv b/erpnext/translations/mr.csv index 21aaa3f8bbf9..64b074882965 100644 --- a/erpnext/translations/mr.csv +++ b/erpnext/translations/mr.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',आपण प्रोजेक्ट प्रकार 'बाह्य' हटवू शकत नाही, You cannot edit root node.,आपण मूळ नोड संपादित करू शकत नाही., You cannot restart a Subscription that is not cancelled.,आपण रद्द न केलेली सबस्क्रिप्शन पुन्हा सुरू करू शकत नाही., -You don't have enought Loyalty Points to redeem,आपण परत विकत घेण्यासाठी निष्ठावान बिंदू नाहीत, +You don't have enough Loyalty Points to redeem,आपण परत विकत घेण्यासाठी निष्ठावान बिंदू नाहीत, You have already assessed for the assessment criteria {}.,आपण मूल्यांकन निकष आधीच मूल्यमापन आहे {}., You have already selected items from {0} {1},आपण आधीच आयटम निवडले आहेत {0} {1}, You have been invited to collaborate on the project: {0},आपण प्रकल्प सहयोग करण्यासाठी आमंत्रित आहेत: {0}, diff --git a/erpnext/translations/ms.csv b/erpnext/translations/ms.csv index 5a3d986f5b58..5d2c2a890e7a 100644 --- a/erpnext/translations/ms.csv +++ b/erpnext/translations/ms.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Anda tidak boleh memadam Jenis Projek 'Luar', You cannot edit root node.,Anda tidak boleh mengedit nod akar., You cannot restart a Subscription that is not cancelled.,Anda tidak boleh memulakan semula Langganan yang tidak dibatalkan., -You don't have enought Loyalty Points to redeem,Anda tidak mempunyai mata Kesetiaan yang cukup untuk menebusnya, +You don't have enough Loyalty Points to redeem,Anda tidak mempunyai mata Kesetiaan yang cukup untuk menebusnya, You have already assessed for the assessment criteria {}.,Anda telah pun dinilai untuk kriteria penilaian {}., You have already selected items from {0} {1},Anda telah memilih barangan dari {0} {1}, You have been invited to collaborate on the project: {0},Anda telah dijemput untuk bekerjasama dalam projek: {0}, diff --git a/erpnext/translations/my.csv b/erpnext/translations/my.csv index 7638e762ba47..0471f66bc55e 100644 --- a/erpnext/translations/my.csv +++ b/erpnext/translations/my.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',သငျသညျစီမံကိန်းအမျိုးအစား '' ပြင်ပ '' မဖျက်နိုင်ပါ, You cannot edit root node.,သငျသညျအမြစ် node ကိုတည်းဖြတ်မရနိုင်ပါ။, You cannot restart a Subscription that is not cancelled.,သငျသညျဖျက်သိမ်းမပေးကြောင်းတစ် Subscription ပြန်လည်စတင်ရန်လို့မရပါဘူး။, -You don't have enought Loyalty Points to redeem,သငျသညျကိုရှေးနှုတျမှ enought သစ္စာရှိမှုအမှတ်ရှိသည်မဟုတ်ကြဘူး, +You don't have enough Loyalty Points to redeem,သငျသညျကိုရှေးနှုတျမှ enough သစ္စာရှိမှုအမှတ်ရှိသည်မဟုတ်ကြဘူး, You have already assessed for the assessment criteria {}.,သငျသညျပြီးသား {} အဆိုပါအကဲဖြတ်သတ်မှတ်ချက်အဘို့အအကဲဖြတ်ပါပြီ။, You have already selected items from {0} {1},သငျသညျပြီးသား {0} {1} ကနေပစ္စည်းကိုရှေးခယျြခဲ့ကြ, You have been invited to collaborate on the project: {0},သငျသညျစီမံကိန်းကိုအပေါ်ပူးပေါင်းဖို့ဖိတ်ခေါ်ခဲ့ကြ: {0}, diff --git a/erpnext/translations/nl.csv b/erpnext/translations/nl.csv index b65f22334c75..92671115bfc6 100644 --- a/erpnext/translations/nl.csv +++ b/erpnext/translations/nl.csv @@ -2364,7 +2364,7 @@ Report Type is mandatory,Rapport type is verplicht, Reports,rapporten, Reqd By Date,Benodigd op datum, Reqd Qty,Gewenste hoeveelheid, -Request for Quotation,Offerte, +Request for Quotation,Offerte-verzoek, Request for Quotations,Verzoek om offertes, Request for Raw Materials,Verzoek om grondstoffen, Request for purchase.,Inkoopaanvraag, @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',U kunt projecttype 'extern' niet verwijderen, You cannot edit root node.,U kunt het basisknooppunt niet bewerken., You cannot restart a Subscription that is not cancelled.,U kunt een Abonnement dat niet is geannuleerd niet opnieuw opstarten., -You don't have enought Loyalty Points to redeem,Je hebt geen genoeg loyaliteitspunten om in te wisselen, +You don't have enough Loyalty Points to redeem,Je hebt geen genoeg loyaliteitspunten om in te wisselen, You have already assessed for the assessment criteria {}.,U heeft al beoordeeld op de beoordelingscriteria {}., You have already selected items from {0} {1},U heeft reeds geselecteerde items uit {0} {1}, You have been invited to collaborate on the project: {0},U bent uitgenodigd om mee te werken aan het project: {0}, diff --git a/erpnext/translations/no.csv b/erpnext/translations/no.csv index 20b8916eaf26..2642a9c70876 100644 --- a/erpnext/translations/no.csv +++ b/erpnext/translations/no.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Du kan ikke slette Project Type 'External', You cannot edit root node.,Du kan ikke redigere rotknutepunktet., You cannot restart a Subscription that is not cancelled.,Du kan ikke starte en abonnement som ikke er kansellert., -You don't have enought Loyalty Points to redeem,Du har ikke nok lojalitetspoeng til å innløse, +You don't have enough Loyalty Points to redeem,Du har ikke nok lojalitetspoeng til å innløse, You have already assessed for the assessment criteria {}.,Du har allerede vurdert for vurderingskriteriene {}., You have already selected items from {0} {1},Du har allerede valgt elementer fra {0} {1}, You have been invited to collaborate on the project: {0},Du har blitt invitert til å samarbeide om prosjektet: {0}, diff --git a/erpnext/translations/pl.csv b/erpnext/translations/pl.csv index 4a93d4987567..ca820258f439 100644 --- a/erpnext/translations/pl.csv +++ b/erpnext/translations/pl.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Nie można usunąć typu projektu "zewnętrzny", You cannot edit root node.,Nie można edytować węzła głównego., You cannot restart a Subscription that is not cancelled.,"Nie można ponownie uruchomić subskrypcji, która nie zostanie anulowana.", -You don't have enought Loyalty Points to redeem,"Nie masz wystarczającej liczby Punktów Lojalnościowych, aby je wykorzystać", +You don't have enough Loyalty Points to redeem,"Nie masz wystarczającej liczby Punktów Lojalnościowych, aby je wykorzystać", You have already assessed for the assessment criteria {}.,Oceniałeś już kryteria oceny {}., You have already selected items from {0} {1},Już wybrane pozycje z {0} {1}, You have been invited to collaborate on the project: {0},Zostałeś zaproszony do współpracy przy projekcie: {0}, diff --git a/erpnext/translations/ps.csv b/erpnext/translations/ps.csv index 26cd0a9cbcb8..ed15740e3781 100644 --- a/erpnext/translations/ps.csv +++ b/erpnext/translations/ps.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',تاسو د پروژې ډول 'بهرني' نه ړنګولی شئ, You cannot edit root node.,تاسو د ریډ نوډ سمون نشو کولی., You cannot restart a Subscription that is not cancelled.,تاسو نشي کولی هغه یو بل ریکارډ بیا پیل کړئ چې رد شوی نه وي., -You don't have enought Loyalty Points to redeem,تاسو د ژغورلو لپاره د وفادارۍ ټکي نلرئ, +You don't have enough Loyalty Points to redeem,تاسو د ژغورلو لپاره د وفادارۍ ټکي نلرئ, You have already assessed for the assessment criteria {}.,تاسو مخکې د ارزونې معیارونه ارزول {}., You have already selected items from {0} {1},تاسو وخته ټاکل څخه توکي {0} د {1}, You have been invited to collaborate on the project: {0},تاسو ته په دغه پروژه کې همکاري بلل شوي دي: {0}, diff --git a/erpnext/translations/pt-BR.csv b/erpnext/translations/pt-BR.csv index edaaddd6a78f..503a16f7ebea 100644 --- a/erpnext/translations/pt-BR.csv +++ b/erpnext/translations/pt-BR.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Você não pode excluir o Tipo de Projeto ';Externo';, You cannot edit root node.,Você não pode editar o nó raiz., You cannot restart a Subscription that is not cancelled.,Você não pode reiniciar uma Assinatura que não seja cancelada., -You don't have enought Loyalty Points to redeem,Você não tem suficientes pontos de lealdade para resgatar, +You don't have enough Loyalty Points to redeem,Você não tem suficientes pontos de lealdade para resgatar, You have already assessed for the assessment criteria {}.,Você já avaliou os critérios de avaliação {}., You have already selected items from {0} {1},Já selecionou itens de {0} {1}, You have been invited to collaborate on the project: {0},Você foi convidado para colaborar com o projeto: {0}, diff --git a/erpnext/translations/pt.csv b/erpnext/translations/pt.csv index 5cc486d8be2d..3e83df5f6933 100644 --- a/erpnext/translations/pt.csv +++ b/erpnext/translations/pt.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Você não pode excluir o Tipo de Projeto 'Externo', You cannot edit root node.,Você não pode editar o nó raiz., You cannot restart a Subscription that is not cancelled.,Você não pode reiniciar uma Assinatura que não seja cancelada., -You don't have enought Loyalty Points to redeem,Você não tem suficientes pontos de lealdade para resgatar, +You don't have enough Loyalty Points to redeem,Você não tem suficientes pontos de lealdade para resgatar, You have already assessed for the assessment criteria {}.,Você já avaliou os critérios de avaliação {}., You have already selected items from {0} {1},Já selecionou itens de {0} {1}, You have been invited to collaborate on the project: {0},Foi convidado para colaborar com o projeto: {0}, diff --git a/erpnext/translations/ro.csv b/erpnext/translations/ro.csv index 8f97d072021d..91c92070758f 100644 --- a/erpnext/translations/ro.csv +++ b/erpnext/translations/ro.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Nu puteți șterge tipul de proiect "extern", You cannot edit root node.,Nu puteți edita nodul rădăcină., You cannot restart a Subscription that is not cancelled.,Nu puteți reporni o abonament care nu este anulat., -You don't have enought Loyalty Points to redeem,Nu aveți puncte de loialitate pentru a răscumpăra, +You don't have enough Loyalty Points to redeem,Nu aveți puncte de loialitate pentru a răscumpăra, You have already assessed for the assessment criteria {}.,Ați evaluat deja criteriile de evaluare {}., You have already selected items from {0} {1},Ați selectat deja un produs de la {0} {1}, You have been invited to collaborate on the project: {0},Ați fost invitat să colaboreze la proiect: {0}, diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv index 1fc37ddedb6b..00641159e064 100644 --- a/erpnext/translations/ru.csv +++ b/erpnext/translations/ru.csv @@ -3336,7 +3336,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Вы не можете удалить проект типа "Внешний", You cannot edit root node.,Вы не можете редактировать корневой узел., You cannot restart a Subscription that is not cancelled.,"Вы не можете перезапустить подписку, которая не отменена.", -You don't have enought Loyalty Points to redeem,У вас недостаточно очков лояльности для выкупа, +You don't have enough Loyalty Points to redeem,У вас недостаточно очков лояльности для выкупа, You have already assessed for the assessment criteria {}.,Вы уже оценили критерии оценки {}., You have already selected items from {0} {1},Вы уже выбрали продукты из {0} {1}, You have been invited to collaborate on the project: {0},Вы были приглашены для совместной работы над проектом: {0}, diff --git a/erpnext/translations/rw.csv b/erpnext/translations/rw.csv index 741f11792f0d..2d8c07ee58dd 100644 --- a/erpnext/translations/rw.csv +++ b/erpnext/translations/rw.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Ntushobora gusiba Ubwoko bwumushinga 'Hanze', You cannot edit root node.,Ntushobora guhindura imizi., You cannot restart a Subscription that is not cancelled.,Ntushobora gutangira Kwiyandikisha bidahagaritswe., -You don't have enought Loyalty Points to redeem,Ntabwo ufite amanota ahagije yo gucungura, +You don't have enough Loyalty Points to redeem,Ntabwo ufite amanota ahagije yo gucungura, You have already assessed for the assessment criteria {}.,Mumaze gusuzuma ibipimo ngenderwaho {}., You have already selected items from {0} {1},Mumaze guhitamo ibintu kuva {0} {1}, You have been invited to collaborate on the project: {0},Watumiwe gufatanya kumushinga: {0}, diff --git a/erpnext/translations/si.csv b/erpnext/translations/si.csv index e5ea9bff7b1f..67fa6ae51dde 100644 --- a/erpnext/translations/si.csv +++ b/erpnext/translations/si.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',ඔබට ව්යාපෘති වර්ගය 'බාහිර', You cannot edit root node.,ඔබට root node සංස්කරණය කළ නොහැක., You cannot restart a Subscription that is not cancelled.,අවලංගු නොකළ දායකත්ව නැවත ආරම්භ කළ නොහැක., -You don't have enought Loyalty Points to redeem,ඔබ මුදා හැරීමට පක්ෂපාතීත්වයේ පොත්වලට ඔබ කැමති නැත, +You don't have enough Loyalty Points to redeem,ඔබ මුදා හැරීමට පක්ෂපාතීත්වයේ පොත්වලට ඔබ කැමති නැත, You have already assessed for the assessment criteria {}.,තක්සේරු නිර්ණායක {} සඳහා ඔබ දැනටමත් තක්සේරු කර ඇත., You have already selected items from {0} {1},ඔබ මේ වන විටත් {0} {1} සිට භාණ්ඩ තෝරාගෙන ඇති, You have been invited to collaborate on the project: {0},ඔබ මෙම ව්යාපෘතිය පිළිබඳව සහයෝගයෙන් කටයුතු කිරීමට ආරාධනා කර ඇත: {0}, diff --git a/erpnext/translations/sk.csv b/erpnext/translations/sk.csv index d16c49201a37..7bdfdffbaf95 100644 --- a/erpnext/translations/sk.csv +++ b/erpnext/translations/sk.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Nemôžete odstrániť typ projektu "Externé", You cannot edit root node.,Nemôžete upraviť koreňový uzol., You cannot restart a Subscription that is not cancelled.,"Predplatné, ktoré nie je zrušené, nemôžete reštartovať.", -You don't have enought Loyalty Points to redeem,Nemáte dostatok vernostných bodov na vykúpenie, +You don't have enough Loyalty Points to redeem,Nemáte dostatok vernostných bodov na vykúpenie, You have already assessed for the assessment criteria {}.,Vyhodnotili ste kritériá hodnotenia {}., You have already selected items from {0} {1},Už ste vybrané položky z {0} {1}, You have been invited to collaborate on the project: {0},Boli ste pozvaní k spolupráci na projekte: {0}, diff --git a/erpnext/translations/sl.csv b/erpnext/translations/sl.csv index 08901606c4fc..370c3c61ae7a 100644 --- a/erpnext/translations/sl.csv +++ b/erpnext/translations/sl.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Ne morete izbrisati vrste projekta "Zunanji", You cannot edit root node.,Rootnega vozlišča ne morete urejati., You cannot restart a Subscription that is not cancelled.,"Naročnino, ki ni preklican, ne morete znova zagnati.", -You don't have enought Loyalty Points to redeem,Za unovčevanje niste prejeli točk za zvestobo, +You don't have enough Loyalty Points to redeem,Za unovčevanje niste prejeli točk za zvestobo, You have already assessed for the assessment criteria {}.,Ste že ocenili za ocenjevalnih meril {}., You have already selected items from {0} {1},Ste že izbrane postavke iz {0} {1}, You have been invited to collaborate on the project: {0},Ti so bili povabljeni k sodelovanju na projektu: {0}, diff --git a/erpnext/translations/sq.csv b/erpnext/translations/sq.csv index 987211ab7a85..f069a059d4e4 100644 --- a/erpnext/translations/sq.csv +++ b/erpnext/translations/sq.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Ju nuk mund të fshini llojin e projektit 'Jashtë', You cannot edit root node.,Nuk mund të ndryshosh nyjen e rrënjës., You cannot restart a Subscription that is not cancelled.,Nuk mund të rifilloni një Abonimi që nuk anulohet., -You don't have enought Loyalty Points to redeem,Ju nuk keni shumë pikat e Besnikërisë për të shpenguar, +You don't have enough Loyalty Points to redeem,Ju nuk keni shumë pikat e Besnikërisë për të shpenguar, You have already assessed for the assessment criteria {}.,Ju kanë vlerësuar tashmë me kriteret e vlerësimit {}., You have already selected items from {0} {1},Ju keni zgjedhur tashmë artikuj nga {0} {1}, You have been invited to collaborate on the project: {0},Ju keni qenë të ftuar për të bashkëpunuar në këtë projekt: {0}, diff --git a/erpnext/translations/sr-SP.csv b/erpnext/translations/sr-SP.csv index 27ac9448abb1..afeb651ac278 100644 --- a/erpnext/translations/sr-SP.csv +++ b/erpnext/translations/sr-SP.csv @@ -265,645 +265,645 @@ DocType: Supplier,Name and Type,Ime i tip DocType: Customs Tariff Number,Customs Tariff Number,Carinska tarifa apps/erpnext/erpnext/hr/doctype/employee_benefit_application/employee_benefit_application.py +65,"You can claim only an amount of {0}, the rest amount {1} should be in the application \ as pro-rata component","Можете тражити само износ од {0}, остатак од {1} би требао бити у апликацији \ као про-рата компонента." -DocType: Crop,Yield UOM,Јединица мере приноса -DocType: Item Default,Default Supplier,Podrazumijevani dobavljač -apps/erpnext/erpnext/healthcare/page/medical_record/patient_select.html +3,Select Patient,Izaberite pacijenta -apps/erpnext/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py +27,Opening,Početno stanje -DocType: POS Profile,Customer Groups,Grupe kupaca -DocType: Hub Tracked Item,Item Manager,Menadžer artikala -DocType: Fiscal Year Company,Fiscal Year Company,Fiskalna godina preduzeća -DocType: Patient Appointment,Patient Appointment,Zakazivanje pacijenata -DocType: BOM,Show In Website,Prikaži na web sajtu -DocType: Payment Entry,Paid Amount,Uplaćeno -apps/erpnext/erpnext/accounts/report/accounts_receivable/accounts_receivable.html +137,Total Paid Amount,Ukupno plaćeno -apps/erpnext/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +267,Purchase Receipt {0} is not submitted,Prijem robe {0} nije potvrđen -apps/erpnext/erpnext/config/selling.py +52,Items and Pricing,Proizvodi i cijene -DocType: Payment Entry,Account Paid From,Račun plaćen preko -apps/erpnext/erpnext/utilities/activation.py +72,Create customer quotes,Kreirajte bilješke kupca -DocType: Purchase Invoice,Supplier Warehouse,Skladište dobavljača -apps/erpnext/erpnext/support/doctype/warranty_claim/warranty_claim.py +20,Customer is required,Kupac je obavezan podatak -DocType: Item,Manufacturer,Proizvođač -apps/erpnext/erpnext/accounts/report/gross_profit/gross_profit.py +69,Selling Amount,Prodajni iznos -apps/erpnext/erpnext/hr/doctype/salary_slip/salary_slip.py +417,Please set the Date Of Joining for employee {0},Molimo podesite datum zasnivanja radnog odnosa {0} -DocType: Item,Allow over delivery or receipt upto this percent,Dozvolite isporukuili prijem robe ukoliko ne premaši ovaj procenat -DocType: Shopping Cart Settings,Orders,Porudžbine -apps/erpnext/erpnext/config/stock.py +7,Stock Transactions,Promjene na zalihama -apps/erpnext/erpnext/hr/doctype/leave_application/leave_application.py +183,You are not authorized to approve leaves on Block Dates,Немате дозволу да одобравате одсуства на Блок Датумима. -,Daily Timesheet Summary,Pregled dnevnog potrošenog vremena -DocType: Project Task,View Timesheet,Pogledaj potrošeno vrijeme -DocType: Purchase Invoice,Rounded Total (Company Currency),Zaokruženi ukupan iznos (valuta preduzeća) -apps/erpnext/erpnext/hr/doctype/salary_slip/salary_slip.py +403,Salary Slip of employee {0} already created for this period,Isplatna lista Zaposlenog {0} kreirana je već za ovaj period -DocType: Item,"If this item has variants, then it cannot be selected in sales orders etc.","Ako ovaj artikal ima varijante, onda ne može biti biran u prodajnom nalogu." -apps/erpnext/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +1517,You can only redeem max {0} points in this order.,Можете унети највише {0} поена у овој наруџбини. -apps/erpnext/erpnext/hr/doctype/attendance/attendance.py +38,No leave record found for employee {0} for {1},Nije nađena evidancija o odsustvu Zaposlenog {0} za {1} -DocType: Pricing Rule,Discount on Price List Rate (%),Popust na cijene iz cjenovnika (%) -apps/erpnext/erpnext/stock/doctype/item/item_dashboard.py +17,Groups,Grupe -DocType: Item,Item Attribute,Atribut artikla -DocType: Payment Request,Amount in customer's currency,Iznos u valuti kupca -apps/erpnext/erpnext/controllers/sales_and_purchase_return.py +105,Warehouse is mandatory,Skladište je obavezan podatak -,Stock Ageing,Starost zaliha -DocType: Email Digest,New Sales Orders,Novi prodajni nalozi -apps/erpnext/erpnext/restaurant/doctype/restaurant_order_entry/restaurant_order_entry.py +64,Invoice Created,Kreirana faktura -DocType: Employee Internal Work History,Employee Internal Work History,Interna radna istorija Zaposlenog -apps/erpnext/erpnext/templates/includes/cart/cart_dropdown.html +25,Cart is Empty,Korpa je prazna -DocType: Patient,Patient Details,Detalji o pacijentu -apps/erpnext/erpnext/accounts/doctype/journal_entry/journal_entry.py +583,Stock Entry {0} is not submitted,Unos zaliha {0} nije potvrđen -apps/erpnext/erpnext/setup/setup_wizard/operations/install_fixtures.py +105,Rest Of The World,Ostatak svijeta -DocType: Work Order,Additional Operating Cost,Dodatni operativni troškovi -DocType: Purchase Invoice,Rejected Warehouse,Odbijeno skladište -DocType: Asset Repair,Manufacturing Manager,Menadžer proizvodnje -apps/erpnext/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py +39,You are not present all day(s) between compensatory leave request days,Нисте присутни свих дана између захтева за компензацијски одмор. -DocType: Purchase Invoice Item,Is Fixed Asset,Artikal je osnovno sredstvo -,POS,POS -apps/erpnext/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +367,Timesheet {0} is already completed or cancelled,Potrošeno vrijeme {0} je već potvrđeno ili otkazano -apps/erpnext/erpnext/hr/doctype/leave_application/leave_application.py +526, (Half Day),(Pola dana) -DocType: Shipping Rule,Net Weight,Neto težina -apps/erpnext/erpnext/education/doctype/student_attendance/student_attendance.py +56,Attendance Record {0} exists against Student {1},Zapis o prisustvu {0} постоји kod studenata {1} -DocType: Payment Entry Reference,Outstanding,Preostalo -DocType: Sales Invoice Item,Discount (%) on Price List Rate with Margin,Popust (%) -DocType: Purchase Invoice,Select Shipping Address,Odaberite adresu isporuke -apps/erpnext/erpnext/accounts/report/purchase_order_items_to_be_billed/purchase_order_items_to_be_billed.py +20,Amount to Bill,Iznos za fakturisanje -apps/erpnext/erpnext/utilities/activation.py +82,Make Sales Orders to help you plan your work and deliver on-time,Kreiranje prodajnog naloga će vam pomoći da isplanirate svoje vrijeme i dostavite robu na vrijeme -apps/erpnext/erpnext/accounts/page/pos/pos.js +809,Sync Offline Invoices,Sinhronizuj offline fakture -DocType: Blanket Order,Manufacturing,Proizvodnja -apps/erpnext/erpnext/controllers/website_list_for_contact.py +117,{0}% Delivered,{0}% Isporučeno -apps/erpnext/erpnext/education/doctype/course_schedule/course_schedule.js +7,Attendance,Prisustvo -DocType: Delivery Note,Customer's Purchase Order No,Broj porudžbenice kupca -apps/erpnext/erpnext/manufacturing/doctype/production_planning_tool/production_planning_tool.py +125,Please enter Sales Orders in the above table,U tabelu iznad unesite prodajni nalog -DocType: Quality Inspection,Report Date,Datum izvještaja -DocType: POS Profile,Item Groups,Vrste artikala -DocType: Pricing Rule,Discount Percentage,Procenat popusta -apps/erpnext/erpnext/accounts/report/gross_profit/gross_profit.py +72,Gross Profit %,Bruto dobit% -DocType: Crop,"You can define all the tasks which need to carried out for this crop here. The day field is used to mention the day on which the task needs to be carried out, 1 being the 1st day, etc.. ","Овде можете дефинисати све задатке које је потребно извршити за ову жетву. Поље Дан говори дан на који је задатак потребно извршити, 1 је 1. дан, итд." -apps/erpnext/erpnext/accounts/doctype/payment_order/payment_order.js +7,Payment Request,Upit za plaćanje -,Purchase Analytics,Analiza nabavke -DocType: Location,Tree Details,Detalji stabla -DocType: Upload Attendance,Upload Attendance,Priloži evidenciju -DocType: GL Entry,Against,Povezano sa -DocType: Grant Application,Requested Amount,Traženi iznos -apps/erpnext/erpnext/config/crm.py +92,"Record of all communications of type email, phone, chat, visit, etc.","Snimanje svih komunikacija tipa email, telefon, poruke, posjete, itd." -DocType: Purchase Order,Customer Contact Email,Kontakt e-mail kupca -apps/erpnext/erpnext/public/js/utils/customer_quick_entry.js +34,Primary Address Details,Detalji o primarnoj adresi -apps/erpnext/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +98,Above,Iznad -DocType: Item,Variant Based On,Varijanta zasnovana na -DocType: Project,Task Weight,Težina zadataka -DocType: Payment Entry,Transaction ID,Transakcije -DocType: Payment Entry Reference,Allocated,Dodijeljeno -apps/erpnext/erpnext/stock/dashboard/item_dashboard.js +180,Add more items or open full form,Dodaj još stavki ili otvori kompletan prozor -apps/erpnext/erpnext/stock/page/stock_balance/stock_balance.js +49,Reserved for sale,Rezervisana za prodaju -DocType: POS Item Group,Item Group,Vrste artikala -apps/erpnext/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +84,Age (Days),Starost (Dani) -apps/erpnext/erpnext/accounts/report/trial_balance/trial_balance.py +243,Opening (Dr),Početno stanje (Du) -apps/erpnext/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +50,Total Outstanding Amt,Preostalo za plaćanje -apps/erpnext/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.html +23,Go to the Desktop and start using ERPNext,Idite na radnu površinu i krenite sa radom u programu -apps/erpnext/erpnext/accounts/doctype/subscription/subscription.py +71,You can only have Plans with the same billing cycle in a Subscription,Сви Планови у Претплати морају имати исти циклус наплате -DocType: Sales Person,Name and Employee ID,Ime i ID Zaposlenog -DocType: Bank Statement Transaction Invoice Item,Invoice,Faktura -DocType: Bank Statement Transaction Invoice Item,Invoice Date,Datum fakture -DocType: Customer,From Lead,Od Lead-a -apps/erpnext/erpnext/config/crm.py +12,Database of potential customers.,Baza potencijalnih kupaca -apps/erpnext/erpnext/projects/report/project_wise_stock_tracking/project_wise_stock_tracking.py +28,Project Status,Status Projekta -apps/erpnext/erpnext/public/js/pos/pos.html +124,All Item Groups,Sve vrste artikala -apps/erpnext/erpnext/selling/doctype/installation_note/installation_note.py +49,Serial No {0} does not exist,Serijski broj {0} ne postoji -apps/erpnext/erpnext/public/js/templates/contact_list.html +34,No contacts added yet.,Još uvijek nema dodatih kontakata -apps/erpnext/erpnext/accounts/report/accounts_payable/accounts_payable.js +42,Ageing Range 3,Opseg dospijeća 3 -DocType: Request for Quotation,Request for Quotation,Zahtjev za ponudu -DocType: Payment Entry,Account Paid To,Račun plaćen u -apps/erpnext/erpnext/hr/doctype/attendance/attendance.py +44,Attendance can not be marked for future dates,Učesnik ne može biti označen za buduće datume -DocType: Stock Entry,Sales Invoice No,Broj fakture prodaje -apps/erpnext/erpnext/projects/doctype/task/task.js +39,Timesheet,Potrošeno vrijeme -DocType: HR Settings,Don't send Employee Birthday Reminders,Nemojte slati podsjetnik o rođendanima Zaposlenih -DocType: Sales Invoice Item,Available Qty at Warehouse,Dostupna količina na skladištu -DocType: Item,Foreign Trade Details,Spoljnotrgovinski detalji -DocType: Item,Minimum Order Qty,Minimalna količina za poručivanje -apps/erpnext/erpnext/hr/doctype/payroll_entry/payroll_entry.py +68,No employees for the mentioned criteria,Za traženi kriterijum nema Zaposlenih -DocType: Budget,Fiscal Year,Fiskalna godina -DocType: Stock Entry,Repack,Prepakovati -apps/erpnext/erpnext/public/js/utils/serial_no_batch_selector.js +135,Please select a warehouse,Izaberite skladište -DocType: Purchase Receipt Item,Received and Accepted,Primio i prihvatio -DocType: Project,Project will be accessible on the website to these users,Projekat će biti dostupan na sajtu sledećim korisnicima -DocType: Upload Attendance,Upload HTML,Priloži HTML -apps/erpnext/erpnext/public/js/setup_wizard.js +29,Services,Usluge -apps/erpnext/erpnext/public/js/pos/pos.html +4,Item Cart,Korpa sa artiklima -apps/erpnext/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +34,Total Paid Amt,Ukupno plaćeno -DocType: Warehouse,Warehouse Detail,Detalji o skldištu -DocType: Quotation Item,Quotation Item,Stavka sa ponude -DocType: Journal Entry Account,Employee Advance,Napredak Zaposlenog -DocType: Purchase Order Item,Warehouse and Reference,Skladište i veza -apps/erpnext/erpnext/accounts/doctype/gl_entry/gl_entry.py +93,{0} {1}: Account {2} is inactive,{0} {1}: Nalog {2} je neaktivan -apps/erpnext/erpnext/hr/doctype/payroll_entry/payroll_entry.py +467,Fiscal Year {0} not found,Fiskalna godina {0} nije pronađena -apps/erpnext/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +121,No Remarks,Nema napomene -DocType: Notification Control,Purchase Receipt Message,Poruka u Prijemu robe -DocType: Purchase Invoice,Taxes and Charges Deducted,Umanjeni porezi i naknade -DocType: Sales Invoice,Include Payment (POS),Uključi POS plaćanje -DocType: Sales Invoice,Customer PO Details,Pregled porudžbine kupca -apps/erpnext/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +27,Total Invoiced Amt,Ukupno fakturisano -apps/erpnext/erpnext/public/js/stock_analytics.js +54,Select Brand...,Izaberite brend -DocType: Item,Default Unit of Measure,Podrazumijevana jedinica mjere -DocType: Purchase Invoice Item,Serial No,Serijski broj -DocType: Supplier,Supplier Type,Tip dobavljača -apps/erpnext/erpnext/stock/dashboard/item_dashboard_list.html +25,Actual Qty {0} / Waiting Qty {1},Trenutna kol. {0} / Na čekanju {1} -DocType: Supplier,Individual,Fizičko lice -apps/erpnext/erpnext/stock/doctype/material_request/material_request_list.js +9,Partially Ordered,Djelimično poručeno -DocType: Bank Reconciliation Detail,Posting Date,Datum dokumenta -DocType: Cheque Print Template,Date Settings,Podešavanje datuma -DocType: Payment Entry,Total Allocated Amount (Company Currency),Ukupan povezani iznos (Valuta) -DocType: Account,Income,Prihod -apps/erpnext/erpnext/public/js/utils/item_selector.js +20,Add Items,Dodaj stavke -apps/erpnext/erpnext/accounts/page/pos/pos.js +1731,Price List not found or disabled,Cjenovnik nije pronađen ili je zaključan -DocType: Vital Signs,Weight (In Kilogram),Težina (u kg) -apps/erpnext/erpnext/accounts/page/pos/pos.js +796,New Sales Invoice,Nova faktura -DocType: Employee Transfer,New Company,Novo preduzeće -DocType: Issue,Support Team,Tim za podršku -DocType: Item,Valuation Method,Način vrednovanja -DocType: Project,Project Type,Tip Projekta -DocType: Purchase Order Item,Returned Qty,Vraćena kol. -DocType: Purchase Invoice,Additional Discount Amount (Company Currency),Iznos dodatnog popusta (valuta preduzeća) -,Employee Information,Informacije o Zaposlenom -apps/erpnext/erpnext/selling/report/inactive_customers/inactive_customers.py +16,'Days Since Last Order' must be greater than or equal to zero,"""Dana od poslednje porudžbine"" mora biti veće ili jednako nuli" -DocType: Asset,Maintenance,Održavanje -DocType: Item Price,Multiple Item prices.,Više cijena artikala -apps/erpnext/erpnext/accounts/doctype/payment_entry/payment_entry.py +379,Received From,je primljen od -DocType: Payment Entry,Write Off Difference Amount,Otpis razlike u iznosu -DocType: Task,Closing Date,Datum zatvaranja -DocType: Payment Entry,Cheque/Reference Date,Datum izvoda -DocType: Production Plan Item,Planned Qty,Planirana količina -DocType: Repayment Schedule,Payment Date,Datum plaćanja -DocType: Vehicle,Additional Details,Dodatni detalji -DocType: Company,Create Chart Of Accounts Based On,Kreiraj kontni plan prema -apps/erpnext/erpnext/manufacturing/doctype/bom/bom.js +244,You can not change rate if BOM mentioned agianst any item,Не можете променити цену ако постоји Саставница за било коју ставку. -apps/erpnext/erpnext/setup/doctype/email_digest/templates/default.html +130,Open To Do,Otvori To Do -DocType: Authorization Rule,Average Discount,Prosječan popust -DocType: Item,Material Issue,Reklamacija robe -DocType: Purchase Order Item,Billed Amt,Fakturisani iznos -apps/erpnext/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +261,Supplier Quotation {0} created,Ponuda dobavljaču {0} је kreirana -apps/erpnext/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +101,Not allowed to update stock transactions older than {0},Nije dozvoljeno mijenjati Promjene na zalihama starije od {0} -apps/erpnext/erpnext/public/js/event.js +27,Add Employees,Dodaj Zaposlenog -apps/erpnext/erpnext/config/hr.py +394,Setting up Employees,Podešavanja Zaposlenih -apps/erpnext/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +96,Warehouse not found in the system,Skladište nije pronađeno u sistemu -apps/erpnext/erpnext/hr/doctype/leave_application/leave_application.py +264,Attendance for employee {0} is already marked for this day,Prisustvo zaposlenog {0} је već označeno za ovaj dan -apps/erpnext/erpnext/hr/doctype/salary_slip/salary_slip.py +349,Employee relieved on {0} must be set as 'Left',"Zaposleni smijenjen na {0} mora biti označen kao ""Napustio""" -DocType: Sales Invoice, Shipping Bill Number,Broj isporuke -,Lab Test Report,Izvještaj labaratorijskog testa -apps/erpnext/erpnext/accounts/doctype/journal_entry/journal_entry.py +331,You cannot credit and debit same account at the same time,Не можете кредитирати и дебитовати исти налог у исто време. -DocType: Sales Invoice,Customer Name,Naziv kupca -DocType: Employee,Current Address,Trenutna adresa -apps/erpnext/erpnext/setup/doctype/email_digest/templates/default.html +97,Upcoming Calendar Events,Predstojeći događaji u kalendaru -DocType: Accounts Settings,Make Payment via Journal Entry,Kreiraj uplatu kroz knjiženje -DocType: Payment Request,Paid,Plaćeno -DocType: Pricing Rule,Buying,Nabavka -DocType: Stock Settings,Default Item Group,Podrazumijevana vrsta artikala -apps/erpnext/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py +24,In Stock Qty,Na zalihama -DocType: Purchase Invoice,Taxes and Charges Deducted (Company Currency),Umanjeni porezi i naknade (valuta preduzeća) -DocType: Stock Entry,Additional Costs,Dodatni troškovi -DocType: Project Task,Pending Review,Čeka provjeru -DocType: Item Default,Default Selling Cost Center,Podrazumijevani centar troškova -apps/erpnext/erpnext/public/js/pos/pos.html +109,No Customers yet!,Još uvijek nema kupaca! -apps/erpnext/erpnext/stock/doctype/delivery_note/delivery_note.js +909,Sales Return,Povraćaj prodaje -apps/erpnext/erpnext/selling/page/point_of_sale/point_of_sale.js +628,No Items added to cart,Nema dodatih artikala na računu -apps/erpnext/erpnext/selling/doctype/customer/customer_dashboard.py +6,This is based on transactions against this Customer. See timeline below for details,Ovo je zasnovano na transkcijama ovog klijenta. Pogledajte vremensku liniju ispod za dodatne informacije -DocType: Project Task,Make Timesheet,Kreiraj potrošeno vrijeme -apps/erpnext/erpnext/selling/doctype/sales_order/sales_order.py +69,Warning: Sales Order {0} already exists against Customer's Purchase Order {1},Upozorenje: Prodajni nalog {0}već postoji veza sa porudžbenicom kupca {1} -DocType: Healthcare Settings,Healthcare Settings,Podešavanje klinike -apps/erpnext/erpnext/buying/doctype/supplier/supplier.js +39,Accounting Ledger,Analitička kartica -DocType: Stock Entry,Total Outgoing Value,Ukupna vrijednost isporuke -apps/erpnext/erpnext/controllers/selling_controller.py +265,Sales Order {0} is {1},Prodajni nalog {0} је {1} -DocType: Stock Settings,Automatically Set Serial Nos based on FIFO,Podesi automatski serijski broj da koristi FIFO -apps/erpnext/erpnext/selling/report/customer_acquisition_and_loyalty/customer_acquisition_and_loyalty.py +57,New Customers,Novi kupci -apps/erpnext/erpnext/selling/doctype/customer/customer_dashboard.py +10,Pre Sales,Prije prodaje -DocType: POS Customer Group,POS Customer Group,POS grupa kupaca -DocType: Quotation,Shopping Cart,Korpa sa sajta -apps/erpnext/erpnext/stock/page/stock_balance/stock_balance.js +50,Reserved for manufacturing,Rezervisana za proizvodnju -DocType: Pricing Rule,Pricing Rule Help,Pravilnik za cijene pomoć -apps/erpnext/erpnext/accounts/report/accounts_payable/accounts_payable.js +35,Ageing Range 2,Opseg dospijeća 2 -DocType: Employee Benefit Application,Employee Benefits,Primanja Zaposlenih -DocType: POS Item Group,POS Item Group,POS Vrsta artikala -DocType: Lead,Lead,Lead -DocType: HR Settings,Employee Settings,Podešavanja zaposlenih -apps/erpnext/erpnext/templates/pages/home.html +32,View All Products,Pogledajte sve proizvode -DocType: Patient Medical Record,Patient Medical Record,Medicinski karton pacijenta -DocType: Student Attendance Tool,Batch,Serija -apps/erpnext/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +928,Purchase Receipt,Prijem robe -DocType: Item,Warranty Period (in days),Garantni rok (u danima) -apps/erpnext/erpnext/config/selling.py +28,Customer database.,Korisnička baza podataka -DocType: Attendance,Attendance Date,Datum prisustva -DocType: Supplier Scorecard,Notify Employee,Obavijestiti Zaposlenog -apps/erpnext/erpnext/setup/doctype/sales_person/sales_person.py +46,User ID not set for Employee {0},Korisnički ID nije podešen za Zaposlenog {0} -,Stock Projected Qty,Projektovana količina na zalihama -apps/erpnext/erpnext/accounts/doctype/payment_request/payment_request.py +408,Make Payment,Kreiraj plaćanje -apps/erpnext/erpnext/accounts/doctype/fiscal_year/fiscal_year.py +51,You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global Settings,Ne možete obrisati fiskalnu godinu {0}. Fiskalna {0} godina je označena kao trenutna u globalnim podešavanjima. -apps/erpnext/erpnext/stock/stock_ledger.py +382,{0} units of {1} needed in {2} to complete this transaction.,Željenu količinu {0} za artikal {1} je potrebno dodati na {2} da bi dovršili transakciju.. -,Item-wise Sales Register,Prodaja po artiklima -DocType: Item Tax,Tax Rate,Poreska stopa -DocType: GL Entry,Remarks,Napomena -DocType: Opening Invoice Creation Tool,Sales,Prodaja -DocType: Pricing Rule,Pricing Rule,Pravilnik za cijene -DocType: Products Settings,Products Settings,Podešavanje proizvoda -DocType: Healthcare Practitioner,Mobile,Mobilni -DocType: Purchase Invoice Item,Price List Rate,Cijena -DocType: Purchase Invoice Item,Discount Amount,Vrijednost popusta -,Sales Invoice Trends,Trendovi faktura prodaje -apps/erpnext/erpnext/accounts/doctype/loyalty_program/loyalty_program.py +110,You don't have enought Loyalty Points to redeem,Немате довољно Бодова Лојалности. -DocType: Purchase Invoice,Tax Breakup,Porez po pozicijama -DocType: Asset Maintenance Log,Task,Zadatak -apps/erpnext/erpnext/stock/doctype/item/item.js +359,Add / Edit Prices,Dodaj / Izmijeni cijene -,Item Prices,Cijene artikala -DocType: Additional Salary,Salary Component,Компонента плате -DocType: Sales Invoice,Customer's Purchase Order Date,Datum porudžbenice kupca -DocType: Item,Country of Origin,Zemlja porijekla -apps/erpnext/erpnext/hr/doctype/department_approver/department_approver.py +17,Please select Employee Record first.,Molimo izaberite registar Zaposlenih prvo -DocType: Blanket Order,Order Type,Vrsta porudžbine -DocType: BOM Item,Rate & Amount,Cijena i iznos sa rabatom -DocType: Pricing Rule,For Price List,Za cjenovnik -DocType: Purchase Invoice,Tax ID,Poreski broj -DocType: Job Card,WIP Warehouse,Wip skladište -,Itemwise Recommended Reorder Level,Pregled preporučenih nivoa dopune -apps/erpnext/erpnext/accounts/doctype/journal_entry/journal_entry.py +408,{0} against Bill {1} dated {2},{0} veza sa računom {1} na datum {2} -apps/erpnext/erpnext/accounts/doctype/account/account.py +113,You are not authorized to set Frozen value,Немате дозволу да постављате замрзнуту вредност -,Requested Items To Be Ordered,Tražene stavke za isporuku -DocType: Employee Attendance Tool,Unmarked Attendance,Neobilježeno prisustvo -apps/erpnext/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +657,Sales Order {0} is not submitted,Prodajni nalog {0} nije potvrđen -DocType: Item,Default Material Request Type,Podrazumijevani zahtjev za tip materijala -apps/erpnext/erpnext/selling/page/sales_funnel/sales_funnel.js +42,Sales Pipeline,Prodajna linija -apps/erpnext/erpnext/manufacturing/doctype/production_order/production_order.py +617,Already completed,Već završen -DocType: Production Plan Item,Ordered Qty,Poručena kol -DocType: Item,Sales Details,Detalji prodaje -apps/erpnext/erpnext/config/learn.py +11,Navigating,Navigacija -apps/erpnext/erpnext/utilities/user_progress.py +138,Your Products or Services,Vaši artikli ili usluge -DocType: Contract,CRM,CRM -apps/erpnext/erpnext/public/js/setup_wizard.js +51,The Brand,Brend -apps/erpnext/erpnext/selling/doctype/sales_order/sales_order.py +165,Quotation {0} is cancelled,Ponuda {0} je otkazana -DocType: Pricing Rule,Item Code,Šifra artikla -DocType: Purchase Order,Customer Mobile No,Broj telefona kupca -apps/erpnext/erpnext/stock/report/stock_balance/stock_balance.py +92,Reorder Qty,Kol. za dopunu -apps/erpnext/erpnext/stock/dashboard/item_dashboard.js +118,Move Item,Premještanje artikala -DocType: Buying Settings,Buying Settings,Podešavanja nabavke -DocType: Asset Movement,From Employee,Od Zaposlenog -DocType: Driver,Fleet Manager,Menadžer transporta -apps/erpnext/erpnext/stock/doctype/batch/batch.js +51,Stock Levels,Nivoi zalihe -DocType: Sales Invoice Item,Rate With Margin (Company Currency),Cijena sa popustom (Valuta preduzeća) -apps/erpnext/erpnext/accounts/report/trial_balance/trial_balance.py +278,Closing (Cr),Saldo (Po) -apps/erpnext/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +622,Product Bundle,Sastavnica -apps/erpnext/erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py +21,Sales and Returns,Prodaja i povraćaji -apps/erpnext/erpnext/accounts/page/pos/pos.js +801,Sync Master Data,Sinhronizuj podatke iz centrale -DocType: Sales Person,Sales Person Name,Ime prodajnog agenta -DocType: Landed Cost Voucher,Purchase Receipts,Prijemi robe -apps/erpnext/erpnext/config/learn.py +21,Customizing Forms,Prilagođavanje formi -apps/erpnext/erpnext/hr/doctype/attendance/attendance.py +19,Attendance for employee {0} is already marked,Prisustvo zaposlenog {0} je već označeno -DocType: Project,% Complete Method,% metod vrednovanja završetka projekta -DocType: Purchase Invoice,Overdue,Istekao -DocType: Purchase Invoice,Posting Time,Vrijeme izrade računa -DocType: Stock Entry,Purchase Receipt No,Broj prijema robe -DocType: Project,Expected End Date,Očekivani datum završetka -apps/erpnext/erpnext/projects/doctype/project/project.py +84,Expected End Date can not be less than Expected Start Date,Očekivani datum završetka ne može biti manji od očekivanog dana početka -DocType: Customer,Customer Primary Contact,Primarni kontakt kupca -DocType: Project,Expected Start Date,Očekivani datum početka -DocType: Supplier,Credit Limit,Kreditni limit -DocType: Item,Item Tax,Porez -DocType: Pricing Rule,Selling,Prodaja -DocType: Purchase Order,Customer Contact,Kontakt kupca -apps/erpnext/erpnext/stock/doctype/item/item.py +578,Item {0} does not exist,Artikal {0} ne postoji -apps/erpnext/erpnext/utilities/user_progress.py +247,Add Users,Dodaj korisnike -apps/erpnext/erpnext/public/js/utils/serial_no_batch_selector.js +81,Select Serial Numbers,Izaberite serijske brojeve -DocType: Bank Reconciliation Detail,Payment Entry,Uplate -DocType: Purchase Invoice,In Words,Riječima -DocType: HR Settings,Employee record is created using selected field. ,Izvještaj o Zaposlenom se kreira korišćenjem izabranog polja. -apps/erpnext/erpnext/selling/doctype/installation_note/installation_note.py +59,Serial No {0} does not belong to Delivery Note {1},Serijski broj {0} ne pripada otpremnici {1} -DocType: Issue,Support,Podrška -DocType: Production Plan,Get Sales Orders,Pregledaj prodajne naloge -DocType: Stock Ledger Entry,Stock Ledger Entry,Unos zalihe robe -apps/erpnext/erpnext/config/projects.py +36,Gantt chart of all tasks.,Gantov grafikon svih zadataka -DocType: Purchase Invoice Item,Price List Rate (Company Currency),Cijena (Valuta preduzeća) -DocType: Delivery Stop,Address Name,Naziv adrese -apps/erpnext/erpnext/setup/doctype/sales_person/sales_person.py +54,Another Sales Person {0} exists with the same Employee id,Postoji još jedan Prodavac {0} sa istim ID zaposlenog -DocType: Item Group,Item Group Name,Naziv vrste artikala -apps/erpnext/erpnext/selling/doctype/customer/customer.py +175,A Customer Group exists with same name please change the Customer name or rename the Customer Group,Isto ime grupe kupca već postoji. Promijenite ime kupca ili izmijenite grupu kupca -apps/erpnext/erpnext/accounts/doctype/journal_entry/journal_entry.py +586,Warning: Another {0} # {1} exists against stock entry {2},Upozorenje: Još jedan {0} # {1} postoji u vezanom Unosu zaliha {2} -apps/erpnext/erpnext/accounts/report/delivered_items_to_be_billed/delivered_items_to_be_billed.py +18,Suplier,Dobavljač -DocType: Item,Has Serial No,Ima serijski broj -apps/erpnext/erpnext/hr/doctype/attendance/attendance.py +31,Employee {0} on Half day on {1},Zaposleni {0} na pola radnog vremena {1} -DocType: Payment Entry,Difference Amount (Company Currency),Razlika u iznosu (Valuta) -apps/erpnext/erpnext/public/js/utils.js +56,Add Serial No,Dodaj serijski broj -apps/erpnext/erpnext/config/accounts.py +35,Company and Accounts,Preduzeće i računi -DocType: Employee,Current Address Is,Trenutna adresa je -DocType: Payment Entry,Unallocated Amount,Nepovezani iznos -apps/erpnext/erpnext/accounts/report/profitability_analysis/profitability_analysis.js +58,Show zero values,Prikaži vrijednosti sa nulom -DocType: Bank Account,Address and Contact,Adresa i kontakt -,Supplier-Wise Sales Analytics,Analiza Dobavljačeve pametne prodaje -apps/erpnext/erpnext/accounts/doctype/payment_request/payment_request.py +348,Payment Entry is already created,Uplata je već kreirana -DocType: Purchase Invoice Item,Item,Artikal -DocType: Purchase Invoice,Unpaid,Neplaćen -DocType: Purchase Invoice Item,Net Rate,Neto cijena sa rabatom -DocType: Project User,Project User,Projektni user -DocType: Item,Customer Items,Proizvodi kupca -apps/erpnext/erpnext/stock/doctype/item/item.py +840,Item {0} is cancelled,Stavka {0} je otkazana -apps/erpnext/erpnext/stock/report/stock_balance/stock_balance.py +89,Balance Value,Stanje vrijed. -apps/erpnext/erpnext/stock/doctype/delivery_note/delivery_note.py +98,Sales Order required for Item {0},Prodajni nalog je obavezan za artikal {0} -DocType: Clinical Procedure,Patient,Pacijent -DocType: Stock Entry,Default Target Warehouse,Prijemno skladište -DocType: GL Entry,Voucher No,Br. dokumenta -apps/erpnext/erpnext/education/api.py +80,Attendance has been marked successfully.,Prisustvo je uspješno obilježeno. -apps/erpnext/erpnext/stock/doctype/serial_no/serial_no.py +398,Serial No {0} created,Serijski broj {0} kreiran -DocType: Account,Asset,Osnovna sredstva -DocType: Payment Entry,Received Amount,Iznos uplate -apps/erpnext/erpnext/hr/doctype/department/department.js +14,You cannot edit root node.,Не можете уређивати коренски чвор. -,Sales Funnel,Prodajni lijevak -DocType: Sales Invoice,Payment Due Date,Datum dospijeća fakture -apps/erpnext/erpnext/config/healthcare.py +8,Consultation,Pregled -apps/erpnext/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py +8,Related,Povezan -DocType: Warehouse,Warehouse Name,Naziv skladišta -DocType: Authorization Rule,Customer / Item Name,Kupac / Naziv proizvoda -DocType: Timesheet,Total Billed Amount,Ukupno fakturisano -apps/erpnext/erpnext/stock/report/stock_balance/stock_balance.py +85,In Value,Prijem vrije. -DocType: Expense Claim,Employees Email Id,ID email Zaposlenih -apps/erpnext/erpnext/healthcare/page/appointment_analytic/appointment_analytic.js +54,Tree Type,Tip stabla -DocType: Stock Entry,Update Rate and Availability,Izmijenite cijenu i dostupnost -apps/erpnext/erpnext/buying/doctype/purchase_order/purchase_order.js +1156,Supplier Quotation,Ponuda dobavljača -DocType: Material Request Item,Quantity and Warehouse,Količina i skladište -DocType: Purchase Invoice,Taxes and Charges Added,Porezi i naknade dodate -DocType: Work Order,Warehouses,Skladišta -DocType: SMS Center,All Customer Contact,Svi kontakti kupca -apps/erpnext/erpnext/accounts/doctype/account/account.js +78,Ledger,Skladišni karton -DocType: Quotation,Quotation Lost Reason,Razlog gubitka ponude -DocType: Purchase Invoice,Return Against Purchase Invoice,Povraćaj u vezi sa Fakturom nabavke -DocType: Sales Invoice Item,Brand Name,Naziv brenda -DocType: Account,Stock,Zalihe -DocType: Customer Group,Customer Group Name,Naziv grupe kupca -DocType: Item,Is Sales Item,Da li je prodajni artikal -apps/erpnext/erpnext/accounts/report/accounts_receivable/accounts_receivable.html +119,Invoiced Amount,Fakturisano -DocType: Purchase Invoice,Edit Posting Date and Time,Izmijeni datum i vrijeme dokumenta -,Inactive Customers,Neaktivni kupci -DocType: Stock Entry Detail,Stock Entry Detail,Detalji unosa zaliha -DocType: Sales Invoice,Accounting Details,Računovodstveni detalji -DocType: Asset Movement,Stock Manager,Menadžer zaliha -apps/erpnext/erpnext/accounts/report/accounts_payable/accounts_payable.js +22,As on Date,Na datum -DocType: Serial No,Is Cancelled,Je otkazan -DocType: Naming Series,Setup Series,Podešavanje tipa dokumenta -,Point of Sale,Kasa -DocType: C-Form Invoice Detail,Invoice No,Broj fakture -DocType: Landed Cost Item,Purchase Receipt Item,Stavka Prijema robe -DocType: Bank Statement Transaction Payment Item,Invoices,Fakture -DocType: Project,Task Progress,% završenosti zadataka -DocType: Employee Attendance Tool,Employee Attendance Tool,Alat za prisustvo Zaposlenih -DocType: Salary Slip,Payment Days,Dana za plaćanje -apps/erpnext/erpnext/config/hr.py +231,Recruitment,Zapošljavanje -DocType: Purchase Invoice,Taxes and Charges Calculation,Izračun Poreza -DocType: Appraisal,For Employee,Za Zaposlenog -apps/erpnext/erpnext/config/selling.py +163,Terms and Conditions Template,Uslovi i odredbe šablon -DocType: Vehicle Service,Change,Kusur -apps/erpnext/erpnext/stock/doctype/batch/batch.js +105,Stock Entry {0} created,Unos zaliha {0} je kreiran -apps/erpnext/erpnext/selling/page/point_of_sale/point_of_sale.js +1181,Search Item (Ctrl + i),Pretraga artikala (Ctrl + i) -apps/erpnext/erpnext/templates/generators/item.html +101,View in Cart,Pogledajte u korpi -apps/erpnext/erpnext/stock/get_item_details.py +405,Item Price updated for {0} in Price List {1},Cijena artikla je izmijenjena {0} u cjenovniku {1} -apps/erpnext/erpnext/selling/page/point_of_sale/point_of_sale.js +623,Discount,Popust -DocType: Packing Slip,Net Weight UOM,Neto težina JM -DocType: Bank Account,Party Type,Tip partije -DocType: Selling Settings,Sales Order Required,Prodajni nalog je obavezan -apps/erpnext/erpnext/accounts/page/pos/pos.js +1108,Search Item,Pretraži artikal -,Delivered Items To Be Billed,Nefakturisana isporučena roba -DocType: Account,Debit,Duguje -DocType: Patient Appointment,Date TIme,Datum i vrijeme -DocType: Bank Reconciliation Detail,Payment Document,Dokument za plaćanje -apps/erpnext/erpnext/accounts/doctype/journal_entry/journal_entry.py +184,You can not enter current voucher in 'Against Journal Entry' column,"Неможете унети тренутни ваучер у колону ""На основу ставке у журналу""" -DocType: Purchase Invoice,In Words (Company Currency),Riječima (valuta kompanije) -,Purchase Receipt Trends,Trendovi prijema robe -DocType: Employee Leave Approver,Employee Leave Approver,Odobreno odsustvo Zaposlenog -apps/erpnext/erpnext/accounts/report/trial_balance/trial_balance.py +25,Fiscal Year {0} does not exist,Fiskalna godina {0} ne postoji -DocType: Purchase Invoice Item,Accepted Warehouse,Prihvaćeno skladište -DocType: Account,Income Account,Račun prihoda -DocType: Journal Entry Account,Account Balance,Knjigovodstveno stanje -apps/erpnext/erpnext/projects/doctype/task/task.py +41,'Expected Start Date' can not be greater than 'Expected End Date',Očekivani datum početka ne može biti veći od očekivanog datuma završetka -DocType: Training Event,Employee Emails,Elektronska pošta Zaposlenog -apps/erpnext/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +36,Opening Qty,Početna količina -DocType: Item,Reorder level based on Warehouse,Nivo dopune u zavisnosti od skladišta -apps/erpnext/erpnext/stock/doctype/batch/batch.js +84,To Warehouse,U skladište -DocType: Account,Is Group,Je grupa -DocType: Purchase Invoice,Contact Person,Kontakt osoba -DocType: Item,Item Code for Suppliers,Dobavljačeva šifra -apps/erpnext/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +893,Return / Debit Note,Povraćaj / knjižno zaduženje -DocType: Request for Quotation Supplier,Request for Quotation Supplier,Zahtjev za ponudu dobavljača -,LeaderBoard,Tabla -DocType: Lab Test Groups,Lab Test Groups,Labaratorijske grupe -DocType: Training Result Employee,Training Result Employee,Rezultati obuke Zaposlenih -DocType: Serial No,Invoice Details,Detalji fakture -apps/erpnext/erpnext/config/accounts.py +130,Banking and Payments,Bakarstvo i plaćanja -DocType: Additional Salary,Employee Name,Ime Zaposlenog -apps/erpnext/erpnext/selling/page/sales_funnel/sales_funnel.py +33,Active Leads / Customers,Активни Леадс / Kupci -DocType: POS Profile,Accounting,Računovodstvo -DocType: Payment Entry,Party Name,Ime partije -DocType: Item,Manufacture,Proizvodnja -apps/erpnext/erpnext/templates/pages/projects.html +27,New task,Novi zadatak -DocType: Journal Entry,Accounts Payable,Obaveze prema dobavljačima -DocType: Purchase Invoice,Shipping Address,Adresa isporuke -DocType: Bank Statement Transaction Invoice Item,Outstanding Amount,Preostalo za uplatu -apps/erpnext/erpnext/stock/doctype/warehouse/warehouse_tree.js +15,New Warehouse Name,Naziv novog skladišta -apps/erpnext/erpnext/accounts/report/delivered_items_to_be_billed/delivered_items_to_be_billed.py +20,Billed Amount,Fakturisani iznos -apps/erpnext/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +37,Balance Qty,Stanje zalihe -,Item Shortage Report,Izvještaj o negativnim zalihama -apps/erpnext/erpnext/accounts/doctype/payment_entry/payment_entry.py +383,Transaction reference no {0} dated {1},Broj izvoda {0} na datum {1} -apps/erpnext/erpnext/utilities/activation.py +83,Make Sales Order,Kreiraj prodajni nalog -DocType: Purchase Invoice,Items,Artikli -,Employees working on a holiday,Zaposleni koji rade za vrijeme praznika -DocType: Payment Entry,Allocate Payment Amount,Poveži uplaćeni iznos -DocType: Patient,Patient ID,ID pacijenta -apps/erpnext/erpnext/accounts/report/accounts_receivable/accounts_receivable.html +263,Printed On,Datum i vrijeme štampe -DocType: Sales Invoice,Debit To,Zaduženje za -apps/erpnext/erpnext/config/setup.py +14,Global Settings,Globalna podešavanja -apps/erpnext/erpnext/hr/doctype/job_offer/job_offer.js +18,Make Employee,Keriraj Zaposlenog -apps/erpnext/erpnext/stock/doctype/stock_entry/stock_entry.py +244,Atleast one warehouse is mandatory,Minimum jedno skladište je obavezno -DocType: Price List,Price List Name,Naziv cjenovnika -DocType: Asset,Journal Entry for Scrap,Knjiženje rastura i loma -DocType: Item,Website Warehouse,Skladište web sajta -DocType: Sales Invoice Item,Customer's Item Code,Šifra kupca -DocType: Bank Guarantee,Supplier,Dobavljači -DocType: Purchase Invoice,Additional Discount Amount,Iznos dodatnog popusta -apps/erpnext/erpnext/projects/report/project_wise_stock_tracking/project_wise_stock_tracking.py +30,Project Start Date,Datum početka projekta -DocType: Announcement,Student,Student -apps/erpnext/erpnext/accounts/report/purchase_order_items_to_be_billed/purchase_order_items_to_be_billed.py +18,Suplier Name,Naziv dobavljača -apps/erpnext/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +37,In Qty,Prijem količine -apps/erpnext/erpnext/stock/report/item_price_stock/item_price_stock.py +68,Selling Rate,Prodajna cijena -apps/erpnext/erpnext/hr/doctype/upload_attendance/upload_attendance.js +64,Import Successful!,Uvoz uspješan! -apps/erpnext/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +539,Stock cannot be updated against Delivery Note {0},Zaliha se ne može promijeniti jer je vezana sa otpremnicom {0} -apps/erpnext/erpnext/accounts/page/pos/pos.js +742,You are in offline mode. You will not be able to reload until you have network.,Радите без интернета. Нећете моћи да учитате страницу док се не повежете. -apps/erpnext/erpnext/selling/page/point_of_sale/point_of_sale.js +542,Form View,Prikaži kao formu -apps/erpnext/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py +81,Shortage Qty,Manjak kol. -DocType: Drug Prescription,Hour,Sat -apps/erpnext/erpnext/setup/doctype/item_group/item_group.js +55,Item Group Tree,Stablo vrste artikala -DocType: POS Profile,Update Stock,Ažuriraj zalihu -DocType: Crop,Target Warehouse,Ciljno skladište -,Delivery Note Trends,Trendovi Otpremnica -DocType: Stock Entry,Default Source Warehouse,Izdajno skladište -apps/erpnext/erpnext/hr/doctype/salary_slip/salary_slip.py +535,"{0}: Employee email not found, hence email not sent","{0}: Email zaposlenog nije pronađena, stoga email nije poslat" -apps/erpnext/erpnext/patches/v7_0/create_warehouse_nestedset.py +59,All Warehouses,Sva skladišta -DocType: Asset Value Adjustment,Difference Amount,Razlika u iznosu -DocType: Journal Entry,User Remark,Korisnička napomena -DocType: Notification Control,Quotation Message,Ponuda - poruka -DocType: Purchase Order,% Received,% Primljeno -DocType: Journal Entry,Stock Entry,Unos zaliha -apps/erpnext/erpnext/stock/report/item_prices/item_prices.py +40,Sales Price List,Prodajni cjenovnik -apps/erpnext/erpnext/accounts/report/gross_profit/gross_profit.py +67,Avg. Selling Rate,Prosječna prodajna cijena -DocType: Item,End of Life,Kraj proizvodnje -DocType: Payment Entry,Payment Type,Vrsta plaćanja -DocType: Selling Settings,Default Customer Group,Podrazumijevana grupa kupaca -DocType: Bank Account,Party,Partija -,Total Stock Summary,Ukupan pregled zalihe -DocType: Purchase Invoice,Net Total (Company Currency),Ukupno bez PDV-a (Valuta preduzeća) -DocType: Healthcare Settings,Patient Name,Ime pacijenta -apps/erpnext/erpnext/public/js/payment/pos_payment.html +17,Write Off,Otpisati -DocType: Notification Control,Delivery Note Message,Poruka na otpremnici -apps/erpnext/erpnext/stock/doctype/serial_no/serial_no.py +162,"Cannot delete Serial No {0}, as it is used in stock transactions","Ne može se obrisati serijski broj {0}, dok god se nalazi u dijelu Promjene na zalihama" -apps/erpnext/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +660,Delivery Note {0} is not submitted,Otpremnica {0} nije potvrđena -apps/erpnext/erpnext/hr/doctype/employee/employee_tree.js +29,New Employee,Novi Zaposleni -apps/erpnext/erpnext/public/js/pos/pos.html +98,Customers in Queue,Kupci na čekanju -DocType: Purchase Invoice,Price List Currency,Valuta Cjenovnika -DocType: Authorization Rule,Applicable To (Employee),Primjenljivo na (zaposlene) -apps/erpnext/erpnext/setup/setup_wizard/operations/install_fixtures.py +96,Project Manager,Projektni menadzer -DocType: Journal Entry,Accounts Receivable,Potraživanja od kupaca -DocType: Purchase Invoice Item,Rate,Cijena sa popustom -DocType: Project Task,View Task,Pogledaj zadatak -DocType: Employee Education,Employee Education,Obrazovanje Zaposlenih -DocType: Account,Expense,Rashod -apps/erpnext/erpnext/config/learn.py +107,Newsletters,Newsletter-i -DocType: Purchase Invoice,Select Supplier Address,Izaberite adresu dobavljača -apps/erpnext/erpnext/stock/get_item_details.py +521,Price List {0} is disabled or does not exist,Cjenovnik {0} je zaključan ili ne postoji -DocType: Delivery Note,Billing Address Name,Naziv adrese za naplatu -DocType: Restaurant Order Entry,Add Item,Dodaj stavku -apps/erpnext/erpnext/setup/setup_wizard/operations/install_fixtures.py +108,All Customer Groups,Sve grupe kupca -,Employee Birthday,Rođendan Zaposlenih -DocType: Project,Total Billed Amount (via Sales Invoices),Ukupno fakturisano (putem fakture prodaje) -DocType: Purchase Invoice Item,Weight UOM,JM Težina -DocType: Purchase Invoice Item,Stock Qty,Zaliha -DocType: Delivery Note,Return Against Delivery Note,Povraćaj u vezi sa otpremnicom -apps/erpnext/erpnext/accounts/report/accounts_payable/accounts_payable.js +28,Ageing Range 1,Opseg dospijeća 1 -DocType: Serial No,Incoming Rate,Nabavna cijena -DocType: Projects Settings,Timesheets,Potrošnja vremena -DocType: Upload Attendance,Attendance From Date,Datum početka prisustva -apps/erpnext/erpnext/public/js/pos/pos.html +115,Stock Items,Artikli na zalihama -apps/erpnext/erpnext/accounts/page/pos/pos.js +2227,New Cart,Nova korpa -apps/erpnext/erpnext/stock/report/stock_balance/stock_balance.py +83,Opening Value,Početna vrijednost -apps/erpnext/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py +60,"Setting Events to {0}, since the Employee attached to the below Sales Persons does not have a User ID{1}","Podešavanje stanja na {0}, pošto Zaposleni koji se priključio Prodavcima nema koririsnički ID {1}" -DocType: Upload Attendance,Import Attendance,Uvoz prisustva -apps/erpnext/erpnext/config/selling.py +184,Analytics,Analitika -DocType: Email Digest,Bank Balance,Stanje na računu -DocType: Education Settings,Employee Number,Broj Zaposlenog -DocType: Purchase Receipt Item,Rate and Amount,Cijena i vrijednost sa popustom -apps/erpnext/erpnext/accounts/report/accounts_receivable/accounts_receivable.html +237,'Total','Ukupno bez PDV-a' -DocType: Purchase Invoice,Total Taxes and Charges,Porez -apps/erpnext/erpnext/hr/doctype/salary_slip/salary_slip.py +266,No active or default Salary Structure found for employee {0} for the given dates,Nisu pronađene aktivne ili podrazumjevane strukture plate za Zaposlenog {0} za dati period -DocType: Purchase Order Item,Supplier Part Number,Dobavljačeva šifra -DocType: Project Task,Project Task,Projektni zadatak -DocType: Item Group,Parent Item Group,Nadređena Vrsta artikala -apps/erpnext/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.js +113,Mark Attendance,Označi prisustvo -apps/erpnext/erpnext/manufacturing/doctype/production_planning_tool/production_planning_tool.py +229,{0} created,Kreirao je korisnik {0} -DocType: Purchase Order,Advance Paid,Avansno plačanje -apps/erpnext/erpnext/stock/doctype/item/item.js +41,Projected,Projektovana količina na zalihama -apps/erpnext/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py +43,Reorder Level,Nivo dopune -DocType: Opportunity,Customer / Lead Address,Kupac / Adresa lead-a -DocType: Buying Settings,Default Buying Price List,Podrazumijevani Cjenovnik -DocType: Purchase Invoice Item,Qty,Kol -DocType: Mode of Payment,General,Opšte -DocType: Supplier,Default Payable Accounts,Podrazumijevani nalog za plaćanje -apps/erpnext/erpnext/templates/includes/cart/cart_items.html +29,Rate: {0},Cijena: {0} -apps/erpnext/erpnext/selling/page/point_of_sale/point_of_sale.js +1659,Write Off Amount,Zaokruženi iznos -apps/erpnext/erpnext/accounts/report/accounts_receivable/accounts_receivable.html +139,Total Outstanding Amount,Preostalo za plaćanje -apps/erpnext/erpnext/selling/doctype/sales_order/sales_order.py +359,Not Paid and Not Delivered,Nije plaćeno i nije isporučeno -DocType: Asset Maintenance Log,Planned,Planirano -DocType: Bank Reconciliation,Total Amount,Ukupan iznos -apps/erpnext/erpnext/manufacturing/doctype/bom/bom.py +177,Please select Price List,Izaberite cjenovnik -DocType: Quality Inspection,Item Serial No,Seriski broj artikla -apps/erpnext/erpnext/setup/setup_wizard/operations/install_fixtures.py +388,Customer Service,Usluga kupca -DocType: Project Task,Working,U toku -DocType: Cost Center,Stock User,Korisnik zaliha -apps/erpnext/erpnext/stock/doctype/warehouse/warehouse.js +27,General Ledger,Glavna knjiga -DocType: C-Form,Received Date,Datum prijema -apps/erpnext/erpnext/config/projects.py +13,Project master.,Projektni master -DocType: Item Price,Valid From,Važi od -,Purchase Order Trends,Trendovi kupovina -DocType: Quotation,In Words will be visible once you save the Quotation.,Sačuvajte Predračun da bi Ispis slovima bio vidljiv -apps/erpnext/erpnext/stock/page/stock_balance/stock_balance.js +48,Projected Qty,Projektovana količina -apps/erpnext/erpnext/config/selling.py +234,Customer Addresses And Contacts,Kontakt i adresa kupca -DocType: Healthcare Settings,Employee name and designation in print,Ime i pozicija Zaposlenog -apps/erpnext/erpnext/selling/doctype/sales_order/sales_order.js +1161,For Warehouse,Za skladište -apps/erpnext/erpnext/stock/report/item_prices/item_prices.py +41,Purchase Price List,Nabavni cjenovnik -apps/erpnext/erpnext/accounts/report/accounts_payable/accounts_payable.js +83,Accounts Payable Summary,Pregled obaveze prema dobavljačima -apps/erpnext/erpnext/selling/doctype/sales_order/sales_order.py +224,Delivery Notes {0} must be cancelled before cancelling this Sales Order,Otpremnice {0} moraju biti otkazane prije otkazivanja prodajnog naloga -DocType: Loan,Total Payment,Ukupno plaćeno -DocType: POS Settings,POS Settings,POS podešavanja -apps/erpnext/erpnext/accounts/report/gross_profit/gross_profit.py +70,Buying Amount,Iznos nabavke -DocType: Purchase Invoice Item,Valuation Rate,Prosječna nab. cijena -apps/erpnext/erpnext/projects/report/project_wise_stock_tracking/project_wise_stock_tracking.py +26,Project Id,ID Projekta -DocType: Purchase Invoice,Invoice Copy,Kopija Fakture -apps/erpnext/erpnext/projects/doctype/project/project.py +279,You have been invited to collaborate on the project: {0},Позвани сте да сарађујете на пројекту: {0} -DocType: Journal Entry Account,Purchase Order,Porudžbenica -DocType: Sales Invoice Item,Rate With Margin,Cijena sa popustom -apps/erpnext/erpnext/selling/page/point_of_sale/point_of_sale.js +1182,"Search by item code, serial number, batch no or barcode","Pretraga po šifri, serijskom br. ili bar kodu" -DocType: GL Entry,Voucher Type,Vrsta dokumenta -apps/erpnext/erpnext/stock/doctype/serial_no/serial_no.py +222,Serial No {0} has already been received,Serijski broj {0} je već primljen -apps/erpnext/erpnext/config/setup.py +51,Data Import and Export,Uvoz i izvoz podataka -apps/erpnext/erpnext/controllers/accounts_controller.py +617,Total advance ({0}) against Order {1} cannot be greater than the Grand Total ({2}),Ukupan avns({0}) na porudžbini {1} ne može biti veći od Ukupnog iznosa ({2}) -DocType: Material Request,% Ordered,% Poručenog -apps/erpnext/erpnext/stock/get_item_details.py +523,Price List not selected,Cjenovnik nije odabran -DocType: POS Profile,Apply Discount On,Primijeni popust na -DocType: Item,Total Projected Qty,Ukupna projektovana količina -DocType: Shipping Rule Condition,Shipping Rule Condition,Uslovi pravila nabavke -apps/erpnext/erpnext/config/stock.py +317,Opening Stock Balance,Početno stanje zalihe -,Customer Credit Balance,Kreditni limit kupca -apps/erpnext/erpnext/public/js/templates/address_list.html +20,No address added yet.,Adresa još nije dodata. -DocType: Subscription,Net Total,Ukupno bez PDV-a -DocType: Sales Invoice,Total Qty,Ukupna kol. -DocType: Purchase Invoice,Return,Povraćaj -DocType: Sales Order Item,Delivery Warehouse,Skladište dostave -DocType: Purchase Invoice,Total (Company Currency),Ukupno bez PDV-a (Valuta) -DocType: Sales Invoice,Change Amount,Kusur -apps/erpnext/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +1083,Opportunity,Prilika -DocType: Sales Order,Fully Delivered,Kompletno isporučeno -DocType: Leave Control Panel,Leave blank if considered for all employee types,Ostavite prazno ako se podrazumijeva za sve tipove Zaposlenih -apps/erpnext/erpnext/selling/page/point_of_sale/point_of_sale.js +594,Disc,Popust -DocType: Customer,Default Price List,Podrazumijevani cjenovnik -DocType: Bank Statement Transaction Invoice Item,Journal Entry,Knjiženje -DocType: Purchase Invoice,Apply Additional Discount On,Primijeni dodatni popust na -apps/erpnext/erpnext/buying/doctype/supplier/supplier_dashboard.py +6,This is based on transactions against this Supplier. See timeline below for details,Ovo je zasnovano na transkcijama ovog dobavljača. Pogledajte vremensku liniju ispod za dodatne informacije -apps/erpnext/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py +64,90-Above,Iznad 90 dana -apps/erpnext/erpnext/education/doctype/assessment_plan/assessment_plan.py +49,You have already assessed for the assessment criteria {}.,Већ сте оценили за критеријум оцењивања {}. -apps/erpnext/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +994,Serial Numbers in row {0} does not match with Delivery Note,Serijski broj na poziciji {0} se ne poklapa sa otpremnicom -apps/erpnext/erpnext/public/js/templates/contact_list.html +37,New Contact,Novi kontakt -DocType: Cashier Closing,Returns,Povraćaj -DocType: Delivery Note,Delivery To,Isporuka za -apps/erpnext/erpnext/projects/report/project_wise_stock_tracking/project_wise_stock_tracking.py +29,Project Value,Vrijednost Projekta -DocType: Warehouse,Parent Warehouse,Nadređeno skladište -DocType: Payment Request,Make Sales Invoice,Kreiraj fakturu prodaje -apps/erpnext/erpnext/selling/page/point_of_sale/point_of_sale.js +594,Del,Obriši -apps/erpnext/erpnext/public/js/stock_analytics.js +58,Select Warehouse...,Izaberite skladište... -DocType: Payment Reconciliation,Invoice/Journal Entry Details,Faktura / Detalji knjiženja -,Projected Quantity as Source,Projektovana izvorna količina -DocType: Asset Maintenance,Manufacturing User,Korisnik u proizvodnji -apps/erpnext/erpnext/utilities/activation.py +99,Create Users,Kreiraj korisnike -apps/erpnext/erpnext/public/js/pos/pos_selected_item.html +15,Price,Cijena -apps/erpnext/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +37,Out Qty,Izdavanje Kol. -DocType: Supplier Scorecard Scoring Standing,Employee,Zaposleni -apps/erpnext/erpnext/config/projects.py +24,Project activity / task.,Projektna aktivnost / zadatak -DocType: Production Plan Item,Reserved Warehouse in Sales Order / Finished Goods Warehouse,Rezervisano skladište u Prodajnom nalogu / Skladište gotovog proizvoda -DocType: Appointment Type,Physician,Ljekar -DocType: Opening Invoice Creation Tool Item,Quantity,Količina -DocType: Buying Settings,Purchase Receipt Required,Prijem robe je obavezan -apps/erpnext/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.py +38,Currency is required for Price List {0},Valuta je obavezna za Cjenovnik {0} -apps/erpnext/erpnext/stock/report/stock_balance/stock_balance.py +87,Out Value,Izdavanje vrije. -DocType: Loyalty Program,Customer Group,Grupa kupaca -apps/erpnext/erpnext/accounts/doctype/gl_entry/gl_entry.py +161,You are not authorized to add or update entries before {0},Немате дозволу да додајете или ажурирате ставке пре {0} -DocType: Serial No,Warehouse can only be changed via Stock Entry / Delivery Note / Purchase Receipt,Skladište se jedino može promijeniti u dijelu Unos zaliha / Otpremnica / Prijem robe -apps/erpnext/erpnext/hooks.py +148,Request for Quotations,Zahtjev za ponude -apps/erpnext/erpnext/config/desktop.py +159,Learn,Naučite -DocType: Timesheet,Employee Detail,Detalji o Zaposlenom -DocType: POS Profile,Ignore Pricing Rule,Zanemari pravilnik o cijenama -DocType: Purchase Invoice,Additional Discount,Dodatni popust -DocType: Payment Entry,Cheque/Reference No,Broj izvoda -apps/erpnext/erpnext/hr/doctype/attendance/attendance.py +46,Attendance date can not be less than employee's joining date,Datum prisustva ne može biti raniji od datuma ulaska zaposlenog -apps/erpnext/erpnext/utilities/user_progress.py +146,Box,Kutija -DocType: Payment Entry,Total Allocated Amount,Ukupno povezani iznos -apps/erpnext/erpnext/config/selling.py +46,All Addresses.,Sve adrese -apps/erpnext/erpnext/utilities/user_progress.py +39,Opening Balances,Početna stanja -apps/erpnext/erpnext/config/setup.py +66,Users and Permissions,Korisnici i dozvole -apps/erpnext/erpnext/hr/doctype/staffing_plan/staffing_plan.py +68,"You can only plan for upto {0} vacancies and budget {1} \ +Yield UOM,Јединица мере приноса +Default Supplier,Podrazumijevani dobavljač +Select Patient,Izaberite pacijenta, +Opening,Početno stanje, +Customer Groups,Grupe kupaca, +Item Manager,Menadžer artikala, +Fiscal Year Company,Fiskalna godina preduzeća, +Patient Appointment,Zakazivanje pacijenata, +Show In Website,Prikaži na web sajtu, +Paid Amount,Uplaćeno, +Total Paid Amount,Ukupno plaćeno, +Purchase Receipt {0} is not submitted,Prijem robe {0} nije potvrđen, +Items and Pricing,Proizvodi i cijene, +Account Paid From,Račun plaćen preko, +Create customer quotes,Kreirajte bilješke kupca, +Supplier Warehouse,Skladište dobavljača, +Customer is required,Kupac je obavezan podatak, +Manufacturer,Proizvođač +Selling Amount,Prodajni iznos, +Please set the Date Of Joining for employee {0},Molimo podesite datum zasnivanja radnog odnosa {0} +Allow over delivery or receipt upto this percent,Dozvolite isporukuili prijem robe ukoliko ne premaši ovaj procenat, +Orders,Porudžbine, +Stock Transactions,Promjene na zalihama, +You are not authorized to approve leaves on Block Dates,Немате дозволу да одобравате одсуства на Блок Датумима. +Daily Timesheet Summary,Pregled dnevnog potrošenog vremena, +View Timesheet,Pogledaj potrošeno vrijeme, +Rounded Total (Company Currency),Zaokruženi ukupan iznos (valuta preduzeća) +Salary Slip of employee {0} already created for this period,Isplatna lista Zaposlenog {0} kreirana je već za ovaj period, +"If this item has variants, then it cannot be selected in sales orders etc.","Ako ovaj artikal ima varijante, onda ne može biti biran u prodajnom nalogu." +You can only redeem max {0} points in this order.,Можете унети највише {0} поена у овој наруџбини. +No leave record found for employee {0} for {1},Nije nađena evidancija o odsustvu Zaposlenog {0} za {1} +Discount on Price List Rate (%),Popust na cijene iz cjenovnika (%) +Groups,Grupe, +Item Attribute,Atribut artikla, +Amount in customer's currency,Iznos u valuti kupca, +Warehouse is mandatory,Skladište je obavezan podatak, +Stock Ageing,Starost zaliha, +New Sales Orders,Novi prodajni nalozi, +Invoice Created,Kreirana faktura, +Employee Internal Work History,Interna radna istorija Zaposlenog, +Cart is Empty,Korpa je prazna, +Patient Details,Detalji o pacijentu, +Stock Entry {0} is not submitted,Unos zaliha {0} nije potvrđen, +Rest Of The World,Ostatak svijeta, +Additional Operating Cost,Dodatni operativni troškovi, +Rejected Warehouse,Odbijeno skladište, +Manufacturing Manager,Menadžer proizvodnje, +You are not present all day(s) between compensatory leave request days,Нисте присутни свих дана између захтева за компензацијски одмор. +Is Fixed Asset,Artikal je osnovno sredstvo, +POS,POS, +Timesheet {0} is already completed or cancelled,Potrošeno vrijeme {0} je već potvrđeno ili otkazano, + (Half Day),(Pola dana) +Net Weight,Neto težina, +Attendance Record {0} exists against Student {1},Zapis o prisustvu {0} постоји kod studenata {1} +Outstanding,Preostalo, +Discount (%) on Price List Rate with Margin,Popust (%) +Select Shipping Address,Odaberite adresu isporuke, +Amount to Bill,Iznos za fakturisanje, +Make Sales Orders to help you plan your work and deliver on-time,Kreiranje prodajnog naloga će vam pomoći da isplanirate svoje vrijeme i dostavite robu na vrijeme, +Sync Offline Invoices,Sinhronizuj offline fakture, +Manufacturing,Proizvodnja, +{0}% Delivered,{0}% Isporučeno, +Attendance,Prisustvo, +Customer's Purchase Order No,Broj porudžbenice kupca, +Please enter Sales Orders in the above table,U tabelu iznad unesite prodajni nalog, +Report Date,Datum izvještaja, +Item Groups,Vrste artikala, +Discount Percentage,Procenat popusta, +Gross Profit %,Bruto dobit% +"You can define all the tasks which need to carried out for this crop here. The day field is used to mention the day on which the task needs to be carried out, 1 being the 1st day, etc.. ","Овде можете дефинисати све задатке које је потребно извршити за ову жетву. Поље Дан говори дан на који је задатак потребно извршити, 1 је 1. дан, итд." +Payment Request,Upit za plaćanje, +Purchase Analytics,Analiza nabavke, +Tree Details,Detalji stabla, +Upload Attendance,Priloži evidenciju, +Against,Povezano sa, +Requested Amount,Traženi iznos, +"Record of all communications of type email, phone, chat, visit, etc.","Snimanje svih komunikacija tipa email, telefon, poruke, posjete, itd." +Customer Contact Email,Kontakt e-mail kupca, +Primary Address Details,Detalji o primarnoj adresi, +Above,Iznad, +Variant Based On,Varijanta zasnovana na, +Task Weight,Težina zadataka, +Transaction ID,Transakcije, +Allocated,Dodijeljeno, +Add more items or open full form,Dodaj još stavki ili otvori kompletan prozor, +Reserved for sale,Rezervisana za prodaju, +Item Group,Vrste artikala, +Age (Days),Starost (Dani) +Opening (Dr),Početno stanje (Du) +Total Outstanding Amt,Preostalo za plaćanje, +Go to the Desktop and start using ERPNext,Idite na radnu površinu i krenite sa radom u programu, +You can only have Plans with the same billing cycle in a Subscription,Сви Планови у Претплати морају имати исти циклус наплате +Name and Employee ID,Ime i ID Zaposlenog, +Invoice,Faktura, +Invoice Date,Datum fakture, +From Lead,Od Lead-a, +Database of potential customers.,Baza potencijalnih kupaca, +Project Status,Status Projekta, +All Item Groups,Sve vrste artikala, +Serial No {0} does not exist,Serijski broj {0} ne postoji, +No contacts added yet.,Još uvijek nema dodatih kontakata, +Ageing Range 3,Opseg dospijeća 3, +Request for Quotation,Zahtjev za ponudu, +Account Paid To,Račun plaćen u, +Attendance can not be marked for future dates,Učesnik ne može biti označen za buduće datume, +Sales Invoice No,Broj fakture prodaje, +Timesheet,Potrošeno vrijeme, +Don't send Employee Birthday Reminders,Nemojte slati podsjetnik o rođendanima Zaposlenih, +Available Qty at Warehouse,Dostupna količina na skladištu, +Foreign Trade Details,Spoljnotrgovinski detalji, +Minimum Order Qty,Minimalna količina za poručivanje, +No employees for the mentioned criteria,Za traženi kriterijum nema Zaposlenih, +Fiscal Year,Fiskalna godina, +Repack,Prepakovati, +Please select a warehouse,Izaberite skladište, +Received and Accepted,Primio i prihvatio, +Project will be accessible on the website to these users,Projekat će biti dostupan na sajtu sledećim korisnicima, +Upload HTML,Priloži HTML, +Services,Usluge, +Item Cart,Korpa sa artiklima, +Total Paid Amt,Ukupno plaćeno, +Warehouse Detail,Detalji o skldištu, +Quotation Item,Stavka sa ponude, +Employee Advance,Napredak Zaposlenog, +Warehouse and Reference,Skladište i veza, +{0} {1}: Account {2} is inactive,{0} {1}: Nalog {2} je neaktivan, +Fiscal Year {0} not found,Fiskalna godina {0} nije pronađena, +No Remarks,Nema napomene, +Purchase Receipt Message,Poruka u Prijemu robe, +Taxes and Charges Deducted,Umanjeni porezi i naknade, +Include Payment (POS),Uključi POS plaćanje, +Customer PO Details,Pregled porudžbine kupca, +Total Invoiced Amt,Ukupno fakturisano, +Select Brand...,Izaberite brend, +Default Unit of Measure,Podrazumijevana jedinica mjere, +Serial No,Serijski broj, +Supplier Type,Tip dobavljača, +Actual Qty {0} / Waiting Qty {1},Trenutna kol. {0} / Na čekanju {1} +Individual,Fizičko lice, +Partially Ordered,Djelimično poručeno, +Posting Date,Datum dokumenta, +Date Settings,Podešavanje datuma, +Total Allocated Amount (Company Currency),Ukupan povezani iznos (Valuta) +Income,Prihod, +Add Items,Dodaj stavke, +Price List not found or disabled,Cjenovnik nije pronađen ili je zaključan, +Weight (In Kilogram),Težina (u kg) +New Sales Invoice,Nova faktura, +New Company,Novo preduzeće, +Support Team,Tim za podršku, +Valuation Method,Način vrednovanja, +Project Type,Tip Projekta, +Returned Qty,Vraćena kol. +Additional Discount Amount (Company Currency),Iznos dodatnog popusta (valuta preduzeća) +Employee Information,Informacije o Zaposlenom, +'Days Since Last Order' must be greater than or equal to zero,"""Dana od poslednje porudžbine"" mora biti veće ili jednako nuli" +Maintenance,Održavanje, +Multiple Item prices.,Više cijena artikala, +Received From,je primljen od, +Write Off Difference Amount,Otpis razlike u iznosu, +Closing Date,Datum zatvaranja, +Cheque/Reference Date,Datum izvoda, +Planned Qty,Planirana količina, +Payment Date,Datum plaćanja, +Additional Details,Dodatni detalji, +Create Chart Of Accounts Based On,Kreiraj kontni plan prema, +You can not change rate if BOM mentioned agianst any item,Не можете променити цену ако постоји Саставница за било коју ставку. +Open To Do,Otvori To Do, +Average Discount,Prosječan popust, +Material Issue,Reklamacija robe, +Billed Amt,Fakturisani iznos, +Supplier Quotation {0} created,Ponuda dobavljaču {0} је kreirana, +Not allowed to update stock transactions older than {0},Nije dozvoljeno mijenjati Promjene na zalihama starije od {0} +Add Employees,Dodaj Zaposlenog, +Setting up Employees,Podešavanja Zaposlenih, +Warehouse not found in the system,Skladište nije pronađeno u sistemu, +Attendance for employee {0} is already marked for this day,Prisustvo zaposlenog {0} је već označeno za ovaj dan, +Employee relieved on {0} must be set as 'Left',"Zaposleni smijenjen na {0} mora biti označen kao ""Napustio""" + Shipping Bill Number,Broj isporuke, +Lab Test Report,Izvještaj labaratorijskog testa, +You cannot credit and debit same account at the same time,Не можете кредитирати и дебитовати исти налог у исто време. +Customer Name,Naziv kupca, +Current Address,Trenutna adresa, +Upcoming Calendar Events,Predstojeći događaji u kalendaru, +Make Payment via Journal Entry,Kreiraj uplatu kroz knjiženje, +Paid,Plaćeno, +Buying,Nabavka, +Default Item Group,Podrazumijevana vrsta artikala, +In Stock Qty,Na zalihama, +Taxes and Charges Deducted (Company Currency),Umanjeni porezi i naknade (valuta preduzeća) +Additional Costs,Dodatni troškovi, +Pending Review,Čeka provjeru, +Default Selling Cost Center,Podrazumijevani centar troškova, +No Customers yet!,Još uvijek nema kupaca! +Sales Return,Povraćaj prodaje, +No Items added to cart,Nema dodatih artikala na računu, +This is based on transactions against this Customer. See timeline below for details,Ovo je zasnovano na transkcijama ovog klijenta. Pogledajte vremensku liniju ispod za dodatne informacije, +Make Timesheet,Kreiraj potrošeno vrijeme, +Warning: Sales Order {0} already exists against Customer's Purchase Order {1},Upozorenje: Prodajni nalog {0}već postoji veza sa porudžbenicom kupca {1} +Healthcare Settings,Podešavanje klinike, +Accounting Ledger,Analitička kartica, +Total Outgoing Value,Ukupna vrijednost isporuke, +Sales Order {0} is {1},Prodajni nalog {0} је {1} +Automatically Set Serial Nos based on FIFO,Podesi automatski serijski broj da koristi FIFO, +New Customers,Novi kupci, +Pre Sales,Prije prodaje, +POS Customer Group,POS grupa kupaca, +Shopping Cart,Korpa sa sajta, +Reserved for manufacturing,Rezervisana za proizvodnju, +Pricing Rule Help,Pravilnik za cijene pomoć +Ageing Range 2,Opseg dospijeća 2, +Employee Benefits,Primanja Zaposlenih, +POS Item Group,POS Vrsta artikala, +Lead,Lead, +Employee Settings,Podešavanja zaposlenih, +View All Products,Pogledajte sve proizvode, +Patient Medical Record,Medicinski karton pacijenta, +Batch,Serija, +Purchase Receipt,Prijem robe, +Warranty Period (in days),Garantni rok (u danima) +Customer database.,Korisnička baza podataka, +Attendance Date,Datum prisustva, +Notify Employee,Obavijestiti Zaposlenog, +User ID not set for Employee {0},Korisnički ID nije podešen za Zaposlenog {0} +Stock Projected Qty,Projektovana količina na zalihama, +Make Payment,Kreiraj plaćanje, +You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global Settings,Ne možete obrisati fiskalnu godinu {0}. Fiskalna {0} godina je označena kao trenutna u globalnim podešavanjima. +{0} units of {1} needed in {2} to complete this transaction.,Željenu količinu {0} za artikal {1} je potrebno dodati na {2} da bi dovršili transakciju.. +Item-wise Sales Register,Prodaja po artiklima, +Tax Rate,Poreska stopa, +Remarks,Napomena, +Sales,Prodaja, +Pricing Rule,Pravilnik za cijene, +Products Settings,Podešavanje proizvoda, +Mobile,Mobilni, +Price List Rate,Cijena, +Discount Amount,Vrijednost popusta, +Sales Invoice Trends,Trendovi faktura prodaje, +You don't have enough Loyalty Points to redeem,Немате довољно Бодова Лојалности. +Tax Breakup,Porez po pozicijama, +Task,Zadatak, +Add / Edit Prices,Dodaj / Izmijeni cijene, +Item Prices,Cijene artikala, +Salary Component,Компонента плате +Customer's Purchase Order Date,Datum porudžbenice kupca, +Country of Origin,Zemlja porijekla, +Please select Employee Record first.,Molimo izaberite registar Zaposlenih prvo, +Order Type,Vrsta porudžbine, +Rate & Amount,Cijena i iznos sa rabatom, +For Price List,Za cjenovnik, +Tax ID,Poreski broj, +WIP Warehouse,Wip skladište, +Itemwise Recommended Reorder Level,Pregled preporučenih nivoa dopune, +{0} against Bill {1} dated {2},{0} veza sa računom {1} na datum {2} +You are not authorized to set Frozen value,Немате дозволу да постављате замрзнуту вредност +Requested Items To Be Ordered,Tražene stavke za isporuku, +Unmarked Attendance,Neobilježeno prisustvo, +Sales Order {0} is not submitted,Prodajni nalog {0} nije potvrđen, +Default Material Request Type,Podrazumijevani zahtjev za tip materijala, +Sales Pipeline,Prodajna linija, +Already completed,Već završen, +Ordered Qty,Poručena kol, +Sales Details,Detalji prodaje, +Navigating,Navigacija, +Your Products or Services,Vaši artikli ili usluge, +CRM,CRM, +The Brand,Brend, +Quotation {0} is cancelled,Ponuda {0} je otkazana, +Item Code,Šifra artikla, +Customer Mobile No,Broj telefona kupca, +Reorder Qty,Kol. za dopunu, +Move Item,Premještanje artikala, +Buying Settings,Podešavanja nabavke, +From Employee,Od Zaposlenog, +Fleet Manager,Menadžer transporta, +Stock Levels,Nivoi zalihe, +Rate With Margin (Company Currency),Cijena sa popustom (Valuta preduzeća) +Closing (Cr),Saldo (Po) +Product Bundle,Sastavnica, +Sales and Returns,Prodaja i povraćaji, +Sync Master Data,Sinhronizuj podatke iz centrale, +Sales Person Name,Ime prodajnog agenta, +Purchase Receipts,Prijemi robe, +Customizing Forms,Prilagođavanje formi, +Attendance for employee {0} is already marked,Prisustvo zaposlenog {0} je već označeno, +% Complete Method,% metod vrednovanja završetka projekta, +Overdue,Istekao, +Posting Time,Vrijeme izrade računa, +Purchase Receipt No,Broj prijema robe, +Expected End Date,Očekivani datum završetka, +Expected End Date can not be less than Expected Start Date,Očekivani datum završetka ne može biti manji od očekivanog dana početka, +Customer Primary Contact,Primarni kontakt kupca, +Expected Start Date,Očekivani datum početka, +Credit Limit,Kreditni limit, +Item Tax,Porez, +Selling,Prodaja, +Customer Contact,Kontakt kupca, +Item {0} does not exist,Artikal {0} ne postoji, +Add Users,Dodaj korisnike, +Select Serial Numbers,Izaberite serijske brojeve, +Payment Entry,Uplate, +In Words,Riječima, +Employee record is created using selected field. ,Izvještaj o Zaposlenom se kreira korišćenjem izabranog polja. +Serial No {0} does not belong to Delivery Note {1},Serijski broj {0} ne pripada otpremnici {1} +Support,Podrška, +Get Sales Orders,Pregledaj prodajne naloge, +Stock Ledger Entry,Unos zalihe robe, +Gantt chart of all tasks.,Gantov grafikon svih zadataka, +Price List Rate (Company Currency),Cijena (Valuta preduzeća) +Address Name,Naziv adrese, +Another Sales Person {0} exists with the same Employee id,Postoji još jedan Prodavac {0} sa istim ID zaposlenog, +Item Group Name,Naziv vrste artikala, +A Customer Group exists with same name please change the Customer name or rename the Customer Group,Isto ime grupe kupca već postoji. Promijenite ime kupca ili izmijenite grupu kupca, +Warning: Another {0} # {1} exists against stock entry {2},Upozorenje: Još jedan {0} # {1} postoji u vezanom Unosu zaliha {2} +Suplier,Dobavljač +Has Serial No,Ima serijski broj, +Employee {0} on Half day on {1},Zaposleni {0} na pola radnog vremena {1} +Difference Amount (Company Currency),Razlika u iznosu (Valuta) +Add Serial No,Dodaj serijski broj, +Company and Accounts,Preduzeće i računi, +Current Address Is,Trenutna adresa je, +Unallocated Amount,Nepovezani iznos, +Show zero values,Prikaži vrijednosti sa nulom, +Address and Contact,Adresa i kontakt, +Supplier-Wise Sales Analytics,Analiza Dobavljačeve pametne prodaje, +Payment Entry is already created,Uplata je već kreirana, +Item,Artikal, +Unpaid,Neplaćen, +Net Rate,Neto cijena sa rabatom, +Project User,Projektni user, +Customer Items,Proizvodi kupca, +Item {0} is cancelled,Stavka {0} je otkazana, +Balance Value,Stanje vrijed. +Sales Order required for Item {0},Prodajni nalog je obavezan za artikal {0} +Patient,Pacijent, +Default Target Warehouse,Prijemno skladište, +Voucher No,Br. dokumenta, +Attendance has been marked successfully.,Prisustvo je uspješno obilježeno. +Serial No {0} created,Serijski broj {0} kreiran, +Asset,Osnovna sredstva, +Received Amount,Iznos uplate, +You cannot edit root node.,Не можете уређивати коренски чвор. +Sales Funnel,Prodajni lijevak, +Payment Due Date,Datum dospijeća fakture, +Consultation,Pregled, +Related,Povezan, +Warehouse Name,Naziv skladišta, +Customer / Item Name,Kupac / Naziv proizvoda, +Total Billed Amount,Ukupno fakturisano, +In Value,Prijem vrije. +Employees Email Id,ID email Zaposlenih, +Tree Type,Tip stabla, +Update Rate and Availability,Izmijenite cijenu i dostupnost, +Supplier Quotation,Ponuda dobavljača, +Quantity and Warehouse,Količina i skladište, +Taxes and Charges Added,Porezi i naknade dodate, +Warehouses,Skladišta, +All Customer Contact,Svi kontakti kupca, +Ledger,Skladišni karton, +Quotation Lost Reason,Razlog gubitka ponude, +Return Against Purchase Invoice,Povraćaj u vezi sa Fakturom nabavke, +Brand Name,Naziv brenda, +Stock,Zalihe, +Customer Group Name,Naziv grupe kupca, +Is Sales Item,Da li je prodajni artikal, +Invoiced Amount,Fakturisano, +Edit Posting Date and Time,Izmijeni datum i vrijeme dokumenta, +Inactive Customers,Neaktivni kupci, +Stock Entry Detail,Detalji unosa zaliha, +Accounting Details,Računovodstveni detalji, +Stock Manager,Menadžer zaliha, +As on Date,Na datum, +Is Cancelled,Je otkazan, +Setup Series,Podešavanje tipa dokumenta, +Point of Sale,Kasa, +Invoice No,Broj fakture, +Purchase Receipt Item,Stavka Prijema robe, +Invoices,Fakture, +Task Progress,% završenosti zadataka, +Employee Attendance Tool,Alat za prisustvo Zaposlenih, +Payment Days,Dana za plaćanje, +Recruitment,Zapošljavanje, +Taxes and Charges Calculation,Izračun Poreza, +For Employee,Za Zaposlenog, +Terms and Conditions Template,Uslovi i odredbe šablon, +Change,Kusur, +Stock Entry {0} created,Unos zaliha {0} je kreiran, +Search Item (Ctrl + i),Pretraga artikala (Ctrl + i) +View in Cart,Pogledajte u korpi, +Item Price updated for {0} in Price List {1},Cijena artikla je izmijenjena {0} u cjenovniku {1} +Discount,Popust, +Net Weight UOM,Neto težina JM, +Party Type,Tip partije, +Sales Order Required,Prodajni nalog je obavezan, +Search Item,Pretraži artikal, +Delivered Items To Be Billed,Nefakturisana isporučena roba, +Debit,Duguje, +Date TIme,Datum i vrijeme, +Payment Document,Dokument za plaćanje, +You can not enter current voucher in 'Against Journal Entry' column,"Неможете унети тренутни ваучер у колону ""На основу ставке у журналу""" +In Words (Company Currency),Riječima (valuta kompanije) +Purchase Receipt Trends,Trendovi prijema robe, +Employee Leave Approver,Odobreno odsustvo Zaposlenog, +Fiscal Year {0} does not exist,Fiskalna godina {0} ne postoji, +Accepted Warehouse,Prihvaćeno skladište, +Income Account,Račun prihoda, +Account Balance,Knjigovodstveno stanje, +'Expected Start Date' can not be greater than 'Expected End Date',Očekivani datum početka ne može biti veći od očekivanog datuma završetka, +Employee Emails,Elektronska pošta Zaposlenog, +Opening Qty,Početna količina, +Reorder level based on Warehouse,Nivo dopune u zavisnosti od skladišta, +To Warehouse,U skladište, +Is Group,Je grupa, +Contact Person,Kontakt osoba, +Item Code for Suppliers,Dobavljačeva šifra, +Return / Debit Note,Povraćaj / knjižno zaduženje, +Request for Quotation Supplier,Zahtjev za ponudu dobavljača, +LeaderBoard,Tabla, +Lab Test Groups,Labaratorijske grupe, +Training Result Employee,Rezultati obuke Zaposlenih, +Invoice Details,Detalji fakture, +Banking and Payments,Bakarstvo i plaćanja, +Employee Name,Ime Zaposlenog, +Active Leads / Customers,Активни Леадс / Kupci, +Accounting,Računovodstvo, +Party Name,Ime partije, +Manufacture,Proizvodnja, +New task,Novi zadatak, +Accounts Payable,Obaveze prema dobavljačima, +Shipping Address,Adresa isporuke, +Outstanding Amount,Preostalo za uplatu, +New Warehouse Name,Naziv novog skladišta, +Billed Amount,Fakturisani iznos, +Balance Qty,Stanje zalihe, +Item Shortage Report,Izvještaj o negativnim zalihama, +Transaction reference no {0} dated {1},Broj izvoda {0} na datum {1} +Make Sales Order,Kreiraj prodajni nalog, +Items,Artikli, +Employees working on a holiday,Zaposleni koji rade za vrijeme praznika, +Allocate Payment Amount,Poveži uplaćeni iznos, +Patient ID,ID pacijenta, +Printed On,Datum i vrijeme štampe, +Debit To,Zaduženje za, +Global Settings,Globalna podešavanja, +Make Employee,Keriraj Zaposlenog, +Atleast one warehouse is mandatory,Minimum jedno skladište je obavezno, +Price List Name,Naziv cjenovnika, +Journal Entry for Scrap,Knjiženje rastura i loma, +Website Warehouse,Skladište web sajta, +Customer's Item Code,Šifra kupca, +Supplier,Dobavljači, +Additional Discount Amount,Iznos dodatnog popusta, +Project Start Date,Datum početka projekta, +Student,Student, +Suplier Name,Naziv dobavljača, +In Qty,Prijem količine, +Selling Rate,Prodajna cijena, +Import Successful!,Uvoz uspješan! +Stock cannot be updated against Delivery Note {0},Zaliha se ne može promijeniti jer je vezana sa otpremnicom {0} +You are in offline mode. You will not be able to reload until you have network.,Радите без интернета. Нећете моћи да учитате страницу док се не повежете. +Form View,Prikaži kao formu, +Shortage Qty,Manjak kol. +Hour,Sat, +Item Group Tree,Stablo vrste artikala, +Update Stock,Ažuriraj zalihu, +Target Warehouse,Ciljno skladište, +Delivery Note Trends,Trendovi Otpremnica, +Default Source Warehouse,Izdajno skladište, +"{0}: Employee email not found, hence email not sent","{0}: Email zaposlenog nije pronađena, stoga email nije poslat" +All Warehouses,Sva skladišta, +Difference Amount,Razlika u iznosu, +User Remark,Korisnička napomena, +Quotation Message,Ponuda - poruka, +% Received,% Primljeno, +Stock Entry,Unos zaliha, +Sales Price List,Prodajni cjenovnik, +Avg. Selling Rate,Prosječna prodajna cijena, +End of Life,Kraj proizvodnje, +Payment Type,Vrsta plaćanja, +Default Customer Group,Podrazumijevana grupa kupaca, +Party,Partija, +Total Stock Summary,Ukupan pregled zalihe, +Net Total (Company Currency),Ukupno bez PDV-a (Valuta preduzeća) +Patient Name,Ime pacijenta, +Write Off,Otpisati, +Delivery Note Message,Poruka na otpremnici, +"Cannot delete Serial No {0}, as it is used in stock transactions","Ne može se obrisati serijski broj {0}, dok god se nalazi u dijelu Promjene na zalihama" +Delivery Note {0} is not submitted,Otpremnica {0} nije potvrđena, +New Employee,Novi Zaposleni, +Customers in Queue,Kupci na čekanju, +Price List Currency,Valuta Cjenovnika, +Applicable To (Employee),Primjenljivo na (zaposlene) +Project Manager,Projektni menadzer, +Accounts Receivable,Potraživanja od kupaca, +Rate,Cijena sa popustom, +View Task,Pogledaj zadatak, +Employee Education,Obrazovanje Zaposlenih, +Expense,Rashod, +Newsletters,Newsletter-i, +Select Supplier Address,Izaberite adresu dobavljača, +Price List {0} is disabled or does not exist,Cjenovnik {0} je zaključan ili ne postoji, +Billing Address Name,Naziv adrese za naplatu, +Add Item,Dodaj stavku, +All Customer Groups,Sve grupe kupca, +Employee Birthday,Rođendan Zaposlenih, +Total Billed Amount (via Sales Invoice),Ukupno fakturisano (putem fakture prodaje), +Weight UOM,JM Težina, +Stock Qty,Zaliha, +Return Against Delivery Note,Povraćaj u vezi sa otpremnicom, +Ageing Range 1,Opseg dospijeća 1, +Incoming Rate,Nabavna cijena, +Timesheets,Potrošnja vremena, +Attendance From Date,Datum početka prisustva, +Stock Items,Artikli na zalihama, +New Cart,Nova korpa, +Opening Value,Početna vrijednost, +"Setting Events to {0}, since the Employee attached to the below Sales Persons does not have a User ID{1}","Podešavanje stanja na {0}, pošto Zaposleni koji se priključio Prodavcima nema koririsnički ID {1}" +Import Attendance,Uvoz prisustva, +Analytics,Analitika, +Bank Balance,Stanje na računu, +Employee Number,Broj Zaposlenog, +Rate and Amount,Cijena i vrijednost sa popustom, +'Total','Ukupno bez PDV-a' +Total Taxes and Charges,Porez, +No active or default Salary Structure found for employee {0} for the given dates,Nisu pronađene aktivne ili podrazumjevane strukture plate za Zaposlenog {0} za dati period, +Supplier Part Number,Dobavljačeva šifra, +Project Task,Projektni zadatak, +Parent Item Group,Nadređena Vrsta artikala, +Mark Attendance,Označi prisustvo, +{0} created,Kreirao je korisnik {0} +Advance Paid,Avansno plačanje, +Projected,Projektovana količina na zalihama, +Reorder Level,Nivo dopune, +Customer / Lead Address,Kupac / Adresa lead-a, +Default Buying Price List,Podrazumijevani Cjenovnik, +Qty,Kol, +General,Opšte, +Default Payable Accounts,Podrazumijevani nalog za plaćanje, +Rate: {0},Cijena: {0} +Write Off Amount,Zaokruženi iznos, +Total Outstanding Amount,Preostalo za plaćanje, +Not Paid and Not Delivered,Nije plaćeno i nije isporučeno, +Planned,Planirano, +Total Amount,Ukupan iznos, +Please select Price List,Izaberite cjenovnik, +Item Serial No,Seriski broj artikla, +Customer Service,Usluga kupca, +Working,U toku, +Stock User,Korisnik zaliha, +General Ledger,Glavna knjiga, +Received Date,Datum prijema, +Project master.,Projektni master, +Valid From,Važi od, +Purchase Order Trends,Trendovi kupovina, +In Words will be visible once you save the Quotation.,Sačuvajte Predračun da bi Ispis slovima bio vidljiv, +Projected Qty,Projektovana količina, +Customer Addresses And Contacts,Kontakt i adresa kupca, +Employee name and designation in print,Ime i pozicija Zaposlenog, +For Warehouse,Za skladište, +Purchase Price List,Nabavni cjenovnik, +Accounts Payable Summary,Pregled obaveze prema dobavljačima, +Delivery Notes {0} must be cancelled before cancelling this Sales Order,Otpremnice {0} moraju biti otkazane prije otkazivanja prodajnog naloga, +Total Payment,Ukupno plaćeno, +POS Settings,POS podešavanja, +Buying Amount,Iznos nabavke, +Valuation Rate,Prosječna nab. cijena, +Project Id,ID Projekta, +Invoice Copy,Kopija Fakture, +You have been invited to collaborate on the project: {0},Позвани сте да сарађујете на пројекту: {0} +Purchase Order,Porudžbenica, +Rate With Margin,Cijena sa popustom, +"Search by item code, serial number, batch no or barcode","Pretraga po šifri, serijskom br. ili bar kodu" +Voucher Type,Vrsta dokumenta, +Serial No {0} has already been received,Serijski broj {0} je već primljen, +Data Import and Export,Uvoz i izvoz podataka, +Total advance ({0}) against Order {1} cannot be greater than the Grand Total ({2}),Ukupan avns({0}) na porudžbini {1} ne može biti veći od Ukupnog iznosa ({2}) +% Ordered,% Poručenog, +Price List not selected,Cjenovnik nije odabran, +Apply Discount On,Primijeni popust na, +Total Projected Qty,Ukupna projektovana količina, +Shipping Rule Condition,Uslovi pravila nabavke, +Opening Stock Balance,Početno stanje zalihe, +Customer Credit Balance,Kreditni limit kupca, +No address added yet.,Adresa još nije dodata. +Net Total,Ukupno bez PDV-a, +Total Qty,Ukupna kol. +Return,Povraćaj, +Delivery Warehouse,Skladište dostave, +Total (Company Currency),Ukupno bez PDV-a (Valuta) +Change Amount,Kusur, +Opportunity,Prilika, +Fully Delivered,Kompletno isporučeno, +Leave blank if considered for all employee types,Ostavite prazno ako se podrazumijeva za sve tipove Zaposlenih, +Disc,Popust, +Default Price List,Podrazumijevani cjenovnik, +Journal Entry,Knjiženje, +Apply Additional Discount On,Primijeni dodatni popust na, +This is based on transactions against this Supplier. See timeline below for details,Ovo je zasnovano na transkcijama ovog dobavljača. Pogledajte vremensku liniju ispod za dodatne informacije, +90-Above,Iznad 90 dana, +You have already assessed for the assessment criteria {}.,Већ сте оценили за критеријум оцењивања {}. +Serial Numbers in row {0} does not match with Delivery Note,Serijski broj na poziciji {0} se ne poklapa sa otpremnicom, +New Contact,Novi kontakt, +Returns,Povraćaj, +Delivery To,Isporuka za, +Project Value,Vrijednost Projekta, +Parent Warehouse,Nadređeno skladište, +Make Sales Invoice,Kreiraj fakturu prodaje, +Del,Obriši, +Select Warehouse...,Izaberite skladište... +Invoice/Journal Entry Details,Faktura / Detalji knjiženja, +Projected Quantity as Source,Projektovana izvorna količina, +Manufacturing User,Korisnik u proizvodnji, +Create Users,Kreiraj korisnike, +Price,Cijena, +Out Qty,Izdavanje Kol. +Employee,Zaposleni, +Project activity / task.,Projektna aktivnost / zadatak, +Reserved Warehouse in Sales Order / Finished Goods Warehouse,Rezervisano skladište u Prodajnom nalogu / Skladište gotovog proizvoda, +Physician,Ljekar, +Quantity,Količina, +Purchase Receipt Required,Prijem robe je obavezan, +Currency is required for Price List {0},Valuta je obavezna za Cjenovnik {0} +Out Value,Izdavanje vrije. +Customer Group,Grupa kupaca, +You are not authorized to add or update entries before {0},Немате дозволу да додајете или ажурирате ставке пре {0} +Warehouse can only be changed via Stock Entry / Delivery Note / Purchase Receipt,Skladište se jedino može promijeniti u dijelu Unos zaliha / Otpremnica / Prijem robe, +Request for Quotations,Zahtjev za ponude, +Learn,Naučite, +Employee Detail,Detalji o Zaposlenom, +Ignore Pricing Rule,Zanemari pravilnik o cijenama, +Additional Discount,Dodatni popust, +Cheque/Reference No,Broj izvoda, +Attendance date can not be less than employee's joining date,Datum prisustva ne može biti raniji od datuma ulaska zaposlenog, +Box,Kutija, +Total Allocated Amount,Ukupno povezani iznos, +All Addresses.,Sve adrese, +Opening Balances,Početna stanja, +Users and Permissions,Korisnici i dozvole, +"You can only plan for upto {0} vacancies and budget {1} \ for {2} as per staffing plan {3} for parent company {4}.",Можете планирати до {0} слободна места и буџетирати {1} \ за {2} по плану особља {3} за матичну компанију {4}. apps/erpnext/erpnext/public/js/event.js +19,Add Customers,Dodaj kupce DocType: Employee External Work History,Employee External Work History,Istorijat o radu van preduzeća za Zaposlenog diff --git a/erpnext/translations/sr.csv b/erpnext/translations/sr.csv index f6cbb57d201d..9558e07797b1 100644 --- a/erpnext/translations/sr.csv +++ b/erpnext/translations/sr.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Не можете обрисати тип пројекта 'Спољни', You cannot edit root node.,Не можете уређивати роот чвор., You cannot restart a Subscription that is not cancelled.,Не можете поново покренути претплату која није отказана., -You don't have enought Loyalty Points to redeem,Не искористите Лоиалти Поинтс за откуп, +You don't have enough Loyalty Points to redeem,Не искористите Лоиалти Поинтс за откуп, You have already assessed for the assessment criteria {}.,Већ сте оцијенили за критеријуми за оцењивање {}., You have already selected items from {0} {1},Који сте изабрали ставке из {0} {1}, You have been invited to collaborate on the project: {0},Позвани сте да сарађују на пројекту: {0}, diff --git a/erpnext/translations/sr_sp.csv b/erpnext/translations/sr_sp.csv index 5e7ae79781a7..7c9ed6de3ad4 100644 --- a/erpnext/translations/sr_sp.csv +++ b/erpnext/translations/sr_sp.csv @@ -545,7 +545,7 @@ You cannot credit and debit same account at the same time,Не можете кр You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global Settings,Ne možete obrisati fiskalnu godinu {0}. Fiskalna {0} godina je označena kao trenutna u globalnim podešavanjima., You cannot delete Project Type 'External',"Не можете обрисати ""Спољни"" тип пројекта.", You cannot edit root node.,Не можете уређивати коренски чвор., -You don't have enought Loyalty Points to redeem,Немате довољно Бодова Лојалности., +You don't have enough Loyalty Points to redeem,Немате довољно Бодова Лојалности., You have already assessed for the assessment criteria {}.,Већ сте оценили за критеријум оцењивања {}., You have already selected items from {0} {1},Већ сте изабрали ставке из {0} {1}, You have been invited to collaborate on the project: {0},Позвани сте да сарађујете на пројекту: {0}, diff --git a/erpnext/translations/sv.csv b/erpnext/translations/sv.csv index ec443e90be65..7d9b70024cd6 100644 --- a/erpnext/translations/sv.csv +++ b/erpnext/translations/sv.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Du kan inte ta bort Project Type 'External', You cannot edit root node.,Du kan inte redigera rotknutpunkt., You cannot restart a Subscription that is not cancelled.,Du kan inte starta om en prenumeration som inte avbryts., -You don't have enought Loyalty Points to redeem,Du har inte tillräckligt med lojalitetspoäng för att lösa in, +You don't have enough Loyalty Points to redeem,Du har inte tillräckligt med lojalitetspoäng för att lösa in, You have already assessed for the assessment criteria {}.,Du har redan bedömt för bedömningskriterierna {}., You have already selected items from {0} {1},Du har redan valt objekt från {0} {1}, You have been invited to collaborate on the project: {0},Du har blivit inbjuden att samarbeta i projektet: {0}, diff --git a/erpnext/translations/sw.csv b/erpnext/translations/sw.csv index 989f8b06c88c..6a0ef8cc7c1e 100644 --- a/erpnext/translations/sw.csv +++ b/erpnext/translations/sw.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Huwezi kufuta Aina ya Mradi 'Nje', You cannot edit root node.,Huwezi kubadilisha node ya mizizi., You cannot restart a Subscription that is not cancelled.,Huwezi kuanzisha upya Usajili ambao haujahairiwa., -You don't have enought Loyalty Points to redeem,Huna ushawishi wa Pole ya Uaminifu ili ukomboe, +You don't have enough Loyalty Points to redeem,Huna ushawishi wa Pole ya Uaminifu ili ukomboe, You have already assessed for the assessment criteria {}.,Tayari umehakikishia vigezo vya tathmini {}., You have already selected items from {0} {1},Tayari umechagua vitu kutoka {0} {1}, You have been invited to collaborate on the project: {0},Umealikwa kushirikiana kwenye mradi: {0}, diff --git a/erpnext/translations/ta.csv b/erpnext/translations/ta.csv index 7314d4be513d..35cdc92b374b 100644 --- a/erpnext/translations/ta.csv +++ b/erpnext/translations/ta.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',நீங்கள் திட்டம் வகை 'வெளிப்புற' நீக்க முடியாது, You cannot edit root node.,ரூட் முனையை நீங்கள் திருத்த முடியாது., You cannot restart a Subscription that is not cancelled.,ரத்துசெய்யப்படாத சந்தாவை மறுதொடக்கம் செய்ய முடியாது., -You don't have enought Loyalty Points to redeem,நீங்கள் மீட்கும் விசுவாச புள்ளிகளைப் பெறுவீர்கள், +You don't have enough Loyalty Points to redeem,நீங்கள் மீட்கும் விசுவாச புள்ளிகளைப் பெறுவீர்கள், You have already assessed for the assessment criteria {}.,ஏற்கனவே மதிப்பீட்டிற்குத் தகுதி மதிப்பீடு செய்யப்பட்டதன் {}., You have already selected items from {0} {1},நீங்கள் ஏற்கனவே இருந்து பொருட்களை தேர்ந்தெடுத்த {0} {1}, You have been invited to collaborate on the project: {0},நீங்கள் திட்டம் இணைந்து அழைக்கப்பட்டுள்ளனர்: {0}, diff --git a/erpnext/translations/te.csv b/erpnext/translations/te.csv index 3fb32dc093df..9b8b2d7d1ae1 100644 --- a/erpnext/translations/te.csv +++ b/erpnext/translations/te.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',మీరు ప్రాజెక్ట్ రకం 'బాహ్య' తొలగించలేరు, You cannot edit root node.,మీరు రూట్ నోడ్ను సవరించలేరు., You cannot restart a Subscription that is not cancelled.,మీరు రద్దు చేయని సభ్యత్వాన్ని పునఃప్రారంభించలేరు., -You don't have enought Loyalty Points to redeem,మీరు విమోచన చేయడానికి లాయల్టీ పాయింట్స్ను కలిగి ఉండరు, +You don't have enough Loyalty Points to redeem,మీరు విమోచన చేయడానికి లాయల్టీ పాయింట్స్ను కలిగి ఉండరు, You have already assessed for the assessment criteria {}.,మీరు ఇప్పటికే అంచనా ప్రమాణం కోసం అంచనా {}., You have already selected items from {0} {1},మీరు ఇప్పటికే ఎంపిక నుండి అంశాలను రోజులో {0} {1}, You have been invited to collaborate on the project: {0},మీరు ప్రాజెక్ట్ సహకరించడానికి ఆహ్వానించబడ్డారు: {0}, diff --git a/erpnext/translations/th.csv b/erpnext/translations/th.csv index e371aed72844..a97be2b8bf06 100644 --- a/erpnext/translations/th.csv +++ b/erpnext/translations/th.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',คุณไม่สามารถลบประเภทโครงการ 'ภายนอก', You cannot edit root node.,คุณไม่สามารถแก้ไขโหนดรากได้, You cannot restart a Subscription that is not cancelled.,คุณไม่สามารถรีสตาร์ทการสมัครสมาชิกที่ไม่ได้ยกเลิกได้, -You don't have enought Loyalty Points to redeem,คุณไม่มีจุดภักดีเพียงพอที่จะไถ่ถอน, +You don't have enough Loyalty Points to redeem,คุณไม่มีจุดภักดีเพียงพอที่จะไถ่ถอน, You have already assessed for the assessment criteria {}.,คุณได้รับการประเมินเกณฑ์การประเมินแล้ว {}, You have already selected items from {0} {1},คุณได้เลือกแล้วรายการจาก {0} {1}, You have been invited to collaborate on the project: {0},คุณได้รับเชิญที่จะทำงานร่วมกันในโครงการ: {0}, diff --git a/erpnext/translations/tr.csv b/erpnext/translations/tr.csv index 66ea69a1b5b5..14d842425e31 100644 --- a/erpnext/translations/tr.csv +++ b/erpnext/translations/tr.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External','Dış' Proje Türünü silemezsiniz., You cannot edit root node.,Kök düğümünü düzenleyemezsiniz., You cannot restart a Subscription that is not cancelled.,İptal edilmeyen bir Aboneliği başlatamazsınız., -You don't have enought Loyalty Points to redeem,Kullanılması gereken sadakat puanlarına sahip değilsiniz, +You don't have enough Loyalty Points to redeem,Kullanılması gereken sadakat puanlarına sahip olabilirsiniz, You have already assessed for the assessment criteria {}.,Zaten değerlendirme kriteri {} için değerlendirdiniz., You have already selected items from {0} {1},Zaten öğeleri seçtiniz {0} {1}, You have been invited to collaborate on the project: {0},{0} projesine katkıda bulunmak için davet edildiniz, diff --git a/erpnext/translations/uk.csv b/erpnext/translations/uk.csv index 83c8d41cb70f..d25b242c7d2c 100644 --- a/erpnext/translations/uk.csv +++ b/erpnext/translations/uk.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Ви не можете видалити тип проекту "Зовнішній", You cannot edit root node.,Ви не можете редагувати кореневий вузол., You cannot restart a Subscription that is not cancelled.,"Ви не можете перезапустити підписку, яку не скасовано.", -You don't have enought Loyalty Points to redeem,"Ви не маєте впевнених точок лояльності, щоб викупити", +You don't have enough Loyalty Points to redeem,"Ви не маєте впевнених точок лояльності, щоб викупити", You have already assessed for the assessment criteria {}.,Ви вже оцінили за критеріями оцінки {}., You have already selected items from {0} {1},Ви вже вибрали елементи з {0} {1}, You have been invited to collaborate on the project: {0},Ви були запрошені для спільної роботи над проектом: {0}, diff --git a/erpnext/translations/ur.csv b/erpnext/translations/ur.csv index 8cf0707e3656..eb26f7cee278 100644 --- a/erpnext/translations/ur.csv +++ b/erpnext/translations/ur.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',آپ پراجیکٹ کی قسم کو خارج نہیں کرسکتے ہیں 'بیرونی', You cannot edit root node.,آپ جڑ نوڈ میں ترمیم نہیں کر سکتے ہیں., You cannot restart a Subscription that is not cancelled.,آپ ایک سبسکرپشن کو دوبارہ شروع نہیں کرسکتے جو منسوخ نہیں ہوسکتا., -You don't have enought Loyalty Points to redeem,آپ کو بہت زیادہ وفادار پوائنٹس حاصل کرنے کے لئے نہیں ہے, +You don't have enough Loyalty Points to redeem,آپ کو بہت زیادہ وفادار پوائنٹس حاصل کرنے کے لئے نہیں ہے, You have already assessed for the assessment criteria {}.,آپ نے پہلے ہی تشخیص کے معیار کے تعین کی ہے {}., You have already selected items from {0} {1},آپ نے پہلے ہی سے اشیاء کو منتخب کیا ہے {0} {1}, You have been invited to collaborate on the project: {0},آپ کو منصوبے پر تعاون کرنے کیلئے مدعو کیا گیا ہے: {0}, diff --git a/erpnext/translations/uz.csv b/erpnext/translations/uz.csv index 1e503769cbd2..95141bdb993f 100644 --- a/erpnext/translations/uz.csv +++ b/erpnext/translations/uz.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Siz "Tashqi" loyiha turini o'chira olmaysiz, You cannot edit root node.,Ildiz tugunni tahrirlay olmaysiz., You cannot restart a Subscription that is not cancelled.,Bekor qilinmagan obunani qayta boshlash mumkin emas., -You don't have enought Loyalty Points to redeem,Siz sotib olish uchun sodiqlik nuqtalari yo'q, +You don't have enough Loyalty Points to redeem,Siz sotib olish uchun sodiqlik nuqtalari yo'q, You have already assessed for the assessment criteria {}.,Siz allaqachon baholash mezonlari uchun baholagansiz {}., You have already selected items from {0} {1},{0} {1} dan tanlangan elementlarni tanladingiz, You have been invited to collaborate on the project: {0},Siz loyihada hamkorlik qilish uchun taklif qilingan: {0}, diff --git a/erpnext/translations/vi.csv b/erpnext/translations/vi.csv index 5c673afc77a3..de35891698dd 100644 --- a/erpnext/translations/vi.csv +++ b/erpnext/translations/vi.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',Bạn không thể xóa Loại dự án 'Bên ngoài', You cannot edit root node.,Bạn không thể chỉnh sửa nút gốc., You cannot restart a Subscription that is not cancelled.,Bạn không thể khởi động lại Đăng ký không bị hủy., -You don't have enought Loyalty Points to redeem,Bạn không có Điểm trung thành đủ để đổi, +You don't have enough Loyalty Points to redeem,Bạn không có Điểm trung thành đủ để đổi, You have already assessed for the assessment criteria {}.,Bạn đã đánh giá các tiêu chí đánh giá {}., You have already selected items from {0} {1},Bạn đã chọn các mục từ {0} {1}, You have been invited to collaborate on the project: {0},Bạn được lời mời cộng tác trong dự án: {0}, diff --git a/erpnext/translations/zh.csv b/erpnext/translations/zh.csv index 9ed7420f4630..e2e6899ed5e2 100644 --- a/erpnext/translations/zh.csv +++ b/erpnext/translations/zh.csv @@ -3338,7 +3338,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',您不能删除“外部”类型的项目, You cannot edit root node.,您不能编辑根节点。, You cannot restart a Subscription that is not cancelled.,您无法重新启动未取消的订阅。, -You don't have enought Loyalty Points to redeem,您没有获得忠诚度积分兑换, +You don't have enough Loyalty Points to redeem,您没有获得忠诚度积分兑换, You have already assessed for the assessment criteria {}.,您已经评估了评估标准{}。, You have already selected items from {0} {1},您已经选择从项目{0} {1}, You have been invited to collaborate on the project: {0},您已被邀请在项目上进行合作:{0}, diff --git a/erpnext/translations/zh_tw.csv b/erpnext/translations/zh_tw.csv index ef4967056773..9fd737bed29f 100644 --- a/erpnext/translations/zh_tw.csv +++ b/erpnext/translations/zh_tw.csv @@ -3127,7 +3127,7 @@ You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global S You cannot delete Project Type 'External',您不能刪除項目類型“外部”, You cannot edit root node.,您不能編輯根節點。, You cannot restart a Subscription that is not cancelled.,您無法重新啟動未取消的訂閱。, -You don't have enought Loyalty Points to redeem,您沒有獲得忠誠度積分兌換, +You don't have enough Loyalty Points to redeem,您沒有獲得忠誠度積分兌換, You have already assessed for the assessment criteria {}.,您已經評估了評估標準{}。, You have already selected items from {0} {1},您已經選擇從項目{0} {1}, You have been invited to collaborate on the project: {0},您已被邀請在項目上進行合作:{0}, diff --git a/erpnext/utilities/product.py b/erpnext/utilities/product.py index afe9654e8eaa..e967f7061bb1 100644 --- a/erpnext/utilities/product.py +++ b/erpnext/utilities/product.py @@ -6,6 +6,7 @@ from erpnext.accounts.doctype.pricing_rule.pricing_rule import get_pricing_rule_for_item from erpnext.stock.doctype.batch.batch import get_batch_qty +from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses def get_web_item_qty_in_stock(item_code, item_warehouse_field, warehouse=None): @@ -22,23 +23,31 @@ def get_web_item_qty_in_stock(item_code, item_warehouse_field, warehouse=None): "Website Item", {"item_code": template_item_code}, item_warehouse_field ) - if warehouse: - stock_qty = frappe.db.sql( - """ - select GREATEST(S.actual_qty - S.reserved_qty - S.reserved_qty_for_production - S.reserved_qty_for_sub_contract, 0) / IFNULL(C.conversion_factor, 1) - from tabBin S - inner join `tabItem` I on S.item_code = I.Item_code - left join `tabUOM Conversion Detail` C on I.sales_uom = C.uom and C.parent = I.Item_code - where S.item_code=%s and S.warehouse=%s""", - (item_code, warehouse), - ) + if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1: + warehouses = get_child_warehouses(warehouse) + else: + warehouses = [warehouse] if warehouse else [] + + total_stock = 0.0 + if warehouses: + for warehouse in warehouses: + stock_qty = frappe.db.sql( + """ + select GREATEST(S.actual_qty - S.reserved_qty - S.reserved_qty_for_production - S.reserved_qty_for_sub_contract, 0) / IFNULL(C.conversion_factor, 1) + from tabBin S + inner join `tabItem` I on S.item_code = I.Item_code + left join `tabUOM Conversion Detail` C on I.sales_uom = C.uom and C.parent = I.Item_code + where S.item_code=%s and S.warehouse=%s""", + (item_code, warehouse), + ) + + if stock_qty: + total_stock += adjust_qty_for_expired_items(item_code, stock_qty, warehouse) - if stock_qty: - stock_qty = adjust_qty_for_expired_items(item_code, stock_qty, warehouse) - in_stock = stock_qty[0][0] > 0 and 1 or 0 + in_stock = total_stock > 0 and 1 or 0 return frappe._dict( - {"in_stock": in_stock, "stock_qty": stock_qty, "is_stock_item": is_stock_item} + {"in_stock": in_stock, "stock_qty": total_stock, "is_stock_item": is_stock_item} ) @@ -56,7 +65,7 @@ def adjust_qty_for_expired_items(item_code, stock_qty, warehouse): if not stock_qty[0][0]: break - return stock_qty + return stock_qty[0][0] if stock_qty else 0 def get_expired_batches(batches):