diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index 48337cee640..d1a97f87ffb 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -68,6 +68,6 @@ if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
wait $wkpid
-bench start &> bench_run_logs.txt &
+bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes
diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml
index aae2928bf0d..07b8de7a900 100644
--- a/.github/workflows/patch.yml
+++ b/.github/workflows/patch.yml
@@ -23,7 +23,7 @@ jobs:
services:
mysql:
- image: mariadb:10.3
+ image: mariadb:10.6
env:
MARIADB_ROOT_PASSWORD: 'root'
ports:
@@ -45,9 +45,7 @@ jobs:
- name: Setup Python
uses: "actions/setup-python@v4"
with:
- python-version: |
- 3.7
- 3.10
+ python-version: '3.10'
- name: Setup Node
uses: actions/setup-node@v2
@@ -102,40 +100,60 @@ jobs:
- name: Run Patch Tests
run: |
cd ~/frappe-bench/
- wget https://erpnext.com/files/v10-erpnext.sql.gz
- bench --site test_site --force restore ~/frappe-bench/v10-erpnext.sql.gz
+ bench remove-app payments --force
+ jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
+ mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
+
+ wget https://erpnext.com/files/v13-erpnext.sql.gz
+ bench --site test_site --force restore ~/frappe-bench/v13-erpnext.sql.gz
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
- for version in $(seq 12 13)
- do
- echo "Updating to v$version"
- branch_name="version-$version-hotfix"
- git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
- git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
+ function update_to_version() {
+ version=$1
- git -C "apps/frappe" checkout -q -f $branch_name
- git -C "apps/erpnext" checkout -q -f $branch_name
+ branch_name="version-$version-hotfix"
+ echo "Updating to v$version"
- rm -rf ~/frappe-bench/env
- bench setup env --python python3.7
- bench pip install -e ./apps/payments
- bench pip install -e ./apps/erpnext
+ # Fetch and checkout branches
+ git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
+ git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
+ git -C "apps/frappe" checkout -q -f $branch_name
+ git -C "apps/erpnext" checkout -q -f $branch_name
- bench --site test_site migrate
- done
+ # Resetup env and install apps
+ pgrep honcho | xargs kill
+ rm -rf ~/frappe-bench/env
+ bench -v setup env
+ bench pip install -e ./apps/erpnext
+ bench start &>> ~/frappe-bench/bench_start.log &
+ bench --site test_site migrate
+ }
+
+ update_to_version 14
echo "Updating to latest version"
git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
+ pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env
- bench -v setup env --python python3.10
- bench pip install -e ./apps/payments
+ bench -v setup env
bench pip install -e ./apps/erpnext
+ bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
- bench --site test_site install-app payments
+
+ - name: Show bench output
+ if: ${{ always() }}
+ run: |
+ cd ~/frappe-bench
+ cat bench_start.log || true
+ cd logs
+ for f in ./*.log*; do
+ echo "Printing log: $f";
+ cat $f
+ done
diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml
index 2ce1125456e..559be06993e 100644
--- a/.github/workflows/server-tests-mariadb.yml
+++ b/.github/workflows/server-tests-mariadb.yml
@@ -123,6 +123,10 @@ jobs:
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
+ - name: Show bench output
+ if: ${{ always() }}
+ run: cat ~/frappe-bench/bench_start.log || true
+
- name: Upload coverage data
uses: actions/upload-artifact@v3
with:
diff --git a/erpnext/accounts/doctype/account/account.js b/erpnext/accounts/doctype/account/account.js
index 3c0eb857018..bcf7efc98b6 100644
--- a/erpnext/accounts/doctype/account/account.js
+++ b/erpnext/accounts/doctype/account/account.js
@@ -137,9 +137,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 c1eca721b6f..02e6c205728 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"
@@ -460,25 +464,34 @@ 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
new_account = frappe.get_cached_doc("Account", new)
+ old_account = frappe.get_cached_doc("Account", old)
if not new_account:
throw(_("Account {0} does not exist").format(new))
- if (new_account.is_group, new_account.root_type, new_account.company) != (
- 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 new_account.parent_account == old:
+ 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/chart_of_accounts/import_from_openerp.py b/erpnext/accounts/doctype/account/chart_of_accounts/import_from_openerp.py
deleted file mode 100644
index 3f25ada8b35..00000000000
--- a/erpnext/accounts/doctype/account/chart_of_accounts/import_from_openerp.py
+++ /dev/null
@@ -1,289 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-"""
-Import chart of accounts from OpenERP sources
-"""
-
-import ast
-import json
-import os
-from xml.etree import ElementTree as ET
-
-import frappe
-from frappe.utils.csvutils import read_csv_content
-
-path = "/Users/nabinhait/projects/odoo/addons"
-
-accounts = {}
-charts = {}
-all_account_types = []
-all_roots = {}
-
-
-def go():
- global accounts, charts
- default_account_types = get_default_account_types()
-
- country_dirs = []
- for basepath, folders, files in os.walk(path):
- basename = os.path.basename(basepath)
- if basename.startswith("l10n_"):
- country_dirs.append(basename)
-
- for country_dir in country_dirs:
- accounts, charts = {}, {}
- country_path = os.path.join(path, country_dir)
- manifest = ast.literal_eval(open(os.path.join(country_path, "__openerp__.py")).read())
- data_files = (
- manifest.get("data", []) + manifest.get("init_xml", []) + manifest.get("update_xml", [])
- )
- files_path = [os.path.join(country_path, d) for d in data_files]
- xml_roots = get_xml_roots(files_path)
- csv_content = get_csv_contents(files_path)
- prefix = country_dir if csv_content else None
- account_types = get_account_types(
- xml_roots.get("account.account.type", []), csv_content.get("account.account.type", []), prefix
- )
- account_types.update(default_account_types)
-
- if xml_roots:
- make_maps_for_xml(xml_roots, account_types, country_dir)
-
- if csv_content:
- make_maps_for_csv(csv_content, account_types, country_dir)
- make_account_trees()
- make_charts()
-
- create_all_roots_file()
-
-
-def get_default_account_types():
- default_types_root = []
- default_types_root.append(
- ET.parse(os.path.join(path, "account", "data", "data_account_type.xml")).getroot()
- )
- return get_account_types(default_types_root, None, prefix="account")
-
-
-def get_xml_roots(files_path):
- xml_roots = frappe._dict()
- for filepath in files_path:
- fname = os.path.basename(filepath)
- if fname.endswith(".xml"):
- tree = ET.parse(filepath)
- root = tree.getroot()
- for node in root[0].findall("record"):
- if node.get("model") in [
- "account.account.template",
- "account.chart.template",
- "account.account.type",
- ]:
- xml_roots.setdefault(node.get("model"), []).append(root)
- break
- return xml_roots
-
-
-def get_csv_contents(files_path):
- csv_content = {}
- for filepath in files_path:
- fname = os.path.basename(filepath)
- for file_type in ["account.account.template", "account.account.type", "account.chart.template"]:
- if fname.startswith(file_type) and fname.endswith(".csv"):
- with open(filepath, "r") as csvfile:
- try:
- csv_content.setdefault(file_type, []).append(read_csv_content(csvfile.read()))
- except Exception as e:
- continue
- return csv_content
-
-
-def get_account_types(root_list, csv_content, prefix=None):
- types = {}
- account_type_map = {
- "cash": "Cash",
- "bank": "Bank",
- "tr_cash": "Cash",
- "tr_bank": "Bank",
- "receivable": "Receivable",
- "tr_receivable": "Receivable",
- "account rec": "Receivable",
- "payable": "Payable",
- "tr_payable": "Payable",
- "equity": "Equity",
- "stocks": "Stock",
- "stock": "Stock",
- "tax": "Tax",
- "tr_tax": "Tax",
- "tax-out": "Tax",
- "tax-in": "Tax",
- "charges_personnel": "Chargeable",
- "fixed asset": "Fixed Asset",
- "cogs": "Cost of Goods Sold",
- }
- for root in root_list:
- for node in root[0].findall("record"):
- if node.get("model") == "account.account.type":
- data = {}
- for field in node.findall("field"):
- if (
- field.get("name") == "code"
- and field.text.lower() != "none"
- and account_type_map.get(field.text)
- ):
- data["account_type"] = account_type_map[field.text]
-
- node_id = prefix + "." + node.get("id") if prefix else node.get("id")
- types[node_id] = data
-
- if csv_content and csv_content[0][0] == "id":
- for row in csv_content[1:]:
- row_dict = dict(zip(csv_content[0], row))
- data = {}
- if row_dict.get("code") and account_type_map.get(row_dict["code"]):
- data["account_type"] = account_type_map[row_dict["code"]]
- if data and data.get("id"):
- node_id = prefix + "." + data.get("id") if prefix else data.get("id")
- types[node_id] = data
- return types
-
-
-def make_maps_for_xml(xml_roots, account_types, country_dir):
- """make maps for `charts` and `accounts`"""
- for model, root_list in xml_roots.items():
- for root in root_list:
- for node in root[0].findall("record"):
- if node.get("model") == "account.account.template":
- data = {}
- for field in node.findall("field"):
- if field.get("name") == "name":
- data["name"] = field.text
- if field.get("name") == "parent_id":
- parent_id = field.get("ref") or field.get("eval")
- data["parent_id"] = parent_id
-
- if field.get("name") == "user_type":
- value = field.get("ref")
- if account_types.get(value, {}).get("account_type"):
- data["account_type"] = account_types[value]["account_type"]
- if data["account_type"] not in all_account_types:
- all_account_types.append(data["account_type"])
-
- data["children"] = []
- accounts[node.get("id")] = data
-
- if node.get("model") == "account.chart.template":
- data = {}
- for field in node.findall("field"):
- if field.get("name") == "name":
- data["name"] = field.text
- if field.get("name") == "account_root_id":
- data["account_root_id"] = field.get("ref")
- data["id"] = country_dir
- charts.setdefault(node.get("id"), {}).update(data)
-
-
-def make_maps_for_csv(csv_content, account_types, country_dir):
- for content in csv_content.get("account.account.template", []):
- for row in content[1:]:
- data = dict(zip(content[0], row))
- account = {
- "name": data.get("name"),
- "parent_id": data.get("parent_id:id") or data.get("parent_id/id"),
- "children": [],
- }
- user_type = data.get("user_type/id") or data.get("user_type:id")
- if account_types.get(user_type, {}).get("account_type"):
- account["account_type"] = account_types[user_type]["account_type"]
- if account["account_type"] not in all_account_types:
- all_account_types.append(account["account_type"])
-
- accounts[data.get("id")] = account
- if not account.get("parent_id") and data.get("chart_template_id:id"):
- chart_id = data.get("chart_template_id:id")
- charts.setdefault(chart_id, {}).update({"account_root_id": data.get("id")})
-
- for content in csv_content.get("account.chart.template", []):
- for row in content[1:]:
- if row:
- data = dict(zip(content[0], row))
- charts.setdefault(data.get("id"), {}).update(
- {
- "account_root_id": data.get("account_root_id:id") or data.get("account_root_id/id"),
- "name": data.get("name"),
- "id": country_dir,
- }
- )
-
-
-def make_account_trees():
- """build tree hierarchy"""
- for id in accounts.keys():
- account = accounts[id]
-
- if account.get("parent_id"):
- if accounts.get(account["parent_id"]):
- # accounts[account["parent_id"]]["children"].append(account)
- accounts[account["parent_id"]][account["name"]] = account
- del account["parent_id"]
- del account["name"]
-
- # remove empty children
- for id in accounts.keys():
- if "children" in accounts[id] and not accounts[id].get("children"):
- del accounts[id]["children"]
-
-
-def make_charts():
- """write chart files in app/setup/doctype/company/charts"""
- for chart_id in charts:
- src = charts[chart_id]
- if not src.get("name") or not src.get("account_root_id"):
- continue
-
- if not src["account_root_id"] in accounts:
- continue
-
- filename = src["id"][5:] + "_" + chart_id
-
- print("building " + filename)
- chart = {}
- chart["name"] = src["name"]
- chart["country_code"] = src["id"][5:]
- chart["tree"] = accounts[src["account_root_id"]]
-
- for key, val in chart["tree"].items():
- if key in ["name", "parent_id"]:
- chart["tree"].pop(key)
- if type(val) == dict:
- val["root_type"] = ""
- if chart:
- fpath = os.path.join(
- "erpnext", "erpnext", "accounts", "doctype", "account", "chart_of_accounts", filename + ".json"
- )
-
- with open(fpath, "r") as chartfile:
- old_content = chartfile.read()
- if not old_content or (
- json.loads(old_content).get("is_active", "No") == "No"
- and json.loads(old_content).get("disabled", "No") == "No"
- ):
- with open(fpath, "w") as chartfile:
- chartfile.write(json.dumps(chart, indent=4, sort_keys=True))
-
- all_roots.setdefault(filename, chart["tree"].keys())
-
-
-def create_all_roots_file():
- with open("all_roots.txt", "w") as f:
- for filename, roots in sorted(all_roots.items()):
- f.write(filename)
- f.write("\n----------------------\n")
- for r in sorted(roots):
- f.write(r.encode("utf-8"))
- f.write("\n")
- f.write("\n\n\n")
-
-
-if __name__ == "__main__":
- go()
diff --git a/erpnext/accounts/doctype/account/test_account.py b/erpnext/accounts/doctype/account/test_account.py
index 62303bd723f..30eebef7fba 100644
--- a/erpnext/accounts/doctype/account/test_account.py
+++ b/erpnext/accounts/doctype/account/test_account.py
@@ -7,7 +7,11 @@ import unittest
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 @@ class TestAccount(unittest.TestCase):
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 @@ class TestAccount(unittest.TestCase):
# 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/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
index cfe5e6e8009..3a2c3cbeeb1 100644
--- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
+++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
@@ -265,20 +265,21 @@ def get_dimension_with_children(doctype, dimensions):
@frappe.whitelist()
def get_dimensions(with_cost_center_and_project=False):
- dimension_filters = frappe.db.sql(
- """
- SELECT label, fieldname, document_type
- FROM `tabAccounting Dimension`
- WHERE disabled = 0
- """,
- as_dict=1,
- )
- default_dimensions = frappe.db.sql(
- """SELECT p.fieldname, c.company, c.default_dimension
- FROM `tabAccounting Dimension Detail` c, `tabAccounting Dimension` p
- WHERE c.parent = p.name""",
- as_dict=1,
+ c = frappe.qb.DocType("Accounting Dimension Detail")
+ p = frappe.qb.DocType("Accounting Dimension")
+ dimension_filters = (
+ frappe.qb.from_(p)
+ .select(p.label, p.fieldname, p.document_type)
+ .where(p.disabled == 0)
+ .run(as_dict=1)
+ )
+ default_dimensions = (
+ frappe.qb.from_(c)
+ .inner_join(p)
+ .on(c.parent == p.name)
+ .select(p.fieldname, c.company, c.default_dimension)
+ .run(as_dict=1)
)
if isinstance(with_cost_center_and_project, str):
diff --git a/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py
index 25ef2ea5c2c..cb7f5f5da78 100644
--- a/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py
+++ b/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py
@@ -84,12 +84,22 @@ def create_dimension():
frappe.set_user("Administrator")
if not frappe.db.exists("Accounting Dimension", {"document_type": "Department"}):
- frappe.get_doc(
+ dimension = frappe.get_doc(
{
"doctype": "Accounting Dimension",
"document_type": "Department",
}
- ).insert()
+ )
+ dimension.append(
+ "dimension_defaults",
+ {
+ "company": "_Test Company",
+ "reference_document": "Department",
+ "default_dimension": "_Test Department - _TC",
+ },
+ )
+ dimension.insert()
+ dimension.save()
else:
dimension = frappe.get_doc("Accounting Dimension", "Department")
dimension.disabled = 0
diff --git a/erpnext/accounts/doctype/bank_account/bank_account.json b/erpnext/accounts/doctype/bank_account/bank_account.json
index 41d79479ca5..32f1c675d3b 100644
--- a/erpnext/accounts/doctype/bank_account/bank_account.json
+++ b/erpnext/accounts/doctype/bank_account/bank_account.json
@@ -13,6 +13,7 @@
"account_type",
"account_subtype",
"column_break_7",
+ "disabled",
"is_default",
"is_company_account",
"company",
@@ -199,10 +200,16 @@
"fieldtype": "Data",
"in_global_search": 1,
"label": "Branch Code"
+ },
+ {
+ "default": "0",
+ "fieldname": "disabled",
+ "fieldtype": "Check",
+ "label": "Disabled"
}
],
"links": [],
- "modified": "2022-05-04 15:49:42.620630",
+ "modified": "2023-09-22 21:31:34.763977",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
index 3da5ac32a42..9a7a9a31d53 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
@@ -7,7 +7,9 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
+from frappe.query_builder.custom import ConstantColumn
from frappe.utils import cint, flt
+from pypika.terms import Parameter
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
@@ -15,7 +17,7 @@ from erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_s
get_amounts_not_reflected_in_system,
get_entries,
)
-from erpnext.accounts.utils import get_balance_on
+from erpnext.accounts.utils import get_account_currency, get_balance_on
class BankReconciliationTool(Document):
@@ -283,68 +285,68 @@ def auto_reconcile_vouchers(
to_reference_date=None,
):
frappe.flags.auto_reconcile_vouchers = True
- document_types = ["payment_entry", "journal_entry"]
+ reconciled, partially_reconciled = set(), set()
+
bank_transactions = get_bank_transactions(bank_account)
- matched_transaction = []
for transaction in bank_transactions:
linked_payments = get_linked_payments(
transaction.name,
- document_types,
+ ["payment_entry", "journal_entry"],
from_date,
to_date,
filter_by_reference_date,
from_reference_date,
to_reference_date,
)
- vouchers = []
- for r in linked_payments:
- vouchers.append(
- {
- "payment_doctype": r[1],
- "payment_name": r[2],
- "amount": r[4],
- }
- )
- transaction = frappe.get_doc("Bank Transaction", transaction.name)
- account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
- matched_trans = 0
- for voucher in vouchers:
- gl_entry = frappe.db.get_value(
- "GL Entry",
- dict(
- account=account, voucher_type=voucher["payment_doctype"], voucher_no=voucher["payment_name"]
- ),
- ["credit", "debit"],
- as_dict=1,
- )
- gl_amount, transaction_amount = (
- (gl_entry.credit, transaction.deposit)
- if gl_entry.credit > 0
- else (gl_entry.debit, transaction.withdrawal)
- )
- allocated_amount = gl_amount if gl_amount >= transaction_amount else transaction_amount
- transaction.append(
- "payment_entries",
- {
- "payment_document": voucher["payment_doctype"],
- "payment_entry": voucher["payment_name"],
- "allocated_amount": allocated_amount,
+
+ if not linked_payments:
+ continue
+
+ vouchers = list(
+ map(
+ lambda entry: {
+ "payment_doctype": entry.get("doctype"),
+ "payment_name": entry.get("name"),
+ "amount": entry.get("paid_amount"),
},
+ linked_payments,
)
- matched_transaction.append(str(transaction.name))
- transaction.save()
- transaction.update_allocations()
- matched_transaction_len = len(set(matched_transaction))
- if matched_transaction_len == 0:
- frappe.msgprint(_("No matching references found for auto reconciliation"))
- elif matched_transaction_len == 1:
- frappe.msgprint(_("{0} transaction is reconcilied").format(matched_transaction_len))
- else:
- frappe.msgprint(_("{0} transactions are reconcilied").format(matched_transaction_len))
+ )
+
+ updated_transaction = reconcile_vouchers(transaction.name, json.dumps(vouchers))
+
+ if updated_transaction.status == "Reconciled":
+ reconciled.add(updated_transaction.name)
+ elif flt(transaction.unallocated_amount) != flt(updated_transaction.unallocated_amount):
+ # Partially reconciled (status = Unreconciled & unallocated amount changed)
+ partially_reconciled.add(updated_transaction.name)
+
+ alert_message, indicator = get_auto_reconcile_message(partially_reconciled, reconciled)
+ frappe.msgprint(title=_("Auto Reconciliation"), msg=alert_message, indicator=indicator)
frappe.flags.auto_reconcile_vouchers = False
+ return reconciled, partially_reconciled
- return frappe.get_doc("Bank Transaction", transaction.name)
+
+def get_auto_reconcile_message(partially_reconciled, reconciled):
+ """Returns alert message and indicator for auto reconciliation depending on result state."""
+ alert_message, indicator = "", "blue"
+ if not partially_reconciled and not reconciled:
+ alert_message = _("No matches occurred via auto reconciliation")
+ return alert_message, indicator
+
+ indicator = "green"
+ if reconciled:
+ alert_message += _("{0} Transaction(s) Reconciled").format(len(reconciled))
+ alert_message += " "
+
+ if partially_reconciled:
+ alert_message += _("{0} {1} Partially Reconciled").format(
+ len(partially_reconciled),
+ _("Transactions") if len(partially_reconciled) > 1 else _("Transaction"),
+ )
+
+ return alert_message, indicator
@frappe.whitelist()
@@ -390,19 +392,13 @@ def subtract_allocations(gl_account, vouchers):
"Look up & subtract any existing Bank Transaction allocations"
copied = []
for voucher in vouchers:
- rows = get_total_allocated_amount(voucher[1], voucher[2])
- amount = None
- for row in rows:
- if row["gl_account"] == gl_account:
- amount = row["total"]
- break
+ rows = get_total_allocated_amount(voucher.get("doctype"), voucher.get("name"))
+ filtered_row = list(filter(lambda row: row.get("gl_account") == gl_account, rows))
- if amount:
- l = list(voucher)
- l[3] -= amount
- copied.append(tuple(l))
- else:
- copied.append(voucher)
+ if amount := None if not filtered_row else filtered_row[0]["total"]:
+ voucher["paid_amount"] -= amount
+
+ copied.append(voucher)
return copied
@@ -418,6 +414,18 @@ def check_matching(
to_reference_date,
):
exact_match = True if "exact_match" in document_types else False
+ queries = get_queries(
+ bank_account,
+ company,
+ transaction,
+ document_types,
+ from_date,
+ to_date,
+ filter_by_reference_date,
+ from_reference_date,
+ to_reference_date,
+ exact_match,
+ )
filters = {
"amount": transaction.unallocated_amount,
@@ -429,30 +437,15 @@ def check_matching(
}
matching_vouchers = []
+ for query in queries:
+ matching_vouchers.extend(frappe.db.sql(query, filters, as_dict=True))
- # get matching vouchers from all the apps
- for method_name in frappe.get_hooks("get_matching_vouchers_for_bank_reconciliation"):
- matching_vouchers.extend(
- frappe.get_attr(method_name)(
- bank_account,
- company,
- transaction,
- document_types,
- from_date,
- to_date,
- filter_by_reference_date,
- from_reference_date,
- to_reference_date,
- exact_match,
- filters,
- )
- or []
- )
-
- return sorted(matching_vouchers, key=lambda x: x[0], reverse=True) if matching_vouchers else []
+ return (
+ sorted(matching_vouchers, key=lambda x: x["rank"], reverse=True) if matching_vouchers else []
+ )
-def get_matching_vouchers_for_bank_reconciliation(
+def get_queries(
bank_account,
company,
transaction,
@@ -463,7 +456,6 @@ def get_matching_vouchers_for_bank_reconciliation(
from_reference_date,
to_reference_date,
exact_match,
- filters,
):
# get queries to get matching vouchers
account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from"
@@ -488,17 +480,7 @@ def get_matching_vouchers_for_bank_reconciliation(
or []
)
- vouchers = []
-
- for query in queries:
- vouchers.extend(
- frappe.db.sql(
- query,
- filters,
- )
- )
-
- return vouchers
+ return queries
def get_matching_queries(
@@ -515,6 +497,8 @@ def get_matching_queries(
to_reference_date,
):
queries = []
+ currency = get_account_currency(bank_account)
+
if "payment_entry" in document_types:
query = get_pe_matching_query(
exact_match,
@@ -541,12 +525,12 @@ def get_matching_queries(
queries.append(query)
if transaction.deposit > 0.0 and "sales_invoice" in document_types:
- query = get_si_matching_query(exact_match)
+ query = get_si_matching_query(exact_match, currency)
queries.append(query)
if transaction.withdrawal > 0.0:
if "purchase_invoice" in document_types:
- query = get_pi_matching_query(exact_match)
+ query = get_pi_matching_query(exact_match, currency)
queries.append(query)
if "bank_transaction" in document_types:
@@ -560,33 +544,48 @@ def get_bt_matching_query(exact_match, transaction):
# get matching bank transaction query
# find bank transactions in the same bank account with opposite sign
# same bank account must have same company and currency
+ bt = frappe.qb.DocType("Bank Transaction")
+
field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal"
+ amount_equality = getattr(bt, field) == transaction.unallocated_amount
+ amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
+ amount_condition = amount_equality if exact_match else getattr(bt, field) > 0.0
- return f"""
+ ref_rank = (
+ frappe.qb.terms.Case().when(bt.reference_number == transaction.reference_number, 1).else_(0)
+ )
+ unallocated_rank = (
+ frappe.qb.terms.Case().when(bt.unallocated_amount == transaction.unallocated_amount, 1).else_(0)
+ )
- SELECT
- (CASE WHEN reference_number = %(reference_no)s THEN 1 ELSE 0 END
- + CASE WHEN {field} = %(amount)s THEN 1 ELSE 0 END
- + CASE WHEN ( party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END
- + CASE WHEN unallocated_amount = %(amount)s THEN 1 ELSE 0 END
- + 1) AS rank,
- 'Bank Transaction' AS doctype,
- name,
- unallocated_amount AS paid_amount,
- reference_number AS reference_no,
- date AS reference_date,
- party,
- party_type,
- date AS posting_date,
- currency
- FROM
- `tabBank Transaction`
- WHERE
- status != 'Reconciled'
- AND name != '{transaction.name}'
- AND bank_account = '{transaction.bank_account}'
- AND {field} {'= %(amount)s' if exact_match else '> 0.0'}
- """
+ party_condition = (
+ (bt.party_type == transaction.party_type)
+ & (bt.party == transaction.party)
+ & bt.party.isnotnull()
+ )
+ party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
+
+ query = (
+ frappe.qb.from_(bt)
+ .select(
+ (ref_rank + amount_rank + party_rank + unallocated_rank + 1).as_("rank"),
+ ConstantColumn("Bank Transaction").as_("doctype"),
+ bt.name,
+ bt.unallocated_amount.as_("paid_amount"),
+ bt.reference_number.as_("reference_no"),
+ bt.date.as_("reference_date"),
+ bt.party,
+ bt.party_type,
+ bt.date.as_("posting_date"),
+ bt.currency,
+ )
+ .where(bt.status != "Reconciled")
+ .where(bt.name != transaction.name)
+ .where(bt.bank_account == transaction.bank_account)
+ .where(amount_condition)
+ .where(bt.docstatus == 1)
+ )
+ return str(query)
def get_pe_matching_query(
@@ -600,45 +599,56 @@ def get_pe_matching_query(
to_reference_date,
):
# get matching payment entries query
- if transaction.deposit > 0.0:
- currency_field = "paid_to_account_currency as currency"
- else:
- currency_field = "paid_from_account_currency as currency"
- filter_by_date = f"AND posting_date between '{from_date}' and '{to_date}'"
- order_by = " posting_date"
- filter_by_reference_no = ""
+ to_from = "to" if transaction.deposit > 0.0 else "from"
+ currency_field = f"paid_{to_from}_account_currency"
+ payment_type = "Receive" if transaction.deposit > 0.0 else "Pay"
+ pe = frappe.qb.DocType("Payment Entry")
+
+ ref_condition = pe.reference_no == transaction.reference_number
+ ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
+
+ amount_equality = pe.paid_amount == transaction.unallocated_amount
+ amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
+ amount_condition = amount_equality if exact_match else pe.paid_amount > 0.0
+
+ party_condition = (
+ (pe.party_type == transaction.party_type)
+ & (pe.party == transaction.party)
+ & pe.party.isnotnull()
+ )
+ party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
+
+ filter_by_date = pe.posting_date.between(from_date, to_date)
if cint(filter_by_reference_date):
- filter_by_date = f"AND reference_date between '{from_reference_date}' and '{to_reference_date}'"
- order_by = " reference_date"
+ filter_by_date = pe.reference_date.between(from_reference_date, to_reference_date)
+
+ query = (
+ frappe.qb.from_(pe)
+ .select(
+ (ref_rank + amount_rank + party_rank + 1).as_("rank"),
+ ConstantColumn("Payment Entry").as_("doctype"),
+ pe.name,
+ pe.paid_amount,
+ pe.reference_no,
+ pe.reference_date,
+ pe.party,
+ pe.party_type,
+ pe.posting_date,
+ getattr(pe, currency_field).as_("currency"),
+ )
+ .where(pe.docstatus == 1)
+ .where(pe.payment_type.isin([payment_type, "Internal Transfer"]))
+ .where(pe.clearance_date.isnull())
+ .where(getattr(pe, account_from_to) == Parameter("%(bank_account)s"))
+ .where(amount_condition)
+ .where(filter_by_date)
+ .orderby(pe.reference_date if cint(filter_by_reference_date) else pe.posting_date)
+ )
+
if frappe.flags.auto_reconcile_vouchers == True:
- filter_by_reference_no = f"AND reference_no = '{transaction.reference_number}'"
- return f"""
- SELECT
- (CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END
- + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END
- + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END
- + 1 ) AS rank,
- 'Payment Entry' as doctype,
- name,
- paid_amount,
- reference_no,
- reference_date,
- party,
- party_type,
- posting_date,
- {currency_field}
- FROM
- `tabPayment Entry`
- WHERE
- docstatus = 1
- AND payment_type IN (%(payment_type)s, 'Internal Transfer')
- AND ifnull(clearance_date, '') = ""
- AND {account_from_to} = %(bank_account)s
- AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'}
- {filter_by_date}
- {filter_by_reference_no}
- order by{order_by}
- """
+ query = query.where(ref_condition)
+
+ return str(query)
def get_je_matching_query(
@@ -655,100 +665,121 @@ def get_je_matching_query(
# So one bank could have both types of bank accounts like asset and liability
# So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type
cr_or_dr = "credit" if transaction.withdrawal > 0.0 else "debit"
- filter_by_date = f"AND je.posting_date between '{from_date}' and '{to_date}'"
- order_by = " je.posting_date"
- filter_by_reference_no = ""
+ je = frappe.qb.DocType("Journal Entry")
+ jea = frappe.qb.DocType("Journal Entry Account")
+
+ ref_condition = je.cheque_no == transaction.reference_number
+ ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
+
+ amount_field = f"{cr_or_dr}_in_account_currency"
+ amount_equality = getattr(jea, amount_field) == transaction.unallocated_amount
+ amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
+
+ filter_by_date = je.posting_date.between(from_date, to_date)
if cint(filter_by_reference_date):
- filter_by_date = f"AND je.cheque_date between '{from_reference_date}' and '{to_reference_date}'"
- order_by = " je.cheque_date"
- if frappe.flags.auto_reconcile_vouchers == True:
- filter_by_reference_no = f"AND je.cheque_no = '{transaction.reference_number}'"
- return f"""
- SELECT
- (CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END
- + CASE WHEN jea.{cr_or_dr}_in_account_currency = %(amount)s THEN 1 ELSE 0 END
- + 1) AS rank ,
- 'Journal Entry' AS doctype,
+ filter_by_date = je.cheque_date.between(from_reference_date, to_reference_date)
+
+ query = (
+ frappe.qb.from_(jea)
+ .join(je)
+ .on(jea.parent == je.name)
+ .select(
+ (ref_rank + amount_rank + 1).as_("rank"),
+ ConstantColumn("Journal Entry").as_("doctype"),
je.name,
- jea.{cr_or_dr}_in_account_currency AS paid_amount,
- je.cheque_no AS reference_no,
- je.cheque_date AS reference_date,
- je.pay_to_recd_from AS party,
+ getattr(jea, amount_field).as_("paid_amount"),
+ je.cheque_no.as_("reference_no"),
+ je.cheque_date.as_("reference_date"),
+ je.pay_to_recd_from.as_("party"),
jea.party_type,
je.posting_date,
- jea.account_currency AS currency
- FROM
- `tabJournal Entry Account` AS jea
- JOIN
- `tabJournal Entry` AS je
- ON
- jea.parent = je.name
- WHERE
- je.docstatus = 1
- AND je.voucher_type NOT IN ('Opening Entry')
- AND (je.clearance_date IS NULL OR je.clearance_date='0000-00-00')
- AND jea.account = %(bank_account)s
- AND jea.{cr_or_dr}_in_account_currency {'= %(amount)s' if exact_match else '> 0.0'}
- AND je.docstatus = 1
- {filter_by_date}
- {filter_by_reference_no}
- order by {order_by}
- """
+ jea.account_currency.as_("currency"),
+ )
+ .where(je.docstatus == 1)
+ .where(je.voucher_type != "Opening Entry")
+ .where(je.clearance_date.isnull())
+ .where(jea.account == Parameter("%(bank_account)s"))
+ .where(amount_equality if exact_match else getattr(jea, amount_field) > 0.0)
+ .where(je.docstatus == 1)
+ .where(filter_by_date)
+ .orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
+ )
+
+ if frappe.flags.auto_reconcile_vouchers == True:
+ query = query.where(ref_condition)
+
+ return str(query)
-def get_si_matching_query(exact_match):
+def get_si_matching_query(exact_match, currency):
# get matching sales invoice query
- return f"""
- SELECT
- ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END
- + CASE WHEN sip.amount = %(amount)s THEN 1 ELSE 0 END
- + 1 ) AS rank,
- 'Sales Invoice' as doctype,
+ si = frappe.qb.DocType("Sales Invoice")
+ sip = frappe.qb.DocType("Sales Invoice Payment")
+
+ amount_equality = sip.amount == Parameter("%(amount)s")
+ amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
+ amount_condition = amount_equality if exact_match else sip.amount > 0.0
+
+ party_condition = si.customer == Parameter("%(party)s")
+ party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
+
+ query = (
+ frappe.qb.from_(sip)
+ .join(si)
+ .on(sip.parent == si.name)
+ .select(
+ (party_rank + amount_rank + 1).as_("rank"),
+ ConstantColumn("Sales Invoice").as_("doctype"),
si.name,
- sip.amount as paid_amount,
- '' as reference_no,
- '' as reference_date,
- si.customer as party,
- 'Customer' as party_type,
+ sip.amount.as_("paid_amount"),
+ ConstantColumn("").as_("reference_no"),
+ ConstantColumn("").as_("reference_date"),
+ si.customer.as_("party"),
+ ConstantColumn("Customer").as_("party_type"),
si.posting_date,
- si.currency
+ si.currency,
+ )
+ .where(si.docstatus == 1)
+ .where(sip.clearance_date.isnull())
+ .where(sip.account == Parameter("%(bank_account)s"))
+ .where(amount_condition)
+ .where(si.currency == currency)
+ )
- FROM
- `tabSales Invoice Payment` as sip
- JOIN
- `tabSales Invoice` as si
- ON
- sip.parent = si.name
- WHERE
- si.docstatus = 1
- AND (sip.clearance_date is null or sip.clearance_date='0000-00-00')
- AND sip.account = %(bank_account)s
- AND sip.amount {'= %(amount)s' if exact_match else '> 0.0'}
- """
+ return str(query)
-def get_pi_matching_query(exact_match):
+def get_pi_matching_query(exact_match, currency):
# get matching purchase invoice query when they are also used as payment entries (is_paid)
- return f"""
- SELECT
- ( CASE WHEN supplier = %(party)s THEN 1 ELSE 0 END
- + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END
- + 1 ) AS rank,
- 'Purchase Invoice' as doctype,
- name,
- paid_amount,
- '' as reference_no,
- '' as reference_date,
- supplier as party,
- 'Supplier' as party_type,
- posting_date,
- currency
- FROM
- `tabPurchase Invoice`
- WHERE
- docstatus = 1
- AND is_paid = 1
- AND ifnull(clearance_date, '') = ""
- AND cash_bank_account = %(bank_account)s
- AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'}
- """
+ purchase_invoice = frappe.qb.DocType("Purchase Invoice")
+
+ amount_equality = purchase_invoice.paid_amount == Parameter("%(amount)s")
+ amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
+ amount_condition = amount_equality if exact_match else purchase_invoice.paid_amount > 0.0
+
+ party_condition = purchase_invoice.supplier == Parameter("%(party)s")
+ party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
+
+ query = (
+ frappe.qb.from_(purchase_invoice)
+ .select(
+ (party_rank + amount_rank + 1).as_("rank"),
+ ConstantColumn("Purchase Invoice").as_("doctype"),
+ purchase_invoice.name,
+ purchase_invoice.paid_amount,
+ ConstantColumn("").as_("reference_no"),
+ ConstantColumn("").as_("reference_date"),
+ purchase_invoice.supplier.as_("party"),
+ ConstantColumn("Supplier").as_("party_type"),
+ purchase_invoice.posting_date,
+ purchase_invoice.currency,
+ )
+ .where(purchase_invoice.docstatus == 1)
+ .where(purchase_invoice.is_paid == 1)
+ .where(purchase_invoice.clearance_date.isnull())
+ .where(purchase_invoice.cash_bank_account == Parameter("%(bank_account)s"))
+ .where(amount_condition)
+ .where(purchase_invoice.currency == currency)
+ )
+
+ return str(query)
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py
index 599ced59192..5a6bb6976f9 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py
@@ -1,9 +1,100 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-# import frappe
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
-class TestBankReconciliationTool(unittest.TestCase):
- pass
+from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
+ auto_reconcile_vouchers,
+ get_bank_transactions,
+)
+from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
+from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
+
+
+class TestBankReconciliationTool(AccountsTestMixin, FrappeTestCase):
+ def setUp(self):
+ self.create_company()
+ self.create_customer()
+ self.clear_old_entries()
+ bank_dt = qb.DocType("Bank")
+ q = qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
+ self.create_bank_account()
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def create_bank_account(self):
+ bank = frappe.get_doc(
+ {
+ "doctype": "Bank",
+ "bank_name": "HDFC",
+ }
+ ).save()
+
+ self.bank_account = (
+ frappe.get_doc(
+ {
+ "doctype": "Bank Account",
+ "account_name": "HDFC _current_",
+ "bank": bank,
+ "is_company_account": True,
+ "account": self.bank, # account from Chart of Accounts
+ }
+ )
+ .insert()
+ .name
+ )
+
+ def test_auto_reconcile(self):
+ # make payment
+ from_date = add_days(today(), -1)
+ to_date = today()
+ payment = create_payment_entry(
+ company=self.company,
+ posting_date=from_date,
+ payment_type="Receive",
+ party_type="Customer",
+ party=self.customer,
+ paid_from=self.debit_to,
+ paid_to=self.bank,
+ paid_amount=100,
+ ).save()
+ payment.reference_no = "123"
+ payment = payment.save().submit()
+
+ # make bank transaction
+ bank_transaction = (
+ frappe.get_doc(
+ {
+ "doctype": "Bank Transaction",
+ "date": to_date,
+ "deposit": 100,
+ "bank_account": self.bank_account,
+ "reference_number": "123",
+ }
+ )
+ .save()
+ .submit()
+ )
+
+ # assert API output pre reconciliation
+ transactions = get_bank_transactions(self.bank_account, from_date, to_date)
+ self.assertEqual(len(transactions), 1)
+ self.assertEqual(transactions[0].name, bank_transaction.name)
+
+ # auto reconcile
+ auto_reconcile_vouchers(
+ bank_account=self.bank_account,
+ from_date=from_date,
+ to_date=to_date,
+ filter_by_reference_date=False,
+ )
+
+ # assert API output post reconciliation
+ transactions = get_bank_transactions(self.bank_account, from_date, to_date)
+ self.assertEqual(len(transactions), 0)
diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
index 59905dad5d8..0c328ff46cd 100644
--- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
@@ -47,7 +47,7 @@ class TestBankTransaction(FrappeTestCase):
from_date=bank_transaction.date,
to_date=utils.today(),
)
- self.assertTrue(linked_payments[0][6] == "Conrad Electronic")
+ self.assertTrue(linked_payments[0]["party"] == "Conrad Electronic")
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
def test_reconcile(self):
@@ -93,7 +93,7 @@ class TestBankTransaction(FrappeTestCase):
from_date=bank_transaction.date,
to_date=utils.today(),
)
- self.assertTrue(linked_payments[0][3])
+ self.assertTrue(linked_payments[0]["paid_amount"])
# Check error if already reconciled
def test_already_reconciled(self):
@@ -188,7 +188,7 @@ class TestBankTransaction(FrappeTestCase):
repayment_entry = create_loan_and_repayment()
linked_payments = get_linked_payments(bank_transaction.name, ["loan_repayment", "exact_match"])
- self.assertEqual(linked_payments[0][2], repayment_entry.name)
+ self.assertEqual(linked_payments[0]["name"], repayment_entry.name)
@if_lending_app_installed
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js
index 35a378856b0..cdd1203d49a 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.js
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js
@@ -50,6 +50,8 @@ frappe.ui.form.on("Journal Entry", {
frm.trigger("make_inter_company_journal_entry");
}, __('Make'));
}
+
+ erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
},
make_inter_company_journal_entry: function(frm) {
diff --git a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py
index 381083bc304..362d27342a6 100644
--- a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py
+++ b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py
@@ -48,9 +48,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/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index 9a0adf5815d..0203c450583 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
frappe.ui.form.on('Payment Entry', {
onload: function(frm) {
- frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting 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);
@@ -154,6 +154,13 @@ frappe.ui.form.on('Payment Entry', {
frm.events.set_dynamic_labels(frm);
frm.events.show_general_ledger(frm);
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(frm);
+ if(frm.doc.references.find((elem) => {return elem.exchange_gain_loss != 0})) {
+ frm.add_custom_button(__("View Exchange Gain/Loss Journals"), function() {
+ frappe.set_route("List", "Journal Entry", {"voucher_type": "Exchange Gain Or Loss", "reference_name": frm.doc.name});
+ }, __('Actions'));
+
+ }
+ erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
},
validate_company: (frm) => {
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 8a894e29d14..38a520996c9 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -148,6 +148,8 @@ class PaymentEntry(AccountsController):
"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)
@@ -1150,8 +1152,25 @@ class PaymentEntry(AccountsController):
)
make_reverse_gl_entries(gl_entries=gl_entries, partial_cancel=True)
- else:
- make_gl_entries(gl_entries)
+ return
+
+ # same reference added to payment entry
+ for gl_entry in gl_entries.copy():
+ if frappe.db.exists(
+ "GL Entry",
+ {
+ "account": gl_entry.account,
+ "voucher_type": gl_entry.voucher_type,
+ "voucher_no": gl_entry.voucher_no,
+ "voucher_detail_no": gl_entry.voucher_detail_no,
+ "debit": gl_entry.debit,
+ "credit": gl_entry.credit,
+ "is_cancelled": 0,
+ },
+ ):
+ gl_entries.remove(gl_entry)
+
+ make_gl_entries(gl_entries)
def make_invoice_liability_entry(self, gl_entries, invoice):
args_dict = {
@@ -1586,6 +1605,14 @@ def get_outstanding_reference_documents(args, validate=False):
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")))
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index 96ae0c30f95..3285a529d2d 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -19,7 +19,7 @@ from erpnext.accounts.utils import (
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):
@@ -78,7 +78,7 @@ class PaymentReconciliation(Document):
if self.payment_name:
condition.update({"name": self.payment_name})
- payment_entries = get_advance_payment_entries(
+ payment_entries = get_advance_payment_entries_for_regional(
self.party_type,
self.party,
party_account,
@@ -363,6 +363,7 @@ class PaymentReconciliation(Document):
)
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"
@@ -663,3 +664,8 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
None,
inv.cost_center,
)
+
+
+@erpnext.allow_regional
+def adjust_allocations_for_taxes(doc):
+ pass
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json
index 381f3fb531a..5ffd7180f62 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.json
+++ b/erpnext/accounts/doctype/payment_request/payment_request.json
@@ -230,6 +230,28 @@
"fieldtype": "Read Only",
"label": "SWIFT Number"
},
+ {
+ "collapsible": 1,
+ "fieldname": "accounting_dimensions_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Dimensions"
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
+ },
+ {
+ "fieldname": "dimension_col_break",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "project",
+ "fieldtype": "Link",
+ "label": "Project",
+ "options": "Project"
+ },
{
"depends_on": "eval: doc.payment_request_type == 'Inward'",
"fieldname": "recipient_and_message",
@@ -246,7 +268,8 @@
"fieldname": "email_to",
"fieldtype": "Data",
"in_global_search": 1,
- "label": "To"
+ "label": "To",
+ "options": "Email"
},
{
"depends_on": "eval: doc.payment_channel != \"Phone\"",
@@ -317,9 +340,10 @@
},
{
"fieldname": "payment_url",
- "fieldtype": "Small Text",
"hidden": 1,
- "label": "payment_url",
+ "fieldtype": "Data",
+ "length": 500,
+ "options": "URL",
"read_only": 1
},
{
@@ -343,6 +367,14 @@
"label": "Payment Account",
"read_only": 1
},
+ {
+ "fetch_from": "payment_gateway_account.payment_channel",
+ "fieldname": "payment_channel",
+ "fieldtype": "Select",
+ "label": "Payment Channel",
+ "options": "\nEmail\nPhone",
+ "read_only": 1
+ },
{
"fieldname": "payment_order",
"fieldtype": "Link",
@@ -358,43 +390,13 @@
"options": "Payment Request",
"print_hide": 1,
"read_only": 1
- },
- {
- "fetch_from": "payment_gateway_account.payment_channel",
- "fieldname": "payment_channel",
- "fieldtype": "Select",
- "label": "Payment Channel",
- "options": "\nEmail\nPhone",
- "read_only": 1
- },
- {
- "collapsible": 1,
- "fieldname": "accounting_dimensions_section",
- "fieldtype": "Section Break",
- "label": "Accounting Dimensions"
- },
- {
- "fieldname": "cost_center",
- "fieldtype": "Link",
- "label": "Cost Center",
- "options": "Cost Center"
- },
- {
- "fieldname": "dimension_col_break",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "project",
- "fieldtype": "Link",
- "label": "Project",
- "options": "Project"
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-12-21 16:56:40.115737",
+ "modified": "2023-09-16 14:15:02.510890",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Request",
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 af1c06643a1..d984d86af25 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 @@ class PeriodClosingVoucher(AccountsController):
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},
diff --git a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py
index 93ba90ad9f9..62b342a3d20 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py
+++ b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py
@@ -5,6 +5,10 @@ import unittest
import frappe
+from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
+ create_dimension,
+ disable_dimension,
+)
from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import (
make_closing_entry_from_opening,
)
@@ -140,6 +144,43 @@ class TestPOSClosingEntry(unittest.TestCase):
pos_inv1.load_from_db()
self.assertEqual(pos_inv1.status, "Paid")
+ def test_pos_closing_for_required_accounting_dimension_in_pos_profile(self):
+ """
+ test case to check whether we can create POS Closing Entry without mandatory accounting dimension
+ """
+
+ create_dimension()
+ pos_profile = make_pos_profile(do_not_insert=1, do_not_set_accounting_dimension=1)
+
+ self.assertRaises(frappe.ValidationError, pos_profile.insert)
+
+ pos_profile.location = "Block 1"
+ pos_profile.insert()
+ self.assertTrue(frappe.db.exists("POS Profile", pos_profile.name))
+
+ test_user = init_user_and_profile(do_not_create_pos_profile=1)
+
+ opening_entry = create_opening_entry(pos_profile, test_user.name)
+ pos_inv1 = create_pos_invoice(rate=350, do_not_submit=1, pos_profile=pos_profile.name)
+ pos_inv1.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
+ pos_inv1.submit()
+
+ # if in between a mandatory accounting dimension is added to the POS Profile then
+ accounting_dimension_department = frappe.get_doc("Accounting Dimension", {"name": "Department"})
+ accounting_dimension_department.dimension_defaults[0].mandatory_for_bs = 1
+ accounting_dimension_department.save()
+
+ pcv_doc = make_closing_entry_from_opening(opening_entry)
+ # will assert coz the new mandatory accounting dimension bank is not set in POS Profile
+ self.assertRaises(frappe.ValidationError, pcv_doc.submit)
+
+ accounting_dimension_department = frappe.get_doc(
+ "Accounting Dimension Detail", {"parent": "Department"}
+ )
+ accounting_dimension_department.mandatory_for_bs = 0
+ accounting_dimension_department.save()
+ disable_dimension()
+
def init_user_and_profile(**args):
user = "test@example.com"
@@ -149,6 +190,9 @@ def init_user_and_profile(**args):
test_user.add_roles(*roles)
frappe.set_user(user)
+ if args.get("do_not_create_pos_profile"):
+ return test_user
+
pos_profile = make_pos_profile(**args)
pos_profile.append("applicable_for_users", {"default": 1, "user": user})
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 842f159967f..e36e97bc4b4 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -414,7 +414,7 @@ class POSInvoice(SalesInvoice):
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:
diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
index b587ce603f4..d42b1e4cd1d 100644
--- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
+++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
@@ -12,6 +12,8 @@ from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
from frappe.utils.background_jobs import enqueue, is_job_enqueued
from frappe.utils.scheduler import is_scheduler_inactive
+from erpnext.accounts.doctype.pos_profile.pos_profile import required_accounting_dimensions
+
class POSInvoiceMergeLog(Document):
def validate(self):
@@ -163,7 +165,8 @@ class POSInvoiceMergeLog(Document):
for i in items:
if (
i.item_code == item.item_code
- and not i.serial_and_batch_bundle
+ and not i.serial_no
+ and not i.batch_no
and i.uom == item.uom
and i.net_rate == item.net_rate
and i.warehouse == item.warehouse
@@ -238,6 +241,22 @@ class POSInvoiceMergeLog(Document):
invoice.disable_rounded_total = cint(
frappe.db.get_value("POS Profile", invoice.pos_profile, "disable_rounded_total")
)
+ accounting_dimensions = required_accounting_dimensions()
+ dimension_values = frappe.db.get_value(
+ "POS Profile", {"name": invoice.pos_profile}, accounting_dimensions, as_dict=1
+ )
+ for dimension in accounting_dimensions:
+ dimension_value = dimension_values.get(dimension)
+
+ if not dimension_value:
+ frappe.throw(
+ _("Please set Accounting Dimension {} in {}").format(
+ frappe.bold(frappe.unscrub(dimension)),
+ frappe.get_desk_link("POS Profile", invoice.pos_profile),
+ )
+ )
+
+ invoice.set(dimension, dimension_value)
if self.merge_invoices_based_on == "Customer Group":
invoice.flags.ignore_pos_profile = True
@@ -424,11 +443,9 @@ def create_merge_logs(invoice_by_customer, closing_entry=None):
)
merge_log.customer = customer
merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None
-
merge_log.set("pos_invoices", _invoices)
merge_log.save(ignore_permissions=True)
merge_log.submit()
-
if closing_entry:
closing_entry.set_status(update=True, status="Submitted")
closing_entry.db_set("error_message", "")
diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.js b/erpnext/accounts/doctype/pos_profile/pos_profile.js
index 0a89aee8e9c..ceaafaa3b12 100755
--- a/erpnext/accounts/doctype/pos_profile/pos_profile.js
+++ b/erpnext/accounts/doctype/pos_profile/pos_profile.js
@@ -1,6 +1,5 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
-
frappe.ui.form.on('POS Profile', {
setup: function(frm) {
frm.set_query("selling_price_list", function() {
@@ -140,6 +139,7 @@ frappe.ui.form.on('POS Profile', {
company: function(frm) {
frm.trigger("toggle_display_account_head");
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
+
},
toggle_display_account_head: function(frm) {
diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py
index e8aee737f29..58be2d3e5c0 100644
--- a/erpnext/accounts/doctype/pos_profile/pos_profile.py
+++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py
@@ -3,7 +3,7 @@
import frappe
-from frappe import _, msgprint
+from frappe import _, msgprint, scrub, unscrub
from frappe.model.document import Document
from frappe.utils import get_link_to_form, now
@@ -14,6 +14,21 @@ class POSProfile(Document):
self.validate_all_link_fields()
self.validate_duplicate_groups()
self.validate_payment_methods()
+ self.validate_accounting_dimensions()
+
+ def validate_accounting_dimensions(self):
+ acc_dim_names = required_accounting_dimensions()
+ for acc_dim in acc_dim_names:
+ if not self.get(acc_dim):
+ frappe.throw(
+ _(
+ "{0} is a mandatory Accounting Dimension. "
+ "Please set a value for {0} in Accounting Dimensions section."
+ ).format(
+ unscrub(frappe.bold(acc_dim)),
+ ),
+ title=_("Mandatory Accounting Dimension"),
+ )
def validate_default_profile(self):
for row in self.applicable_for_users:
@@ -152,6 +167,24 @@ def get_child_nodes(group_type, root):
)
+def required_accounting_dimensions():
+
+ p = frappe.qb.DocType("Accounting Dimension")
+ c = frappe.qb.DocType("Accounting Dimension Detail")
+
+ acc_dim_doc = (
+ frappe.qb.from_(p)
+ .inner_join(c)
+ .on(p.name == c.parent)
+ .select(c.parent)
+ .where((c.mandatory_for_bs == 1) | (c.mandatory_for_pl == 1))
+ .where(p.disabled == 0)
+ ).run(as_dict=1)
+
+ acc_dim_names = [scrub(d.parent) for d in acc_dim_doc]
+ return acc_dim_names
+
+
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
diff --git a/erpnext/accounts/doctype/pos_profile/test_pos_profile.py b/erpnext/accounts/doctype/pos_profile/test_pos_profile.py
index 788aa62701d..b468ad3fe9b 100644
--- a/erpnext/accounts/doctype/pos_profile/test_pos_profile.py
+++ b/erpnext/accounts/doctype/pos_profile/test_pos_profile.py
@@ -5,7 +5,10 @@ import unittest
import frappe
-from erpnext.accounts.doctype.pos_profile.pos_profile import get_child_nodes
+from erpnext.accounts.doctype.pos_profile.pos_profile import (
+ get_child_nodes,
+ required_accounting_dimensions,
+)
from erpnext.stock.get_item_details import get_pos_profile
test_dependencies = ["Item"]
@@ -118,6 +121,7 @@ def make_pos_profile(**args):
"warehouse": args.warehouse or "_Test Warehouse - _TC",
"write_off_account": args.write_off_account or "_Test Write Off - _TC",
"write_off_cost_center": args.write_off_cost_center or "_Test Write Off Cost Center - _TC",
+ "location": "Block 1" if not args.do_not_set_accounting_dimension else None,
}
)
@@ -132,6 +136,7 @@ def make_pos_profile(**args):
pos_profile.append("payments", {"mode_of_payment": "Cash", "default": 1})
if not frappe.db.exists("POS Profile", args.name or "_Test POS Profile"):
- pos_profile.insert()
+ if not args.get("do_not_insert"):
+ pos_profile.insert()
return pos_profile
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 78631032698..9a5ad35bcf1 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
@@ -65,6 +65,7 @@ def get_report_pdf(doc, consolidated=True):
filters = get_common_filters(doc)
if doc.report == "General Ledger":
+ filters.update(get_gl_filters(doc, entry, tax_id, presentation_currency))
col, res = get_soa(filters)
for x in [0, -2, -1]:
res[x]["account"] = res[x]["account"].replace("'", "")
diff --git a/erpnext/crm/doctype/linkedin_settings/__init__.py b/erpnext/accounts/doctype/process_subscription/__init__.py
similarity index 100%
rename from erpnext/crm/doctype/linkedin_settings/__init__.py
rename to erpnext/accounts/doctype/process_subscription/__init__.py
diff --git a/erpnext/accounts/doctype/process_subscription/process_subscription.js b/erpnext/accounts/doctype/process_subscription/process_subscription.js
new file mode 100644
index 00000000000..858c91334bd
--- /dev/null
+++ b/erpnext/accounts/doctype/process_subscription/process_subscription.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Process Subscription", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/erpnext/accounts/doctype/process_subscription/process_subscription.json b/erpnext/accounts/doctype/process_subscription/process_subscription.json
new file mode 100644
index 00000000000..502d00286be
--- /dev/null
+++ b/erpnext/accounts/doctype/process_subscription/process_subscription.json
@@ -0,0 +1,90 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2023-09-17 15:40:59.724177",
+ "default_view": "List",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "posting_date",
+ "subscription",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Process Subscription",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "posting_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Posting Date",
+ "reqd": 1
+ },
+ {
+ "fieldname": "subscription",
+ "fieldtype": "Link",
+ "label": "Subscription",
+ "options": "Subscription"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2023-09-17 17:33:37.974166",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Process Subscription",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/process_subscription/process_subscription.py b/erpnext/accounts/doctype/process_subscription/process_subscription.py
new file mode 100644
index 00000000000..99269d6a7d5
--- /dev/null
+++ b/erpnext/accounts/doctype/process_subscription/process_subscription.py
@@ -0,0 +1,27 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from datetime import datetime
+from typing import Union
+
+import frappe
+from frappe.model.document import Document
+from frappe.utils import getdate
+
+from erpnext.accounts.doctype.subscription.subscription import process_all
+
+
+class ProcessSubscription(Document):
+ def on_submit(self):
+ process_all(subscription=self.subscription, posting_date=self.posting_date)
+
+
+def create_subscription_process(
+ subscription: str | None, posting_date: Union[str, datetime.date] | None
+):
+ """Create a new Process Subscription document"""
+ doc = frappe.new_doc("Process Subscription")
+ doc.subscription = subscription
+ doc.posting_date = getdate(posting_date)
+ doc.insert(ignore_permissions=True)
+ doc.submit()
diff --git a/erpnext/accounts/doctype/process_subscription/test_process_subscription.py b/erpnext/accounts/doctype/process_subscription/test_process_subscription.py
new file mode 100644
index 00000000000..723695f1ab2
--- /dev/null
+++ b/erpnext/accounts/doctype/process_subscription/test_process_subscription.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestProcessSubscription(FrappeTestCase):
+ pass
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
index efe97415a55..c8c9ad1b3a9 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
@@ -161,6 +161,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() {
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index ce7ada3b097..b4dd75a714d 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -1164,7 +1164,7 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
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)
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
index 642e99cd58a..d4d923902f1 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
@@ -37,7 +37,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", "Repost Accounting 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
@@ -183,8 +183,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.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index fba2fa7552e..7bdb2b49cea 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -388,6 +388,8 @@ class SalesInvoice(SellingController):
"Repost Payment Ledger Items",
"Repost Accounting Ledger",
"Repost Accounting Ledger Items",
+ "Unreconcile Payments",
+ "Unreconcile Payment Entries",
"Payment Ledger Entry",
"Serial and Batch Bundle",
)
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 21b39d73120..84b01499421 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -1801,6 +1801,10 @@ class TestSalesInvoice(unittest.TestCase):
)
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 +1824,25 @@ class TestSalesInvoice(unittest.TestCase):
"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 +1850,7 @@ class TestSalesInvoice(unittest.TestCase):
"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 +1859,13 @@ class TestSalesInvoice(unittest.TestCase):
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 +1873,9 @@ class TestSalesInvoice(unittest.TestCase):
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),
@@ -2322,7 +2346,7 @@ class TestSalesInvoice(unittest.TestCase):
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()
@@ -3102,7 +3126,7 @@ class TestSalesInvoice(unittest.TestCase):
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(
diff --git a/erpnext/accounts/doctype/subscription/subscription.json b/erpnext/accounts/doctype/subscription/subscription.json
index c15aa1e05a7..187b7abce18 100644
--- a/erpnext/accounts/doctype/subscription/subscription.json
+++ b/erpnext/accounts/doctype/subscription/subscription.json
@@ -24,8 +24,9 @@
"current_invoice_start",
"current_invoice_end",
"days_until_due",
+ "generate_invoice_at",
+ "number_of_days",
"cancel_at_period_end",
- "generate_invoice_at_period_start",
"sb_4",
"plans",
"sb_1",
@@ -86,12 +87,14 @@
"fieldname": "current_invoice_start",
"fieldtype": "Date",
"label": "Current Invoice Start Date",
+ "no_copy": 1,
"read_only": 1
},
{
"fieldname": "current_invoice_end",
"fieldtype": "Date",
"label": "Current Invoice End Date",
+ "no_copy": 1,
"read_only": 1
},
{
@@ -107,12 +110,6 @@
"fieldtype": "Check",
"label": "Cancel At End Of Period"
},
- {
- "default": "0",
- "fieldname": "generate_invoice_at_period_start",
- "fieldtype": "Check",
- "label": "Generate Invoice At Beginning Of Period"
- },
{
"allow_on_submit": 1,
"fieldname": "sb_4",
@@ -240,6 +237,21 @@
"fieldname": "submit_invoice",
"fieldtype": "Check",
"label": "Submit Generated Invoices"
+ },
+ {
+ "default": "End of the current subscription period",
+ "fieldname": "generate_invoice_at",
+ "fieldtype": "Select",
+ "label": "Generate Invoice At",
+ "options": "End of the current subscription period\nBeginning of the current subscription period\nDays before the current subscription period",
+ "reqd": 1
+ },
+ {
+ "depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"",
+ "fieldname": "number_of_days",
+ "fieldtype": "Int",
+ "label": "Number of Days",
+ "mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\""
}
],
"index_web_pages_for_search": 1,
@@ -255,7 +267,7 @@
"link_fieldname": "subscription"
}
],
- "modified": "2022-02-18 23:24:57.185054",
+ "modified": "2023-09-18 17:48:21.900252",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription",
diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py
index bbcade17589..3cf7d284bbb 100644
--- a/erpnext/accounts/doctype/subscription/subscription.py
+++ b/erpnext/accounts/doctype/subscription/subscription.py
@@ -36,12 +36,15 @@ class InvoiceNotCancelled(frappe.ValidationError):
pass
+DateTimeLikeObject = Union[str, datetime.date]
+
+
class Subscription(Document):
def before_insert(self):
# update start just before the subscription doc is created
self.update_subscription_period(self.start_date)
- def update_subscription_period(self, date: Optional[Union[datetime.date, str]] = None):
+ def update_subscription_period(self, date: Optional["DateTimeLikeObject"] = None):
"""
Subscription period is the period to be billed. This method updates the
beginning of the billing period and end of the billing period.
@@ -52,14 +55,14 @@ class Subscription(Document):
self.current_invoice_start = self.get_current_invoice_start(date)
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
- def _get_subscription_period(self, date: Optional[Union[datetime.date, str]] = None):
+ def _get_subscription_period(self, date: Optional["DateTimeLikeObject"] = None):
_current_invoice_start = self.get_current_invoice_start(date)
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
return _current_invoice_start, _current_invoice_end
def get_current_invoice_start(
- self, date: Optional[Union[datetime.date, str]] = None
+ self, date: Optional["DateTimeLikeObject"] = None
) -> Union[datetime.date, str]:
"""
This returns the date of the beginning of the current billing period.
@@ -84,7 +87,7 @@ class Subscription(Document):
return _current_invoice_start
def get_current_invoice_end(
- self, date: Optional[Union[datetime.date, str]] = None
+ self, date: Optional["DateTimeLikeObject"] = None
) -> Union[datetime.date, str]:
"""
This returns the date of the end of the current billing period.
@@ -179,30 +182,24 @@ class Subscription(Document):
return data
- def set_subscription_status(self) -> None:
+ def set_subscription_status(self, posting_date: Optional["DateTimeLikeObject"] = None) -> None:
"""
Sets the status of the `Subscription`
"""
if self.is_trialling():
self.status = "Trialling"
elif (
- self.status == "Active"
- and self.end_date
- and getdate(frappe.flags.current_date) > getdate(self.end_date)
+ self.status == "Active" and self.end_date and getdate(posting_date) > getdate(self.end_date)
):
self.status = "Completed"
elif self.is_past_grace_period():
self.status = self.get_status_for_past_grace_period()
- self.cancelation_date = (
- getdate(frappe.flags.current_date) if self.status == "Cancelled" else None
- )
+ self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
self.status = "Past Due Date"
elif not self.has_outstanding_invoice() or self.is_new_subscription():
self.status = "Active"
- self.save()
-
def is_trialling(self) -> bool:
"""
Returns `True` if the `Subscription` is in trial period.
@@ -210,7 +207,9 @@ class Subscription(Document):
return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription()
@staticmethod
- def period_has_passed(end_date: Union[str, datetime.date]) -> bool:
+ def period_has_passed(
+ end_date: Union[str, datetime.date], posting_date: Optional["DateTimeLikeObject"] = None
+ ) -> bool:
"""
Returns true if the given `end_date` has passed
"""
@@ -218,7 +217,7 @@ class Subscription(Document):
if not end_date:
return True
- return getdate(frappe.flags.current_date) > getdate(end_date)
+ return getdate(posting_date) > getdate(end_date)
def get_status_for_past_grace_period(self) -> str:
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
@@ -229,7 +228,7 @@ class Subscription(Document):
return status
- def is_past_grace_period(self) -> bool:
+ def is_past_grace_period(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool:
"""
Returns `True` if the grace period for the `Subscription` has passed
"""
@@ -237,18 +236,18 @@ class Subscription(Document):
return
grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period"))
- return getdate(frappe.flags.current_date) >= getdate(
- add_days(self.current_invoice.due_date, grace_period)
- )
+ return getdate(posting_date) >= getdate(add_days(self.current_invoice.due_date, grace_period))
- def current_invoice_is_past_due(self) -> bool:
+ def current_invoice_is_past_due(
+ self, posting_date: Optional["DateTimeLikeObject"] = None
+ ) -> bool:
"""
Returns `True` if the current generated invoice is overdue
"""
if not self.current_invoice or self.is_paid(self.current_invoice):
return False
- return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date)
+ return getdate(posting_date) >= getdate(self.current_invoice.due_date)
@property
def invoice_document_type(self) -> str:
@@ -270,6 +269,9 @@ class Subscription(Document):
if not self.cost_center:
self.cost_center = get_default_cost_center(self.get("company"))
+ if self.is_new():
+ self.set_subscription_status()
+
def validate_trial_period(self) -> None:
"""
Runs sanity checks on trial period dates for the `Subscription`
@@ -305,10 +307,6 @@ class Subscription(Document):
if billing_info[0]["billing_interval"] != "Month":
frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months"))
- def after_insert(self) -> None:
- # todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype?
- self.set_subscription_status()
-
def generate_invoice(
self,
from_date: Optional[Union[str, datetime.date]] = None,
@@ -344,7 +342,7 @@ class Subscription(Document):
invoice.set_posting_time = 1
invoice.posting_date = (
self.current_invoice_start
- if self.generate_invoice_at_period_start
+ if self.generate_invoice_at == "Beginning of the current subscription period"
else self.current_invoice_end
)
@@ -438,7 +436,7 @@ class Subscription(Document):
prorate_factor = get_prorata_factor(
self.current_invoice_end,
self.current_invoice_start,
- cint(self.generate_invoice_at_period_start),
+ cint(self.generate_invoice_at == "Beginning of the current subscription period"),
)
items = []
@@ -503,42 +501,45 @@ class Subscription(Document):
return items
@frappe.whitelist()
- def process(self) -> bool:
+ def process(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool:
"""
To be called by task periodically. It checks the subscription and takes appropriate action
as need be. It calls either of these methods depending the `Subscription` status:
1. `process_for_active`
2. `process_for_past_due`
"""
- if (
- not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
- and self.can_generate_new_invoice()
- ):
+ if not self.is_current_invoice_generated(
+ self.current_invoice_start, self.current_invoice_end
+ ) and self.can_generate_new_invoice(posting_date):
self.generate_invoice()
self.update_subscription_period(add_days(self.current_invoice_end, 1))
if self.cancel_at_period_end and (
- getdate(frappe.flags.current_date) >= getdate(self.current_invoice_end)
- or getdate(frappe.flags.current_date) >= getdate(self.end_date)
+ getdate(posting_date) >= getdate(self.current_invoice_end)
+ or getdate(posting_date) >= getdate(self.end_date)
):
self.cancel_subscription()
- self.set_subscription_status()
+ self.set_subscription_status(posting_date=posting_date)
self.save()
- def can_generate_new_invoice(self) -> bool:
+ def can_generate_new_invoice(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool:
if self.cancelation_date:
return False
- elif self.generate_invoice_at_period_start and (
- getdate(frappe.flags.current_date) == getdate(self.current_invoice_start)
- or self.is_new_subscription()
+
+ if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
+ return False
+
+ if self.generate_invoice_at == "Beginning of the current subscription period" and (
+ getdate(posting_date) == getdate(self.current_invoice_start) or self.is_new_subscription()
):
return True
- elif getdate(frappe.flags.current_date) == getdate(self.current_invoice_end):
- if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
- return False
-
+ elif self.generate_invoice_at == "Days before the current subscription period" and (
+ getdate(posting_date) == getdate(add_days(self.current_invoice_start, -1 * self.number_of_days))
+ ):
+ return True
+ elif getdate(posting_date) == getdate(self.current_invoice_end):
return True
else:
return False
@@ -628,7 +629,10 @@ class Subscription(Document):
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
to_generate_invoice = (
- True if self.status == "Active" and not self.generate_invoice_at_period_start else False
+ True
+ if self.status == "Active"
+ and not self.generate_invoice_at == "Beginning of the current subscription period"
+ else False
)
self.status = "Cancelled"
self.cancelation_date = nowdate()
@@ -639,7 +643,7 @@ class Subscription(Document):
self.save()
@frappe.whitelist()
- def restart_subscription(self) -> None:
+ def restart_subscription(self, posting_date: Optional["DateTimeLikeObject"] = None) -> None:
"""
This sets the subscription as active. The subscription will be made to be like a new
subscription and the `Subscription` will lose all the history of generated invoices
@@ -650,7 +654,7 @@ class Subscription(Document):
self.status = "Active"
self.cancelation_date = None
- self.update_subscription_period(frappe.flags.current_date or nowdate())
+ self.update_subscription_period(posting_date or nowdate())
self.save()
@@ -671,14 +675,21 @@ def get_prorata_factor(
return diff / plan_days
-def process_all() -> None:
+def process_all(
+ subscription: str | None, posting_date: Optional["DateTimeLikeObject"] = None
+) -> None:
"""
Task to updates the status of all `Subscription` apart from those that are cancelled
"""
- for subscription in frappe.get_all("Subscription", {"status": ("!=", "Cancelled")}, pluck="name"):
+ filters = {"status": ("!=", "Cancelled")}
+
+ if subscription:
+ filters["name"] = subscription
+
+ for subscription in frappe.get_all("Subscription", filters, pluck="name"):
try:
subscription = frappe.get_doc("Subscription", subscription)
- subscription.process()
+ subscription.process(posting_date)
frappe.db.commit()
except frappe.ValidationError:
frappe.db.rollback()
diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py
index 0bb171f464e..803e87900dc 100644
--- a/erpnext/accounts/doctype/subscription/test_subscription.py
+++ b/erpnext/accounts/doctype/subscription/test_subscription.py
@@ -8,6 +8,7 @@ from frappe.utils.data import (
add_days,
add_months,
add_to_date,
+ cint,
date_diff,
flt,
get_date_str,
@@ -20,99 +21,16 @@ from erpnext.accounts.doctype.subscription.subscription import get_prorata_facto
test_dependencies = ("UOM", "Item Group", "Item")
-def create_plan():
- if not frappe.db.exists("Subscription Plan", "_Test Plan Name"):
- plan = frappe.new_doc("Subscription Plan")
- plan.plan_name = "_Test Plan Name"
- plan.item = "_Test Non Stock Item"
- plan.price_determination = "Fixed Rate"
- plan.cost = 900
- plan.billing_interval = "Month"
- plan.billing_interval_count = 1
- plan.insert()
-
- if not frappe.db.exists("Subscription Plan", "_Test Plan Name 2"):
- plan = frappe.new_doc("Subscription Plan")
- plan.plan_name = "_Test Plan Name 2"
- plan.item = "_Test Non Stock Item"
- plan.price_determination = "Fixed Rate"
- plan.cost = 1999
- plan.billing_interval = "Month"
- plan.billing_interval_count = 1
- plan.insert()
-
- if not frappe.db.exists("Subscription Plan", "_Test Plan Name 3"):
- plan = frappe.new_doc("Subscription Plan")
- plan.plan_name = "_Test Plan Name 3"
- plan.item = "_Test Non Stock Item"
- plan.price_determination = "Fixed Rate"
- plan.cost = 1999
- plan.billing_interval = "Day"
- plan.billing_interval_count = 14
- plan.insert()
-
- # Defined a quarterly Subscription Plan
- if not frappe.db.exists("Subscription Plan", "_Test Plan Name 4"):
- plan = frappe.new_doc("Subscription Plan")
- plan.plan_name = "_Test Plan Name 4"
- plan.item = "_Test Non Stock Item"
- plan.price_determination = "Monthly Rate"
- plan.cost = 20000
- plan.billing_interval = "Month"
- plan.billing_interval_count = 3
- plan.insert()
-
- if not frappe.db.exists("Subscription Plan", "_Test Plan Multicurrency"):
- plan = frappe.new_doc("Subscription Plan")
- plan.plan_name = "_Test Plan Multicurrency"
- plan.item = "_Test Non Stock Item"
- plan.price_determination = "Fixed Rate"
- plan.cost = 50
- plan.currency = "USD"
- plan.billing_interval = "Month"
- plan.billing_interval_count = 1
- plan.insert()
-
-
-def create_parties():
- if not frappe.db.exists("Supplier", "_Test Supplier"):
- supplier = frappe.new_doc("Supplier")
- supplier.supplier_name = "_Test Supplier"
- supplier.supplier_group = "All Supplier Groups"
- supplier.insert()
-
- if not frappe.db.exists("Customer", "_Test Subscription Customer"):
- customer = frappe.new_doc("Customer")
- customer.customer_name = "_Test Subscription Customer"
- customer.billing_currency = "USD"
- customer.append(
- "accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"}
- )
- customer.insert()
-
-
-def reset_settings():
- settings = frappe.get_single("Subscription Settings")
- settings.grace_period = 0
- settings.cancel_after_grace = 0
- settings.save()
-
-
class TestSubscription(unittest.TestCase):
def setUp(self):
- create_plan()
+ make_plans()
create_parties()
reset_settings()
def test_create_subscription_with_trial_with_correct_period(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.trial_period_start = nowdate()
- subscription.trial_period_end = add_months(nowdate(), 1)
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
-
+ subscription = create_subscription(
+ trial_period_start=nowdate(), trial_period_end=add_months(nowdate(), 1)
+ )
self.assertEqual(subscription.trial_period_start, nowdate())
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
self.assertEqual(
@@ -126,12 +44,7 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Trialling")
def test_create_subscription_without_trial_with_correct_period(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
-
+ subscription = create_subscription()
self.assertEqual(subscription.trial_period_start, None)
self.assertEqual(subscription.trial_period_end, None)
self.assertEqual(subscription.current_invoice_start, nowdate())
@@ -141,55 +54,28 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active")
def test_create_subscription_trial_with_wrong_dates(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.trial_period_end = nowdate()
- subscription.trial_period_start = add_days(nowdate(), 30)
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
-
- self.assertRaises(frappe.ValidationError, subscription.save)
-
- def test_create_subscription_multi_with_different_billing_fails(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.trial_period_end = nowdate()
- subscription.trial_period_start = add_days(nowdate(), 30)
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1})
-
+ subscription = create_subscription(
+ trial_period_start=add_days(nowdate(), 30), trial_period_end=nowdate(), do_not_save=True
+ )
self.assertRaises(frappe.ValidationError, subscription.save)
def test_invoice_is_generated_at_end_of_billing_period(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.start_date = "2018-01-01"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.insert()
-
+ subscription = create_subscription(start_date="2018-01-01")
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, "2018-01-01")
self.assertEqual(subscription.current_invoice_end, "2018-01-31")
- frappe.flags.current_date = "2018-01-31"
- subscription.process()
+ subscription.process(posting_date="2018-01-31")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.current_invoice_start, "2018-02-01")
self.assertEqual(subscription.current_invoice_end, "2018-02-28")
self.assertEqual(subscription.status, "Unpaid")
def test_status_goes_back_to_active_after_invoice_is_paid(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.start_date = "2018-01-01"
- subscription.generate_invoice_at_period_start = True
- subscription.insert()
- frappe.flags.current_date = "2018-01-01"
- subscription.process() # generate first invoice
+ subscription = create_subscription(
+ start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
+ )
+ subscription.process(posting_date="2018-01-01") # generate first invoice
self.assertEqual(len(subscription.invoices), 1)
# Status is unpaid as Days until Due is zero and grace period is Zero
@@ -213,18 +99,10 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = 1
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- # subscription.generate_invoice_at_period_start = True
- subscription.start_date = "2018-01-01"
- subscription.insert()
-
+ subscription = create_subscription(start_date="2018-01-01")
self.assertEqual(subscription.status, "Active")
- frappe.flags.current_date = "2018-01-31"
- subscription.process() # generate first invoice
+ subscription.process(posting_date="2018-01-31") # generate first invoice
# This should change status to Cancelled since grace period is 0
# And is backdated subscription so subscription will be cancelled after processing
self.assertEqual(subscription.status, "Cancelled")
@@ -235,13 +113,8 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = 0
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.start_date = "2018-01-01"
- subscription.insert()
- subscription.process() # generate first invoice
+ subscription = create_subscription(start_date="2018-01-01")
+ subscription.process(posting_date="2018-01-31") # generate first invoice
# Status is unpaid as Days until Due is zero and grace period is Zero
self.assertEqual(subscription.status, "Unpaid")
@@ -251,21 +124,9 @@ class TestSubscription(unittest.TestCase):
def test_subscription_invoice_days_until_due(self):
_date = add_months(nowdate(), -1)
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.days_until_due = 10
- subscription.start_date = _date
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.insert()
+ subscription = create_subscription(start_date=_date, days_until_due=10)
- frappe.flags.current_date = subscription.current_invoice_end
-
- subscription.process() # generate first invoice
- self.assertEqual(len(subscription.invoices), 1)
- self.assertEqual(subscription.status, "Active")
-
- frappe.flags.current_date = add_days(subscription.current_invoice_end, 3)
+ subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active")
@@ -275,16 +136,9 @@ class TestSubscription(unittest.TestCase):
settings.grace_period = 1000
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.start_date = add_days(nowdate(), -1000)
- subscription.insert()
-
- frappe.flags.current_date = subscription.current_invoice_end
- subscription.process() # generate first invoice
+ subscription = create_subscription(start_date=add_days(nowdate(), -1000))
+ subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
self.assertEqual(subscription.status, "Past Due Date")
subscription.process()
@@ -301,12 +155,7 @@ class TestSubscription(unittest.TestCase):
settings.save()
def test_subscription_remains_active_during_invoice_period(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
- subscription.process() # no changes expected
+ subscription = create_subscription() # no changes expected
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, nowdate())
@@ -325,12 +174,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0)
- def test_subscription_cancelation(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
+ def test_subscription_cancellation(self):
+ subscription = create_subscription()
subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled")
@@ -341,11 +186,7 @@ class TestSubscription(unittest.TestCase):
settings.prorate = 1
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
+ subscription = create_subscription()
self.assertEqual(subscription.status, "Active")
@@ -365,7 +206,7 @@ class TestSubscription(unittest.TestCase):
get_prorata_factor(
subscription.current_invoice_end,
subscription.current_invoice_start,
- subscription.generate_invoice_at_period_start,
+ cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
),
2,
),
@@ -383,11 +224,7 @@ class TestSubscription(unittest.TestCase):
settings.prorate = 0
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
+ subscription = create_subscription()
subscription.cancel_subscription()
invoice = subscription.get_current_invoice()
@@ -402,11 +239,7 @@ class TestSubscription(unittest.TestCase):
settings.prorate = 1
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
+ subscription = create_subscription()
subscription.cancel_subscription()
invoice = subscription.get_current_invoice()
@@ -421,18 +254,13 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate
settings.save()
- def test_subcription_cancellation_and_process(self):
+ def test_subscription_cancellation_and_process(self):
settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace
settings.cancel_after_grace = 1
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.start_date = "2018-01-01"
- subscription.insert()
+ subscription = create_subscription(start_date="2018-01-01")
subscription.process() # generate first invoice
# Generate an invoice for the cancelled period
@@ -458,14 +286,8 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = 0
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.start_date = "2018-01-01"
- subscription.insert()
- frappe.flags.current_date = "2018-01-31"
- subscription.process() # generate first invoice
+ subscription = create_subscription(start_date="2018-01-01")
+ subscription.process(posting_date="2018-01-31") # generate first invoice
# Status is unpaid as Days until Due is zero and grace period is Zero
self.assertEqual(subscription.status, "Unpaid")
@@ -494,17 +316,10 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = 0
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.start_date = "2018-01-01"
- subscription.generate_invoice_at_period_start = True
- subscription.insert()
-
- frappe.flags.current_date = subscription.current_invoice_start
-
- subscription.process() # generate first invoice
+ subscription = create_subscription(
+ start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
+ )
+ subscription.process(subscription.current_invoice_start) # generate first invoice
# This should change status to Unpaid since grace period is 0
self.assertEqual(subscription.status, "Unpaid")
@@ -516,29 +331,18 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active")
# A new invoice is generated
- frappe.flags.current_date = subscription.current_invoice_start
- subscription.process()
+ subscription.process(posting_date=subscription.current_invoice_start)
self.assertEqual(subscription.status, "Unpaid")
settings.cancel_after_grace = default_grace_period_action
settings.save()
def test_restart_active_subscription(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
-
+ subscription = create_subscription()
self.assertRaises(frappe.ValidationError, subscription.restart_subscription)
def test_subscription_invoice_discount_percentage(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.additional_discount_percentage = 10
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
+ subscription = create_subscription(additional_discount_percentage=10)
subscription.cancel_subscription()
invoice = subscription.get_current_invoice()
@@ -547,12 +351,7 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(invoice.apply_discount_on, "Grand Total")
def test_subscription_invoice_discount_amount(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.additional_discount_amount = 11
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
+ subscription = create_subscription(additional_discount_amount=11)
subscription.cancel_subscription()
invoice = subscription.get_current_invoice()
@@ -563,18 +362,13 @@ class TestSubscription(unittest.TestCase):
def test_prepaid_subscriptions(self):
# Create a non pre-billed subscription, processing should not create
# invoices.
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
+ subscription = create_subscription()
subscription.process()
-
self.assertEqual(len(subscription.invoices), 0)
# Change the subscription type to prebilled and process it.
# Prepaid invoice should be generated
- subscription.generate_invoice_at_period_start = True
+ subscription.generate_invoice_at = "Beginning of the current subscription period"
subscription.save()
subscription.process()
@@ -586,12 +380,9 @@ class TestSubscription(unittest.TestCase):
settings.prorate = 1
settings.save()
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Customer"
- subscription.generate_invoice_at_period_start = True
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.save()
+ subscription = create_subscription(
+ generate_invoice_at="Beginning of the current subscription period"
+ )
subscription.process()
subscription.cancel_subscription()
@@ -609,9 +400,10 @@ class TestSubscription(unittest.TestCase):
def test_subscription_with_follow_calendar_months(self):
subscription = frappe.new_doc("Subscription")
+ subscription.company = "_Test Company"
subscription.party_type = "Supplier"
subscription.party = "_Test Supplier"
- subscription.generate_invoice_at_period_start = 1
+ subscription.generate_invoice_at = "Beginning of the current subscription period"
subscription.follow_calendar_months = 1
# select subscription start date as "2018-01-15"
@@ -625,39 +417,33 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
def test_subscription_generate_invoice_past_due(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Supplier"
- subscription.party = "_Test Supplier"
- subscription.generate_invoice_at_period_start = 1
- subscription.generate_new_invoices_past_due_date = 1
- # select subscription start date as "2018-01-15"
- subscription.start_date = "2018-01-01"
- subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
- subscription.save()
+ subscription = create_subscription(
+ start_date="2018-01-01",
+ party_type="Supplier",
+ party="_Test Supplier",
+ generate_invoice_at="Beginning of the current subscription period",
+ generate_new_invoices_past_due_date=1,
+ plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
+ )
- frappe.flags.current_date = "2018-01-01"
# Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed
- subscription.process()
+ subscription.process(posting_date="2018-01-01")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Unpaid")
# Now the Subscription is unpaid
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
# subscription and the interval between the subscriptions is 3 months
- frappe.flags.current_date = "2018-04-01"
- subscription.process()
+ subscription.process(posting_date="2018-04-01")
self.assertEqual(len(subscription.invoices), 2)
def test_subscription_without_generate_invoice_past_due(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Supplier"
- subscription.party = "_Test Supplier"
- subscription.generate_invoice_at_period_start = 1
- # select subscription start date as "2018-01-15"
- subscription.start_date = "2018-01-01"
- subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
- subscription.save()
+ subscription = create_subscription(
+ start_date="2018-01-01",
+ generate_invoice_at="Beginning of the current subscription period",
+ plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
+ )
# Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed
@@ -668,16 +454,13 @@ class TestSubscription(unittest.TestCase):
subscription.process()
self.assertEqual(len(subscription.invoices), 1)
- def test_multicurrency_subscription(self):
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Subscription Customer"
- subscription.generate_invoice_at_period_start = 1
- subscription.company = "_Test Company"
- # select subscription start date as "2018-01-15"
- subscription.start_date = "2018-01-01"
- subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1})
- subscription.save()
+ def test_multi_currency_subscription(self):
+ subscription = create_subscription(
+ start_date="2018-01-01",
+ generate_invoice_at="Beginning of the current subscription period",
+ plans=[{"plan": "_Test Plan Multicurrency", "qty": 1}],
+ party="_Test Subscription Customer",
+ )
subscription.process()
self.assertEqual(len(subscription.invoices), 1)
@@ -689,42 +472,135 @@ class TestSubscription(unittest.TestCase):
def test_subscription_recovery(self):
"""Test if Subscription recovers when start/end date run out of sync with created invoices."""
- subscription = frappe.new_doc("Subscription")
- subscription.party_type = "Customer"
- subscription.party = "_Test Subscription Customer"
- subscription.company = "_Test Company"
- subscription.start_date = "2021-12-01"
- subscription.generate_new_invoices_past_due_date = 1
- subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
- subscription.submit_invoice = 0
- subscription.save()
+ subscription = create_subscription(
+ start_date="2021-01-01",
+ submit_invoice=0,
+ generate_new_invoices_past_due_date=1,
+ party="_Test Subscription Customer",
+ )
# create invoices for the first two moths
- frappe.flags.current_date = "2021-12-31"
- subscription.process()
+ subscription.process(posting_date="2021-01-31")
- frappe.flags.current_date = "2022-01-31"
- subscription.process()
+ subscription.process(posting_date="2021-02-28")
self.assertEqual(len(subscription.invoices), 2)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
- getdate("2021-12-01"),
+ getdate("2021-01-01"),
)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
- getdate("2022-01-01"),
+ getdate("2021-02-01"),
)
# recreate most recent invoice
- subscription.process()
+ subscription.process(posting_date="2022-01-31")
self.assertEqual(len(subscription.invoices), 2)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
- getdate("2021-12-01"),
+ getdate("2021-01-01"),
)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
- getdate("2022-01-01"),
+ getdate("2021-02-01"),
)
+
+ def test_subscription_invoice_generation_before_days(self):
+ subscription = create_subscription(
+ start_date="2023-01-01",
+ generate_invoice_at="Days before the current subscription period",
+ number_of_days=10,
+ generate_new_invoices_past_due_date=1,
+ )
+
+ subscription.process(posting_date="2022-12-22")
+ self.assertEqual(len(subscription.invoices), 1)
+
+ subscription.process(posting_date="2023-01-22")
+ self.assertEqual(len(subscription.invoices), 2)
+
+
+def make_plans():
+ create_plan(plan_name="_Test Plan Name", cost=900)
+ create_plan(plan_name="_Test Plan Name 2", cost=1999)
+ create_plan(
+ plan_name="_Test Plan Name 3", cost=1999, billing_interval="Day", billing_interval_count=14
+ )
+ create_plan(
+ plan_name="_Test Plan Name 4", cost=20000, billing_interval="Month", billing_interval_count=3
+ )
+ create_plan(
+ plan_name="_Test Plan Multicurrency", cost=50, billing_interval="Month", currency="USD"
+ )
+
+
+def create_plan(**kwargs):
+ if not frappe.db.exists("Subscription Plan", kwargs.get("plan_name")):
+ plan = frappe.new_doc("Subscription Plan")
+ plan.plan_name = kwargs.get("plan_name") or "_Test Plan Name"
+ plan.item = kwargs.get("item") or "_Test Non Stock Item"
+ plan.price_determination = kwargs.get("price_determination") or "Fixed Rate"
+ plan.cost = kwargs.get("cost") or 1000
+ plan.billing_interval = kwargs.get("billing_interval") or "Month"
+ plan.billing_interval_count = kwargs.get("billing_interval_count") or 1
+ plan.currency = kwargs.get("currency")
+ plan.insert()
+
+
+def create_parties():
+ if not frappe.db.exists("Supplier", "_Test Supplier"):
+ supplier = frappe.new_doc("Supplier")
+ supplier.supplier_name = "_Test Supplier"
+ supplier.supplier_group = "All Supplier Groups"
+ supplier.insert()
+
+ if not frappe.db.exists("Customer", "_Test Subscription Customer"):
+ customer = frappe.new_doc("Customer")
+ customer.customer_name = "_Test Subscription Customer"
+ customer.billing_currency = "USD"
+ customer.append(
+ "accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"}
+ )
+ customer.insert()
+
+
+def reset_settings():
+ settings = frappe.get_single("Subscription Settings")
+ settings.grace_period = 0
+ settings.cancel_after_grace = 0
+ settings.save()
+
+
+def create_subscription(**kwargs):
+ subscription = frappe.new_doc("Subscription")
+ subscription.party_type = (kwargs.get("party_type") or "Customer",)
+ subscription.company = kwargs.get("company") or "_Test Company"
+ subscription.party = kwargs.get("party") or "_Test Customer"
+ subscription.trial_period_start = kwargs.get("trial_period_start")
+ subscription.trial_period_end = kwargs.get("trial_period_end")
+ subscription.start_date = kwargs.get("start_date")
+ subscription.generate_invoice_at = kwargs.get("generate_invoice_at")
+ subscription.additional_discount_percentage = kwargs.get("additional_discount_percentage")
+ subscription.additional_discount_amount = kwargs.get("additional_discount_amount")
+ subscription.follow_calendar_months = kwargs.get("follow_calendar_months")
+ subscription.generate_new_invoices_past_due_date = kwargs.get(
+ "generate_new_invoices_past_due_date"
+ )
+ subscription.submit_invoice = kwargs.get("submit_invoice")
+ subscription.days_until_due = kwargs.get("days_until_due")
+ subscription.number_of_days = kwargs.get("number_of_days")
+
+ if not kwargs.get("plans"):
+ subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
+ else:
+ for plan in kwargs.get("plans"):
+ subscription.append("plans", plan)
+
+ if kwargs.get("do_not_save"):
+ return subscription
+
+ subscription.save()
+
+ return subscription
diff --git a/erpnext/accounts/doctype/subscription_plan/subscription_plan.py b/erpnext/accounts/doctype/subscription_plan/subscription_plan.py
index f3acdc5aa87..75223c2ccca 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,
- )
-
- 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
-
+ cost -= plan.cost * get_prorate_factor(start_date, end_date)
return cost
+
+
+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)
+
+ 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/crm/doctype/social_media_post/__init__.py b/erpnext/accounts/doctype/unreconcile_payment_entries/__init__.py
similarity index 100%
rename from erpnext/crm/doctype/social_media_post/__init__.py
rename to erpnext/accounts/doctype/unreconcile_payment_entries/__init__.py
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 00000000000..42da669e650
--- /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 00000000000..c41545c2685
--- /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/crm/doctype/twitter_settings/__init__.py b/erpnext/accounts/doctype/unreconcile_payments/__init__.py
similarity index 100%
rename from erpnext/crm/doctype/twitter_settings/__init__.py
rename to erpnext/accounts/doctype/unreconcile_payments/__init__.py
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 00000000000..78e04bff819
--- /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 00000000000..c522567637f
--- /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 00000000000..f29e61b6ef6
--- /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 00000000000..4f9fb50d463
--- /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/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py
index cb84cf4fc0a..3cf93cc8659 100644
--- a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py
+++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py
@@ -24,7 +24,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
def tearDown(self):
frappe.db.rollback()
- def test_accounts_receivable_with_supplier(self):
+ 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
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
index cb8ec876e9e..bb00d616dbc 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
@@ -38,32 +38,31 @@ frappe.query_reports["Accounts Receivable"] = {
}
},
{
- "fieldname": "customer",
- "label": __("Customer"),
+ "fieldname": "party_type",
+ "label": __("Party Type"),
"fieldtype": "Link",
- "options": "Customer",
+ "options": "Party Type",
+ "Default": "Customer",
+ get_query: () => {
+ return {
+ filters: {
+ 'account_type': 'Receivable'
+ }
+ };
+ },
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, ["customer_name", "payment_terms"], function(value) {
- frappe.query_report.set_filter_value('customer_name', value["customer_name"]);
- frappe.query_report.set_filter_value('payment_terms', value["payment_terms"]);
- });
+ frappe.query_report.set_filter_value('party', "");
+ let party_type = frappe.query_report.get_filter_value('party_type');
+ frappe.query_report.toggle_filter_display('customer_group', frappe.query_report.get_filter_value('party_type') !== "Customer");
- 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('customer_name', "");
- frappe.query_report.set_filter_value('credit_limit', "");
- frappe.query_report.set_filter_value('payment_terms', "");
- }
}
},
+ {
+ "fieldname":"party",
+ "label": __("Party"),
+ "fieldtype": "Dynamic Link",
+ "options": "party_type",
+ },
{
"fieldname": "party_account",
"label": __("Receivable Account"),
@@ -174,24 +173,6 @@ frappe.query_reports["Accounts Receivable"] = {
"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
}
],
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index 14f8993727a..79424023652 100755
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -769,15 +769,12 @@ class ReceivablePayableReport(object):
self.or_filters = []
for party_type in self.party_type:
- party_type_field = scrub(party_type)
- self.or_filters.append(self.ple.party_type == party_type)
+ self.add_common_filters()
- self.add_common_filters(party_type_field=party_type_field)
-
- if party_type_field == "customer":
+ if self.account_type == "Receivable":
self.add_customer_filters()
- elif party_type_field == "supplier":
+ elif self.account_type == "Payable":
self.add_supplier_filters()
if self.filters.cost_center:
@@ -793,16 +790,13 @@ class ReceivablePayableReport(object):
]
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)
@@ -969,6 +963,20 @@ class ReceivablePayableReport(object):
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")
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index 0c7d931d2d5..b98916ee443 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -568,3 +568,40 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
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))
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 28d0c20a918..7b1a9027780 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
@@ -81,7 +81,7 @@ class TestDeferredRevenueAndExpense(FrappeTestCase, AccountsTestMixin):
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 = self.deferred_revenue_account
+ item.item_defaults[0].deferred_revenue_account = self.deferred_revenue_account
item.no_of_months = 3
item.save()
@@ -150,7 +150,7 @@ class TestDeferredRevenueAndExpense(FrappeTestCase, AccountsTestMixin):
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 = self.deferred_expense_account
+ item.item_defaults[0].deferred_expense_account = self.deferred_expense_account
item.no_of_months_exp = 3
item.save()
diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.js b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.js
index b709ab9b57d..423cd6a845b 100644
--- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.js
+++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.js
@@ -23,6 +23,12 @@ frappe.query_reports["Item-wise Purchase Register"] = {
"fieldtype": "Link",
"options": "Item",
},
+ {
+ "fieldname": "item_group",
+ "label": __("Item Group"),
+ "fieldtype": "Link",
+ "options": "Item Group",
+ },
{
"fieldname":"supplier",
"label": __("Supplier"),
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 5d3d4d74978..ad196a90328 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
@@ -293,6 +293,7 @@ def get_conditions(filters):
("from_date", " and `tabPurchase Invoice`.posting_date>=%(from_date)s"),
("to_date", " and `tabPurchase Invoice`.posting_date<=%(to_date)s"),
("mode_of_payment", " and ifnull(mode_of_payment, '') = %(mode_of_payment)s"),
+ ("item_group", " and ifnull(`tabPurchase Invoice Item`.item_group, '') = %(item_group)s"),
):
if filters.get(opts[0]):
conditions += opts[1]
diff --git a/erpnext/accounts/report/trial_balance/trial_balance.js b/erpnext/accounts/report/trial_balance/trial_balance.js
index ee6b4fef213..c12ab0ff915 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 376571f0346..2a8aa0c202f 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)
@@ -310,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,
@@ -335,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):
@@ -375,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/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py
index 08688608f4b..d503f7bc4af 100644
--- a/erpnext/accounts/test/accounts_mixin.py
+++ b/erpnext/accounts/test/accounts_mixin.py
@@ -158,6 +158,8 @@ class AccountsTestMixin:
"Journal Entry",
"Sales Order",
"Exchange Rate Revaluation",
+ "Bank Account",
+ "Bank Transaction",
]
for doctype in doctype_list:
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 6a80f20ec94..555ed4ffa2e 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -581,6 +581,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"])
@@ -647,6 +651,13 @@ def update_reference_in_payment_entry(
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)
@@ -674,7 +685,9 @@ def update_reference_in_payment_entry(
payment_entry.save(ignore_permissions=True)
-def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None:
+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.
"""
@@ -701,76 +714,147 @@ def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None:
as_list=1,
)
for doc in gain_loss_journals:
- frappe.get_doc("Journal Entry", doc[0]).cancel()
+ 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 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)
-
- 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),
+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)
+ )
+ )
- 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()
+ if payment_name:
+ ple_update_query = ple_update_query.where(ple.voucher_no == payment_name)
+ ple_update_query.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", [])
-
- frappe.db.sql(
- """delete from `tab{0} Advance` where parent = %s""".format(ref_doc.doctype), ref_doc.name
- )
+ adv_type = qb.DocType(f"{ref_doc.doctype} Advance")
+ qb.from_(adv_type).delete().where(adv_type.parent == ref_doc.name).run()
-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 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: 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)
@@ -784,19 +868,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)))
diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js
index dae993a2834..03afcb94429 100644
--- a/erpnext/assets/doctype/asset_repair/asset_repair.js
+++ b/erpnext/assets/doctype/asset_repair/asset_repair.js
@@ -40,16 +40,6 @@ frappe.ui.form.on('Asset Repair', {
}
}
});
-
- let sbb_field = frm.get_docfield('stock_items', 'serial_and_batch_bundle');
- if (sbb_field) {
- sbb_field.get_route_options_for_new_doc = (row) => {
- return {
- 'item_code': row.doc.item_code,
- 'voucher_type': frm.doc.doctype,
- }
- };
- }
},
refresh: function(frm) {
@@ -61,6 +51,16 @@ frappe.ui.form.on('Asset Repair', {
frappe.set_route("query-report", "General Ledger");
});
}
+
+ let sbb_field = frm.get_docfield('stock_items', 'serial_and_batch_bundle');
+ if (sbb_field) {
+ sbb_field.get_route_options_for_new_doc = (row) => {
+ return {
+ 'item_code': row.doc.item_code,
+ 'voucher_type': frm.doc.doctype,
+ }
+ };
+ }
},
repair_status: (frm) => {
diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json
index a07af7124e5..19972ca1fa7 100644
--- a/erpnext/buying/doctype/supplier/supplier.json
+++ b/erpnext/buying/doctype/supplier/supplier.json
@@ -45,12 +45,13 @@
"column_break1",
"contact_html",
"primary_address_and_contact_detail_section",
- "supplier_primary_contact",
- "mobile_no",
- "email_id",
"column_break_44",
"supplier_primary_address",
"primary_address",
+ "column_break_mglr",
+ "supplier_primary_contact",
+ "mobile_no",
+ "email_id",
"accounting_tab",
"payment_terms",
"default_accounts_section",
@@ -469,6 +470,10 @@
{
"fieldname": "column_break_1mqv",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_mglr",
+ "fieldtype": "Column Break"
}
],
"icon": "fa fa-user",
@@ -481,7 +486,7 @@
"link_fieldname": "party"
}
],
- "modified": "2023-06-26 14:20:00.961554",
+ "modified": "2023-09-21 12:24:20.398889",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",
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 21241e08603..07187352eb7 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 copy
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/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 0ca1e94427e..e635aa7924c 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -169,7 +169,6 @@ class AccountsController(TransactionBase):
self.validate_value("base_grand_total", ">=", 0)
validate_return(self)
- self.set_total_in_words()
self.validate_all_documents_schedule()
@@ -208,9 +207,42 @@ class AccountsController(TransactionBase):
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 on_trash(self):
# delete references in 'Repost Payment Ledger'
rpi = frappe.qb.DocType("Repost Payment Ledger Items")
@@ -218,6 +250,8 @@ class AccountsController(TransactionBase):
(rpi.voucher_type == self.doctype) & (rpi.voucher_no == self.name)
).run()
+ 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"):
ple = frappe.qb.DocType("Payment Ledger Entry")
@@ -935,7 +969,7 @@ class AccountsController(TransactionBase):
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
)
@@ -2376,6 +2410,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,
diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py
index b1ce539bc3d..a76abe2154b 100644
--- a/erpnext/controllers/buying_controller.py
+++ b/erpnext/controllers/buying_controller.py
@@ -190,10 +190,13 @@ class BuyingController(SubcontractingController):
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(
_(
@@ -415,6 +418,10 @@ class BuyingController(SubcontractingController):
item.bom = None
def set_qty_as_per_stock_uom(self):
+ allow_to_edit_stock_qty = frappe.db.get_single_value(
+ "Stock Settings", "allow_to_edit_stock_uom_qty_for_purchase"
+ )
+
for d in self.get("items"):
if d.meta.get_field("stock_qty"):
# Check if item code is present
@@ -429,6 +436,11 @@ class BuyingController(SubcontractingController):
d.conversion_factor, d.precision("conversion_factor")
)
+ if allow_to_edit_stock_qty:
+ d.stock_qty = flt(d.stock_qty, d.precision("stock_qty"))
+ if d.get("received_stock_qty"):
+ d.received_stock_qty = flt(d.received_stock_qty, d.precision("received_stock_qty"))
+
def validate_purchase_return(self):
for d in self.get("items"):
if self.is_return and flt(d.rejected_qty) != 0:
diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py
index 9771f60ceb4..901466267bb 100644
--- a/erpnext/controllers/selling_controller.py
+++ b/erpnext/controllers/selling_controller.py
@@ -194,11 +194,17 @@ class SellingController(StockController):
frappe.throw(_("Maximum discount for Item {0} is {1}%").format(d.item_code, discount))
def set_qty_as_per_stock_uom(self):
+ allow_to_edit_stock_qty = frappe.db.get_single_value(
+ "Stock Settings", "allow_to_edit_stock_uom_qty_for_sales"
+ )
+
for d in self.get("items"):
if d.meta.get_field("stock_qty"):
if not d.conversion_factor:
frappe.throw(_("Row {0}: Conversion Factor is mandatory").format(d.idx))
d.stock_qty = flt(d.qty) * flt(d.conversion_factor)
+ if allow_to_edit_stock_qty:
+ d.stock_qty = flt(d.stock_qty, d.precision("stock_qty"))
def validate_selling_price(self):
def throw_message(idx, item_name, rate, ref_rate_field):
diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js
deleted file mode 100644
index 7d6b3955cde..00000000000
--- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('LinkedIn Settings', {
- onload: function(frm) {
- if (frm.doc.session_status == 'Expired' && frm.doc.consumer_key && frm.doc.consumer_secret) {
- frappe.confirm(
- __('Session not valid. Do you want to login?'),
- function(){
- frm.trigger("login");
- },
- function(){
- window.close();
- }
- );
- }
- frm.dashboard.set_headline(__("For more information, {0}.", [`${__('click here')}`]));
- },
- refresh: function(frm) {
- if (frm.doc.session_status=="Expired"){
- let msg = __("Session not active. Save document to login.");
- frm.dashboard.set_headline_alert(
- `
-
- ${msg}
-
-
`
- );
- }
-
- if (frm.doc.session_status=="Active"){
- let d = new Date(frm.doc.modified);
- d.setDate(d.getDate()+60);
- let dn = new Date();
- let days = d.getTime() - dn.getTime();
- days = Math.floor(days/(1000 * 3600 * 24));
- let msg,color;
-
- if (days>0){
- msg = __("Your session will be expire in {0} days.", [days]);
- color = "green";
- }
- else {
- msg = __("Session is expired. Save doc to login.");
- color = "red";
- }
-
- frm.dashboard.set_headline_alert(
- `
+ 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?
+ 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 00000000000..f36ed1b9ca6
--- /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 00000000000..732f108ac41
--- /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/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
index 8ac22e6f1bf..19a1c939c3e 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
@@ -77,6 +77,7 @@ frappe.ui.form.on('Subcontracting Receipt', {
}
frm.trigger('setup_quality_inspection');
+ frm.trigger('set_route_options_for_new_doc');
},
set_warehouse: (frm) => {
@@ -87,6 +88,23 @@ frappe.ui.form.on('Subcontracting Receipt', {
set_warehouse_in_children(frm.doc.items, 'rejected_warehouse', frm.doc.rejected_warehouse);
},
+ get_scrap_items: (frm) => {
+ frappe.call({
+ doc: frm.doc,
+ method: 'get_scrap_items',
+ args: {
+ recalculate_rate: true
+ },
+ freeze: true,
+ freeze_message: __('Getting Scrap Items'),
+ callback: (r) => {
+ if (!r.exc) {
+ frm.refresh();
+ }
+ }
+ });
+ },
+
set_queries: (frm) => {
frm.set_query('set_warehouse', () => {
return {
@@ -173,7 +191,28 @@ frappe.ui.form.on('Subcontracting Receipt', {
}
}
});
+ },
+ get_serial_and_batch_bundle_filters: (doc, cdt, cdn) => {
+ let row = locals[cdt][cdn];
+ return {
+ filters: {
+ 'item_code': row.item_code,
+ 'voucher_type': doc.doctype,
+ 'voucher_no': ['in', [doc.name, '']],
+ 'is_cancelled': 0,
+ }
+ }
+ },
+
+ setup_quality_inspection: (frm) => {
+ if (!frm.is_new() && frm.doc.docstatus === 0 && !frm.doc.is_return) {
+ let transaction_controller = new erpnext.TransactionController({ frm: frm });
+ transaction_controller.setup_quality_inspection();
+ }
+ },
+
+ set_route_options_for_new_doc: (frm) => {
let batch_no_field = frm.get_docfield('items', 'batch_no');
if (batch_no_field) {
batch_no_field.get_route_options_for_new_doc = (row) => {
@@ -213,42 +252,6 @@ frappe.ui.form.on('Subcontracting Receipt', {
}
}
},
-
- get_serial_and_batch_bundle_filters: (doc, cdt, cdn) => {
- let row = locals[cdt][cdn];
- return {
- filters: {
- 'item_code': row.item_code,
- 'voucher_type': doc.doctype,
- 'voucher_no': ['in', [doc.name, '']],
- 'is_cancelled': 0,
- }
- }
- },
-
- setup_quality_inspection: (frm) => {
- if (!frm.is_new() && frm.doc.docstatus === 0 && !frm.doc.is_return) {
- let transaction_controller = new erpnext.TransactionController({ frm: frm });
- transaction_controller.setup_quality_inspection();
- }
- },
-
- get_scrap_items: (frm) => {
- frappe.call({
- doc: frm.doc,
- method: 'get_scrap_items',
- args: {
- recalculate_rate: true
- },
- freeze: true,
- freeze_message: __('Getting Scrap Items'),
- callback: (r) => {
- if (!r.exc) {
- frm.refresh();
- }
- }
- });
- },
});
frappe.ui.form.on('Landed Cost Taxes and Charges', {
@@ -303,4 +306,4 @@ let set_missing_values = (frm) => {
if (!r.exc) frm.refresh();
},
});
-}
\ No newline at end of file
+}
diff --git a/erpnext/templates/generators/item/item_add_to_cart.html b/erpnext/templates/generators/item/item_add_to_cart.html
index 1381dfe3b74..9bd3f7514c9 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/pages/wishlist.py b/erpnext/templates/pages/wishlist.py
index d70f27c9d9d..17607e45f91 100644
--- a/erpnext/templates/pages/wishlist.py
+++ b/erpnext/templates/pages/wishlist.py
@@ -25,9 +25,19 @@ def get_context(context):
def get_stock_availability(item_code, warehouse):
- stock_qty = frappe.utils.flt(
- frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
- )
+ from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
+
+ if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1:
+ warehouses = get_child_warehouses(warehouse)
+ else:
+ warehouses = [warehouse] if warehouse else []
+
+ stock_qty = 0.0
+ for warehouse in warehouses:
+ stock_qty += frappe.utils.flt(
+ frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
+ )
+
return bool(stock_qty)
diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv
index 1dddc3c5759..03e9de4c55d 100644
--- a/erpnext/translations/de.csv
+++ b/erpnext/translations/de.csv
@@ -477,7 +477,7 @@ Chapter,Gruppe,
Chapter information.,Gruppeninformation,
Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount,Kosten für den Typ „Tatsächlich“ in Zeile {0} können nicht in den Artikelpreis oder den bezahlen Betrag einfließen,
Chargeble,Belastung,
-Charges are updated in Purchase Receipt against each item,Kosten werden im Kaufbeleg für jede Position aktualisiert,
+Charges are updated in Purchase Receipt against each item,Kosten werden im Eingangsbeleg für jede Position aktualisiert,
"Charges will be distributed proportionately based on item qty or amount, as per your selection",Die Kosten werden gemäß Ihrer Wahl anteilig verteilt basierend auf Artikelmenge oder -preis,
Chart of Cost Centers,Kostenstellenplan,
Check all,Alle prüfen,
@@ -1202,7 +1202,7 @@ Invalid GSTIN! A GSTIN must have 15 characters.,Ungültige GSTIN! Eine GSTIN mus
Invalid GSTIN! First 2 digits of GSTIN should match with State number {0}.,Ungültige GSTIN! Die ersten beiden Ziffern von GSTIN sollten mit der Statusnummer {0} übereinstimmen.,
Invalid GSTIN! The input you've entered doesn't match the format of GSTIN.,Ungültige GSTIN! Die von Ihnen eingegebene Eingabe stimmt nicht mit dem Format von GSTIN überein.,
Invalid Posting Time,Ungültige Buchungszeit,
-Invalid Purchase Invoice,Ungültige Einkaufsrechnung,
+Invalid Purchase Invoice,Ungültige Eingangsrechnung,
Invalid attribute {0} {1},Ungültiges Attribut {0} {1},
Invalid quantity specified for item {0}. Quantity should be greater than 0.,Ungültzige Anzahl für Artikel {0} angegeben. Anzahl sollte größer als 0 sein.,
Invalid reference {0} {1},Ungültige Referenz {0} {1},
@@ -1264,7 +1264,7 @@ Item Variant {0} already exists with same attributes,Artikelvariante {0} mit den
Item Variants,Artikelvarianten,
Item Variants updated,Artikelvarianten aktualisiert,
Item has variants.,Artikel hat Varianten.,
-Item must be added using 'Get Items from Purchase Receipts' button,"Artikel müssen über die Schaltfläche ""Artikel von Kaufbeleg übernehmen"" hinzugefügt werden",
+Item must be added using 'Get Items from Purchase Receipts' button,"Artikel müssen über die Schaltfläche ""Artikel von Eingangsbeleg übernehmen"" hinzugefügt werden",
Item valuation rate is recalculated considering landed cost voucher amount,Artikelpreis wird unter Einbezug von Belegen über den Einstandspreis neu berechnet,
Item variant {0} exists with same attributes,Artikelvariante {0} mit denselben Attributen existiert,
Item {0} does not exist,Artikel {0} existiert nicht,
@@ -1524,7 +1524,7 @@ New Location,Neuer Ort,
New Quality Procedure,Neues Qualitätsverfahren,
New Sales Invoice,Neue Ausgangsrechnung,
New Sales Person Name,Neuer Verkaufspersonenname,
-New Serial No cannot have Warehouse. Warehouse must be set by Stock Entry or Purchase Receipt,"""Neue Seriennummer"" kann keine Lagerangabe enthalten. Lagerangaben müssen durch eine Lagerbuchung oder einen Kaufbeleg erstellt werden",
+New Serial No cannot have Warehouse. Warehouse must be set by Stock Entry or Purchase Receipt,"""Neue Seriennummer"" kann keine Lagerangabe enthalten. Lagerangaben müssen durch eine Lagerbuchung oder einen Eingangsbeleg erstellt werden",
New Warehouse Name,Neuer Lagername,
New credit limit is less than current outstanding amount for the customer. Credit limit has to be atleast {0},Neues Kreditlimit ist weniger als der aktuell ausstehende Betrag für den Kunden. Kreditlimit muss mindestens {0} sein,
New task,Neuer Vorgang,
@@ -1790,7 +1790,7 @@ Please check Multi Currency option to allow accounts with other currency,"Bitte
Please click on 'Generate Schedule',"Bitte auf ""Zeitplan generieren"" klicken",
Please click on 'Generate Schedule' to fetch Serial No added for Item {0},"Bitte auf ""Zeitplan generieren"" klicken, um die Seriennummer für Artikel {0} abzurufen",
Please click on 'Generate Schedule' to get schedule,"Bitte auf ""Zeitplan generieren"" klicken, um den Zeitplan zu erhalten",
-Please create purchase receipt or purchase invoice for the item {0},Bitte erstellen Sie eine Kaufquittung oder eine Kaufrechnung für den Artikel {0},
+Please create purchase receipt or purchase invoice for the item {0},Bitte erstellen Sie eine Kaufquittung oder eine Eingangsrechnungen für den Artikel {0},
Please define grade for Threshold 0%,Bitte definieren Sie Grade for Threshold 0%,
Please enable Applicable on Booking Actual Expenses,Bitte aktivieren Sie Anwendbar bei der Buchung von tatsächlichen Ausgaben,
Please enable Applicable on Purchase Order and Applicable on Booking Actual Expenses,Bitte aktivieren Sie Anwendbar bei Bestellung und Anwendbar bei Buchung von tatsächlichen Ausgaben,
@@ -1811,7 +1811,7 @@ Please enter Maintaince Details first,Bitte zuerst die Einzelheiten zur Wartung
Please enter Planned Qty for Item {0} at row {1},Bitte die geplante Menge für Artikel {0} in Zeile {1} eingeben,
Please enter Preferred Contact Email,Bitte geben Sie Bevorzugte Kontakt per E-Mail,
Please enter Production Item first,Bitte zuerst Herstellungsartikel eingeben,
-Please enter Purchase Receipt first,Bitte zuerst Kaufbeleg eingeben,
+Please enter Purchase Receipt first,Bitte zuerst Eingangsbeleg eingeben,
Please enter Receipt Document,Bitte geben Sie Eingangsbeleg,
Please enter Reference date,Bitte den Stichtag eingeben,
Please enter Reqd by Date,Bitte geben Sie Requd by Date ein,
@@ -2045,8 +2045,8 @@ Purchase Order {0} is not submitted,Bestellung {0} wurde nicht übertragen,
Purchase Orders are not allowed for {0} due to a scorecard standing of {1}.,Kaufaufträge sind für {0} wegen einer Scorecard von {1} nicht erlaubt.,
Purchase Orders given to Suppliers.,An Lieferanten erteilte Bestellungen,
Purchase Price List,Einkaufspreisliste,
-Purchase Receipt,Kaufbeleg,
-Purchase Receipt {0} is not submitted,Kaufbeleg {0} wurde nicht übertragen,
+Purchase Receipt,Eingangsbeleg,
+Purchase Receipt {0} is not submitted,Eingangsbeleg {0} wurde nicht übertragen,
Purchase Tax Template,Umsatzsteuer-Vorlage,
Purchase User,Nutzer Einkauf,
Purchase orders help you plan and follow up on your purchases,Bestellungen helfen Ihnen bei der Planung und Follow-up auf Ihre Einkäufe,
@@ -2108,7 +2108,7 @@ Real Estate,Immobilien,
Reason For Putting On Hold,Grund für das auf Eis legen,
Reason for Hold,Grund für das auf Eis legen,
Reason for hold: ,Grund für das auf Eis legen:,
-Receipt,Kaufbeleg,
+Receipt,Eingangsbeleg,
Receipt document must be submitted,Eingangsbeleg muss vorgelegt werden,
Receivable,Forderung,
Receivable Account,Forderungskonto,
@@ -2571,7 +2571,7 @@ Stock Expenses,Lagerkosten,
Stock In Hand,Stock In Hand,
Stock Items,Lagerartikel,
Stock Ledger,Lagerbuch,
-Stock Ledger Entries and GL Entries are reposted for the selected Purchase Receipts,Buchungen auf das Lagerbuch und Hauptbuch-Buchungen werden für die gewählten Kaufbelege umgebucht,
+Stock Ledger Entries and GL Entries are reposted for the selected Purchase Receipts,Buchungen auf das Lagerbuch und Hauptbuch-Buchungen werden für die gewählten Eingangsbelege umgebucht,
Stock Levels,Lagerbestände,
Stock Liabilities,Lager-Verbindlichkeiten,
Stock Qty,Lagermenge,
@@ -2583,7 +2583,7 @@ Stock UOM,Lagermaßeinheit,
Stock Value,Lagerwert,
Stock balance in Batch {0} will become negative {1} for Item {2} at Warehouse {3},Lagerbestand in Charge {0} wird für Artikel {2} im Lager {3} negativ {1},
Stock cannot be updated against Delivery Note {0},Lager kann nicht mit Lieferschein {0} aktualisiert werden,
-Stock cannot be updated against Purchase Receipt {0},Auf nicht gegen Kaufbeleg aktualisiert werden {0},
+Stock cannot be updated against Purchase Receipt {0},Bestand kann nicht gegen Eingangsbeleg {0} aktualisiert werden,
Stock cannot exist for Item {0} since has variants,"Für Artikel {0} kann es kein Lager geben, da es Varianten gibt",
Stock transactions before {0} are frozen,Lagertransaktionen vor {0} werden gesperrt,
Stop,Anhalten,
@@ -2648,7 +2648,7 @@ Supplier Name,Lieferantenname,
Supplier Part No,Lieferant Teile-Nr,
Supplier Quotation,Lieferantenangebot,
Supplier Scorecard,Lieferanten-Scorecard,
-Supplier Warehouse mandatory for sub-contracted Purchase Receipt,Lieferantenlager notwendig für Kaufbeleg aus Unteraufträgen,
+Supplier Warehouse mandatory for sub-contracted Purchase Receipt,Lieferantenlager notwendig für Eingangsbeleg aus Unteraufträgen,
Supplier database.,Lieferantendatenbank,
Supplier {0} not found in {1},Lieferant {0} nicht in {1} gefunden,
Supplier(s),Lieferant(en),
@@ -3356,7 +3356,7 @@ Close,Schließen,
Communication,Kommunikation,
Compact Item Print,Artikel kompakt drucken,
Company,Unternehmen,
-Company of asset {0} and purchase document {1} doesn't matches.,Das Unternehmen von Anlage {0} und Kaufbeleg {1} stimmt nicht überein.,
+Company of asset {0} and purchase document {1} doesn't matches.,Das Unternehmen von Anlage {0} und Eingangsbeleg {1} stimmt nicht überein.,
Compare BOMs for changes in Raw Materials and Operations,Vergleichen Sie Stücklisten auf Änderungen in Rohstoffen und Vorgängen,
Compare List function takes on list arguments,Die Funktion "Liste vergleichen" übernimmt Listenargumente,
Complete,Komplett,
@@ -3616,7 +3616,7 @@ Published Items,Veröffentlichte Artikel,
Purchase Invoice cannot be made against an existing asset {0},Eingangsrechnung kann nicht für ein vorhandenes Asset erstellt werden {0},
Purchase Invoices,Eingangsrechnungen,
Purchase Orders,Kauforder,
-Purchase Receipt doesn't have any Item for which Retain Sample is enabled.,"Der Kaufbeleg enthält keinen Artikel, für den die Option "Probe aufbewahren" aktiviert ist.",
+Purchase Receipt doesn't have any Item for which Retain Sample is enabled.,"Der Eingangsbeleg enthält keinen Artikel, für den die Option "Probe aufbewahren" aktiviert ist.",
Purchase Return,Warenrücksendung,
Qty of Finished Goods Item,Menge des Fertigerzeugnisses,
Quality Inspection required for Item {0} to submit,"Qualitätsprüfung erforderlich, damit Artikel {0} eingereicht werden kann",
@@ -3925,7 +3925,7 @@ Assets not created for {0}. You will have to create asset manually.,Assets nicht
{0} {1} has accounting entries in currency {2} for company {3}. Please select a receivable or payable account with currency {2}.,{0} {1} hat Buchhaltungseinträge in Währung {2} für Firma {3}. Bitte wählen Sie ein Debitoren- oder Kreditorenkonto mit der Währung {2} aus.,
Invalid Account,Ungültiger Account,
Purchase Order Required,Bestellung erforderlich,
-Purchase Receipt Required,Kaufbeleg notwendig,
+Purchase Receipt Required,Eingangsbeleg notwendig,
Account Missing,Konto fehlt,
Requested,Angefordert,
Partially Paid,Teilweise bezahlt,
@@ -4521,7 +4521,7 @@ POS Customer Group,POS Kundengruppe,
POS Field,POS-Feld,
POS Item Group,POS Artikelgruppe,
Company Address,Anschrift des Unternehmens,
-Update Stock,Lagerbestand aktualisieren,
+Update Stock,Lagerbestand aktualisieren,
Ignore Pricing Rule,Preisregel ignorieren,
Applicable for Users,Anwendbar für Benutzer,
Sales Invoice Payment,Ausgangsrechnung-Zahlungen,
@@ -4691,7 +4691,7 @@ Allow Zero Valuation Rate,Nullbewertung zulassen,
Item Tax Rate,Artikelsteuersatz,
Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges,Die Tabelle Steuerdetails wird aus dem Artikelstamm als Zeichenfolge entnommen und in diesem Feld gespeichert. Wird verwendet für Steuern und Abgaben,
Purchase Order Item,Bestellartikel,
-Purchase Receipt Detail,Kaufbelegdetail,
+Purchase Receipt Detail,Eingangsbelegposition,
Item Weight Details,Artikel Gewicht Details,
Weight Per Unit,Gewicht pro Einheit,
Total Weight,Gesamtgewicht,
@@ -4716,7 +4716,6 @@ ACC-SINV-.YYYY.-,ACC-SINV-.JJJJ.-,
Include Payment (POS),(POS) Zahlung einschließen,
Offline POS Name,Offline-Verkaufsstellen-Name,
Is Return (Credit Note),ist Rücklieferung (Gutschrift),
-Return Against Sales Invoice,Zurück zur Kundenrechnung,
Update Billed Amount in Sales Order,Aktualisierung des Rechnungsbetrags im Auftrag,
Customer PO Details,Auftragsdetails,
Customer's Purchase Order,Bestellung des Kunden,
@@ -4993,7 +4992,7 @@ Comprehensive Insurance,Vollkaskoversicherung,
Maintenance Required,Wartung erforderlich,
Check if Asset requires Preventive Maintenance or Calibration,"Überprüfen Sie, ob der Vermögenswert eine vorbeugende Wartung oder Kalibrierung erfordert",
Booked Fixed Asset,Gebuchtes Anlagevermögen,
-Purchase Receipt Amount,Kaufbelegbetrag,
+Purchase Receipt Amount,Betrag Eingangsbeleg,
Default Finance Book,Standardfinanzbuch,
Quality Manager,Qualitätsmanager,
Asset Category Name,Name Vermögenswertkategorie,
@@ -5113,7 +5112,7 @@ BOM Detail No,Stückliste Detailnr.,
Stock Uom,Lagermaßeinheit,
Raw Material Item Code,Rohmaterial-Artikelnummer,
Supplied Qty,Gelieferte Anzahl,
-Purchase Receipt Item Supplied,Kaufbeleg-Artikel geliefert,
+Purchase Receipt Item Supplied,Eingangsbeleg-Artikel geliefert,
Current Stock,Aktueller Lagerbestand,
PUR-RFQ-.YYYY.-,PUR-RFQ-.YYYY.-,
For individual supplier,Für einzelne Anbieter,
@@ -5133,7 +5132,7 @@ Is Transporter,Ist Transporter,
Represents Company,Repräsentiert das Unternehmen,
Supplier Type,Lieferantentyp,
Allow Purchase Invoice Creation Without Purchase Order,Erstellen von Eingangsrechnung ohne Bestellung zulassen,
-Allow Purchase Invoice Creation Without Purchase Receipt,Erstellen von Eingangsrechnung ohne Kaufbeleg ohne Kaufbeleg zulassen,
+Allow Purchase Invoice Creation Without Purchase Receipt,Erstellen von Eingangsrechnung ohne Eingangsbeleg zulassen,
Warn RFQs,Warnung Ausschreibungen,
Warn POs,Warnen Sie POs,
Prevent RFQs,Vermeidung von Ausschreibungen,
@@ -6692,8 +6691,8 @@ Default Company Bank Account,Standard-Bankkonto des Unternehmens,
From Lead,Aus Lead,
Account Manager,Kundenberater,
Accounts Manager,Buchhalter,
-Allow Sales Invoice Creation Without Sales Order,Ermöglichen Sie die Erstellung von Kundenrechnungen ohne Auftrag,
-Allow Sales Invoice Creation Without Delivery Note,Ermöglichen Sie die Erstellung einer Ausgangsrechnung ohne Lieferschein,
+Allow Sales Invoice Creation Without Sales Order,Ermöglichen Sie die Erstellung von Ausgangsrechnungen ohne Auftrag,
+Allow Sales Invoice Creation Without Delivery Note,Ermöglichen Sie die Erstellung von Ausgangsrechnungen ohne Lieferschein,
Default Price List,Standardpreisliste,
Primary Address and Contact,Hauptadresse und -kontakt,
"Select, to make the customer searchable with these fields","Wählen Sie, um den Kunden mit diesen Feldern durchsuchbar zu machen",
@@ -7200,13 +7199,13 @@ Landed Cost Item,Einstandspreis-Artikel,
Receipt Document Type,Receipt Dokumenttyp,
Receipt Document,Eingangsbeleg,
Applicable Charges,Anfallende Gebühren,
-Purchase Receipt Item,Kaufbeleg-Artikel,
-Landed Cost Purchase Receipt,Einstandspreis-Kaufbeleg,
+Purchase Receipt Item,Eingangsbeleg-Artikel,
+Landed Cost Purchase Receipt,Einstandspreis-Eingangsbeleg,
Landed Cost Taxes and Charges,Einstandspreis Steuern und Gebühren,
Landed Cost Voucher,Beleg über Einstandskosten,
-Purchase Receipts,Kaufbelege,
-Purchase Receipt Items,Kaufbeleg-Artikel,
-Get Items From Purchase Receipts,Artikel vom Kaufbeleg übernehmen,
+Purchase Receipts,Eingangsbelege,
+Purchase Receipt Items,Eingangsbeleg-Artikel,
+Get Items From Purchase Receipts,Artikel vom Eingangsbeleg übernehmen,
Distribute Charges Based On,Kosten auf folgender Grundlage verteilen,
Landed Cost Help,Hilfe zum Einstandpreis,
Manufacturers used in Items,Hersteller im Artikel verwendet,
@@ -7254,7 +7253,7 @@ Price List Country,Preisliste Land,
MAT-PRE-.YYYY.-,MAT-PRE-.JJJJ.-,
Supplier Delivery Note,Lieferschein Nr.,
Time at which materials were received,"Zeitpunkt, zu dem Materialien empfangen wurden",
-Return Against Purchase Receipt,Zurück zum Kaufbeleg,
+Return Against Purchase Receipt,Zurück zum Eingangsbeleg,
Rate at which supplier's currency is converted to company's base currency,"Kurs, zu dem die Währung des Lieferanten in die Basiswährung des Unternehmens umgerechnet wird",
Sets 'Accepted Warehouse' in each row of the items table.,Legt 'Akzeptiertes Lager' in jeder Zeile der Artikeltabelle fest.,
Sets 'Rejected Warehouse' in each row of the items table.,Legt 'Abgelehntes Lager' in jeder Zeile der Artikeltabelle fest.,
@@ -7294,7 +7293,7 @@ Quality Inspection Template Name,Name der Qualitätsinspektionsvorlage,
Quick Stock Balance,Schneller Lagerbestand,
Available Quantity,verfügbare Anzahl,
Distinct unit of an Item,Eindeutige Einheit eines Artikels,
-Warehouse can only be changed via Stock Entry / Delivery Note / Purchase Receipt,Lager kann nur über Lagerbuchung / Lieferschein / Kaufbeleg geändert werden,
+Warehouse can only be changed via Stock Entry / Delivery Note / Purchase Receipt,Lager kann nur über Lagerbuchung / Lieferschein / Eingangsbeleg geändert werden,
Purchase / Manufacture Details,Einzelheiten zu Kauf / Herstellung,
Creation Document Type,Belegerstellungs-Typ,
Creation Document No,Belegerstellungs-Nr.,
@@ -7322,7 +7321,7 @@ Repack,Umpacken,
Send to Subcontractor,An Subunternehmer senden,
Delivery Note No,Lieferschein-Nummer,
Sales Invoice No,Ausgangsrechnungs-Nr.,
-Purchase Receipt No,Kaufbeleg Nr.,
+Purchase Receipt No,Eingangsbeleg Nr.,
Inspection Required,Prüfung erforderlich,
From BOM,Von Stückliste,
For Quantity,Für Menge,
@@ -7351,7 +7350,7 @@ Subcontracted Item,Unterauftragsgegenstand,
Against Stock Entry,Gegen Lagerbuchung,
Stock Entry Child,Stock Entry Child,
PO Supplied Item,PO geliefertes Einzelteil,
-Reference Purchase Receipt,Referenz Kaufbeleg,
+Reference Purchase Receipt,Referenz Eingangsbeleg,
Stock Ledger Entry,Buchung im Lagerbuch,
Outgoing Rate,Verkaufspreis,
Actual Qty After Transaction,Tatsächliche Anzahl nach Transaktionen,
@@ -7567,7 +7566,7 @@ Qty to Receive,Anzunehmende Menge,
Received Qty Amount,Erhaltene Menge Menge,
Billed Qty,Rechnungsmenge,
Purchase Order Trends,Entwicklung Bestellungen,
-Purchase Receipt Trends,Trendanalyse Kaufbelege,
+Purchase Receipt Trends,Trendanalyse Eingangsbelege,
Purchase Register,Übersicht über Einkäufe,
Quotation Trends,Trendanalyse Angebote,
Received Items To Be Billed,"Von Lieferanten gelieferte Artikel, die noch abgerechnet werden müssen",
@@ -7804,8 +7803,8 @@ Depreciation Posting Date,Buchungsdatum der Abschreibung,
"By default, the Supplier Name is set as per the Supplier Name entered. If you want Suppliers to be named by a ","Standardmäßig wird der Lieferantenname gemäß dem eingegebenen Lieferantennamen festgelegt. Wenn Sie möchten, dass Lieferanten von a benannt werden",
choose the 'Naming Series' option.,Wählen Sie die Option "Naming Series".,
Configure the default Price List when creating a new Purchase transaction. Item prices will be fetched from this Price List.,Konfigurieren Sie die Standardpreisliste beim Erstellen einer neuen Kauftransaktion. Artikelpreise werden aus dieser Preisliste abgerufen.,
-"If this option is configured 'Yes', ERPNext will prevent you from creating a Purchase Invoice or Receipt without creating a Purchase Order first. This configuration can be overridden for a particular supplier by enabling the 'Allow Purchase Invoice Creation Without Purchase Order' checkbox in the Supplier master.","Wenn diese Option auf 'Ja' gesetzt ist, validiert ERPNext, dass Sie eine Bestellung angelegt haben, bevor Sie eine Eingangsrechnung oder einen Kaufbeleg erfassen können. Diese Konfiguration kann für einzelne Lieferanten überschrieben werden, indem Sie die Option 'Erstellung von Eingangsrechnungen ohne Bestellung zulassen' im Lieferantenstamm aktivieren.",
-"If this option is configured 'Yes', ERPNext will prevent you from creating a Purchase Invoice without creating a Purchase Receipt first. This configuration can be overridden for a particular supplier by enabling the 'Allow Purchase Invoice Creation Without Purchase Receipt' checkbox in the Supplier master.","Wenn diese Option auf 'Ja' gesetzt ist, validiert ERPNext, dass Sie einen Kaufbeleg angelegt haben, bevor Sie eine Eingangsrechnung erfasen können. Diese Konfiguration kann für einzelne Lieferanten überschrieben werden, indem Sie die Option 'Erstellung von Kaufrechnungen ohne Kaufbeleg zulassen' im Lieferantenstamm aktivieren.",
+"If this option is configured 'Yes', ERPNext will prevent you from creating a Purchase Invoice or Receipt without creating a Purchase Order first. This configuration can be overridden for a particular supplier by enabling the 'Allow Purchase Invoice Creation Without Purchase Order' checkbox in the Supplier master.","Wenn diese Option auf 'Ja' gesetzt ist, validiert ERPNext, dass Sie eine Bestellung angelegt haben, bevor Sie eine Eingangsrechnung oder einen Eingangsbeleg erfassen können. Diese Konfiguration kann für einzelne Lieferanten überschrieben werden, indem Sie die Option 'Erstellung von Eingangsrechnungen ohne Bestellung zulassen' im Lieferantenstamm aktivieren.",
+"If this option is configured 'Yes', ERPNext will prevent you from creating a Purchase Invoice without creating a Purchase Receipt first. This configuration can be overridden for a particular supplier by enabling the 'Allow Purchase Invoice Creation Without Purchase Receipt' checkbox in the Supplier master.","Wenn diese Option auf 'Ja' gesetzt ist, validiert ERPNext, dass Sie einen Eingangsbeleg angelegt haben, bevor Sie eine Eingangsrechnung erfassen können. Diese Konfiguration kann für einzelne Lieferanten überschrieben werden, indem Sie die Option 'Erstellung von Kaufrechnungen ohne Eingangsbeleg zulassen' im Lieferantenstamm aktivieren.",
Quantity & Stock,Menge & Lager,
Call Details,Anrufdetails,
Authorised By,Authorisiert von,
@@ -8047,7 +8046,6 @@ Is Non GST ,Ist nicht GST,
Image Description,Bildbeschreibung,
Transfer Status,Übertragungsstatus,
MAT-PR-RET-.YYYY.-,MAT-PR-RET-.YYYY.-,
-Track this Purchase Receipt against any Project,Verfolgen Sie diesen Kaufbeleg für jedes Projekt,
Please Select a Supplier,Bitte wählen Sie einen Lieferanten,
Add to Transit,Zum Transit hinzufügen,
Set Basic Rate Manually,Grundpreis manuell einstellen,
@@ -8472,8 +8470,8 @@ This role is allowed to submit transactions that exceed credit limits,"Diese Rol
Show Inclusive Tax in Print,Inklusive Steuern im Druck anzeigen,
Only select this if you have set up the Cash Flow Mapper documents,"Wählen Sie diese Option nur, wenn Sie die Cash Flow Mapper-Dokumente eingerichtet haben",
Payment Channel,Zahlungskanal,
-Is Purchase Order Required for Purchase Invoice & Receipt Creation?,Ist für die Erstellung von Kaufrechnungen und Quittungen eine Bestellung erforderlich?,
-Is Purchase Receipt Required for Purchase Invoice Creation?,Ist für die Erstellung der Kaufrechnung ein Kaufbeleg erforderlich?,
+Is Purchase Order Required for Purchase Invoice & Receipt Creation?,Ist für die Erstellung von Eingangsrechnungen und Quittungen eine Bestellung erforderlich?,
+Is Purchase Receipt Required for Purchase Invoice Creation?,Ist für die Erstellung der Eingangsrechnungen ein Eingangsbeleg erforderlich?,
Maintain Same Rate Throughout the Purchase Cycle,Behalten Sie den gleichen Preis während des gesamten Kaufzyklus bei,
Allow Item To Be Added Multiple Times in a Transaction,"Zulassen, dass ein Element in einer Transaktion mehrmals hinzugefügt wird",
Suppliers,Lieferanten,
@@ -8531,8 +8529,8 @@ Purchase Order already created for all Sales Order items,Bestellung bereits für
Select Items,Gegenstände auswählen,
Against Default Supplier,Gegen Standardlieferanten,
Auto close Opportunity after the no. of days mentioned above,Gelegenheit zum automatischen Schließen nach der Nr. der oben genannten Tage,
-Is Sales Order Required for Sales Invoice & Delivery Note Creation?,Ist ein Auftrag für die Erstellung von Kundenrechnungen und Lieferscheinen erforderlich?,
-Is Delivery Note Required for Sales Invoice Creation?,Ist für die Erstellung der Ausgangsrechnung ein Lieferschein erforderlich?,
+Is Sales Order Required for Sales Invoice & Delivery Note Creation?,Ist ein Auftrag für die Erstellung von Ausgangsrechnungen und Lieferscheinen erforderlich?,
+Is Delivery Note Required for Sales Invoice Creation?,Ist ein Lieferschein für die Erstellung von Ausgangsrechnungen erforderlich?,
How often should Project and Company be updated based on Sales Transactions?,Wie oft sollten Projekt und Unternehmen basierend auf Verkaufstransaktionen aktualisiert werden?,
Allow User to Edit Price List Rate in Transactions,Benutzer darf Preisliste in Transaktionen bearbeiten,
Allow Item to Be Added Multiple Times in a Transaction,"Zulassen, dass ein Element in einer Transaktion mehrmals hinzugefügt wird",
@@ -8547,7 +8545,7 @@ Set Qty in Transactions Based on Serial No Input,Setze die Anzahl in der Transak
Raise Material Request When Stock Reaches Re-order Level,"Erhöhen Sie die Materialanforderung, wenn der Lagerbestand die Nachbestellmenge erreicht",
Notify by Email on Creation of Automatic Material Request,Benachrichtigen Sie per E-Mail über die Erstellung einer automatischen Materialanforderung,
Allow Material Transfer from Delivery Note to Sales Invoice,Materialübertragung vom Lieferschein zur Ausgangsrechnung zulassen,
-Allow Material Transfer from Purchase Receipt to Purchase Invoice,Materialübertragung vom Kaufbeleg zur Eingangsrechnung zulassen,
+Allow Material Transfer from Purchase Receipt to Purchase Invoice,Materialübertragung vom Eingangsbeleg zur Eingangsrechnung zulassen,
Freeze Stocks Older Than (Days),Aktien einfrieren älter als (Tage),
Role Allowed to Edit Frozen Stock,Rolle darf eingefrorenes Material bearbeiten,
The unallocated amount of Payment Entry {0} is greater than the Bank Transaction's unallocated amount,Der nicht zugewiesene Betrag der Zahlungseingabe {0} ist größer als der nicht zugewiesene Betrag der Banküberweisung,
@@ -8672,20 +8670,17 @@ Please set default Cash or Bank account in Mode of Payment {},Bitte setzen Sie d
Please set default Cash or Bank account in Mode of Payments {},Bitte setzen Sie das Standard-Bargeld- oder Bankkonto im Zahlungsmodus {},
Please ensure {} account is a Balance Sheet account. You can change the parent account to a Balance Sheet account or select a different account.,"Bitte stellen Sie sicher, dass das Konto {} ein Bilanzkonto ist. Sie können das übergeordnete Konto in ein Bilanzkonto ändern oder ein anderes Konto auswählen.",
Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.,"Bitte stellen Sie sicher, dass das Konto {} ein zahlbares Konto ist. Ändern Sie den Kontotyp in "Verbindlichkeiten" oder wählen Sie ein anderes Konto aus.",
-Row {}: Expense Head changed to {} ,Zeile {}: Ausgabenkopf geändert in {},
-because account {} is not linked to warehouse {} ,weil das Konto {} nicht mit dem Lager {} verknüpft ist,
-or it is not the default inventory account,oder es ist nicht das Standard-Inventarkonto,
-Expense Head Changed,Ausgabenkopf geändert,
-because expense is booked against this account in Purchase Receipt {},weil die Kosten für dieses Konto im Kaufbeleg {} gebucht werden,
-as no Purchase Receipt is created against Item {}. ,da für Artikel {} kein Kaufbeleg erstellt wird.,
-This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice,"Dies erfolgt zur Abrechnung von Fällen, in denen der Kaufbeleg nach der Kaufrechnung erstellt wird",
+"Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account","Zeile {0}: Aufwandskonto geändert zu {1}, weil das Konto {2} nicht mit dem Lager {3} verknüpft ist oder es nicht das Standard-Inventarkonto ist",
+Row {0}: Expense Head changed to {1} because expense is booked against this account in Purchase Receipt {2},"Zeile {0}: Aufwandskonto geändert zu {1}, da dieses bereits in Eingangsbeleg {2} verwendet wurde",
+Row {0}: Expense Head changed to {1} as no Purchase Receipt is created against Item {2}.,"Zeile {0}: Aufwandskonto geändert zu {1}, da kein Eingangsbeleg für Artikel {2} erstellt wird.",
+This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice,"Dies erfolgt zur Abrechnung von Fällen, in denen der Eingangsbeleg nach der Eingangsrechnung erstellt wird",
Purchase Order Required for item {},Bestellung erforderlich für Artikel {},
To submit the invoice without purchase order please set {} ,"Um die Rechnung ohne Bestellung einzureichen, setzen Sie bitte {}",
as {} in {},wie in {},
Mandatory Purchase Order,Obligatorische Bestellung,
-Purchase Receipt Required for item {},Kaufbeleg für Artikel {} erforderlich,
-To submit the invoice without purchase receipt please set {} ,"Um die Rechnung ohne Kaufbeleg einzureichen, setzen Sie bitte {}",
-Mandatory Purchase Receipt,Obligatorischer Kaufbeleg,
+Purchase Receipt Required for item {},Eingangsbeleg für Artikel {} erforderlich,
+To submit the invoice without purchase receipt please set {} ,"Um die Rechnung ohne Eingangsbeleg einzureichen, setzen Sie bitte {}",
+Mandatory Purchase Receipt,Obligatorischer Eingangsbeleg,
POS Profile {} does not belongs to company {},Das POS-Profil {} gehört nicht zur Firma {},
User {} is disabled. Please select valid user/cashier,Benutzer {} ist deaktiviert. Bitte wählen Sie einen gültigen Benutzer / Kassierer aus,
Row #{}: Original Invoice {} of return invoice {} is {}. ,Zeile # {}: Die Originalrechnung {} der Rücksenderechnung {} ist {}.,
diff --git a/erpnext/translations/nl.csv b/erpnext/translations/nl.csv
index a1928d53d09..862d5852469 100644
--- a/erpnext/translations/nl.csv
+++ b/erpnext/translations/nl.csv
@@ -2156,7 +2156,7 @@ Report Type is mandatory,Rapport type is verplicht,
Reports,rapporten,
Reqd By Date,Benodigd op datum,
Reqd Qty,Gewenste hoeveelheid,
-Request for Quotation,Offerte,
+Request for Quotation,Offerte-verzoek,
Request for Quotations,Verzoek om offertes,
Request for Raw Materials,Verzoek om grondstoffen,
Request for purchase.,Inkoopaanvraag,
diff --git a/erpnext/utilities/product.py b/erpnext/utilities/product.py
index afe9654e8ea..e967f7061bb 100644
--- a/erpnext/utilities/product.py
+++ b/erpnext/utilities/product.py
@@ -6,6 +6,7 @@ from frappe.utils import cint, flt, fmt_money, getdate, nowdate
from erpnext.accounts.doctype.pricing_rule.pricing_rule import get_pricing_rule_for_item
from erpnext.stock.doctype.batch.batch import get_batch_qty
+from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
def get_web_item_qty_in_stock(item_code, item_warehouse_field, warehouse=None):
@@ -22,23 +23,31 @@ def get_web_item_qty_in_stock(item_code, item_warehouse_field, warehouse=None):
"Website Item", {"item_code": template_item_code}, item_warehouse_field
)
- if warehouse:
- stock_qty = frappe.db.sql(
- """
- select GREATEST(S.actual_qty - S.reserved_qty - S.reserved_qty_for_production - S.reserved_qty_for_sub_contract, 0) / IFNULL(C.conversion_factor, 1)
- from tabBin S
- inner join `tabItem` I on S.item_code = I.Item_code
- left join `tabUOM Conversion Detail` C on I.sales_uom = C.uom and C.parent = I.Item_code
- where S.item_code=%s and S.warehouse=%s""",
- (item_code, warehouse),
- )
+ if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1:
+ warehouses = get_child_warehouses(warehouse)
+ else:
+ warehouses = [warehouse] if warehouse else []
- if stock_qty:
- stock_qty = adjust_qty_for_expired_items(item_code, stock_qty, warehouse)
- in_stock = stock_qty[0][0] > 0 and 1 or 0
+ total_stock = 0.0
+ if warehouses:
+ for warehouse in warehouses:
+ stock_qty = frappe.db.sql(
+ """
+ select GREATEST(S.actual_qty - S.reserved_qty - S.reserved_qty_for_production - S.reserved_qty_for_sub_contract, 0) / IFNULL(C.conversion_factor, 1)
+ from tabBin S
+ inner join `tabItem` I on S.item_code = I.Item_code
+ left join `tabUOM Conversion Detail` C on I.sales_uom = C.uom and C.parent = I.Item_code
+ where S.item_code=%s and S.warehouse=%s""",
+ (item_code, warehouse),
+ )
+
+ if stock_qty:
+ total_stock += adjust_qty_for_expired_items(item_code, stock_qty, warehouse)
+
+ in_stock = total_stock > 0 and 1 or 0
return frappe._dict(
- {"in_stock": in_stock, "stock_qty": stock_qty, "is_stock_item": is_stock_item}
+ {"in_stock": in_stock, "stock_qty": total_stock, "is_stock_item": is_stock_item}
)
@@ -56,7 +65,7 @@ def adjust_qty_for_expired_items(item_code, stock_qty, warehouse):
if not stock_qty[0][0]:
break
- return stock_qty
+ return stock_qty[0][0] if stock_qty else 0
def get_expired_batches(batches):