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- \nYou can use Jinja tags in Subject and Body fields for dynamic values.\n
- \n All fields in this doctype are available under the doc object and all fields for the customer to whom the mail will go to is available under the customer object.\n
\n Examples
\n\n\n - Subject:
Statement Of Accounts for {{ customer.name }}
\n - Body:
\nHello {{ customer.name }},
PFA your Statement Of Accounts from {{ doc.from_date }} to {{ doc.to_date }}.
\n
\n"
+ "options": "
\nNote
\n\n- \nYou can use Jinja tags in Subject and Body fields for dynamic values.\n
- \n All fields in this doctype are available under the doc object and all fields for the customer to whom the mail will go to is available under the customer object.\n
\n Examples
\n\n\n - Subject:
Statement Of Accounts for {{ customer.customer_name }}
\n - Body:
\nHello {{ customer.customer_name }},
PFA your Statement Of Accounts from {{ doc.from_date }} to {{ doc.to_date }}.
\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 @@
}
+
+
+
{{ _(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%}
+ {{ col.label }} |
+ {% endfor %}
+
+
+{% for gl in gl_data%}
+{% if gl["old"]%}
+
+{% else %}
+
+{% endif %}
+ {% for col in gl_columns %}
+
+ {{ gl[col.fieldname] }}
+ |
+ {% endfor %}
+
+{% endfor %}
+
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
{{ update_password_link }}
: A link where your supplier can set a new password to log into your portal.\n \n - \n
{{ portal_link }}
: A link to this RFQ in your supplier portal.\n \n - \n
{{ supplier_name }}
: The company name of your supplier.\n \n - \n
{{ contact.salutation }} {{ contact.last_name }}
: The contact person of your supplier.\n - \n
{{ user_fullname }}
: Your full name.\n \n
\n\nApart 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 %}