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( - `
-
- -
-
` - ); - } - - 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( - `
-
- -
-
` - ); - } - }, - login: function(frm) { - if (frm.doc.consumer_key && frm.doc.consumer_secret){ - frappe.dom.freeze(); - frappe.call({ - doc: frm.doc, - method: "get_authorization_url", - callback : function(r) { - window.location.href = r.message; - } - }).fail(function() { - frappe.dom.unfreeze(); - }); - } - }, - after_save: function(frm) { - frm.trigger("login"); - } -}); diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json deleted file mode 100644 index f882e36c32a..00000000000 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "actions": [], - "creation": "2020-01-30 13:36:39.492931", - "doctype": "DocType", - "documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/linkedin-settings", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "account_name", - "column_break_2", - "company_id", - "oauth_details", - "consumer_key", - "column_break_5", - "consumer_secret", - "user_details_section", - "access_token", - "person_urn", - "session_status" - ], - "fields": [ - { - "fieldname": "account_name", - "fieldtype": "Data", - "label": "Account Name", - "read_only": 1 - }, - { - "fieldname": "oauth_details", - "fieldtype": "Section Break", - "label": "OAuth Credentials" - }, - { - "fieldname": "consumer_key", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Consumer Key", - "reqd": 1 - }, - { - "fieldname": "consumer_secret", - "fieldtype": "Password", - "in_list_view": 1, - "label": "Consumer Secret", - "reqd": 1 - }, - { - "fieldname": "access_token", - "fieldtype": "Data", - "hidden": 1, - "label": "Access Token", - "read_only": 1 - }, - { - "fieldname": "person_urn", - "fieldtype": "Data", - "hidden": 1, - "label": "Person URN", - "read_only": 1 - }, - { - "fieldname": "column_break_5", - "fieldtype": "Column Break" - }, - { - "fieldname": "user_details_section", - "fieldtype": "Section Break", - "label": "User Details" - }, - { - "fieldname": "session_status", - "fieldtype": "Select", - "hidden": 1, - "label": "Session Status", - "options": "Expired\nActive", - "read_only": 1 - }, - { - "fieldname": "column_break_2", - "fieldtype": "Column Break" - }, - { - "fieldname": "company_id", - "fieldtype": "Data", - "label": "Company ID", - "reqd": 1 - } - ], - "issingle": 1, - "links": [], - "modified": "2021-02-18 15:19:21.920725", - "modified_by": "Administrator", - "module": "CRM", - "name": "LinkedIn Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py deleted file mode 100644 index 64b3a017b46..00000000000 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py +++ /dev/null @@ -1,208 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -from urllib.parse import urlencode - -import frappe -import requests -from frappe import _ -from frappe.model.document import Document -from frappe.utils import get_url_to_form -from frappe.utils.file_manager import get_file_path - - -class LinkedInSettings(Document): - @frappe.whitelist() - def get_authorization_url(self): - params = urlencode( - { - "response_type": "code", - "client_id": self.consumer_key, - "redirect_uri": "{0}/api/method/erpnext.crm.doctype.linkedin_settings.linkedin_settings.callback?".format( - frappe.utils.get_url() - ), - "scope": "r_emailaddress w_organization_social r_basicprofile r_liteprofile r_organization_social rw_organization_admin w_member_social", - } - ) - - url = "https://www.linkedin.com/oauth/v2/authorization?{}".format(params) - - return url - - def get_access_token(self, code): - url = "https://www.linkedin.com/oauth/v2/accessToken" - body = { - "grant_type": "authorization_code", - "code": code, - "client_id": self.consumer_key, - "client_secret": self.get_password(fieldname="consumer_secret"), - "redirect_uri": "{0}/api/method/erpnext.crm.doctype.linkedin_settings.linkedin_settings.callback?".format( - frappe.utils.get_url() - ), - } - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - response = self.http_post(url=url, data=body, headers=headers) - response = frappe.parse_json(response.content.decode()) - self.db_set("access_token", response["access_token"]) - - def get_member_profile(self): - response = requests.get(url="https://api.linkedin.com/v2/me", headers=self.get_headers()) - response = frappe.parse_json(response.content.decode()) - - frappe.db.set_value( - self.doctype, - self.name, - { - "person_urn": response["id"], - "account_name": response["vanityName"], - "session_status": "Active", - }, - ) - frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = get_url_to_form("LinkedIn Settings", "LinkedIn Settings") - - def post(self, text, title, media=None): - if not media: - return self.post_text(text, title) - else: - media_id = self.upload_image(media) - - if media_id: - return self.post_text(text, title, media_id=media_id) - else: - self.log_error("LinkedIn: Failed to upload media") - - def upload_image(self, media): - media = get_file_path(media) - register_url = "https://api.linkedin.com/v2/assets?action=registerUpload" - body = { - "registerUploadRequest": { - "recipes": ["urn:li:digitalmediaRecipe:feedshare-image"], - "owner": "urn:li:organization:{0}".format(self.company_id), - "serviceRelationships": [ - {"relationshipType": "OWNER", "identifier": "urn:li:userGeneratedContent"} - ], - } - } - headers = self.get_headers() - response = self.http_post(url=register_url, body=body, headers=headers) - - if response.status_code == 200: - response = response.json() - asset = response["value"]["asset"] - upload_url = response["value"]["uploadMechanism"][ - "com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest" - ]["uploadUrl"] - headers["Content-Type"] = "image/jpeg" - response = self.http_post(upload_url, headers=headers, data=open(media, "rb")) - if response.status_code < 200 and response.status_code > 299: - frappe.throw( - _("Error While Uploading Image"), - title="{0} {1}".format(response.status_code, response.reason), - ) - return None - return asset - - return None - - def post_text(self, text, title, media_id=None): - url = "https://api.linkedin.com/v2/shares" - headers = self.get_headers() - headers["X-Restli-Protocol-Version"] = "2.0.0" - headers["Content-Type"] = "application/json; charset=UTF-8" - - body = { - "distribution": {"linkedInDistributionTarget": {}}, - "owner": "urn:li:organization:{0}".format(self.company_id), - "subject": title, - "text": {"text": text}, - } - - reference_url = self.get_reference_url(text) - if reference_url: - body["content"] = {"contentEntities": [{"entityLocation": reference_url}]} - - if media_id: - body["content"] = {"contentEntities": [{"entity": media_id}], "shareMediaCategory": "IMAGE"} - - response = self.http_post(url=url, headers=headers, body=body) - return response - - def http_post(self, url, headers=None, body=None, data=None): - try: - response = requests.post(url=url, json=body, data=data, headers=headers) - if response.status_code not in [201, 200]: - raise - - except Exception as e: - self.api_error(response) - - return response - - def get_headers(self): - return {"Authorization": "Bearer {}".format(self.access_token)} - - def get_reference_url(self, text): - import re - - regex_url = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" - urls = re.findall(regex_url, text) - if urls: - return urls[0] - - def delete_post(self, post_id): - try: - response = requests.delete( - url="https://api.linkedin.com/v2/shares/urn:li:share:{0}".format(post_id), - headers=self.get_headers(), - ) - if response.status_code != 200: - raise - except Exception: - self.api_error(response) - - def get_post(self, post_id): - url = "https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=urn:li:organization:{0}&shares[0]=urn:li:share:{1}".format( - self.company_id, post_id - ) - - try: - response = requests.get(url=url, headers=self.get_headers()) - if response.status_code != 200: - raise - - except Exception: - self.api_error(response) - - response = frappe.parse_json(response.content.decode()) - if len(response.elements): - return response.elements[0] - - return None - - def api_error(self, response): - content = frappe.parse_json(response.content.decode()) - - if response.status_code == 401: - self.db_set("session_status", "Expired") - frappe.db.commit() - frappe.throw(content["message"], title=_("LinkedIn Error - Unauthorized")) - elif response.status_code == 403: - frappe.msgprint(_("You didn't have permission to access this API")) - frappe.throw(content["message"], title=_("LinkedIn Error - Access Denied")) - else: - frappe.throw(response.reason, title=response.status_code) - - -@frappe.whitelist(allow_guest=True) -def callback(code=None, error=None, error_description=None): - if not error: - linkedin_settings = frappe.get_doc("LinkedIn Settings") - linkedin_settings.get_access_token(code) - linkedin_settings.get_member_profile() - frappe.db.commit() - else: - frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = get_url_to_form("LinkedIn Settings", "LinkedIn Settings") diff --git a/erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py deleted file mode 100644 index 09732e405ee..00000000000 --- a/erpnext/crm/doctype/linkedin_settings/test_linkedin_settings.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -import unittest - - -class TestLinkedInSettings(unittest.TestCase): - pass diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.js b/erpnext/crm/doctype/social_media_post/social_media_post.js deleted file mode 100644 index d4ac0bad16c..00000000000 --- a/erpnext/crm/doctype/social_media_post/social_media_post.js +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt -frappe.ui.form.on('Social Media Post', { - validate: function(frm) { - if (frm.doc.twitter === 0 && frm.doc.linkedin === 0) { - frappe.throw(__("Select atleast one Social Media Platform to Share on.")); - } - if (frm.doc.scheduled_time) { - let scheduled_time = new Date(frm.doc.scheduled_time); - let date_time = new Date(); - if (scheduled_time.getTime() < date_time.getTime()) { - frappe.throw(__("Scheduled Time must be a future time.")); - } - } - frm.trigger('validate_tweet_length'); - }, - - text: function(frm) { - if (frm.doc.text) { - frm.set_df_property('text', 'description', `${frm.doc.text.length}/280`); - frm.refresh_field('text'); - frm.trigger('validate_tweet_length'); - } - }, - - validate_tweet_length: function(frm) { - if (frm.doc.text && frm.doc.text.length > 280) { - frappe.throw(__("Tweet length Must be less than 280.")); - } - }, - - onload: function(frm) { - frm.trigger('make_dashboard'); - }, - - make_dashboard: function(frm) { - if (frm.doc.post_status == "Posted") { - frappe.call({ - doc: frm.doc, - method: 'get_post', - freeze: true, - callback: (r) => { - if (!r.message) { - return; - } - - let datasets = [], colors = []; - if (r.message && r.message.twitter) { - colors.push('#1DA1F2'); - datasets.push({ - name: 'Twitter', - values: [r.message.twitter.favorite_count, r.message.twitter.retweet_count] - }); - } - if (r.message && r.message.linkedin) { - colors.push('#0077b5'); - datasets.push({ - name: 'LinkedIn', - values: [r.message.linkedin.totalShareStatistics.likeCount, r.message.linkedin.totalShareStatistics.shareCount] - }); - } - - if (datasets.length) { - frm.dashboard.render_graph({ - data: { - labels: ['Likes', 'Retweets/Shares'], - datasets: datasets - }, - - title: __("Post Metrics"), - type: 'bar', - height: 300, - colors: colors - }); - } - } - }); - } - }, - - refresh: function(frm) { - frm.trigger('text'); - - if (frm.doc.docstatus === 1) { - if (!['Posted', 'Deleted'].includes(frm.doc.post_status)) { - frm.trigger('add_post_btn'); - } - if (frm.doc.post_status !='Deleted') { - frm.add_custom_button(__('Delete Post'), function() { - frappe.confirm(__('Are you sure want to delete the Post from Social Media platforms?'), - function() { - frappe.call({ - doc: frm.doc, - method: 'delete_post', - freeze: true, - callback: () => { - frm.reload_doc(); - } - }); - } - ); - }); - } - - if (frm.doc.post_status !='Deleted') { - let html=''; - if (frm.doc.twitter) { - let color = frm.doc.twitter_post_id ? "green" : "red"; - let status = frm.doc.twitter_post_id ? "Posted" : "Not Posted"; - html += `
- Twitter : ${status} -
` ; - } - if (frm.doc.linkedin) { - let color = frm.doc.linkedin_post_id ? "green" : "red"; - let status = frm.doc.linkedin_post_id ? "Posted" : "Not Posted"; - html += `
- LinkedIn : ${status} -
` ; - } - html = `
${html}
`; - frm.dashboard.set_headline_alert(html); - } - } - }, - - add_post_btn: function(frm) { - frm.add_custom_button(__('Post Now'), function() { - frappe.call({ - doc: frm.doc, - method: 'post', - freeze: true, - callback: function() { - frm.reload_doc(); - } - }); - }); - } -}); diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.json b/erpnext/crm/doctype/social_media_post/social_media_post.json deleted file mode 100644 index 98e78f949e8..00000000000 --- a/erpnext/crm/doctype/social_media_post/social_media_post.json +++ /dev/null @@ -1,208 +0,0 @@ -{ - "actions": [], - "autoname": "format: CRM-SMP-{YYYY}-{MM}-{DD}-{###}", - "creation": "2020-01-30 11:53:13.872864", - "doctype": "DocType", - "documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/social-media-post", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "title", - "campaign_name", - "scheduled_time", - "post_status", - "column_break_6", - "twitter", - "linkedin", - "twitter_post_id", - "linkedin_post_id", - "content", - "text", - "column_break_14", - "tweet_preview", - "linkedin_section", - "linkedin_post", - "column_break_15", - "attachments_section", - "image", - "amended_from" - ], - "fields": [ - { - "fieldname": "text", - "fieldtype": "Small Text", - "label": "Tweet", - "mandatory_depends_on": "eval:doc.twitter ==1" - }, - { - "fieldname": "image", - "fieldtype": "Attach Image", - "label": "Image" - }, - { - "default": "1", - "fieldname": "twitter", - "fieldtype": "Check", - "label": "Twitter" - }, - { - "default": "1", - "fieldname": "linkedin", - "fieldtype": "Check", - "label": "LinkedIn" - }, - { - "fieldname": "amended_from", - "fieldtype": "Link", - "label": "Amended From", - "no_copy": 1, - "options": "Social Media Post", - "print_hide": 1, - "read_only": 1 - }, - { - "depends_on": "eval:doc.twitter ==1", - "fieldname": "content", - "fieldtype": "Section Break", - "label": "Twitter" - }, - { - "allow_on_submit": 1, - "fieldname": "post_status", - "fieldtype": "Select", - "label": "Post Status", - "no_copy": 1, - "options": "\nScheduled\nPosted\nCancelled\nDeleted\nError", - "read_only": 1 - }, - { - "allow_on_submit": 1, - "fieldname": "twitter_post_id", - "fieldtype": "Data", - "hidden": 1, - "label": "Twitter Post Id", - "no_copy": 1, - "read_only": 1 - }, - { - "allow_on_submit": 1, - "fieldname": "linkedin_post_id", - "fieldtype": "Data", - "hidden": 1, - "label": "LinkedIn Post Id", - "no_copy": 1, - "read_only": 1 - }, - { - "fieldname": "campaign_name", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Campaign", - "options": "Campaign" - }, - { - "fieldname": "column_break_6", - "fieldtype": "Column Break", - "label": "Share On" - }, - { - "fieldname": "column_break_14", - "fieldtype": "Column Break" - }, - { - "fieldname": "tweet_preview", - "fieldtype": "HTML" - }, - { - "collapsible": 1, - "depends_on": "eval:doc.linkedin==1", - "fieldname": "linkedin_section", - "fieldtype": "Section Break", - "label": "LinkedIn" - }, - { - "collapsible": 1, - "fieldname": "attachments_section", - "fieldtype": "Section Break", - "label": "Attachments" - }, - { - "fieldname": "linkedin_post", - "fieldtype": "Text", - "label": "Post", - "mandatory_depends_on": "eval:doc.linkedin ==1" - }, - { - "fieldname": "column_break_15", - "fieldtype": "Column Break" - }, - { - "allow_on_submit": 1, - "fieldname": "scheduled_time", - "fieldtype": "Datetime", - "label": "Scheduled Time", - "read_only_depends_on": "eval:doc.post_status == \"Posted\"" - }, - { - "fieldname": "title", - "fieldtype": "Data", - "label": "Title", - "reqd": 1 - } - ], - "is_submittable": 1, - "links": [], - "modified": "2021-04-14 14:24:59.821223", - "modified_by": "Administrator", - "module": "CRM", - "name": "Social Media Post", - "owner": "Administrator", - "permissions": [ - { - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "submit": 1, - "write": 1 - }, - { - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales User", - "share": 1, - "submit": 1, - "write": 1 - }, - { - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales Manager", - "share": 1, - "submit": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "title", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.py b/erpnext/crm/doctype/social_media_post/social_media_post.py deleted file mode 100644 index 3654d29bdc0..00000000000 --- a/erpnext/crm/doctype/social_media_post/social_media_post.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import datetime - -import frappe -from frappe import _ -from frappe.model.document import Document - - -class SocialMediaPost(Document): - def validate(self): - if not self.twitter and not self.linkedin: - frappe.throw(_("Select atleast one Social Media Platform to Share on.")) - - if self.scheduled_time: - current_time = frappe.utils.now_datetime() - scheduled_time = frappe.utils.get_datetime(self.scheduled_time) - if scheduled_time < current_time: - frappe.throw(_("Scheduled Time must be a future time.")) - - if self.text and len(self.text) > 280: - frappe.throw(_("Tweet length must be less than 280.")) - - def submit(self): - if self.scheduled_time: - self.post_status = "Scheduled" - super(SocialMediaPost, self).submit() - - def on_cancel(self): - self.db_set("post_status", "Cancelled") - - @frappe.whitelist() - def delete_post(self): - if self.twitter and self.twitter_post_id: - twitter = frappe.get_doc("Twitter Settings") - twitter.delete_tweet(self.twitter_post_id) - - if self.linkedin and self.linkedin_post_id: - linkedin = frappe.get_doc("LinkedIn Settings") - linkedin.delete_post(self.linkedin_post_id) - - self.db_set("post_status", "Deleted") - - @frappe.whitelist() - def get_post(self): - response = {} - if self.linkedin and self.linkedin_post_id: - linkedin = frappe.get_doc("LinkedIn Settings") - response["linkedin"] = linkedin.get_post(self.linkedin_post_id) - if self.twitter and self.twitter_post_id: - twitter = frappe.get_doc("Twitter Settings") - response["twitter"] = twitter.get_tweet(self.twitter_post_id) - - return response - - @frappe.whitelist() - def post(self): - try: - if self.twitter and not self.twitter_post_id: - twitter = frappe.get_doc("Twitter Settings") - twitter_post = twitter.post(self.text, self.image) - self.db_set("twitter_post_id", twitter_post.id) - if self.linkedin and not self.linkedin_post_id: - linkedin = frappe.get_doc("LinkedIn Settings") - linkedin_post = linkedin.post(self.linkedin_post, self.title, self.image) - self.db_set("linkedin_post_id", linkedin_post.headers["X-RestLi-Id"]) - self.db_set("post_status", "Posted") - - except Exception: - self.db_set("post_status", "Error") - self.log_error("Social posting failed") - - -def process_scheduled_social_media_posts(): - posts = frappe.get_all( - "Social Media Post", - filters={"post_status": "Scheduled", "docstatus": 1}, - fields=["name", "scheduled_time"], - ) - start = frappe.utils.now_datetime() - end = start + datetime.timedelta(minutes=10) - for post in posts: - if post.scheduled_time: - post_time = frappe.utils.get_datetime(post.scheduled_time) - if post_time > start and post_time <= end: - sm_post = frappe.get_doc("Social Media Post", post.name) - sm_post.post() diff --git a/erpnext/crm/doctype/social_media_post/social_media_post_list.js b/erpnext/crm/doctype/social_media_post/social_media_post_list.js deleted file mode 100644 index a8c8272ad08..00000000000 --- a/erpnext/crm/doctype/social_media_post/social_media_post_list.js +++ /dev/null @@ -1,11 +0,0 @@ -frappe.listview_settings['Social Media Post'] = { - add_fields: ["status", "post_status"], - get_indicator: function(doc) { - return [__(doc.post_status), { - "Scheduled": "orange", - "Posted": "green", - "Error": "red", - "Deleted": "red" - }[doc.post_status]]; - } -} diff --git a/erpnext/crm/doctype/social_media_post/test_social_media_post.py b/erpnext/crm/doctype/social_media_post/test_social_media_post.py deleted file mode 100644 index 75744767dca..00000000000 --- a/erpnext/crm/doctype/social_media_post/test_social_media_post.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -import unittest - - -class TestSocialMediaPost(unittest.TestCase): - pass diff --git a/erpnext/crm/doctype/twitter_settings/test_twitter_settings.py b/erpnext/crm/doctype/twitter_settings/test_twitter_settings.py deleted file mode 100644 index 9dbce8f8aba..00000000000 --- a/erpnext/crm/doctype/twitter_settings/test_twitter_settings.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -import unittest - - -class TestTwitterSettings(unittest.TestCase): - pass diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.js b/erpnext/crm/doctype/twitter_settings/twitter_settings.js deleted file mode 100644 index c322092d6f3..00000000000 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.js +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Twitter 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) { - let msg, color, flag=false; - if (frm.doc.session_status == "Active") { - msg = __("Session Active"); - color = 'green'; - flag = true; - } - else if(frm.doc.consumer_key && frm.doc.consumer_secret) { - msg = __("Session Not Active. Save doc to login."); - color = 'red'; - flag = true; - } - - if (flag) { - frm.dashboard.set_headline_alert( - `
-
- -
-
` - ); - } - }, - login: function(frm) { - if (frm.doc.consumer_key && frm.doc.consumer_secret){ - frappe.dom.freeze(); - frappe.call({ - doc: frm.doc, - method: "get_authorize_url", - callback : function(r) { - window.location.href = r.message; - } - }).fail(function() { - frappe.dom.unfreeze(); - }); - } - }, - after_save: function(frm) { - frm.trigger("login"); - } -}); diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.json b/erpnext/crm/doctype/twitter_settings/twitter_settings.json deleted file mode 100644 index 8d05877f060..00000000000 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "actions": [], - "creation": "2020-01-30 10:29:08.562108", - "doctype": "DocType", - "documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/twitter-settings", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "account_name", - "profile_pic", - "oauth_details", - "consumer_key", - "column_break_5", - "consumer_secret", - "access_token", - "access_token_secret", - "session_status" - ], - "fields": [ - { - "fieldname": "account_name", - "fieldtype": "Data", - "label": "Account Name", - "read_only": 1 - }, - { - "fieldname": "oauth_details", - "fieldtype": "Section Break", - "label": "OAuth Credentials" - }, - { - "fieldname": "consumer_key", - "fieldtype": "Data", - "in_list_view": 1, - "label": "API Key", - "reqd": 1 - }, - { - "fieldname": "consumer_secret", - "fieldtype": "Password", - "in_list_view": 1, - "label": "API Secret Key", - "reqd": 1 - }, - { - "fieldname": "column_break_5", - "fieldtype": "Column Break" - }, - { - "fieldname": "profile_pic", - "fieldtype": "Attach Image", - "hidden": 1, - "read_only": 1 - }, - { - "fieldname": "session_status", - "fieldtype": "Select", - "hidden": 1, - "label": "Session Status", - "options": "Expired\nActive", - "read_only": 1 - }, - { - "fieldname": "access_token", - "fieldtype": "Data", - "hidden": 1, - "label": "Access Token", - "read_only": 1 - }, - { - "fieldname": "access_token_secret", - "fieldtype": "Data", - "hidden": 1, - "label": "Access Token Secret", - "read_only": 1 - } - ], - "image_field": "profile_pic", - "issingle": 1, - "links": [], - "modified": "2021-02-18 15:18:07.900031", - "modified_by": "Administrator", - "module": "CRM", - "name": "Twitter Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/crm/doctype/twitter_settings/twitter_settings.py b/erpnext/crm/doctype/twitter_settings/twitter_settings.py deleted file mode 100644 index 442aa77a5ff..00000000000 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.py +++ /dev/null @@ -1,141 +0,0 @@ -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import json - -import frappe -import tweepy -from frappe import _ -from frappe.model.document import Document -from frappe.utils import get_url_to_form -from frappe.utils.file_manager import get_file_path - - -class TwitterSettings(Document): - @frappe.whitelist() - def get_authorize_url(self): - callback_url = ( - "{0}/api/method/erpnext.crm.doctype.twitter_settings.twitter_settings.callback?".format( - frappe.utils.get_url() - ) - ) - auth = tweepy.OAuth1UserHandler( - self.consumer_key, self.get_password(fieldname="consumer_secret"), callback_url - ) - try: - redirect_url = auth.get_authorization_url() - return redirect_url - except (tweepy.TweepyException, tweepy.HTTPException) as e: - frappe.msgprint(_("Error! Failed to get request token.")) - frappe.throw( - _("Invalid {0} or {1}").format(frappe.bold("Consumer Key"), frappe.bold("Consumer Secret Key")) - ) - - def get_access_token(self, oauth_token, oauth_verifier): - auth = tweepy.OAuth1UserHandler( - self.consumer_key, self.get_password(fieldname="consumer_secret") - ) - auth.request_token = {"oauth_token": oauth_token, "oauth_token_secret": oauth_verifier} - - try: - auth.get_access_token(oauth_verifier) - self.access_token = auth.access_token - self.access_token_secret = auth.access_token_secret - api = self.get_api() - user = api.me() - profile_pic = (user._json["profile_image_url"]).replace("_normal", "") - - frappe.db.set_value( - self.doctype, - self.name, - { - "access_token": auth.access_token, - "access_token_secret": auth.access_token_secret, - "account_name": user._json["screen_name"], - "profile_pic": profile_pic, - "session_status": "Active", - }, - ) - - frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = get_url_to_form("Twitter Settings", "Twitter Settings") - except (tweepy.TweepyException, tweepy.HTTPException) as e: - frappe.msgprint(_("Error! Failed to get access token.")) - frappe.throw(_("Invalid Consumer Key or Consumer Secret Key")) - - def get_api(self): - # authentication of consumer key and secret - auth = tweepy.OAuth1UserHandler( - self.consumer_key, self.get_password(fieldname="consumer_secret") - ) - # authentication of access token and secret - auth.set_access_token(self.access_token, self.access_token_secret) - - return tweepy.API(auth) - - def post(self, text, media=None): - if not media: - return self.send_tweet(text) - - if media: - media_id = self.upload_image(media) - return self.send_tweet(text, media_id) - - def upload_image(self, media): - media = get_file_path(media) - api = self.get_api() - media = api.media_upload(media) - - return media.media_id - - def send_tweet(self, text, media_id=None): - api = self.get_api() - try: - if media_id: - response = api.update_status(status=text, media_ids=[media_id]) - else: - response = api.update_status(status=text) - - return response - - except (tweepy.TweepyException, tweepy.HTTPException) as e: - self.api_error(e) - - def delete_tweet(self, tweet_id): - api = self.get_api() - try: - api.destroy_status(tweet_id) - except (tweepy.TweepyException, tweepy.HTTPException) as e: - self.api_error(e) - - def get_tweet(self, tweet_id): - api = self.get_api() - try: - response = api.get_status(tweet_id, trim_user=True, include_entities=True) - except (tweepy.TweepyException, tweepy.HTTPException) as e: - self.api_error(e) - - return response._json - - def api_error(self, e): - content = json.loads(e.response.content) - content = content["errors"][0] - if e.response.status_code == 401: - self.db_set("session_status", "Expired") - frappe.db.commit() - frappe.throw( - content["message"], - title=_("Twitter Error {0} : {1}").format(e.response.status_code, e.response.reason), - ) - - -@frappe.whitelist(allow_guest=True) -def callback(oauth_token=None, oauth_verifier=None): - if oauth_token and oauth_verifier: - twitter_settings = frappe.get_single("Twitter Settings") - twitter_settings.get_access_token(oauth_token, oauth_verifier) - frappe.db.commit() - else: - frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = get_url_to_form("Twitter Settings", "Twitter Settings") diff --git a/erpnext/crm/workspace/crm/crm.json b/erpnext/crm/workspace/crm/crm.json index b107df76f8f..4b5b9af714b 100644 --- a/erpnext/crm/workspace/crm/crm.json +++ b/erpnext/crm/workspace/crm/crm.json @@ -122,131 +122,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Campaign", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Campaign", - "link_count": 0, - "link_to": "Campaign", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Email Campaign", - "link_count": 0, - "link_to": "Email Campaign", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Social Media Post", - "link_count": 0, - "link_to": "Social Media Post", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "SMS Center", - "link_count": 0, - "link_to": "SMS Center", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "SMS Log", - "link_count": 0, - "link_to": "SMS Log", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Email Group", - "link_count": 0, - "link_to": "Email Group", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Settings", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "CRM Settings", - "link_count": 0, - "link_to": "CRM Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "SMS Settings", - "link_count": 0, - "link_to": "SMS Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Twitter Settings", - "link_count": 0, - "link_to": "Twitter Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "LinkedIn Settings", - "link_count": 0, - "link_to": "LinkedIn Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, { "hidden": 0, "is_query_report": 0, @@ -450,9 +325,101 @@ "link_type": "DocType", "onboard": 0, "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "link_count": 2, + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "CRM Settings", + "link_count": 0, + "link_to": "CRM Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Settings", + "link_count": 0, + "link_to": "SMS Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Campaign", + "link_count": 5, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Campaign", + "link_count": 0, + "link_to": "Campaign", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Campaign", + "link_count": 0, + "link_to": "Email Campaign", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Center", + "link_count": 0, + "link_to": "SMS Center", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Log", + "link_count": 0, + "link_to": "SMS Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Group", + "link_count": 0, + "link_to": "Email Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" } ], - "modified": "2023-05-26 16:49:04.298122", + "modified": "2023-09-14 12:11:03.968048", "modified_by": "Administrator", "module": "CRM", "name": "CRM", @@ -463,7 +430,7 @@ "quick_lists": [], "restrict_to_domain": "", "roles": [], - "sequence_id": 10.0, + "sequence_id": 17.0, "shortcuts": [ { "color": "Blue", diff --git a/erpnext/e_commerce/doctype/website_item/test_website_item.py b/erpnext/e_commerce/doctype/website_item/test_website_item.py index 43b2f675716..2ba84c05007 100644 --- a/erpnext/e_commerce/doctype/website_item/test_website_item.py +++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py @@ -312,7 +312,7 @@ class TestWebsiteItem(unittest.TestCase): # check if stock details are fetched and item not in stock with warehouse set data = get_product_info_for_website(item_code, skip_quotation_creation=True) self.assertFalse(bool(data.product_info["in_stock"])) - self.assertEqual(data.product_info["stock_qty"][0][0], 0) + self.assertEqual(data.product_info["stock_qty"], 0) # disable show stock availability setup_e_commerce_settings({"show_stock_availability": 0}) @@ -355,7 +355,7 @@ class TestWebsiteItem(unittest.TestCase): # check if stock details are fetched and item is in stock with warehouse set data = get_product_info_for_website(item_code, skip_quotation_creation=True) self.assertTrue(bool(data.product_info["in_stock"])) - self.assertEqual(data.product_info["stock_qty"][0][0], 2) + self.assertEqual(data.product_info["stock_qty"], 2) # unset warehouse frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "") @@ -364,7 +364,7 @@ class TestWebsiteItem(unittest.TestCase): # (even though it has stock in some warehouse) data = get_product_info_for_website(item_code, skip_quotation_creation=True) self.assertFalse(bool(data.product_info["in_stock"])) - self.assertFalse(bool(data.product_info["stock_qty"])) + self.assertFalse(data.product_info["stock_qty"]) # disable show stock availability setup_e_commerce_settings({"show_stock_availability": 0}) diff --git a/erpnext/e_commerce/doctype/website_item/website_item.js b/erpnext/e_commerce/doctype/website_item/website_item.js index 7b7193e833a..b6595cce8a9 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.js +++ b/erpnext/e_commerce/doctype/website_item/website_item.js @@ -5,12 +5,6 @@ frappe.ui.form.on('Website Item', { onload: (frm) => { // should never check Private frm.fields_dict["website_image"].df.is_private = 0; - - frm.set_query("website_warehouse", () => { - return { - filters: {"is_group": 0} - }; - }); }, refresh: (frm) => { diff --git a/erpnext/e_commerce/doctype/website_item/website_item.json b/erpnext/e_commerce/doctype/website_item/website_item.json index 6556eabf4ab..6f551a0b42d 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.json +++ b/erpnext/e_commerce/doctype/website_item/website_item.json @@ -135,7 +135,7 @@ "fieldtype": "Column Break" }, { - "description": "Show Stock availability based on this warehouse.", + "description": "Show Stock availability based on this warehouse. If the parent warehouse is selected, then the system will display the consolidated available quantity of all child warehouses.", "fieldname": "website_warehouse", "fieldtype": "Link", "ignore_user_permissions": 1, @@ -348,7 +348,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2022-09-30 04:01:52.090732", + "modified": "2023-09-12 14:19:22.822689", "modified_by": "Administrator", "module": "E-commerce", "name": "Website Item", diff --git a/erpnext/e_commerce/product_data_engine/query.py b/erpnext/e_commerce/product_data_engine/query.py index e6a595a0344..975f87608a6 100644 --- a/erpnext/e_commerce/product_data_engine/query.py +++ b/erpnext/e_commerce/product_data_engine/query.py @@ -259,6 +259,10 @@ class ProductQuery: ) def get_stock_availability(self, item): + from erpnext.templates.pages.wishlist import ( + get_stock_availability as get_stock_availability_from_template, + ) + """Modify item object and add stock details.""" item.in_stock = False warehouse = item.get("website_warehouse") @@ -274,11 +278,7 @@ class ProductQuery: else: item.in_stock = True elif warehouse: - # stock item and has warehouse - actual_qty = frappe.db.get_value( - "Bin", {"item_code": item.item_code, "warehouse": item.get("website_warehouse")}, "actual_qty" - ) - item.in_stock = bool(flt(actual_qty)) + item.in_stock = get_stock_availability_from_template(item.item_code, warehouse) def get_cart_items(self): customer = get_customer(silent=True) diff --git a/erpnext/e_commerce/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py index c66ae1d6009..7c7e169c528 100644 --- a/erpnext/e_commerce/shopping_cart/cart.py +++ b/erpnext/e_commerce/shopping_cart/cart.py @@ -111,8 +111,8 @@ def place_order(): item_stock = get_web_item_qty_in_stock(item.item_code, "website_warehouse") if not cint(item_stock.in_stock): throw(_("{0} Not in Stock").format(item.item_code)) - if item.qty > item_stock.stock_qty[0][0]: - throw(_("Only {0} in Stock for item {1}").format(item_stock.stock_qty[0][0], item.item_code)) + if item.qty > item_stock.stock_qty: + throw(_("Only {0} in Stock for item {1}").format(item_stock.stock_qty, item.item_code)) sales_order.flags.ignore_permissions = True sales_order.insert() @@ -150,6 +150,10 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False): empty_card = True else: + warehouse = frappe.get_cached_value( + "Website Item", {"item_code": item_code}, "website_warehouse" + ) + quotation_items = quotation.get("items", {"item_code": item_code}) if not quotation_items: quotation.append( @@ -159,11 +163,13 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False): "item_code": item_code, "qty": qty, "additional_notes": additional_notes, + "warehouse": warehouse, }, ) else: quotation_items[0].qty = qty quotation_items[0].additional_notes = additional_notes + quotation_items[0].warehouse = warehouse apply_cart_settings(quotation=quotation) @@ -317,6 +323,10 @@ def decorate_quotation_doc(doc): fields = fields[2:] d.update(frappe.db.get_value("Website Item", {"item_code": item_code}, fields, as_dict=True)) + website_warehouse = frappe.get_cached_value( + "Website Item", {"item_code": item_code}, "website_warehouse" + ) + d.warehouse = website_warehouse return doc diff --git a/erpnext/e_commerce/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py index 4466c457436..88356f5e909 100644 --- a/erpnext/e_commerce/variant_selector/utils.py +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -104,6 +104,8 @@ def get_attributes_and_values(item_code): @frappe.whitelist(allow_guest=True) def get_next_attribute_and_values(item_code, selected_attributes): + from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses + """Find the count of Items that match the selected attributes. Also, find the attribute values that are not applicable for further searching. If less than equal to 10 items are found, return item_codes of those items. @@ -168,7 +170,7 @@ def get_next_attribute_and_values(item_code, selected_attributes): product_info = None product_id = "" - website_warehouse = "" + warehouse = "" if exact_match or filtered_items: if exact_match and len(exact_match) == 1: product_id = exact_match[0] @@ -176,16 +178,19 @@ def get_next_attribute_and_values(item_code, selected_attributes): product_id = list(filtered_items)[0] if product_id: - website_warehouse = frappe.get_cached_value( + warehouse = frappe.get_cached_value( "Website Item", {"item_code": product_id}, "website_warehouse" ) available_qty = 0.0 - if website_warehouse: - available_qty = flt( - frappe.db.get_value( - "Bin", {"item_code": product_id, "warehouse": website_warehouse}, "actual_qty" - ) + if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1: + warehouses = get_child_warehouses(warehouse) + else: + warehouses = [warehouse] if warehouse else [] + + for warehouse in warehouses: + available_qty += flt( + frappe.db.get_value("Bin", {"item_code": product_id, "warehouse": warehouse}, "actual_qty") ) return { diff --git a/erpnext/erpnext_integrations/connectors/woocommerce_connection.py b/erpnext/erpnext_integrations/connectors/woocommerce_connection.py deleted file mode 100644 index 2b2da7b971b..00000000000 --- a/erpnext/erpnext_integrations/connectors/woocommerce_connection.py +++ /dev/null @@ -1,256 +0,0 @@ -import base64 -import hashlib -import hmac -import json - -import frappe -from frappe import _ -from frappe.utils import cstr - - -def verify_request(): - woocommerce_settings = frappe.get_doc("Woocommerce Settings") - sig = base64.b64encode( - hmac.new( - woocommerce_settings.secret.encode("utf8"), frappe.request.data, hashlib.sha256 - ).digest() - ) - - if ( - frappe.request.data - and not sig == frappe.get_request_header("X-Wc-Webhook-Signature", "").encode() - ): - frappe.throw(_("Unverified Webhook Data")) - frappe.set_user(woocommerce_settings.creation_user) - - -@frappe.whitelist(allow_guest=True) -def order(*args, **kwargs): - try: - _order(*args, **kwargs) - except Exception: - error_message = ( - frappe.get_traceback() + "\n\n Request Data: \n" + json.loads(frappe.request.data).__str__() - ) - frappe.log_error("WooCommerce Error", error_message) - raise - - -def _order(*args, **kwargs): - woocommerce_settings = frappe.get_doc("Woocommerce Settings") - if frappe.flags.woocomm_test_order_data: - order = frappe.flags.woocomm_test_order_data - event = "created" - # Ignore the test ping issued during WooCommerce webhook configuration - # Ref: https://github.com/woocommerce/woocommerce/issues/15642 - if frappe.request.data.decode("utf-8").startswith("webhook_id="): - return "success" - elif frappe.request and frappe.request.data: - verify_request() - try: - order = json.loads(frappe.request.data) - except ValueError: - # woocommerce returns 'webhook_id=value' for the first request which is not JSON - order = frappe.request.data - event = frappe.get_request_header("X-Wc-Webhook-Event") - - else: - return "success" - - if event == "created": - sys_lang = frappe.get_single("System Settings").language or "en" - raw_billing_data = order.get("billing") - raw_shipping_data = order.get("shipping") - customer_name = raw_billing_data.get("first_name") + " " + raw_billing_data.get("last_name") - link_customer_and_address(raw_billing_data, raw_shipping_data, customer_name) - link_items(order.get("line_items"), woocommerce_settings, sys_lang) - create_sales_order(order, woocommerce_settings, customer_name, sys_lang) - - -def link_customer_and_address(raw_billing_data, raw_shipping_data, customer_name): - customer_woo_com_email = raw_billing_data.get("email") - customer_exists = frappe.get_value("Customer", {"woocommerce_email": customer_woo_com_email}) - if not customer_exists: - # Create Customer - customer = frappe.new_doc("Customer") - else: - # Edit Customer - customer = frappe.get_doc("Customer", {"woocommerce_email": customer_woo_com_email}) - old_name = customer.customer_name - - customer.customer_name = customer_name - customer.woocommerce_email = customer_woo_com_email - customer.flags.ignore_mandatory = True - customer.save() - - if customer_exists: - # Fixes https://github.com/frappe/erpnext/issues/33708 - if old_name != customer_name: - frappe.rename_doc("Customer", old_name, customer_name) - for address_type in ( - "Billing", - "Shipping", - ): - try: - address = frappe.get_doc( - "Address", {"woocommerce_email": customer_woo_com_email, "address_type": address_type} - ) - rename_address(address, customer) - except ( - frappe.DoesNotExistError, - frappe.DuplicateEntryError, - frappe.ValidationError, - ): - pass - else: - create_address(raw_billing_data, customer, "Billing") - create_address(raw_shipping_data, customer, "Shipping") - create_contact(raw_billing_data, customer) - - -def create_contact(data, customer): - email = data.get("email", None) - phone = data.get("phone", None) - - if not email and not phone: - return - - contact = frappe.new_doc("Contact") - contact.first_name = data.get("first_name") - contact.last_name = data.get("last_name") - contact.is_primary_contact = 1 - contact.is_billing_contact = 1 - - if phone: - contact.add_phone(phone, is_primary_mobile_no=1, is_primary_phone=1) - - if email: - contact.add_email(email, is_primary=1) - - contact.append("links", {"link_doctype": "Customer", "link_name": customer.name}) - - contact.flags.ignore_mandatory = True - contact.save() - - -def create_address(raw_data, customer, address_type): - address = frappe.new_doc("Address") - - address.address_line1 = raw_data.get("address_1", "Not Provided") - address.address_line2 = raw_data.get("address_2", "Not Provided") - address.city = raw_data.get("city", "Not Provided") - address.woocommerce_email = customer.woocommerce_email - address.address_type = address_type - address.country = frappe.get_value("Country", {"code": raw_data.get("country", "IN").lower()}) - address.state = raw_data.get("state") - address.pincode = raw_data.get("postcode") - address.phone = raw_data.get("phone") - address.email_id = customer.woocommerce_email - address.append("links", {"link_doctype": "Customer", "link_name": customer.name}) - - address.flags.ignore_mandatory = True - address.save() - - -def rename_address(address, customer): - old_address_title = address.name - new_address_title = customer.name + "-" + address.address_type - address.address_title = customer.customer_name - address.save() - - frappe.rename_doc("Address", old_address_title, new_address_title) - - -def link_items(items_list, woocommerce_settings, sys_lang): - for item_data in items_list: - item_woo_com_id = cstr(item_data.get("product_id")) - - if not frappe.db.get_value("Item", {"woocommerce_id": item_woo_com_id}, "name"): - # Create Item - item = frappe.new_doc("Item") - item.item_code = _("woocommerce - {0}", sys_lang).format(item_woo_com_id) - item.stock_uom = woocommerce_settings.uom or _("Nos", sys_lang) - item.item_group = _("WooCommerce Products", sys_lang) - - item.item_name = item_data.get("name") - item.woocommerce_id = item_woo_com_id - item.flags.ignore_mandatory = True - item.save() - - -def create_sales_order(order, woocommerce_settings, customer_name, sys_lang): - new_sales_order = frappe.new_doc("Sales Order") - new_sales_order.customer = customer_name - - new_sales_order.po_no = new_sales_order.woocommerce_id = order.get("id") - new_sales_order.naming_series = woocommerce_settings.sales_order_series or "SO-WOO-" - - created_date = order.get("date_created").split("T") - new_sales_order.transaction_date = created_date[0] - delivery_after = woocommerce_settings.delivery_after_days or 7 - new_sales_order.delivery_date = frappe.utils.add_days(created_date[0], delivery_after) - - new_sales_order.company = woocommerce_settings.company - - set_items_in_sales_order(new_sales_order, woocommerce_settings, order, sys_lang) - new_sales_order.flags.ignore_mandatory = True - new_sales_order.insert() - new_sales_order.submit() - - frappe.db.commit() - - -def set_items_in_sales_order(new_sales_order, woocommerce_settings, order, sys_lang): - company_abbr = frappe.db.get_value("Company", woocommerce_settings.company, "abbr") - - default_warehouse = _("Stores - {0}", sys_lang).format(company_abbr) - if not frappe.db.exists("Warehouse", default_warehouse) and not woocommerce_settings.warehouse: - frappe.throw(_("Please set Warehouse in Woocommerce Settings")) - - for item in order.get("line_items"): - woocomm_item_id = item.get("product_id") - found_item = frappe.get_doc("Item", {"woocommerce_id": cstr(woocomm_item_id)}) - - ordered_items_tax = item.get("total_tax") - - new_sales_order.append( - "items", - { - "item_code": found_item.name, - "item_name": found_item.item_name, - "description": found_item.item_name, - "delivery_date": new_sales_order.delivery_date, - "uom": woocommerce_settings.uom or _("Nos", sys_lang), - "qty": item.get("quantity"), - "rate": item.get("price"), - "warehouse": woocommerce_settings.warehouse or default_warehouse, - }, - ) - - add_tax_details( - new_sales_order, ordered_items_tax, "Ordered Item tax", woocommerce_settings.tax_account - ) - - # shipping_details = order.get("shipping_lines") # used for detailed order - - add_tax_details( - new_sales_order, order.get("shipping_tax"), "Shipping Tax", woocommerce_settings.f_n_f_account - ) - add_tax_details( - new_sales_order, - order.get("shipping_total"), - "Shipping Total", - woocommerce_settings.f_n_f_account, - ) - - -def add_tax_details(sales_order, price, desc, tax_account_head): - sales_order.append( - "taxes", - { - "charge_type": "Actual", - "account_head": tax_account_head, - "tax_amount": price, - "description": desc, - }, - ) diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/__init__.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/test_woocommerce_settings.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/test_woocommerce_settings.py deleted file mode 100644 index 9945823bf73..00000000000 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/test_woocommerce_settings.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestWoocommerceSettings(unittest.TestCase): - pass diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.js b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.js deleted file mode 100644 index d7a3d36a5f1..00000000000 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.js +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Woocommerce Settings', { - refresh (frm) { - frm.trigger("add_button_generate_secret"); - frm.trigger("check_enabled"); - frm.set_query("tax_account", ()=>{ - return { - "filters": { - "company": frappe.defaults.get_default("company"), - "is_group": 0 - } - }; - }); - }, - - enable_sync (frm) { - frm.trigger("check_enabled"); - }, - - add_button_generate_secret(frm) { - frm.add_custom_button(__('Generate Secret'), () => { - frappe.confirm( - __("Apps using current key won't be able to access, are you sure?"), - () => { - frappe.call({ - type:"POST", - method:"erpnext.erpnext_integrations.doctype.woocommerce_settings.woocommerce_settings.generate_secret", - }).done(() => { - frm.reload_doc(); - }).fail(() => { - frappe.msgprint(__("Could not generate Secret")); - }); - } - ); - }); - }, - - check_enabled (frm) { - frm.set_df_property("woocommerce_server_url", "reqd", frm.doc.enable_sync); - frm.set_df_property("api_consumer_key", "reqd", frm.doc.enable_sync); - frm.set_df_property("api_consumer_secret", "reqd", frm.doc.enable_sync); - } -}); - -frappe.ui.form.on("Woocommerce Settings", "onload", function () { - frappe.call({ - method: "erpnext.erpnext_integrations.doctype.woocommerce_settings.woocommerce_settings.get_series", - callback: function (r) { - $.each(r.message, function (key, value) { - set_field_options(key, value); - }); - } - }); -}); diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.json b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.json deleted file mode 100644 index 956ae09cbd6..00000000000 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "creation": "2018-02-12 15:10:05.495713", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enable_sync", - "sb_00", - "woocommerce_server_url", - "secret", - "cb_00", - "api_consumer_key", - "api_consumer_secret", - "sb_accounting_details", - "tax_account", - "column_break_10", - "f_n_f_account", - "defaults_section", - "creation_user", - "warehouse", - "sales_order_series", - "column_break_14", - "company", - "delivery_after_days", - "uom", - "endpoints", - "endpoint" - ], - "fields": [ - { - "default": "0", - "fieldname": "enable_sync", - "fieldtype": "Check", - "label": "Enable Sync" - }, - { - "fieldname": "sb_00", - "fieldtype": "Section Break" - }, - { - "fieldname": "woocommerce_server_url", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Woocommerce Server URL" - }, - { - "fieldname": "secret", - "fieldtype": "Code", - "label": "Secret", - "read_only": 1 - }, - { - "fieldname": "cb_00", - "fieldtype": "Column Break" - }, - { - "fieldname": "api_consumer_key", - "fieldtype": "Data", - "in_list_view": 1, - "label": "API consumer key" - }, - { - "fieldname": "api_consumer_secret", - "fieldtype": "Data", - "in_list_view": 1, - "label": "API consumer secret" - }, - { - "fieldname": "sb_accounting_details", - "fieldtype": "Section Break", - "label": "Accounting Details" - }, - { - "fieldname": "tax_account", - "fieldtype": "Link", - "label": "Tax Account", - "options": "Account", - "reqd": 1 - }, - { - "fieldname": "column_break_10", - "fieldtype": "Column Break" - }, - { - "fieldname": "f_n_f_account", - "fieldtype": "Link", - "label": "Freight and Forwarding Account", - "options": "Account", - "reqd": 1 - }, - { - "fieldname": "defaults_section", - "fieldtype": "Section Break", - "label": "Defaults" - }, - { - "description": "The user that will be used to create Customers, Items and Sales Orders. This user should have the relevant permissions.", - "fieldname": "creation_user", - "fieldtype": "Link", - "label": "Creation User", - "options": "User", - "reqd": 1 - }, - { - "description": "This warehouse will be used to create Sales Orders. The fallback warehouse is \"Stores\".", - "fieldname": "warehouse", - "fieldtype": "Link", - "label": "Warehouse", - "options": "Warehouse" - }, - { - "fieldname": "column_break_14", - "fieldtype": "Column Break" - }, - { - "description": "The fallback series is \"SO-WOO-\".", - "fieldname": "sales_order_series", - "fieldtype": "Select", - "label": "Sales Order Series" - }, - { - "description": "This is the default UOM used for items and Sales orders. The fallback UOM is \"Nos\".", - "fieldname": "uom", - "fieldtype": "Link", - "label": "UOM", - "options": "UOM" - }, - { - "fieldname": "endpoints", - "fieldtype": "Section Break", - "label": "Endpoints" - }, - { - "fieldname": "endpoint", - "fieldtype": "Code", - "label": "Endpoint", - "read_only": 1 - }, - { - "description": "This company will be used to create Sales Orders.", - "fieldname": "company", - "fieldtype": "Link", - "label": "Company", - "options": "Company", - "reqd": 1 - }, - { - "description": "This is the default offset (days) for the Delivery Date in Sales Orders. The fallback offset is 7 days from the order placement date.", - "fieldname": "delivery_after_days", - "fieldtype": "Int", - "label": "Delivery After (Days)" - } - ], - "issingle": 1, - "modified": "2019-11-04 00:45:21.232096", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "Woocommerce Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py deleted file mode 100644 index 4aa98aab56b..00000000000 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -from urllib.parse import urlparse - -import frappe -from frappe import _ -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields -from frappe.model.document import Document -from frappe.utils.nestedset import get_root_of - - -class WoocommerceSettings(Document): - def validate(self): - self.validate_settings() - self.create_delete_custom_fields() - self.create_webhook_url() - - def create_delete_custom_fields(self): - if self.enable_sync: - create_custom_fields( - { - ("Customer", "Sales Order", "Item", "Address"): dict( - fieldname="woocommerce_id", - label="Woocommerce ID", - fieldtype="Data", - read_only=1, - print_hide=1, - ), - ("Customer", "Address"): dict( - fieldname="woocommerce_email", - label="Woocommerce Email", - fieldtype="Data", - read_only=1, - print_hide=1, - ), - } - ) - - if not frappe.get_value("Item Group", {"name": _("WooCommerce Products")}): - item_group = frappe.new_doc("Item Group") - item_group.item_group_name = _("WooCommerce Products") - item_group.parent_item_group = get_root_of("Item Group") - item_group.insert() - - def validate_settings(self): - if self.enable_sync: - if not self.secret: - self.set("secret", frappe.generate_hash()) - - if not self.woocommerce_server_url: - frappe.throw(_("Please enter Woocommerce Server URL")) - - if not self.api_consumer_key: - frappe.throw(_("Please enter API Consumer Key")) - - if not self.api_consumer_secret: - frappe.throw(_("Please enter API Consumer Secret")) - - def create_webhook_url(self): - endpoint = "/api/method/erpnext.erpnext_integrations.connectors.woocommerce_connection.order" - - try: - url = frappe.request.url - except RuntimeError: - # for CI Test to work - url = "http://localhost:8000" - - server_url = "{uri.scheme}://{uri.netloc}".format(uri=urlparse(url)) - - delivery_url = server_url + endpoint - self.endpoint = delivery_url - - -@frappe.whitelist() -def generate_secret(): - woocommerce_settings = frappe.get_doc("Woocommerce Settings") - woocommerce_settings.secret = frappe.generate_hash() - woocommerce_settings.save() - - -@frappe.whitelist() -def get_series(): - return { - "sales_order_series": frappe.get_meta("Sales Order").get_options("naming_series") or "SO-WOO-", - } diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 41db6b3a725..2155699a4cd 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -423,9 +423,6 @@ scheduler_events = { "erpnext.stock.reorder_item.reorder_item", ], }, - "all": [ - "erpnext.crm.doctype.social_media_post.social_media_post.process_scheduled_social_media_posts", - ], "hourly": [ "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", "erpnext.projects.doctype.project.project.project_status_update_reminder", @@ -433,7 +430,7 @@ scheduler_events = { "erpnext.projects.doctype.project.project.collect_project_status", ], "hourly_long": [ - "erpnext.accounts.doctype.subscription.subscription.process_all", + "erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process", "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries", "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction", ], @@ -558,8 +555,6 @@ get_matching_queries = ( "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_matching_queries" ) -get_matching_vouchers_for_bank_reconciliation = "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_matching_vouchers_for_bank_reconciliation" - get_amounts_not_reflected_in_system_for_bank_reconciliation_statement = "erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_statement.get_amounts_not_reflected_in_system_for_bank_reconciliation_statement" get_payment_entries_for_bank_clearance = ( diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index f1e60948130..66212755b23 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -23,17 +23,6 @@ frappe.ui.form.on('Job Card', { } }); - let sbb_field = frm.get_docfield('serial_and_batch_bundle'); - if (sbb_field) { - sbb_field.get_route_options_for_new_doc = () => { - return { - 'item_code': frm.doc.production_item, - 'warehouse': frm.doc.wip_warehouse, - 'voucher_type': frm.doc.doctype, - } - }; - } - frm.set_indicator_formatter('sub_operation', function(doc) { if (doc.status == "Pending") { @@ -124,6 +113,17 @@ frappe.ui.form.on('Job Card', { } }); } + + let sbb_field = frm.get_docfield('serial_and_batch_bundle'); + if (sbb_field) { + sbb_field.get_route_options_for_new_doc = () => { + return { + 'item_code': frm.doc.production_item, + 'warehouse': frm.doc.wip_warehouse, + 'voucher_type': frm.doc.doctype, + } + }; + } }, setup_quality_inspection: function(frm) { diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index 46c554c1e80..72438ddceea 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -476,6 +476,15 @@ frappe.ui.form.on("Material Request Plan Item", { } }) } + }, + + material_request_type(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + + if (row.from_warehouse && + row.material_request_type !== "Material Transfer") { + frappe.model.set_value(cdt, cdn, 'from_warehouse', ''); + } } }); diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 7bde29f0641..e88b791401b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -40,6 +40,12 @@ class ProductionPlan(Document): self._rename_temporary_references() validate_uom_is_integer(self, "stock_uom", "planned_qty") self.validate_sales_orders() + self.validate_material_request_type() + + def validate_material_request_type(self): + for row in self.get("mr_items"): + if row.from_warehouse and row.material_request_type != "Material Transfer": + row.from_warehouse = "" @frappe.whitelist() def validate_sales_orders(self, sales_order=None): @@ -750,7 +756,9 @@ class ProductionPlan(Document): "items", { "item_code": item.item_code, - "from_warehouse": item.from_warehouse, + "from_warehouse": item.from_warehouse + if material_request_type == "Material Transfer" + else None, "qty": item.quantity, "schedule_date": schedule_date, "warehouse": item.warehouse, diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 6ed750679f0..52925710587 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1098,6 +1098,41 @@ class TestProductionPlan(FrappeTestCase): ) self.assertEqual(reserved_qty_after_mr, before_qty) + def test_from_warehouse_for_purchase_material_request(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + from erpnext.stock.utils import get_or_make_bin + + create_item("RM-TEST-123 For Purchase", valuation_rate=100) + bin_name = get_or_make_bin("RM-TEST-123 For Purchase", "_Test Warehouse - _TC") + t_warehouse = create_warehouse("_Test Store - _TC") + make_stock_entry( + item_code="Raw Material Item 1", + qty=5, + rate=100, + target=t_warehouse, + ) + + plan = create_production_plan(item_code="Test Production Item 1", do_not_save=1) + mr_items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": t_warehouse}] + ) + + for d in mr_items: + plan.append("mr_items", d) + + plan.save() + + for row in plan.mr_items: + if row.material_request_type == "Material Transfer": + self.assertEqual(row.from_warehouse, t_warehouse) + + row.material_request_type = "Purchase" + + plan.save() + + for row in plan.mr_items: + self.assertFalse(row.from_warehouse) + def test_skip_available_qty_for_sub_assembly_items(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom diff --git a/erpnext/patches.txt b/erpnext/patches.txt index b3d6d3e80a4..e9c056e3a9c 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -340,5 +340,9 @@ erpnext.patches.v15_0.remove_exotel_integration erpnext.patches.v14_0.single_to_multi_dunning execute:frappe.db.set_single_value('Selling Settings', 'allow_negative_rates_for_items', 0) erpnext.patches.v15_0.correct_asset_value_if_je_with_workflow +erpnext.patches.v15_0.delete_woocommerce_settings_doctype +erpnext.patches.v14_0.migrate_deferred_accounts_to_item_defaults +erpnext.patches.v14_0.update_invoicing_period_in_subscription +execute:frappe.delete_doc("Page", "welcome-to-erpnext") # below migration patch should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger diff --git a/erpnext/patches/v14_0/delete_education_doctypes.py b/erpnext/patches/v14_0/delete_education_doctypes.py index 56a596a02e7..aeeda7026f8 100644 --- a/erpnext/patches/v14_0/delete_education_doctypes.py +++ b/erpnext/patches/v14_0/delete_education_doctypes.py @@ -47,13 +47,16 @@ def execute(): for doctype in doctypes: frappe.delete_doc("DocType", doctype, ignore_missing=True) - portal_settings = frappe.get_doc("Portal Settings") - - for row in portal_settings.get("menu"): - if row.reference_doctype in doctypes: - row.delete() - - portal_settings.save() + titles = [ + "Fees", + "Student Admission", + "Grant Application", + "Chapter", + "Certification Application", + ] + items = frappe.get_all("Portal Menu Item", filters=[["title", "in", titles]], pluck="name") + for item in items: + frappe.delete_doc("Portal Menu Item", item, ignore_missing=True, force=True) frappe.delete_doc("Module Def", "Education", ignore_missing=True, force=True) diff --git a/erpnext/patches/v14_0/delete_healthcare_doctypes.py b/erpnext/patches/v14_0/delete_healthcare_doctypes.py index 2c699e4a9f2..896a4409507 100644 --- a/erpnext/patches/v14_0/delete_healthcare_doctypes.py +++ b/erpnext/patches/v14_0/delete_healthcare_doctypes.py @@ -41,7 +41,7 @@ def execute(): for card in cards: frappe.delete_doc("Number Card", card, ignore_missing=True, force=True) - titles = ["Lab Test", "Prescription", "Patient Appointment"] + titles = ["Lab Test", "Prescription", "Patient Appointment", "Patient"] items = frappe.get_all("Portal Menu Item", filters=[["title", "in", titles]], pluck="name") for item in items: frappe.delete_doc("Portal Menu Item", item, ignore_missing=True, force=True) diff --git a/erpnext/patches/v14_0/migrate_deferred_accounts_to_item_defaults.py b/erpnext/patches/v14_0/migrate_deferred_accounts_to_item_defaults.py new file mode 100644 index 00000000000..44b830babb2 --- /dev/null +++ b/erpnext/patches/v14_0/migrate_deferred_accounts_to_item_defaults.py @@ -0,0 +1,39 @@ +import frappe + + +def execute(): + try: + item_dict = get_deferred_accounts() + add_to_item_defaults(item_dict) + except Exception: + frappe.db.rollback() + frappe.log_error("Failed to migrate deferred accounts in Item Defaults.") + + +def get_deferred_accounts(): + item = frappe.qb.DocType("Item") + return ( + frappe.qb.from_(item) + .select(item.name, item.deferred_expense_account, item.deferred_revenue_account) + .where((item.enable_deferred_expense == 1) | (item.enable_deferred_revenue == 1)) + .run(as_dict=True) + ) + + +def add_to_item_defaults(item_dict): + for item in item_dict: + add_company_wise_item_default(item, "deferred_expense_account") + add_company_wise_item_default(item, "deferred_revenue_account") + + +def add_company_wise_item_default(item, account_type): + company = frappe.get_cached_value("Account", item[account_type], "company") + if company and item[account_type]: + item_defaults = frappe.get_cached_value("Item", item["name"], "item_defaults") + for item_row in item_defaults: + if item_row.company == company: + frappe.set_value("Item Default", item_row.name, account_type, item[account_type]) + break + else: + item_defaults.append({"company": company, account_type: item[account_type]}) + frappe.set_value("Item", item["name"], "item_defaults", item_defaults) diff --git a/erpnext/patches/v14_0/update_invoicing_period_in_subscription.py b/erpnext/patches/v14_0/update_invoicing_period_in_subscription.py new file mode 100644 index 00000000000..2879e57e1a2 --- /dev/null +++ b/erpnext/patches/v14_0/update_invoicing_period_in_subscription.py @@ -0,0 +1,8 @@ +import frappe + + +def execute(): + subscription = frappe.qb.DocType("Subscription") + frappe.qb.update(subscription).set( + subscription.generate_invoice_at, "Beginning of the currency subscription period" + ).where(subscription.generate_invoice_at_period_start == 1).run() diff --git a/erpnext/patches/v15_0/delete_woocommerce_settings_doctype.py b/erpnext/patches/v15_0/delete_woocommerce_settings_doctype.py new file mode 100644 index 00000000000..fb92ca55d17 --- /dev/null +++ b/erpnext/patches/v15_0/delete_woocommerce_settings_doctype.py @@ -0,0 +1,5 @@ +import frappe + + +def execute(): + frappe.delete_doc("DocType", "Woocommerce Settings", ignore_missing=True) diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index 52fa8ab0f3c..1f47347d46e 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -134,12 +134,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { format_row(row) { return [ - row[1], // Document Type - row[2], // Document Name - row[5] || row[8], // Reference Date - format_currency(row[3], row[9]), // Remaining - row[4], // Reference Number - row[6], // Party + row["doctype"], + row["name"], + row["reference_date"] || row["posting_date"], + format_currency(row["paid_amount"], row["currency"]), + row["reference_no"], + row["party"], ]; } diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 54f0aadb1dd..0860d9c667e 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -9,6 +9,7 @@ erpnext.buying = { erpnext.buying.BuyingController = class BuyingController extends erpnext.TransactionController { setup() { super.setup(); + this.toggle_enable_for_stock_uom("allow_to_edit_stock_uom_qty_for_purchase"); this.frm.email_field = "contact_email"; } diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 80d7b79c1ec..b0a9e405cd5 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -134,15 +134,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } }); - - let sbb_field = this.frm.get_docfield('items', 'serial_and_batch_bundle'); - if (sbb_field) { - sbb_field.get_route_options_for_new_doc = (row) => { - return { - 'item_code': row.doc.item_code, - } - }; - } } if( @@ -207,15 +198,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }); } - let batch_no_field = this.frm.get_docfield("items", "batch_no"); - if (batch_no_field) { - batch_no_field.get_route_options_for_new_doc = function(row) { - return { - "item": row.doc.item_code - } - }; - } - if (this.frm.fields_dict["items"].grid.get_field('blanket_order')) { this.frm.set_query("blanket_order", "items", function(doc, cdt, cdn) { var item = locals[cdt][cdn]; @@ -242,6 +224,14 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } } + + toggle_enable_for_stock_uom(field) { + frappe.db.get_single_value('Stock Settings', field) + .then(value => { + this.frm.fields_dict["items"].grid.toggle_enable("stock_qty", value); + }); + } + onload() { var me = this; @@ -268,6 +258,28 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } ]); } + + if(this.frm.fields_dict['items'].grid.get_field('serial_and_batch_bundle')) { + let sbb_field = this.frm.get_docfield('items', 'serial_and_batch_bundle'); + if (sbb_field) { + sbb_field.get_route_options_for_new_doc = (row) => { + return { + 'item_code': row.doc.item_code, + } + }; + } + } + + if(this.frm.fields_dict['items'].grid.get_field('batch_no')) { + let batch_no_field = this.frm.get_docfield('items', 'batch_no'); + if (batch_no_field) { + batch_no_field.get_route_options_for_new_doc = function(row) { + return { + 'item': row.doc.item_code + } + }; + } + } } is_return() { @@ -358,7 +370,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } refresh() { - erpnext.toggle_naming_series(); erpnext.hide_company(); this.set_dynamic_labels(); @@ -1188,6 +1199,16 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe ]); } + stock_qty(doc, cdt, cdn) { + let item = frappe.get_doc(cdt, cdn); + item.conversion_factor = 1.0; + if (item.stock_qty) { + item.conversion_factor = flt(item.stock_qty) / flt(item.qty); + } + + refresh_field("conversion_factor", item.name, item.parentfield); + } + calculate_stock_uom_rate(doc, cdt, cdn) { let item = frappe.get_doc(cdt, cdn); item.stock_uom_rate = flt(item.rate)/flt(item.conversion_factor); diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 966a9e1f9b3..0e1b23b0eae 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -16,7 +16,8 @@ import "./utils/customer_quick_entry"; import "./utils/supplier_quick_entry"; import "./call_popup/call_popup"; import "./utils/dimension_tree_filter"; -import "./utils/ledger_preview.js" +import "./utils/ledger_preview.js"; +import "./utils/unreconcile.js"; import "./utils/barcode_scanner"; import "./telephony"; import "./templates/call_link.html"; diff --git a/erpnext/public/js/help_links.js b/erpnext/public/js/help_links.js index 1c3f43e9cf4..6742761056a 100644 --- a/erpnext/public/js/help_links.js +++ b/erpnext/public/js/help_links.js @@ -5,7 +5,7 @@ const docsUrl = "https://erpnext.com/docs/"; frappe.help.help_links["Form/Rename Tool"] = [ { label: "Bulk Rename", - url: docsUrl + "user/manual/en/using-erpnext/articles/bulk-rename", + url: docsUrl + "user/manual/en/transactions-bulk-rename", }, ]; @@ -14,201 +14,174 @@ frappe.help.help_links["Form/Rename Tool"] = [ frappe.help.help_links["List/User"] = [ { label: "New User", - url: - docsUrl + - "user/manual/en/setting-up/users-and-permissions/adding-users", + url: docsUrl + "user/manual/en/adding-users", }, { label: "Rename User", - url: docsUrl + "user/manual/en/setting-up/articles/rename-user", + url: docsUrl + "user/manual/en/renaming-documents", }, ]; frappe.help.help_links["permission-manager"] = [ { - label: "Role Permissions Manager", - url: - docsUrl + - "user/manual/en/setting-up/users-and-permissions/role-based-permissions", + label: "Role Based Permissions", + url: docsUrl + "user/manual/en/role-based-permissions", }, { label: "Managing Perm Level in Permissions Manager", - url: docsUrl + "user/manual/en/setting-up/articles/managing-perm-level", + url: docsUrl + "user/manual/en/managing-perm-level", }, { label: "User Permissions", - url: - docsUrl + - "user/manual/en/setting-up/users-and-permissions/user-permissions", + url: docsUrl + "user/manual/en/user-permissions", }, { label: "Sharing", - url: - docsUrl + "user/manual/en/setting-up/users-and-permissions/sharing", + url: docsUrl + "user/manual/en/sharing", }, { label: "Password", - url: docsUrl + "user/manual/en/setting-up/articles/change-password", + url: docsUrl + "user/manual/en/change-password", }, ]; frappe.help.help_links["Form/System Settings"] = [ { label: "System Settings", - url: docsUrl + "user/manual/en/setting-up/settings/system-settings", + url: docsUrl + "user/manual/en/system-settings", }, ]; frappe.help.help_links["Form/Data Import"] = [ { label: "Importing and Exporting Data", - url: docsUrl + "user/manual/en/setting-up/data/data-import", - }, - { - label: "Overwriting Data from Data Import Tool", - url: - docsUrl + - "user/manual/en/setting-up/articles/overwriting-data-from-data-import-tool", + url: docsUrl + "user/manual/en/data-import", }, ]; frappe.help.help_links["List/Data Import"] = [ { label: "Importing and Exporting Data", - url: docsUrl + "user/manual/en/setting-up/data/data-import", - }, - { - label: "Overwriting Data from Data Import Tool", - url: - docsUrl + - "user/manual/en/setting-up/articles/overwriting-data-from-data-import-tool", + url: docsUrl + "user/manual/en/data-import", }, ]; frappe.help.help_links["module_setup"] = [ { label: "Role Permissions Manager", - url: - docsUrl + - "user/manual/en/setting-up/users-and-permissions/role-based-permissions", + url: docsUrl + "user/manual/en/role-based-permissions", }, ]; -frappe.help.help_links["Form/Naming Series"] = [ +frappe.help.help_links["Form/Document Naming Settings"] = [ { label: "Naming Series", - url: docsUrl + "user/manual/en/setting-up/settings/naming-series", - }, - { - label: "Setting the Current Value for Naming Series", - url: - docsUrl + - "user/manual/en/setting-up/articles/naming-series-current-value", + url: docsUrl + "user/manual/en/document-naming-settings", }, ]; frappe.help.help_links["Form/Global Defaults"] = [ { label: "Global Settings", - url: docsUrl + "user/manual/en/setting-up/settings/global-defaults", + url: docsUrl + "user/manual/en/global-defaults", }, ]; frappe.help.help_links["List/Print Heading"] = [ { label: "Print Heading", - url: docsUrl + "user/manual/en/setting-up/print/print-headings", + url: docsUrl + "user/manual/en/print-headings", }, ]; frappe.help.help_links["Form/Print Heading"] = [ { label: "Print Heading", - url: docsUrl + "user/manual/en/setting-up/print/print-headings", + url: docsUrl + "user/manual/en/print-headings", }, ]; frappe.help.help_links["List/Letter Head"] = [ { label: "Letter Head", - url: docsUrl + "user/manual/en/setting-up/print/letter-head", + url: docsUrl + "user/manual/en/letter-head", }, ]; frappe.help.help_links["List/Address Template"] = [ { label: "Address Template", - url: docsUrl + "user/manual/en/setting-up/print/address-template", + url: docsUrl + "user/manual/en/address-template", }, ]; frappe.help.help_links["List/Terms and Conditions"] = [ { label: "Terms and Conditions", - url: docsUrl + "user/manual/en/setting-up/print/terms-and-conditions", + url: docsUrl + "user/manual/en/terms-and-conditions", }, ]; frappe.help.help_links["List/Cheque Print Template"] = [ { label: "Cheque Print Template", - url: docsUrl + "user/manual/en/setting-up/print/cheque-print-template", + url: docsUrl + "user/manual/en/cheque-print-template", }, ]; frappe.help.help_links["List/Email Account"] = [ { label: "Email Account", - url: docsUrl + "user/manual/en/setting-up/email/email-account", + url: docsUrl + "user/manual/en/email-account", }, ]; frappe.help.help_links["List/Notification"] = [ { label: "Notification", - url: docsUrl + "user/manual/en/setting-up/notifications", + url: docsUrl + "user/manual/en/notifications", }, ]; frappe.help.help_links["Form/Notification"] = [ { label: "Notification", - url: docsUrl + "user/manual/en/setting-up/notifications", + url: docsUrl + "user/manual/en/notifications", }, ]; frappe.help.help_links["Form/Email Digest"] = [ { label: "Email Digest", - url: docsUrl + "user/manual/en/setting-up/email/email-digest", + url: docsUrl + "user/manual/en/email-digest", }, ]; frappe.help.help_links["Form/Email Digest"] = [ { label: "Email Digest", - url: docsUrl + "user/manual/en/setting-up/email/email-digest", + url: docsUrl + "user/manual/en/email-digest", }, ]; frappe.help.help_links["List/Auto Email Report"] = [ { label: "Auto Email Reports", - url: docsUrl + "user/manual/en/setting-up/email/auto-email-reports", + url: docsUrl + "user/manual/en/auto-email-reports", }, ]; frappe.help.help_links["Form/Print Settings"] = [ { label: "Print Settings", - url: docsUrl + "user/manual/en/setting-up/print/print-settings", + url: docsUrl + "user/manual/en/print-settings", }, ]; frappe.help.help_links["print-format-builder"] = [ { label: "Print Format Builder", - url: docsUrl + "user/manual/en/setting-up/print/print-format-builder", + url: docsUrl + "user/manual/en/print-format-builder", }, ]; @@ -217,171 +190,160 @@ frappe.help.help_links["print-format-builder"] = [ frappe.help.help_links["Form/PayPal Settings"] = [ { label: "PayPal Settings", - url: - docsUrl + - "user/manual/en/erpnext_integration/paypal-integration", + url: docsUrl + "user/manual/en/paypal-integration", }, ]; frappe.help.help_links["Form/Razorpay Settings"] = [ { label: "Razorpay Settings", - url: - docsUrl + - "user/manual/en/erpnext_integration/razorpay-integration", + url: docsUrl + "user/manual/en/razorpay-integration", }, ]; frappe.help.help_links["Form/Dropbox Settings"] = [ { label: "Dropbox Settings", - url: docsUrl + "user/manual/en/erpnext_integration/dropbox-backup", + url: docsUrl + "user/manual/en/dropbox-backup", }, ]; frappe.help.help_links["Form/LDAP Settings"] = [ { label: "LDAP Settings", - url: - docsUrl + "user/manual/en/erpnext_integration/ldap-integration", + url: docsUrl + "user/manual/en/ldap-integration", }, ]; frappe.help.help_links["Form/Stripe Settings"] = [ { label: "Stripe Settings", - url: - docsUrl + - "user/manual/en/erpnext_integration/stripe-integration", + url: docsUrl + "user/manual/en/stripe-integration", }, ]; //Sales frappe.help.help_links["Form/Quotation"] = [ - { label: "Quotation", url: docsUrl + "user/manual/en/selling/quotation" }, + { label: "Quotation", url: docsUrl + "user/manual/en/quotation" }, { label: "Applying Discount", - url: docsUrl + "user/manual/en/selling/articles/applying-discount", + url: docsUrl + "user/manual/en/applying-discount", }, { label: "Sales Person", - url: - docsUrl + - "user/manual/en/selling/articles/sales-persons-in-the-sales-transactions", + url: docsUrl + "user/manual/en/sales-persons-in-the-sales-transactions", }, { label: "Applying Margin", - url: docsUrl + "user/manual/en/selling/articles/adding-margin", + url: docsUrl + "user/manual/en/adding-margin", }, ]; frappe.help.help_links["List/Customer"] = [ - { label: "Customer", url: docsUrl + "user/manual/en/CRM/customer" }, + { label: "Customer", url: docsUrl + "user/manual/en/customer" }, { label: "Credit Limit", - url: docsUrl + "user/manual/en/accounts/credit-limit", + url: docsUrl + "user/manual/en/credit-limit", }, ]; frappe.help.help_links["Form/Customer"] = [ - { label: "Customer", url: docsUrl + "user/manual/en/CRM/customer" }, + { label: "Customer", url: docsUrl + "user/manual/en/customer" }, { label: "Credit Limit", - url: docsUrl + "user/manual/en/accounts/credit-limit", + url: docsUrl + "user/manual/en/credit-limit", }, ]; frappe.help.help_links["List/Sales Taxes and Charges Template"] = [ { label: "Setting Up Taxes", - url: docsUrl + "user/manual/en/setting-up/setting-up-taxes", + url: docsUrl + "user/manual/en/setting-up-taxes", }, ]; frappe.help.help_links["Form/Sales Taxes and Charges Template"] = [ { label: "Setting Up Taxes", - url: docsUrl + "user/manual/en/setting-up/setting-up-taxes", + url: docsUrl + "user/manual/en/setting-up-taxes", }, ]; frappe.help.help_links["List/Sales Order"] = [ { label: "Sales Order", - url: docsUrl + "user/manual/en/selling/sales-order", + url: docsUrl + "user/manual/en/sales-order", }, { label: "Recurring Sales Order", - url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/auto-repeat", }, { label: "Applying Discount", - url: docsUrl + "user/manual/en/selling/articles/applying-discount", + url: docsUrl + "user/manual/en/applying-discount", }, ]; frappe.help.help_links["Form/Sales Order"] = [ { label: "Sales Order", - url: docsUrl + "user/manual/en/selling/sales-order", + url: docsUrl + "user/manual/en/sales-order", }, { label: "Recurring Sales Order", - url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/auto-repeat", }, { label: "Applying Discount", - url: docsUrl + "user/manual/en/selling/articles/applying-discount", + url: docsUrl + "user/manual/en/applying-discount", }, { label: "Drop Shipping", - url: docsUrl + "user/manual/en/selling/articles/drop-shipping", + url: docsUrl + "user/manual/en/drop-shipping", }, { label: "Sales Person", - url: - docsUrl + - "user/manual/en/selling/articles/sales-persons-in-the-sales-transactions", + url: docsUrl + "user/manual/en/sales-persons-in-the-sales-transactions", }, { label: "Close Sales Order", - url: docsUrl + "user/manual/en/selling/articles/close-sales-order", + url: docsUrl + "user/manual/en/close-sales-order", }, { label: "Applying Margin", - url: docsUrl + "user/manual/en/selling/articles/adding-margin", + url: docsUrl + "user/manual/en/adding-margin", }, ]; frappe.help.help_links["Form/Product Bundle"] = [ { label: "Product Bundle", - url: docsUrl + "user/manual/en/selling/product-bundle", + url: docsUrl + "user/manual/en/product-bundle", }, ]; frappe.help.help_links["Form/Selling Settings"] = [ { label: "Selling Settings", - url: docsUrl + "user/manual/en/selling/selling-settings", + url: docsUrl + "user/manual/en/selling-settings", }, ]; //Buying frappe.help.help_links["List/Supplier"] = [ - { label: "Supplier", url: docsUrl + "user/manual/en/buying/supplier" }, + { label: "Supplier", url: docsUrl + "user/manual/en/supplier" }, ]; frappe.help.help_links["Form/Supplier"] = [ - { label: "Supplier", url: docsUrl + "user/manual/en/buying/supplier" }, + { label: "Supplier", url: docsUrl + "user/manual/en/supplier" }, ]; frappe.help.help_links["Form/Request for Quotation"] = [ { label: "Request for Quotation", - url: docsUrl + "user/manual/en/buying/request-for-quotation", + url: docsUrl + "user/manual/en/request-for-quotation", }, { label: "RFQ Video", @@ -392,113 +354,105 @@ frappe.help.help_links["Form/Request for Quotation"] = [ frappe.help.help_links["Form/Supplier Quotation"] = [ { label: "Supplier Quotation", - url: docsUrl + "user/manual/en/buying/supplier-quotation", + url: docsUrl + "user/manual/en/supplier-quotation", }, ]; frappe.help.help_links["Form/Buying Settings"] = [ { label: "Buying Settings", - url: docsUrl + "user/manual/en/buying/setup/buying-settings", + url: docsUrl + "user/manual/en/buying-settings", }, ]; frappe.help.help_links["List/Purchase Order"] = [ { label: "Purchase Order", - url: docsUrl + "user/manual/en/buying/purchase-order", + url: docsUrl + "user/manual/en/purchase-order", }, { label: "Recurring Purchase Order", - url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/auto-repeat", }, ]; frappe.help.help_links["Form/Purchase Order"] = [ { label: "Purchase Order", - url: docsUrl + "user/manual/en/buying/purchase-order", + url: docsUrl + "user/manual/en/purchase-order", }, { label: "Item UoM", - url: - docsUrl + - "user/manual/en/buying/articles/purchasing-in-different-unit", + url: docsUrl + "user/manual/en/purchasing-in-different-unit", }, { label: "Supplier Item Code", - url: - docsUrl + - "user/manual/en/buying/articles/maintaining-suppliers-part-no-in-item", + url: docsUrl + "user/manual/en/maintaining-suppliers-part-no-in-item", }, { label: "Recurring Purchase Order", - url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/auto-repeat", }, { label: "Subcontracting", - url: docsUrl + "user/manual/en/manufacturing/subcontracting", + url: docsUrl + "user/manual/en/subcontracting", }, ]; frappe.help.help_links["List/Purchase Taxes and Charges Template"] = [ { label: "Setting Up Taxes", - url: docsUrl + "user/manual/en/setting-up/setting-up-taxes", + url: docsUrl + "user/manual/en/setting-up-taxes", }, ]; frappe.help.help_links["List/Price List"] = [ { label: "Price List", - url: docsUrl + "user/manual/en/stock/price-lists", + url: docsUrl + "user/manual/en/price-lists", }, ]; frappe.help.help_links["List/Authorization Rule"] = [ { label: "Authorization Rule", - url: docsUrl + "user/manual/en/customize-erpnext/authorization-rule", + url: docsUrl + "user/manual/en/authorization-rule", }, ]; frappe.help.help_links["Form/SMS Settings"] = [ { label: "SMS Settings", - url: docsUrl + "user/manual/en/setting-up/sms-setting", + url: docsUrl + "user/manual/en/sms-setting", }, ]; frappe.help.help_links["List/Stock Reconciliation"] = [ { label: "Stock Reconciliation", - url: - docsUrl + - "user/manual/en/stock/stock-reconciliation", + url: docsUrl + "user/manual/en/stock-reconciliation", }, ]; frappe.help.help_links["Tree/Territory"] = [ { label: "Territory", - url: docsUrl + "user/manual/en/selling/territory", + url: docsUrl + "user/manual/en/territory", }, ]; frappe.help.help_links["List/Workflow"] = [ - { label: "Workflow", url: docsUrl + "user/manual/en/setting-up/workflows" }, + { label: "Workflow", url: docsUrl + "user/manual/en/workflows" }, ]; frappe.help.help_links["List/Company"] = [ { label: "Company", - url: docsUrl + "user/manual/en/setting-up/company-setup", + url: docsUrl + "user/manual/en/company-setup", }, { label: "Delete All Related Transactions for a Company", - url: - docsUrl + - "user/manual/en/setting-up/articles/delete-a-company-and-all-related-transactions", + url: docsUrl + "user/manual/en/delete_company_transactions", }, ]; @@ -507,116 +461,114 @@ frappe.help.help_links["List/Company"] = [ frappe.help.help_links["Tree/Account"] = [ { label: "Chart of Accounts", - url: docsUrl + "user/manual/en/accounts/chart-of-accounts", + url: docsUrl + "user/manual/en/chart-of-accounts", }, { label: "Managing Tree Mastes", - url: - docsUrl + - "user/manual/en/setting-up/articles/managing-tree-structure-masters", + url: docsUrl + "user/manual/en/managing-tree-structure-masters", }, ]; frappe.help.help_links["Form/Sales Invoice"] = [ { label: "Sales Invoice", - url: docsUrl + "user/manual/en/accounts/sales-invoice", + url: docsUrl + "user/manual/en/sales-invoice", }, { label: "Accounts Opening Balance", - url: docsUrl + "user/manual/en/accounts/opening-balance", + url: docsUrl + "user/manual/en/opening-balance", }, { label: "Sales Return", - url: docsUrl + "user/manual/en/stock/sales-return", + url: docsUrl + "user/manual/en/sales-return", }, { label: "Recurring Sales Invoice", - url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/auto-repeat", }, ]; frappe.help.help_links["List/Sales Invoice"] = [ { label: "Sales Invoice", - url: docsUrl + "user/manual/en/accounts/sales-invoice", + url: docsUrl + "user/manual/en/sales-invoice", }, { label: "Accounts Opening Balance", - url: docsUrl + "user/manual/en/accounts/opening-balances", + url: docsUrl + "user/manual/en/opening-balance", }, { label: "Sales Return", - url: docsUrl + "user/manual/en/stock/sales-return", + url: docsUrl + "user/manual/en/sales-return", }, { label: "Recurring Sales Invoice", - url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/auto-repeat", }, ]; frappe.help.help_links["point-of-sale"] = [ { label: "Point of Sale Invoice", - url: docsUrl + "user/manual/en/accounts/point-of-sales", + url: docsUrl + "user/manual/en/point-of-sales", }, ]; frappe.help.help_links["List/POS Profile"] = [ { label: "Point of Sale Profile", - url: docsUrl + "user/manual/en/accounts/pos-profile", + url: docsUrl + "user/manual/en/pos-profile", }, ]; frappe.help.help_links["Form/POS Profile"] = [ { label: "POS Profile", - url: docsUrl + "user/manual/en/accounts/pos-profile", + url: docsUrl + "user/manual/en/pos-profile", }, ]; frappe.help.help_links["List/Purchase Invoice"] = [ { label: "Purchase Invoice", - url: docsUrl + "user/manual/en/accounts/purchase-invoice", + url: docsUrl + "user/manual/en/purchase-invoice", }, { label: "Accounts Opening Balance", - url: docsUrl + "user/manual/en/accounts/opening-balance", + url: docsUrl + "user/manual/en/opening-balance", }, { label: "Recurring Purchase Invoice", - url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/auto-repeat", }, ]; frappe.help.help_links["List/Journal Entry"] = [ { label: "Journal Entry", - url: docsUrl + "user/manual/en/accounts/journal-entry", + url: docsUrl + "user/manual/en/journal-entry", }, { label: "Advance Payment Entry", - url: docsUrl + "user/manual/en/accounts/advance-payment-entry", + url: docsUrl + "user/manual/en/advance-payment-entry", }, { label: "Accounts Opening Balance", - url: docsUrl + "user/manual/en/accounts/opening-balance", + url: docsUrl + "user/manual/en/opening-balance", }, ]; frappe.help.help_links["List/Payment Entry"] = [ { label: "Payment Entry", - url: docsUrl + "user/manual/en/accounts/payment-entry", + url: docsUrl + "user/manual/en/payment-entry", }, ]; frappe.help.help_links["List/Payment Request"] = [ { label: "Payment Request", - url: docsUrl + "user/manual/en/accounts/payment-request", + url: docsUrl + "user/manual/en/payment-request", }, ]; @@ -630,30 +582,29 @@ frappe.help.help_links["List/Asset"] = [ frappe.help.help_links["List/Asset Category"] = [ { label: "Asset Category", - url: docsUrl + "user/manual/en/asset/asset-category", + url: docsUrl + "user/manual/en/asset-category", }, ]; frappe.help.help_links["Tree/Cost Center"] = [ - { label: "Budgeting", url: docsUrl + "user/manual/en/accounts/budgeting" }, + { label: "Budgeting", url: docsUrl + "user/manual/en/budgeting" }, ]; //Stock frappe.help.help_links["List/Item"] = [ - { label: "Item", url: docsUrl + "user/manual/en/stock/item" }, + { label: "Item", url: docsUrl + "user/manual/en/item" }, { label: "Item Price", - url: docsUrl + "user/manual/en/stock/item-price", + url: docsUrl + "user/manual/en/item-price", }, { label: "Barcode", - url: - docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + url: docsUrl + "user/manual/en/track-items-using-barcode", }, { label: "Item Wise Taxation", - url: docsUrl + "user/manual/en/accounts/item-tax-template", + url: docsUrl + "user/manual/en/item-tax-template", }, { label: "Managing Fixed Assets", @@ -661,34 +612,33 @@ frappe.help.help_links["List/Item"] = [ }, { label: "Item Codification", - url: docsUrl + "user/manual/en/stock/articles/item-codification", + url: docsUrl + "user/manual/en/item-codification", }, { label: "Item Variants", - url: docsUrl + "user/manual/en/stock/item-variants", + url: docsUrl + "user/manual/en/item-variants", }, { label: "Item Valuation", url: docsUrl + - "user/manual/en/stock/articles/calculation-of-valuation-rate-in-fifo-and-moving-average", + "user/manual/en/calculation-of-valuation-rate-in-fifo-and-moving-average", }, ]; frappe.help.help_links["Form/Item"] = [ - { label: "Item", url: docsUrl + "user/manual/en/stock/item" }, + { label: "Item", url: docsUrl + "user/manual/en/item" }, { label: "Item Price", - url: docsUrl + "user/manual/en/stock/item-price", + url: docsUrl + "user/manual/en/item-price", }, { label: "Barcode", - url: - docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + url: docsUrl + "user/manual/en/track-items-using-barcode", }, { label: "Item Wise Taxation", - url: docsUrl + "user/manual/en/accounts/item-tax-template", + url: docsUrl + "user/manual/en/item-tax-template", }, { label: "Managing Fixed Assets", @@ -696,240 +646,226 @@ frappe.help.help_links["Form/Item"] = [ }, { label: "Item Codification", - url: docsUrl + "user/manual/en/stock/articles/item-codification", + url: docsUrl + "user/manual/en/item-codification", }, { label: "Item Variants", - url: docsUrl + "user/manual/en/stock/item-variants", + url: docsUrl + "user/manual/en/item-variants", }, { label: "Item Valuation", - url: - docsUrl + - "user/manual/en/stock/item/item-valuation-fifo-and-moving-average", + url: docsUrl + "user/manual/en/item-valuation-transactions", }, ]; frappe.help.help_links["List/Purchase Receipt"] = [ { label: "Purchase Receipt", - url: docsUrl + "user/manual/en/stock/purchase-receipt", + url: docsUrl + "user/manual/en/purchase-receipt", }, { label: "Barcode", - url: - docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + url: docsUrl + "user/manual/en/track-items-using-barcode", }, ]; frappe.help.help_links["List/Delivery Note"] = [ { label: "Delivery Note", - url: docsUrl + "user/manual/en/stock/delivery-note", + url: docsUrl + "user/manual/en/delivery-note", }, { label: "Barcode", - url: - docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + url: docsUrl + "user/manual/en/track-items-using-barcode", }, { label: "Sales Return", - url: docsUrl + "user/manual/en/stock/sales-return", + url: docsUrl + "user/manual/en/sales-return", }, ]; frappe.help.help_links["Form/Delivery Note"] = [ { label: "Delivery Note", - url: docsUrl + "user/manual/en/stock/delivery-note", + url: docsUrl + "user/manual/en/delivery-note", }, { label: "Sales Return", - url: docsUrl + "user/manual/en/stock/sales-return", + url: docsUrl + "user/manual/en/sales-return", }, { label: "Barcode", - url: - docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + url: docsUrl + "user/manual/en/track-items-using-barcode", }, ]; frappe.help.help_links["List/Installation Note"] = [ { label: "Installation Note", - url: docsUrl + "user/manual/en/stock/installation-note", + url: docsUrl + "user/manual/en/installation-note", }, ]; frappe.help.help_links["List/Budget"] = [ - { label: "Budgeting", url: docsUrl + "user/manual/en/accounts/budgeting" }, + { label: "Budgeting", url: docsUrl + "user/manual/en/budgeting" }, ]; frappe.help.help_links["List/Material Request"] = [ { label: "Material Request", - url: docsUrl + "user/manual/en/stock/material-request", + url: docsUrl + "user/manual/en/material-request", }, { label: "Auto-creation of Material Request", - url: - docsUrl + - "user/manual/en/stock/articles/auto-creation-of-material-request", + url: docsUrl + "user/manual/en/auto-creation-of-material-request", }, ]; frappe.help.help_links["Form/Material Request"] = [ { label: "Material Request", - url: docsUrl + "user/manual/en/stock/material-request", + url: docsUrl + "user/manual/en/material-request", }, { label: "Auto-creation of Material Request", - url: - docsUrl + - "user/manual/en/stock/articles/auto-creation-of-material-request", + url: docsUrl + "user/manual/en/auto-creation-of-material-request", }, ]; frappe.help.help_links["Form/Stock Entry"] = [ - { label: "Stock Entry", url: docsUrl + "user/manual/en/stock/stock-entry" }, + { label: "Stock Entry", url: docsUrl + "user/manual/en/stock-entry" }, { label: "Stock Entry Types", - url: docsUrl + "user/manual/en/stock/articles/stock-entry-purpose", + url: docsUrl + "user/manual/en/stock-entry-purpose", }, { label: "Repack Entry", - url: docsUrl + "user/manual/en/stock/articles/repack-entry", + url: docsUrl + "user/manual/en/repack-entry", }, { label: "Opening Stock", - url: docsUrl + "user/manual/en/stock/opening-stock", + url: docsUrl + "user/manual/en/opening-stock", }, { label: "Subcontracting", - url: docsUrl + "user/manual/en/manufacturing/subcontracting", + url: docsUrl + "user/manual/en/subcontracting", }, ]; frappe.help.help_links["List/Stock Entry"] = [ - { label: "Stock Entry", url: docsUrl + "user/manual/en/stock/stock-entry" }, + { label: "Stock Entry", url: docsUrl + "user/manual/en/stock-entry" }, ]; frappe.help.help_links["Tree/Warehouse"] = [ - { label: "Warehouse", url: docsUrl + "user/manual/en/stock/warehouse" }, + { label: "Warehouse", url: docsUrl + "user/manual/en/warehouse" }, ]; frappe.help.help_links["List/Serial No"] = [ - { label: "Serial No", url: docsUrl + "user/manual/en/stock/serial-no" }, + { label: "Serial No", url: docsUrl + "user/manual/en/serial-no" }, ]; frappe.help.help_links["Form/Serial No"] = [ - { label: "Serial No", url: docsUrl + "user/manual/en/stock/serial-no" }, + { label: "Serial No", url: docsUrl + "user/manual/en/serial-no" }, ]; frappe.help.help_links["List/Batch"] = [ - { label: "Batch", url: docsUrl + "user/manual/en/stock/batch" }, + { label: "Batch", url: docsUrl + "user/manual/en/batch" }, ]; frappe.help.help_links["Form/Batch"] = [ - { label: "Batch", url: docsUrl + "user/manual/en/stock/batch" }, + { label: "Batch", url: docsUrl + "user/manual/en/batch" }, ]; frappe.help.help_links["Form/Packing Slip"] = [ { label: "Packing Slip", - url: docsUrl + "user/manual/en/stock/packing-slip", + url: docsUrl + "user/manual/en/packing-slip", }, ]; frappe.help.help_links["Form/Quality Inspection"] = [ { label: "Quality Inspection", - url: docsUrl + "user/manual/en/stock/quality-inspection", + url: docsUrl + "user/manual/en/quality-inspection", }, ]; frappe.help.help_links["Form/Landed Cost Voucher"] = [ { label: "Landed Cost Voucher", - url: docsUrl + "user/manual/en/stock/landed-cost-voucher", + url: docsUrl + "user/manual/en/landed-cost-voucher", }, ]; frappe.help.help_links["Tree/Item Group"] = [ { label: "Item Group", - url: docsUrl + "user/manual/en/stock/item-group", + url: docsUrl + "user/manual/en/item-group", }, ]; frappe.help.help_links["Form/Item Attribute"] = [ { label: "Item Attribute", - url: docsUrl + "user/manual/en/stock/item-attribute", + url: docsUrl + "user/manual/en/item-attribute", }, ]; frappe.help.help_links["Form/UOM"] = [ { label: "Fractions in UOM", - url: - docsUrl + "user/manual/en/stock/articles/managing-fractions-in-uom", + url: docsUrl + "user/manual/en/managing-fractions-in-uom", }, ]; frappe.help.help_links["Form/Stock Reconciliation"] = [ { label: "Opening Stock Entry", - url: docsUrl + "user/manual/en/stock/stock-reconciliation", + url: docsUrl + "user/manual/en/stock-reconciliation", }, ]; //CRM frappe.help.help_links["Form/Lead"] = [ - { label: "Lead", url: docsUrl + "user/manual/en/CRM/lead" }, + { label: "Lead", url: docsUrl + "user/manual/en/lead" }, ]; frappe.help.help_links["Form/Opportunity"] = [ - { label: "Opportunity", url: docsUrl + "user/manual/en/CRM/opportunity" }, + { label: "Opportunity", url: docsUrl + "user/manual/en/opportunity" }, ]; frappe.help.help_links["Form/Address"] = [ - { label: "Address", url: docsUrl + "user/manual/en/CRM/address" }, + { label: "Address", url: docsUrl + "user/manual/en/address" }, ]; frappe.help.help_links["Form/Contact"] = [ - { label: "Contact", url: docsUrl + "user/manual/en/CRM/contact" }, + { label: "Contact", url: docsUrl + "user/manual/en/contact" }, ]; frappe.help.help_links["Form/Newsletter"] = [ - { label: "Newsletter", url: docsUrl + "user/manual/en/CRM/newsletter" }, + { label: "Newsletter", url: docsUrl + "user/manual/en/newsletter" }, ]; frappe.help.help_links["Form/Campaign"] = [ - { label: "Campaign", url: docsUrl + "user/manual/en/CRM/campaign" }, + { label: "Campaign", url: docsUrl + "user/manual/en/campaign" }, ]; frappe.help.help_links["Tree/Sales Person"] = [ { label: "Sales Person", - url: docsUrl + "user/manual/en/CRM/sales-person", + url: docsUrl + "user/manual/en/sales-person", }, ]; frappe.help.help_links["Form/Sales Person"] = [ { label: "Sales Person Target", - url: - docsUrl + - "user/manual/en/selling/sales-person-target-allocation", + url: docsUrl + "user/manual/en/sales-person-target-allocation", }, { label: "Sales Person in Transactions", - url: - docsUrl + - "user/manual/en/selling/articles/sales-persons-in-the-sales-transactions", + url: docsUrl + "user/manual/en/sales-persons-in-the-sales-transactions", }, ]; @@ -938,41 +874,39 @@ frappe.help.help_links["Form/Sales Person"] = [ frappe.help.help_links["Form/BOM"] = [ { label: "Bill of Material", - url: docsUrl + "user/manual/en/manufacturing/bill-of-materials", + url: docsUrl + "user/manual/en/bill-of-materials", }, { label: "Nested BOM Structure", - url: - docsUrl + - "user/manual/en/manufacturing/articles/managing-multi-level-bom", + url: docsUrl + "user/manual/en/managing-multi-level-bom", }, ]; frappe.help.help_links["Form/Work Order"] = [ { label: "Work Order", - url: docsUrl + "user/manual/en/manufacturing/work-order", + url: docsUrl + "user/manual/en/work-order", }, ]; frappe.help.help_links["Form/Workstation"] = [ { label: "Workstation", - url: docsUrl + "user/manual/en/manufacturing/workstation", + url: docsUrl + "user/manual/en/workstation", }, ]; frappe.help.help_links["Form/Operation"] = [ { label: "Operation", - url: docsUrl + "user/manual/en/manufacturing/operation", + url: docsUrl + "user/manual/en/operation", }, ]; frappe.help.help_links["Form/BOM Update Tool"] = [ { label: "BOM Update Tool", - url: docsUrl + "user/manual/en/manufacturing/bom-update-tool", + url: docsUrl + "user/manual/en/bom-update-tool", }, ]; @@ -981,24 +915,24 @@ frappe.help.help_links["Form/BOM Update Tool"] = [ frappe.help.help_links["Form/Customize Form"] = [ { label: "Custom Field", - url: docsUrl + "user/manual/en/customize-erpnext/custom-field", + url: docsUrl + "user/manual/en/custom-field", }, { label: "Customize Field", - url: docsUrl + "user/manual/en/customize-erpnext/customize-form", + url: docsUrl + "user/manual/en/customize-form", }, ]; frappe.help.help_links["List/Custom Field"] = [ { label: "Custom Field", - url: docsUrl + "user/manual/en/customize-erpnext/custom-field", + url: docsUrl + "user/manual/en/custom-field", }, ]; frappe.help.help_links["Form/Custom Field"] = [ { label: "Custom Field", - url: docsUrl + "user/manual/en/customize-erpnext/custom-field", + url: docsUrl + "user/manual/en/custom-field", }, ]; diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 89750f8446c..d435711cf52 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -769,6 +769,9 @@ erpnext.utils.update_child_items = function(opts) { dialog.show(); } + + + erpnext.utils.map_current_doc = function(opts) { function _map() { if($.isArray(cur_frm.doc.items) && cur_frm.doc.items.length > 0) { @@ -1097,4 +1100,4 @@ function attach_selector_button(inner_text, append_loction, context, grid_row) { $btn.on("click", function() { context.show_serial_batch_selector(grid_row.frm, grid_row.doc, "", "", true); }); -} \ No newline at end of file +} diff --git a/erpnext/public/js/utils/demo.js b/erpnext/public/js/utils/demo.js index 3ebc5efcf13..ef83cf61d44 100644 --- a/erpnext/public/js/utils/demo.js +++ b/erpnext/public/js/utils/demo.js @@ -1,91 +1,34 @@ +frappe.provide("erpnext.demo"); + $(document).on("toolbar_setup", function () { if (frappe.boot.sysdefaults.demo_company) { - render_clear_demo_button(); + render_clear_demo_action(); } - - // for first load after setup. - frappe.realtime.on("demo_data_complete", () => { - render_clear_demo_button(); - }); }); -function render_clear_demo_button() { - let wait_for_onboaring_tours = setInterval(() => { - if ($("#driver-page-overlay").length || $("#show-dialog").length) { - return; - } - setup_clear_demo_button(); - clearInterval(wait_for_onboaring_tours); - }, 2000); -} - -function setup_clear_demo_button() { - let message_string = __( - "Demo data is present on the system, erase data before starting real usage." +function render_clear_demo_action() { + let demo_action = $( + ` + ${__("Clear Demo Data")} + ` ); - let $floatingBar = $(` -
-
-

- ${message_string} -

- - - - - - -
-
- `); - - $("footer").append($floatingBar); - - $("#clear-demo").on("click", function () { - frappe.confirm( - __("Are you sure you want to clear all demo data?"), - () => { - frappe.call({ - method: "erpnext.setup.demo.clear_demo_data", - freeze: true, - freeze_message: __("Clearing Demo Data..."), - callback: function (r) { - frappe.ui.toolbar.clear_cache(); - frappe.show_alert({ - message: __("Demo data cleared"), - indicator: "green", - }); - $("footer").remove($floatingBar); - }, - }); - } - ); - }); - - $("#dismiss-demo-banner").on("click", function () { - $floatingBar.remove(); - }); + demo_action.appendTo($("#toolbar-user")); } + +erpnext.demo.clear_demo = function () { + frappe.confirm(__("Are you sure you want to clear all demo data?"), () => { + frappe.call({ + method: "erpnext.setup.demo.clear_demo_data", + freeze: true, + freeze_message: __("Clearing Demo Data..."), + callback: function (r) { + frappe.ui.toolbar.clear_cache(); + frappe.show_alert({ + message: __("Demo data cleared"), + indicator: "green", + }); + }, + }); + }); +}; diff --git a/erpnext/public/js/utils/sales_common.js b/erpnext/public/js/utils/sales_common.js index 89dcaa64853..1d6daa554bd 100644 --- a/erpnext/public/js/utils/sales_common.js +++ b/erpnext/public/js/utils/sales_common.js @@ -8,6 +8,7 @@ erpnext.sales_common = { erpnext.selling.SellingController = class SellingController extends erpnext.TransactionController { setup() { super.setup(); + this.toggle_enable_for_stock_uom("allow_to_edit_stock_uom_qty_for_sales"); this.frm.email_field = "contact_email"; } diff --git a/erpnext/public/js/utils/unreconcile.js b/erpnext/public/js/utils/unreconcile.js new file mode 100644 index 00000000000..fa00ed23620 --- /dev/null +++ b/erpnext/public/js/utils/unreconcile.js @@ -0,0 +1,127 @@ +frappe.provide('erpnext.accounts'); + +erpnext.accounts.unreconcile_payments = { + add_unreconcile_btn(frm) { + if (frm.doc.docstatus == 1) { + if(((frm.doc.doctype == "Journal Entry") && (frm.doc.voucher_type != "Journal Entry")) + || !["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"].includes(frm.doc.doctype) + ) { + return; + } + + frappe.call({ + "method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.doc_has_references", + "args": { + "doctype": frm.doc.doctype, + "docname": frm.doc.name + }, + callback: function(r) { + if (r.message) { + frm.add_custom_button(__("UnReconcile"), function() { + erpnext.accounts.unreconcile_payments.build_unreconcile_dialog(frm); + }, __('Actions')); + } + } + }); + } + }, + + build_selection_map(frm, selections) { + // assuming each row is an individual voucher + // pass this to server side method that creates unreconcile doc for each row + let selection_map = []; + if (['Sales Invoice', 'Purchase Invoice'].includes(frm.doc.doctype)) { + selection_map = selections.map(function(elem) { + return { + company: elem.company, + voucher_type: elem.voucher_type, + voucher_no: elem.voucher_no, + against_voucher_type: frm.doc.doctype, + against_voucher_no: frm.doc.name + }; + }); + } else if (['Payment Entry', 'Journal Entry'].includes(frm.doc.doctype)) { + selection_map = selections.map(function(elem) { + return { + company: elem.company, + voucher_type: frm.doc.doctype, + voucher_no: frm.doc.name, + against_voucher_type: elem.voucher_type, + against_voucher_no: elem.voucher_no, + }; + }); + } + return selection_map; + }, + + build_unreconcile_dialog(frm) { + if (['Sales Invoice', 'Purchase Invoice', 'Payment Entry', 'Journal Entry'].includes(frm.doc.doctype)) { + let child_table_fields = [ + { label: __("Voucher Type"), fieldname: "voucher_type", fieldtype: "Dynamic Link", options: "DocType", in_list_view: 1, read_only: 1}, + { label: __("Voucher No"), fieldname: "voucher_no", fieldtype: "Link", options: "voucher_type", in_list_view: 1, read_only: 1 }, + { label: __("Allocated Amount"), fieldname: "allocated_amount", fieldtype: "Currency", in_list_view: 1, read_only: 1 , options: "account_currency"}, + { label: __("Currency"), fieldname: "account_currency", fieldtype: "Currency", read_only: 1}, + ] + let unreconcile_dialog_fields = [ + { + label: __('Allocations'), + fieldname: 'allocations', + fieldtype: 'Table', + read_only: 1, + fields: child_table_fields, + }, + ]; + + // get linked payments + frappe.call({ + "method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.get_linked_payments_for_doc", + "args": { + "company": frm.doc.company, + "doctype": frm.doc.doctype, + "docname": frm.doc.name + }, + callback: function(r) { + if (r.message) { + // populate child table with allocations + unreconcile_dialog_fields[0].data = r.message; + unreconcile_dialog_fields[0].get_data = function(){ return r.message}; + + let d = new frappe.ui.Dialog({ + title: 'UnReconcile Allocations', + fields: unreconcile_dialog_fields, + size: 'large', + cannot_add_rows: true, + primary_action_label: 'UnReconcile', + primary_action(values) { + + let selected_allocations = values.allocations.filter(x=>x.__checked); + if (selected_allocations.length > 0) { + let selection_map = erpnext.accounts.unreconcile_payments.build_selection_map(frm, selected_allocations); + erpnext.accounts.unreconcile_payments.create_unreconcile_docs(selection_map); + d.hide(); + + } else { + frappe.msgprint("No Selection"); + } + } + }); + + d.show(); + } + } + }); + } + }, + + create_unreconcile_docs(selection_map) { + frappe.call({ + "method": "erpnext.accounts.doctype.unreconcile_payments.unreconcile_payments.create_unreconcile_doc_for_selection", + "args": { + "selections": selection_map + }, + }); + } + + + +} diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json index be8f62f7156..0f42def0bdf 100644 --- a/erpnext/selling/doctype/customer/customer.json +++ b/erpnext/selling/doctype/customer/customer.json @@ -48,12 +48,13 @@ "column_break1", "contact_html", "primary_address_and_contact_detail", - "customer_primary_contact", - "mobile_no", - "email_id", "column_break_26", "customer_primary_address", "primary_address", + "column_break_nwor", + "customer_primary_contact", + "mobile_no", + "email_id", "tax_tab", "taxation_section", "tax_id", @@ -339,12 +340,12 @@ "label": "Default Accounts" }, { - "description": "Mention if non-standard Receivable account", - "fieldname": "accounts", - "fieldtype": "Table", - "label": "Accounts", - "options": "Party Account" - }, + "description": "Mention if non-standard Receivable account", + "fieldname": "accounts", + "fieldtype": "Table", + "label": "Accounts", + "options": "Party Account" + }, { "fieldname": "credit_limit_section", "fieldtype": "Section Break", @@ -568,6 +569,10 @@ "fieldtype": "Table", "label": "Customer Portal Users", "options": "Portal User" + }, + { + "fieldname": "column_break_nwor", + "fieldtype": "Column Break" } ], "icon": "fa fa-user", @@ -581,7 +586,7 @@ "link_fieldname": "party" } ], - "modified": "2023-06-22 13:21:10.678382", + "modified": "2023-09-21 12:23:20.706020", "modified_by": "Administrator", "module": "Selling", "name": "Customer", diff --git a/erpnext/selling/doctype/installation_note/installation_note.js b/erpnext/selling/doctype/installation_note/installation_note.js index dd6f8a8104e..8128c77f8d4 100644 --- a/erpnext/selling/doctype/installation_note/installation_note.js +++ b/erpnext/selling/doctype/installation_note/installation_note.js @@ -18,6 +18,14 @@ frappe.ui.form.on('Installation Note', { } } }); + }, + onload: function(frm) { + if(!frm.doc.status) { + frm.set_value({ status:'Draft'}); + } + if(frm.doc.__islocal) { + frm.set_value({inst_date: frappe.datetime.get_today()}); + } let sbb_field = frm.get_docfield('items', 'serial_and_batch_bundle'); if (sbb_field) { @@ -29,14 +37,6 @@ frappe.ui.form.on('Installation Note', { }; } }, - onload: function(frm) { - if(!frm.doc.status) { - frm.set_value({ status:'Draft'}); - } - if(frm.doc.__islocal) { - frm.set_value({inst_date: frappe.datetime.get_today()}); - } - }, customer: function(frm) { erpnext.utils.get_party_details(frm); }, diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index d18f70ade6d..1bc8d6857e9 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -48,6 +48,11 @@ frappe.ui.form.on('Quotation', { } } }); + }, + + refresh: function(frm) { + frm.trigger("set_label"); + frm.trigger("set_dynamic_field_label"); let sbb_field = frm.get_docfield('packed_items', 'serial_and_batch_bundle'); if (sbb_field) { @@ -61,11 +66,6 @@ frappe.ui.form.on('Quotation', { } }, - refresh: function(frm) { - frm.trigger("set_label"); - frm.trigger("set_dynamic_field_label"); - }, - quotation_to: function(frm) { frm.trigger("set_label"); frm.trigger("toggle_reqd_lead_customer"); diff --git a/erpnext/setup/doctype/holiday_list/holiday_list.py b/erpnext/setup/doctype/holiday_list/holiday_list.py index 526bc2ba4ac..df5b40762c1 100644 --- a/erpnext/setup/doctype/holiday_list/holiday_list.py +++ b/erpnext/setup/doctype/holiday_list/holiday_list.py @@ -19,6 +19,7 @@ class HolidayList(Document): def validate(self): self.validate_days() self.total_holidays = len(self.holidays) + self.validate_dupliacte_date() @frappe.whitelist() def get_weekly_off_dates(self): @@ -124,6 +125,14 @@ class HolidayList(Document): def clear_table(self): self.set("holidays", []) + def validate_dupliacte_date(self): + unique_dates = [] + for row in self.holidays: + if row.holiday_date in unique_dates: + frappe.throw(_("Holiday Date {0} added multiple times").format(frappe.bold(row.holiday_date))) + + unique_dates.append(row.holiday_date) + @frappe.whitelist() def get_events(start, end, filters=None): diff --git a/erpnext/setup/page/welcome_to_erpnext/__init__.py b/erpnext/setup/page/welcome_to_erpnext/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.css b/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.css deleted file mode 100644 index 1fbb459fa78..00000000000 --- a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.css +++ /dev/null @@ -1,13 +0,0 @@ -#page-welcome-to-erpnext ul li { - margin: 7px 0px; -} - -#page-welcome-to-erpnext .video-placeholder-image { - width: 100%; - cursor: pointer; -} - -#page-welcome-to-erpnext .youtube-icon { - width: 10%; - cursor: pointer; -} diff --git a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.html b/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.html deleted file mode 100644 index 7166ba37867..00000000000 --- a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.html +++ /dev/null @@ -1,30 +0,0 @@ -
-
-
-

{%= __("Welcome to ERPNext") %}

-

- {%= __("To get the best out of ERPNext, we recommend that you take some time and watch these help videos.") %} -

-

- -
-
- - -
-
- -
-
-

{%= __("Next Steps") %}

- - -
-
-
diff --git a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.js b/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.js deleted file mode 100644 index f072b8d8c57..00000000000 --- a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.js +++ /dev/null @@ -1,20 +0,0 @@ -frappe.pages['welcome-to-erpnext'].on_page_load = function(wrapper) { - var parent = $('
').appendTo(wrapper); - - parent.html(frappe.render_template("welcome_to_erpnext", {})); - - parent.find(".video-placeholder").on("click", function() { - window.erpnext_welcome_video_started = true; - parent.find(".video-placeholder").addClass("hidden"); - parent.find(".embed-responsive").append('') - }); - - // pause video on page change - $(document).on("page-change", function() { - if (window.erpnext_welcome_video_started && parent) { - parent.find(".video-playlist").each(function() { - this.contentWindow.postMessage('{"event":"command","func":"' + 'pauseVideo' + '","args":""}', '*'); - }); - } - }); -} diff --git a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.json b/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.json deleted file mode 100644 index 0f532aac68e..00000000000 --- a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "content": null, - "creation": "2015-10-28 16:27:02.197707", - "docstatus": 0, - "doctype": "Page", - "modified": "2015-10-28 16:27:02.197707", - "modified_by": "Administrator", - "module": "Setup", - "name": "welcome-to-erpnext", - "owner": "Administrator", - "page_name": "welcome-to-erpnext", - "roles": [], - "script": null, - "standard": "Yes", - "style": null, - "title": "Welcome to ERPNext" -} \ No newline at end of file diff --git a/erpnext/stock/dashboard/item_dashboard.py b/erpnext/stock/dashboard/item_dashboard.py index 62bd61f19e3..e6382688669 100644 --- a/erpnext/stock/dashboard/item_dashboard.py +++ b/erpnext/stock/dashboard/item_dashboard.py @@ -3,7 +3,7 @@ from frappe.model.db_query import DatabaseQuery from frappe.utils import cint, flt from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock, + get_sre_reserved_qty_for_items_and_warehouses as get_reserved_stock_details, ) @@ -61,7 +61,10 @@ def get_data( limit_page_length=21, ) - sre_reserved_stock_details = get_reserved_stock(item_code, warehouse) + item_code_list = [item_code] if item_code else [i.item_code for i in items] + warehouse_list = [warehouse] if warehouse else [i.warehouse for i in items] + + sre_reserved_stock_details = get_reserved_stock_details(item_code_list, warehouse_list) precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) for item in items: @@ -75,7 +78,8 @@ def get_data( "reserved_qty_for_production": flt(item.reserved_qty_for_production, precision), "reserved_qty_for_sub_contract": flt(item.reserved_qty_for_sub_contract, precision), "actual_qty": flt(item.actual_qty, precision), - "reserved_stock": sre_reserved_stock_details.get((item.item_code, item.warehouse), 0), + "reserved_stock": flt(sre_reserved_stock_details.get((item.item_code, item.warehouse))), } ) + return items diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 76e8866ece6..4ae9bf5b2a2 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -350,18 +350,20 @@ $.extend(erpnext.item, { } } - frm.fields_dict['deferred_revenue_account'].get_query = function() { + frm.fields_dict["item_defaults"].grid.get_field("deferred_revenue_account").get_query = function(doc, cdt, cdn) { return { filters: { + "company": locals[cdt][cdn].company, 'root_type': 'Liability', "is_group": 0 } } } - frm.fields_dict['deferred_expense_account'].get_query = function() { + frm.fields_dict["item_defaults"].grid.get_field("deferred_expense_account").get_query = function(doc, cdt, cdn) { return { filters: { + "company": locals[cdt][cdn].company, 'root_type': 'Asset', "is_group": 0 } diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 756d0040f1e..1bcddfa77e5 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -69,6 +69,13 @@ "variant_based_on", "attributes", "accounting", + "deferred_accounting_section", + "enable_deferred_expense", + "no_of_months_exp", + "column_break_9s9o", + "enable_deferred_revenue", + "no_of_months", + "section_break_avcp", "item_defaults", "purchasing_tab", "purchase_uom", @@ -84,10 +91,6 @@ "delivered_by_supplier", "column_break2", "supplier_items", - "deferred_expense_section", - "enable_deferred_expense", - "deferred_expense_account", - "no_of_months_exp", "foreign_trade_details", "country_of_origin", "column_break_59", @@ -98,10 +101,6 @@ "is_sales_item", "column_break3", "max_discount", - "deferred_revenue", - "enable_deferred_revenue", - "deferred_revenue_account", - "no_of_months", "customer_details", "customer_items", "item_tax_section_break", @@ -657,20 +656,6 @@ "oldfieldname": "max_discount", "oldfieldtype": "Currency" }, - { - "collapsible": 1, - "fieldname": "deferred_revenue", - "fieldtype": "Section Break", - "label": "Deferred Revenue" - }, - { - "depends_on": "enable_deferred_revenue", - "fieldname": "deferred_revenue_account", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "label": "Deferred Revenue Account", - "options": "Account" - }, { "default": "0", "fieldname": "enable_deferred_revenue", @@ -681,21 +666,7 @@ "depends_on": "enable_deferred_revenue", "fieldname": "no_of_months", "fieldtype": "Int", - "label": "No of Months" - }, - { - "collapsible": 1, - "fieldname": "deferred_expense_section", - "fieldtype": "Section Break", - "label": "Deferred Expense" - }, - { - "depends_on": "enable_deferred_expense", - "fieldname": "deferred_expense_account", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "label": "Deferred Expense Account", - "options": "Account" + "label": "No of Months (Revenue)" }, { "default": "0", @@ -904,6 +875,20 @@ "fieldname": "accounting", "fieldtype": "Tab Break", "label": "Accounting" + }, + { + "fieldname": "column_break_9s9o", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_avcp", + "fieldtype": "Section Break" + }, + { + "collapsible": 1, + "fieldname": "deferred_accounting_section", + "fieldtype": "Section Break", + "label": "Deferred Accounting" } ], "icon": "fa fa-tag", @@ -912,7 +897,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2023-08-28 22:16:40.305094", + "modified": "2023-09-11 13:46:32.688051", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item_default/item_default.json b/erpnext/stock/doctype/item_default/item_default.json index 042d398256a..28956612762 100644 --- a/erpnext/stock/doctype/item_default/item_default.json +++ b/erpnext/stock/doctype/item_default/item_default.json @@ -19,7 +19,11 @@ "selling_defaults", "selling_cost_center", "column_break_12", - "income_account" + "income_account", + "deferred_accounting_defaults_section", + "deferred_expense_account", + "column_break_kwad", + "deferred_revenue_account" ], "fields": [ { @@ -108,11 +112,34 @@ "fieldtype": "Link", "label": "Default Provisional Account", "options": "Account" + }, + { + "fieldname": "deferred_accounting_defaults_section", + "fieldtype": "Section Break", + "label": "Deferred Accounting Defaults" + }, + { + "depends_on": "eval: parent.enable_deferred_expense", + "fieldname": "deferred_expense_account", + "fieldtype": "Link", + "label": "Deferred Expense Account", + "options": "Account" + }, + { + "depends_on": "eval: parent.enable_deferred_revenue", + "fieldname": "deferred_revenue_account", + "fieldtype": "Link", + "label": "Deferred Revenue Account", + "options": "Account" + }, + { + "fieldname": "column_break_kwad", + "fieldtype": "Column Break" } ], "istable": 1, "links": [], - "modified": "2022-04-10 20:18:54.148195", + "modified": "2023-09-04 12:33:14.607267", "modified_by": "Administrator", "module": "Stock", "name": "Item Default", diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 989bfd0d1d7..bf3301f6d8c 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -218,7 +218,8 @@ frappe.ui.form.on('Material Request', { plc_conversion_rate: 1, rate: item.rate, uom: item.uom, - conversion_factor: item.conversion_factor + conversion_factor: item.conversion_factor, + project: item.project, }, overwrite_warehouse: overwrite_warehouse }, diff --git a/erpnext/stock/doctype/material_request/material_request.json b/erpnext/stock/doctype/material_request/material_request.json index ffec57ca1df..25c765bbced 100644 --- a/erpnext/stock/doctype/material_request/material_request.json +++ b/erpnext/stock/doctype/material_request/material_request.json @@ -296,6 +296,7 @@ "depends_on": "eval:doc.material_request_type == 'Material Transfer'", "fieldname": "set_from_warehouse", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Set Source Warehouse", "options": "Warehouse" }, @@ -356,7 +357,7 @@ "idx": 70, "is_submittable": 1, "links": [], - "modified": "2023-07-25 17:19:31.662662", + "modified": "2023-09-15 12:07:24.789471", "modified_by": "Administrator", "module": "Stock", "name": "Material Request", diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 4eed285fdab..ae05b80727f 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -65,17 +65,6 @@ frappe.ui.form.on('Pick List', { } } }); - - let sbb_field = frm.get_docfield('locations', 'serial_and_batch_bundle'); - if (sbb_field) { - sbb_field.get_route_options_for_new_doc = (row) => { - return { - 'item_code': row.doc.item_code, - 'warehouse': row.doc.warehouse, - 'voucher_type': frm.doc.doctype, - } - }; - } }, set_item_locations:(frm, save) => { if (!(frm.doc.locations && frm.doc.locations.length)) { @@ -132,6 +121,17 @@ frappe.ui.form.on('Pick List', { } } } + + let sbb_field = frm.get_docfield('locations', 'serial_and_batch_bundle'); + if (sbb_field) { + sbb_field.get_route_options_for_new_doc = (row) => { + return { + 'item_code': row.doc.item_code, + 'warehouse': row.doc.warehouse, + 'voucher_type': frm.doc.doctype, + } + }; + } }, work_order: (frm) => { frappe.db.get_value('Work Order', diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index d31fec54c6e..5eb3656c006 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -34,8 +34,8 @@ "sample_quantity", "tracking_section", "received_stock_qty", - "stock_qty", "col_break_tracking_section", + "stock_qty", "returned_qty", "rate_and_amount", "price_list_rate", @@ -858,7 +858,8 @@ }, { "fieldname": "tracking_section", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Qty as Per Stock UOM" }, { "fieldname": "col_break_tracking_section", @@ -1060,7 +1061,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2023-07-26 12:55:15.234477", + "modified": "2023-08-11 16:16:16.504549", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 4fb8a10748e..d37e8ee599b 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -117,15 +117,6 @@ frappe.ui.form.on('Stock Entry', { } }); - let sbb_field = frm.get_docfield('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, - } - }; - } frm.add_fetch("bom_no", "inspection_required", "inspection_required"); erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); @@ -362,6 +353,16 @@ frappe.ui.form.on('Stock Entry', { if(!check_should_not_attach_bom_items(frm.doc.bom_no)) { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); } + + let sbb_field = frm.get_docfield('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, + } + }; + } }, get_items_from_transit_entry: function(frm) { diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 26ca012d2c4..e36d5769bd2 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -346,7 +346,7 @@ class StockReconciliation(StockController): """Raises an exception if there is any reserved stock for the items in the Stock Reconciliation.""" from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_for_item_and_warehouse as get_sre_reserved_qty_details, + get_sre_reserved_qty_for_items_and_warehouses as get_sre_reserved_qty_details, ) item_code_list, warehouse_list = [], [] diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js index 4d9663602dd..c5df319e224 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js @@ -1,42 +1,42 @@ // Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -frappe.ui.form.on("Stock Reservation Entry", { +frappe.ui.form.on('Stock Reservation Entry', { refresh(frm) { - frm.trigger("set_queries"); - frm.trigger("toggle_read_only_fields"); - frm.trigger("hide_rate_related_fields"); - frm.trigger("hide_primary_action_button"); - frm.trigger("make_sb_entries_warehouse_read_only"); + frm.trigger('set_queries'); + frm.trigger('toggle_read_only_fields'); + frm.trigger('hide_rate_related_fields'); + frm.trigger('hide_primary_action_button'); + frm.trigger('make_sb_entries_warehouse_read_only'); }, has_serial_no(frm) { - frm.trigger("toggle_read_only_fields"); + frm.trigger('toggle_read_only_fields'); }, has_batch_no(frm) { - frm.trigger("toggle_read_only_fields"); + frm.trigger('toggle_read_only_fields'); }, warehouse(frm) { if (frm.doc.warehouse) { frm.doc.sb_entries.forEach((row) => { - frappe.model.set_value(row.doctype, row.name, "warehouse", frm.doc.warehouse); + frappe.model.set_value(row.doctype, row.name, 'warehouse', frm.doc.warehouse); }); } }, set_queries(frm) { - frm.set_query("warehouse", () => { + frm.set_query('warehouse', () => { return { filters: { - "is_group": 0, - "company": frm.doc.company, + 'is_group': 0, + 'company': frm.doc.company, } }; }); - frm.set_query("serial_no", "sb_entries", function(doc, cdt, cdn) { + frm.set_query('serial_no', 'sb_entries', function(doc, cdt, cdn) { var selected_serial_nos = doc.sb_entries.map(row => { return row.serial_no; }); @@ -45,16 +45,16 @@ frappe.ui.form.on("Stock Reservation Entry", { filters: { item_code: doc.item_code, warehouse: row.warehouse, - status: "Active", - name: ["not in", selected_serial_nos], + status: 'Active', + name: ['not in', selected_serial_nos], } } }); - frm.set_query("batch_no", "sb_entries", function(doc, cdt, cdn) { + frm.set_query('batch_no', 'sb_entries', function(doc, cdt, cdn) { let filters = { item: doc.item_code, - batch_qty: [">", 0], + batch_qty: ['>', 0], disabled: 0, } @@ -63,7 +63,7 @@ frappe.ui.form.on("Stock Reservation Entry", { return row.batch_no; }); - filters.name = ["not in", selected_batch_nos]; + filters.name = ['not in', selected_batch_nos]; } return { filters: filters } @@ -74,37 +74,37 @@ frappe.ui.form.on("Stock Reservation Entry", { if (frm.doc.has_serial_no) { frm.doc.sb_entries.forEach(row => { if (row.qty !== 1) { - frappe.model.set_value(row.doctype, row.name, "qty", 1); + frappe.model.set_value(row.doctype, row.name, 'qty', 1); } }) } frm.fields_dict.sb_entries.grid.update_docfield_property( - "serial_no", "read_only", !frm.doc.has_serial_no + 'serial_no', 'read_only', !frm.doc.has_serial_no ); frm.fields_dict.sb_entries.grid.update_docfield_property( - "batch_no", "read_only", !frm.doc.has_batch_no + 'batch_no', 'read_only', !frm.doc.has_batch_no ); // Qty will always be 1 for Serial No. frm.fields_dict.sb_entries.grid.update_docfield_property( - "qty", "read_only", frm.doc.has_serial_no + 'qty', 'read_only', frm.doc.has_serial_no ); - frm.set_df_property("sb_entries", "allow_on_submit", frm.doc.against_pick_list ? 0 : 1); + frm.set_df_property('sb_entries', 'allow_on_submit', frm.doc.against_pick_list ? 0 : 1); }, hide_rate_related_fields(frm) { - ["incoming_rate", "outgoing_rate", "stock_value_difference", "is_outward", "stock_queue"].forEach(field => { + ['incoming_rate', 'outgoing_rate', 'stock_value_difference', 'is_outward', 'stock_queue'].forEach(field => { frm.fields_dict.sb_entries.grid.update_docfield_property( - field, "hidden", 1 + field, 'hidden', 1 ); }); }, hide_primary_action_button(frm) { - // Hide "Amend" button on cancelled document + // Hide 'Amend' button on cancelled document if (frm.doc.docstatus == 2) { frm.page.btn_primary.hide() } @@ -112,15 +112,15 @@ frappe.ui.form.on("Stock Reservation Entry", { make_sb_entries_warehouse_read_only(frm) { frm.fields_dict.sb_entries.grid.update_docfield_property( - "warehouse", "read_only", 1 + 'warehouse', 'read_only', 1 ); }, }); -frappe.ui.form.on("Serial and Batch Entry", { +frappe.ui.form.on('Serial and Batch Entry', { sb_entries_add(frm, cdt, cdn) { if (frm.doc.warehouse) { - frappe.model.set_value(cdt, cdn, "warehouse", frm.doc.warehouse); + frappe.model.set_value(cdt, cdn, 'warehouse', frm.doc.warehouse); } }, }); \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index bd7bb668367..936be3f73b4 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -14,7 +14,7 @@ class StockReservationEntry(Document): self.validate_amended_doc() self.validate_mandatory() - self.validate_for_group_warehouse() + self.validate_group_warehouse() validate_disabled_warehouse(self.warehouse) validate_warehouse_company(self.warehouse, self.company) self.validate_uom_is_integer() @@ -74,7 +74,7 @@ class StockReservationEntry(Document): msg = _("{0} is required").format(self.meta.get_label(d)) frappe.throw(msg) - def validate_for_group_warehouse(self) -> None: + def validate_group_warehouse(self) -> None: """Raises an exception if `Warehouse` is a Group Warehouse.""" if frappe.get_cached_value("Warehouse", self.warehouse, "is_group"): @@ -544,10 +544,36 @@ def get_available_serial_nos_to_reserve( return available_serial_nos_list -def get_sre_reserved_qty_for_item_and_warehouse( - item_code: str | list, warehouse: str | list = None -) -> float | dict: - """Returns `Reserved Qty` for Item and Warehouse combination OR a dict like {("item_code", "warehouse"): "reserved_qty", ... }.""" +def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str = None) -> float: + """Returns current `Reserved Qty` for Item and Warehouse combination.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty")) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .groupby(sre.item_code, sre.warehouse) + ) + + if warehouse: + query = query.where(sre.warehouse == warehouse) + + reserved_qty = query.run(as_list=True) + + return flt(reserved_qty[0][0]) if reserved_qty else 0.0 + + +def get_sre_reserved_qty_for_items_and_warehouses( + item_code_list: list, warehouse_list: list = None +) -> dict: + """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }.""" + + if not item_code_list: + return {} sre = frappe.qb.DocType("Stock Reservation Entry") query = ( @@ -557,29 +583,20 @@ def get_sre_reserved_qty_for_item_and_warehouse( sre.warehouse, Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"), ) - .where((sre.docstatus == 1) & (sre.status.notin(["Delivered", "Cancelled"]))) + .where( + (sre.docstatus == 1) + & sre.item_code.isin(item_code_list) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) .groupby(sre.item_code, sre.warehouse) ) - query = ( - query.where(sre.item_code.isin(item_code)) - if isinstance(item_code, list) - else query.where(sre.item_code == item_code) - ) - - if warehouse: - query = ( - query.where(sre.warehouse.isin(warehouse)) - if isinstance(warehouse, list) - else query.where(sre.warehouse == warehouse) - ) + if warehouse_list: + query = query.where(sre.warehouse.isin(warehouse_list)) data = query.run(as_dict=True) - if isinstance(item_code, str) and isinstance(warehouse, str): - return data[0]["reserved_qty"] if data else 0.0 - else: - return {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in data} if data else {} + return {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in data} if data else {} def get_sre_reserved_qty_details_for_voucher(voucher_type: str, voucher_no: str) -> dict: @@ -711,7 +728,7 @@ def get_serial_batch_entries_for_voucher(sre_name: str) -> list[dict]: ).run(as_dict=True) -def get_ssb_bundle_for_voucher(sre: dict) -> object | None: +def get_ssb_bundle_for_voucher(sre: dict) -> object: """Returns a new `Serial and Batch Bundle` against the provided SRE.""" sb_entries = get_serial_batch_entries_for_voucher(sre["name"]) diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js index 442ac39f13e..5b390f7f1c0 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry_list.js @@ -4,13 +4,14 @@ frappe.listview_settings['Stock Reservation Entry'] = { get_indicator: function (doc) { const status_colors = { - 'Draft': 'red', - 'Partially Reserved': 'orange', - 'Reserved': 'blue', - 'Partially Delivered': 'purple', - 'Delivered': 'green', - 'Cancelled': 'red', + 'Draft': 'red', + 'Partially Reserved': 'orange', + 'Reserved': 'blue', + 'Partially Delivered': 'purple', + 'Delivered': 'green', + 'Cancelled': 'red', }; - return [__(doc.status), status_colors[doc.status], 'status,=,' + doc.status]; + + return [__(doc.status), status_colors[doc.status], 'status,=,' + doc.status]; }, }; \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 88b5575a373..4fbc0eb43a9 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -18,6 +18,10 @@ "auto_insert_price_list_rate_if_missing", "column_break_12", "update_existing_price_list_rate", + "conversion_factor_section", + "allow_to_edit_stock_uom_qty_for_sales", + "column_break_lznj", + "allow_to_edit_stock_uom_qty_for_purchase", "stock_validations_tab", "section_break_9", "over_delivery_receipt_allowance", @@ -357,10 +361,6 @@ "fieldtype": "Check", "label": "Allow Partial Reservation" }, - { - "fieldname": "section_break_plhx", - "fieldtype": "Section Break" - }, { "fieldname": "column_break_mhzc", "fieldtype": "Column Break" @@ -400,6 +400,27 @@ "fieldname": "auto_reserve_stock_for_sales_order", "fieldtype": "Check", "label": "Auto Reserve Stock for Sales Order" + }, + { + "fieldname": "conversion_factor_section", + "fieldtype": "Section Break", + "label": "Stock UOM Quantity" + }, + { + "fieldname": "column_break_lznj", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "allow_to_edit_stock_uom_qty_for_sales", + "fieldtype": "Check", + "label": "Allow to Edit Stock UOM Qty for Sales Documents" + }, + { + "default": "0", + "fieldname": "allow_to_edit_stock_uom_qty_for_purchase", + "fieldtype": "Check", + "label": "Allow to Edit Stock UOM Qty for Purchase Documents" } ], "icon": "icon-cog", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 9ad3c9db284..c7afb105b17 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -56,6 +56,8 @@ class StockSettings(Document): self.validate_clean_description_html() self.validate_pending_reposts() self.validate_stock_reservation() + self.change_precision_for_for_sales() + self.change_precision_for_purchase() def validate_warehouses(self): warehouse_fields = ["default_warehouse", "sample_retention_warehouse"] @@ -167,6 +169,56 @@ class StockSettings(Document): def on_update(self): self.toggle_warehouse_field_for_inter_warehouse_transfer() + def change_precision_for_for_sales(self): + doc_before_save = self.get_doc_before_save() + if doc_before_save and ( + doc_before_save.allow_to_edit_stock_uom_qty_for_sales + == self.allow_to_edit_stock_uom_qty_for_sales + ): + return + + if self.allow_to_edit_stock_uom_qty_for_sales: + doctypes = ["Sales Order Item", "Sales Invoice Item", "Delivery Note Item", "Quotation Item"] + self.make_property_setter_for_precision(doctypes) + + def change_precision_for_purchase(self): + doc_before_save = self.get_doc_before_save() + if doc_before_save and ( + doc_before_save.allow_to_edit_stock_uom_qty_for_purchase + == self.allow_to_edit_stock_uom_qty_for_purchase + ): + return + + if self.allow_to_edit_stock_uom_qty_for_purchase: + doctypes = [ + "Purchase Order Item", + "Purchase Receipt Item", + "Purchase Invoice Item", + "Request for Quotation Item", + "Supplier Quotation Item", + "Material Request Item", + ] + self.make_property_setter_for_precision(doctypes) + + @staticmethod + def make_property_setter_for_precision(doctypes): + for doctype in doctypes: + if property_name := frappe.db.exists( + "Property Setter", + {"doc_type": doctype, "field_name": "conversion_factor", "property": "precision"}, + ): + frappe.db.set_value("Property Setter", property_name, "value", 9) + continue + + make_property_setter( + doctype, + "conversion_factor", + "precision", + 9, + "Float", + validate_fields_for_doctype=False, + ) + def toggle_warehouse_field_for_inter_warehouse_transfer(self): make_property_setter( "Sales Invoice Item", diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 4f85ac054d0..a6ab63bb59d 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -696,7 +696,11 @@ def get_default_discount_account(args, item): def get_default_deferred_account(args, item, fieldname=None): if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"): return ( - item.get(fieldname) + frappe.get_cached_value( + "Item Default", + {"parent": args.item_code, "company": args.get("company")}, + fieldname, + ) or args.get(fieldname) or frappe.get_cached_value("Company", args.company, "default_" + fieldname) ) @@ -1288,6 +1292,9 @@ def get_default_bom(item_code=None): @frappe.whitelist() def get_valuation_rate(item_code, company, warehouse=None): + if frappe.get_cached_value("Warehouse", warehouse, "is_group"): + return {"valuation_rate": 0.0} + item = get_item_defaults(item_code, company) item_group = get_item_group_defaults(item_code, company) brand = get_brand_defaults(item_code, company) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 337b0ea3a54..a59f9de42e7 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -165,7 +165,7 @@ class StockBalanceReport(object): def get_sre_reserved_qty_details(self) -> dict: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_for_item_and_warehouse as get_reserved_qty_details, + get_sre_reserved_qty_for_items_and_warehouses as get_reserved_qty_details, ) item_code_list, warehouse_list = [], [] diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js index 3447e0a6fa8..3f67bffa52e 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js @@ -2,24 +2,24 @@ // For license information, please see license.txt -const DIFFERNCE_FIELD_NAMES = [ - "difference_in_qty", - "fifo_qty_diff", - "fifo_value_diff", - "fifo_valuation_diff", - "valuation_diff", - "fifo_difference_diff", - "diff_value_diff" +const DIFFERENCE_FIELD_NAMES = [ + 'difference_in_qty', + 'fifo_qty_diff', + 'fifo_value_diff', + 'fifo_valuation_diff', + 'valuation_diff', + 'fifo_difference_diff', + 'diff_value_diff' ]; -frappe.query_reports["Stock Ledger Invariant Check"] = { - "filters": [ +frappe.query_reports['Stock Ledger Invariant Check'] = { + 'filters': [ { - "fieldname": "item_code", - "fieldtype": "Link", - "label": "Item", - "mandatory": 1, - "options": "Item", + 'fieldname': 'item_code', + 'fieldtype': 'Link', + 'label': 'Item', + 'mandatory': 1, + 'options': 'Item', get_query: function() { return { filters: {is_stock_item: 1, has_serial_no: 0} @@ -27,18 +27,61 @@ frappe.query_reports["Stock Ledger Invariant Check"] = { } }, { - "fieldname": "warehouse", - "fieldtype": "Link", - "label": "Warehouse", - "mandatory": 1, - "options": "Warehouse", + 'fieldname': 'warehouse', + 'fieldtype': 'Link', + 'label': 'Warehouse', + 'mandatory': 1, + 'options': 'Warehouse', } ], + formatter (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); - if (DIFFERNCE_FIELD_NAMES.includes(column.fieldname) && Math.abs(data[column.fieldname]) > 0.001) { - value = "" + value + ""; + if (DIFFERENCE_FIELD_NAMES.includes(column.fieldname) && Math.abs(data[column.fieldname]) > 0.001) { + value = '' + value + ''; } return value; }, + + get_datatable_options(options) { + return Object.assign(options, { + checkboxColumn: true, + }); + }, + + onload(report) { + report.page.add_inner_button(__('Create Reposting Entry'), () => { + let message = ` +
+

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

+

Are you sure you want to create a Reposting Entry?

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

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

+

Are you sure you want to create Reposting Entries?

+
`; + let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows(); + let selected_rows = indexes.map(i => frappe.query_report.data[i]); + + if (!selected_rows.length) { + frappe.throw(__("Please select rows to create Reposting Entries")); + } + + frappe.confirm(__(message), () => { + frappe.call({ + method: 'erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check.create_reposting_entries', + args: { + rows: selected_rows, + } + }); + }); + }); + }, +}; diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.json b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.json new file mode 100644 index 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):