diff --git a/.github/helper/.flake8_strict b/.github/helper/.flake8_strict index 198ec7bfe54c..3e8f7dd11aba 100644 --- a/.github/helper/.flake8_strict +++ b/.github/helper/.flake8_strict @@ -66,7 +66,8 @@ ignore = F841, E713, E712, - B023 + B023, + B028 max-line-length = 200 diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py index 378983e95f2a..83346045f89d 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -3,52 +3,71 @@ from urllib.parse import urlparse -docs_repos = [ - "frappe_docs", - "erpnext_documentation", +WEBSITE_REPOS = [ "erpnext_com", "frappe_io", ] +DOCUMENTATION_DOMAINS = [ + "docs.erpnext.com", + "frappeframework.com", +] + + +def is_valid_url(url: str) -> bool: + parts = urlparse(url) + return all((parts.scheme, parts.netloc, parts.path)) + + +def is_documentation_link(word: str) -> bool: + if not word.startswith("http") or not is_valid_url(word): + return False + + parsed_url = urlparse(word) + if parsed_url.netloc in DOCUMENTATION_DOMAINS: + return True + + if parsed_url.netloc == "github.com": + parts = parsed_url.path.split("/") + if len(parts) == 5 and parts[1] == "frappe" and parts[2] in WEBSITE_REPOS: + return True + + return False + + +def contains_documentation_link(body: str) -> bool: + return any( + is_documentation_link(word) + for line in body.splitlines() + for word in line.split() + ) + + +def check_pull_request(number: str) -> "tuple[int, str]": + response = requests.get(f"https://api.github.com/repos/frappe/erpnext/pulls/{number}") + if not response.ok: + return 1, "Pull Request Not Found! ⚠️" + + payload = response.json() + title = (payload.get("title") or "").lower().strip() + head_sha = (payload.get("head") or {}).get("sha") + body = (payload.get("body") or "").lower() + + if ( + not title.startswith("feat") + or not head_sha + or "no-docs" in body + or "backport" in body + ): + return 0, "Skipping documentation checks... 🏃" -def uri_validator(x): - result = urlparse(x) - return all([result.scheme, result.netloc, result.path]) + if contains_documentation_link(body): + return 0, "Documentation Link Found. You're Awesome! 🎉" -def docs_link_exists(body): - for line in body.splitlines(): - for word in line.split(): - if word.startswith('http') and uri_validator(word): - parsed_url = urlparse(word) - if parsed_url.netloc == "github.com": - parts = parsed_url.path.split('/') - if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos: - return True - elif parsed_url.netloc == "docs.erpnext.com": - return True + return 1, "Documentation Link Not Found! ⚠️" if __name__ == "__main__": - pr = sys.argv[1] - response = requests.get("https://api.github.com/repos/frappe/erpnext/pulls/{}".format(pr)) - - if response.ok: - payload = response.json() - title = (payload.get("title") or "").lower().strip() - head_sha = (payload.get("head") or {}).get("sha") - body = (payload.get("body") or "").lower() - - if (title.startswith("feat") - and head_sha - and "no-docs" not in body - and "backport" not in body - ): - if docs_link_exists(body): - print("Documentation Link Found. You're Awesome! 🎉") - - else: - print("Documentation Link Not Found! ⚠️") - sys.exit(1) - - else: - print("Skipping documentation checks... 🏃") + exit_code, message = check_pull_request(sys.argv[1]) + print(message) + sys.exit(exit_code) diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 9e72448f530c..c505d3b9326d 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -8,8 +8,9 @@ sudo apt update && sudo apt install redis-server libcups2-dev pip install frappe-bench +githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}} frappeuser=${FRAPPE_USER:-"frappe"} -frappebranch=${FRAPPE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF##*/}}} +frappebranch=${FRAPPE_BRANCH:-$githubbranch} git clone "https://github.com/ParsimonyGit/frappe" --branch "${frappebranch}" --depth 1 bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench @@ -56,7 +57,7 @@ sed -i 's/schedule:/# schedule:/g' Procfile sed -i 's/socketio:/# socketio:/g' Procfile sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile -bench get-app payments +bench get-app payments --branch version-14 bench get-app erpnext "${GITHUB_WORKSPACE}" if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi diff --git a/.github/helper/site_config_mariadb.json b/.github/helper/site_config_mariadb.json index 948ad08babd0..ff40818fa5bf 100644 --- a/.github/helper/site_config_mariadb.json +++ b/.github/helper/site_config_mariadb.json @@ -11,6 +11,6 @@ "root_login": "root", "root_password": "travis", "host_name": "http://test_site:8000", - "install_apps": ["erpnext"], + "install_apps": ["payments", "erpnext"], "throttle_user_limit": 100 } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d61caa98708c..ccd712065dcd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v2 with: - node-version: 16 + node-version: 18 - name: Setup dependencies run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 73aae33e9364..d70977c07e20 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,8 +32,8 @@ repos: - id: black additional_dependencies: ['click==8.0.4'] - - repo: https://github.com/timothycrosley/isort - rev: 5.9.1 + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 hooks: - id: isort exclude: ".*setup.py$" diff --git a/CODEOWNERS b/CODEOWNERS index e0a0fb75f8e7..465337a0c5c2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -4,7 +4,7 @@ # the repo. Unless a later match takes precedence, erpnext/accounts/ @nextchamp-saqib @deepeshgarg007 @ruthra-kumar -erpnext/assets/ @nextchamp-saqib @deepeshgarg007 @ruthra-kumar +erpnext/assets/ @anandbaburajan @deepeshgarg007 erpnext/loan_management/ @nextchamp-saqib @deepeshgarg007 erpnext/regional @nextchamp-saqib @deepeshgarg007 @ruthra-kumar erpnext/selling @nextchamp-saqib @deepeshgarg007 @ruthra-kumar @@ -16,6 +16,7 @@ erpnext/maintenance/ @rohitwaghchaure @s-aga-r erpnext/manufacturing/ @rohitwaghchaure @s-aga-r erpnext/quality_management/ @rohitwaghchaure @s-aga-r erpnext/stock/ @rohitwaghchaure @s-aga-r +erpnext/subcontracting @rohitwaghchaure @s-aga-r erpnext/crm/ @NagariaHussain erpnext/education/ @rutwikhdev diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 4aa7b4fd90dd..456ca52020ba 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -2,7 +2,7 @@ import frappe -__version__ = "14.12.0" +__version__ = "14.20.3" def get_default_company(user=None): diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index 9dff1168fded..6635d3e733b2 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -393,7 +393,13 @@ def update_account_number(name, account_name, account_number=None, from_descenda if ancestors and not allow_independent_account_creation: for ancestor in ancestors: - if frappe.db.get_value("Account", {"account_name": old_acc_name, "company": ancestor}, "name"): + old_name = frappe.db.get_value( + "Account", + {"account_number": old_acc_number, "account_name": old_acc_name, "company": ancestor}, + "name", + ) + + if old_name: # same account in parent company exists allow_child_account_creation = _("Allow Account Creation Against Child Company") diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json index ee501f664b67..741d4283e2f1 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json @@ -1,38 +1,38 @@ { - "country_code": "de", - "name": "SKR03 mit Kontonummern", - "tree": { - "Aktiva": { - "is_group": 1, + "country_code": "de", + "name": "SKR03 mit Kontonummern", + "tree": { + "Aktiva": { + "is_group": 1, "root_type": "Asset", - "A - Anlagevermögen": { - "is_group": 1, - "EDV-Software": { - "account_number": "0027", - "account_type": "Fixed Asset" - }, - "Gesch\u00e4ftsausstattung": { - "account_number": "0410", - "account_type": "Fixed Asset" - }, - "B\u00fcroeinrichtung": { - "account_number": "0420", - "account_type": "Fixed Asset" - }, - "Darlehen": { - "account_number": "0565" - }, - "Maschinen": { - "account_number": "0210", - "account_type": "Fixed Asset" - }, - "Betriebsausstattung": { - "account_number": "0400", - "account_type": "Fixed Asset" - }, - "Ladeneinrichtung": { - "account_number": "0430", - "account_type": "Fixed Asset" + "A - Anlagevermögen": { + "is_group": 1, + "EDV-Software": { + "account_number": "0027", + "account_type": "Fixed Asset" + }, + "Geschäftsausstattung": { + "account_number": "0410", + "account_type": "Fixed Asset" + }, + "Büroeinrichtung": { + "account_number": "0420", + "account_type": "Fixed Asset" + }, + "Darlehen": { + "account_number": "0565" + }, + "Maschinen": { + "account_number": "0210", + "account_type": "Fixed Asset" + }, + "Betriebsausstattung": { + "account_number": "0400", + "account_type": "Fixed Asset" + }, + "Ladeneinrichtung": { + "account_number": "0430", + "account_type": "Fixed Asset" }, "Accumulated Depreciation": { "account_type": "Accumulated Depreciation" @@ -60,36 +60,46 @@ "Durchlaufende Posten": { "account_number": "1590" }, - "Gewinnermittlung \u00a74/3 nicht Ergebniswirksam": { + "Verrechnungskonto Gewinnermittlung § 4 Abs. 3 EStG, nicht ergebniswirksam": { "account_number": "1371" }, "Abziehbare Vorsteuer": { - "account_type": "Tax", "is_group": 1, - "Abziehbare Vorsteuer 7%": { - "account_number": "1571" - }, - "Abziehbare Vorsteuer 19%": { - "account_number": "1576" + "Abziehbare Vorsteuer 7 %": { + "account_number": "1571", + "account_type": "Tax", + "tax_rate": 7.0 }, - "Abziehbare Vorsteuer nach \u00a713b UStG 19%": { - "account_number": "1577" + "Abziehbare Vorsteuer 19 %": { + "account_number": "1576", + "account_type": "Tax", + "tax_rate": 19.0 }, - "Leistungen \u00a713b UStG 19% Vorsteuer, 19% Umsatzsteuer": { - "account_number": "3120" + "Abziehbare Vorsteuer nach § 13b UStG 19 %": { + "account_number": "1577", + "account_type": "Tax", + "tax_rate": 19.0 } } }, "III. Wertpapiere": { - "is_group": 1 + "is_group": 1, + "Anteile an verbundenen Unternehmen (Umlaufvermögen)": { + "account_number": "1340" + }, + "Anteile an herrschender oder mit Mehrheit beteiligter Gesellschaft": { + "account_number": "1344" + }, + "Sonstige Wertpapiere": { + "account_number": "1348" + } }, "IV. Kassenbestand, Bundesbankguthaben, Guthaben bei Kreditinstituten und Schecks.": { "is_group": 1, "Kasse": { - "account_type": "Cash", "is_group": 1, + "account_type": "Cash", "Kasse": { - "is_group": 1, "account_number": "1000", "account_type": "Cash" } @@ -111,21 +121,21 @@ "C - Rechnungsabgrenzungsposten": { "is_group": 1, "Aktive Rechnungsabgrenzung": { - "account_number": "0980" + "account_number": "0980" } }, "D - Aktive latente Steuern": { "is_group": 1, "Aktive latente Steuern": { - "account_number": "0983" + "account_number": "0983" } }, "E - Aktiver Unterschiedsbetrag aus der Vermögensverrechnung": { "is_group": 1 } - }, - "Passiva": { - "is_group": 1, + }, + "Passiva": { + "is_group": 1, "root_type": "Liability", "A. Eigenkapital": { "is_group": 1, @@ -200,26 +210,32 @@ }, "Umsatzsteuer": { "is_group": 1, - "account_type": "Tax", - "Umsatzsteuer 7%": { - "account_number": "1771" + "Umsatzsteuer 7 %": { + "account_number": "1771", + "account_type": "Tax", + "tax_rate": 7.0 }, - "Umsatzsteuer 19%": { - "account_number": "1776" + "Umsatzsteuer 19 %": { + "account_number": "1776", + "account_type": "Tax", + "tax_rate": 19.0 }, "Umsatzsteuer-Vorauszahlung": { - "account_number": "1780" + "account_number": "1780", + "account_type": "Tax" }, "Umsatzsteuer-Vorauszahlung 1/11": { "account_number": "1781" }, - "Umsatzsteuer \u00a7 13b UStG 19%": { - "account_number": "1787" + "Umsatzsteuer nach § 13b UStG 19 %": { + "account_number": "1787", + "account_type": "Tax", + "tax_rate": 19.0 }, "Umsatzsteuer Vorjahr": { "account_number": "1790" }, - "Umsatzsteuer fr\u00fchere Jahre": { + "Umsatzsteuer frühere Jahre": { "account_number": "1791" } } @@ -234,44 +250,56 @@ "E. Passive latente Steuern": { "is_group": 1 } - }, - "Erl\u00f6se u. Ertr\u00e4ge 2/8": { - "is_group": 1, - "root_type": "Income", - "Erl\u00f6skonten 8": { + }, + "Erlöse u. Erträge 2/8": { + "is_group": 1, + "root_type": "Income", + "Erlöskonten 8": { + "is_group": 1, + "Erlöse": { + "account_number": "8200", + "account_type": "Income Account" + }, + "Erlöse USt. 19 %": { + "account_number": "8400", + "account_type": "Income Account" + }, + "Erlöse USt. 7 %": { + "account_number": "8300", + "account_type": "Income Account" + } + }, + "Ertragskonten 2": { "is_group": 1, - "Erl\u00f6se": { - "account_number": "8200", - "account_type": "Income Account" - }, - "Erl\u00f6se USt. 19%": { - "account_number": "8400", - "account_type": "Income Account" - }, - "Erl\u00f6se USt. 7%": { - "account_number": "8300", - "account_type": "Income Account" - } - }, - "Ertragskonten 2": { - "is_group": 1, - "sonstige Zinsen und \u00e4hnliche Ertr\u00e4ge": { - "account_number": "2650", - "account_type": "Income Account" - }, - "Au\u00dferordentliche Ertr\u00e4ge": { - "account_number": "2500", - "account_type": "Income Account" - }, - "Sonstige Ertr\u00e4ge": { - "account_number": "2700", - "account_type": "Income Account" - } - } - }, - "Aufwendungen 2/4": { - "is_group": 1, + "sonstige Zinsen und ähnliche Erträge": { + "account_number": "2650", + "account_type": "Income Account" + }, + "Außerordentliche Erträge": { + "account_number": "2500", + "account_type": "Income Account" + }, + "Sonstige Erträge": { + "account_number": "2700", + "account_type": "Income Account" + } + } + }, + "Aufwendungen 2/4": { + "is_group": 1, "root_type": "Expense", + "Fremdleistungen": { + "account_number": "3100", + "account_type": "Expense Account" + }, + "Fremdleistungen ohne Vorsteuer": { + "account_number": "3109", + "account_type": "Expense Account" + }, + "Bauleistungen eines im Inland ansässigen Unternehmers 19 % Vorsteuer und 19 % Umsatzsteuer": { + "account_number": "3120", + "account_type": "Expense Account" + }, "Wareneingang": { "account_number": "3200" }, @@ -298,234 +326,234 @@ "Gegenkonto 4996-4998": { "account_number": "4999" }, - "Abschreibungen": { - "is_group": 1, + "Abschreibungen": { + "is_group": 1, "Abschreibungen auf Sachanlagen (ohne AfA auf Kfz und Gebäude)": { - "account_number": "4830", - "account_type": "Accumulated Depreciation" + "account_number": "4830", + "account_type": "Accumulated Depreciation" }, "Abschreibungen auf Gebäude": { - "account_number": "4831", - "account_type": "Depreciation" + "account_number": "4831", + "account_type": "Depreciation" }, "Abschreibungen auf Kfz": { - "account_number": "4832", - "account_type": "Depreciation" + "account_number": "4832", + "account_type": "Depreciation" }, "Sofortabschreibung GWG": { - "account_number": "4855", - "account_type": "Expense Account" + "account_number": "4855", + "account_type": "Expense Account" + } + }, + "Kfz-Kosten": { + "is_group": 1, + "Kfz-Steuer": { + "account_number": "4510", + "account_type": "Expense Account" + }, + "Kfz-Versicherungen": { + "account_number": "4520", + "account_type": "Expense Account" + }, + "laufende Kfz-Betriebskosten": { + "account_number": "4530", + "account_type": "Expense Account" + }, + "Kfz-Reparaturen": { + "account_number": "4540", + "account_type": "Expense Account" + }, + "Fremdfahrzeuge": { + "account_number": "4570", + "account_type": "Expense Account" + }, + "sonstige Kfz-Kosten": { + "account_number": "4580", + "account_type": "Expense Account" + } + }, + "Personalkosten": { + "is_group": 1, + "Gehälter": { + "account_number": "4120", + "account_type": "Expense Account" + }, + "gesetzliche soziale Aufwendungen": { + "account_number": "4130", + "account_type": "Expense Account" + }, + "Aufwendungen für Altersvorsorge": { + "account_number": "4165", + "account_type": "Expense Account" + }, + "Vermögenswirksame Leistungen": { + "account_number": "4170", + "account_type": "Expense Account" + }, + "Aushilfslöhne": { + "account_number": "4190", + "account_type": "Expense Account" } - }, - "Kfz-Kosten": { - "is_group": 1, - "Kfz-Steuer": { - "account_number": "4510", - "account_type": "Expense Account" - }, - "Kfz-Versicherungen": { - "account_number": "4520", - "account_type": "Expense Account" - }, - "laufende Kfz-Betriebskosten": { - "account_number": "4530", - "account_type": "Expense Account" - }, - "Kfz-Reparaturen": { - "account_number": "4540", - "account_type": "Expense Account" - }, - "Fremdfahrzeuge": { - "account_number": "4570", - "account_type": "Expense Account" - }, - "sonstige Kfz-Kosten": { - "account_number": "4580", - "account_type": "Expense Account" - } - }, - "Personalkosten": { - "is_group": 1, - "Geh\u00e4lter": { - "account_number": "4120", - "account_type": "Expense Account" - }, - "gesetzliche soziale Aufwendungen": { - "account_number": "4130", - "account_type": "Expense Account" - }, - "Aufwendungen f\u00fcr Altersvorsorge": { - "account_number": "4165", - "account_type": "Expense Account" - }, - "Verm\u00f6genswirksame Leistungen": { - "account_number": "4170", - "account_type": "Expense Account" - }, - "Aushilfsl\u00f6hne": { - "account_number": "4190", - "account_type": "Expense Account" - } - }, - "Raumkosten": { - "is_group": 1, - "Miete und Nebenkosten": { - "account_number": "4210", - "account_type": "Expense Account" - }, - "Gas, Wasser, Strom (Verwaltung, Vertrieb)": { - "account_number": "4240", - "account_type": "Expense Account" - }, - "Reinigung": { - "account_number": "4250", - "account_type": "Expense Account" - } - }, - "Reparatur/Instandhaltung": { - "is_group": 1, - "Reparatur u. Instandh. von Anlagen/Maschinen u. Betriebs- u. Gesch\u00e4ftsausst.": { - "account_number": "4805", - "account_type": "Expense Account" - } - }, - "Versicherungsbeitr\u00e4ge": { - "is_group": 1, - "Versicherungen": { - "account_number": "4360", - "account_type": "Expense Account" - }, - "Beitr\u00e4ge": { - "account_number": "4380", - "account_type": "Expense Account" - }, - "sonstige Ausgaben": { - "account_number": "4390", - "account_type": "Expense Account" - }, - "steuerlich abzugsf\u00e4hige Versp\u00e4tungszuschl\u00e4ge und Zwangsgelder": { - "account_number": "4396", - "account_type": "Expense Account" - } - }, - "Werbe-/Reisekosten": { - "is_group": 1, - "Werbekosten": { - "account_number": "4610", - "account_type": "Expense Account" - }, - "Aufmerksamkeiten": { - "account_number": "4653", - "account_type": "Expense Account" - }, - "nicht abzugsf\u00e4hige Betriebsausg. aus Werbe-, Repr\u00e4s.- u. Reisekosten": { - "account_number": "4665", - "account_type": "Expense Account" - }, - "Reisekosten Unternehmer": { - "account_number": "4670", - "account_type": "Expense Account" - } - }, - "verschiedene Kosten": { - "is_group": 1, - "Porto": { - "account_number": "4910", - "account_type": "Expense Account" - }, - "Telekom": { - "account_number": "4920", - "account_type": "Expense Account" - }, - "Mobilfunk D2": { - "account_number": "4921", - "account_type": "Expense Account" - }, - "Internet": { - "account_number": "4922", - "account_type": "Expense Account" - }, - "B\u00fcrobedarf": { - "account_number": "4930", - "account_type": "Expense Account" - }, - "Zeitschriften, B\u00fccher": { - "account_number": "4940", - "account_type": "Expense Account" - }, - "Fortbildungskosten": { - "account_number": "4945", - "account_type": "Expense Account" - }, - "Buchf\u00fchrungskosten": { - "account_number": "4955", - "account_type": "Expense Account" - }, - "Abschlu\u00df- u. Pr\u00fcfungskosten": { - "account_number": "4957", - "account_type": "Expense Account" - }, - "Nebenkosten des Geldverkehrs": { - "account_number": "4970", - "account_type": "Expense Account" - }, - "Werkzeuge und Kleinger\u00e4te": { - "account_number": "4985", - "account_type": "Expense Account" - } - }, - "Zinsaufwendungen": { - "is_group": 1, - "Zinsaufwendungen f\u00fcr kurzfristige Verbindlichkeiten": { - "account_number": "2110", - "account_type": "Expense Account" - }, - "Zinsaufwendungen f\u00fcr KFZ Finanzierung": { - "account_number": "2121", - "account_type": "Expense Account" - } - } - }, - "Anfangsbestand 9": { - "is_group": 1, - "root_type": "Equity", - "Saldenvortragskonten": { - "is_group": 1, - "Saldenvortrag Sachkonten": { - "account_number": "9000" - }, - "Saldenvortr\u00e4ge Debitoren": { - "account_number": "9008" - }, - "Saldenvortr\u00e4ge Kreditoren": { - "account_number": "9009" - } - } - }, - "Privatkonten 1": { - "is_group": 1, - "root_type": "Equity", - "Privatentnahmen/-einlagen": { - "is_group": 1, - "Privatentnahme allgemein": { - "account_number": "1800" - }, - "Privatsteuern": { - "account_number": "1810" - }, - "Sonderausgaben beschr\u00e4nkt abzugsf\u00e4hig": { - "account_number": "1820" - }, - "Sonderausgaben unbeschr\u00e4nkt abzugsf\u00e4hig": { - "account_number": "1830" - }, - "Au\u00dfergew\u00f6hnliche Belastungen": { - "account_number": "1850" - }, - "Privateinlagen": { - "account_number": "1890" - } - } - } - } + }, + "Raumkosten": { + "is_group": 1, + "Miete und Nebenkosten": { + "account_number": "4210", + "account_type": "Expense Account" + }, + "Gas, Wasser, Strom (Verwaltung, Vertrieb)": { + "account_number": "4240", + "account_type": "Expense Account" + }, + "Reinigung": { + "account_number": "4250", + "account_type": "Expense Account" + } + }, + "Reparatur/Instandhaltung": { + "is_group": 1, + "Reparaturen und Instandhaltungen von anderen Anlagen und Betriebs- und Geschäftsausstattung": { + "account_number": "4805", + "account_type": "Expense Account" + } + }, + "Versicherungsbeiträge": { + "is_group": 1, + "Versicherungen": { + "account_number": "4360", + "account_type": "Expense Account" + }, + "Beiträge": { + "account_number": "4380", + "account_type": "Expense Account" + }, + "sonstige Ausgaben": { + "account_number": "4390", + "account_type": "Expense Account" + }, + "steuerlich abzugsfähige Verspätungszuschläge und Zwangsgelder": { + "account_number": "4396", + "account_type": "Expense Account" + } + }, + "Werbe-/Reisekosten": { + "is_group": 1, + "Werbekosten": { + "account_number": "4610", + "account_type": "Expense Account" + }, + "Aufmerksamkeiten": { + "account_number": "4653", + "account_type": "Expense Account" + }, + "nicht abzugsfähige Betriebsausg. aus Werbe-, Repräs.- u. Reisekosten": { + "account_number": "4665", + "account_type": "Expense Account" + }, + "Reisekosten Unternehmer": { + "account_number": "4670", + "account_type": "Expense Account" + } + }, + "verschiedene Kosten": { + "is_group": 1, + "Porto": { + "account_number": "4910", + "account_type": "Expense Account" + }, + "Telekom": { + "account_number": "4920", + "account_type": "Expense Account" + }, + "Mobilfunk D2": { + "account_number": "4921", + "account_type": "Expense Account" + }, + "Internet": { + "account_number": "4922", + "account_type": "Expense Account" + }, + "Bürobedarf": { + "account_number": "4930", + "account_type": "Expense Account" + }, + "Zeitschriften, Bücher": { + "account_number": "4940", + "account_type": "Expense Account" + }, + "Fortbildungskosten": { + "account_number": "4945", + "account_type": "Expense Account" + }, + "Buchführungskosten": { + "account_number": "4955", + "account_type": "Expense Account" + }, + "Abschluß- u. Prüfungskosten": { + "account_number": "4957", + "account_type": "Expense Account" + }, + "Nebenkosten des Geldverkehrs": { + "account_number": "4970", + "account_type": "Expense Account" + }, + "Werkzeuge und Kleingeräte": { + "account_number": "4985", + "account_type": "Expense Account" + } + }, + "Zinsaufwendungen": { + "is_group": 1, + "Zinsaufwendungen für kurzfristige Verbindlichkeiten": { + "account_number": "2110", + "account_type": "Expense Account" + }, + "Zinsaufwendungen für KFZ Finanzierung": { + "account_number": "2121", + "account_type": "Expense Account" + } + } + }, + "Anfangsbestand 9": { + "is_group": 1, + "root_type": "Equity", + "Saldenvortragskonten": { + "is_group": 1, + "Saldenvortrag Sachkonten": { + "account_number": "9000" + }, + "Saldenvorträge Debitoren": { + "account_number": "9008" + }, + "Saldenvorträge Kreditoren": { + "account_number": "9009" + } + } + }, + "Privatkonten 1": { + "is_group": 1, + "root_type": "Equity", + "Privatentnahmen/-einlagen": { + "is_group": 1, + "Privatentnahme allgemein": { + "account_number": "1800" + }, + "Privatsteuern": { + "account_number": "1810" + }, + "Sonderausgaben beschränkt abzugsfähig": { + "account_number": "1820" + }, + "Sonderausgaben unbeschränkt abzugsfähig": { + "account_number": "1830" + }, + "Außergewöhnliche Belastungen": { + "account_number": "1850" + }, + "Privateinlagen": { + "account_number": "1890" + } + } + } + } } diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 1e2e2acd79af..1c0d64f065b8 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -31,6 +31,7 @@ "determine_address_tax_category_from", "column_break_19", "add_taxes_from_item_tax_template", + "book_tax_discount_loss", "print_settings", "show_inclusive_tax_in_print", "column_break_12", @@ -347,6 +348,13 @@ "fieldname": "allow_multi_currency_invoices_against_single_party_account", "fieldtype": "Check", "label": "Allow multi-currency invoices against single party account " + }, + { + "default": "0", + "description": "Split Early Payment Discount Loss into Income and Tax Loss", + "fieldname": "book_tax_discount_loss", + "fieldtype": "Check", + "label": "Book Tax Loss on Early Payment Discount" } ], "icon": "icon-cog", @@ -354,7 +362,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-11-27 21:49:52.538655", + "modified": "2023-03-28 09:50:20.375233", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js index 059e1d315884..35d606ba3ae3 100644 --- a/erpnext/accounts/doctype/bank/bank.js +++ b/erpnext/accounts/doctype/bank/bank.js @@ -118,6 +118,10 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink { } plaid_success(token, response) { - frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.update_bank_account_ids', { + response: response, + }).then(() => { + frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + }); } }; diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 80878ac50682..081718726bdf 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -81,7 +81,7 @@ def get_payment_entries(self): loan_disbursement = frappe.qb.DocType("Loan Disbursement") - loan_disbursements = ( + query = ( frappe.qb.from_(loan_disbursement) .select( ConstantColumn("Loan Disbursement").as_("payment_document"), @@ -90,17 +90,22 @@ def get_payment_entries(self): ConstantColumn(0).as_("debit"), loan_disbursement.reference_number.as_("cheque_number"), loan_disbursement.reference_date.as_("cheque_date"), + loan_disbursement.clearance_date.as_("clearance_date"), loan_disbursement.disbursement_date.as_("posting_date"), loan_disbursement.applicant.as_("against_account"), ) .where(loan_disbursement.docstatus == 1) .where(loan_disbursement.disbursement_date >= self.from_date) .where(loan_disbursement.disbursement_date <= self.to_date) - .where(loan_disbursement.clearance_date.isnull()) .where(loan_disbursement.disbursement_account.isin([self.bank_account, self.account])) .orderby(loan_disbursement.disbursement_date) .orderby(loan_disbursement.name, order=frappe.qb.desc) - ).run(as_dict=1) + ) + + if not self.include_reconciled_entries: + query = query.where(loan_disbursement.clearance_date.isnull()) + + loan_disbursements = query.run(as_dict=1) loan_repayment = frappe.qb.DocType("Loan Repayment") @@ -113,16 +118,19 @@ def get_payment_entries(self): ConstantColumn(0).as_("credit"), loan_repayment.reference_number.as_("cheque_number"), loan_repayment.reference_date.as_("cheque_date"), + loan_repayment.clearance_date.as_("clearance_date"), loan_repayment.applicant.as_("against_account"), loan_repayment.posting_date, ) .where(loan_repayment.docstatus == 1) - .where(loan_repayment.clearance_date.isnull()) .where(loan_repayment.posting_date >= self.from_date) .where(loan_repayment.posting_date <= self.to_date) .where(loan_repayment.payment_account.isin([self.bank_account, self.account])) ) + if not self.include_reconciled_entries: + query = query.where(loan_repayment.clearance_date.isnull()) + if frappe.db.has_column("Loan Repayment", "repay_from_salary"): query = query.where((loan_repayment.repay_from_salary == 0)) diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js index 28e79b5d2c6a..d97726144113 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js @@ -18,16 +18,30 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }, onload: function (frm) { + // Set default filter dates + today = frappe.datetime.get_today() + frm.doc.bank_statement_from_date = frappe.datetime.add_months(today, -1); + frm.doc.bank_statement_to_date = today; frm.trigger('bank_account'); }, + filter_by_reference_date: function (frm) { + if (frm.doc.filter_by_reference_date) { + frm.set_value("bank_statement_from_date", ""); + frm.set_value("bank_statement_to_date", ""); + } else { + frm.set_value("from_reference_date", ""); + frm.set_value("to_reference_date", ""); + } + }, + refresh: function (frm) { + frm.disable_save(); frappe.require("bank-reconciliation-tool.bundle.js", () => frm.trigger("make_reconciliation_tool") ); - frm.upload_statement_button = frm.page.set_secondary_action( - __("Upload Bank Statement"), - () => + + frm.add_custom_button(__("Upload Bank Statement"), () => frappe.call({ method: "erpnext.accounts.doctype.bank_statement_import.bank_statement_import.upload_bank_statement", @@ -49,10 +63,26 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }, }) ); - }, - after_save: function (frm) { - frm.trigger("make_reconciliation_tool"); + frm.add_custom_button(__('Auto Reconcile'), function() { + frappe.call({ + method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.auto_reconcile_vouchers", + args: { + bank_account: frm.doc.bank_account, + from_date: frm.doc.bank_statement_from_date, + to_date: frm.doc.bank_statement_to_date, + filter_by_reference_date: frm.doc.filter_by_reference_date, + from_reference_date: frm.doc.from_reference_date, + to_reference_date: frm.doc.to_reference_date, + }, + }) + }); + + frm.add_custom_button(__('Get Unreconciled Entries'), function() { + frm.trigger("make_reconciliation_tool"); + }); + frm.change_custom_button_type('Get Unreconciled Entries', null, 'primary'); + }, bank_account: function (frm) { @@ -66,7 +96,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { r.account, "account_currency", (r) => { - frm.currency = r.account_currency; + frm.doc.account_currency = r.account_currency; frm.trigger("render_chart"); } ); @@ -132,19 +162,19 @@ frappe.ui.form.on("Bank Reconciliation Tool", { } }, - render_chart: frappe.utils.debounce((frm) => { + render_chart(frm) { frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager( { $reconciliation_tool_cards: frm.get_field( "reconciliation_tool_cards" ).$wrapper, bank_statement_closing_balance: - frm.doc.bank_statement_closing_balance, + frm.doc.bank_statement_closing_balance, cleared_balance: frm.cleared_balance, - currency: frm.currency, + currency: frm.doc.account_currency, } ); - }, 500), + }, render(frm) { if (frm.doc.bank_account) { @@ -160,6 +190,9 @@ frappe.ui.form.on("Bank Reconciliation Tool", { ).$wrapper, bank_statement_from_date: frm.doc.bank_statement_from_date, bank_statement_to_date: frm.doc.bank_statement_to_date, + filter_by_reference_date: frm.doc.filter_by_reference_date, + from_reference_date: frm.doc.from_reference_date, + to_reference_date: frm.doc.to_reference_date, bank_statement_closing_balance: frm.doc.bank_statement_closing_balance, cards_manager: frm.cards_manager, diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json index f666101d3fd8..93fc4439d351 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json @@ -10,7 +10,11 @@ "column_break_1", "bank_statement_from_date", "bank_statement_to_date", + "from_reference_date", + "to_reference_date", + "filter_by_reference_date", "column_break_2", + "account_currency", "account_opening_balance", "bank_statement_closing_balance", "section_break_1", @@ -36,13 +40,13 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval: doc.bank_account", + "depends_on": "eval: doc.bank_account && !doc.filter_by_reference_date", "fieldname": "bank_statement_from_date", "fieldtype": "Date", "label": "From Date" }, { - "depends_on": "eval: doc.bank_statement_from_date", + "depends_on": "eval: doc.bank_account && !doc.filter_by_reference_date", "fieldname": "bank_statement_to_date", "fieldtype": "Date", "label": "To Date" @@ -56,7 +60,7 @@ "fieldname": "account_opening_balance", "fieldtype": "Currency", "label": "Account Opening Balance", - "options": "Currency", + "options": "account_currency", "read_only": 1 }, { @@ -64,7 +68,7 @@ "fieldname": "bank_statement_closing_balance", "fieldtype": "Currency", "label": "Closing Balance", - "options": "Currency" + "options": "account_currency" }, { "fieldname": "section_break_1", @@ -81,14 +85,40 @@ }, { "fieldname": "no_bank_transactions", - "fieldtype": "HTML" + "fieldtype": "HTML", + "options": "
No Matching Bank Transactions Found
" + }, + { + "depends_on": "eval:doc.filter_by_reference_date", + "fieldname": "from_reference_date", + "fieldtype": "Date", + "label": "From Reference Date" + }, + { + "depends_on": "eval:doc.filter_by_reference_date", + "fieldname": "to_reference_date", + "fieldtype": "Date", + "label": "To Reference Date" + }, + { + "default": "0", + "fieldname": "filter_by_reference_date", + "fieldtype": "Check", + "label": "Filter by Reference Date" + }, + { + "fieldname": "account_currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Account Currency", + "options": "Currency" } ], "hide_toolbar": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-04-21 11:13:49.831769", + "modified": "2023-03-07 11:02:24.535714", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Reconciliation Tool", @@ -107,5 +137,6 @@ ], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" -} + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 8b496e5ea335..c4a23a640c3a 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -8,9 +8,9 @@ from frappe import _ from frappe.model.document import Document from frappe.query_builder.custom import ConstantColumn -from frappe.utils import flt +from frappe.utils import cint, flt -from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_paid_amount +from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount from erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_statement import ( get_amounts_not_reflected_in_system, get_entries, @@ -28,7 +28,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): filters = [] filters.append(["bank_account", "=", bank_account]) filters.append(["docstatus", "=", 1]) - filters.append(["unallocated_amount", ">", 0]) + filters.append(["unallocated_amount", ">", 0.0]) if to_date: filters.append(["date", "<=", to_date]) if from_date: @@ -50,6 +50,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): "party", ], filters=filters, + order_by="date", ) return transactions @@ -65,7 +66,7 @@ def get_account_balance(bank_account, till_date): balance_as_per_system = get_balance_on(filters["account"], filters["report_date"]) - total_debit, total_credit = 0, 0 + total_debit, total_credit = 0.0, 0.0 for d in data: total_debit += flt(d.debit) total_credit += flt(d.credit) @@ -144,10 +145,8 @@ def create_journal_entry_bts( accounts.append( { "account": second_account, - "credit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0, - "debit_in_account_currency": bank_transaction.withdrawal - if bank_transaction.withdrawal > 0 - else 0, + "credit_in_account_currency": bank_transaction.deposit, + "debit_in_account_currency": bank_transaction.withdrawal, "party_type": party_type, "party": party, } @@ -157,10 +156,8 @@ def create_journal_entry_bts( { "account": company_account, "bank_account": bank_transaction.bank_account, - "credit_in_account_currency": bank_transaction.withdrawal - if bank_transaction.withdrawal > 0 - else 0, - "debit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0, + "credit_in_account_currency": bank_transaction.withdrawal, + "debit_in_account_currency": bank_transaction.deposit, } ) @@ -184,16 +181,22 @@ def create_journal_entry_bts( journal_entry.insert() journal_entry.submit() - if bank_transaction.deposit > 0: + if bank_transaction.deposit > 0.0: paid_amount = bank_transaction.deposit else: paid_amount = bank_transaction.withdrawal vouchers = json.dumps( - [{"payment_doctype": "Journal Entry", "payment_name": journal_entry.name, "amount": paid_amount}] + [ + { + "payment_doctype": "Journal Entry", + "payment_name": journal_entry.name, + "amount": paid_amount, + } + ] ) - return reconcile_vouchers(bank_transaction.name, vouchers) + return reconcile_vouchers(bank_transaction_name, vouchers) @frappe.whitelist() @@ -217,7 +220,7 @@ def create_payment_entry_bts( as_dict=True, )[0] paid_amount = bank_transaction.unallocated_amount - payment_type = "Receive" if bank_transaction.deposit > 0 else "Pay" + payment_type = "Receive" if bank_transaction.deposit > 0.0 else "Pay" company_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account") company = frappe.get_value("Account", company_account, "company") @@ -256,90 +259,178 @@ def create_payment_entry_bts( payment_entry.submit() vouchers = json.dumps( - [{"payment_doctype": "Payment Entry", "payment_name": payment_entry.name, "amount": paid_amount}] + [ + { + "payment_doctype": "Payment Entry", + "payment_name": payment_entry.name, + "amount": paid_amount, + } + ] ) - return reconcile_vouchers(bank_transaction.name, vouchers) + return reconcile_vouchers(bank_transaction_name, vouchers) @frappe.whitelist() -def reconcile_vouchers(bank_transaction_name, vouchers): - # updated clear date of all the vouchers based on the bank transaction - vouchers = json.loads(vouchers) - transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) - company_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") - - if transaction.unallocated_amount == 0: - frappe.throw(_("This bank transaction is already fully reconciled")) - total_amount = 0 - for voucher in vouchers: - voucher["payment_entry"] = frappe.get_doc(voucher["payment_doctype"], voucher["payment_name"]) - total_amount += get_paid_amount( - frappe._dict( +def auto_reconcile_vouchers( + bank_account, + from_date=None, + to_date=None, + filter_by_reference_date=None, + from_reference_date=None, + to_reference_date=None, +): + frappe.flags.auto_reconcile_vouchers = True + document_types = ["payment_entry", "journal_entry"] + bank_transactions = get_bank_transactions(bank_account) + matched_transaction = [] + for transaction in bank_transactions: + linked_payments = get_linked_payments( + transaction.name, + document_types, + 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"], - } - ), - transaction.currency, - company_account, - ) - - if total_amount > transaction.unallocated_amount: - frappe.throw( - _( - "The sum total of amounts of all selected vouchers should be less than the unallocated amount of the bank transaction" + "allocated_amount": allocated_amount, + }, ) - ) - account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") + 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)) - 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_in_account_currency as credit", "debit_in_account_currency as 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 + frappe.flags.auto_reconcile_vouchers = False + + return frappe.get_doc("Bank Transaction", transaction.name) - transaction.append( - "payment_entries", - { - "payment_document": voucher["payment_entry"].doctype, - "payment_entry": voucher["payment_entry"].name, - "allocated_amount": allocated_amount, - }, - ) - transaction.save() - transaction.update_allocations() +@frappe.whitelist() +def reconcile_vouchers(bank_transaction_name, vouchers): + # updated clear date of all the vouchers based on the bank transaction + vouchers = json.loads(vouchers) + transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) + transaction.add_payment_entries(vouchers) return frappe.get_doc("Bank Transaction", bank_transaction_name) @frappe.whitelist() -def get_linked_payments(bank_transaction_name, document_types=None): +def get_linked_payments( + bank_transaction_name, + document_types=None, + from_date=None, + to_date=None, + filter_by_reference_date=None, + from_reference_date=None, + to_reference_date=None, +): # get all matching payments for a bank transaction transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) bank_account = frappe.db.get_values( "Bank Account", transaction.bank_account, ["account", "company"], as_dict=True )[0] - (account, company) = (bank_account.account, bank_account.company) - matching = check_matching(account, company, transaction, document_types) - return matching + (gl_account, company) = (bank_account.account, bank_account.company) + matching = check_matching( + gl_account, + company, + transaction, + document_types, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + ) + return subtract_allocations(gl_account, matching) -def check_matching(bank_account, company, transaction, document_types): +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 + + if amount: + l = list(voucher) + l[3] -= amount + copied.append(tuple(l)) + else: + copied.append(voucher) + return copied + + +def check_matching( + bank_account, + company, + transaction, + document_types, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, +): + exact_match = True if "exact_match" in document_types else False # combine all types of vouchers - subquery = get_queries(bank_account, company, transaction, document_types) + subquery = 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, - "payment_type": "Receive" if transaction.deposit > 0 else "Pay", + "payment_type": "Receive" if transaction.deposit > 0.0 else "Pay", "reference_no": transaction.reference_number, "party_type": transaction.party_type, "party": transaction.party, @@ -348,7 +439,9 @@ def check_matching(bank_account, company, transaction, document_types): matching_vouchers = [] - matching_vouchers.extend(get_loan_vouchers(bank_account, transaction, document_types, filters)) + matching_vouchers.extend( + get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match) + ) for query in subquery: matching_vouchers.extend( @@ -357,14 +450,23 @@ def check_matching(bank_account, company, transaction, document_types): filters, ) ) - return sorted(matching_vouchers, key=lambda x: x[0], reverse=True) if matching_vouchers else [] -def get_queries(bank_account, company, transaction, document_types): +def get_queries( + bank_account, + company, + transaction, + document_types, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + exact_match, +): # get queries to get matching vouchers - amount_condition = "=" if "exact_match" in document_types else "<=" - account_from_to = "paid_to" if transaction.deposit > 0 else "paid_from" + account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from" queries = [] # get matching queries from all the apps @@ -375,8 +477,13 @@ def get_queries(bank_account, company, transaction, document_types): company, transaction, document_types, - amount_condition, + exact_match, account_from_to, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, ) or [] ) @@ -385,43 +492,106 @@ def get_queries(bank_account, company, transaction, document_types): def get_matching_queries( - bank_account, company, transaction, document_types, amount_condition, account_from_to + bank_account, + company, + transaction, + document_types, + exact_match, + account_from_to, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, ): queries = [] if "payment_entry" in document_types: - pe_amount_matching = get_pe_matching_query(amount_condition, account_from_to, transaction) - queries.extend([pe_amount_matching]) + query = get_pe_matching_query( + exact_match, + account_from_to, + transaction, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + ) + queries.append(query) if "journal_entry" in document_types: - je_amount_matching = get_je_matching_query(amount_condition, transaction) - queries.extend([je_amount_matching]) + query = get_je_matching_query( + exact_match, + transaction, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, + ) + queries.append(query) - if transaction.deposit > 0 and "sales_invoice" in document_types: - si_amount_matching = get_si_matching_query(amount_condition) - queries.extend([si_amount_matching]) + if transaction.deposit > 0.0 and "sales_invoice" in document_types: + query = get_si_matching_query(exact_match) + queries.append(query) - if transaction.withdrawal > 0: + if transaction.withdrawal > 0.0: if "purchase_invoice" in document_types: - pi_amount_matching = get_pi_matching_query(amount_condition) - queries.extend([pi_amount_matching]) + query = get_pi_matching_query(exact_match) + queries.append(query) + + if "bank_transaction" in document_types: + query = get_bt_matching_query(exact_match, transaction) + queries.append(query) return queries -def get_loan_vouchers(bank_account, transaction, document_types, filters): +def get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match): vouchers = [] - amount_condition = True if "exact_match" in document_types else False - if transaction.withdrawal > 0 and "loan_disbursement" in document_types: - vouchers.extend(get_ld_matching_query(bank_account, amount_condition, filters)) + if transaction.withdrawal > 0.0 and "loan_disbursement" in document_types: + vouchers.extend(get_ld_matching_query(bank_account, exact_match, filters)) - if transaction.deposit > 0 and "loan_repayment" in document_types: - vouchers.extend(get_lr_matching_query(bank_account, amount_condition, filters)) + if transaction.deposit > 0.0 and "loan_repayment" in document_types: + vouchers.extend(get_lr_matching_query(bank_account, exact_match, filters)) return vouchers -def get_ld_matching_query(bank_account, amount_condition, filters): +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 + field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal" + + return f""" + + 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'} + """ + + +def get_ld_matching_query(bank_account, exact_match, filters): loan_disbursement = frappe.qb.DocType("Loan Disbursement") matching_reference = loan_disbursement.reference_number == filters.get("reference_number") matching_party = loan_disbursement.applicant_type == filters.get( @@ -449,17 +619,17 @@ def get_ld_matching_query(bank_account, amount_condition, filters): .where(loan_disbursement.disbursement_account == bank_account) ) - if amount_condition: + if exact_match: query.where(loan_disbursement.disbursed_amount == filters.get("amount")) else: - query.where(loan_disbursement.disbursed_amount <= filters.get("amount")) + query.where(loan_disbursement.disbursed_amount > 0.0) vouchers = query.run(as_list=True) return vouchers -def get_lr_matching_query(bank_account, amount_condition, filters): +def get_lr_matching_query(bank_account, exact_match, filters): loan_repayment = frappe.qb.DocType("Loan Repayment") matching_reference = loan_repayment.reference_number == filters.get("reference_number") matching_party = loan_repayment.applicant_type == filters.get( @@ -490,88 +660,129 @@ def get_lr_matching_query(bank_account, amount_condition, filters): if frappe.db.has_column("Loan Repayment", "repay_from_salary"): query = query.where((loan_repayment.repay_from_salary == 0)) - if amount_condition: + if exact_match: query.where(loan_repayment.amount_paid == filters.get("amount")) else: - query.where(loan_repayment.amount_paid <= filters.get("amount")) + query.where(loan_repayment.amount_paid > 0.0) vouchers = query.run() return vouchers -def get_pe_matching_query(amount_condition, account_from_to, transaction): +def get_pe_matching_query( + exact_match, + account_from_to, + transaction, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, +): # get matching payment entries query - if transaction.deposit > 0: + 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 = "" + 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" + 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 - + 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 - paid_amount {amount_condition} %(amount)s - AND docstatus = 1 - AND payment_type IN (%(payment_type)s, 'Internal Transfer') - AND ifnull(clearance_date, '') = "" - AND {account_from_to} = %(bank_account)s + 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} """ -def get_je_matching_query(amount_condition, transaction): +def get_je_matching_query( + exact_match, + transaction, + from_date, + to_date, + filter_by_reference_date, + from_reference_date, + to_reference_date, +): # get matching journal entry query - # We have mapping at the bank level # 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 else "debit" - + 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 = "" + 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, + '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, + 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, jea.party_type, je.posting_date, - jea.account_currency as currency + jea.account_currency AS currency FROM - `tabJournal Entry Account` as jea + `tabJournal Entry Account` AS jea JOIN - `tabJournal Entry` as je + `tabJournal Entry` AS je ON jea.parent = je.name WHERE - (je.clearance_date is null or je.clearance_date='0000-00-00') + 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_condition} %(amount)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} """ -def get_si_matching_query(amount_condition): - # get matchin sales invoice query +def get_si_matching_query(exact_match): + # get matching sales invoice query return f""" SELECT - ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END + ( 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.name, @@ -589,18 +800,20 @@ def get_si_matching_query(amount_condition): `tabSales Invoice` as si ON sip.parent = si.name - WHERE (sip.clearance_date is null or sip.clearance_date='0000-00-00') + 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_condition} %(amount)s - AND si.docstatus = 1 + AND sip.amount {'= %(amount)s' if exact_match else '> 0.0'} """ -def get_pi_matching_query(amount_condition): - # get matching purchase invoice query +def get_pi_matching_query(exact_match): + # 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, @@ -614,9 +827,9 @@ def get_pi_matching_query(amount_condition): FROM `tabPurchase Invoice` WHERE - paid_amount {amount_condition} %(amount)s - AND docstatus = 1 + docstatus = 1 AND is_paid = 1 AND ifnull(clearance_date, '') = "" - AND cash_bank_account = %(bank_account)s + AND cash_bank_account = %(bank_account)s + AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} """ diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js index 6f2900a68082..e548b4c7e9a8 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js @@ -12,8 +12,13 @@ frappe.ui.form.on("Bank Transaction", { }; }); }, - - bank_account: function(frm) { + refresh(frm) { + frm.add_custom_button(__('Unreconcile Transaction'), () => { + frm.call('remove_payment_entries') + .then( () => frm.refresh() ); + }); + }, + bank_account: function (frm) { set_bank_statement_filter(frm); }, @@ -34,6 +39,7 @@ frappe.ui.form.on("Bank Transaction", { "Journal Entry", "Sales Invoice", "Purchase Invoice", + "Bank Transaction", ]; } }); @@ -49,7 +55,7 @@ const update_clearance_date = (frm, cdt, cdn) => { frappe .xcall( "erpnext.accounts.doctype.bank_transaction.bank_transaction.unclear_reference_payment", - { doctype: cdt, docname: cdn } + { doctype: cdt, docname: cdn, bt_name: frm.doc.name } ) .then((e) => { if (e == "success") { diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json index 2bdaa1049b75..768d2f0fa452 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json @@ -20,9 +20,11 @@ "currency", "section_break_10", "description", - "section_break_14", "reference_number", + "column_break_10", "transaction_id", + "transaction_type", + "section_break_14", "payment_entries", "section_break_18", "allocated_amount", @@ -190,11 +192,21 @@ "label": "Withdrawal", "oldfieldname": "credit", "options": "currency" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "transaction_type", + "fieldtype": "Data", + "label": "Transaction Type", + "length": 50 } ], "is_submittable": 1, "links": [], - "modified": "2022-03-21 19:05:04.208222", + "modified": "2022-05-29 18:36:50.475964", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Transaction", @@ -248,4 +260,4 @@ "states": [], "title_field": "bank_account", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 9b36c93a0f38..15162376c154 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -1,9 +1,6 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - -from functools import reduce - import frappe from frappe.utils import flt @@ -18,72 +15,137 @@ def on_submit(self): self.clear_linked_payment_entries() self.set_status() + _saving_flag = False + + # nosemgrep: frappe-semgrep-rules.rules.frappe-modifying-but-not-comitting def on_update_after_submit(self): - self.update_allocations() - self.clear_linked_payment_entries() - self.set_status(update=True) + "Run on save(). Avoid recursion caused by multiple saves" + if not self._saving_flag: + self._saving_flag = True + self.clear_linked_payment_entries() + self.update_allocations() + self._saving_flag = False def on_cancel(self): self.clear_linked_payment_entries(for_cancel=True) self.set_status(update=True) def update_allocations(self): + "The doctype does not allow modifications after submission, so write to the db direct" if self.payment_entries: - allocated_amount = reduce( - lambda x, y: flt(x) + flt(y), [x.allocated_amount for x in self.payment_entries] - ) + allocated_amount = sum(p.allocated_amount for p in self.payment_entries) else: - allocated_amount = 0 - - if allocated_amount: - frappe.db.set_value(self.doctype, self.name, "allocated_amount", flt(allocated_amount)) - frappe.db.set_value( - self.doctype, - self.name, - "unallocated_amount", - abs(flt(self.withdrawal) - flt(self.deposit)) - flt(allocated_amount), - ) + allocated_amount = 0.0 - else: - frappe.db.set_value(self.doctype, self.name, "allocated_amount", 0) - frappe.db.set_value( - self.doctype, self.name, "unallocated_amount", abs(flt(self.withdrawal) - flt(self.deposit)) - ) + amount = abs(flt(self.withdrawal) - flt(self.deposit)) + self.db_set("allocated_amount", flt(allocated_amount)) + self.db_set("unallocated_amount", amount - flt(allocated_amount)) + self.reload() + self.set_status(update=True) + + def add_payment_entries(self, vouchers): + "Add the vouchers with zero allocation. Save() will perform the allocations and clearance" + if 0.0 >= self.unallocated_amount: + frappe.throw(frappe._(f"Bank Transaction {self.name} is already fully reconciled")) + + added = False + for voucher in vouchers: + # Can't add same voucher twice + found = False + for pe in self.payment_entries: + if ( + pe.payment_document == voucher["payment_doctype"] + and pe.payment_entry == voucher["payment_name"] + ): + found = True + + if not found: + pe = { + "payment_document": voucher["payment_doctype"], + "payment_entry": voucher["payment_name"], + "allocated_amount": 0.0, # Temporary + } + child = self.append("payment_entries", pe) + added = True + + # runs on_update_after_submit + if added: + self.save() + + def allocate_payment_entries(self): + """Refactored from bank reconciliation tool. + Non-zero allocations must be amended/cleared manually + Get the bank transaction amount (b) and remove as we allocate + For each payment_entry if allocated_amount == 0: + - get the amount already allocated against all transactions (t), need latest date + - get the voucher amount (from gl) (v) + - allocate (a = v - t) + - a = 0: should already be cleared, so clear & remove payment_entry + - 0 < a <= u: allocate a & clear + - 0 < a, a > u: allocate u + - 0 > a: Error: already over-allocated + - clear means: set the latest transaction date as clearance date + """ + gl_bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account") + remaining_amount = self.unallocated_amount + for payment_entry in self.payment_entries: + if payment_entry.allocated_amount == 0.0: + unallocated_amount, should_clear, latest_transaction = get_clearance_details( + self, payment_entry + ) - amount = self.deposit or self.withdrawal - if amount == self.allocated_amount: - frappe.db.set_value(self.doctype, self.name, "status", "Reconciled") + if 0.0 == unallocated_amount: + if should_clear: + latest_transaction.clear_linked_payment_entry(payment_entry) + self.db_delete_payment_entry(payment_entry) + + elif remaining_amount <= 0.0: + self.db_delete_payment_entry(payment_entry) + + elif 0.0 < unallocated_amount and unallocated_amount <= remaining_amount: + payment_entry.db_set("allocated_amount", unallocated_amount) + remaining_amount -= unallocated_amount + if should_clear: + latest_transaction.clear_linked_payment_entry(payment_entry) + + elif 0.0 < unallocated_amount and unallocated_amount > remaining_amount: + payment_entry.db_set("allocated_amount", remaining_amount) + remaining_amount = 0.0 + + elif 0.0 > unallocated_amount: + self.db_delete_payment_entry(payment_entry) + frappe.throw( + frappe._(f"Voucher {payment_entry.payment_entry} is over-allocated by {unallocated_amount}") + ) self.reload() - def clear_linked_payment_entries(self, for_cancel=False): + def db_delete_payment_entry(self, payment_entry): + frappe.db.delete("Bank Transaction Payments", {"name": payment_entry.name}) + + @frappe.whitelist() + def remove_payment_entries(self): for payment_entry in self.payment_entries: - if payment_entry.payment_document == "Sales Invoice": - self.clear_sales_invoice(payment_entry, for_cancel=for_cancel) - elif payment_entry.payment_document in get_doctypes_for_bank_reconciliation(): - self.clear_simple_entry(payment_entry, for_cancel=for_cancel) + self.remove_payment_entry(payment_entry) + # runs on_update_after_submit + self.save() - def clear_simple_entry(self, payment_entry, for_cancel=False): - if payment_entry.payment_document == "Payment Entry": - if ( - frappe.db.get_value("Payment Entry", payment_entry.payment_entry, "payment_type") - == "Internal Transfer" - ): - if len(get_reconciled_bank_transactions(payment_entry)) < 2: - return - - clearance_date = self.date if not for_cancel else None - frappe.db.set_value( - payment_entry.payment_document, payment_entry.payment_entry, "clearance_date", clearance_date - ) + def remove_payment_entry(self, payment_entry): + "Clear payment entry and clearance" + self.clear_linked_payment_entry(payment_entry, for_cancel=True) + self.remove(payment_entry) - def clear_sales_invoice(self, payment_entry, for_cancel=False): - clearance_date = self.date if not for_cancel else None - frappe.db.set_value( - "Sales Invoice Payment", - dict(parenttype=payment_entry.payment_document, parent=payment_entry.payment_entry), - "clearance_date", - clearance_date, + def clear_linked_payment_entries(self, for_cancel=False): + if for_cancel: + for payment_entry in self.payment_entries: + self.clear_linked_payment_entry(payment_entry, for_cancel) + else: + self.allocate_payment_entries() + + def clear_linked_payment_entry(self, payment_entry, for_cancel=False): + clearance_date = None if for_cancel else self.date + set_voucher_clearance( + payment_entry.payment_document, payment_entry.payment_entry, clearance_date, self ) @@ -93,38 +155,112 @@ def get_doctypes_for_bank_reconciliation(): return frappe.get_hooks("bank_reconciliation_doctypes") -def get_reconciled_bank_transactions(payment_entry): - reconciled_bank_transactions = frappe.get_all( - "Bank Transaction Payments", - filters={"payment_entry": payment_entry.payment_entry}, - fields=["parent"], +def get_clearance_details(transaction, payment_entry): + """ + There should only be one bank gle for a voucher. + Could be none for a Bank Transaction. + But if a JE, could affect two banks. + Should only clear the voucher if all bank gles are allocated. + """ + gl_bank_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") + gles = get_related_bank_gl_entries(payment_entry.payment_document, payment_entry.payment_entry) + bt_allocations = get_total_allocated_amount( + payment_entry.payment_document, payment_entry.payment_entry + ) + + unallocated_amount = min( + transaction.unallocated_amount, + get_paid_amount(payment_entry, transaction.currency, gl_bank_account), ) + unmatched_gles = len(gles) + latest_transaction = transaction + for gle in gles: + if gle["gl_account"] == gl_bank_account: + if gle["amount"] <= 0.0: + frappe.throw( + frappe._(f"Voucher {payment_entry.payment_entry} value is broken: {gle['amount']}") + ) + + unmatched_gles -= 1 + unallocated_amount = gle["amount"] + for a in bt_allocations: + if a["gl_account"] == gle["gl_account"]: + unallocated_amount = gle["amount"] - a["total"] + if frappe.utils.getdate(transaction.date) < a["latest_date"]: + latest_transaction = frappe.get_doc("Bank Transaction", a["latest_name"]) + else: + # Must be a Journal Entry affecting more than one bank + for a in bt_allocations: + if a["gl_account"] == gle["gl_account"] and a["total"] == gle["amount"]: + unmatched_gles -= 1 - return reconciled_bank_transactions + return unallocated_amount, unmatched_gles == 0, latest_transaction -def get_total_allocated_amount(payment_entry): - return frappe.db.sql( +def get_related_bank_gl_entries(doctype, docname): + # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql + result = frappe.db.sql( """ SELECT - SUM(btp.allocated_amount) as allocated_amount, - bt.name + ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount, + gle.account AS gl_account FROM - `tabBank Transaction Payments` as btp + `tabGL Entry` gle LEFT JOIN - `tabBank Transaction` bt ON bt.name=btp.parent + `tabAccount` ac ON ac.name=gle.account WHERE - btp.payment_document = %s - AND - btp.payment_entry = %s - AND - bt.docstatus = 1""", - (payment_entry.payment_document, payment_entry.payment_entry), + ac.account_type = 'Bank' + AND gle.voucher_type = %(doctype)s + AND gle.voucher_no = %(docname)s + AND is_cancelled = 0 + """, + dict(doctype=doctype, docname=docname), as_dict=True, ) + return result -def get_paid_amount(payment_entry, currency, bank_account): +def get_total_allocated_amount(doctype, docname): + """ + Gets the sum of allocations for a voucher on each bank GL account + along with the latest bank transaction name & date + NOTE: query may also include just saved vouchers/payments but with zero allocated_amount + """ + # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql + result = frappe.db.sql( + """ + SELECT total, latest_name, latest_date, gl_account FROM ( + SELECT + ROW_NUMBER() OVER w AS rownum, + SUM(btp.allocated_amount) OVER(PARTITION BY ba.account) AS total, + FIRST_VALUE(bt.name) OVER w AS latest_name, + FIRST_VALUE(bt.date) OVER w AS latest_date, + ba.account AS gl_account + FROM + `tabBank Transaction Payments` btp + LEFT JOIN `tabBank Transaction` bt ON bt.name=btp.parent + LEFT JOIN `tabBank Account` ba ON ba.name=bt.bank_account + WHERE + btp.payment_document = %(doctype)s + AND btp.payment_entry = %(docname)s + AND bt.docstatus = 1 + WINDOW w AS (PARTITION BY ba.account ORDER BY bt.date desc) + ) temp + WHERE + rownum = 1 + """, + dict(doctype=doctype, docname=docname), + as_dict=True, + ) + for row in result: + # Why is this *sometimes* a byte string? + if isinstance(row["latest_name"], bytes): + row["latest_name"] = row["latest_name"].decode() + row["latest_date"] = frappe.utils.getdate(row["latest_date"]) + return result + + +def get_paid_amount(payment_entry, currency, gl_bank_account): if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]: paid_amount_field = "paid_amount" @@ -147,7 +283,7 @@ def get_paid_amount(payment_entry, currency, bank_account): elif payment_entry.payment_document == "Journal Entry": return frappe.db.get_value( "Journal Entry Account", - {"parent": payment_entry.payment_entry, "account": bank_account}, + {"parent": payment_entry.payment_entry, "account": gl_bank_account}, "sum(credit_in_account_currency)", ) @@ -166,6 +302,12 @@ def get_paid_amount(payment_entry, currency, bank_account): payment_entry.payment_document, payment_entry.payment_entry, "amount_paid" ) + elif payment_entry.payment_document == "Bank Transaction": + dep, wth = frappe.db.get_value( + "Bank Transaction", payment_entry.payment_entry, ("deposit", "withdrawal") + ) + return abs(flt(wth) - flt(dep)) + else: frappe.throw( "Please reconcile {0}: {1} manually".format( @@ -174,18 +316,55 @@ def get_paid_amount(payment_entry, currency, bank_account): ) -@frappe.whitelist() -def unclear_reference_payment(doctype, docname): - if frappe.db.exists(doctype, docname): - doc = frappe.get_doc(doctype, docname) - if doctype == "Sales Invoice": - frappe.db.set_value( - "Sales Invoice Payment", - dict(parenttype=doc.payment_document, parent=doc.payment_entry), - "clearance_date", - None, - ) +def set_voucher_clearance(doctype, docname, clearance_date, self): + if doctype in [ + "Payment Entry", + "Journal Entry", + "Purchase Invoice", + "Expense Claim", + "Loan Repayment", + "Loan Disbursement", + ]: + if ( + doctype == "Payment Entry" + and frappe.db.get_value("Payment Entry", docname, "payment_type") == "Internal Transfer" + and len(get_reconciled_bank_transactions(doctype, docname)) < 2 + ): + return + frappe.db.set_value(doctype, docname, "clearance_date", clearance_date) + + elif doctype == "Sales Invoice": + frappe.db.set_value( + "Sales Invoice Payment", + dict(parenttype=doctype, parent=docname), + "clearance_date", + clearance_date, + ) + + elif doctype == "Bank Transaction": + # For when a second bank transaction has fixed another, e.g. refund + bt = frappe.get_doc(doctype, docname) + if clearance_date: + vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}] + bt.add_payment_entries(vouchers) else: - frappe.db.set_value(doc.payment_document, doc.payment_entry, "clearance_date", None) + for pe in bt.payment_entries: + if pe.payment_document == self.doctype and pe.payment_entry == self.name: + bt.remove(pe) + bt.save() + break + - return doc.payment_entry +def get_reconciled_bank_transactions(doctype, docname): + return frappe.get_all( + "Bank Transaction Payments", + filters={"payment_document": doctype, "payment_entry": docname}, + pluck="parent", + ) + + +@frappe.whitelist() +def unclear_reference_payment(doctype, docname, bt_name): + bt = frappe.get_doc("Bank Transaction", bt_name) + set_voucher_clearance(doctype, docname, None, bt) + return docname diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index a5d04137995d..f900e0775ce9 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -5,6 +5,7 @@ import unittest import frappe +from frappe import utils from frappe.tests.utils import FrappeTestCase from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import ( @@ -40,7 +41,12 @@ def test_linked_payments(self): "Bank Transaction", dict(description="Re 95282925234 FE/000002917 AT171513000281183046 Conrad Electronic"), ) - linked_payments = get_linked_payments(bank_transaction.name, ["payment_entry", "exact_match"]) + linked_payments = get_linked_payments( + bank_transaction.name, + ["payment_entry", "exact_match"], + from_date=bank_transaction.date, + to_date=utils.today(), + ) self.assertTrue(linked_payments[0][6] == "Conrad Electronic") # This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment @@ -81,7 +87,12 @@ def test_debit_credit_output(self): "Bank Transaction", dict(description="Auszahlung Karte MC/000002916 AUTOMAT 698769 K002 27.10. 14:07"), ) - linked_payments = get_linked_payments(bank_transaction.name, ["payment_entry", "exact_match"]) + linked_payments = get_linked_payments( + bank_transaction.name, + ["payment_entry", "exact_match"], + from_date=bank_transaction.date, + to_date=utils.today(), + ) self.assertTrue(linked_payments[0][3]) # Check error if already reconciled diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 637ac7a04cec..ef2a7621f841 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -184,6 +184,11 @@ def validate_budget_records(args, budget_records, expense_amount): amount = expense_amount or get_amount(args, budget) yearly_action, monthly_action = get_actions(args, budget) + if yearly_action in ("Stop", "Warn"): + compare_expense_with_budget( + args, flt(budget.budget_amount), _("Annual"), yearly_action, budget.budget_against, amount + ) + if monthly_action in ["Stop", "Warn"]: budget_amount = get_accumulated_monthly_budget( budget.monthly_distribution, args.posting_date, args.fiscal_year, budget.budget_amount @@ -195,28 +200,28 @@ def validate_budget_records(args, budget_records, expense_amount): args, budget_amount, _("Accumulated Monthly"), monthly_action, budget.budget_against, amount ) - if ( - yearly_action in ("Stop", "Warn") - and monthly_action != "Stop" - and yearly_action != monthly_action - ): - compare_expense_with_budget( - args, flt(budget.budget_amount), _("Annual"), yearly_action, budget.budget_against, amount - ) - def compare_expense_with_budget(args, budget_amount, action_for, action, budget_against, amount=0): - actual_expense = amount or get_actual_expense(args) - if actual_expense > budget_amount: - diff = actual_expense - budget_amount + actual_expense = get_actual_expense(args) + total_expense = actual_expense + amount + + if total_expense > budget_amount: + if actual_expense > budget_amount: + error_tense = _("is already") + diff = actual_expense - budget_amount + else: + error_tense = _("will be") + diff = total_expense - budget_amount + currency = frappe.get_cached_value("Company", args.company, "default_currency") - msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It will exceed by {5}").format( + msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It {5} exceed by {6}").format( _(action_for), frappe.bold(args.account), - args.budget_against_field, + frappe.unscrub(args.budget_against_field), frappe.bold(budget_against), frappe.bold(fmt_money(budget_amount, currency=currency)), + error_tense, frappe.bold(fmt_money(diff, currency=currency)), ) @@ -227,9 +232,9 @@ def compare_expense_with_budget(args, budget_amount, action_for, action, budget_ action = "Warn" if action == "Stop": - frappe.throw(msg, BudgetError) + frappe.throw(msg, BudgetError, title=_("Budget Exceeded")) else: - frappe.msgprint(msg, indicator="orange") + frappe.msgprint(msg, indicator="orange", title=_("Budget Exceeded")) def get_actions(args, budget): @@ -351,7 +356,9 @@ def get_actual_expense(args): """ select sum(gle.debit) - sum(gle.credit) from `tabGL Entry` gle - where gle.account=%(account)s + where + is_cancelled = 0 + and gle.account=%(account)s {condition1} and gle.fiscal_year=%(fiscal_year)s and gle.company=%(company)s diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py index c88a00077c74..d475fc01172a 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py @@ -39,7 +39,7 @@ def validate_columns(data): no_of_columns = max([len(d) for d in data]) - if no_of_columns > 7: + if no_of_columns > 8: frappe.throw( _( "More columns found than expected. Please compare the uploaded file with standard template" @@ -243,7 +243,7 @@ def return_parent(data, child): is_group, account_type, root_type, - *others, + account_currency, ) = row if not account_name: @@ -262,8 +262,8 @@ def return_parent(data, child): charts_map[account_name]["root_type"] = root_type if account_number: charts_map[account_name]["account_number"] = account_number - if others and others[0]: # currency - charts_map[account_name]["account_currency"] = others[0] + if account_currency: + charts_map[account_name]["account_currency"] = account_currency # create a list of paths path = return_parent(data, account_name) @@ -329,21 +329,21 @@ def get_template(template_type): "Is Group", "Account Type", "Root Type", - "Currency", + "Account Currency", ] writer = UnicodeWriter() writer.writerow(fields) if template_type == "Blank Template": for root_type in get_root_types(): - writer.writerow(["", "", "", 1, "", root_type]) + writer.writerow(["", "", "", "", 1, "", root_type]) for account in get_mandatory_group_accounts(): - writer.writerow(["", "", "", 1, account, "Asset"]) + writer.writerow(["", "", "", "", 1, account, "Asset"]) for account_type in get_mandatory_account_types(): writer.writerow( - ["", "", "", 0, account_type.get("account_type"), account_type.get("root_type")] + ["", "", "", "", 0, account_type.get("account_type"), account_type.get("root_type")] ) else: writer = get_sample_template(writer) diff --git a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py index d25016fe596c..54ffe21a1522 100644 --- a/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py +++ b/erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py @@ -28,9 +28,14 @@ class InvalidDateError(frappe.ValidationError): class CostCenterAllocation(Document): + def __init__(self, *args, **kwargs): + super(CostCenterAllocation, self).__init__(*args, **kwargs) + self._skip_from_date_validation = False + def validate(self): self.validate_total_allocation_percentage() - self.validate_from_date_based_on_existing_gle() + if not self._skip_from_date_validation: + self.validate_from_date_based_on_existing_gle() self.validate_backdated_allocation() self.validate_main_cost_center() self.validate_child_cost_centers() diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index 9874d66fa553..7347865563a1 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -40,7 +40,7 @@ def on_submit(self): def on_cancel(self): if self.dunning_amount: - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) def make_gl_entries(self): diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index d67d59b5d455..81c2d8bb7389 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -211,8 +211,7 @@ def calculate_new_account_balance(company, posting_date, account_details): # Handle Accounts with '0' balance in Account/Base Currency for d in [x for x in account_details if x.zero_balance]: - # TODO: Set new balance in Base/Account currency - if d.balance > 0: + if d.balance != 0: current_exchange_rate = new_exchange_rate = 0 new_balance_in_account_currency = 0 # this will be '0' @@ -399,6 +398,9 @@ def make_jv_for_revaluation(self): journal_entry_accounts = [] for d in accounts: + if not flt(d.get("balance_in_account_currency"), d.precision("balance_in_account_currency")): + continue + dr_or_cr = ( "debit_in_account_currency" if d.get("balance_in_account_currency") > 0 @@ -448,7 +450,13 @@ def make_jv_for_revaluation(self): } ) - journal_entry_accounts.append( + journal_entry.set("accounts", journal_entry_accounts) + journal_entry.set_amounts_in_company_currency() + journal_entry.set_total_debit_credit() + + self.gain_loss_unbooked += journal_entry.difference - self.gain_loss_unbooked + journal_entry.append( + "accounts", { "account": unrealized_exchange_gain_loss_account, "balance": get_balance_on(unrealized_exchange_gain_loss_account), @@ -460,10 +468,9 @@ def make_jv_for_revaluation(self): "exchange_rate": 1, "reference_type": "Exchange Rate Revaluation", "reference_name": self.name, - } + }, ) - journal_entry.set("accounts", journal_entry_accounts) journal_entry.set_amounts_in_company_currency() journal_entry.set_total_debit_credit() journal_entry.save() @@ -483,6 +490,8 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party): conditions.append(gl.company == company) conditions.append(gl.account == account) conditions.append(gl.is_cancelled == 0) + conditions.append((gl.debit > 0) | (gl.credit > 0)) + conditions.append((gl.debit_in_account_currency > 0) | (gl.credit_in_account_currency > 0)) if party_type: conditions.append(gl.party_type == party_type) if party: diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 30a32015f5d4..089f20b467df 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -8,7 +8,7 @@ frappe.provide("erpnext.journal_entry"); frappe.ui.form.on("Journal Entry", { setup: function(frm) { frm.add_fetch("bank_account", "account", "account"); - frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice']; + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger"]; }, refresh: function(frm) { diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 3f69d5c7cd8d..80e72226d3d3 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -539,7 +539,7 @@ "idx": 176, "is_submittable": 1, "links": [], - "modified": "2022-11-28 17:40:01.241908", + "modified": "2023-03-01 14:58:59.286591", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry", diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 29a8f503f562..62916cb9d533 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -51,7 +51,7 @@ def validate(self): self.validate_multi_currency() self.set_amounts_in_company_currency() self.validate_debit_credit_amount() - + self.set_total_debit_credit() # Do not validate while importing via data import if not frappe.flags.in_import: self.validate_total_debit_and_credit() @@ -81,6 +81,7 @@ def on_submit(self): self.check_credit_limit() self.make_gl_entries() self.update_advance_paid() + self.update_asset_value() self.update_inter_company_jv() self.update_invoice_discounting() @@ -88,7 +89,13 @@ def on_cancel(self): from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries unlink_ref_doc_from_payment_entries(self) - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") + self.ignore_linked_doctypes = ( + "GL Entry", + "Stock Ledger Entry", + "Payment Ledger Entry", + "Repost Payment Ledger", + "Repost Payment Ledger Items", + ) self.make_gl_entries(1) self.update_advance_paid() self.unlink_advance_entry_reference() @@ -225,6 +232,29 @@ def apply_tax_withholding(self): for d in to_remove: self.remove(d) + def update_asset_value(self): + if self.voucher_type != "Depreciation Entry": + return + + processed_assets = [] + + for d in self.get("accounts"): + if ( + d.reference_type == "Asset" and d.reference_name and d.reference_name not in processed_assets + ): + processed_assets.append(d.reference_name) + + asset = frappe.get_doc("Asset", d.reference_name) + + if asset.calculate_depreciation: + continue + + depr_value = d.debit or d.credit + + asset.db_set("value_after_depreciation", asset.value_after_depreciation - depr_value) + + asset.set_status() + def update_inter_company_jv(self): if ( self.voucher_type == "Inter Company Journal Entry" @@ -283,19 +313,38 @@ def unlink_advance_entry_reference(self): d.db_update() def unlink_asset_reference(self): + if self.voucher_type != "Depreciation Entry": + return + + processed_assets = [] + for d in self.get("accounts"): - if d.reference_type == "Asset" and d.reference_name: + if ( + d.reference_type == "Asset" and d.reference_name and d.reference_name not in processed_assets + ): + processed_assets.append(d.reference_name) + asset = frappe.get_doc("Asset", d.reference_name) - for s in asset.get("schedules"): - if s.journal_entry == self.name: - s.db_set("journal_entry", None) - idx = cint(s.finance_book_id) or 1 - finance_books = asset.get("finance_books")[idx - 1] - finance_books.value_after_depreciation += s.depreciation_amount - finance_books.db_update() + if asset.calculate_depreciation: + for s in asset.get("schedules"): + if s.journal_entry == self.name: + s.db_set("journal_entry", None) + + idx = cint(s.finance_book_id) or 1 + finance_books = asset.get("finance_books")[idx - 1] + finance_books.value_after_depreciation += s.depreciation_amount + finance_books.db_update() - asset.set_status() + asset.set_status() + + break + else: + depr_value = d.debit or d.credit + + asset.db_set("value_after_depreciation", asset.value_after_depreciation + depr_value) + + asset.set_status() def unlink_inter_company_jv(self): if ( @@ -592,15 +641,15 @@ def set_against_account(self): d.against_account = frappe.db.get_value(d.reference_type, d.reference_name, field) else: for d in self.get("accounts"): - if flt(d.debit > 0): + if flt(d.debit) > 0: accounts_debited.append(d.party or d.account) if flt(d.credit) > 0: accounts_credited.append(d.party or d.account) for d in self.get("accounts"): - if flt(d.debit > 0): + if flt(d.debit) > 0: d.against_account = ", ".join(list(set(accounts_credited))) - if flt(d.credit > 0): + if flt(d.credit) > 0: d.against_account = ", ".join(list(set(accounts_debited))) def validate_debit_credit_amount(self): @@ -610,7 +659,6 @@ def validate_debit_credit_amount(self): frappe.throw(_("Row {0}: Both Debit and Credit values cannot be zero").format(d.idx)) def validate_total_debit_and_credit(self): - self.set_total_debit_credit() if not (self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency): if self.difference: frappe.throw( @@ -764,7 +812,7 @@ def set_print_format_fields(self): pay_to_recd_from = d.party if pay_to_recd_from and pay_to_recd_from == d.party: - party_amount += d.debit_in_account_currency or d.credit_in_account_currency + party_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency) party_account_currency = d.account_currency elif frappe.db.get_value("Account", d.account, "account_type") in ["Bank", "Cash"]: @@ -842,7 +890,7 @@ def make_gl_entries(self, cancel=0, adv_adj=0): make_gl_entries(gl_map, cancel=cancel, adv_adj=adv_adj, update_outstanding=update_outstanding) @frappe.whitelist() - def get_balance(self): + def get_balance(self, difference_account=None): if not self.get("accounts"): msgprint(_("'Entries' cannot be empty"), raise_exception=True) else: @@ -857,7 +905,13 @@ def get_balance(self): blank_row = d if not blank_row: - blank_row = self.append("accounts", {}) + blank_row = self.append( + "accounts", + { + "account": difference_account, + "cost_center": erpnext.get_default_cost_center(self.company), + }, + ) blank_row.exchange_rate = 1 if diff > 0: diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 6039bdfe95fd..5a56a6b00466 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -7,7 +7,7 @@ cur_frm.cscript.tax_table = "Advance Taxes and Charges"; frappe.ui.form.on('Payment Entry', { onload: function(frm) { - frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice']; + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Repost Payment Ledger"]; if(frm.doc.__islocal) { if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); @@ -245,8 +245,6 @@ frappe.ui.form.on('Payment Entry', { frm.set_currency_labels(["total_amount", "outstanding_amount", "allocated_amount"], party_account_currency, "references"); - frm.set_currency_labels(["amount"], company_currency, "deductions"); - cur_frm.set_df_property("source_exchange_rate", "description", ("1 " + frm.doc.paid_from_account_currency + " = [?] " + company_currency)); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index 4a7a57b6275e..3927ecae43da 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -239,7 +239,7 @@ "depends_on": "paid_from", "fieldname": "paid_from_account_currency", "fieldtype": "Link", - "label": "Account Currency", + "label": "Account Currency (From)", "options": "Currency", "print_hide": 1, "read_only": 1, @@ -249,7 +249,7 @@ "depends_on": "paid_from", "fieldname": "paid_from_account_balance", "fieldtype": "Currency", - "label": "Account Balance", + "label": "Account Balance (From)", "options": "paid_from_account_currency", "print_hide": 1, "read_only": 1 @@ -272,7 +272,7 @@ "depends_on": "paid_to", "fieldname": "paid_to_account_currency", "fieldtype": "Link", - "label": "Account Currency", + "label": "Account Currency (To)", "options": "Currency", "print_hide": 1, "read_only": 1, @@ -282,7 +282,7 @@ "depends_on": "paid_to", "fieldname": "paid_to_account_balance", "fieldtype": "Currency", - "label": "Account Balance", + "label": "Account Balance (To)", "options": "paid_to_account_currency", "print_hide": 1, "read_only": 1 @@ -304,7 +304,7 @@ { "fieldname": "source_exchange_rate", "fieldtype": "Float", - "label": "Exchange Rate", + "label": "Source Exchange Rate", "precision": "9", "print_hide": 1, "reqd": 1 @@ -334,7 +334,7 @@ { "fieldname": "target_exchange_rate", "fieldtype": "Float", - "label": "Exchange Rate", + "label": "Target Exchange Rate", "precision": "9", "print_hide": 1, "reqd": 1 @@ -633,14 +633,14 @@ "depends_on": "eval:doc.party_type == 'Supplier'", "fieldname": "purchase_taxes_and_charges_template", "fieldtype": "Link", - "label": "Taxes and Charges Template", + "label": "Purchase Taxes and Charges Template", "options": "Purchase Taxes and Charges Template" }, { "depends_on": "eval: doc.party_type == 'Customer'", "fieldname": "sales_taxes_and_charges_template", "fieldtype": "Link", - "label": "Taxes and Charges Template", + "label": "Sales Taxes and Charges Template", "options": "Sales Taxes and Charges Template" }, { @@ -733,7 +733,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-12-08 16:25:43.824051", + "modified": "2023-02-14 04:52:30.478523", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry", diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 5dbc91654f97..58ed7d1822c0 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -92,7 +92,13 @@ def on_submit(self): self.set_status() def on_cancel(self): - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") + self.ignore_linked_doctypes = ( + "GL Entry", + "Stock Ledger Entry", + "Payment Ledger Entry", + "Repost Payment Ledger", + "Repost Payment Ledger Items", + ) self.make_gl_entries(cancel=1) self.update_outstanding_amounts() self.update_advance_paid() @@ -247,7 +253,7 @@ def set_exchange_rate(self, ref_doc=None): self.set_target_exchange_rate(ref_doc) def set_source_exchange_rate(self, ref_doc=None): - if self.paid_from and not self.source_exchange_rate: + if self.paid_from: if self.paid_from_account_currency == self.company_currency: self.source_exchange_rate = 1 else: @@ -410,7 +416,7 @@ def update_payment_schedule(self, cancel=0): for ref in self.get("references"): if ref.payment_term and ref.reference_name: - key = (ref.payment_term, ref.reference_name) + key = (ref.payment_term, ref.reference_name, ref.reference_doctype) invoice_payment_amount_map.setdefault(key, 0.0) invoice_payment_amount_map[key] += ref.allocated_amount @@ -418,20 +424,37 @@ def update_payment_schedule(self, cancel=0): payment_schedule = frappe.get_all( "Payment Schedule", filters={"parent": ref.reference_name}, - fields=["paid_amount", "payment_amount", "payment_term", "discount", "outstanding"], + fields=[ + "paid_amount", + "payment_amount", + "payment_term", + "discount", + "outstanding", + "discount_type", + ], ) for term in payment_schedule: - invoice_key = (term.payment_term, ref.reference_name) + invoice_key = (term.payment_term, ref.reference_name, ref.reference_doctype) invoice_paid_amount_map.setdefault(invoice_key, {}) invoice_paid_amount_map[invoice_key]["outstanding"] = term.outstanding - invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * ( - term.discount / 100 - ) + if not (term.discount_type and term.discount): + continue + + if term.discount_type == "Percentage": + invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * ( + term.discount / 100 + ) + else: + invoice_paid_amount_map[invoice_key]["discounted_amt"] = term.discount for idx, (key, allocated_amount) in enumerate(invoice_payment_amount_map.items(), 1): if not invoice_paid_amount_map.get(key): frappe.throw(_("Payment term {0} not used in {1}").format(key[0], key[1])) + allocated_amount = self.get_allocated_amount_in_transaction_currency( + allocated_amount, key[2], key[1] + ) + outstanding = flt(invoice_paid_amount_map.get(key, {}).get("outstanding")) discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get("discounted_amt")) @@ -466,6 +489,33 @@ def update_payment_schedule(self, cancel=0): (allocated_amount - discounted_amt, discounted_amt, allocated_amount, key[1], key[0]), ) + def get_allocated_amount_in_transaction_currency( + self, allocated_amount, reference_doctype, reference_docname + ): + """ + Payment Entry could be in base currency while reference's payment schedule + is always in transaction currency. + E.g. + * SI with base=INR and currency=USD + * SI with payment schedule in USD + * PE in INR (accounting done in base currency) + """ + ref_currency, ref_exchange_rate = frappe.db.get_value( + reference_doctype, reference_docname, ["currency", "conversion_rate"] + ) + is_single_currency = self.paid_from_account_currency == self.paid_to_account_currency + # PE in different currency + reference_is_multi_currency = self.paid_from_account_currency != ref_currency + + if not (is_single_currency and reference_is_multi_currency): + return allocated_amount + + allocated_amount = flt( + allocated_amount / ref_exchange_rate, self.precision("total_allocated_amount") + ) + + return allocated_amount + def set_status(self): if self.docstatus == 2: self.status = "Cancelled" @@ -622,7 +672,7 @@ def set_unallocated_amount(self): self.payment_type == "Receive" and self.base_total_allocated_amount < self.base_received_amount + total_deductions and self.total_allocated_amount - < self.paid_amount + (total_deductions / self.source_exchange_rate) + < flt(self.paid_amount) + (total_deductions / self.source_exchange_rate) ): self.unallocated_amount = ( self.base_received_amount + total_deductions - self.base_total_allocated_amount @@ -632,7 +682,7 @@ def set_unallocated_amount(self): self.payment_type == "Pay" and self.base_total_allocated_amount < (self.base_paid_amount - total_deductions) and self.total_allocated_amount - < self.received_amount + (total_deductions / self.target_exchange_rate) + < flt(self.received_amount) + (total_deductions / self.target_exchange_rate) ): self.unallocated_amount = ( self.base_paid_amount - (total_deductions + self.base_total_allocated_amount) @@ -1636,7 +1686,14 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre @frappe.whitelist() def get_payment_entry( - dt, dn, party_amount=None, bank_account=None, bank_amount=None, party_type=None, payment_type=None + dt, + dn, + party_amount=None, + bank_account=None, + bank_amount=None, + party_type=None, + payment_type=None, + reference_date=None, ): reference_doc = None doc = frappe.get_doc(dt, dn) @@ -1663,8 +1720,9 @@ def get_payment_entry( dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc ) - paid_amount, received_amount, discount_amount = apply_early_payment_discount( - paid_amount, received_amount, doc + reference_date = getdate(reference_date) + paid_amount, received_amount, discount_amount, valid_discounts = apply_early_payment_discount( + paid_amount, received_amount, doc, party_account_currency, reference_date ) pe = frappe.new_doc("Payment Entry") @@ -1672,6 +1730,7 @@ def get_payment_entry( pe.company = doc.company pe.cost_center = doc.get("cost_center") pe.posting_date = nowdate() + pe.reference_date = reference_date pe.mode_of_payment = doc.get("mode_of_payment") pe.party_type = party_type pe.party = doc.get(scrub(party_type)) @@ -1712,7 +1771,7 @@ def get_payment_entry( ): for reference in get_reference_as_per_payment_terms( - doc.payment_schedule, dt, dn, doc, grand_total, outstanding_amount + doc.payment_schedule, dt, dn, doc, grand_total, outstanding_amount, party_account_currency ): pe.append("references", reference) else: @@ -1763,16 +1822,17 @@ def get_payment_entry( if party_account and bank: pe.set_exchange_rate(ref_doc=reference_doc) pe.set_amounts() + if discount_amount: - pe.set_gain_or_loss( - account_details={ - "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), - "cost_center": pe.cost_center - or frappe.get_cached_value("Company", pe.company, "cost_center"), - "amount": discount_amount * (-1 if payment_type == "Pay" else 1), - } + base_total_discount_loss = 0 + if frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss"): + base_total_discount_loss = split_early_payment_discount_loss(pe, doc, valid_discounts) + + set_pending_discount_loss( + pe, doc, discount_amount, base_total_discount_loss, party_account_currency ) - pe.set_difference_amount() + + pe.set_difference_amount() return pe @@ -1883,20 +1943,28 @@ def set_paid_amount_and_received_amount( return paid_amount, received_amount -def apply_early_payment_discount(paid_amount, received_amount, doc): +def apply_early_payment_discount( + paid_amount, received_amount, doc, party_account_currency, reference_date +): total_discount = 0 + valid_discounts = [] eligible_for_payments = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"] has_payment_schedule = hasattr(doc, "payment_schedule") and doc.payment_schedule + is_multi_currency = party_account_currency != doc.company_currency if doc.doctype in eligible_for_payments and has_payment_schedule: for term in doc.payment_schedule: - if not term.discounted_amount and term.discount and getdate(nowdate()) <= term.discount_date: + if not term.discounted_amount and term.discount and reference_date <= term.discount_date: + if term.discount_type == "Percentage": - discount_amount = flt(doc.get("grand_total")) * (term.discount / 100) + grand_total = doc.get("grand_total") if is_multi_currency else doc.get("base_grand_total") + discount_amount = flt(grand_total) * (term.discount / 100) else: discount_amount = term.discount - discount_amount_in_foreign_currency = discount_amount * doc.get("conversion_rate", 1) + # if accounting is done in the same currency, paid_amount = received_amount + conversion_rate = doc.get("conversion_rate", 1) if is_multi_currency else 1 + discount_amount_in_foreign_currency = discount_amount * conversion_rate if doc.doctype == "Sales Invoice": paid_amount -= discount_amount @@ -1905,23 +1973,151 @@ def apply_early_payment_discount(paid_amount, received_amount, doc): received_amount -= discount_amount paid_amount -= discount_amount_in_foreign_currency + valid_discounts.append({"type": term.discount_type, "discount": term.discount}) total_discount += discount_amount if total_discount: - money = frappe.utils.fmt_money(total_discount, currency=doc.get("currency")) + currency = doc.get("currency") if is_multi_currency else doc.company_currency + money = frappe.utils.fmt_money(total_discount, currency=currency) frappe.msgprint(_("Discount of {} applied as per Payment Term").format(money), alert=1) - return paid_amount, received_amount, total_discount + return paid_amount, received_amount, total_discount, valid_discounts + + +def set_pending_discount_loss( + pe, doc, discount_amount, base_total_discount_loss, party_account_currency +): + # If multi-currency, get base discount amount to adjust with base currency deductions/losses + if party_account_currency != doc.company_currency: + discount_amount = discount_amount * doc.get("conversion_rate", 1) + + # Avoid considering miniscule losses + discount_amount = flt(discount_amount - base_total_discount_loss, doc.precision("grand_total")) + + # Set base discount amount (discount loss/pending rounding loss) in deductions + if discount_amount > 0.0: + positive_negative = -1 if pe.payment_type == "Pay" else 1 + + # If tax loss booking is enabled, pending loss will be rounding loss. + # Otherwise it will be the total discount loss. + book_tax_loss = frappe.db.get_single_value("Accounts Settings", "book_tax_discount_loss") + account_type = "round_off_account" if book_tax_loss else "default_discount_account" + + pe.set_gain_or_loss( + account_details={ + "account": frappe.get_cached_value("Company", pe.company, account_type), + "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), + "amount": discount_amount * positive_negative, + } + ) + + +def split_early_payment_discount_loss(pe, doc, valid_discounts) -> float: + """Split early payment discount into Income Loss & Tax Loss.""" + total_discount_percent = get_total_discount_percent(doc, valid_discounts) + + if not total_discount_percent: + return 0.0 + + base_loss_on_income = add_income_discount_loss(pe, doc, total_discount_percent) + base_loss_on_taxes = add_tax_discount_loss(pe, doc, total_discount_percent) + + # Round off total loss rather than individual losses to reduce rounding error + return flt(base_loss_on_income + base_loss_on_taxes, doc.precision("grand_total")) + + +def get_total_discount_percent(doc, valid_discounts) -> float: + """Get total percentage and amount discount applied as a percentage.""" + total_discount_percent = ( + sum( + discount.get("discount") for discount in valid_discounts if discount.get("type") == "Percentage" + ) + or 0.0 + ) + + # Operate in percentages only as it makes the income & tax split easier + total_discount_amount = ( + sum(discount.get("discount") for discount in valid_discounts if discount.get("type") == "Amount") + or 0.0 + ) + + if total_discount_amount: + discount_percentage = (total_discount_amount / doc.get("grand_total")) * 100 + total_discount_percent += discount_percentage + return total_discount_percent + + return total_discount_percent + + +def add_income_discount_loss(pe, doc, total_discount_percent) -> float: + """Add loss on income discount in base currency.""" + precision = doc.precision("total") + base_loss_on_income = doc.get("base_total") * (total_discount_percent / 100) + + pe.append( + "deductions", + { + "account": frappe.get_cached_value("Company", pe.company, "default_discount_account"), + "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), + "amount": flt(base_loss_on_income, precision), + }, + ) + + return base_loss_on_income # Return loss without rounding + + +def add_tax_discount_loss(pe, doc, total_discount_percentage) -> float: + """Add loss on tax discount in base currency.""" + tax_discount_loss = {} + base_total_tax_loss = 0 + precision = doc.precision("tax_amount_after_discount_amount", "taxes") + + # The same account head could be used more than once + for tax in doc.get("taxes", []): + base_tax_loss = tax.get("base_tax_amount_after_discount_amount") * ( + total_discount_percentage / 100 + ) + + account = tax.get("account_head") + if not tax_discount_loss.get(account): + tax_discount_loss[account] = base_tax_loss + else: + tax_discount_loss[account] += base_tax_loss + + for account, loss in tax_discount_loss.items(): + base_total_tax_loss += loss + if loss == 0.0: + continue + + pe.append( + "deductions", + { + "account": account, + "cost_center": pe.cost_center or frappe.get_cached_value("Company", pe.company, "cost_center"), + "amount": flt(loss, precision), + }, + ) + + return base_total_tax_loss # Return loss without rounding def get_reference_as_per_payment_terms( - payment_schedule, dt, dn, doc, grand_total, outstanding_amount + payment_schedule, dt, dn, doc, grand_total, outstanding_amount, party_account_currency ): references = [] + is_multi_currency_acc = (doc.currency != doc.company_currency) and ( + party_account_currency != doc.company_currency + ) + for payment_term in payment_schedule: payment_term_outstanding = flt( payment_term.payment_amount - payment_term.paid_amount, payment_term.precision("payment_amount") ) + if not is_multi_currency_acc: + # If accounting is done in company currency for multi-currency transaction + payment_term_outstanding = flt( + payment_term_outstanding * doc.get("conversion_rate"), payment_term.precision("payment_amount") + ) if payment_term_outstanding: references.append( diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 123b5dfd512b..67049c47ad05 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -5,7 +5,7 @@ import frappe from frappe import qb -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import flt, nowdate from erpnext.accounts.doctype.payment_entry.payment_entry import ( @@ -256,10 +256,25 @@ def test_payment_entry_against_payment_terms_with_discount(self): }, ) si.save() - si.submit() + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 1) + pe_with_tax_loss = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + + self.assertEqual(pe_with_tax_loss.references[0].payment_term, "30 Credit Days with 10% Discount") + self.assertEqual(pe_with_tax_loss.references[0].allocated_amount, 236.0) + self.assertEqual(pe_with_tax_loss.paid_amount, 212.4) + self.assertEqual(pe_with_tax_loss.deductions[0].amount, 20.0) # Loss on Income + self.assertEqual(pe_with_tax_loss.deductions[1].amount, 3.6) # Loss on Tax + self.assertEqual(pe_with_tax_loss.deductions[1].account, "_Test Account Service Tax - _TC") + + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 0) pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + + self.assertEqual(pe.references[0].allocated_amount, 236.0) + self.assertEqual(pe.paid_amount, 212.4) + self.assertEqual(pe.deductions[0].amount, 23.6) + pe.submit() si.load_from_db() @@ -269,6 +284,190 @@ def test_payment_entry_against_payment_terms_with_discount(self): self.assertEqual(si.payment_schedule[0].outstanding, 0) self.assertEqual(si.payment_schedule[0].discounted_amount, 23.6) + def test_payment_entry_against_payment_terms_with_discount_amount(self): + si = create_sales_invoice(do_not_save=1, qty=1, rate=200) + + si.payment_terms_template = "Test Discount Amount Template" + create_payment_terms_template_with_discount( + name="30 Credit Days with Rs.50 Discount", + discount_type="Amount", + discount=50, + template_name="Test Discount Amount Template", + ) + frappe.db.set_value("Company", si.company, "default_discount_account", "Write Off - _TC") + + si.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 18, + }, + ) + si.save() + si.submit() + + # Set reference date past discount cut off date + pe_1 = get_payment_entry( + "Sales Invoice", + si.name, + bank_account="_Test Cash - _TC", + reference_date=frappe.utils.add_days(si.posting_date, 2), + ) + self.assertEqual(pe_1.paid_amount, 236.0) # discount not applied + + # Test if tax loss is booked on enabling configuration + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 1) + pe_with_tax_loss = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + self.assertEqual(pe_with_tax_loss.deductions[0].amount, 42.37) # Loss on Income + self.assertEqual(pe_with_tax_loss.deductions[1].amount, 7.63) # Loss on Tax + self.assertEqual(pe_with_tax_loss.deductions[1].account, "_Test Account Service Tax - _TC") + + frappe.db.set_single_value("Accounts Settings", "book_tax_discount_loss", 0) + pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Cash - _TC") + self.assertEqual(pe.references[0].allocated_amount, 236.0) + self.assertEqual(pe.paid_amount, 186) + self.assertEqual(pe.deductions[0].amount, 50.0) + + pe.submit() + si.load_from_db() + + self.assertEqual(si.payment_schedule[0].payment_amount, 236.0) + self.assertEqual(si.payment_schedule[0].paid_amount, 186) + self.assertEqual(si.payment_schedule[0].outstanding, 0) + self.assertEqual(si.payment_schedule[0].discounted_amount, 50) + + @change_settings( + "Accounts Settings", + { + "allow_multi_currency_invoices_against_single_party_account": 1, + "book_tax_discount_loss": 1, + }, + ) + def test_payment_entry_multicurrency_si_with_base_currency_accounting_early_payment_discount( + self, + ): + """ + 1. Multi-currency SI with single currency accounting (company currency) + 2. PE with early payment discount + 3. Test if Paid Amount is calculated in company currency + 4. Test if deductions are calculated in company currency + + SI is in USD to document agreed amounts that are in USD, but the accounting is in base currency. + """ + si = create_sales_invoice( + customer="_Test Customer", + currency="USD", + conversion_rate=50, + do_not_save=1, + ) + create_payment_terms_template_with_discount() + si.payment_terms_template = "Test Discount Template" + + frappe.db.set_value("Company", si.company, "default_discount_account", "Write Off - _TC") + si.save() + si.submit() + + pe = get_payment_entry( + "Sales Invoice", + si.name, + bank_account="_Test Bank - _TC", + ) + pe.reference_no = si.name + pe.reference_date = nowdate() + + # Early payment discount loss on income + self.assertEqual(pe.paid_amount, 4500.0) # Amount in company currency + self.assertEqual(pe.received_amount, 4500.0) + self.assertEqual(pe.deductions[0].amount, 500.0) + self.assertEqual(pe.deductions[0].account, "Write Off - _TC") + self.assertEqual(pe.difference_amount, 0.0) + + pe.insert() + pe.submit() + + expected_gle = dict( + (d[0], d) + for d in [ + ["Debtors - _TC", 0, 5000, si.name], + ["_Test Bank - _TC", 4500, 0, None], + ["Write Off - _TC", 500.0, 0, None], + ] + ) + + self.validate_gl_entries(pe.name, expected_gle) + + outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) + self.assertEqual(outstanding_amount, 0) + + def test_payment_entry_multicurrency_accounting_si_with_early_payment_discount(self): + """ + 1. Multi-currency SI with multi-currency accounting + 2. PE with early payment discount and also exchange loss + 3. Test if Paid Amount is calculated in transaction currency + 4. Test if deductions are calculated in base/company currency + 5. Test if exchange loss is reflected in difference + """ + si = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=50, + do_not_save=1, + ) + create_payment_terms_template_with_discount() + si.payment_terms_template = "Test Discount Template" + + frappe.db.set_value("Company", si.company, "default_discount_account", "Write Off - _TC") + si.save() + si.submit() + + pe = get_payment_entry( + "Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700 + ) + pe.reference_no = si.name + pe.reference_date = nowdate() + + # Early payment discount loss on income + self.assertEqual(pe.paid_amount, 90.0) + self.assertEqual(pe.received_amount, 4200.0) # 5000 - 500 (discount) - 300 (exchange loss) + self.assertEqual(pe.deductions[0].amount, 500.0) + self.assertEqual(pe.deductions[0].account, "Write Off - _TC") + + # Exchange loss + self.assertEqual(pe.difference_amount, 300.0) + + pe.append( + "deductions", + { + "account": "_Test Exchange Gain/Loss - _TC", + "cost_center": "_Test Cost Center - _TC", + "amount": 300.0, + }, + ) + + pe.insert() + pe.submit() + + self.assertEqual(pe.difference_amount, 0.0) + + expected_gle = dict( + (d[0], d) + for d in [ + ["_Test Receivable USD - _TC", 0, 5000, si.name], + ["_Test Bank - _TC", 4200, 0, None], + ["Write Off - _TC", 500.0, 0, None], + ["_Test Exchange Gain/Loss - _TC", 300.0, 0, None], + ] + ) + + self.validate_gl_entries(pe.name, expected_gle) + + outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) + self.assertEqual(outstanding_amount, 0) + def test_payment_against_purchase_invoice_to_check_status(self): pi = make_purchase_invoice( supplier="_Test Supplier USD", @@ -839,24 +1038,27 @@ def create_payment_terms_template(): ).insert() -def create_payment_terms_template_with_discount(): - - create_payment_term("30 Credit Days with 10% Discount") +def create_payment_terms_template_with_discount( + name=None, discount_type=None, discount=None, template_name=None +): + create_payment_term(name or "30 Credit Days with 10% Discount") + template_name = template_name or "Test Discount Template" - if not frappe.db.exists("Payment Terms Template", "Test Discount Template"): - payment_term_template = frappe.get_doc( + if not frappe.db.exists("Payment Terms Template", template_name): + frappe.get_doc( { "doctype": "Payment Terms Template", - "template_name": "Test Discount Template", + "template_name": template_name, "allocate_payment_based_on_payment_terms": 1, "terms": [ { "doctype": "Payment Terms Template Detail", - "payment_term": "30 Credit Days with 10% Discount", + "payment_term": name or "30 Credit Days with 10% Discount", "invoice_portion": 100, "credit_days_based_on": "Day(s) after invoice date", "credit_days": 2, - "discount": 10, + "discount_type": discount_type or "Percentage", + "discount": discount or 10, "discount_validity_based_on": "Day(s) after invoice date", "discount_validity": 1, } diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json index 61a1462dd7a4..1c31829f0ea6 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json @@ -3,6 +3,7 @@ "creation": "2016-06-15 15:56:30.815503", "doctype": "DocType", "editable_grid": 1, + "engine": "InnoDB", "field_order": [ "account", "cost_center", @@ -17,9 +18,7 @@ "in_list_view": 1, "label": "Account", "options": "Account", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "cost_center", @@ -28,37 +27,30 @@ "label": "Cost Center", "options": "Cost Center", "print_hide": 1, - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "Amount", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "label": "Amount (Company Currency)", + "options": "Company:company:default_currency", + "reqd": 1 }, { "fieldname": "column_break_2", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "fieldname": "description", "fieldtype": "Small Text", - "label": "Description", - "show_days": 1, - "show_seconds": 1 + "label": "Description" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-09-12 20:38:08.110674", + "modified": "2023-03-06 07:11:57.739619", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry Deduction", @@ -66,5 +58,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js index 8f09bc369129..aff067eab893 100644 --- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js +++ b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js @@ -3,6 +3,7 @@ frappe.ui.form.on('Payment Gateway Account', { refresh(frm) { + erpnext.utils.check_payments_app(); if(!frm.doc.__islocal) { frm.set_df_property('payment_gateway', 'read_only', 1); } diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index 0b334ae076df..caffac5354ff 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -170,7 +170,7 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo } reconcile() { - var show_dialog = this.frm.doc.allocation.filter(d => d.difference_amount && !d.difference_account); + var show_dialog = this.frm.doc.allocation.filter(d => d.difference_amount); if (show_dialog && show_dialog.length) { @@ -179,8 +179,12 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo title: __("Select Difference Account"), fields: [ { - fieldname: "allocation", fieldtype: "Table", label: __("Allocation"), - data: this.data, in_place_edit: true, + fieldname: "allocation", + fieldtype: "Table", + label: __("Allocation"), + data: this.data, + in_place_edit: true, + cannot_add_rows: true, get_data: () => { return this.data; }, @@ -218,6 +222,10 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo read_only: 1 }] }, + { + fieldtype: 'HTML', + options: " New Journal Entry will be posted for the difference amount " + } ], primary_action: () => { const args = dialog.get_values()["allocation"]; @@ -234,7 +242,7 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo }); this.frm.doc.allocation.forEach(d => { - if (d.difference_amount && !d.difference_account) { + if (d.difference_amount) { dialog.fields_dict.allocation.df.data.push({ 'docname': d.name, 'reference_name': d.reference_name, @@ -264,4 +272,32 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo } }; +frappe.ui.form.on('Payment Reconciliation Allocation', { + allocated_amount: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + // filter invoice + let invoice = frm.doc.invoices.filter((x) => (x.invoice_number == row.invoice_number)); + // filter payment + let payment = frm.doc.payments.filter((x) => (x.reference_name == row.reference_name)); + + frm.call({ + doc: frm.doc, + method: 'calculate_difference_on_allocation_change', + args: { + payment_entry: payment, + invoice: invoice, + allocated_amount: row.allocated_amount + }, + callback: (r) => { + if (r.message) { + row.difference_amount = r.message; + frm.refresh(); + } + } + }); + } +}); + + + extend_cscript(cur_frm.cscript, new erpnext.accounts.PaymentReconciliationController({frm: cur_frm})); diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index ff212f2a35f5..d8082d058f3f 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -14,7 +14,6 @@ QueryPaymentLedger, get_outstanding_invoices, reconcile_against_document, - update_reference_in_payment_entry, ) from erpnext.controllers.accounts_controller import get_advance_payment_entries @@ -70,6 +69,10 @@ def get_payment_entries(self): def get_jv_entries(self): condition = self.get_conditions() + + if self.get("cost_center"): + condition += f" and t2.cost_center = '{self.cost_center}' " + dr_or_cr = ( "credit_in_account_currency" if erpnext.get_party_account_type(self.party_type) == "Receivable" @@ -80,12 +83,13 @@ def get_jv_entries(self): "t2.against_account like %(bank_cash_account)s" if self.bank_cash_account else "1=1" ) + # nosemgrep journal_entries = frappe.db.sql( """ select "Journal Entry" as reference_type, t1.name as reference_name, t1.posting_date, t1.remark as remarks, t2.name as reference_row, - {dr_or_cr} as amount, t2.is_advance, + {dr_or_cr} as amount, t2.is_advance, t2.exchange_rate, t2.account_currency as currency from `tabJournal Entry` t1, `tabJournal Entry Account` t2 @@ -215,26 +219,38 @@ def add_invoice_entries(self, non_reconciled_invoices): inv.currency = entry.get("currency") inv.outstanding_amount = flt(entry.get("outstanding_amount")) - def get_difference_amount(self, allocated_entry): - if allocated_entry.get("reference_type") != "Payment Entry": - return - - dr_or_cr = ( - "credit_in_account_currency" - if erpnext.get_party_account_type(self.party_type) == "Receivable" - else "debit_in_account_currency" - ) - - row = self.get_payment_details(allocated_entry, dr_or_cr) + def get_difference_amount(self, payment_entry, invoice, allocated_amount): + difference_amount = 0 + if frappe.get_cached_value( + "Account", self.receivable_payable_account, "account_currency" + ) != frappe.get_cached_value("Company", self.company, "default_currency"): + if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get( + "exchange_rate", 1 + ): + allocated_amount_in_ref_rate = payment_entry.get("exchange_rate", 1) * allocated_amount + allocated_amount_in_inv_rate = invoice.get("exchange_rate", 1) * allocated_amount + difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate - doc = frappe.get_doc(allocated_entry.reference_type, allocated_entry.reference_name) - update_reference_in_payment_entry(row, doc, do_not_save=True) + return difference_amount - return doc.difference_amount + @frappe.whitelist() + def calculate_difference_on_allocation_change(self, payment_entry, invoice, allocated_amount): + invoice_exchange_map = self.get_invoice_exchange_map(invoice, payment_entry) + invoice[0]["exchange_rate"] = invoice_exchange_map.get(invoice[0].get("invoice_number")) + new_difference_amount = self.get_difference_amount( + payment_entry[0], invoice[0], allocated_amount + ) + return new_difference_amount @frappe.whitelist() def allocate_entries(self, args): self.validate_entries() + + invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments")) + default_exchange_gain_loss_account = frappe.get_cached_value( + "Company", self.company, "exchange_gain_loss_account" + ) + entries = [] for pay in args.get("payments"): pay.update({"unreconciled_amount": pay.get("amount")}) @@ -248,7 +264,13 @@ def allocate_entries(self, args): inv["outstanding_amount"] = flt(inv.get("outstanding_amount")) - flt(pay.get("amount")) pay["amount"] = 0 - res.difference_amount = self.get_difference_amount(res) + inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number")) + if pay.get("reference_type") in ["Sales Invoice", "Purchase Invoice"]: + pay["exchange_rate"] = invoice_exchange_map.get(pay.get("reference_name")) + + res.difference_amount = self.get_difference_amount(pay, inv, res["allocated_amount"]) + res.difference_account = default_exchange_gain_loss_account + res.exchange_rate = inv.get("exchange_rate") if pay.get("amount") == 0: entries.append(res) @@ -278,6 +300,7 @@ def get_allocated_entry(self, pay, inv, allocated_amount): "amount": pay.get("amount"), "allocated_amount": allocated_amount, "difference_amount": pay.get("difference_amount"), + "currency": inv.get("currency"), } ) @@ -300,7 +323,11 @@ def reconcile(self): else: reconciled_entry = entry_list - reconciled_entry.append(self.get_payment_details(row, dr_or_cr)) + payment_details = self.get_payment_details(row, dr_or_cr) + reconciled_entry.append(payment_details) + + if payment_details.difference_amount: + self.make_difference_entry(payment_details) if entry_list: reconcile_against_document(entry_list) @@ -311,6 +338,57 @@ def reconcile(self): msgprint(_("Successfully Reconciled")) self.get_unreconciled_entries() + def make_difference_entry(self, row): + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Exchange Gain Or Loss" + journal_entry.company = self.company + journal_entry.posting_date = nowdate() + journal_entry.multi_currency = 1 + + party_account_currency = frappe.get_cached_value( + "Account", self.receivable_payable_account, "account_currency" + ) + difference_account_currency = frappe.get_cached_value( + "Account", row.difference_account, "account_currency" + ) + + # Account Currency has balance + dr_or_cr = "debit" if self.party_type == "Customer" else "credit" + reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" + + journal_account = frappe._dict( + { + "account": self.receivable_payable_account, + "party_type": self.party_type, + "party": self.party, + "account_currency": party_account_currency, + "exchange_rate": 0, + "cost_center": erpnext.get_default_cost_center(self.company), + "reference_type": row.against_voucher_type, + "reference_name": row.against_voucher, + dr_or_cr: flt(row.difference_amount), + dr_or_cr + "_in_account_currency": 0, + } + ) + + journal_entry.append("accounts", journal_account) + + journal_account = frappe._dict( + { + "account": row.difference_account, + "account_currency": difference_account_currency, + "exchange_rate": 1, + "cost_center": erpnext.get_default_cost_center(self.company), + reverse_dr_or_cr + "_in_account_currency": flt(row.difference_amount), + reverse_dr_or_cr: flt(row.difference_amount), + } + ) + + journal_entry.append("accounts", journal_account) + + journal_entry.save() + journal_entry.submit() + def get_payment_details(self, row, dr_or_cr): return frappe._dict( { @@ -320,6 +398,7 @@ def get_payment_details(self, row, dr_or_cr): "against_voucher_type": row.get("invoice_type"), "against_voucher": row.get("invoice_number"), "account": self.receivable_payable_account, + "exchange_rate": row.get("exchange_rate"), "party_type": self.party_type, "party": self.party, "is_advance": row.get("is_advance"), @@ -344,6 +423,49 @@ def validate_entries(self): if not self.get("payments"): frappe.throw(_("No records found in the Payments table")) + def get_invoice_exchange_map(self, invoices, payments): + sales_invoices = [ + d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Sales Invoice" + ] + + sales_invoices.extend( + [d.get("reference_name") for d in payments if d.get("reference_type") == "Sales Invoice"] + ) + purchase_invoices = [ + d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Purchase Invoice" + ] + purchase_invoices.extend( + [d.get("reference_name") for d in payments if d.get("reference_type") == "Purchase Invoice"] + ) + + invoice_exchange_map = frappe._dict() + + if sales_invoices: + sales_invoice_map = frappe._dict( + frappe.db.get_all( + "Sales Invoice", + filters={"name": ("in", sales_invoices)}, + fields=["name", "conversion_rate"], + as_list=1, + ) + ) + + invoice_exchange_map.update(sales_invoice_map) + + if purchase_invoices: + purchase_invoice_map = frappe._dict( + frappe.db.get_all( + "Purchase Invoice", + filters={"name": ("in", purchase_invoices)}, + fields=["name", "conversion_rate"], + as_list=1, + ) + ) + + invoice_exchange_map.update(purchase_invoice_map) + + return invoice_exchange_map + def validate_allocation(self): unreconciled_invoices = frappe._dict() @@ -377,6 +499,7 @@ def validate_allocation(self): def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False): self.common_filter_conditions.clear() + self.accounting_dimension_filter_conditions.clear() self.ple_posting_date_filter.clear() ple = qb.DocType("Payment Ledger Entry") diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index 6030134fff2d..3be11ae31a71 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -5,8 +5,8 @@ import frappe from frappe import qb -from frappe.tests.utils import FrappeTestCase -from frappe.utils import add_days, nowdate +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import add_days, flt, nowdate from erpnext import get_default_cost_center from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry @@ -75,33 +75,11 @@ def create_item(self): self.item = item if isinstance(item, str) else item.item_code def create_customer(self): - if frappe.db.exists("Customer", "_Test PR Customer"): - self.customer = "_Test PR Customer" - else: - customer = frappe.new_doc("Customer") - customer.customer_name = "_Test PR Customer" - customer.type = "Individual" - customer.save() - self.customer = customer.name - - if frappe.db.exists("Customer", "_Test PR Customer 2"): - self.customer2 = "_Test PR Customer 2" - else: - customer = frappe.new_doc("Customer") - customer.customer_name = "_Test PR Customer 2" - customer.type = "Individual" - customer.save() - self.customer2 = customer.name - - if frappe.db.exists("Customer", "_Test PR Customer 3"): - self.customer3 = "_Test PR Customer 3" - else: - customer = frappe.new_doc("Customer") - customer.customer_name = "_Test PR Customer 3" - customer.type = "Individual" - customer.default_currency = "EUR" - customer.save() - self.customer3 = customer.name + self.customer = make_customer("_Test PR Customer") + self.customer2 = make_customer("_Test PR Customer 2") + self.customer3 = make_customer("_Test PR Customer 3", "EUR") + self.customer4 = make_customer("_Test PR Customer 4", "EUR") + self.customer5 = make_customer("_Test PR Customer 5", "EUR") def create_account(self): account_name = "Debtors EUR" @@ -371,6 +349,11 @@ def test_payment_against_invoice(self): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() si.reload() @@ -412,6 +395,11 @@ def test_payment_against_journal(self): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() # check PR tool output @@ -436,6 +424,11 @@ def test_journal_against_invoice(self): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() # assert outstanding @@ -472,6 +465,11 @@ def test_journal_against_journal(self): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() self.assertEqual(pr.get("invoices"), []) @@ -495,6 +493,11 @@ def test_cr_note_against_invoice(self): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Cr Note and Invoice are of the same currency. There shouldn't any difference amount. + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() pr.get_unreconciled_entries() @@ -528,6 +531,11 @@ def test_cr_note_partial_against_invoice(self): payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) pr.allocation[0].allocated_amount = allocated_amount + + # Cr Note and Invoice are of the same currency. There shouldn't any difference amount. + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() # assert outstanding @@ -598,6 +606,156 @@ def test_pr_output_foreign_currency_and_amount(self): self.assertEqual(pr.payments[0].amount, amount) self.assertEqual(pr.payments[0].currency, "EUR") + def test_difference_amount_via_journal_entry(self): + # Make Sale Invoice + si = self.create_sales_invoice( + qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + si.customer = self.customer4 + si.currency = "EUR" + si.conversion_rate = 85 + si.debit_to = self.debtors_eur + si.save().submit() + + # Make payment using Journal Entry + je1 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 100, nowdate()) + je1.multi_currency = 1 + je1.accounts[0].exchange_rate = 1 + je1.accounts[0].credit_in_account_currency = 0 + je1.accounts[0].credit = 0 + je1.accounts[0].debit_in_account_currency = 8000 + je1.accounts[0].debit = 8000 + je1.accounts[1].party_type = "Customer" + je1.accounts[1].party = self.customer4 + je1.accounts[1].exchange_rate = 80 + je1.accounts[1].credit_in_account_currency = 100 + je1.accounts[1].credit = 8000 + je1.accounts[1].debit_in_account_currency = 0 + je1.accounts[1].debit = 0 + je1.save() + je1.submit() + + je2 = self.create_journal_entry("HDFC - _PR", self.debtors_eur, 200, nowdate()) + je2.multi_currency = 1 + je2.accounts[0].exchange_rate = 1 + je2.accounts[0].credit_in_account_currency = 0 + je2.accounts[0].credit = 0 + je2.accounts[0].debit_in_account_currency = 16000 + je2.accounts[0].debit = 16000 + je2.accounts[1].party_type = "Customer" + je2.accounts[1].party = self.customer4 + je2.accounts[1].exchange_rate = 80 + je2.accounts[1].credit_in_account_currency = 200 + je1.accounts[1].credit = 16000 + je1.accounts[1].debit_in_account_currency = 0 + je1.accounts[1].debit = 0 + je2.save() + je2.submit() + + pr = self.create_payment_reconciliation() + pr.party = self.customer4 + pr.receivable_payable_account = self.debtors_eur + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 2) + + # Test exact payment allocation + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[0].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].allocated_amount, 100) + self.assertEqual(pr.allocation[0].difference_amount, -500) + + # Test partial payment allocation (with excess payment entry) + pr.set("allocation", []) + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[1].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.allocation[0].difference_account = "Exchange Gain/Loss - _PR" + + self.assertEqual(pr.allocation[0].allocated_amount, 100) + self.assertEqual(pr.allocation[0].difference_amount, -500) + + # Check if difference journal entry gets generated for difference amount after reconciliation + pr.reconcile() + total_debit_amount = frappe.db.get_all( + "Journal Entry Account", + {"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name}, + "sum(debit) as amount", + group_by="reference_name", + )[0].amount + + self.assertEqual(flt(total_debit_amount, 2), -500) + + def test_difference_amount_via_payment_entry(self): + # Make Sale Invoice + si = self.create_sales_invoice( + qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + si.customer = self.customer5 + si.currency = "EUR" + si.conversion_rate = 85 + si.debit_to = self.debtors_eur + si.save().submit() + + # Make payment using Payment Entry + pe1 = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer5, + paid_from=self.debtors_eur, + paid_to=self.bank, + paid_amount=100, + ) + + pe1.source_exchange_rate = 80 + pe1.received_amount = 8000 + pe1.save() + pe1.submit() + + pe2 = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer5, + paid_from=self.debtors_eur, + paid_to=self.bank, + paid_amount=200, + ) + + pe2.source_exchange_rate = 80 + pe2.received_amount = 16000 + pe2.save() + pe2.submit() + + pr = self.create_payment_reconciliation() + pr.party = self.customer5 + pr.receivable_payable_account = self.debtors_eur + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 2) + + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[0].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].allocated_amount, 100) + self.assertEqual(pr.allocation[0].difference_amount, -500) + + pr.set("allocation", []) + pr.get_unreconciled_entries() + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[1].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].allocated_amount, 100) + self.assertEqual(pr.allocation[0].difference_amount, -500) + def test_differing_cost_center_on_invoice_and_payment(self): """ Cost Center filter should not affect outstanding amount calculation @@ -618,3 +776,130 @@ def test_differing_cost_center_on_invoice_and_payment(self): # check PR tool output self.assertEqual(len(pr.get("invoices")), 0) self.assertEqual(len(pr.get("payments")), 0) + + def test_cost_center_filter_on_vouchers(self): + """ + Test Cost Center filter is applied on Invoices, Payment Entries and Journals + """ + transaction_date = nowdate() + rate = 100 + + # 'Main - PR' Cost Center + si1 = self.create_sales_invoice( + qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True + ) + si1.cost_center = self.main_cc.name + si1.submit() + + pe1 = self.create_payment_entry(posting_date=transaction_date, amount=rate) + pe1.cost_center = self.main_cc.name + pe1 = pe1.save().submit() + + je1 = self.create_journal_entry(self.bank, self.debit_to, 100, transaction_date) + je1.accounts[0].cost_center = self.main_cc.name + je1.accounts[1].cost_center = self.main_cc.name + je1.accounts[1].party_type = "Customer" + je1.accounts[1].party = self.customer + je1 = je1.save().submit() + + # 'Sub - PR' Cost Center + si2 = self.create_sales_invoice( + qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True + ) + si2.cost_center = self.sub_cc.name + si2.submit() + + pe2 = self.create_payment_entry(posting_date=transaction_date, amount=rate) + pe2.cost_center = self.sub_cc.name + pe2 = pe2.save().submit() + + je2 = self.create_journal_entry(self.bank, self.debit_to, 100, transaction_date) + je2.accounts[0].cost_center = self.sub_cc.name + je2.accounts[1].cost_center = self.sub_cc.name + je2.accounts[1].party_type = "Customer" + je2.accounts[1].party = self.customer + je2 = je2.save().submit() + + pr = self.create_payment_reconciliation() + pr.cost_center = self.main_cc.name + + pr.get_unreconciled_entries() + + # check PR tool output + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(pr.get("invoices")[0].get("invoice_number"), si1.name) + self.assertEqual(len(pr.get("payments")), 2) + payment_vouchers = [x.get("reference_name") for x in pr.get("payments")] + self.assertCountEqual(payment_vouchers, [pe1.name, je1.name]) + + # Change cost center + pr.cost_center = self.sub_cc.name + + pr.get_unreconciled_entries() + + # check PR tool output + self.assertEqual(len(pr.get("invoices")), 1) + self.assertEqual(pr.get("invoices")[0].get("invoice_number"), si2.name) + self.assertEqual(len(pr.get("payments")), 2) + payment_vouchers = [x.get("reference_name") for x in pr.get("payments")] + self.assertCountEqual(payment_vouchers, [je2.name, pe2.name]) + + @change_settings( + "Accounts Settings", + { + "allow_multi_currency_invoices_against_single_party_account": 1, + }, + ) + def test_no_difference_amount_for_base_currency_accounts(self): + # Make Sale Invoice + si = self.create_sales_invoice( + qty=1, rate=1, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + si.customer = self.customer + si.currency = "EUR" + si.conversion_rate = 85 + si.debit_to = self.debit_to + si.save().submit() + + # Make payment using Payment Entry + pe1 = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer, + paid_from=self.debit_to, + paid_to=self.bank, + paid_amount=100, + ) + + pe1.save() + pe1.submit() + + pr = self.create_payment_reconciliation() + pr.party = self.customer + pr.receivable_payable_account = self.debit_to + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[0].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].allocated_amount, 85) + self.assertEqual(pr.allocation[0].difference_amount, 0) + + +def make_customer(customer_name, currency=None): + if not frappe.db.exists("Customer", customer_name): + customer = frappe.new_doc("Customer") + customer.customer_name = customer_name + customer.type = "Individual" + + if currency: + customer.default_currency = currency + customer.save() + return customer.name + else: + return customer_name diff --git a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json index 6a21692c6ac1..0f7e47acfee4 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json +++ b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json @@ -20,7 +20,9 @@ "section_break_5", "difference_amount", "column_break_7", - "difference_account" + "difference_account", + "exchange_rate", + "currency" ], "fields": [ { @@ -37,7 +39,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Allocated Amount", - "options": "Currency", + "options": "currency", "reqd": 1 }, { @@ -112,7 +114,7 @@ "fieldtype": "Currency", "hidden": 1, "label": "Unreconciled Amount", - "options": "Currency", + "options": "currency", "read_only": 1 }, { @@ -120,7 +122,7 @@ "fieldtype": "Currency", "hidden": 1, "label": "Amount", - "options": "Currency", + "options": "currency", "read_only": 1 }, { @@ -129,11 +131,24 @@ "hidden": 1, "label": "Reference Row", "read_only": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2021-10-06 11:48:59.616562", + "modified": "2022-12-24 21:01:14.882747", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Allocation", @@ -141,5 +156,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json b/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json index 00c9e1240c54..c4dbd7e84412 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json +++ b/erpnext/accounts/doctype/payment_reconciliation_invoice/payment_reconciliation_invoice.json @@ -11,7 +11,8 @@ "col_break1", "amount", "outstanding_amount", - "currency" + "currency", + "exchange_rate" ], "fields": [ { @@ -62,11 +63,17 @@ "hidden": 1, "label": "Currency", "options": "Currency" + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "hidden": 1, + "label": "Exchange Rate" } ], "istable": 1, "links": [], - "modified": "2021-08-24 22:42:40.923179", + "modified": "2022-11-08 18:18:02.502149", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Invoice", @@ -75,5 +82,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json index add07e870d8e..d300ea97abc4 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json +++ b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json @@ -15,7 +15,8 @@ "difference_amount", "sec_break1", "remark", - "currency" + "currency", + "exchange_rate" ], "fields": [ { @@ -91,11 +92,17 @@ "label": "Difference Amount", "options": "currency", "read_only": 1 + }, + { + "fieldname": "exchange_rate", + "fieldtype": "Float", + "hidden": 1, + "label": "Exchange Rate" } ], "istable": 1, "links": [], - "modified": "2021-08-30 10:51:48.140062", + "modified": "2022-11-08 18:18:36.268760", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Payment", @@ -103,5 +110,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index f63fba1b7164..0955664d98be 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -9,7 +9,6 @@ from frappe.model.document import Document from frappe.utils import flt, get_url, nowdate from frappe.utils.background_jobs import enqueue -from payments.utils import get_payment_gateway_controller from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, @@ -22,6 +21,14 @@ from erpnext.accounts.party import get_party_account, get_party_bank_account from erpnext.accounts.utils import get_account_currency from erpnext.erpnext_integrations.stripe_integration import create_stripe_subscription +from erpnext.utilities import payment_app_import_guard + + +def _get_payment_gateway_controller(*args, **kwargs): + with payment_app_import_guard(): + from payments.utils import get_payment_gateway_controller + + return get_payment_gateway_controller(*args, **kwargs) class PaymentRequest(Document): @@ -38,21 +45,20 @@ def validate_reference_document(self): frappe.throw(_("To create a Payment Request reference document is required")) def validate_payment_request_amount(self): - existing_payment_request_amount = get_existing_payment_request_amount( - self.reference_doctype, self.reference_name + existing_payment_request_amount = flt( + get_existing_payment_request_amount(self.reference_doctype, self.reference_name) ) - if existing_payment_request_amount: - ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) - if hasattr(ref_doc, "order_type") and getattr(ref_doc, "order_type") != "Shopping Cart": - ref_amount = get_amount(ref_doc, self.payment_account) + ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) + if not hasattr(ref_doc, "order_type") or getattr(ref_doc, "order_type") != "Shopping Cart": + ref_amount = get_amount(ref_doc, self.payment_account) - if existing_payment_request_amount + flt(self.grand_total) > ref_amount: - frappe.throw( - _("Total Payment Request amount cannot be greater than {0} amount").format( - self.reference_doctype - ) + if existing_payment_request_amount + flt(self.grand_total) > ref_amount: + frappe.throw( + _("Total Payment Request amount cannot be greater than {0} amount").format( + self.reference_doctype ) + ) def validate_currency(self): ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) @@ -110,7 +116,7 @@ def on_submit(self): self.request_phone_payment() def request_phone_payment(self): - controller = get_payment_gateway_controller(self.payment_gateway) + controller = _get_payment_gateway_controller(self.payment_gateway) request_amount = self.get_request_amount() payment_record = dict( @@ -159,7 +165,7 @@ def make_invoice(self): def payment_gateway_validation(self): try: - controller = get_payment_gateway_controller(self.payment_gateway) + controller = _get_payment_gateway_controller(self.payment_gateway) if hasattr(controller, "on_payment_request_submission"): return controller.on_payment_request_submission(self) else: @@ -192,7 +198,7 @@ def get_payment_url(self): ) data.update({"company": frappe.defaults.get_defaults().company}) - controller = get_payment_gateway_controller(self.payment_gateway) + controller = _get_payment_gateway_controller(self.payment_gateway) controller.validate_transaction_currency(self.currency) if hasattr(controller, "validate_minimum_transaction_amount"): @@ -489,26 +495,28 @@ def get_amount(ref_doc, payment_account=None): """get amount based on doctype""" dt = ref_doc.doctype if dt in ["Sales Order", "Purchase Order"]: - grand_total = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid) - + grand_total = flt(ref_doc.rounded_total) or flt(ref_doc.grand_total) elif dt in ["Sales Invoice", "Purchase Invoice"]: - if ref_doc.party_account_currency == ref_doc.currency: - grand_total = flt(ref_doc.outstanding_amount) - else: - grand_total = flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate - + if not ref_doc.get("is_pos"): + if ref_doc.party_account_currency == ref_doc.currency: + grand_total = flt(ref_doc.outstanding_amount) + else: + grand_total = flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate + elif dt == "Sales Invoice": + for pay in ref_doc.payments: + if pay.type == "Phone" and pay.account == payment_account: + grand_total = pay.amount + break elif dt == "POS Invoice": for pay in ref_doc.payments: if pay.type == "Phone" and pay.account == payment_account: grand_total = pay.amount break - elif dt == "Fees": grand_total = ref_doc.outstanding_amount if grand_total > 0: return grand_total - else: frappe.throw(_("Payment Entry is already created")) diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 477c726940cd..e17a846dd814 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -6,6 +6,7 @@ import frappe from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.setup.utils import get_exchange_rate @@ -45,7 +46,10 @@ def setUp(self): frappe.get_doc(method).insert(ignore_permissions=True) def test_payment_request_linkings(self): - so_inr = make_sales_order(currency="INR") + so_inr = make_sales_order(currency="INR", do_not_save=True) + so_inr.disable_rounded_total = 1 + so_inr.save() + pr = make_payment_request( dt="Sales Order", dn=so_inr.name, @@ -71,6 +75,29 @@ def test_payment_request_linkings(self): self.assertEqual(pr.reference_name, si_usd.name) self.assertEqual(pr.currency, "USD") + def test_payment_entry_against_purchase_invoice(self): + si_usd = make_purchase_invoice( + customer="_Test Supplier USD", + debit_to="_Test Payable USD - _TC", + currency="USD", + conversion_rate=50, + ) + + pr = make_payment_request( + dt="Purchase Invoice", + dn=si_usd.name, + recipient_id="user@example.com", + mute_email=1, + payment_gateway_account="_Test Gateway - USD", + submit_doc=1, + return_doc=1, + ) + + pe = pr.create_payment_entry() + pr.load_from_db() + + self.assertEqual(pr.status, "Paid") + def test_payment_entry(self): frappe.db.set_value( "Company", "_Test Company", "exchange_gain_loss_account", "_Test Exchange Gain/Loss - _TC" diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py index 655c4ec00355..115b415eedad 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py @@ -21,8 +21,24 @@ def validate(self): if frappe.db.get_value("POS Opening Entry", self.pos_opening_entry, "status") != "Open": frappe.throw(_("Selected POS Opening Entry should be open."), title=_("Invalid Opening Entry")) + self.validate_duplicate_pos_invoices() self.validate_pos_invoices() + def validate_duplicate_pos_invoices(self): + pos_occurences = {} + for idx, inv in enumerate(self.pos_transactions, 1): + pos_occurences.setdefault(inv.pos_invoice, []).append(idx) + + error_list = [] + for key, value in pos_occurences.items(): + if len(value) > 1: + error_list.append( + _("{} is added multiple times on rows: {}".format(frappe.bold(key), frappe.bold(value))) + ) + + if error_list: + frappe.throw(error_list, title=_("Duplicate POS Invoices found"), as_list=True) + def validate_pos_invoices(self): invalid_rows = [] for d in self.pos_transactions: diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 56b857992c75..cced37589bad 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -112,7 +112,8 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex party_type: "Customer", account: this.frm.doc.debit_to, price_list: this.frm.doc.selling_price_list, - pos_profile: pos_profile + pos_profile: pos_profile, + company_address: this.frm.doc.company_address }, () => { this.apply_pricing_rule(); }); diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 54a3e934b2dc..27e6f0e598f5 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -161,7 +161,7 @@ def validate_pos_reserved_batch_qty(self, item): bold_item_name = frappe.bold(item.item_name) bold_extra_batch_qty_needed = frappe.bold( - abs(available_batch_qty - reserved_batch_qty - item.qty) + abs(available_batch_qty - reserved_batch_qty - item.stock_qty) ) bold_invalid_batch_no = frappe.bold(item.batch_no) @@ -172,7 +172,7 @@ def validate_pos_reserved_batch_qty(self, item): ).format(item.idx, bold_invalid_batch_no, bold_item_name), title=_("Item Unavailable"), ) - elif (available_batch_qty - reserved_batch_qty - item.qty) < 0: + elif (available_batch_qty - reserved_batch_qty - item.stock_qty) < 0: frappe.throw( _( "Row #{}: Batch No. {} of item {} has less than required stock available, {} more required" @@ -246,7 +246,7 @@ def validate_stock_availablility(self): ), title=_("Item Unavailable"), ) - elif is_stock_item and flt(available_stock) < flt(d.qty): + elif is_stock_item and flt(available_stock) < flt(d.stock_qty): frappe.throw( _( "Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}. Available quantity {}." @@ -650,7 +650,7 @@ def get_bundle_availability(bundle_item_code, warehouse): item_pos_reserved_qty = get_pos_reserved_qty(item.item_code, warehouse) available_qty = item_bin_qty - item_pos_reserved_qty - max_available_bundles = available_qty / item.qty + max_available_bundles = available_qty / item.stock_qty if bundle_bin_qty > max_available_bundles and frappe.get_value( "Item", item.item_code, "is_stock_item" ): @@ -674,7 +674,7 @@ def get_bin_qty(item_code, warehouse): def get_pos_reserved_qty(item_code, warehouse): reserved_qty = frappe.db.sql( - """select sum(p_item.qty) as qty + """select sum(p_item.stock_qty) as qty from `tabPOS Invoice` p, `tabPOS Invoice Item` p_item where p.name = p_item.parent and ifnull(p.consolidated_invoice, '') = '' diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 70e3baff2e43..438ff9f3c4c1 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 @@ -18,6 +18,22 @@ class POSInvoiceMergeLog(Document): def validate(self): self.validate_customer() self.validate_pos_invoice_status() + self.validate_duplicate_pos_invoices() + + def validate_duplicate_pos_invoices(self): + pos_occurences = {} + for idx, inv in enumerate(self.pos_invoices, 1): + pos_occurences.setdefault(inv.pos_invoice, []).append(idx) + + error_list = [] + for key, value in pos_occurences.items(): + if len(value) > 1: + error_list.append( + _("{} is added multiple times on rows: {}".format(frappe.bold(key), frappe.bold(value))) + ) + + if error_list: + frappe.throw(error_list, title=_("Duplicate POS Invoices found"), as_list=True) def validate_customer(self): if self.merge_invoices_based_on == "Customer Group": @@ -426,6 +442,8 @@ def create_merge_logs(invoice_by_customer, closing_entry=None): if closing_entry: closing_entry.set_status(update=True, status="Failed") + if type(error_message) == list: + error_message = frappe.json.dumps(error_message) closing_entry.db_set("error_message", error_message) raise diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json index ce9ce647db0d..a63039e0e3ab 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json @@ -472,7 +472,7 @@ "description": "If rate is zero them item will be treated as \"Free Item\"", "fieldname": "free_item_rate", "fieldtype": "Currency", - "label": "Rate" + "label": "Free Item Rate" }, { "collapsible": 1, @@ -608,7 +608,7 @@ "icon": "fa fa-gift", "idx": 1, "links": [], - "modified": "2022-10-13 19:05:35.056304", + "modified": "2023-02-14 04:53:34.887358", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule", diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 199766e9a8df..65d4595fcd54 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -681,11 +681,21 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None): def apply_pricing_rule_for_free_items(doc, pricing_rule_args): if pricing_rule_args: - items = tuple((d.item_code, d.pricing_rules) for d in doc.items if d.is_free_item) + args = {(d["item_code"], d["pricing_rules"]): d for d in pricing_rule_args} - for args in pricing_rule_args: - if not items or (args.get("item_code"), args.get("pricing_rules")) not in items: - doc.append("items", args) + for item in doc.items: + if not item.is_free_item: + continue + + free_item_data = args.get((item.item_code, item.pricing_rules)) + if free_item_data: + free_item_data.pop("item_name") + free_item_data.pop("description") + item.update(free_item_data) + args.pop((item.item_code, item.pricing_rules)) + + for free_item in args.values(): + doc.append("items", free_item) def get_pricing_rule_items(pr_doc, other_items=False) -> list: diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html index 0da44a464e76..b9680dfb3bfd 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html @@ -15,7 +15,7 @@

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

-
{{ _("Customer: ") }} {{filters.party[0] }}
+
{{ _("Customer: ") }} {{filters.party_name[0] }}
{{ _("Date: ") }} {{ frappe.format(filters.from_date, 'Date')}} @@ -49,7 +49,6 @@

{% endif %} - {{ _("Against") }}: {{ row.against }}
{{ _("Remarks") }}: {{ row.remarks }} {% if row.bill_no %}
{{ _("Supplier Invoice No") }}: {{ row.bill_no }} 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 01f716daa211..39b801432085 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 @@ -23,7 +23,7 @@ class ProcessStatementOfAccounts(Document): def validate(self): if not self.subject: - self.subject = "Statement Of Accounts for {{ customer.name }}" + self.subject = "Statement Of Accounts for {{ customer.customer_name }}" if not self.body: self.body = "Hello {{ customer.name }},
PFA your Statement Of Accounts from {{ doc.from_date }} to {{ doc.to_date }}." @@ -86,6 +86,7 @@ def get_report_pdf(doc, consolidated=True): "account": [doc.account] if doc.account else None, "party_type": "Customer", "party": [entry.customer], + "party_name": [entry.customer_name] if entry.customer_name else None, "presentation_currency": presentation_currency, "group_by": doc.group_by, "currency": doc.currency, @@ -153,7 +154,7 @@ def get_customers_based_on_territory_or_customer_group(customer_collection, coll ] return frappe.get_list( "Customer", - fields=["name", "email_id"], + fields=["name", "customer_name", "email_id"], filters=[[fields_dict[customer_collection], "IN", selected]], ) @@ -176,7 +177,7 @@ def get_customers_based_on_sales_person(sales_person): if sales_person_records.get("Customer"): return frappe.get_list( "Customer", - fields=["name", "email_id"], + fields=["name", "customer_name", "email_id"], filters=[["name", "in", list(sales_person_records["Customer"])]], ) else: @@ -225,7 +226,7 @@ def fetch_customers(customer_collection, collection_name, primary_mandatory): if customer_collection == "Sales Partner": customers = frappe.get_list( "Customer", - fields=["name", "email_id"], + fields=["name", "customer_name", "email_id"], filters=[["default_sales_partner", "=", collection_name]], ) else: @@ -244,7 +245,12 @@ def fetch_customers(customer_collection, collection_name, primary_mandatory): continue customer_list.append( - {"name": customer.name, "primary_email": primary_email, "billing_email": billing_email} + { + "name": customer.name, + "customer_name": customer.customer_name, + "primary_email": primary_email, + "billing_email": billing_email, + } ) return customer_list diff --git a/erpnext/accounts/doctype/process_statement_of_accounts_customer/process_statement_of_accounts_customer.json b/erpnext/accounts/doctype/process_statement_of_accounts_customer/process_statement_of_accounts_customer.json index dd04dc1b3c66..8bffd6a93b92 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts_customer/process_statement_of_accounts_customer.json +++ b/erpnext/accounts/doctype/process_statement_of_accounts_customer/process_statement_of_accounts_customer.json @@ -1,12 +1,12 @@ { "actions": [], - "allow_workflow": 1, "creation": "2020-08-03 16:35:21.852178", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "customer", + "customer_name", "billing_email", "primary_email" ], @@ -30,11 +30,18 @@ "fieldtype": "Read Only", "in_list_view": 1, "label": "Billing Email" + }, + { + "fetch_from": "customer.customer_name", + "fieldname": "customer_name", + "fieldtype": "Data", + "label": "Customer Name", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2020-08-03 22:55:38.875601", + "modified": "2023-03-13 00:12:34.508086", "modified_by": "Administrator", "module": "Accounts", "name": "Process Statement Of Accounts Customer", @@ -43,5 +50,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index a098e8d1db82..5c9168bf9c50 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -31,7 +31,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. super.onload(); // Ignore linked advances - this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice']; + this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger"]; if(!this.frm.doc.__islocal) { // show credit_to in print format @@ -82,7 +82,11 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. if(doc.docstatus == 1 && doc.outstanding_amount != 0 && !(doc.is_return && doc.return_against) && !doc.on_hold) { - this.frm.add_custom_button(__('Payment'), this.make_payment_entry, __('Create')); + this.frm.add_custom_button( + __('Payment'), + () => this.make_payment_entry(), + __('Create') + ); cur_frm.page.set_inner_btn_group_as_primary(__('Create')); } diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 16490c00e8df..5ae2e148d807 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -119,6 +119,7 @@ "paid_amount", "advances_section", "allocate_advances_automatically", + "only_include_allocated_payments", "get_advances", "advances", "advance_tax", @@ -1427,6 +1428,7 @@ }, { "default": "0", + "depends_on": "apply_tds", "fieldname": "tax_withholding_net_total", "fieldtype": "Currency", "hidden": 1, @@ -1436,12 +1438,13 @@ "read_only": 1 }, { + "depends_on": "apply_tds", "fieldname": "base_tax_withholding_net_total", "fieldtype": "Currency", "hidden": 1, "label": "Base Tax Withholding Net Total", "no_copy": 1, - "options": "currency", + "options": "Company:company:default_currency", "print_hide": 1, "read_only": 1 }, @@ -1552,6 +1555,13 @@ }, { "default": "0", + "depends_on": "allocate_advances_automatically", + "description": "Advance payments allocated against orders will only be fetched", + "fieldname": "only_include_allocated_payments", + "fieldtype": "Check", + "label": "Only Include Allocated Payments" + }, + { "depends_on": "eval: doc.supplier", "fieldname": "filter_items_by_supplier", "fieldtype": "Check", @@ -1562,11 +1572,10 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2022-12-27 18:37:38.142688", + "modified": "2023-04-03 22:57:14.074982", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", - "name_case": "Title Case", "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index df4c2770e3c8..0ded4a883bb9 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -5,6 +5,7 @@ import frappe from frappe import _, throw from frappe.model.mapper import get_mapped_doc +from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate import erpnext @@ -116,7 +117,7 @@ def validate(self): self.validate_expense_account() self.set_against_expense_account() self.validate_write_off_account() - self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount", "items") + self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount") self.create_remarks() self.set_status() self.validate_purchase_receipt_if_update_stock() @@ -231,7 +232,7 @@ def validate_with_previous_doc(self): ) if ( - cint(frappe.db.get_single_value("Buying Settings", "maintain_same_rate")) + cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate")) and not self.is_return and not self.is_internal_supplier ): @@ -580,6 +581,7 @@ def get_gl_entries(self, warehouse_account=None): self.make_supplier_gl_entry(gl_entries) self.make_item_gl_entries(gl_entries) + self.make_precision_loss_gl_entry(gl_entries) if self.check_asset_cwip_enabled(): self.get_asset_gl_entry(gl_entries) @@ -974,6 +976,28 @@ def make_item_gl_entries(self, gl_entries): item.item_tax_amount, item.precision("item_tax_amount") ) + def make_precision_loss_gl_entry(self, gl_entries): + round_off_account, round_off_cost_center = get_round_off_account_and_cost_center( + self.company, "Purchase Invoice", self.name + ) + + precision_loss = self.get("base_net_total") - flt( + self.get("net_total") * self.conversion_rate, self.precision("net_total") + ) + + if precision_loss: + gl_entries.append( + self.get_gl_dict( + { + "account": round_off_account, + "against": self.supplier, + "credit": precision_loss, + "cost_center": self.cost_center or round_off_cost_center, + "remarks": _("Net total calculation precision loss"), + } + ) + ) + def get_asset_gl_entry(self, gl_entries): arbnb_account = self.get_company_default("asset_received_but_not_billed") eiiav_account = self.get_company_default("expenses_included_in_asset_valuation") @@ -1416,6 +1440,8 @@ def on_cancel(self): "GL Entry", "Stock Ledger Entry", "Repost Item Valuation", + "Repost Payment Ledger", + "Repost Payment Ledger Items", "Payment Ledger Entry", "Tax Withheld Vouchers", ) @@ -1463,19 +1489,16 @@ def validate_supplier_invoice(self): def update_billing_status_in_pr(self, update_modified=True): updated_pr = [] po_details = [] + + pr_details_billed_amt = self.get_pr_details_billed_amt() + for d in self.get("items"): if d.pr_detail: - billed_amt = frappe.db.sql( - """select sum(amount) from `tabPurchase Invoice Item` - where pr_detail=%s and docstatus=1""", - d.pr_detail, - ) - billed_amt = billed_amt and billed_amt[0][0] or 0 frappe.db.set_value( "Purchase Receipt Item", d.pr_detail, "billed_amt", - billed_amt, + flt(pr_details_billed_amt.get(d.pr_detail)), update_modified=update_modified, ) updated_pr.append(d.purchase_receipt) @@ -1485,11 +1508,35 @@ def update_billing_status_in_pr(self, update_modified=True): if po_details: updated_pr += update_billed_amount_based_on_po(po_details, update_modified) + adjust_incoming_rate = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + for pr in set(updated_pr): from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_billing_percentage pr_doc = frappe.get_doc("Purchase Receipt", pr) - update_billing_percentage(pr_doc, update_modified=update_modified) + update_billing_percentage( + pr_doc, update_modified=update_modified, adjust_incoming_rate=adjust_incoming_rate + ) + + def get_pr_details_billed_amt(self): + # Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice + + pr_details_billed_amt = {} + pr_details = [d.get("pr_detail") for d in self.get("items") if d.get("pr_detail")] + if pr_details: + doctype = frappe.qb.DocType("Purchase Invoice Item") + query = ( + frappe.qb.from_(doctype) + .select(doctype.pr_detail, Sum(doctype.amount)) + .where(doctype.pr_detail.isin(pr_details) & doctype.docstatus == 1) + .groupby(doctype.pr_detail) + ) + + pr_details_billed_amt = frappe._dict(query.run(as_list=1)) + + return pr_details_billed_amt def on_recurring(self, reference_doc, auto_repeat_doc): self.due_date = None diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index f901257ccf63..a6d7df6971f9 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -1523,6 +1523,94 @@ def test_provisional_accounting_entry(self): company.enable_provisional_accounting_for_non_stock_items = 0 company.save() + def test_adjust_incoming_rate(self): + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 0) + + frappe.db.set_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1 + ) + + # Increase the cost of the item + + pr = make_purchase_receipt(qty=1, rate=100) + + stock_value_difference = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr.name}, + "stock_value_difference", + ) + self.assertEqual(stock_value_difference, 100) + + pi = create_purchase_invoice_from_receipt(pr.name) + for row in pi.items: + row.rate = 150 + + pi.save() + pi.submit() + + stock_value_difference = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr.name}, + "stock_value_difference", + ) + self.assertEqual(stock_value_difference, 150) + + # Reduce the cost of the item + + pr = make_purchase_receipt(qty=1, rate=100) + + stock_value_difference = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr.name}, + "stock_value_difference", + ) + self.assertEqual(stock_value_difference, 100) + + pi = create_purchase_invoice_from_receipt(pr.name) + for row in pi.items: + row.rate = 50 + + pi.save() + pi.submit() + + stock_value_difference = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr.name}, + "stock_value_difference", + ) + self.assertEqual(stock_value_difference, 50) + + frappe.db.set_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0 + ) + + # Don't adjust incoming rate + + pr = make_purchase_receipt(qty=1, rate=100) + + stock_value_difference = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr.name}, + "stock_value_difference", + ) + self.assertEqual(stock_value_difference, 100) + + pi = create_purchase_invoice_from_receipt(pr.name) + for row in pi.items: + row.rate = 50 + + pi.save() + pi.submit() + + stock_value_difference = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr.name}, + "stock_value_difference", + ) + self.assertEqual(stock_value_difference, 100) + + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + def test_item_less_defaults(self): pi = frappe.new_doc("Purchase Invoice") diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py index 25c89a17a06c..209cad4f9059 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py +++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py @@ -26,7 +26,7 @@ def start_payment_ledger_repost(docname=None): """ if docname: repost_doc = frappe.get_doc("Repost Payment Ledger", docname) - if repost_doc.docstatus == 1 and repost_doc.repost_status in ["Queued", "Failed"]: + if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]: try: for entry in repost_doc.repost_vouchers: doc = frappe.get_doc(entry.voucher_type, entry.voucher_no) @@ -101,10 +101,9 @@ def execute_repost_payment_ledger(docname): job_name = "payment_ledger_repost_" + docname - if not frappe.utils.background_jobs.is_job_queued(job_name): - frappe.enqueue( - method="erpnext.accounts.doctype.repost_payment_ledger.repost_payment_ledger.start_payment_ledger_repost", - docname=docname, - is_async=True, - job_name=job_name, - ) + frappe.enqueue( + method="erpnext.accounts.doctype.repost_payment_ledger.repost_payment_ledger.start_payment_ledger_repost", + docname=docname, + is_async=True, + job_name=job_name, + ) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index a06da72ccbfc..56e412b297c9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -34,7 +34,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e super.onload(); this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', - 'POS Closing Entry', 'Journal Entry', 'Payment Entry']; + 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger"]; if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { // show debit_to in print format @@ -64,6 +64,25 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e this.frm.toggle_reqd("due_date", !this.frm.doc.is_return); + if (this.frm.doc.repost_required && this.frm.doc.docstatus===1) { + this.frm.set_intro(__("Accounting entries for this invoice needs to be reposted. Please click on 'Repost' button to update.")); + this.frm.add_custom_button(__('Repost Accounting Entries'), + () => { + this.frm.call({ + doc: this.frm.doc, + method: 'repost_accounting_entries', + freeze: true, + freeze_message: __('Reposting...'), + callback: (r) => { + if (!r.exc) { + frappe.msgprint(__('Accounting Entries are reposted')); + me.frm.refresh(); + } + } + }); + }).removeClass('btn-default').addClass('btn-warning'); + } + if (this.frm.doc.is_return) { this.frm.return_print_format = "Sales Invoice Return"; } @@ -74,9 +93,12 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e if (doc.docstatus == 1 && doc.outstanding_amount!=0 && !(cint(doc.is_return) && doc.return_against)) { - cur_frm.add_custom_button(__('Payment'), - this.make_payment_entry, __('Create')); - cur_frm.page.set_inner_btn_group_as_primary(__('Create')); + this.frm.add_custom_button( + __('Payment'), + () => this.make_payment_entry(), + __('Create') + ); + this.frm.page.set_inner_btn_group_as_primary(__('Create')); } if(doc.docstatus==1 && !doc.is_return) { @@ -1028,7 +1050,7 @@ var select_loyalty_program = function(frm, loyalty_programs) { ] }); - dialog.set_primary_action(__("Set"), function() { + dialog.set_primary_action(__("Set Loyalty Program"), function() { dialog.hide(); return frappe.call({ method: "frappe.client.set_value", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 67cd867b97c9..9a0d71a3850b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -32,9 +32,6 @@ "cost_center", "dimension_col_break", "project", - "column_break_27", - "campaign", - "source", "currency_and_price_list", "currency", "conversion_rate", @@ -123,6 +120,7 @@ "account_for_change_amount", "advances_section", "allocate_advances_automatically", + "only_include_allocated_payments", "get_advances", "advances", "write_off_section", @@ -203,12 +201,15 @@ "more_information", "status", "inter_company_invoice_reference", + "campaign", "represents_company", + "source", "customer_group", "col_break23", "is_internal_customer", "is_discounted", "remarks", + "repost_required", "connections_tab" ], "fields": [ @@ -1038,6 +1039,7 @@ "read_only": 1 }, { + "allow_on_submit": 1, "depends_on": "redeem_loyalty_points", "fieldname": "loyalty_redemption_account", "fieldtype": "Link", @@ -1336,6 +1338,7 @@ "options": "fa fa-money" }, { + "allow_on_submit": 1, "depends_on": "is_pos", "fieldname": "cash_bank_account", "fieldtype": "Link", @@ -1435,6 +1438,7 @@ "print_hide": 1 }, { + "allow_on_submit": 1, "depends_on": "is_pos", "fieldname": "account_for_change_amount", "fieldtype": "Link", @@ -1483,6 +1487,7 @@ "hide_seconds": 1 }, { + "allow_on_submit": 1, "fieldname": "write_off_account", "fieldtype": "Link", "hide_days": 1, @@ -1706,6 +1711,7 @@ "read_only": 1 }, { + "allow_on_submit": 1, "default": "No", "fieldname": "is_opening", "fieldtype": "Select", @@ -1770,6 +1776,8 @@ "width": "50%" }, { + "fetch_from": "sales_partner.commission_rate", + "fetch_if_empty": 1, "fieldname": "commission_rate", "fieldtype": "Float", "hide_days": 1, @@ -1920,6 +1928,7 @@ "read_only": 1 }, { + "allow_on_submit": 1, "depends_on": "eval:doc.is_internal_customer", "description": "Unrealized Profit / Loss account for intra-company transfers", "fieldname": "unrealized_profit_loss_account", @@ -1962,6 +1971,7 @@ "label": "Disable Rounded Total" }, { + "allow_on_submit": 1, "fieldname": "additional_discount_account", "fieldtype": "Link", "label": "Discount Account", @@ -2073,10 +2083,6 @@ "fieldname": "company_addr_col_break", "fieldtype": "Column Break" }, - { - "fieldname": "column_break_27", - "fieldtype": "Column Break" - }, { "fieldname": "column_break_52", "fieldtype": "Column Break" @@ -2112,6 +2118,22 @@ "fieldname": "named_place", "fieldtype": "Data", "label": "Named Place" + }, + { + "default": "0", + "fieldname": "repost_required", + "fieldtype": "Check", + "hidden": 1, + "label": "Repost Required", + "no_copy": 1, + "read_only": 1 + }, + { + "depends_on": "allocate_advances_automatically", + "description": "Advance payments allocated against orders will only be fetched", + "fieldname": "only_include_allocated_payments", + "fieldtype": "Check", + "label": "Only Include Allocated Payments" } ], "icon": "fa fa-file-text", @@ -2124,11 +2146,10 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2022-12-12 18:34:33.409895", + "modified": "2023-04-03 22:55:14.206473", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", - "name_case": "Title Case", "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 4748826ec379..7af98ddf9340 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -11,6 +11,9 @@ import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, +) from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( get_loyalty_program_details_with_points, validate_loyalty_points, @@ -100,13 +103,11 @@ def validate(self): self.validate_debit_to_acc() self.clear_unallocated_advances("Sales Invoice Advance", "advances") self.add_remarks() - self.validate_write_off_account() - self.validate_account_for_change_amount() self.validate_fixed_asset() self.set_income_account_for_fixed_assets() self.validate_item_cost_centers() - self.validate_income_account() self.check_conversion_rate() + self.validate_accounts() validate_inter_company_party( self.doctype, self.customer, self.company, self.inter_company_invoice_reference @@ -144,7 +145,7 @@ def validate(self): self.set_against_income_account() self.validate_time_sheets_are_submitted() - self.validate_multiple_billing("Delivery Note", "dn_detail", "amount", "items") + self.validate_multiple_billing("Delivery Note", "dn_detail", "amount") if not self.is_return: self.validate_serial_numbers() else: @@ -170,6 +171,11 @@ def validate(self): self.reset_default_field_value("set_warehouse", "items", "warehouse") + def validate_accounts(self): + self.validate_write_off_account() + self.validate_account_for_change_amount() + self.validate_income_account() + def validate_fixed_asset(self): for d in self.get("items"): if d.is_fixed_asset and d.meta.get_field("asset") and d.asset: @@ -368,6 +374,7 @@ def on_cancel(self): self.repost_future_sle_and_gle() self.db_set("status", "Cancelled") + self.db_set("repost_required", 0) if ( frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction" @@ -390,6 +397,8 @@ def on_cancel(self): "GL Entry", "Stock Ledger Entry", "Repost Item Valuation", + "Repost Payment Ledger", + "Repost Payment Ledger Items", "Payment Ledger Entry", ) @@ -514,6 +523,92 @@ def update_time_sheet_detail(self, timesheet, args, sales_invoice): def on_update(self): self.set_paid_amount() + def on_update_after_submit(self): + if hasattr(self, "repost_required"): + needs_repost = 0 + + # Check if any field affecting accounting entry is altered + doc_before_update = self.get_doc_before_save() + accounting_dimensions = get_accounting_dimensions() + ["cost_center", "project"] + + # Check if opening entry check updated + if doc_before_update.get("is_opening") != self.is_opening: + needs_repost = 1 + + if not needs_repost: + # Parent Level Accounts excluding party account + for field in ( + "additional_discount_account", + "cash_bank_account", + "account_for_change_amount", + "write_off_account", + "loyalty_redemption_account", + "unrealized_profit_loss_account", + ): + if doc_before_update.get(field) != self.get(field): + needs_repost = 1 + break + + # Check for parent accounting dimensions + for dimension in accounting_dimensions: + if doc_before_update.get(dimension) != self.get(dimension): + needs_repost = 1 + break + + # Check for child tables + if self.check_if_child_table_updated( + "items", + doc_before_update, + ("income_account", "expense_account", "discount_account"), + accounting_dimensions, + ): + needs_repost = 1 + + if self.check_if_child_table_updated( + "taxes", doc_before_update, ("account_head",), accounting_dimensions + ): + needs_repost = 1 + + self.validate_accounts() + + # validate if deferred revenue is enabled for any item + # Don't allow to update the invoice if deferred revenue is enabled + for item in self.get("items"): + if item.enable_deferred_revenue: + frappe.throw( + _( + "Deferred Revenue is enabled for item {0}. You cannot update the invoice after submission." + ).format(item.item_code) + ) + + self.db_set("repost_required", needs_repost) + + def check_if_child_table_updated( + self, child_table, doc_before_update, fields_to_check, accounting_dimensions + ): + # Check if any field affecting accounting entry is altered + for index, item in enumerate(self.get(child_table)): + for field in fields_to_check: + if doc_before_update.get(child_table)[index].get(field) != item.get(field): + return True + + for dimension in accounting_dimensions: + if doc_before_update.get(child_table)[index].get(dimension) != item.get(dimension): + return True + + return False + + @frappe.whitelist() + def repost_accounting_entries(self): + if self.repost_required: + self.docstatus = 2 + self.make_gl_entries_on_cancel() + self.docstatus = 1 + self.make_gl_entries() + self.db_set("repost_required", 0) + else: + frappe.throw(_("No updates pending for reposting")) + def set_paid_amount(self): paid_amount = 0.0 base_paid_amount = 0.0 diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index cb0d1a75a0c6..f9fb6232ac96 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1166,6 +1166,46 @@ def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gl frappe.db.sql("delete from `tabPOS Profile`") + def test_bin_details_of_packed_item(self): + from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle + from erpnext.stock.doctype.item.test_item import make_item + + # test Update Items with product bundle + if not frappe.db.exists("Item", "_Test Product Bundle Item New"): + bundle_item = make_item("_Test Product Bundle Item New", {"is_stock_item": 0}) + bundle_item.append( + "item_defaults", {"company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC"} + ) + bundle_item.save(ignore_permissions=True) + + make_item("_Packed Item New 1", {"is_stock_item": 1}) + make_product_bundle("_Test Product Bundle Item New", ["_Packed Item New 1"], 2) + + si = create_sales_invoice( + item_code="_Test Product Bundle Item New", + update_stock=1, + warehouse="_Test Warehouse - _TC", + transaction_date=add_days(nowdate(), -1), + do_not_submit=1, + ) + + make_stock_entry(item="_Packed Item New 1", target="_Test Warehouse - _TC", qty=120, rate=100) + + bin_details = frappe.db.get_value( + "Bin", + {"item_code": "_Packed Item New 1", "warehouse": "_Test Warehouse - _TC"}, + ["actual_qty", "projected_qty", "ordered_qty"], + as_dict=1, + ) + + si.transaction_date = nowdate() + si.save() + + packed_item = si.packed_items[0] + self.assertEqual(flt(bin_details.actual_qty), flt(packed_item.actual_qty)) + self.assertEqual(flt(bin_details.projected_qty), flt(packed_item.projected_qty)) + self.assertEqual(flt(bin_details.ordered_qty), flt(packed_item.ordered_qty)) + def test_pos_si_without_payment(self): make_pos_profile() @@ -2729,6 +2769,31 @@ def test_additional_discount_for_sales_invoice_with_discount_accounting_enabled( check_gl_entries(self, si.name, expected_gle, add_days(nowdate(), -1)) + # Update Invoice post submit and then check GL Entries again + + si.load_from_db() + si.items[0].income_account = "Service - _TC" + si.additional_discount_account = "_Test Account Sales - _TC" + si.taxes[0].account_head = "TDS Payable - _TC" + si.save() + + si.load_from_db() + self.assertTrue(si.repost_required) + + si.repost_accounting_entries() + + expected_gle = [ + ["_Test Account Sales - _TC", 22.0, 0.0, nowdate()], + ["Debtors - _TC", 88, 0.0, nowdate()], + ["Service - _TC", 0.0, 100.0, nowdate()], + ["TDS Payable - _TC", 0.0, 10.0, nowdate()], + ] + + check_gl_entries(self, si.name, expected_gle, add_days(nowdate(), -1)) + + si.load_from_db() + self.assertFalse(si.repost_required) + def test_asset_depreciation_on_sale_with_pro_rata(self): """ Tests if an Asset set to depreciate yearly on June 30, that gets sold on Sept 30, creates an additional depreciation entry on its date of sale. @@ -3286,6 +3351,7 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date): """select account, debit, credit, posting_date from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s and posting_date > %s + and is_cancelled = 0 order by posting_date asc, account asc""", (voucher_no, posting_date), as_dict=1, diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index f4f083068eec..cfda48e5c756 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -438,6 +438,7 @@ "label": "Accounting Details" }, { + "allow_on_submit": 1, "fieldname": "income_account", "fieldtype": "Link", "label": "Income Account", @@ -450,6 +451,7 @@ "width": "120px" }, { + "allow_on_submit": 1, "fieldname": "expense_account", "fieldtype": "Link", "label": "Expense Account", @@ -469,6 +471,7 @@ "print_hide": 1 }, { + "allow_on_submit": 1, "default": ":Company", "fieldname": "cost_center", "fieldtype": "Link", @@ -800,6 +803,7 @@ "options": "Finance Book" }, { + "allow_on_submit": 1, "fieldname": "project", "fieldtype": "Link", "label": "Project", @@ -822,7 +826,6 @@ "label": "Incoming Rate (Costing)", "no_copy": 1, "options": "Company:company:default_currency", - "precision": "6", "print_hide": 1 }, { @@ -835,6 +838,7 @@ "read_only": 1 }, { + "allow_on_submit": 1, "fieldname": "discount_account", "fieldtype": "Link", "label": "Discount Account", @@ -886,7 +890,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-12-28 16:17:33.484531", + "modified": "2022-10-17 12:51:44.825398", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json index 3a871bfceded..e236577e118a 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json +++ b/erpnext/accounts/doctype/sales_taxes_and_charges/sales_taxes_and_charges.json @@ -51,6 +51,7 @@ "oldfieldtype": "Data" }, { + "allow_on_submit": 1, "columns": 2, "fieldname": "account_head", "fieldtype": "Link", @@ -63,6 +64,7 @@ "search_index": 1 }, { + "allow_on_submit": 1, "default": ":Company", "fieldname": "cost_center", "fieldtype": "Link", @@ -216,12 +218,13 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-08-05 20:04:01.726867", + "modified": "2022-10-17 13:08:17.776528", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Taxes and Charges", "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/subscription_plan/subscription_plan.js b/erpnext/accounts/doctype/subscription_plan/subscription_plan.js index 7d6f2aed100f..00727f103f98 100644 --- a/erpnext/accounts/doctype/subscription_plan/subscription_plan.js +++ b/erpnext/accounts/doctype/subscription_plan/subscription_plan.js @@ -5,5 +5,9 @@ frappe.ui.form.on('Subscription Plan', { price_determination: function(frm) { frm.toggle_reqd("cost", frm.doc.price_determination === 'Fixed rate'); frm.toggle_reqd("price_list", frm.doc.price_determination === 'Based on price list'); - } + }, + + subscription_plan: function (frm) { + erpnext.utils.check_payments_app(); + }, }); diff --git a/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json b/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json index ce8c0c370865..46b430c6594b 100644 --- a/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json +++ b/erpnext/accounts/doctype/tax_withheld_vouchers/tax_withheld_vouchers.json @@ -1,6 +1,6 @@ { "actions": [], - "autoname": "autoincrement", + "autoname": "hash", "creation": "2022-09-13 16:18:59.404842", "doctype": "DocType", "editable_grid": 1, @@ -36,11 +36,11 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-09-13 23:40:41.479208", + "modified": "2023-01-13 13:40:41.479208", "modified_by": "Administrator", "module": "Accounts", "name": "Tax Withheld Vouchers", - "naming_rule": "Autoincrement", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "sort_field": "modified", diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index b834d1404d09..f0146ea70eb9 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -259,9 +259,7 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N if tax_deducted: net_total = inv.tax_withholding_net_total if ldc: - tax_amount = get_tds_amount_from_ldc( - ldc, parties, pan_no, tax_details, posting_date, net_total - ) + tax_amount = get_tds_amount_from_ldc(ldc, parties, tax_details, posting_date, net_total) else: tax_amount = net_total * tax_details.rate / 100 if net_total > 0 else 0 @@ -280,7 +278,7 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N tax_amount = get_tcs_amount(parties, inv, tax_details, vouchers, advance_vouchers) if cint(tax_details.round_off_tax_amount): - tax_amount = round(tax_amount) + tax_amount = normal_round(tax_amount) return tax_amount, tax_deducted, tax_deducted_on_advances, voucher_wise_amount @@ -412,12 +410,26 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers): tds_amount = 0 invoice_filters = {"name": ("in", vouchers), "docstatus": 1, "apply_tds": 1} + ## for TDS to be deducted on advances + payment_entry_filters = { + "party_type": "Supplier", + "party": ("in", parties), + "docstatus": 1, + "apply_tax_withholding_amount": 1, + "unallocated_amount": (">", 0), + "posting_date": ["between", (tax_details.from_date, tax_details.to_date)], + "tax_withholding_category": tax_details.get("tax_withholding_category"), + } + field = "sum(tax_withholding_net_total)" if cint(tax_details.consider_party_ledger_amount): invoice_filters.pop("apply_tds", None) field = "sum(grand_total)" + payment_entry_filters.pop("apply_tax_withholding_amount", None) + payment_entry_filters.pop("tax_withholding_category", None) + supp_credit_amt = frappe.db.get_value("Purchase Invoice", invoice_filters, field) or 0.0 supp_jv_credit_amt = ( @@ -429,14 +441,28 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers): "party": ("in", parties), "reference_type": ("!=", "Purchase Invoice"), }, - "sum(credit_in_account_currency)", + "sum(credit_in_account_currency - debit_in_account_currency)", ) or 0.0 ) + # Get Amount via payment entry + payment_entry_amounts = frappe.db.get_all( + "Payment Entry", + filters=payment_entry_filters, + fields=["sum(unallocated_amount) as amount", "payment_type"], + group_by="payment_type", + ) + supp_credit_amt += supp_jv_credit_amt supp_credit_amt += inv.tax_withholding_net_total + for type in payment_entry_amounts: + if type.payment_type == "Pay": + supp_credit_amt += type.amount + else: + supp_credit_amt -= type.amount + threshold = tax_details.get("threshold", 0) cumulative_threshold = tax_details.get("cumulative_threshold", 0) @@ -538,7 +564,7 @@ def get_invoice_total_without_tcs(inv, tax_details): return inv.grand_total - tcs_tax_row_amount -def get_tds_amount_from_ldc(ldc, parties, pan_no, tax_details, posting_date, net_total): +def get_tds_amount_from_ldc(ldc, parties, tax_details, posting_date, net_total): tds_amount = 0 limit_consumed = frappe.db.get_value( "Purchase Invoice", @@ -577,3 +603,20 @@ def is_valid_certificate( valid = True return valid + + +def normal_round(number): + """ + Rounds a number to the nearest integer. + :param number: The number to round. + """ + decimal_part = number - int(number) + + if decimal_part >= 0.5: + decimal_part = 1 + else: + decimal_part = 0 + + number = int(number) + decimal_part + + return number diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index 23caac047adf..1e86cf5d2efb 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -16,7 +16,7 @@ class TestTaxWithholdingCategory(unittest.TestCase): def setUpClass(self): # create relevant supplier, etc create_records() - create_tax_with_holding_category() + create_tax_withholding_category_records() def tearDown(self): cancel_invoices() @@ -38,7 +38,7 @@ def test_cumulative_threshold_tds(self): pi = create_purchase_invoice(supplier="Test TDS Supplier") pi.submit() - # assert equal tax deduction on total invoice amount uptil now + # assert equal tax deduction on total invoice amount until now self.assertEqual(pi.taxes_and_charges_deducted, 3000) self.assertEqual(pi.grand_total, 7000) invoices.append(pi) @@ -47,7 +47,7 @@ def test_cumulative_threshold_tds(self): pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=5000) pi.submit() - # assert equal tax deduction on total invoice amount uptil now + # assert equal tax deduction on total invoice amount until now self.assertEqual(pi.taxes_and_charges_deducted, 500) invoices.append(pi) @@ -130,7 +130,7 @@ def test_cumulative_threshold_tcs(self): invoices.append(si) # create another invoice whose total when added to previously created invoice, - # surpasses cumulative threshhold + # surpasses cumulative threshold si = create_sales_invoice(customer="Test TCS Customer", rate=12000) si.submit() @@ -329,6 +329,38 @@ def test_tax_withholding_category_voucher_display(self): for d in reversed(invoices): d.cancel() + def test_tax_withholding_via_payment_entry_for_advances(self): + frappe.db.set_value( + "Supplier", "Test TDS Supplier7", "tax_withholding_category", "Advance TDS Category" + ) + + # create payment entry + pe1 = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier7", paid_amount=4000 + ) + pe1.submit() + + self.assertFalse(pe1.get("taxes")) + + pe2 = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier7", paid_amount=4000 + ) + pe2.submit() + + self.assertFalse(pe2.get("taxes")) + + pe3 = create_payment_entry( + payment_type="Pay", party_type="Supplier", party="Test TDS Supplier7", paid_amount=4000 + ) + pe3.apply_tax_withholding_amount = 1 + pe3.save() + pe3.submit() + + self.assertEquals(pe3.get("taxes")[0].tax_amount, 1200) + pe1.cancel() + pe2.cancel() + pe3.cancel() + def cancel_invoices(): purchase_invoices = frappe.get_all( @@ -450,6 +482,32 @@ def create_sales_invoice(**args): return si +def create_payment_entry(**args): + # return payment entry doc object + args = frappe._dict(args) + pe = frappe.get_doc( + { + "doctype": "Payment Entry", + "posting_date": today(), + "payment_type": args.payment_type, + "party_type": args.party_type, + "party": args.party, + "company": "_Test Company", + "paid_from": "Cash - _TC", + "paid_to": "Creditors - _TC", + "paid_amount": args.paid_amount or 10000, + "received_amount": args.paid_amount or 10000, + "reference_no": args.reference_no or "12345", + "reference_date": today(), + "paid_from_account_currency": "INR", + "paid_to_account_currency": "INR", + } + ) + + pe.save() + return pe + + def create_records(): # create a new suppliers for name in [ @@ -460,6 +518,7 @@ def create_records(): "Test TDS Supplier4", "Test TDS Supplier5", "Test TDS Supplier6", + "Test TDS Supplier7", ]: if frappe.db.exists("Supplier", name): continue @@ -530,142 +589,129 @@ def create_records(): ).insert() -def create_tax_with_holding_category(): +def create_tax_withholding_category_records(): fiscal_year = get_fiscal_year(today(), company="_Test Company") + from_date = fiscal_year[1] + to_date = fiscal_year[2] + # Cumulative threshold - if not frappe.db.exists("Tax Withholding Category", "Cumulative Threshold TDS"): - frappe.get_doc( - { - "doctype": "Tax Withholding Category", - "name": "Cumulative Threshold TDS", - "category_name": "10% TDS", - "rates": [ - { - "from_date": fiscal_year[1], - "to_date": fiscal_year[2], - "tax_withholding_rate": 10, - "single_threshold": 0, - "cumulative_threshold": 30000.00, - } - ], - "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}], - } - ).insert() + create_tax_withholding_category( + category_name="Cumulative Threshold TDS", + rate=10, + from_date=from_date, + to_date=to_date, + account="TDS - _TC", + single_threshold=0, + cumulative_threshold=30000.00, + ) - if not frappe.db.exists("Tax Withholding Category", "Cumulative Threshold TCS"): - frappe.get_doc( - { - "doctype": "Tax Withholding Category", - "name": "Cumulative Threshold TCS", - "category_name": "10% TCS", - "rates": [ - { - "from_date": fiscal_year[1], - "to_date": fiscal_year[2], - "tax_withholding_rate": 10, - "single_threshold": 0, - "cumulative_threshold": 30000.00, - } - ], - "accounts": [{"company": "_Test Company", "account": "TCS - _TC"}], - } - ).insert() + # Category for TCS + create_tax_withholding_category( + category_name="Cumulative Threshold TCS", + rate=10, + from_date=from_date, + to_date=to_date, + account="TCS - _TC", + single_threshold=0, + cumulative_threshold=30000.00, + ) - # Single thresold - if not frappe.db.exists("Tax Withholding Category", "Single Threshold TDS"): - frappe.get_doc( - { - "doctype": "Tax Withholding Category", - "name": "Single Threshold TDS", - "category_name": "10% TDS", - "rates": [ - { - "from_date": fiscal_year[1], - "to_date": fiscal_year[2], - "tax_withholding_rate": 10, - "single_threshold": 20000.00, - "cumulative_threshold": 0, - } - ], - "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}], - } - ).insert() + # Single threshold + create_tax_withholding_category( + category_name="Single Threshold TDS", + rate=10, + from_date=from_date, + to_date=to_date, + account="TDS - _TC", + single_threshold=20000, + cumulative_threshold=0, + ) - if not frappe.db.exists("Tax Withholding Category", "New TDS Category"): - frappe.get_doc( - { - "doctype": "Tax Withholding Category", - "name": "New TDS Category", - "category_name": "New TDS Category", - "round_off_tax_amount": 1, - "consider_party_ledger_amount": 1, - "tax_on_excess_amount": 1, - "rates": [ - { - "from_date": fiscal_year[1], - "to_date": fiscal_year[2], - "tax_withholding_rate": 10, - "single_threshold": 0, - "cumulative_threshold": 30000, - } - ], - "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}], - } - ).insert() + create_tax_withholding_category( + category_name="New TDS Category", + rate=10, + from_date=from_date, + to_date=to_date, + account="TDS - _TC", + single_threshold=0, + cumulative_threshold=30000, + round_off_tax_amount=1, + consider_party_ledger_amount=1, + tax_on_excess_amount=1, + ) - if not frappe.db.exists("Tax Withholding Category", "Test Service Category"): - frappe.get_doc( - { - "doctype": "Tax Withholding Category", - "name": "Test Service Category", - "category_name": "Test Service Category", - "rates": [ - { - "from_date": fiscal_year[1], - "to_date": fiscal_year[2], - "tax_withholding_rate": 10, - "single_threshold": 2000, - "cumulative_threshold": 2000, - } - ], - "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}], - } - ).insert() + create_tax_withholding_category( + category_name="Test Service Category", + rate=10, + from_date=from_date, + to_date=to_date, + account="TDS - _TC", + single_threshold=2000, + cumulative_threshold=2000, + ) + + create_tax_withholding_category( + category_name="Test Goods Category", + rate=10, + from_date=from_date, + to_date=to_date, + account="TDS - _TC", + single_threshold=2000, + cumulative_threshold=2000, + ) + + create_tax_withholding_category( + category_name="Test Multi Invoice Category", + rate=10, + from_date=from_date, + to_date=to_date, + account="TDS - _TC", + single_threshold=5000, + cumulative_threshold=10000, + ) + + create_tax_withholding_category( + category_name="Advance TDS Category", + rate=10, + from_date=from_date, + to_date=to_date, + account="TDS - _TC", + single_threshold=5000, + cumulative_threshold=10000, + consider_party_ledger_amount=1, + ) - if not frappe.db.exists("Tax Withholding Category", "Test Goods Category"): - frappe.get_doc( - { - "doctype": "Tax Withholding Category", - "name": "Test Goods Category", - "category_name": "Test Goods Category", - "rates": [ - { - "from_date": fiscal_year[1], - "to_date": fiscal_year[2], - "tax_withholding_rate": 10, - "single_threshold": 2000, - "cumulative_threshold": 2000, - } - ], - "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}], - } - ).insert() - if not frappe.db.exists("Tax Withholding Category", "Test Multi Invoice Category"): +def create_tax_withholding_category( + category_name, + rate, + from_date, + to_date, + account, + single_threshold=0, + cumulative_threshold=0, + round_off_tax_amount=0, + consider_party_ledger_amount=0, + tax_on_excess_amount=0, +): + if not frappe.db.exists("Tax Withholding Category", category_name): frappe.get_doc( { "doctype": "Tax Withholding Category", - "name": "Test Multi Invoice Category", - "category_name": "Test Multi Invoice Category", + "name": category_name, + "category_name": category_name, + "round_off_tax_amount": round_off_tax_amount, + "consider_party_ledger_amount": consider_party_ledger_amount, + "tax_on_excess_amount": tax_on_excess_amount, "rates": [ { - "from_date": fiscal_year[1], - "to_date": fiscal_year[2], - "tax_withholding_rate": 10, - "single_threshold": 5000, - "cumulative_threshold": 10000, + "from_date": from_date, + "to_date": to_date, + "tax_withholding_rate": rate, + "single_threshold": single_threshold, + "cumulative_threshold": cumulative_threshold, } ], - "accounts": [{"company": "_Test Company", "account": "TDS - _TC"}], + "accounts": [{"company": "_Test Company", "account": account}], } ).insert() diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 67cf6443538a..22cff133089f 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -32,6 +32,16 @@ from erpnext.accounts.utils import get_fiscal_year from erpnext.exceptions import InvalidAccountCurrency, PartyDisabled, PartyFrozen +PURCHASE_TRANSACTION_TYPES = {"Purchase Order", "Purchase Receipt", "Purchase Invoice"} +SALES_TRANSACTION_TYPES = { + "Quotation", + "Sales Order", + "Delivery Note", + "Sales Invoice", + "POS Invoice", +} +TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES + class DuplicatePartyAccountError(frappe.ValidationError): pass @@ -124,12 +134,6 @@ def _get_party_details( set_other_values(party_details, party, party_type) set_price_list(party_details, party, party_type, price_list, pos_profile) - party_details["tax_category"] = get_address_tax_category( - party.get("tax_category"), - party_address, - shipping_address if party_type != "Supplier" else party_address, - ) - tax_template = set_taxes( party.name, party_type, @@ -170,6 +174,9 @@ def _get_party_details( party_type, party.name, "tax_withholding_category" ) + if not party_details.get("tax_category") and pos_profile: + party_details["tax_category"] = frappe.get_value("POS Profile", pos_profile, "tax_category") + return party_details @@ -211,14 +218,10 @@ def set_address_details( else: party_details.update(get_company_address(company)) - if doctype and doctype in ["Delivery Note", "Sales Invoice", "Sales Order", "Quotation"]: - if party_details.company_address: - party_details.update( - get_fetch_values(doctype, "company_address", party_details.company_address) - ) - get_regional_address_details(party_details, doctype, company) + if doctype in SALES_TRANSACTION_TYPES and party_details.company_address: + party_details.update(get_fetch_values(doctype, "company_address", party_details.company_address)) - elif doctype and doctype in ["Purchase Invoice", "Purchase Order", "Purchase Receipt"]: + if doctype in PURCHASE_TRANSACTION_TYPES: if shipping_address: party_details.update( shipping_address=shipping_address, @@ -244,9 +247,21 @@ def set_address_details( **get_fetch_values(doctype, "shipping_address", party_details.billing_address) ) + party_address, shipping_address = ( + party_details.get(billing_address_field), + party_details.shipping_address_name, + ) + + party_details["tax_category"] = get_address_tax_category( + party.get("tax_category"), + party_address, + shipping_address if party_type != "Supplier" else party_address, + ) + + if doctype in TRANSACTION_TYPES: get_regional_address_details(party_details, doctype, company) - return party_details.get(billing_address_field), party_details.shipping_address_name + return party_address, shipping_address @erpnext.allow_regional @@ -544,7 +559,7 @@ def get_due_date_from_template(template_name, posting_date, bill_date): elif term.due_date_based_on == "Day(s) after the end of the invoice month": due_date = max(due_date, add_days(get_last_day(due_date), term.credit_days)) else: - due_date = max(due_date, add_months(get_last_day(due_date), term.credit_months)) + due_date = max(due_date, get_last_day(add_months(due_date, term.credit_months))) return due_date diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index fb2e444abd14..11de9a098dc4 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -810,7 +810,7 @@ def add_customer_filters( self.ple.party.isin( qb.from_(self.customer) .select(self.customer.name) - .where(self.customer.default_sales_partner == self.filters.get("payment_terms_template")) + .where(self.customer.default_sales_partner == self.filters.get("sales_partner")) ) ) @@ -859,7 +859,7 @@ def add_accounting_dimensions_filters(self): ) else: self.qb_selection_filter.append( - self.ple[dimension.fieldname] == self.filters[dimension.fieldname] + self.ple[dimension.fieldname].isin(self.filters[dimension.fieldname]) ) def is_invoice(self, ple): @@ -869,10 +869,15 @@ def is_invoice(self, ple): def get_party_details(self, party): if not party in self.party_details: if self.party_type == "Customer": + fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"] + + if self.filters.get("sales_partner"): + fields.append("default_sales_partner") + self.party_details[party] = frappe.db.get_value( "Customer", party, - ["customer_name", "territory", "customer_group", "customer_primary_contact"], + fields, as_dict=True, ) else: @@ -973,6 +978,9 @@ def get_columns(self): if self.filters.show_sales_person: self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data") + if self.filters.sales_partner: + self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data") + if self.filters.party_type == "Supplier": self.add_column( label=_("Supplier Group"), diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 889f5a22a8a1..29217b04be23 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -121,6 +121,9 @@ def set_party_details(self, row): if row.sales_person: self.party_total[row.party].sales_person.append(row.sales_person) + if self.filters.sales_partner: + self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner") + def get_columns(self): self.columns = [] self.add_column( @@ -160,6 +163,10 @@ def get_columns(self): ) if self.filters.show_sales_person: self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data") + + if self.filters.sales_partner: + self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data") + else: self.add_column( label=_("Supplier Group"), diff --git a/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py b/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py index 57d80492ae0e..f21c94b4940c 100644 --- a/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py +++ b/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py @@ -25,6 +25,7 @@ def get_data(filters): ["posting_date", "<=", filters.get("to_date")], ["against_voucher_type", "=", "Asset"], ["account", "in", depreciation_accounts], + ["is_cancelled", "=", 0], ] if filters.get("asset"): diff --git a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py index ad9b1ba58eb3..9cea37c4f802 100644 --- a/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py +++ b/erpnext/accounts/report/asset_depreciations_and_balances/asset_depreciations_and_balances.py @@ -135,6 +135,34 @@ def get_assets(filters): where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s and a.name = ds.parent and ifnull(ds.journal_entry, '') != '' group by a.asset_category union + SELECT a.asset_category, + ifnull(sum(case when gle.posting_date < %(from_date)s and (ifnull(a.disposal_date, 0) = 0 or a.disposal_date >= %(from_date)s) then + gle.debit + else + 0 + end), 0) as accumulated_depreciation_as_on_from_date, + ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and a.disposal_date >= %(from_date)s + and a.disposal_date <= %(to_date)s and gle.posting_date <= a.disposal_date then + gle.debit + else + 0 + end), 0) as depreciation_eliminated_during_the_period, + ifnull(sum(case when gle.posting_date >= %(from_date)s and gle.posting_date <= %(to_date)s + and (ifnull(a.disposal_date, 0) = 0 or gle.posting_date <= a.disposal_date) then + gle.debit + else + 0 + end), 0) as depreciation_amount_during_the_period + from `tabGL Entry` gle + join `tabAsset` a on + gle.against_voucher = a.name + join `tabAsset Category Account` aca on + aca.parent = a.asset_category and aca.company_name = %(company)s + join `tabCompany` company on + company.name = %(company)s + where a.docstatus=1 and a.company=%(company)s and a.calculate_depreciation=0 and a.purchase_date <= %(to_date)s and gle.debit != 0 and gle.is_cancelled = 0 and gle.account = ifnull(aca.depreciation_expense_account, company.depreciation_expense_account) + group by a.asset_category + union SELECT a.asset_category, ifnull(sum(case when ifnull(a.disposal_date, 0) != 0 and (a.disposal_date < %(from_date)s or a.disposal_date > %(to_date)s) then 0 diff --git a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py index 449ebdcd9240..306af722ba84 100644 --- a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py +++ b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.custom import ConstantColumn from frappe.utils import getdate, nowdate @@ -91,4 +92,65 @@ def get_entries(filters): as_list=1, ) - return sorted(journal_entries + payment_entries, key=lambda k: k[2] or getdate(nowdate())) + # Loan Disbursement + loan_disbursement = frappe.qb.DocType("Loan Disbursement") + + query = ( + frappe.qb.from_(loan_disbursement) + .select( + ConstantColumn("Loan Disbursement").as_("payment_document_type"), + loan_disbursement.name.as_("payment_entry"), + loan_disbursement.disbursement_date.as_("posting_date"), + loan_disbursement.reference_number.as_("cheque_no"), + loan_disbursement.clearance_date.as_("clearance_date"), + loan_disbursement.applicant.as_("against"), + -loan_disbursement.disbursed_amount.as_("amount"), + ) + .where(loan_disbursement.docstatus == 1) + .where(loan_disbursement.disbursement_date >= filters["from_date"]) + .where(loan_disbursement.disbursement_date <= filters["to_date"]) + .where(loan_disbursement.disbursement_account == filters["account"]) + .orderby(loan_disbursement.disbursement_date, order=frappe.qb.desc) + .orderby(loan_disbursement.name, order=frappe.qb.desc) + ) + + if filters.get("from_date"): + query = query.where(loan_disbursement.disbursement_date >= filters["from_date"]) + if filters.get("to_date"): + query = query.where(loan_disbursement.disbursement_date <= filters["to_date"]) + + loan_disbursements = query.run(as_list=1) + + # Loan Repayment + loan_repayment = frappe.qb.DocType("Loan Repayment") + + query = ( + frappe.qb.from_(loan_repayment) + .select( + ConstantColumn("Loan Repayment").as_("payment_document_type"), + loan_repayment.name.as_("payment_entry"), + loan_repayment.posting_date.as_("posting_date"), + loan_repayment.reference_number.as_("cheque_no"), + loan_repayment.clearance_date.as_("clearance_date"), + loan_repayment.applicant.as_("against"), + loan_repayment.amount_paid.as_("amount"), + ) + .where(loan_repayment.docstatus == 1) + .where(loan_repayment.posting_date >= filters["from_date"]) + .where(loan_repayment.posting_date <= filters["to_date"]) + .where(loan_repayment.payment_account == filters["account"]) + .orderby(loan_repayment.posting_date, order=frappe.qb.desc) + .orderby(loan_repayment.name, order=frappe.qb.desc) + ) + + if filters.get("from_date"): + query = query.where(loan_repayment.posting_date >= filters["from_date"]) + if filters.get("to_date"): + query = query.where(loan_repayment.posting_date <= filters["to_date"]) + + loan_repayments = query.run(as_list=1) + + return sorted( + journal_entries + payment_entries + loan_disbursements + loan_repayments, + key=lambda k: k[2] or getdate(nowdate()), + ) diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 560b79243d70..9a3e82486afc 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -138,7 +138,8 @@ def prepare_companywise_opening_balance(asset_data, liability_data, equity_data, for data in [asset_data, liability_data, equity_data]: if data: account_name = get_root_account_name(data[0].root_type, company) - opening_value += get_opening_balance(account_name, data, company) or 0.0 + if account_name: + opening_value += get_opening_balance(account_name, data, company) or 0.0 opening_balance[company] = opening_value @@ -155,7 +156,7 @@ def get_opening_balance(account_name, data, company): def get_root_account_name(root_type, company): - return frappe.get_all( + root_account = frappe.get_all( "Account", fields=["account_name"], filters={ @@ -165,7 +166,10 @@ def get_root_account_name(root_type, company): "parent_account": ("is", "not set"), }, as_list=1, - )[0][0] + ) + + if root_account: + return root_account[0][0] def get_profit_loss_data(fiscal_year, companies, columns, filters): diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py index 6cc86c3efec4..3e11643776ea 100644 --- a/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py +++ b/erpnext/accounts/report/deferred_revenue_and_expense/deferred_revenue_and_expense.py @@ -378,15 +378,14 @@ def generate_report_data(self): ret += [{}] # add total row - if ret is not []: - if self.filters.type == "Revenue": - total_row = frappe._dict({"name": "Total Deferred Income"}) - elif self.filters.type == "Expense": - total_row = frappe._dict({"name": "Total Deferred Expense"}) + if self.filters.type == "Revenue": + total_row = frappe._dict({"name": "Total Deferred Income"}) + elif self.filters.type == "Expense": + total_row = frappe._dict({"name": "Total Deferred Expense"}) - for idx, period in enumerate(self.period_list, 0): - total_row[period.key] = self.period_total[idx].total - ret.append(total_row) + for idx, period in enumerate(self.period_list, 0): + total_row[period.key] = self.period_total[idx].total + ret.append(total_row) return ret diff --git a/erpnext/accounts/report/general_ledger/general_ledger.html b/erpnext/accounts/report/general_ledger/general_ledger.html index c04f518d7e6c..2d5ca497654f 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.html +++ b/erpnext/accounts/report/general_ledger/general_ledger.html @@ -25,8 +25,8 @@
{%= __("Date") %} - {%= __("Ref") %} - {%= __("Party") %} + {%= __("Reference") %} + {%= __("Remarks") %} {%= __("Debit") %} {%= __("Credit") %} {%= __("Balance (Dr - Cr)") %} @@ -38,23 +38,28 @@
{% if(data[i].posting_date) { %} {%= frappe.datetime.str_to_user(data[i].posting_date) %} {%= data[i].voucher_type %} -
{%= data[i].voucher_no %} - +
{%= data[i].voucher_no %} + + {% var longest_word = cstr(data[i].remarks).split(" ").reduce((longest, word) => word.length > longest.length ? word : longest, ""); %} + 45 %} class="overflow-wrap-anywhere" {% endif %}> + {% if(!(filters.party || filters.account)) { %} {%= data[i].party || data[i].account %}
{% } %} - {{ __("Against") }}: {%= data[i].against %}
{%= __("Remarks") %}: {%= data[i].remarks %} {% if(data[i].bill_no) { %}
{%= __("Supplier Invoice No") %}: {%= data[i].bill_no %} {% } %} - - - {%= format_currency(data[i].debit, filters.presentation_currency || data[i].account_currency) %} - - {%= format_currency(data[i].credit, filters.presentation_currency || data[i].account_currency) %} +
+ + + {%= format_currency(data[i].debit, filters.presentation_currency) %} + + + {%= format_currency(data[i].credit, filters.presentation_currency) %} + {% } else { %} diff --git a/erpnext/accounts/report/general_ledger/general_ledger.js b/erpnext/accounts/report/general_ledger/general_ledger.js index 010284c2ea55..2100f26c1ec8 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.js +++ b/erpnext/accounts/report/general_ledger/general_ledger.js @@ -58,9 +58,8 @@ frappe.query_reports["General Ledger"] = { { "fieldname":"party_type", "label": __("Party Type"), - "fieldtype": "Link", - "options": "Party Type", - "default": "", + "fieldtype": "Autocomplete", + options: Object.keys(frappe.boot.party_account_types), on_change: function() { frappe.query_report.set_filter_value('party', ""); } diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index ab93893861bf..745b6dfbdce2 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -524,7 +524,7 @@ def get_columns(filters): "options": "GL Entry", "hidden": 1, }, - {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 90}, + {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, { "label": _("Account"), "fieldname": "account", @@ -536,13 +536,13 @@ def get_columns(filters): "label": _("Debit ({0})").format(currency), "fieldname": "debit", "fieldtype": "Float", - "width": 100, + "width": 130, }, { "label": _("Credit ({0})").format(currency), "fieldname": "credit", "fieldtype": "Float", - "width": 100, + "width": 130, }, { "label": _("Balance ({0})").format(currency), diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index ba947f392f81..41ba11adc6fe 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -395,6 +395,7 @@ def get_column_names(): class GrossProfitGenerator(object): def __init__(self, filters=None): + self.sle = {} self.data = [] self.average_buying_rate = {} self.filters = frappe._dict(filters) @@ -404,7 +405,6 @@ def __init__(self, filters=None): if filters.group_by == "Invoice": self.group_items_by_invoice() - self.load_stock_ledger_entries() self.load_product_bundle() self.load_non_stock_items() self.get_returned_invoice_items() @@ -439,6 +439,18 @@ def process(self): row.delivery_note, frappe._dict() ) row.item_row = row.dn_detail + # Update warehouse and base_amount from 'Packed Item' List + if product_bundles and not row.parent: + # For Packed Items, row.parent_invoice will be the Bundle name + product_bundle = product_bundles.get(row.parent_invoice) + if product_bundle: + for packed_item in product_bundle: + if ( + packed_item.get("item_code") == row.item_code + and packed_item.get("parent_detail_docname") == row.item_row + ): + row.warehouse = packed_item.warehouse + row.base_amount = packed_item.base_amount # get buying amount if row.item_code in product_bundles: @@ -489,7 +501,14 @@ def get_average_rate_based_on_group_by(self): ): returned_item_rows = self.returned_invoices[row.parent][row.item_code] for returned_item_row in returned_item_rows: - row.qty += flt(returned_item_row.qty) + # returned_items 'qty' should be stateful + if returned_item_row.qty != 0: + if row.qty >= abs(returned_item_row.qty): + row.qty += returned_item_row.qty + returned_item_row.qty = 0 + else: + row.qty = 0 + returned_item_row.qty += row.qty row.base_amount += flt(returned_item_row.base_amount, self.currency_precision) row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision) if flt(row.qty) or row.base_amount: @@ -589,7 +608,9 @@ def get_buying_amount_from_product_bundle(self, row, product_bundle): buying_amount = 0.0 for packed_item in product_bundle: if packed_item.get("parent_detail_docname") == row.item_row: - buying_amount += self.get_buying_amount(row, packed_item.item_code) + packed_item_row = row.copy() + packed_item_row.warehouse = packed_item.warehouse + buying_amount += self.get_buying_amount(packed_item_row, packed_item.item_code) return flt(buying_amount, self.currency_precision) @@ -619,7 +640,7 @@ def get_buying_amount(self, row, item_code): return flt(row.qty) * item_rate else: - my_sle = self.sle.get((item_code, row.warehouse)) + my_sle = self.get_stock_ledger_entries(item_code, row.warehouse) if (row.update_stock or row.dn_detail) and my_sle: parenttype, parent = row.parenttype, row.parent if row.dn_detail: @@ -637,14 +658,36 @@ def get_buying_amount(self, row, item_code): dn["item_row"], dn["warehouse"], ) - my_sle = self.sle.get((item_code, warehouse)) + my_sle = self.get_stock_ledger_entries(item_code, row.warehouse) return self.calculate_buying_amount_from_sle( row, my_sle, parenttype, parent, item_row, item_code ) + elif row.sales_order and row.so_detail: + incoming_amount = self.get_buying_amount_from_so_dn(row.sales_order, row.so_detail, item_code) + if incoming_amount: + return incoming_amount else: return flt(row.qty) * self.get_average_buying_rate(row, item_code) - return 0.0 + return flt(row.qty) * self.get_average_buying_rate(row, item_code) + + def get_buying_amount_from_so_dn(self, sales_order, so_detail, item_code): + from frappe.query_builder.functions import Sum + + delivery_note_item = frappe.qb.DocType("Delivery Note Item") + + query = ( + frappe.qb.from_(delivery_note_item) + .select(Sum(delivery_note_item.incoming_rate * delivery_note_item.stock_qty)) + .where(delivery_note_item.docstatus == 1) + .where(delivery_note_item.item_code == item_code) + .where(delivery_note_item.against_sales_order == sales_order) + .where(delivery_note_item.so_detail == so_detail) + .groupby(delivery_note_item.item_code) + ) + + incoming_amount = query.run() + return flt(incoming_amount[0][0]) if incoming_amount else 0 def get_average_buying_rate(self, row, item_code): args = row @@ -698,6 +741,8 @@ def load_invoice_items(self): if self.filters.to_date: conditions += " and posting_date <= %(to_date)s" + conditions += " and (is_return = 0 or (is_return=1 and return_against is null))" + if self.filters.item_group: conditions += " and {0}".format(get_item_group_condition(self.filters.item_group)) @@ -746,7 +791,8 @@ def load_invoice_items(self): `tabSales Invoice`.territory, `tabSales Invoice Item`.item_code, `tabSales Invoice Item`.item_name, `tabSales Invoice Item`.description, `tabSales Invoice Item`.warehouse, `tabSales Invoice Item`.item_group, - `tabSales Invoice Item`.brand, `tabSales Invoice Item`.dn_detail, + `tabSales Invoice Item`.brand, `tabSales Invoice Item`.so_detail, + `tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.dn_detail, `tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty, `tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount, `tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return, @@ -900,34 +946,59 @@ def get_bundle_item_details(self, item_code): "Item", item_code, ["item_name", "description", "item_group", "brand"] ) - def load_stock_ledger_entries(self): - res = frappe.db.sql( - """select item_code, voucher_type, voucher_no, - voucher_detail_no, stock_value, warehouse, actual_qty as qty - from `tabStock Ledger Entry` - where company=%(company)s and is_cancelled = 0 - order by - item_code desc, warehouse desc, posting_date desc, - posting_time desc, creation desc""", - self.filters, - as_dict=True, - ) - self.sle = {} - for r in res: - if (r.item_code, r.warehouse) not in self.sle: - self.sle[(r.item_code, r.warehouse)] = [] + def get_stock_ledger_entries(self, item_code, warehouse): + if item_code and warehouse: + if (item_code, warehouse) not in self.sle: + sle = qb.DocType("Stock Ledger Entry") + res = ( + qb.from_(sle) + .select( + sle.item_code, + sle.voucher_type, + sle.voucher_no, + sle.voucher_detail_no, + sle.stock_value, + sle.warehouse, + sle.actual_qty.as_("qty"), + ) + .where( + (sle.company == self.filters.company) + & (sle.item_code == item_code) + & (sle.warehouse == warehouse) + & (sle.is_cancelled == 0) + ) + .orderby(sle.item_code) + .orderby(sle.warehouse, sle.posting_date, sle.posting_time, sle.creation, order=Order.desc) + .run(as_dict=True) + ) - self.sle[(r.item_code, r.warehouse)].append(r) + self.sle[(item_code, warehouse)] = res + + return self.sle[(item_code, warehouse)] + return [] def load_product_bundle(self): self.product_bundles = {} - for d in frappe.db.sql( - """select parenttype, parent, parent_item, - item_code, warehouse, -1*qty as total_qty, parent_detail_docname - from `tabPacked Item` where docstatus=1""", - as_dict=True, - ): + pki = qb.DocType("Packed Item") + + pki_query = ( + frappe.qb.from_(pki) + .select( + pki.parenttype, + pki.parent, + pki.parent_item, + pki.item_code, + pki.warehouse, + (-1 * pki.qty).as_("total_qty"), + pki.rate, + (pki.rate * pki.qty).as_("base_amount"), + pki.parent_detail_docname, + ) + .where(pki.docstatus == 1) + ) + + for d in pki_query.run(as_dict=True): self.product_bundles.setdefault(d.parenttype, frappe._dict()).setdefault( d.parent, frappe._dict() ).setdefault(d.parent_item, []).append(d) diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index fa11a41df4ad..82fe1a0ba129 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -302,3 +302,161 @@ def test_bundled_delivery_note_with_different_warehouses(self): columns, data = execute(filters=filters) self.assertGreater(len(data), 0) + + def test_order_connected_dn_and_inv(self): + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + """ + Test gp calculation when invoice and delivery note aren't directly connected. + SO -- INV + | + DN + """ + se = make_stock_entry( + company=self.company, + item_code=self.item, + target=self.warehouse, + qty=3, + basic_rate=100, + do_not_submit=True, + ) + item = se.items[0] + se.append( + "items", + { + "item_code": item.item_code, + "s_warehouse": item.s_warehouse, + "t_warehouse": item.t_warehouse, + "qty": 10, + "basic_rate": 200, + "conversion_factor": item.conversion_factor or 1.0, + "transfer_qty": flt(item.qty) * (flt(item.conversion_factor) or 1.0), + "serial_no": item.serial_no, + "batch_no": item.batch_no, + "cost_center": item.cost_center, + "expense_account": item.expense_account, + }, + ) + se = se.save().submit() + + so = make_sales_order( + customer=self.customer, + company=self.company, + warehouse=self.warehouse, + item=self.item, + qty=4, + do_not_save=False, + do_not_submit=False, + ) + + from erpnext.selling.doctype.sales_order.sales_order import ( + make_delivery_note, + make_sales_invoice, + ) + + make_delivery_note(so.name).submit() + sinv = make_sales_invoice(so.name).submit() + + filters = frappe._dict( + company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice" + ) + + columns, data = execute(filters=filters) + expected_entry = { + "parent_invoice": sinv.name, + "currency": "INR", + "sales_invoice": self.item, + "customer": self.customer, + "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()), + "item_code": self.item, + "item_name": self.item, + "warehouse": "Stores - _GP", + "qty": 4.0, + "avg._selling_rate": 100.0, + "valuation_rate": 125.0, + "selling_amount": 400.0, + "buying_amount": 500.0, + "gross_profit": -100.0, + "gross_profit_%": -25.0, + } + gp_entry = [x for x in data if x.parent_invoice == sinv.name] + self.assertDictContainsSubset(expected_entry, gp_entry[0]) + + def test_crnote_against_invoice_with_multiple_instances_of_same_item(self): + """ + Item Qty for Sales Invoices with multiple instances of same item go in the -ve. Ideally, the credit noteshould cancel out the invoice items. + """ + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + + # Invoice with an item added twice + sinv = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate(), do_not_submit=True) + sinv.append("items", frappe.copy_doc(sinv.items[0], ignore_no_copy=False)) + sinv = sinv.save().submit() + + # Create Credit Note for Invoice + cr_note = make_sales_return(sinv.name) + cr_note = cr_note.save().submit() + + filters = frappe._dict( + company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice" + ) + + columns, data = execute(filters=filters) + expected_entry = { + "parent_invoice": sinv.name, + "currency": "INR", + "sales_invoice": self.item, + "customer": self.customer, + "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()), + "item_code": self.item, + "item_name": self.item, + "warehouse": "Stores - _GP", + "qty": 0.0, + "avg._selling_rate": 0.0, + "valuation_rate": 0.0, + "selling_amount": -100.0, + "buying_amount": 0.0, + "gross_profit": -100.0, + "gross_profit_%": 100.0, + } + gp_entry = [x for x in data if x.parent_invoice == sinv.name] + # Both items of Invoice should have '0' qty + self.assertEqual(len(gp_entry), 2) + self.assertDictContainsSubset(expected_entry, gp_entry[0]) + self.assertDictContainsSubset(expected_entry, gp_entry[1]) + + def test_standalone_cr_notes(self): + """ + Standalone cr notes will be reported as usual + """ + # Make Cr Note + sinv = self.create_sales_invoice( + qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + sinv.is_return = 1 + sinv = sinv.save().submit() + + filters = frappe._dict( + company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice" + ) + + columns, data = execute(filters=filters) + expected_entry = { + "parent_invoice": sinv.name, + "currency": "INR", + "sales_invoice": self.item, + "customer": self.customer, + "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()), + "item_code": self.item, + "item_name": self.item, + "warehouse": "Stores - _GP", + "qty": -1.0, + "avg._selling_rate": 100.0, + "valuation_rate": 0.0, + "selling_amount": -100.0, + "buying_amount": 0.0, + "gross_profit": -100.0, + "gross_profit_%": 100.0, + } + gp_entry = [x for x in data if x.parent_invoice == sinv.name] + self.assertDictContainsSubset(expected_entry, gp_entry[0]) diff --git a/erpnext/accounts/report/payment_ledger/payment_ledger.js b/erpnext/accounts/report/payment_ledger/payment_ledger.js index 9779844dc945..a5a4108f1df9 100644 --- a/erpnext/accounts/report/payment_ledger/payment_ledger.js +++ b/erpnext/accounts/report/payment_ledger/payment_ledger.js @@ -37,6 +37,29 @@ function get_filters() { }); } }, + { + "fieldname":"party_type", + "label": __("Party Type"), + "fieldtype": "Link", + "options": "Party Type", + "default": "", + on_change: function() { + frappe.query_report.set_filter_value('party', ""); + } + }, + { + "fieldname":"party", + "label": __("Party"), + "fieldtype": "MultiSelectList", + get_data: function(txt) { + if (!frappe.query_report.filters) return; + + let party_type = frappe.query_report.get_filter_value('party_type'); + if (!party_type) return; + + return frappe.db.get_link_options(party_type, txt); + }, + }, { "fieldname":"voucher_no", "label": __("Voucher No"), @@ -49,6 +72,20 @@ function get_filters() { "fieldtype": "Data", "width": 100, }, + { + "fieldname":"include_account_currency", + "label": __("Include Account Currency"), + "fieldtype": "Check", + "width": 100, + }, + { + "fieldname":"group_party", + "label": __("Group by Party"), + "fieldtype": "Check", + "width": 100, + }, + + ] return filters; diff --git a/erpnext/accounts/report/payment_ledger/payment_ledger.py b/erpnext/accounts/report/payment_ledger/payment_ledger.py index e470c2727e38..8875d2743fe2 100644 --- a/erpnext/accounts/report/payment_ledger/payment_ledger.py +++ b/erpnext/accounts/report/payment_ledger/payment_ledger.py @@ -17,34 +17,26 @@ def __init__(self, filters=None): self.ple = qb.DocType("Payment Ledger Entry") def init_voucher_dict(self): - if self.voucher_amount: - s = set() - # build a set of unique vouchers + # for each ple, using group_by_key to create a key and assign it to +/- list for ple in self.voucher_amount: - key = (ple.voucher_type, ple.voucher_no, ple.party) - s.add(key) - - # for each unique vouchers, initialize +/- list - for key in s: - self.voucher_dict[key] = frappe._dict(increase=list(), decrease=list()) + group_by_key = None + if not self.filters.group_party: + group_by_key = (ple.against_voucher_type, ple.against_voucher_no, ple.party) + else: + group_by_key = (ple.party_type, ple.party) - # for each ple, using against voucher and amount, assign it to +/- list - # group by against voucher - for ple in self.voucher_amount: - against_key = (ple.against_voucher_type, ple.against_voucher_no, ple.party) target = None - if self.voucher_dict.get(against_key): - if ple.amount > 0: - target = self.voucher_dict.get(against_key).increase - else: - target = self.voucher_dict.get(against_key).decrease + if ple.amount > 0: + target = self.voucher_dict.setdefault(group_by_key, {}).setdefault("increase", []) + else: + target = self.voucher_dict.setdefault(group_by_key, {}).setdefault("decrease", []) # this if condition will lose unassigned ple entries(against_voucher doc doesn't have ple) # need to somehow include the stray entries as well. if target is not None: entry = frappe._dict( - company=ple.company, + posting_date=ple.posting_date, account=ple.account, party_type=ple.party_type, party=ple.party, @@ -66,10 +58,10 @@ def build_data(self): for value in self.voucher_dict.values(): voucher_data = [] - if value.increase != []: - voucher_data.extend(value.increase) - if value.decrease != []: - voucher_data.extend(value.decrease) + if value.get("increase"): + voucher_data.extend(value.get("increase")) + if value.get("decrease"): + voucher_data.extend(value.get("decrease")) if voucher_data: # balance row @@ -117,6 +109,12 @@ def build_conditions(self): if self.filters.against_voucher_no: self.conditions.append(self.ple.against_voucher_no == self.filters.against_voucher_no) + if self.filters.party_type: + self.conditions.append(self.ple.party_type == self.filters.party_type) + + if self.filters.party: + self.conditions.append(self.ple.party.isin(self.filters.party)) + def get_data(self): ple = self.ple @@ -134,7 +132,13 @@ def get_data(self): def get_columns(self): options = None self.columns.append( - dict(label=_("Company"), fieldname="company", fieldtype="data", options=options, width="100") + dict( + label=_("Posting Date"), + fieldname="posting_date", + fieldtype="Date", + options=options, + width="100", + ) ) self.columns.append( @@ -160,7 +164,11 @@ def get_columns(self): ) self.columns.append( dict( - label=_("Voucher No"), fieldname="voucher_no", fieldtype="data", options=options, width="100" + label=_("Voucher No"), + fieldname="voucher_no", + fieldtype="Dynamic Link", + options="voucher_type", + width="100", ) ) self.columns.append( @@ -176,8 +184,8 @@ def get_columns(self): dict( label=_("Against Voucher No"), fieldname="against_voucher_no", - fieldtype="data", - options=options, + fieldtype="Dynamic Link", + options="against_voucher_type", width="100", ) ) @@ -209,7 +217,7 @@ def run(self): self.get_columns() self.get_data() - # initialize dictionary and group using against voucher + # initialize dictionary and group using key self.init_voucher_dict() # convert dictionary to list and add balance rows diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index 98838907be1f..bfe2a0fd2bec 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.utils import flt def execute(filters=None): @@ -65,6 +66,12 @@ def get_result( else: total_amount_credited += entry.credit + ## Check if ldc is applied and show rate as per ldc + actual_rate = (tds_deducted / total_amount_credited) * 100 + + if flt(actual_rate) < flt(rate): + rate = actual_rate + if tds_deducted: row = { "pan" diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 6d2cd8ed4112..61bc58009a6f 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -78,7 +78,6 @@ def validate_filters(filters): def get_data(filters): - accounts = frappe.db.sql( """select name, account_number, parent_account, account_name, root_type, report_type, lft, rgt @@ -118,12 +117,10 @@ def get_data(filters): ignore_closing_entries=not flt(filters.with_period_closing_entry), ) - total_row = calculate_values( - accounts, gl_entries_by_account, opening_balances, filters, company_currency - ) + calculate_values(accounts, gl_entries_by_account, opening_balances) accumulate_values_into_parents(accounts, accounts_by_name) - data = prepare_data(accounts, filters, total_row, parent_children_map, company_currency) + data = prepare_data(accounts, filters, parent_children_map, company_currency) data = filter_out_zero_value_rows( data, parent_children_map, show_zero_values=filters.get("show_zero_values") ) @@ -218,7 +215,7 @@ def get_rootwise_opening_balances(filters, report_type): return opening -def calculate_values(accounts, gl_entries_by_account, opening_balances, filters, company_currency): +def calculate_values(accounts, gl_entries_by_account, opening_balances): init = { "opening_debit": 0.0, "opening_credit": 0.0, @@ -228,22 +225,6 @@ def calculate_values(accounts, gl_entries_by_account, opening_balances, filters, "closing_credit": 0.0, } - total_row = { - "account": "'" + _("Total") + "'", - "account_name": "'" + _("Total") + "'", - "warn_if_negative": True, - "opening_debit": 0.0, - "opening_credit": 0.0, - "debit": 0.0, - "credit": 0.0, - "closing_debit": 0.0, - "closing_credit": 0.0, - "parent_account": None, - "indent": 0, - "has_value": True, - "currency": company_currency, - } - for d in accounts: d.update(init.copy()) @@ -261,8 +242,28 @@ def calculate_values(accounts, gl_entries_by_account, opening_balances, filters, prepare_opening_closing(d) - for field in value_fields: - total_row[field] += d[field] + +def calculate_total_row(accounts, company_currency): + total_row = { + "account": "'" + _("Total") + "'", + "account_name": "'" + _("Total") + "'", + "warn_if_negative": True, + "opening_debit": 0.0, + "opening_credit": 0.0, + "debit": 0.0, + "credit": 0.0, + "closing_debit": 0.0, + "closing_credit": 0.0, + "parent_account": None, + "indent": 0, + "has_value": True, + "currency": company_currency, + } + + for d in accounts: + if not d.parent_account: + for field in value_fields: + total_row[field] += d[field] return total_row @@ -274,7 +275,7 @@ def accumulate_values_into_parents(accounts, accounts_by_name): accounts_by_name[d.parent_account][key] += d[key] -def prepare_data(accounts, filters, total_row, parent_children_map, company_currency): +def prepare_data(accounts, filters, parent_children_map, company_currency): data = [] for d in accounts: @@ -305,6 +306,7 @@ def prepare_data(accounts, filters, total_row, parent_children_map, company_curr row["has_value"] = has_value data.append(row) + total_row = calculate_total_row(accounts, company_currency) data.extend([{}, total_row]) return data diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 7ba433ea8c85..4ab0d56f681e 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -439,8 +439,7 @@ def reconcile_against_document(args): # nosemgrep # cancel advance entry doc = frappe.get_doc(voucher_type, voucher_no) frappe.flags.ignore_party_validation = True - gl_map = doc.build_gl_map() - create_payment_ledger_entry(gl_map, cancel=1, adv_adj=1) + _delete_pl_entries(voucher_type, voucher_no) for entry in entries: check_if_advance_entry_modified(entry) @@ -456,7 +455,13 @@ def reconcile_against_document(args): # nosemgrep # re-submit advance entry doc = frappe.get_doc(entry.voucher_type, entry.voucher_no) gl_map = doc.build_gl_map() - create_payment_ledger_entry(gl_map, cancel=0, adv_adj=1) + create_payment_ledger_entry(gl_map, update_outstanding="No", cancel=0, adv_adj=1) + + # Only update outstanding for newly linked vouchers + for entry in entries: + update_voucher_outstanding( + entry.against_voucher_type, entry.against_voucher, entry.account, entry.party_type, entry.party + ) frappe.flags.ignore_party_validation = False @@ -1501,9 +1506,12 @@ def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, pa ref_doc = frappe.get_doc(voucher_type, voucher_no) # Didn't use db_set for optimisation purpose - ref_doc.outstanding_amount = outstanding["outstanding_in_account_currency"] + ref_doc.outstanding_amount = outstanding["outstanding_in_account_currency"] or 0.0 frappe.db.set_value( - voucher_type, voucher_no, "outstanding_amount", outstanding["outstanding_in_account_currency"] + voucher_type, + voucher_no, + "outstanding_amount", + outstanding["outstanding_in_account_currency"] or 0.0, ) ref_doc.set_status(update=True) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 7791b1174443..0923d0093f97 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -205,39 +205,58 @@ frappe.ui.form.on('Asset', { }) }, - setup_chart: function(frm) { - var x_intervals = [frm.doc.purchase_date]; - var asset_values = [frm.doc.gross_purchase_amount]; - var last_depreciation_date = frm.doc.purchase_date; + setup_chart: async function(frm) { + if(frm.doc.finance_books.length > 1) { + return + } - if(frm.doc.opening_accumulated_depreciation) { - last_depreciation_date = frappe.datetime.add_months(frm.doc.next_depreciation_date, - -1*frm.doc.frequency_of_depreciation); + var x_intervals = [frappe.format(frm.doc.purchase_date, { fieldtype: 'Date' })]; + var asset_values = [frm.doc.gross_purchase_amount]; - x_intervals.push(last_depreciation_date); - asset_values.push(flt(frm.doc.gross_purchase_amount) - - flt(frm.doc.opening_accumulated_depreciation)); - } + if(frm.doc.calculate_depreciation) { + if(frm.doc.opening_accumulated_depreciation) { + var depreciation_date = frappe.datetime.add_months( + frm.doc.finance_books[0].depreciation_start_date, + -1 * frm.doc.finance_books[0].frequency_of_depreciation + ); + x_intervals.push(frappe.format(depreciation_date, { fieldtype: 'Date' })); + asset_values.push(flt(frm.doc.gross_purchase_amount - frm.doc.opening_accumulated_depreciation, precision('gross_purchase_amount'))); + } - $.each(frm.doc.schedules || [], function(i, v) { - x_intervals.push(v.schedule_date); - var asset_value = flt(frm.doc.gross_purchase_amount) - flt(v.accumulated_depreciation_amount); - if(v.journal_entry) { - last_depreciation_date = v.schedule_date; - asset_values.push(asset_value); - } else { - if (in_list(["Scrapped", "Sold"], frm.doc.status)) { - asset_values.push(null); + $.each(frm.doc.schedules || [], function(i, v) { + x_intervals.push(frappe.format(v.schedule_date, { fieldtype: 'Date' })); + var asset_value = flt(frm.doc.gross_purchase_amount - v.accumulated_depreciation_amount, precision('gross_purchase_amount')); + if(v.journal_entry) { + asset_values.push(asset_value); } else { - asset_values.push(asset_value) + if (in_list(["Scrapped", "Sold"], frm.doc.status)) { + asset_values.push(null); + } else { + asset_values.push(asset_value); + } } + }); + } else { + if(frm.doc.opening_accumulated_depreciation) { + x_intervals.push(frappe.format(frm.doc.creation.split(" ")[0], { fieldtype: 'Date' })); + asset_values.push(flt(frm.doc.gross_purchase_amount - frm.doc.opening_accumulated_depreciation, precision('gross_purchase_amount'))); } - }); + + let depr_entries = (await frappe.call({ + method: "get_manual_depreciation_entries", + doc: frm.doc, + })).message; + + $.each(depr_entries || [], function(i, v) { + x_intervals.push(frappe.format(v.posting_date, { fieldtype: 'Date' })); + let last_asset_value = asset_values[asset_values.length - 1] + asset_values.push(flt(last_asset_value - v.value, precision('gross_purchase_amount'))); + }); + } if(in_list(["Scrapped", "Sold"], frm.doc.status)) { - x_intervals.push(frm.doc.disposal_date); + x_intervals.push(frappe.format(frm.doc.disposal_date, { fieldtype: 'Date' })); asset_values.push(0); - last_depreciation_date = frm.doc.disposal_date; } frm.dashboard.render_graph({ @@ -283,10 +302,6 @@ frappe.ui.form.on('Asset', { // frm.toggle_reqd("next_depreciation_date", (!frm.doc.is_existing_asset && frm.doc.calculate_depreciation)); }, - opening_accumulated_depreciation: function(frm) { - erpnext.asset.set_accumulated_depreciation(frm); - }, - make_schedules_editable: function(frm) { if (frm.doc.finance_books) { var is_editable = frm.doc.finance_books.filter(d => d.depreciation_method == "Manual").length > 0 @@ -454,6 +469,9 @@ frappe.ui.form.on('Asset', { } else { frm.set_value('purchase_date', purchase_doc.posting_date); } + if (!frm.doc.is_existing_asset && !frm.doc.available_for_use_date) { + frm.set_value('available_for_use_date', frm.doc.purchase_date); + } const item = purchase_doc.items.find(item => item.item_code === frm.doc.item_code); if (!item) { doctype_field = frappe.scrub(doctype) @@ -548,19 +566,23 @@ frappe.ui.form.on('Depreciation Schedule', { }, depreciation_amount: function(frm, cdt, cdn) { - erpnext.asset.set_accumulated_depreciation(frm); + erpnext.asset.set_accumulated_depreciation(frm, locals[cdt][cdn].finance_book_id); } -}) +}); -erpnext.asset.set_accumulated_depreciation = function(frm) { - if(frm.doc.depreciation_method != "Manual") return; +erpnext.asset.set_accumulated_depreciation = function(frm, finance_book_id) { + var depreciation_method = frm.doc.finance_books[Number(finance_book_id) - 1].depreciation_method; + + if(depreciation_method != "Manual") return; var accumulated_depreciation = flt(frm.doc.opening_accumulated_depreciation); + $.each(frm.doc.schedules || [], function(i, row) { - accumulated_depreciation += flt(row.depreciation_amount); - frappe.model.set_value(row.doctype, row.name, - "accumulated_depreciation_amount", accumulated_depreciation); + if (row.finance_book_id === finance_book_id) { + accumulated_depreciation += flt(row.depreciation_amount); + frappe.model.set_value(row.doctype, row.name, "accumulated_depreciation_amount", accumulated_depreciation); + }; }) }; diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index 8b6af47b0492..3e93f0f03e3c 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -81,6 +81,9 @@ "options": "ACC-ASS-.YYYY.-" }, { + "depends_on": "item_code", + "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, "fieldname": "asset_name", "fieldtype": "Data", "in_list_view": 1, @@ -513,15 +516,21 @@ { "group": "Repair", "link_doctype": "Asset Repair", - "link_fieldname": "asset_name" + "link_fieldname": "asset" }, { "group": "Value", "link_doctype": "Asset Value Adjustment", "link_fieldname": "asset" + }, + { + "group": "Journal Entry", + "link_doctype": "Journal Entry", + "link_fieldname": "reference_name", + "table_fieldname": "accounts" } ], - "modified": "2022-12-05 16:21:30.024060", + "modified": "2023-03-30 15:07:41.542374", "modified_by": "Administrator", "module": "Assets", "name": "Asset", @@ -565,4 +574,4 @@ "states": [], "title_field": "asset_name", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index ca6be9b57b2d..fe1fd98aa098 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -26,6 +26,7 @@ from erpnext.assets.doctype.asset.depreciation import ( get_depreciation_accounts, get_disposal_account_and_cost_center, + is_last_day_of_the_month, ) from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.controllers.accounts_controller import AccountsController @@ -83,14 +84,55 @@ def prepare_depreciation_data(self, date_of_disposal=None, date_of_return=None): if self.calculate_depreciation: self.value_after_depreciation = 0 self.set_depreciation_rate() - self.make_depreciation_schedule(date_of_disposal) - self.set_accumulated_depreciation(date_of_disposal, date_of_return) + if self.should_prepare_depreciation_schedule(): + self.make_depreciation_schedule(date_of_disposal) + self.set_accumulated_depreciation(date_of_disposal, date_of_return) else: self.finance_books = [] self.value_after_depreciation = flt(self.gross_purchase_amount) - flt( self.opening_accumulated_depreciation ) + def should_prepare_depreciation_schedule(self): + if not self.get("schedules"): + return True + + old_asset_doc = self.get_doc_before_save() + + if not old_asset_doc: + return True + + have_asset_details_been_modified = ( + old_asset_doc.gross_purchase_amount != self.gross_purchase_amount + or old_asset_doc.opening_accumulated_depreciation != self.opening_accumulated_depreciation + or old_asset_doc.number_of_depreciations_booked != self.number_of_depreciations_booked + ) + + if have_asset_details_been_modified: + return True + + manual_fb_idx = -1 + for d in self.finance_books: + if d.depreciation_method == "Manual": + manual_fb_idx = d.idx - 1 + + no_manual_depr_or_have_manual_depr_details_been_modified = ( + manual_fb_idx == -1 + or old_asset_doc.finance_books[manual_fb_idx].total_number_of_depreciations + != self.finance_books[manual_fb_idx].total_number_of_depreciations + or old_asset_doc.finance_books[manual_fb_idx].frequency_of_depreciation + != self.finance_books[manual_fb_idx].frequency_of_depreciation + or old_asset_doc.finance_books[manual_fb_idx].depreciation_start_date + != getdate(self.finance_books[manual_fb_idx].depreciation_start_date) + or old_asset_doc.finance_books[manual_fb_idx].expected_value_after_useful_life + != self.finance_books[manual_fb_idx].expected_value_after_useful_life + ) + + if no_manual_depr_or_have_manual_depr_details_been_modified: + return True + + return False + def validate_item(self): item = frappe.get_cached_value( "Item", self.item_code, ["is_fixed_asset", "is_stock_item", "disabled"], as_dict=1 @@ -224,9 +266,7 @@ def set_depreciation_rate(self): ) def make_depreciation_schedule(self, date_of_disposal): - if "Manual" not in [d.depreciation_method for d in self.finance_books] and not self.get( - "schedules" - ): + if not self.get("schedules"): self.schedules = [] if not self.available_for_use_date: @@ -237,10 +277,13 @@ def make_depreciation_schedule(self, date_of_disposal): for finance_book in self.get("finance_books"): self._make_depreciation_schedule(finance_book, start, date_of_disposal) + if len(self.get("finance_books")) > 1 and any(start): + self.sort_depreciation_schedule() + def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): self.validate_asset_finance_books(finance_book) - value_after_depreciation = self._get_value_after_depreciation(finance_book) + value_after_depreciation = self._get_value_after_depreciation_for_making_schedule(finance_book) finance_book.value_after_depreciation = value_after_depreciation number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - cint( @@ -251,17 +294,42 @@ def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): if has_pro_rata: number_of_pending_depreciations += 1 + has_wdv_or_dd_non_yearly_pro_rata = False + if ( + finance_book.depreciation_method in ("Written Down Value", "Double Declining Balance") + and cint(finance_book.frequency_of_depreciation) != 12 + ): + has_wdv_or_dd_non_yearly_pro_rata = self.check_is_pro_rata( + finance_book, wdv_or_dd_non_yearly=True + ) + skip_row = False should_get_last_day = is_last_day_of_the_month(finance_book.depreciation_start_date) + depreciation_amount = 0 + for n in range(start[finance_book.idx - 1], number_of_pending_depreciations): # If depreciation is already completed (for double declining balance) if skip_row: continue - depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book) + if n > 0 and len(self.get("schedules")) > n - 1: + prev_depreciation_amount = self.get("schedules")[n - 1].depreciation_amount + else: + prev_depreciation_amount = 0 + + depreciation_amount = get_depreciation_amount( + self, + value_after_depreciation, + finance_book, + n, + prev_depreciation_amount, + has_wdv_or_dd_non_yearly_pro_rata, + ) - if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: + if not has_pro_rata or ( + n < (cint(number_of_pending_depreciations) - 1) or number_of_pending_depreciations == 2 + ): schedule_date = add_months( finance_book.depreciation_start_date, n * cint(finance_book.frequency_of_depreciation) ) @@ -277,7 +345,10 @@ def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): if date_of_disposal: from_date = self.get_from_date(finance_book.finance_book) depreciation_amount, days, months = self.get_pro_rata_amt( - finance_book, depreciation_amount, from_date, date_of_disposal + finance_book, + depreciation_amount, + from_date, + date_of_disposal, ) if depreciation_amount > 0: @@ -292,12 +363,20 @@ def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): break # For first row - if has_pro_rata and not self.opening_accumulated_depreciation and n == 0: + if ( + (has_pro_rata or has_wdv_or_dd_non_yearly_pro_rata) + and not self.opening_accumulated_depreciation + and n == 0 + ): from_date = add_days( self.available_for_use_date, -1 ) # needed to calc depr amount for available_for_use_date too depreciation_amount, days, months = self.get_pro_rata_amt( - finance_book, depreciation_amount, from_date, finance_book.depreciation_start_date + finance_book, + depreciation_amount, + from_date, + finance_book.depreciation_start_date, + has_wdv_or_dd_non_yearly_pro_rata, ) # For first depr schedule date will be the start date @@ -316,7 +395,11 @@ def _make_depreciation_schedule(self, finance_book, start, date_of_disposal): depreciation_amount_without_pro_rata = depreciation_amount depreciation_amount, days, months = self.get_pro_rata_amt( - finance_book, depreciation_amount, schedule_date, self.to_date + finance_book, + depreciation_amount, + schedule_date, + self.to_date, + has_wdv_or_dd_non_yearly_pro_rata, ) depreciation_amount = self.get_adjusted_depreciation_amount( @@ -365,8 +448,15 @@ def _add_depreciation_row( }, ) - def _get_value_after_depreciation(self, finance_book): - # value_after_depreciation - current Asset value + def sort_depreciation_schedule(self): + self.schedules = sorted( + self.schedules, key=lambda s: (int(s.finance_book_id), getdate(s.schedule_date)) + ) + + for idx, s in enumerate(self.schedules, 1): + s.idx = idx + + def _get_value_after_depreciation_for_making_schedule(self, finance_book): if self.docstatus == 1 and finance_book.value_after_depreciation: value_after_depreciation = flt(finance_book.value_after_depreciation) else: @@ -429,28 +519,37 @@ def get_from_date(self, finance_book): return add_days(self.available_for_use_date, -1) # if it returns True, depreciation_amount will not be equal for the first and last rows - def check_is_pro_rata(self, row): + def check_is_pro_rata(self, row, wdv_or_dd_non_yearly=False): has_pro_rata = False # if not existing asset, from_date = available_for_use_date # otherwise, if number_of_depreciations_booked = 2, available_for_use_date = 01/01/2020 and frequency_of_depreciation = 12 # from_date = 01/01/2022 - from_date = self.get_modified_available_for_use_date(row) + from_date = self.get_modified_available_for_use_date(row, wdv_or_dd_non_yearly) days = date_diff(row.depreciation_start_date, from_date) + 1 - # if frequency_of_depreciation is 12 months, total_days = 365 - total_days = get_total_days(row.depreciation_start_date, row.frequency_of_depreciation) + if wdv_or_dd_non_yearly: + total_days = get_total_days(row.depreciation_start_date, 12) + else: + # if frequency_of_depreciation is 12 months, total_days = 365 + total_days = get_total_days(row.depreciation_start_date, row.frequency_of_depreciation) if days < total_days: has_pro_rata = True return has_pro_rata - def get_modified_available_for_use_date(self, row): - return add_months( - self.available_for_use_date, - (self.number_of_depreciations_booked * row.frequency_of_depreciation), - ) + def get_modified_available_for_use_date(self, row, wdv_or_dd_non_yearly=False): + if wdv_or_dd_non_yearly: + return add_months( + self.available_for_use_date, + (self.number_of_depreciations_booked * 12), + ) + else: + return add_months( + self.available_for_use_date, + (self.number_of_depreciations_booked * row.frequency_of_depreciation), + ) def validate_asset_finance_books(self, row): if flt(row.expected_value_after_useful_life) >= flt(self.gross_purchase_amount): @@ -542,11 +641,9 @@ def has_only_one_finance_book(self): return True def set_accumulated_depreciation( - self, date_of_sale=None, date_of_return=None, ignore_booked_entry=False + self, date_of_disposal=None, date_of_return=None, ignore_booked_entry=False ): - straight_line_idx = [ - d.idx for d in self.get("schedules") if d.depreciation_method == "Straight Line" - ] + straight_line_idx = [] finance_books = [] for i, d in enumerate(self.get("schedules")): @@ -554,8 +651,16 @@ def set_accumulated_depreciation( continue if int(d.finance_book_id) not in finance_books: + straight_line_idx = [ + s.idx + for s in self.get("schedules") + if s.finance_book_id == d.finance_book_id + and (s.depreciation_method == "Straight Line" or s.depreciation_method == "Manual") + ] accumulated_depreciation = flt(self.opening_accumulated_depreciation) - value_after_depreciation = flt(self.get_value_after_depreciation(d.finance_book_id)) + value_after_depreciation = flt( + self.get("finance_books")[cint(d.finance_book_id) - 1].value_after_depreciation + ) finance_books.append(int(d.finance_book_id)) depreciation_amount = flt(d.depreciation_amount, d.precision("depreciation_amount")) @@ -565,7 +670,7 @@ def set_accumulated_depreciation( if ( straight_line_idx and i == max(straight_line_idx) - 1 - and not date_of_sale + and not date_of_disposal and not date_of_return ): book = self.get("finance_books")[cint(d.finance_book_id) - 1] @@ -580,9 +685,6 @@ def set_accumulated_depreciation( accumulated_depreciation, d.precision("accumulated_depreciation_amount") ) - def get_value_after_depreciation(self, idx): - return flt(self.get("finance_books")[cint(idx) - 1].value_after_depreciation) - def validate_expected_value_after_useful_life(self): for row in self.get("finance_books"): accumulated_depreciation_after_full_schedule = [ @@ -637,15 +739,20 @@ def cancel_movement_entries(self): movement.cancel() def delete_depreciation_entries(self): - for d in self.get("schedules"): - if d.journal_entry: - frappe.get_doc("Journal Entry", d.journal_entry).cancel() - d.db_set("journal_entry", None) - - self.db_set( - "value_after_depreciation", - (flt(self.gross_purchase_amount) - flt(self.opening_accumulated_depreciation)), - ) + if self.calculate_depreciation: + for d in self.get("schedules"): + if d.journal_entry: + frappe.get_doc("Journal Entry", d.journal_entry).cancel() + else: + depr_entries = self.get_manual_depreciation_entries() + + for depr_entry in depr_entries or []: + frappe.get_doc("Journal Entry", depr_entry.name).cancel() + + self.db_set( + "value_after_depreciation", + (flt(self.gross_purchase_amount) - flt(self.opening_accumulated_depreciation)), + ) def set_status(self, status=None): """Get and update status""" @@ -662,11 +769,15 @@ def get_status(self): if self.journal_entry_for_scrap: status = "Scrapped" - elif self.finance_books: - idx = self.get_default_finance_book_idx() or 0 + else: + expected_value_after_useful_life = 0 + value_after_depreciation = self.value_after_depreciation - expected_value_after_useful_life = self.finance_books[idx].expected_value_after_useful_life - value_after_depreciation = self.finance_books[idx].value_after_depreciation + if self.calculate_depreciation: + idx = self.get_default_finance_book_idx() or 0 + + expected_value_after_useful_life = self.finance_books[idx].expected_value_after_useful_life + value_after_depreciation = self.finance_books[idx].value_after_depreciation if flt(value_after_depreciation) <= expected_value_after_useful_life: status = "Fully Depreciated" @@ -676,6 +787,19 @@ def get_status(self): status = "Cancelled" return status + def get_value_after_depreciation(self, finance_book=None): + if not self.calculate_depreciation: + return flt(self.value_after_depreciation, self.precision("gross_purchase_amount")) + + if not finance_book: + return flt( + self.get("finance_books")[0].value_after_depreciation, self.precision("gross_purchase_amount") + ) + + for row in self.get("finance_books"): + if finance_book == row.finance_book: + return flt(row.value_after_depreciation, self.precision("gross_purchase_amount")) + def get_default_finance_book_idx(self): if not self.get("default_finance_book") and self.company: self.default_finance_book = erpnext.get_default_finance_book(self.company) @@ -801,6 +925,25 @@ def make_gl_entries(self): make_gl_entries(gl_entries) self.db_set("booked_fixed_asset", 1) + @frappe.whitelist() + def get_manual_depreciation_entries(self): + (_, _, depreciation_expense_account) = get_depreciation_accounts(self) + + gle = frappe.qb.DocType("GL Entry") + + records = ( + frappe.qb.from_(gle) + .select(gle.voucher_no.as_("name"), gle.debit.as_("value"), gle.posting_date) + .where(gle.against_voucher == self.name) + .where(gle.account == depreciation_expense_account) + .where(gle.debit != 0) + .where(gle.is_cancelled == 0) + .orderby(gle.posting_date) + .orderby(gle.creation) + ).run(as_dict=True) + + return records + @frappe.whitelist() def get_depreciation_rate(self, args, on_validate=False): if isinstance(args, str): @@ -809,20 +952,51 @@ def get_depreciation_rate(self, args, on_validate=False): float_precision = cint(frappe.db.get_default("float_precision")) or 2 if args.get("depreciation_method") == "Double Declining Balance": - return 200.0 / args.get("total_number_of_depreciations") + return 200.0 / ( + ( + flt(args.get("total_number_of_depreciations"), 2) * flt(args.get("frequency_of_depreciation")) + ) + / 12 + ) if args.get("depreciation_method") == "Written Down Value": - if args.get("rate_of_depreciation") and on_validate: + if ( + args.get("rate_of_depreciation") + and on_validate + and not self.flags.increase_in_asset_value_due_to_repair + ): return args.get("rate_of_depreciation") - value = flt(args.get("expected_value_after_useful_life")) / flt(self.gross_purchase_amount) - depreciation_rate = math.pow(value, 1.0 / flt(args.get("total_number_of_depreciations"), 2)) + if self.flags.increase_in_asset_value_due_to_repair: + value = flt(args.get("expected_value_after_useful_life")) / flt( + args.get("value_after_depreciation") + ) + else: + value = flt(args.get("expected_value_after_useful_life")) / flt(self.gross_purchase_amount) + + depreciation_rate = math.pow( + value, + 1.0 + / ( + ( + flt(args.get("total_number_of_depreciations"), 2) + * flt(args.get("frequency_of_depreciation")) + ) + / 12 + ), + ) + return flt((100 * (1 - depreciation_rate)), float_precision) - def get_pro_rata_amt(self, row, depreciation_amount, from_date, to_date): + def get_pro_rata_amt( + self, row, depreciation_amount, from_date, to_date, has_wdv_or_dd_non_yearly_pro_rata=False + ): days = date_diff(to_date, from_date) months = month_diff(to_date, from_date) - total_days = get_total_days(to_date, row.frequency_of_depreciation) + if has_wdv_or_dd_non_yearly_pro_rata: + total_days = get_total_days(to_date, 12) + else: + total_days = get_total_days(to_date, row.frequency_of_depreciation) return (depreciation_amount * flt(days)) / flt(total_days), days, months @@ -845,7 +1019,6 @@ def update_maintenance_status(): def make_post_gl_entry(): - asset_categories = frappe.db.get_all("Asset Category", fields=["name", "enable_cwip_accounting"]) for asset_category in asset_categories: @@ -998,7 +1171,7 @@ def make_journal_entry(asset_name): depreciation_expense_account, ) = get_depreciation_accounts(asset) - depreciation_cost_center, depreciation_series = frappe.db.get_value( + depreciation_cost_center, depreciation_series = frappe.get_cached_value( "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] ) depreciation_cost_center = asset.cost_center or depreciation_cost_center @@ -1063,6 +1236,13 @@ def is_cwip_accounting_enabled(asset_category): return cint(frappe.db.get_value("Asset Category", asset_category, "enable_cwip_accounting")) +@frappe.whitelist() +def get_asset_value_after_depreciation(asset_name, finance_book=None): + asset = frappe.get_doc("Asset", asset_name) + + return asset.get_value_after_depreciation(finance_book) + + def get_total_days(date, frequency): period_start_date = add_months(date, cint(frequency) * -1) @@ -1072,30 +1252,73 @@ def get_total_days(date, frequency): return date_diff(date, period_start_date) -def is_last_day_of_the_month(date): - last_day_of_the_month = get_last_day(date) - - return getdate(last_day_of_the_month) == getdate(date) - - @erpnext.allow_regional -def get_depreciation_amount(asset, depreciable_value, row): +def get_depreciation_amount( + asset, + depreciable_value, + row, + schedule_idx=0, + prev_depreciation_amount=0, + has_wdv_or_dd_non_yearly_pro_rata=False, +): if row.depreciation_method in ("Straight Line", "Manual"): - # if the Depreciation Schedule is being prepared for the first time - if not asset.flags.increase_in_asset_life: - depreciation_amount = ( - flt(asset.gross_purchase_amount) - flt(row.expected_value_after_useful_life) - ) / flt(row.total_number_of_depreciations) + return get_straight_line_or_manual_depr_amount(asset, row) + else: + return get_wdv_or_dd_depr_amount( + depreciable_value, + row.rate_of_depreciation, + row.frequency_of_depreciation, + schedule_idx, + prev_depreciation_amount, + has_wdv_or_dd_non_yearly_pro_rata, + ) - # if the Depreciation Schedule is being modified after Asset Repair - else: - depreciation_amount = ( - flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life) - ) / (date_diff(asset.to_date, asset.available_for_use_date) / 365) + +def get_straight_line_or_manual_depr_amount(asset, row): + # if the Depreciation Schedule is being modified after Asset Repair due to increase in asset life and value + if asset.flags.increase_in_asset_life: + return (flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life)) / ( + date_diff(asset.to_date, asset.available_for_use_date) / 365 + ) + # if the Depreciation Schedule is being modified after Asset Repair due to increase in asset value + elif asset.flags.increase_in_asset_value_due_to_repair: + return (flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life)) / flt( + row.total_number_of_depreciations + ) + # if the Depreciation Schedule is being prepared for the first time else: - depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100)) + return (flt(asset.gross_purchase_amount) - flt(row.expected_value_after_useful_life)) / flt( + row.total_number_of_depreciations + ) + - return depreciation_amount +def get_wdv_or_dd_depr_amount( + depreciable_value, + rate_of_depreciation, + frequency_of_depreciation, + schedule_idx, + prev_depreciation_amount, + has_wdv_or_dd_non_yearly_pro_rata, +): + if cint(frequency_of_depreciation) == 12: + return flt(depreciable_value) * (flt(rate_of_depreciation) / 100) + else: + if has_wdv_or_dd_non_yearly_pro_rata: + if schedule_idx == 0: + return flt(depreciable_value) * (flt(rate_of_depreciation) / 100) + elif schedule_idx % (12 / cint(frequency_of_depreciation)) == 1: + return ( + flt(depreciable_value) * flt(frequency_of_depreciation) * (flt(rate_of_depreciation) / 1200) + ) + else: + return prev_depreciation_amount + else: + if schedule_idx % (12 / cint(frequency_of_depreciation)) == 0: + return ( + flt(depreciable_value) * flt(frequency_of_depreciation) * (flt(rate_of_depreciation) / 1200) + ) + else: + return prev_depreciation_amount @frappe.whitelist() @@ -1149,9 +1372,13 @@ def update_existing_asset(asset, remaining_qty): expected_value_after_useful_life, ) - accumulated_depreciation = 0 + processed_finance_books = [] for term in asset.get("schedules"): + if int(term.finance_book_id) not in processed_finance_books: + accumulated_depreciation = 0 + processed_finance_books.append(int(term.finance_book_id)) + depreciation_amount = flt((term.depreciation_amount * remaining_qty) / asset.asset_quantity) frappe.db.set_value( "Depreciation Schedule", term.name, "depreciation_amount", depreciation_amount @@ -1173,7 +1400,6 @@ def create_new_asset_after_split(asset, split_qty): new_asset.opening_accumulated_depreciation = opening_accumulated_depreciation new_asset.asset_quantity = split_qty new_asset.split_from = asset.name - accumulated_depreciation = 0 for finance_book in new_asset.get("finance_books"): finance_book.value_after_depreciation = flt( @@ -1183,7 +1409,13 @@ def create_new_asset_after_split(asset, split_qty): (finance_book.expected_value_after_useful_life * split_qty) / asset.asset_quantity ) + processed_finance_books = [] + for term in new_asset.get("schedules"): + if int(term.finance_book_id) not in processed_finance_books: + accumulated_depreciation = 0 + processed_finance_books.append(int(term.finance_book_id)) + depreciation_amount = flt((term.depreciation_amount * split_qty) / asset.asset_quantity) term.depreciation_amount = depreciation_amount accumulated_depreciation += depreciation_amount diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index c6daccc9728b..74625890a697 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -4,7 +4,7 @@ import frappe from frappe import _ -from frappe.utils import add_months, cint, flt, getdate, nowdate, today +from frappe.utils import add_months, cint, flt, get_last_day, getdate, nowdate, today from frappe.utils.data import get_link_to_form from frappe.utils.user import get_users_with_role @@ -137,7 +137,7 @@ def make_depreciation_entry(asset_name, date=None): finance_books.value_after_depreciation -= d.depreciation_amount finance_books.db_update() - frappe.db.set_value("Asset", asset_name, "depr_entry_posting_status", "Successful") + asset.db_set("depr_entry_posting_status", "Successful") asset.set_status() @@ -218,10 +218,16 @@ def notify_depr_entry_posting_error(failed_asset_names): asset_links = get_comma_separated_asset_links(failed_asset_names) message = ( - _("Hi,") - + "
" - + _("The following assets have failed to post depreciation entries: {0}").format(asset_links) + _("Hello,") + + "

" + + _("The following assets have failed to automatically post depreciation entries: {0}").format( + asset_links + ) + "." + + "

" + + _( + "Please raise a support ticket and share this email, or forward this email to your development team so that they can find the issue in the developer console by manually creating the depreciation entry via the asset's depreciation schedule table." + ) ) frappe.sendmail(recipients=recipients, subject=subject, message=message) @@ -350,7 +356,10 @@ def reverse_depreciation_entry_made_after_disposal(asset, date): asset.flags.ignore_validate_update_after_submit = True schedule.journal_entry = None depreciation_amount = get_depreciation_amount_in_je(reverse_journal_entry) - asset.finance_books[0].value_after_depreciation += depreciation_amount + + idx = cint(schedule.finance_book_id) + asset.finance_books[idx - 1].value_after_depreciation += depreciation_amount + asset.save() @@ -369,6 +378,9 @@ def disposal_was_made_on_original_schedule_date(asset, schedule, row, posting_da finance_book.depreciation_start_date, row * cint(finance_book.frequency_of_depreciation) ) + if is_last_day_of_the_month(finance_book.depreciation_start_date): + orginal_schedule_date = get_last_day(orginal_schedule_date) + if orginal_schedule_date == posting_date_of_disposal: return True return False @@ -488,18 +500,8 @@ def get_asset_details(asset, finance_book=None): disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company) depreciation_cost_center = asset.cost_center or depreciation_cost_center - idx = 1 - if finance_book: - for d in asset.finance_books: - if d.finance_book == finance_book: - idx = d.idx - break - - value_after_depreciation = ( - asset.finance_books[idx - 1].value_after_depreciation - if asset.calculate_depreciation - else asset.value_after_depreciation - ) + value_after_depreciation = asset.get_value_after_depreciation(finance_book) + accumulated_depr_amount = flt(asset.gross_purchase_amount) - flt(value_after_depreciation) return ( @@ -572,3 +574,9 @@ def get_value_after_depreciation_on_disposal_date(asset, disposal_date, finance_ ) else: return flt(asset_doc.value_after_depreciation) + + +def is_last_day_of_the_month(date): + last_day_of_the_month = get_last_day(date) + + return getdate(last_day_of_the_month) == getdate(date) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 5a31ca0e2d77..7eaa4bf997a8 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -15,6 +15,7 @@ nowdate, ) +from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.assets.doctype.asset.asset import ( make_sales_invoice, @@ -22,6 +23,7 @@ update_maintenance_status, ) from erpnext.assets.doctype.asset.depreciation import ( + is_last_day_of_the_month, post_depreciation_entries, restore_asset, scrap_asset, @@ -816,12 +818,12 @@ def test_monthly_depreciation_by_wdv_method(self): ) expected_schedules = [ - ["2022-02-28", 647.25, 647.25], - ["2022-03-31", 1210.71, 1857.96], - ["2022-04-30", 1053.99, 2911.95], - ["2022-05-31", 917.55, 3829.5], - ["2022-06-30", 798.77, 4628.27], - ["2022-07-15", 371.73, 5000.0], + ["2022-02-28", 310.89, 310.89], + ["2022-03-31", 654.45, 965.34], + ["2022-04-30", 654.45, 1619.79], + ["2022-05-31", 654.45, 2274.24], + ["2022-06-30", 654.45, 2928.69], + ["2022-07-15", 2071.31, 5000.0], ] schedules = [ @@ -1441,6 +1443,36 @@ def test_depreciation_on_final_day_of_the_month(self): for i, schedule in enumerate(asset.schedules): self.assertEqual(getdate(expected_dates[i]), getdate(schedule.schedule_date)) + def test_manual_depreciation_for_existing_asset(self): + asset = create_asset( + item_code="Macbook Pro", + is_existing_asset=1, + purchase_date="2020-01-30", + available_for_use_date="2020-01-30", + submit=1, + ) + + self.assertEqual(asset.status, "Submitted") + self.assertEqual(asset.get("value_after_depreciation"), 100000) + + jv = make_journal_entry( + "_Test Depreciations - _TC", "_Test Accumulated Depreciations - _TC", 100, save=False + ) + for d in jv.accounts: + d.reference_type = "Asset" + d.reference_name = asset.name + jv.voucher_type = "Depreciation Entry" + jv.insert() + jv.submit() + + asset.reload() + self.assertEqual(asset.get("value_after_depreciation"), 99900) + + jv.cancel() + + asset.reload() + self.assertEqual(asset.get("value_after_depreciation"), 100000) + def create_asset_data(): if not frappe.db.exists("Asset Category", "Computers"): @@ -1577,9 +1609,3 @@ def set_depreciation_settings_in_company(company=None): def enable_cwip_accounting(asset_category, enable=1): frappe.db.set_value("Asset Category", asset_category, "enable_cwip_accounting", enable) - - -def is_last_day_of_the_month(dt): - last_day_of_the_month = get_last_day(dt) - - return getdate(dt) == getdate(last_day_of_the_month) diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index 08355f047e56..90b4d7e58343 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -11,6 +11,7 @@ from six import string_types import erpnext +from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation from erpnext.assets.doctype.asset.depreciation import ( depreciate_asset, get_gl_entries_on_asset_disposal, @@ -19,9 +20,6 @@ reverse_depreciation_entry_made_after_disposal, ) from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account -from erpnext.assets.doctype.asset_value_adjustment.asset_value_adjustment import ( - get_current_asset_value, -) from erpnext.controllers.stock_controller import StockController from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults @@ -259,7 +257,9 @@ def set_asset_values(self): for d in self.get("asset_items"): if d.asset: finance_book = d.get("finance_book") or self.get("finance_book") - d.current_asset_value = flt(get_current_asset_value(d.asset, finance_book=finance_book)) + d.current_asset_value = flt( + get_asset_value_after_depreciation(d.asset, finance_book=finance_book) + ) d.asset_value = get_value_after_depreciation_on_disposal_date( d.asset, self.posting_date, finance_book=finance_book ) @@ -696,7 +696,7 @@ def get_consumed_asset_details(args): if args.asset: out.current_asset_value = flt( - get_current_asset_value(args.asset, finance_book=args.finance_book) + get_asset_value_after_depreciation(args.asset, finance_book=args.finance_book) ) out.asset_value = get_value_after_depreciation_on_disposal_date( args.asset, args.posting_date, finance_book=args.finance_book diff --git a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py index 0028d84508d8..83031415ec3d 100644 --- a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py +++ b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py @@ -84,6 +84,8 @@ def calculate_next_due_date( next_due_date = add_years(start_date, 1) if periodicity == "2 Yearly": next_due_date = add_years(start_date, 2) + if periodicity == "3 Yearly": + next_due_date = add_years(start_date, 3) if periodicity == "Quarterly": next_due_date = add_months(start_date, 3) if end_date and ( diff --git a/erpnext/assets/doctype/asset_maintenance_task/asset_maintenance_task.json b/erpnext/assets/doctype/asset_maintenance_task/asset_maintenance_task.json index 20963e3fdc7a..b7cb23e66878 100644 --- a/erpnext/assets/doctype/asset_maintenance_task/asset_maintenance_task.json +++ b/erpnext/assets/doctype/asset_maintenance_task/asset_maintenance_task.json @@ -1,664 +1,156 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "", - "beta": 0, - "creation": "2017-10-20 07:10:55.903571", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2017-10-20 07:10:55.903571", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "maintenance_task", + "maintenance_type", + "column_break_2", + "maintenance_status", + "section_break_2", + "start_date", + "periodicity", + "column_break_4", + "end_date", + "certificate_required", + "section_break_9", + "assign_to", + "column_break_10", + "assign_to_name", + "section_break_10", + "next_due_date", + "column_break_14", + "last_completion_date", + "section_break_7", + "description" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "maintenance_task", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 1, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Maintenance Task", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "maintenance_task", + "fieldtype": "Data", + "in_filter": 1, + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Maintenance Task", + "reqd": 1 + }, + { + "fieldname": "maintenance_type", + "fieldtype": "Select", + "label": "Maintenance Type", + "options": "Preventive Maintenance\nCalibration" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "maintenance_status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Maintenance Status", + "options": "Planned\nOverdue\nCancelled", + "reqd": 1 + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "default": "Today", + "fieldname": "start_date", + "fieldtype": "Date", + "label": "Start Date", + "reqd": 1 + }, + { + "fieldname": "periodicity", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Periodicity", + "options": "\nDaily\nWeekly\nMonthly\nQuarterly\nYearly\n2 Yearly\n3 Yearly", + "reqd": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "end_date", + "fieldtype": "Date", + "label": "End Date" + }, + { + "default": "0", + "fieldname": "certificate_required", + "fieldtype": "Check", + "label": "Certificate Required", + "search_index": 1, + "set_only_once": 1 + }, + { + "fieldname": "section_break_9", + "fieldtype": "Section Break" + }, + { + "fieldname": "assign_to", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Assign To", + "options": "User" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "maintenance_type", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Maintenance Type", - "length": 0, - "no_copy": 0, - "options": "Preventive Maintenance\nCalibration", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_2", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "", - "fieldname": "maintenance_status", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Maintenance Status", - "length": 0, - "no_copy": 0, - "options": "Planned\nOverdue\nCancelled", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_2", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Today", - "fieldname": "start_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Start Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "periodicity", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Periodicity", - "length": 0, - "no_copy": 0, - "options": "\nDaily\nWeekly\nMonthly\nQuarterly\nYearly\n2 Yearly", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_4", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "end_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "End Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "certificate_required", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Certificate Required", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 1, - "set_only_once": 1, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_9", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "assign_to", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Assign To", - "length": 0, - "no_copy": 0, - "options": "User", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_10", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fetch_from": "assign_to.full_name", - "fieldname": "assign_to_name", - "fieldtype": "Read Only", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Assign to Name", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_10", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "next_due_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Next Due Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_14", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "last_completion_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Last Completion Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_7", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "description", - "fieldtype": "Text Editor", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "assign_to_name", + "fieldtype": "Read Only", + "label": "Assign to Name" + }, + { + "fieldname": "section_break_10", + "fieldtype": "Section Break" + }, + { + "fieldname": "next_due_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Next Due Date" + }, + { + "fieldname": "column_break_14", + "fieldtype": "Column Break" + }, + { + "fieldname": "last_completion_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Last Completion Date" + }, + { + "fieldname": "section_break_7", + "fieldtype": "Section Break" + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2018-06-18 16:12:04.330021", - "modified_by": "Administrator", - "module": "Assets", - "name": "Asset Maintenance Task", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "istable": 1, + "links": [], + "modified": "2023-03-23 07:03:07.113452", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Maintenance Task", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index d5913c59463b..47453e303086 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -39,43 +39,50 @@ def calculate_total_repair_cost(self): def before_submit(self): self.check_repair_status() + self.asset_doc.flags.increase_in_asset_value_due_to_repair = False + if self.get("stock_consumption") or self.get("capitalize_repair_cost"): + self.asset_doc.flags.increase_in_asset_value_due_to_repair = True + self.increase_asset_value() - if self.get("stock_consumption"): - self.check_for_stock_items_and_warehouse() - self.decrease_stock_quantity() - if self.get("capitalize_repair_cost"): - self.make_gl_entries() - if ( - frappe.db.get_value("Asset", self.asset, "calculate_depreciation") - and self.increase_in_asset_life - ): - self.modify_depreciation_schedule() - - self.asset_doc.flags.ignore_validate_update_after_submit = True - self.asset_doc.prepare_depreciation_data() - self.asset_doc.save() + + if self.get("stock_consumption"): + self.check_for_stock_items_and_warehouse() + self.decrease_stock_quantity() + if self.get("capitalize_repair_cost"): + self.make_gl_entries() + if self.asset_doc.calculate_depreciation and self.increase_in_asset_life: + self.modify_depreciation_schedule() + + self.asset_doc.flags.ignore_validate_update_after_submit = True + self.asset_doc.prepare_depreciation_data() + self.asset_doc.save() def before_cancel(self): self.asset_doc = frappe.get_doc("Asset", self.asset) + self.asset_doc.flags.increase_in_asset_value_due_to_repair = False + if self.get("stock_consumption") or self.get("capitalize_repair_cost"): + self.asset_doc.flags.increase_in_asset_value_due_to_repair = True + self.decrease_asset_value() - if self.get("stock_consumption"): - self.increase_stock_quantity() - if self.get("capitalize_repair_cost"): - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") - self.make_gl_entries(cancel=True) - self.db_set("stock_entry", None) - if ( - frappe.db.get_value("Asset", self.asset, "calculate_depreciation") - and self.increase_in_asset_life - ): - self.revert_depreciation_schedule_on_cancellation() - - self.asset_doc.flags.ignore_validate_update_after_submit = True - self.asset_doc.prepare_depreciation_data() - self.asset_doc.save() + + if self.get("stock_consumption"): + self.increase_stock_quantity() + if self.get("capitalize_repair_cost"): + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") + self.make_gl_entries(cancel=True) + self.db_set("stock_entry", None) + if self.asset_doc.calculate_depreciation and self.increase_in_asset_life: + self.revert_depreciation_schedule_on_cancellation() + + self.asset_doc.flags.ignore_validate_update_after_submit = True + self.asset_doc.prepare_depreciation_data() + self.asset_doc.save() + + def after_delete(self): + frappe.get_doc("Asset", self.asset).set_status() def check_repair_status(self): if self.repair_status == "Pending": diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 6599c078231d..3adb35169089 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -6,7 +6,10 @@ import frappe from frappe.utils import flt, nowdate -from erpnext.assets.doctype.asset.asset import get_asset_account +from erpnext.assets.doctype.asset.asset import ( + get_asset_account, + get_asset_value_after_depreciation, +) from erpnext.assets.doctype.asset.test_asset import ( create_asset, create_asset_data, @@ -106,20 +109,20 @@ def test_serialized_item_consumption(self): def test_increase_in_asset_value_due_to_stock_consumption(self): asset = create_asset(calculate_depreciation=1, submit=1) - initial_asset_value = get_asset_value(asset) + initial_asset_value = get_asset_value_after_depreciation(asset.name) asset_repair = create_asset_repair(asset=asset, stock_consumption=1, submit=1) asset.reload() - increase_in_asset_value = get_asset_value(asset) - initial_asset_value + increase_in_asset_value = get_asset_value_after_depreciation(asset.name) - initial_asset_value self.assertEqual(asset_repair.stock_items[0].total_value, increase_in_asset_value) def test_increase_in_asset_value_due_to_repair_cost_capitalisation(self): asset = create_asset(calculate_depreciation=1, submit=1) - initial_asset_value = get_asset_value(asset) + initial_asset_value = get_asset_value_after_depreciation(asset.name) asset_repair = create_asset_repair(asset=asset, capitalize_repair_cost=1, submit=1) asset.reload() - increase_in_asset_value = get_asset_value(asset) - initial_asset_value + increase_in_asset_value = get_asset_value_after_depreciation(asset.name) - initial_asset_value self.assertEqual(asset_repair.repair_cost, increase_in_asset_value) def test_purchase_invoice(self): @@ -244,10 +247,6 @@ def test_increase_in_asset_life(self): ) -def get_asset_value(asset): - return asset.finance_books[0].value_after_depreciation - - def num_of_depreciations(asset): return asset.finance_books[0].total_number_of_depreciations diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js index 36f510b18ee3..d07f40cdf422 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js @@ -47,9 +47,9 @@ frappe.ui.form.on('Asset Value Adjustment', { set_current_asset_value: function(frm) { if (frm.doc.asset) { frm.call({ - method: "erpnext.assets.doctype.asset_value_adjustment.asset_value_adjustment.get_current_asset_value", + method: "erpnext.assets.doctype.asset.asset.get_asset_value_after_depreciation", args: { - asset: frm.doc.asset, + asset_name: frm.doc.asset, finance_book: frm.doc.finance_book }, callback: function(r) { diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py index 59ab6a910d2f..e718472e1642 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -10,7 +10,10 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_checks_for_pl_and_bs_accounts, ) -from erpnext.assets.doctype.asset.asset import get_depreciation_amount +from erpnext.assets.doctype.asset.asset import ( + get_asset_value_after_depreciation, + get_depreciation_amount, +) from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts @@ -42,7 +45,7 @@ def set_difference_amount(self): def set_current_asset_value(self): if not self.current_asset_value and self.asset: - self.current_asset_value = get_current_asset_value(self.asset, self.finance_book) + self.current_asset_value = get_asset_value_after_depreciation(self.asset, self.finance_book) def make_depreciation_entry(self): asset = frappe.get_doc("Asset", self.asset) @@ -142,12 +145,3 @@ def reschedule_depreciations(self, asset_value): for asset_data in asset.schedules: if not asset_data.journal_entry: asset_data.db_update() - - -@frappe.whitelist() -def get_current_asset_value(asset, finance_book=None): - cond = {"parent": asset, "parenttype": "Asset"} - if finance_book: - cond.update({"finance_book": finance_book}) - - return frappe.db.get_value("Asset Finance Book", cond, "value_after_depreciation") diff --git a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py index 62c636624cef..b2aa3958080b 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py @@ -6,10 +6,8 @@ import frappe from frappe.utils import add_days, get_last_day, nowdate +from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation from erpnext.assets.doctype.asset.test_asset import create_asset_data -from erpnext.assets.doctype.asset_value_adjustment.asset_value_adjustment import ( - get_current_asset_value, -) from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -43,7 +41,7 @@ def test_current_asset_value(self): ) asset_doc.submit() - current_value = get_current_asset_value(asset_doc.name) + current_value = get_asset_value_after_depreciation(asset_doc.name) self.assertEqual(current_value, 100000.0) def test_asset_depreciation_value_adjustment(self): @@ -73,7 +71,7 @@ def test_asset_depreciation_value_adjustment(self): ) asset_doc.submit() - current_value = get_current_asset_value(asset_doc.name) + current_value = get_asset_value_after_depreciation(asset_doc.name) adj_doc = make_asset_value_adjustment( asset=asset_doc.name, current_asset_value=current_value, new_asset_value=50000.0 ) diff --git a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py index 6b14dce084e6..ae99c491a93f 100644 --- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py +++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import Sum from frappe.utils import cstr, flt, formatdate, getdate from erpnext.accounts.report.financial_statements import ( @@ -11,6 +12,8 @@ get_period_list, validate_fiscal_year, ) +from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation +from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts def execute(filters=None): @@ -85,7 +88,9 @@ def get_data(filters): "asset_name", "status", "department", + "company", "cost_center", + "calculate_depreciation", "purchase_receipt", "asset_category", "purchase_date", @@ -97,12 +102,21 @@ def get_data(filters): ] assets_record = frappe.db.get_all("Asset", filters=conditions, fields=fields) + assets_linked_to_fb = frappe.db.get_all( + doctype="Asset Finance Book", + filters={"finance_book": filters.finance_book or ("is", "not set")}, + pluck="parent", + ) + for asset in assets_record: - asset_value = ( - asset.gross_purchase_amount - - flt(asset.opening_accumulated_depreciation) - - flt(depreciation_amount_map.get(asset.name)) - ) + if filters.finance_book: + if asset.asset_id not in assets_linked_to_fb: + continue + else: + if asset.calculate_depreciation and asset.asset_id not in assets_linked_to_fb: + continue + + asset_value = get_asset_value_after_depreciation(asset.asset_id, filters.finance_book) row = { "asset_id": asset.asset_id, "asset_name": asset.asset_name, @@ -113,7 +127,7 @@ def get_data(filters): or pi_supplier_map.get(asset.purchase_invoice), "gross_purchase_amount": asset.gross_purchase_amount, "opening_accumulated_depreciation": asset.opening_accumulated_depreciation, - "depreciated_amount": depreciation_amount_map.get(asset.asset_id) or 0.0, + "depreciated_amount": get_depreciation_amount_of_asset(asset, depreciation_amount_map, filters), "available_for_use_date": asset.available_for_use_date, "location": asset.location, "asset_category": asset.asset_category, @@ -137,6 +151,7 @@ def prepare_chart_data(data, filters): filters.filter_based_on, "Monthly", company=filters.company, + ignore_fiscal_year=True, ) for d in period_list: @@ -170,6 +185,15 @@ def prepare_chart_data(data, filters): } +def get_depreciation_amount_of_asset(asset, depreciation_amount_map, filters): + if asset.calculate_depreciation: + depr_amount = depreciation_amount_map.get(asset.asset_id) or 0.0 + else: + depr_amount = get_manual_depreciation_amount_of_asset(asset, filters) + + return flt(depr_amount, 2) + + def get_finance_book_value_map(filters): date = filters.to_date if filters.filter_based_on == "Date Range" else filters.year_end_date @@ -189,6 +213,31 @@ def get_finance_book_value_map(filters): ) +def get_manual_depreciation_amount_of_asset(asset, filters): + date = filters.to_date if filters.filter_based_on == "Date Range" else filters.year_end_date + + (_, _, depreciation_expense_account) = get_depreciation_accounts(asset) + + gle = frappe.qb.DocType("GL Entry") + + result = ( + frappe.qb.from_(gle) + .select(Sum(gle.debit)) + .where(gle.against_voucher == asset.asset_id) + .where(gle.account == depreciation_expense_account) + .where(gle.debit != 0) + .where(gle.is_cancelled == 0) + .where(gle.posting_date <= date) + ).run() + + if result and result[0] and result[0][0]: + depr_amount = result[0][0] + else: + depr_amount = 0 + + return depr_amount + + def get_purchase_receipt_supplier_map(): return frappe._dict( frappe.db.sql( diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py index 646dba51ce96..c673be89b3f6 100644 --- a/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py @@ -15,17 +15,6 @@ def setUp(self): create_customer() create_item() - def test_for_single_record(self): - so_name = create_so() - transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice") - data = frappe.db.get_list( - "Sales Invoice", - filters={"posting_date": date.today(), "customer": "Bulk Customer"}, - fields=["*"], - ) - if not data: - self.fail("No Sales Invoice Created !") - def test_entry_in_log(self): so_name = create_so() transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice") diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 28158a31b94f..8c73e56a99e2 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -9,17 +9,21 @@ "supplier_and_price_defaults_section", "supp_master_name", "supplier_group", - "column_break_4", "buying_price_list", + "column_break_4", "maintain_same_rate_action", "role_to_override_stop_action", "transaction_settings_section", "po_required", "pr_required", + "over_order_allowance", "column_break_12", "maintain_same_rate", + "set_landed_cost_based_on_purchase_invoice_rate", "allow_multiple_items", "bill_for_rejected_quantity_in_purchase_invoice", + "disable_last_purchase_rate", + "show_pay_button", "subcontract", "backflush_raw_materials_of_subcontract_based_on", "column_break_11", @@ -71,7 +75,7 @@ }, { "fieldname": "subcontract", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Subcontracting Settings" }, { @@ -118,8 +122,8 @@ }, { "fieldname": "supplier_and_price_defaults_section", - "fieldtype": "Section Break", - "label": "Supplier and Price Defaults" + "fieldtype": "Tab Break", + "label": "Naming Series and Price Defaults" }, { "fieldname": "column_break_4", @@ -127,12 +131,39 @@ }, { "fieldname": "transaction_settings_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Transaction Settings" }, { "fieldname": "column_break_12", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "disable_last_purchase_rate", + "fieldtype": "Check", + "label": "Disable Last Purchase Rate" + }, + { + "default": "1", + "fieldname": "show_pay_button", + "fieldtype": "Check", + "label": "Show Pay Button in Purchase Order Portal" + }, + { + "default": "0", + "depends_on": "eval: !doc.maintain_same_rate", + "description": "Users can enable the checkbox If they want to adjust the incoming rate (set using purchase receipt) based on the purchase invoice rate.", + "fieldname": "set_landed_cost_based_on_purchase_invoice_rate", + "fieldtype": "Check", + "label": "Set Landed Cost Based on Purchase Invoice Rate" + }, + { + "default": "0", + "description": "Percentage you are allowed to order more against the Blanket Order Quantity. For example: If you have a Blanket Order of Quantity 100 units. and your Allowance is 10% then you are allowed to order 110 units.", + "fieldname": "over_order_allowance", + "fieldtype": "Float", + "label": "Over Order Allowance (%)" } ], "icon": "fa fa-cog", @@ -140,7 +171,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-09-27 10:50:27.050252", + "modified": "2023-03-02 17:02:14.404622", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py index be1ebdeb64e5..4680a889d3a0 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.py +++ b/erpnext/buying/doctype/buying_settings/buying_settings.py @@ -21,3 +21,10 @@ def validate(self): self.get("supp_master_name") == "Naming Series", hide_name_field=False, ) + + def before_save(self): + self.check_maintain_same_rate() + + def check_maintain_same_rate(self): + if self.maintain_same_rate: + self.set_landed_cost_based_on_purchase_invoice_rate = 0 diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 47089f7d8503..c6c9f1f98a39 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -236,7 +236,11 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e this.make_purchase_invoice, __('Create')); if(flt(doc.per_billed) < 100 && doc.status != "Delivered") { - cur_frm.add_custom_button(__('Payment'), cur_frm.cscript.make_payment_entry, __('Create')); + this.frm.add_custom_button( + __('Payment'), + () => this.make_payment_entry(), + __('Create') + ); } if(flt(doc.per_billed) < 100) { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 7009dde0ef93..15e689c96b14 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -1222,6 +1222,7 @@ }, { "default": "0", + "depends_on": "apply_tds", "fieldname": "tax_withholding_net_total", "fieldtype": "Currency", "hidden": 1, @@ -1231,12 +1232,13 @@ "read_only": 1 }, { + "depends_on": "apply_tds", "fieldname": "base_tax_withholding_net_total", "fieldtype": "Currency", "hidden": 1, "label": "Base Tax Withholding Net Total", "no_copy": 1, - "options": "currency", + "options": "Company:company:default_currency", "print_hide": 1, "read_only": 1 }, @@ -1277,7 +1279,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2022-12-28 18:08:59.074182", + "modified": "2023-01-28 18:59:16.322824", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 5a4168a573ea..06b9d29e69c7 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -21,6 +21,9 @@ from erpnext.accounts.party import get_party_account, get_party_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( + validate_against_blanket_order, +) from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.item.item import get_item_defaults, get_last_purchase_details from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty @@ -69,6 +72,7 @@ def validate(self): self.validate_with_previous_doc() self.validate_for_subcontracting() self.validate_minimum_order_qty() + validate_against_blanket_order(self) if self.is_old_subcontracting_flow: self.validate_bom_for_subcontracting_items() @@ -219,20 +223,16 @@ def validate_fg_item_for_subcontracting(self): else: if not frappe.get_value("Item", item.fg_item, "is_sub_contracted_item"): frappe.throw( - _( - "Row #{0}: Finished Good Item {1} must be a sub-contracted item for service item {2}" - ).format(item.idx, item.fg_item, item.item_code) + _("Row #{0}: Finished Good Item {1} must be a sub-contracted item").format( + item.idx, item.fg_item + ) ) elif not frappe.get_value("Item", item.fg_item, "default_bom"): frappe.throw( _("Row #{0}: Default BOM not found for FG Item {1}").format(item.idx, item.fg_item) ) if not item.fg_item_qty: - frappe.throw( - _("Row #{0}: Finished Good Item Qty is not specified for service item {0}").format( - item.idx, item.item_code - ) - ) + frappe.throw(_("Row #{0}: Finished Good Item Qty can not be zero").format(item.idx)) else: for item in self.items: item.set("fg_item", None) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 291d756a4157..920486a78ef9 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -10,6 +10,7 @@ from frappe.utils.data import today from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.party import get_due_date_from_template from erpnext.buying.doctype.purchase_order.purchase_order import make_inter_company_sales_order from erpnext.buying.doctype.purchase_order.purchase_order import ( make_purchase_invoice as make_pi_from_po, @@ -685,6 +686,12 @@ def test_po_for_blocked_supplier_payments_past_date(self): else: raise Exception + def test_default_payment_terms(self): + due_date = get_due_date_from_template( + "_Test Payment Term Template 1", "2023-02-03", None + ).strftime("%Y-%m-%d") + self.assertEqual(due_date, "2023-03-31") + def test_terms_are_not_copied_if_automatically_fetch_payment_terms_is_unchecked(self): po = create_purchase_order(do_not_save=1) po.payment_terms_template = "_Test Payment Term Template" @@ -743,9 +750,9 @@ def test_advance_paid_upon_payment_entry_cancellation(self): pe = get_payment_entry("Purchase Order", po_doc.name) pe.mode_of_payment = "Cash" pe.paid_from = "Cash - _TC" - pe.source_exchange_rate = 80 - pe.target_exchange_rate = 1 - pe.paid_amount = po_doc.grand_total + pe.source_exchange_rate = 1 + pe.target_exchange_rate = 80 + pe.paid_amount = po_doc.base_grand_total pe.save(ignore_permissions=True) pe.submit() @@ -889,6 +896,11 @@ def test_internal_transfer_flow(self): self.assertEqual(po.status, "Completed") self.assertEqual(mr.status, "Received") + def test_variant_item_po(self): + po = create_purchase_order(item_code="_Test Variant Item", qty=1, rate=100, do_not_save=1) + + self.assertRaises(frappe.ValidationError, po.save) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier @@ -994,8 +1006,8 @@ def create_purchase_order(**args): }, ) - po.set_missing_values() if not args.do_not_save: + po.set_missing_values() po.insert() if not args.do_not_submit: if po.is_subcontracted: diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index a9f5afb2e987..2f0b7862a82d 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -124,12 +124,11 @@ frappe.ui.form.on("Request for Quotation",{ frappe.urllib.get_full_url( "/api/method/erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_pdf?" + new URLSearchParams({ - doctype: frm.doc.doctype, name: frm.doc.name, supplier: data.supplier, print_format: data.print_format || "Standard", language: data.language || frappe.boot.lang, - letter_head: data.letter_head || frm.doc.letter_head || "", + letterhead: data.letter_head || frm.doc.letter_head || "", }).toString() ) ); diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index 019d45b5683e..bd65b0c805e8 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -29,6 +29,7 @@ "message_for_supplier", "terms_section_break", "incoterm", + "named_place", "tc_name", "terms", "printing_settings", @@ -278,13 +279,19 @@ "fieldtype": "Link", "label": "Incoterm", "options": "Incoterm" + }, + { + "depends_on": "incoterm", + "fieldname": "named_place", + "fieldtype": "Data", + "label": "Named Place" } ], "icon": "fa fa-shopping-cart", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-11-17 17:26:33.770993", + "modified": "2023-01-31 23:22:06.684694", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index dbc364495705..4590f8c3d937 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -3,6 +3,7 @@ import json +from typing import Optional import frappe from frappe import _ @@ -112,7 +113,10 @@ def send_to_supplier(self): def get_link(self): # RFQ link for supplier portal - return get_url("/app/request-for-quotation/" + self.name) + route = frappe.db.get_value( + "Portal Menu Item", {"reference_doctype": "Request for Quotation"}, ["route"] + ) + return get_url("/app/{0}/".format(route) + self.name) def update_supplier_part_no(self, supplier): self.vendor = supplier @@ -216,6 +220,7 @@ def send_email(self, data, sender, subject, message, attachments): recipients=data.email_id, sender=sender, attachments=attachments, + print_format=self.meta.default_print_format or "Standard", send_email=True, doctype=self.doctype, name=self.name, @@ -224,9 +229,7 @@ def send_email(self, data, sender, subject, message, attachments): frappe.msgprint(_("Email Sent to Supplier {0}").format(data.supplier)) def get_attachments(self): - attachments = [d.name for d in get_attachments(self.doctype, self.name)] - attachments.append(frappe.attach_print(self.doctype, self.name, doc=self)) - return attachments + return [d.name for d in get_attachments(self.doctype, self.name)] def update_rfq_supplier_status(self, sup_name=None): for supplier in self.suppliers: @@ -389,24 +392,26 @@ def create_rfq_items(sq_doc, supplier, data): @frappe.whitelist() -def get_pdf(doctype, name, supplier, print_format=None, language=None, letter_head=None): - # permissions get checked in `download_pdf` - if doc := get_rfq_doc(doctype, name, supplier): - download_pdf( - doctype, - name, - print_format, - doc=doc, - language=language, - letter_head=letter_head or None, - ) - - -def get_rfq_doc(doctype, name, supplier): +def get_pdf( + name: str, + supplier: str, + print_format: Optional[str] = None, + language: Optional[str] = None, + letterhead: Optional[str] = None, +): + doc = frappe.get_doc("Request for Quotation", name) if supplier: - doc = frappe.get_doc(doctype, name) doc.update_supplier_part_no(supplier) - return doc + + # permissions get checked in `download_pdf` + download_pdf( + doc.doctype, + doc.name, + print_format, + doc=doc, + language=language, + letterhead=letterhead or None, + ) @frappe.whitelist() diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index 064b806e9534..d250e6f18a96 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -8,6 +8,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( create_supplier_quotation, + get_pdf, make_supplier_quotation_from_rfq, ) from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq @@ -124,6 +125,11 @@ def test_make_rfq_from_opportunity(self): rfq.status = "Draft" rfq.submit() + def test_get_pdf(self): + rfq = make_request_for_quotation() + get_pdf(rfq.name, rfq.get("suppliers")[0].supplier) + self.assertEqual(frappe.local.response.type, "pdf") + def make_request_for_quotation(**args): """ diff --git a/erpnext/buying/doctype/supplier/supplier.js b/erpnext/buying/doctype/supplier/supplier.js index f0899b06b57b..1ae6f0364746 100644 --- a/erpnext/buying/doctype/supplier/supplier.js +++ b/erpnext/buying/doctype/supplier/supplier.js @@ -64,7 +64,7 @@ frappe.ui.form.on("Supplier", { // custom buttons frm.add_custom_button(__('Accounting Ledger'), function () { frappe.set_route('query-report', 'General Ledger', - { party_type: 'Supplier', party: frm.doc.name }); + { party_type: 'Supplier', party: frm.doc.name, party_name: frm.doc.supplier_name }); }, __("View")); frm.add_custom_button(__('Accounts Payable'), function () { diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index bebff1c3acbb..c96519a91538 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -128,18 +128,9 @@ def create_primary_address(self): def on_trash(self): if self.supplier_primary_contact: - frappe.db.sql( - """ - UPDATE `tabSupplier` - SET - supplier_primary_contact=null, - supplier_primary_address=null, - mobile_no=null, - email_id=null, - primary_address=null - WHERE name=%(name)s""", - {"name": self.name}, - ) + self.db_set("supplier_primary_contact", None) + if self.supplier_primary_address: + self.db_set("supplier_primary_address", None) delete_contact_and_address("Supplier", self.name) diff --git a/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py b/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py index 47a66ad46f2a..9b53421319d9 100644 --- a/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py +++ b/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py @@ -15,60 +15,4 @@ class TestProcurementTracker(FrappeTestCase): - def test_result_for_procurement_tracker(self): - filters = {"company": "_Test Procurement Company", "cost_center": "Main - _TPC"} - expected_data = self.generate_expected_data() - report = execute(filters) - - length = len(report[1]) - self.assertEqual(expected_data, report[1][length - 1]) - - def generate_expected_data(self): - if not frappe.db.exists("Company", "_Test Procurement Company"): - frappe.get_doc( - dict( - doctype="Company", - company_name="_Test Procurement Company", - abbr="_TPC", - default_currency="INR", - country="Pakistan", - ) - ).insert() - warehouse = create_warehouse("_Test Procurement Warehouse", company="_Test Procurement Company") - mr = make_material_request( - company="_Test Procurement Company", warehouse=warehouse, cost_center="Main - _TPC" - ) - po = make_purchase_order(mr.name) - po.supplier = "_Test Supplier" - po.get("items")[0].cost_center = "Main - _TPC" - po.submit() - pr = make_purchase_receipt(po.name) - pr.get("items")[0].cost_center = "Main - _TPC" - pr.submit() - date_obj = datetime.date(datetime.now()) - - po.load_from_db() - - expected_data = { - "material_request_date": date_obj, - "cost_center": "Main - _TPC", - "project": None, - "requesting_site": "_Test Procurement Warehouse - _TPC", - "requestor": "Administrator", - "material_request_no": mr.name, - "item_code": "_Test Item", - "quantity": 10.0, - "unit_of_measurement": "_Test UOM", - "status": "To Bill", - "purchase_order_date": date_obj, - "purchase_order": po.name, - "supplier": "_Test Supplier", - "estimated_cost": 0.0, - "actual_cost": 0.0, - "purchase_order_amt": po.net_total, - "purchase_order_amt_in_company_currency": po.base_net_total, - "expected_delivery_date": date_obj, - "actual_delivery_date": date_obj, - } - - return expected_data + pass diff --git a/erpnext/buying/report/subcontracted_item_to_be_received/subcontracted_item_to_be_received.js b/erpnext/buying/report/subcontracted_item_to_be_received/subcontracted_item_to_be_received.js index 6304a0908d03..9db769d59bf8 100644 --- a/erpnext/buying/report/subcontracted_item_to_be_received/subcontracted_item_to_be_received.js +++ b/erpnext/buying/report/subcontracted_item_to_be_received/subcontracted_item_to_be_received.js @@ -22,14 +22,14 @@ frappe.query_reports["Subcontracted Item To Be Received"] = { fieldname:"from_date", label: __("From Date"), fieldtype: "Date", - default: frappe.datetime.add_months(frappe.datetime.month_start(), -1), + default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), reqd: 1 }, { fieldname:"to_date", label: __("To Date"), fieldtype: "Date", - default: frappe.datetime.add_days(frappe.datetime.month_start(),-1), + default: frappe.datetime.get_today(), reqd: 1 }, ] diff --git a/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.js b/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.js index b6739fe66320..7e5338f353b4 100644 --- a/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.js +++ b/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.js @@ -22,14 +22,14 @@ frappe.query_reports["Subcontracted Raw Materials To Be Transferred"] = { fieldname:"from_date", label: __("From Date"), fieldtype: "Date", - default: frappe.datetime.add_months(frappe.datetime.month_start(), -1), + default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), reqd: 1 }, { fieldname:"to_date", label: __("To Date"), fieldtype: "Date", - default: frappe.datetime.add_days(frappe.datetime.month_start(),-1), + default: frappe.datetime.get_today(), reqd: 1 }, ] diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 242a27848c43..8e2e6e83a8b6 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -200,6 +200,12 @@ def before_cancel(self): validate_einvoice_fields(self) def on_trash(self): + # delete references in 'Repost Payment Ledger' + rpi = frappe.qb.DocType("Repost Payment Ledger Items") + frappe.qb.from_(rpi).delete().where( + (rpi.voucher_type == self.doctype) & (rpi.voucher_no == self.name) + ).run() + # 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") @@ -390,7 +396,7 @@ def validate_inter_company_reference(self): self.get("inter_company_reference") or self.get("inter_company_invoice_reference") or self.get("inter_company_order_reference") - ): + ) and not self.get("is_return"): msg = _("Internal Sale or Delivery Reference missing.") msg += _("Please create purchase from internal sale or delivery document itself") frappe.throw(msg, title=_("Internal Sales Reference Missing")) @@ -505,6 +511,8 @@ def set_missing_item_details(self, for_validate=False): parent_dict.update({"customer": parent_dict.get("party_name")}) self.pricing_rules = [] + basic_item_details_map = {} + for item in self.get("items"): if item.get("item_code"): args = parent_dict.copy() @@ -523,7 +531,17 @@ def set_missing_item_details(self, for_validate=False): if self.get("is_subcontracted"): args["is_subcontracted"] = self.is_subcontracted - ret = get_item_details(args, self, for_validate=True, overwrite_warehouse=False) + basic_details = basic_item_details_map.get(item.item_code) + ret, basic_item_details = get_item_details( + args, + self, + for_validate=True, + overwrite_warehouse=False, + return_basic_details=True, + basic_details=basic_details, + ) + + basic_item_details_map.setdefault(item.item_code, basic_item_details) for fieldname, value in ret.items(): if item.meta.get_field(fieldname) and value is not None: @@ -823,7 +841,9 @@ def get_shipping_address(self): def set_advances(self): """Returns list of advances against Account, Party, Reference""" - res = self.get_advance_entries() + res = self.get_advance_entries( + include_unallocated=not cint(self.get("only_include_allocated_payments")) + ) self.set("advances", []) advance_allocated = 0 @@ -1222,7 +1242,7 @@ def make_discount_gl_entries(self, gl_entries): ) ) - def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on, parentfield): + def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on): from erpnext.controllers.status_updater import get_allowance_for item_allowance = {} @@ -1235,17 +1255,20 @@ def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on, parentfield): total_overbilled_amt = 0.0 + reference_names = [d.get(item_ref_dn) for d in self.get("items") if d.get(item_ref_dn)] + reference_details = self.get_billing_reference_details( + reference_names, ref_dt + " Item", based_on + ) + for item in self.get("items"): if not item.get(item_ref_dn): continue - ref_amt = flt( - frappe.db.get_value(ref_dt + " Item", item.get(item_ref_dn), based_on), - self.precision(based_on, item), - ) + ref_amt = flt(reference_details.get(item.get(item_ref_dn)), self.precision(based_on, item)) + if not ref_amt: frappe.msgprint( - _("System will not check overbilling since amount for Item {0} in {1} is zero").format( + _("System will not check over billing since amount for Item {0} in {1} is zero").format( item.item_code, ref_dt ), title=_("Warning"), @@ -1292,6 +1315,16 @@ def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on, parentfield): alert=True, ) + def get_billing_reference_details(self, reference_names, reference_doctype, based_on): + return frappe._dict( + frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name", based_on], + as_list=1, + ) + ) + def get_billed_amount_for_item(self, item, item_ref_dn, based_on): """ Returns Sum of Amount of diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 2efa54573685..ab29dd858293 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -269,7 +269,10 @@ def update_valuation_rate(self, reset_outgoing_rate=True): ) / qty_in_stock_uom else: item.valuation_rate = ( - item.base_net_amount + item.item_tax_amount + flt(item.landed_cost_voucher_amount) + item.base_net_amount + + item.item_tax_amount + + flt(item.landed_cost_voucher_amount) + + flt(item.get("rate_difference_with_purchase_invoice")) ) / qty_in_stock_uom else: item.valuation_rate = 0.0 @@ -278,6 +281,9 @@ def set_incoming_rate(self): if self.doctype not in ("Purchase Receipt", "Purchase Invoice", "Purchase Order"): return + if not self.is_internal_transfer(): + return + ref_doctype_map = { "Purchase Order": "Sales Order Item", "Purchase Receipt": "Delivery Note Item", @@ -548,7 +554,9 @@ def on_submit(self): self.process_fixed_asset() self.update_fixed_asset(field) - if self.doctype in ["Purchase Order", "Purchase Receipt"]: + if self.doctype in ["Purchase Order", "Purchase Receipt"] and not frappe.db.get_single_value( + "Buying Settings", "disable_last_purchase_rate" + ): update_last_purchase_rate(self, is_submit=1) def on_cancel(self): @@ -557,7 +565,9 @@ def on_cancel(self): if self.get("is_return"): return - if self.doctype in ["Purchase Order", "Purchase Receipt"]: + if self.doctype in ["Purchase Order", "Purchase Receipt"] and not frappe.db.get_single_value( + "Buying Settings", "disable_last_purchase_rate" + ): update_last_purchase_rate(self, is_submit=0) if self.doctype in ["Purchase Receipt", "Purchase Invoice"]: @@ -709,6 +719,8 @@ def update_fixed_asset(self, field, delete_asset=False): asset.purchase_date = self.posting_date asset.supplier = self.supplier elif self.docstatus == 2: + if asset.docstatus == 2: + continue if asset.docstatus == 0: asset.set(field, None) asset.supplier = None diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 15c82af856b8..15c270e58adc 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -37,7 +37,7 @@ def validate_return_against(doc): if ( ref_doc.company == doc.company and ref_doc.get(party_type) == doc.get(party_type) - and ref_doc.docstatus == 1 + and ref_doc.docstatus.is_submitted() ): # validate posting date time return_posting_datetime = "%s %s" % (doc.posting_date, doc.get("posting_time") or "00:00:00") @@ -131,7 +131,7 @@ def validate_returned_items(doc): ) elif ref.serial_no: - if not d.serial_no: + if d.qty and not d.serial_no: frappe.throw(_("Row # {0}: Serial No is mandatory").format(d.idx)) else: serial_nos = get_serial_nos(d.serial_no) @@ -305,7 +305,7 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype): fields += ["sum(abs(`tab{0}`.received_stock_qty)) as received_stock_qty".format(child_doctype)] # Used retrun against and supplier and is_retrun because there is an index added for it - data = frappe.db.get_list( + data = frappe.get_all( doctype, fields=fields, filters=[ @@ -400,6 +400,16 @@ def update_item(source_doc, target_doc, source_parent): if serial_nos: target_doc.serial_no = "\n".join(serial_nos) + if source_doc.get("rejected_serial_no"): + returned_serial_nos = get_returned_serial_nos( + source_doc, source_parent, serial_no_field="rejected_serial_no" + ) + rejected_serial_nos = list( + set(get_serial_nos(source_doc.rejected_serial_no)) - set(returned_serial_nos) + ) + if rejected_serial_nos: + target_doc.rejected_serial_no = "\n".join(rejected_serial_nos) + if doctype in ["Purchase Receipt", "Subcontracting Receipt"]: returned_qty_map = get_returned_qty_map_for_row( source_parent.name, source_parent.supplier, source_doc.name, doctype @@ -610,7 +620,7 @@ def get_filters( return filters -def get_returned_serial_nos(child_doc, parent_doc): +def get_returned_serial_nos(child_doc, parent_doc, serial_no_field="serial_no"): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos return_ref_field = frappe.scrub(child_doc.doctype) @@ -619,7 +629,7 @@ def get_returned_serial_nos(child_doc, parent_doc): serial_nos = [] - fields = ["`{0}`.`serial_no`".format("tab" + child_doc.doctype)] + fields = [f"`{'tab' + child_doc.doctype}`.`{serial_no_field}`"] filters = [ [parent_doc.doctype, "return_against", "=", parent_doc.name], @@ -629,6 +639,6 @@ def get_returned_serial_nos(child_doc, parent_doc): ] for row in frappe.get_all(parent_doc.doctype, fields=fields, filters=filters): - serial_nos.extend(get_serial_nos(row.serial_no)) + serial_nos.extend(get_serial_nos(row.get(serial_no_field))) return serial_nos diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index dec3b750ebc2..590e00fb11e5 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -25,7 +25,7 @@ def get_feed(self): def onload(self): super(SellingController, self).onload() if self.doctype in ("Sales Order", "Delivery Note", "Sales Invoice"): - for item in self.get("items"): + for item in self.get("items") + (self.get("packed_items") or []): item.update(get_bin_details(item.item_code, item.warehouse, include_child_warehouses=True)) def validate(self): @@ -139,7 +139,7 @@ def set_total_in_words(self): self.in_words = money_in_words(amount, self.currency) def calculate_commission(self): - if not self.meta.get_field("commission_rate"): + if not self.meta.get_field("commission_rate") or self.docstatus.is_submitted(): return self.round_floats_in(self, ("amount_eligible_for_commission", "commission_rate")) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index d4972973d0b4..58cab147a477 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -58,7 +58,7 @@ def validate_status(status, options): "eval:(self.per_delivered == 100 or self.skip_delivery_note) and self.per_billed == 100 and self.docstatus == 1", ], ["Cancelled", "eval:self.docstatus==2"], - ["Closed", "eval:self.status=='Closed'"], + ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"], ["On Hold", "eval:self.status=='On Hold'"], ], "Purchase Order": [ @@ -79,7 +79,7 @@ def validate_status(status, options): ["Delivered", "eval:self.status=='Delivered'"], ["Cancelled", "eval:self.docstatus==2"], ["On Hold", "eval:self.status=='On Hold'"], - ["Closed", "eval:self.status=='Closed'"], + ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"], ], "Delivery Note": [ ["Draft", None], @@ -87,7 +87,7 @@ def validate_status(status, options): ["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"], ["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"], ["Cancelled", "eval:self.docstatus==2"], - ["Closed", "eval:self.status=='Closed'"], + ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"], ], "Purchase Receipt": [ ["Draft", None], @@ -95,7 +95,7 @@ def validate_status(status, options): ["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"], ["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"], ["Cancelled", "eval:self.docstatus==2"], - ["Closed", "eval:self.status=='Closed'"], + ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"], ], "Material Request": [ ["Draft", None], @@ -464,7 +464,7 @@ def _update_percent_field(self, args, update_modified=True): ifnull((select ifnull(sum(case when abs(%(target_ref_field)s) > abs(%(target_field)s) then abs(%(target_field)s) else abs(%(target_ref_field)s) end), 0) / sum(abs(%(target_ref_field)s)) * 100 - from `tab%(target_dt)s` where parent='%(name)s' having sum(abs(%(target_ref_field)s)) > 0), 0), 6) + from `tab%(target_dt)s` where parent='%(name)s' and parenttype='%(target_parent_dt)s' having sum(abs(%(target_ref_field)s)) > 0), 0), 6) %(update_modified)s where name='%(name)s'""" % args diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 335d92f43f38..05754293b776 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -74,24 +74,25 @@ def validate_items(self): ) if not is_stock_item: - msg = f"Item {item.item_name} must be a stock item." - frappe.throw(_(msg)) + frappe.throw(_("Row {0}: Item {1} must be a stock item.").format(item.idx, item.item_name)) if not is_sub_contracted_item: - msg = f"Item {item.item_name} must be a subcontracted item." - frappe.throw(_(msg)) + frappe.throw( + _("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name) + ) if item.bom: bom = frappe.get_doc("BOM", item.bom) if not bom.is_active: - msg = f"Please select an active BOM for Item {item.item_name}." - frappe.throw(_(msg)) + frappe.throw( + _("Row {0}: Please select an active BOM for Item {1}.").format(item.idx, item.item_name) + ) if bom.item != item.item_code: - msg = f"Please select an valid BOM for Item {item.item_name}." - frappe.throw(_(msg)) + frappe.throw( + _("Row {0}: Please select an valid BOM for Item {1}.").format(item.idx, item.item_name) + ) else: - msg = f"Please select a BOM for Item {item.item_name}." - frappe.throw(_(msg)) + frappe.throw(_("Row {0}: Please select a BOM for Item {1}.").format(item.idx, item.item_name)) def __get_data_before_save(self): item_dict = {} @@ -408,7 +409,14 @@ def __set_batch_nos(self, bom_item, item_row, rm_obj, qty): if self.available_materials.get(key) and self.available_materials[key]["batch_no"]: new_rm_obj = None for batch_no, batch_qty in self.available_materials[key]["batch_no"].items(): - if batch_qty >= qty: + if batch_qty >= qty or ( + rm_obj.consumed_qty == 0 + and self.backflush_based_on == "BOM" + and len(self.available_materials[key]["batch_no"]) == 1 + ): + if rm_obj.consumed_qty == 0: + self.__set_consumed_qty(rm_obj, qty) + self.__set_batch_no_as_per_qty(item_row, rm_obj, batch_no, qty) self.available_materials[key]["batch_no"][batch_no] -= qty return @@ -447,7 +455,7 @@ def __add_supplied_item(self, item_row, bom_item, qty): "allow_zero_valuation": 1, } ) - rm_obj.rate = get_incoming_rate(args) + rm_obj.rate = bom_item.rate if self.backflush_based_on == "BOM" else get_incoming_rate(args) if self.doctype == self.subcontract_data.order_doctype: rm_obj.required_qty = qty diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index c6a634ba806f..1edd7bf85e1f 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -6,6 +6,7 @@ import frappe from frappe import _, scrub +from frappe.model.document import Document from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction import erpnext @@ -20,14 +21,22 @@ class calculate_taxes_and_totals(object): - def __init__(self, doc): + def __init__(self, doc: Document): self.doc = doc frappe.flags.round_off_applicable_accounts = [] + + self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items") + get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts) self.calculate() + def filter_rows(self): + """Exclude rows, that do not fulfill the filter criteria, from totals computation.""" + items = list(filter(lambda item: not item.get("is_alternative"), self.doc.get("items"))) + return items + def calculate(self): - if not len(self.doc.get("items")): + if not len(self._items): return self.discount_amount_applied = False @@ -69,7 +78,7 @@ def calculate_tax_withholding_net_total(self): if hasattr(self.doc, "tax_withholding_net_total"): sum_net_amount = 0 sum_base_net_amount = 0 - for item in self.doc.get("items"): + for item in self._items: if hasattr(item, "apply_tds") and item.apply_tds: sum_net_amount += item.net_amount sum_base_net_amount += item.base_net_amount @@ -78,7 +87,7 @@ def calculate_tax_withholding_net_total(self): self.doc.base_tax_withholding_net_total = sum_base_net_amount def validate_item_tax_template(self): - for item in self.doc.get("items"): + for item in self._items: if item.item_code and item.get("item_tax_template"): item_doc = frappe.get_cached_doc("Item", item.item_code) args = { @@ -136,7 +145,7 @@ def calculate_item_values(self): return if not self.discount_amount_applied: - for item in self.doc.get("items"): + for item in self._items: self.doc.round_floats_in(item) if item.discount_percentage == 100: @@ -235,7 +244,7 @@ def determine_exclusive_rate(self): if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")): return - for item in self.doc.get("items"): + for item in self._items: item_tax_map = self._load_item_tax_rate(item.item_tax_rate) cumulated_tax_fraction = 0 total_inclusive_tax_amount_per_qty = 0 @@ -316,7 +325,7 @@ def calculate_net_total(self): self.doc.total ) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0 - for item in self.doc.get("items"): + for item in self._items: self.doc.total += item.amount self.doc.total_qty += item.qty self.doc.base_total += item.base_amount @@ -353,7 +362,7 @@ def calculate_taxes(self): ] ) - for n, item in enumerate(self.doc.get("items")): + for n, item in enumerate(self._items): item_tax_map = self._load_item_tax_rate(item.item_tax_rate) for i, tax in enumerate(self.doc.get("taxes")): # tax_amount represents the amount of tax for the current step @@ -362,7 +371,7 @@ def calculate_taxes(self): # Adjust divisional loss to the last item if tax.charge_type == "Actual": actual_tax_dict[tax.idx] -= current_tax_amount - if n == len(self.doc.get("items")) - 1: + if n == len(self._items) - 1: current_tax_amount += actual_tax_dict[tax.idx] # accumulate tax amount into tax.tax_amount @@ -390,7 +399,7 @@ def calculate_taxes(self): ) # set precision in the last item iteration - if n == len(self.doc.get("items")) - 1: + if n == len(self._items) - 1: self.round_off_totals(tax) self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"]) @@ -569,7 +578,7 @@ def calculate_totals(self): def calculate_total_net_weight(self): if self.doc.meta.get_field("total_net_weight"): self.doc.total_net_weight = 0.0 - for d in self.doc.items: + for d in self._items: if d.total_weight: self.doc.total_net_weight += d.total_weight @@ -629,7 +638,7 @@ def apply_discount_amount(self): if total_for_discount_amount: # calculate item amount after Discount Amount - for i, item in enumerate(self.doc.get("items")): + for i, item in enumerate(self._items): distributed_amount = ( flt(self.doc.discount_amount) * item.net_amount / total_for_discount_amount ) @@ -642,7 +651,7 @@ def apply_discount_amount(self): self.doc.apply_discount_on == "Net Total" or not taxes or total_for_discount_amount == self.doc.net_total - ) and i == len(self.doc.get("items")) - 1: + ) and i == len(self._items) - 1: discount_amount_loss = flt( self.doc.net_total - net_total - self.doc.discount_amount, self.doc.precision("net_total") ) @@ -677,7 +686,7 @@ def get_total_for_discount_amount(self): ) def calculate_total_advance(self): - if self.doc.docstatus < 2: + if not self.doc.docstatus.is_cancelled(): total_allocated_amount = sum( flt(adv.allocated_amount, adv.precision("allocated_amount")) for adv in self.doc.get("advances") @@ -708,7 +717,7 @@ def calculate_total_advance(self): ) ) - if self.doc.docstatus == 0: + if self.doc.docstatus.is_draft(): if self.doc.get("write_off_outstanding_amount_automatically"): self.doc.write_off_amount = 0 diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index 467323035ea5..7c3c38706dc8 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -76,12 +76,9 @@ def get_transaction_list( ignore_permissions = False if not filters: - filters = [] + filters = {} - if doctype in ["Supplier Quotation", "Purchase Invoice"]: - filters.append((doctype, "docstatus", "<", 2)) - else: - filters.append((doctype, "docstatus", "=", 1)) + filters["docstatus"] = ["<", "2"] if doctype in ["Supplier Quotation", "Purchase Invoice"] else 1 if (user != "Guest" and is_website_user()) or doctype == "Request for Quotation": parties_doctype = ( @@ -92,12 +89,12 @@ def get_transaction_list( if customers: if doctype == "Quotation": - filters.append(("quotation_to", "=", "Customer")) - filters.append(("party_name", "in", customers)) + filters["quotation_to"] = "Customer" + filters["party_name"] = ["in", customers] else: - filters.append(("customer", "in", customers)) + filters["customer"] = ["in", customers] elif suppliers: - filters.append(("supplier", "in", suppliers)) + filters["supplier"] = ["in", suppliers] elif not custom: return [] @@ -110,7 +107,7 @@ def get_transaction_list( if not customers and not suppliers and custom: ignore_permissions = False - filters = [] + filters = {} transactions = get_list_for_transactions( doctype, diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 0d12499771e1..6443928bf66a 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -285,6 +285,7 @@ def set_missing_values(source, target): "contact_no": "phone_1", "fax": "fax_1", }, + "field_no_map": ["disabled"], } }, target_doc, @@ -393,7 +394,7 @@ def get_lead_details(lead, posting_date=None, company=None): { "territory": lead.territory, "customer_name": lead.company_name or lead.lead_name, - "contact_display": " ".join(filter(None, [lead.salutation, lead.lead_name])), + "contact_display": " ".join(filter(None, [lead.lead_name])), "contact_email": lead.email_id, "contact_mobile": lead.mobile_no, "contact_phone": lead.phone, diff --git a/erpnext/crm/doctype/lead_source/lead_source.json b/erpnext/crm/doctype/lead_source/lead_source.json index 723c6d993d7f..c3cedcc7a634 100644 --- a/erpnext/crm/doctype/lead_source/lead_source.json +++ b/erpnext/crm/doctype/lead_source/lead_source.json @@ -26,10 +26,11 @@ } ], "links": [], - "modified": "2021-02-08 12:51:48.971517", + "modified": "2023-02-10 00:51:44.973957", "modified_by": "Administrator", "module": "CRM", "name": "Lead Source", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -58,5 +59,7 @@ ], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [], + "translated_doctype": 1 } \ No newline at end of file diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js index 1f76a1ae2eb0..b2617955a367 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.js +++ b/erpnext/crm/doctype/opportunity/opportunity.js @@ -19,10 +19,6 @@ frappe.ui.form.on("Opportunity", { } } }); - - if (frm.doc.opportunity_from && frm.doc.party_name){ - frm.trigger('set_contact_link'); - } }, validate: function(frm) { @@ -130,6 +126,10 @@ frappe.ui.form.on("Opportunity", { } else { frappe.contacts.clear_address_and_contact(frm); } + + if (frm.doc.opportunity_from && frm.doc.party_name) { + frm.trigger('set_contact_link'); + } }, set_contact_link: function(frm) { @@ -137,6 +137,8 @@ frappe.ui.form.on("Opportunity", { frappe.dynamic_link = {doc: frm.doc, fieldname: 'party_name', doctype: 'Customer'} } else if(frm.doc.opportunity_from == "Lead" && frm.doc.party_name) { frappe.dynamic_link = {doc: frm.doc, fieldname: 'party_name', doctype: 'Lead'} + } else if (frm.doc.opportunity_from == "Prospect" && frm.doc.party_name) { + frappe.dynamic_link = {doc: frm.doc, fieldname: 'party_name', doctype: 'Prospect'} } }, diff --git a/erpnext/crm/doctype/sales_stage/sales_stage.json b/erpnext/crm/doctype/sales_stage/sales_stage.json index 77aa559b771c..caf8ff5b36b4 100644 --- a/erpnext/crm/doctype/sales_stage/sales_stage.json +++ b/erpnext/crm/doctype/sales_stage/sales_stage.json @@ -18,10 +18,11 @@ } ], "links": [], - "modified": "2020-05-20 12:22:01.866472", + "modified": "2023-02-10 01:40:23.713390", "modified_by": "Administrator", "module": "CRM", "name": "Sales Stage", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -40,5 +41,7 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", - "track_changes": 1 + "states": [], + "track_changes": 1, + "translated_doctype": 1 } \ No newline at end of file diff --git a/erpnext/crm/report/lead_details/lead_details.py b/erpnext/crm/report/lead_details/lead_details.py index 8660c7331038..7b8c43b2d65f 100644 --- a/erpnext/crm/report/lead_details/lead_details.py +++ b/erpnext/crm/report/lead_details/lead_details.py @@ -98,7 +98,7 @@ def get_data(filters): `tabAddress`.name=`tabDynamic Link`.parent) WHERE company = %(company)s - AND `tabLead`.creation BETWEEN %(from_date)s AND %(to_date)s + AND DATE(`tabLead`.creation) BETWEEN %(from_date)s AND %(to_date)s {conditions} ORDER BY `tabLead`.creation asc """.format( diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.py b/erpnext/crm/report/lost_opportunity/lost_opportunity.py index 254511c92fa2..b37cfa449fee 100644 --- a/erpnext/crm/report/lost_opportunity/lost_opportunity.py +++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.py @@ -82,7 +82,7 @@ def get_data(filters): {join} WHERE `tabOpportunity`.status = 'Lost' and `tabOpportunity`.company = %(company)s - AND `tabOpportunity`.modified BETWEEN %(from_date)s AND %(to_date)s + AND DATE(`tabOpportunity`.modified) BETWEEN %(from_date)s AND %(to_date)s {conditions} GROUP BY `tabOpportunity`.name diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js index 69b9cfaa6872..c37fa2f6eae4 100644 --- a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js @@ -48,5 +48,11 @@ frappe.ui.form.on("E Commerce Settings", { frm.set_value('default_customer_group', ''); frm.set_value('quotation_series', ''); } + }, + + enable_checkout: function(frm) { + if (frm.doc.enable_checkout) { + erpnext.utils.check_payments_app(); + } } }); 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 828c655e7937..019a5f9ee4f4 100644 --- a/erpnext/e_commerce/doctype/website_item/test_website_item.py +++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py @@ -174,7 +174,10 @@ def test_impact_on_merging_items(self): # Website Item Portal Tests Begin def test_website_item_breadcrumbs(self): - "Check if breadcrumbs include homepage, product listing navigation page, parent item group(s) and item group." + """ + Check if breadcrumbs include homepage, product listing navigation page, + parent item group(s) and item group + """ from erpnext.setup.doctype.item_group.item_group import get_parent_item_groups item_code = "Test Breadcrumb Item" @@ -196,8 +199,14 @@ def test_website_item_breadcrumbs(self): breadcrumbs = get_parent_item_groups(item.item_group) + settings = frappe.get_cached_doc("E Commerce Settings") + if settings.enable_field_filters: + base_breadcrumb = "Shop by Category" + else: + base_breadcrumb = "All Products" + self.assertEqual(breadcrumbs[0]["name"], "Home") - self.assertEqual(breadcrumbs[1]["name"], "Shop by Category") + self.assertEqual(breadcrumbs[1]["name"], base_breadcrumb) self.assertEqual(breadcrumbs[2]["name"], "_Test Item Group B") # parent item group self.assertEqual(breadcrumbs[3]["name"], "_Test Item Group B - 1") diff --git a/erpnext/e_commerce/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py index df62c23aa486..1a3e73792816 100644 --- a/erpnext/e_commerce/variant_selector/utils.py +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -1,5 +1,5 @@ import frappe -from frappe.utils import cint +from frappe.utils import cint, flt from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( get_shopping_cart_settings, @@ -166,6 +166,27 @@ def get_next_attribute_and_values(item_code, selected_attributes): else: product_info = None + product_id = "" + website_warehouse = "" + if exact_match or filtered_items: + if exact_match and len(exact_match) == 1: + product_id = exact_match[0] + elif filtered_items_count == 1: + product_id = list(filtered_items)[0] + + if product_id: + website_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" + ) + ) + return { "next_attribute": next_attribute, "valid_options_for_attributes": valid_options_for_attributes, @@ -173,6 +194,7 @@ def get_next_attribute_and_values(item_code, selected_attributes): "filtered_items": filtered_items if filtered_items_count < 10 else [], "exact_match": exact_match, "product_info": product_info, + "available_qty": available_qty, } diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js index b649d9d6cc91..241129719b82 100644 --- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.js @@ -2,4 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on('GoCardless Settings', { + refresh: function(frm) { + erpnext.utils.check_payments_app(); + } }); diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json index 9738106a30de..cca36536ac4f 100644 --- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.json @@ -173,7 +173,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2018-02-12 14:18:47.209114", + "modified": "2022-02-12 14:18:47.209114", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "GoCardless Settings", @@ -201,7 +201,6 @@ "write": 1 } ], - "quick_entry": 1, "read_only": 0, "read_only_onload": 0, "show_name_in_global_search": 0, diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py index f9a293fc30e7..4a29a6a21de5 100644 --- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py @@ -10,7 +10,8 @@ from frappe.integrations.utils import create_request_log from frappe.model.document import Document from frappe.utils import call_hook_method, cint, flt, get_url -from payments.utils import create_payment_gateway + +from erpnext.utilities import payment_app_import_guard class GoCardlessSettings(Document): @@ -30,6 +31,9 @@ def initialize_client(self): frappe.throw(e) def on_update(self): + with payment_app_import_guard(): + from payments.utils import create_payment_gateway + create_payment_gateway( "GoCardless-" + self.gateway_name, settings="GoCardLess Settings", controller=self.gateway_name ) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index 7c8ae5c80231..447d720ca244 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -7,6 +7,8 @@ frappe.ui.form.on('Mpesa Settings', { }, refresh: function(frm) { + erpnext.utils.check_payments_app(); + frappe.realtime.on("refresh_mpesa_dashboard", function(){ frm.reload_doc(); frm.events.setup_account_balance_html(frm); diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index b5347838647a..a298e11eaf52 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -9,13 +9,13 @@ from frappe.integrations.utils import create_request_log from frappe.model.document import Document from frappe.utils import call_hook_method, fmt_money, get_request_site_address -from payments.utils import create_payment_gateway from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import ( create_custom_pos_fields, ) from erpnext.erpnext_integrations.utils import create_mode_of_payment +from erpnext.utilities import payment_app_import_guard class MpesaSettings(Document): @@ -30,6 +30,9 @@ def validate_transaction_currency(self, currency): ) def on_update(self): + with payment_app_import_guard(): + from payments.utils import create_payment_gateway + create_custom_pos_fields() create_payment_gateway( "Mpesa-" + self.payment_gateway_name, diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index 9f8689a6e1ae..2cc0f335caa6 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -12,7 +12,7 @@ class PlaidConnector: def __init__(self, access_token=None): self.access_token = access_token self.settings = frappe.get_single("Plaid Settings") - self.products = ["auth", "transactions"] + self.products = ["transactions"] self.client_name = frappe.local.site self.client = plaid.Client( client_id=self.settings.plaid_client_id, diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js index 3740d0498390..3ba6bb998735 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js @@ -47,7 +47,7 @@ erpnext.integrations.plaidLink = class plaidLink { } async init_config() { - this.product = ["auth", "transactions"]; + this.product = ["transactions"]; this.plaid_env = this.frm.doc.plaid_env; this.client_name = frappe.boot.sitename; this.token = await this.get_link_token(); diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 62ea85fc5d21..e57a30a88e18 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -70,7 +70,8 @@ def add_bank_accounts(response, bank, company): except TypeError: pass - bank = json.loads(bank) + if isinstance(bank, str): + bank = json.loads(bank) result = [] default_gl_account = get_default_bank_cash_account(company, "Bank") @@ -177,16 +178,15 @@ def sync_transactions(bank, bank_account): ) result = [] - for transaction in reversed(transactions): - result += new_bank_transaction(transaction) + if transactions: + for transaction in reversed(transactions): + result += new_bank_transaction(transaction) if result: last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date") frappe.logger().info( - "Plaid added {} new Bank Transactions from '{}' between {} and {}".format( - len(result), bank_account, start_date, end_date - ) + f"Plaid added {len(result)} new Bank Transactions from '{bank_account}' between {start_date} and {end_date}" ) frappe.db.set_value( @@ -220,7 +220,7 @@ def get_transactions(bank, bank_account=None, start_date=None, end_date=None): if e.code == "ITEM_LOGIN_REQUIRED": msg = _("There was an error syncing transactions.") + " " msg += _("Please refresh or reset the Plaid linking of the Bank {}.").format(bank) + " " - frappe.log_error(msg, title=_("Plaid Link Refresh Required")) + frappe.log_error(message=msg, title=_("Plaid Link Refresh Required")) return transactions @@ -230,19 +230,20 @@ def new_bank_transaction(transaction): bank_account = frappe.db.get_value("Bank Account", dict(integration_id=transaction["account_id"])) - if float(transaction["amount"]) >= 0: - debit = 0 - credit = float(transaction["amount"]) + amount = float(transaction["amount"]) + if amount >= 0.0: + deposit = 0.0 + withdrawal = amount else: - debit = abs(float(transaction["amount"])) - credit = 0 + deposit = abs(amount) + withdrawal = 0.0 status = "Pending" if transaction["pending"] == "True" else "Settled" tags = [] try: tags += transaction["category"] - tags += ["Plaid Cat. {}".format(transaction["category_id"])] + tags += [f'Plaid Cat. {transaction["category_id"]}'] except KeyError: pass @@ -254,11 +255,18 @@ def new_bank_transaction(transaction): "date": getdate(transaction["date"]), "status": status, "bank_account": bank_account, - "deposit": debit, - "withdrawal": credit, + "deposit": deposit, + "withdrawal": withdrawal, "currency": transaction["iso_currency_code"], "transaction_id": transaction["transaction_id"], - "reference_number": transaction["payment_meta"]["reference_number"], + "transaction_type": ( + transaction["transaction_code"] or transaction["payment_meta"]["payment_method"] + ), + "reference_number": ( + transaction["check_number"] + or transaction["payment_meta"]["reference_number"] + or transaction["name"] + ), "description": transaction["name"], } ) @@ -271,7 +279,7 @@ def new_bank_transaction(transaction): result.append(new_transaction.name) except Exception: - frappe.throw(title=_("Bank transaction creation error")) + frappe.throw(_("Bank transaction creation error")) return result @@ -300,3 +308,26 @@ def enqueue_synchronization(): def get_link_token_for_update(access_token): plaid = PlaidConnector(access_token) return plaid.get_link_token(update_mode=True) + + +def get_company(bank_account_name): + from frappe.defaults import get_user_default + + company_names = frappe.db.get_all("Company", pluck="name") + if len(company_names) == 1: + return company_names[0] + if frappe.db.exists("Bank Account", bank_account_name): + return frappe.db.get_value("Bank Account", bank_account_name, "company") + company_default = get_user_default("Company") + if company_default: + return company_default + frappe.throw(_("Could not detect the Company for updating Bank Accounts")) + + +@frappe.whitelist() +def update_bank_account_ids(response): + data = json.loads(response) + institution_name = data["institution"]["name"] + bank = frappe.get_doc("Bank", institution_name).as_dict() + bank_account_name = f"{data['account']['name']} - {institution_name}" + return add_bank_accounts(response, bank, get_company(bank_account_name)) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py index e8dc3e258f6d..6d34a204cd25 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py @@ -125,6 +125,8 @@ def test_new_transaction(self): "unofficial_currency_code": None, "name": "INTRST PYMNT", "transaction_type": "place", + "transaction_code": "direct debit", + "check_number": "3456789", "amount": -4.22, "location": { "city": None, diff --git a/erpnext/erpnext_integrations/stripe_integration.py b/erpnext/erpnext_integrations/stripe_integration.py index 2d7e8a5d31e7..634e5c2e89f4 100644 --- a/erpnext/erpnext_integrations/stripe_integration.py +++ b/erpnext/erpnext_integrations/stripe_integration.py @@ -2,12 +2,16 @@ # For license information, please see license.txt import frappe -import stripe from frappe import _ from frappe.integrations.utils import create_request_log +from erpnext.utilities import payment_app_import_guard + def create_stripe_subscription(gateway_controller, data): + with payment_app_import_guard(): + import stripe + stripe_settings = frappe.get_doc("Stripe Settings", gateway_controller) stripe_settings.data = frappe._dict(data) @@ -35,6 +39,9 @@ def create_stripe_subscription(gateway_controller, data): def create_subscription_on_stripe(stripe_settings): + with payment_app_import_guard(): + import stripe + items = [] for payment_plan in stripe_settings.payment_plans: plan = frappe.db.get_value("Subscription Plan", payment_plan.plan, "product_price_id") diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 6bc17a3675a8..c4032596f47a 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -10,7 +10,6 @@ app_license = "GNU General Public License (v3)" source_link = "https://github.com/frappe/erpnext" app_logo_url = "/assets/erpnext/images/erpnext-logo.svg" -required_apps = ["payments"] develop_version = "14.x.x-develop" @@ -31,6 +30,10 @@ override_doctype_class = {"Address": "erpnext.accounts.custom.address.ERPNextAddress"} +override_whitelisted_methods = { + "frappe.www.contact.send_message": "erpnext.templates.utils.send_message" +} + welcome_email = "erpnext.setup.utils.welcome_email" # setup wizard @@ -277,7 +280,7 @@ before_tests = "erpnext.setup.utils.before_tests" standard_queries = { - "Customer": "erpnext.selling.doctype.customer.customer.get_customer_list", + "Customer": "erpnext.controllers.queries.customer_query", } doc_events = { @@ -369,7 +372,7 @@ scheduler_events = { "cron": { - "0/5 * * * *": [ + "0/15 * * * *": [ "erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs", ], "0/30 * * * *": [ diff --git a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.js b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.js index a227b6d79733..458c79a1ea8f 100644 --- a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.js +++ b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.js @@ -11,6 +11,40 @@ frappe.query_reports["Loan Interest Report"] = { "options": "Company", "default": frappe.defaults.get_user_default("Company"), "reqd": 1 - } + }, + { + "fieldname":"applicant_type", + "label": __("Applicant Type"), + "fieldtype": "Select", + "options": ["Customer", "Employee"], + "reqd": 1, + "default": "Customer", + on_change: function() { + frappe.query_report.set_filter_value('applicant', ""); + } + }, + { + "fieldname": "applicant", + "label": __("Applicant"), + "fieldtype": "Dynamic Link", + "get_options": function() { + var applicant_type = frappe.query_report.get_filter_value('applicant_type'); + var applicant = frappe.query_report.get_filter_value('applicant'); + if(applicant && !applicant_type) { + frappe.throw(__("Please select Applicant Type first")); + } + return applicant_type; + } + }, + { + "fieldname":"from_date", + "label": __("From Date"), + "fieldtype": "Date", + }, + { + "fieldname":"to_date", + "label": __("From Date"), + "fieldtype": "Date", + }, ] }; diff --git a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py index 9186ce617439..58a7880a4592 100644 --- a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py +++ b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py @@ -13,12 +13,12 @@ def execute(filters=None): - columns = get_columns(filters) + columns = get_columns() data = get_active_loan_details(filters) return columns, data -def get_columns(filters): +def get_columns(): columns = [ {"label": _("Loan"), "fieldname": "loan", "fieldtype": "Link", "options": "Loan", "width": 160}, {"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 160}, @@ -70,6 +70,13 @@ def get_columns(filters): "options": "currency", "width": 120, }, + { + "label": _("Accrued Principal"), + "fieldname": "accrued_principal", + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, { "label": _("Total Repayment"), "fieldname": "total_repayment", @@ -137,11 +144,16 @@ def get_columns(filters): def get_active_loan_details(filters): - - filter_obj = {"status": ("!=", "Closed")} + filter_obj = { + "status": ("!=", "Closed"), + "docstatus": 1, + } if filters.get("company"): filter_obj.update({"company": filters.get("company")}) + if filters.get("applicant"): + filter_obj.update({"applicant": filters.get("applicant")}) + loan_details = frappe.get_all( "Loan", fields=[ @@ -167,8 +179,8 @@ def get_active_loan_details(filters): sanctioned_amount_map = get_sanctioned_amount_map() penal_interest_rate_map = get_penal_interest_rate_map() - payments = get_payments(loan_list) - accrual_map = get_interest_accruals(loan_list) + payments = get_payments(loan_list, filters) + accrual_map = get_interest_accruals(loan_list, filters) currency = erpnext.get_company_currency(filters.get("company")) for loan in loan_details: @@ -183,6 +195,7 @@ def get_active_loan_details(filters): - flt(loan.written_off_amount), "total_repayment": flt(payments.get(loan.loan)), "accrued_interest": flt(accrual_map.get(loan.loan, {}).get("accrued_interest")), + "accrued_principal": flt(accrual_map.get(loan.loan, {}).get("accrued_principal")), "interest_outstanding": flt(accrual_map.get(loan.loan, {}).get("interest_outstanding")), "penalty": flt(accrual_map.get(loan.loan, {}).get("penalty")), "penalty_interest": penal_interest_rate_map.get(loan.loan_type), @@ -212,20 +225,35 @@ def get_sanctioned_amount_map(): ) -def get_payments(loans): +def get_payments(loans, filters): + query_filters = {"against_loan": ("in", loans)} + + if filters.get("from_date"): + query_filters.update({"posting_date": (">=", filters.get("from_date"))}) + + if filters.get("to_date"): + query_filters.update({"posting_date": ("<=", filters.get("to_date"))}) + return frappe._dict( frappe.get_all( "Loan Repayment", fields=["against_loan", "sum(amount_paid)"], - filters={"against_loan": ("in", loans)}, + filters=query_filters, group_by="against_loan", as_list=1, ) ) -def get_interest_accruals(loans): +def get_interest_accruals(loans, filters): accrual_map = {} + query_filters = {"loan": ("in", loans)} + + if filters.get("from_date"): + query_filters.update({"posting_date": (">=", filters.get("from_date"))}) + + if filters.get("to_date"): + query_filters.update({"posting_date": ("<=", filters.get("to_date"))}) interest_accruals = frappe.get_all( "Loan Interest Accrual", @@ -236,8 +264,9 @@ def get_interest_accruals(loans): "penalty_amount", "paid_interest_amount", "accrual_type", + "payable_principal_amount", ], - filters={"loan": ("in", loans)}, + filters=query_filters, order_by="posting_date desc", ) @@ -246,6 +275,7 @@ def get_interest_accruals(loans): entry.loan, { "accrued_interest": 0.0, + "accrued_principal": 0.0, "undue_interest": 0.0, "interest_outstanding": 0.0, "last_accrual_date": "", @@ -270,6 +300,7 @@ def get_interest_accruals(loans): accrual_map[entry.loan]["undue_interest"] += entry.interest_amount - entry.paid_interest_amount accrual_map[entry.loan]["accrued_interest"] += entry.interest_amount + accrual_map[entry.loan]["accrued_principal"] += entry.payable_principal_amount if last_accrual_date and getdate(entry.posting_date) == last_accrual_date: accrual_map[entry.loan]["penalty"] = entry.penalty_amount diff --git a/erpnext/loan_management/workspace/loans/loans.json b/erpnext/loan_management/workspace/loans/loans.json new file mode 100644 index 000000000000..c65be4efae9d --- /dev/null +++ b/erpnext/loan_management/workspace/loans/loans.json @@ -0,0 +1,315 @@ +{ + "charts": [], + "content": "[{\"id\":\"_38WStznya\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"t7o_K__1jB\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Loan Application\",\"col\":3}},{\"id\":\"IRiNDC6w1p\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Loan\",\"col\":3}},{\"id\":\"xbbo0FYbq0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"7ZL4Bro-Vi\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"yhyioTViZ3\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\",\"col\":12}},{\"id\":\"oYFn4b1kSw\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan\",\"col\":4}},{\"id\":\"vZepJF5tl9\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan Processes\",\"col\":4}},{\"id\":\"k-393Mjhqe\",\"type\":\"card\",\"data\":{\"card_name\":\"Disbursement and Repayment\",\"col\":4}},{\"id\":\"6crJ0DBiBJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Loan Security\",\"col\":4}},{\"id\":\"Um5YwxVLRJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]", + "creation": "2020-03-12 16:35:55.299820", + "docstatus": 0, + "doctype": "Workspace", + "for_user": "", + "hide_custom": 0, + "icon": "loan", + "idx": 0, + "is_hidden": 0, + "label": "Loans", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Loan", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Type", + "link_count": 0, + "link_to": "Loan Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Application", + "link_count": 0, + "link_to": "Loan Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan", + "link_count": 0, + "link_to": "Loan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Loan Processes", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Process Loan Security Shortfall", + "link_count": 0, + "link_to": "Process Loan Security Shortfall", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Process Loan Interest Accrual", + "link_count": 0, + "link_to": "Process Loan Interest Accrual", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Disbursement and Repayment", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Disbursement", + "link_count": 0, + "link_to": "Loan Disbursement", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Repayment", + "link_count": 0, + "link_to": "Loan Repayment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Write Off", + "link_count": 0, + "link_to": "Loan Write Off", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Interest Accrual", + "link_count": 0, + "link_to": "Loan Interest Accrual", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Type", + "link_count": 0, + "link_to": "Loan Security Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Price", + "link_count": 0, + "link_to": "Loan Security Price", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security", + "link_count": 0, + "link_to": "Loan Security", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Pledge", + "link_count": 0, + "link_to": "Loan Security Pledge", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Unpledge", + "link_count": 0, + "link_to": "Loan Security Unpledge", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Shortfall", + "link_count": 0, + "link_to": "Loan Security Shortfall", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "link_count": 6, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Loan Repayment and Closure", + "link_count": 0, + "link_to": "Loan Repayment and Closure", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Loan Security Status", + "link_count": 0, + "link_to": "Loan Security Status", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 1, + "label": "Loan Interest Report", + "link_count": 0, + "link_to": "Loan Interest Report", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 1, + "label": "Loan Security Exposure", + "link_count": 0, + "link_to": "Loan Security Exposure", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 1, + "label": "Applicant-Wise Loan Security Exposure", + "link_count": 0, + "link_to": "Applicant-Wise Loan Security Exposure", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 1, + "label": "Loan Security Status", + "link_count": 0, + "link_to": "Loan Security Status", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2023-01-31 19:47:13.114415", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Loans", + "owner": "Administrator", + "parent_page": "", + "public": 1, + "quick_lists": [], + "restrict_to_domain": "", + "roles": [], + "sequence_id": 16.0, + "shortcuts": [ + { + "color": "Green", + "format": "{} Open", + "label": "Loan Application", + "link_to": "Loan Application", + "stats_filter": "{ \"status\": \"Open\" }", + "type": "DocType" + }, + { + "label": "Loan", + "link_to": "Loan", + "type": "DocType" + }, + { + "doc_view": "", + "label": "Dashboard", + "link_to": "Loan Dashboard", + "type": "Dashboard" + } + ], + "title": "Loans" +} \ No newline at end of file diff --git a/erpnext/maintenance/doctype/maintenance_visit_purpose/maintenance_visit_purpose.json b/erpnext/maintenance/doctype/maintenance_visit_purpose/maintenance_visit_purpose.json index 158f143ae866..ba0535555319 100644 --- a/erpnext/maintenance/doctype/maintenance_visit_purpose/maintenance_visit_purpose.json +++ b/erpnext/maintenance/doctype/maintenance_visit_purpose/maintenance_visit_purpose.json @@ -64,8 +64,6 @@ "fieldtype": "Section Break" }, { - "fetch_from": "prevdoc_detail_docname.sales_person", - "fetch_if_empty": 1, "fieldname": "service_person", "fieldtype": "Link", "in_list_view": 1, @@ -110,13 +108,15 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-05-27 17:47:21.474282", + "modified": "2023-02-27 11:09:33.114458", "modified_by": "Administrator", "module": "Maintenance", "name": "Maintenance Visit Purpose", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js index d3bb33e86e0b..7b26a14a57b2 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js @@ -7,6 +7,12 @@ frappe.ui.form.on('Blanket Order', { }, setup: function(frm) { + frm.custom_make_buttons = { + 'Purchase Order': 'Purchase Order', + 'Sales Order': 'Sales Order', + 'Quotation': 'Quotation', + }; + frm.add_fetch("customer", "customer_name", "customer_name"); frm.add_fetch("supplier", "supplier_name", "supplier_name"); }, diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py index ff2140199de9..32f1c365adef 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py @@ -6,6 +6,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc +from frappe.query_builder.functions import Sum from frappe.utils import flt, getdate from erpnext.stock.doctype.item.item import get_item_defaults @@ -29,21 +30,23 @@ def validate_duplicate_items(self): def update_ordered_qty(self): ref_doctype = "Sales Order" if self.blanket_order_type == "Selling" else "Purchase Order" + + trans = frappe.qb.DocType(ref_doctype) + trans_item = frappe.qb.DocType(f"{ref_doctype} Item") + item_ordered_qty = frappe._dict( - frappe.db.sql( - """ - select trans_item.item_code, sum(trans_item.stock_qty) as qty - from `tab{0} Item` trans_item, `tab{0}` trans - where trans.name = trans_item.parent - and trans_item.blanket_order=%s - and trans.docstatus=1 - and trans.status not in ('Closed', 'Stopped') - group by trans_item.item_code - """.format( - ref_doctype - ), - self.name, - ) + ( + frappe.qb.from_(trans_item) + .from_(trans) + .select(trans_item.item_code, Sum(trans_item.stock_qty).as_("qty")) + .where( + (trans.name == trans_item.parent) + & (trans_item.blanket_order == self.name) + & (trans.docstatus == 1) + & (trans.status.notin(["Stopped", "Closed"])) + ) + .groupby(trans_item.item_code) + ).run() ) for d in self.items: @@ -79,7 +82,43 @@ def update_item(source, target, source_parent): "doctype": doctype + " Item", "field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"}, "postprocess": update_item, + "condition": lambda item: (flt(item.qty) - flt(item.ordered_qty)) > 0, }, }, ) return target_doc + + +def validate_against_blanket_order(order_doc): + if order_doc.doctype in ("Sales Order", "Purchase Order"): + order_data = {} + + for item in order_doc.get("items"): + if item.against_blanket_order and item.blanket_order: + if item.blanket_order in order_data: + if item.item_code in order_data[item.blanket_order]: + order_data[item.blanket_order][item.item_code] += item.qty + else: + order_data[item.blanket_order][item.item_code] = item.qty + else: + order_data[item.blanket_order] = {item.item_code: item.qty} + + if order_data: + allowance = flt( + frappe.db.get_single_value( + "Selling Settings" if order_doc.doctype == "Sales Order" else "Buying Settings", + "over_order_allowance", + ) + ) + for bo_name, item_data in order_data.items(): + bo_doc = frappe.get_doc("Blanket Order", bo_name) + for item in bo_doc.get("items"): + if item.item_code in item_data: + remaining_qty = item.qty - item.ordered_qty + allowed_qty = remaining_qty + (remaining_qty * (allowance / 100)) + if allowed_qty < item_data[item.item_code]: + frappe.throw( + _("Item {0} cannot be ordered more than {1} against Blanket Order {2}.").format( + item.item_code, allowed_qty, bo_name + ) + ) diff --git a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py index 2f1f3ae0f52e..58f3c9505989 100644 --- a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py @@ -63,6 +63,33 @@ def test_purchase_order_creation(self): po1.currency = get_company_currency(po1.company) self.assertEqual(po1.items[0].qty, (bo.items[0].qty - bo.items[0].ordered_qty)) + def test_over_order_allowance(self): + # Sales Order + bo = make_blanket_order(blanket_order_type="Selling", quantity=100) + + frappe.flags.args.doctype = "Sales Order" + so = make_order(bo.name) + so.currency = get_company_currency(so.company) + so.delivery_date = today() + so.items[0].qty = 110 + self.assertRaises(frappe.ValidationError, so.submit) + + frappe.db.set_single_value("Selling Settings", "over_order_allowance", 10) + so.submit() + + # Purchase Order + bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100) + + frappe.flags.args.doctype = "Purchase Order" + po = make_order(bo.name) + po.currency = get_company_currency(po.company) + po.schedule_date = today() + po.items[0].qty = 110 + self.assertRaises(frappe.ValidationError, po.submit) + + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 10) + po.submit() + def make_blanket_order(**args): args = frappe._dict(args) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index ecad41fe7b88..4304193afaed 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -4,7 +4,7 @@ frappe.provide("erpnext.bom"); frappe.ui.form.on("BOM", { - setup: function(frm) { + setup(frm) { frm.custom_make_buttons = { 'Work Order': 'Work Order', 'Quality Inspection': 'Quality Inspection' @@ -65,11 +65,25 @@ frappe.ui.form.on("BOM", { }); }, + validate: function(frm) { + if (frm.doc.fg_based_operating_cost && frm.doc.with_operations) { + frappe.throw({message: __("Please check either with operations or FG Based Operating Cost."), title: __("Mandatory")}); + } + }, + + with_operations: function(frm) { + frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0); + }, + + fg_based_operating_cost: function(frm) { + frm.set_df_property("with_operations", "hidden", frm.doc.fg_based_operating_cost ? 1 : 0); + }, + onload_post_render: function(frm) { frm.get_field("items").grid.set_multiple_add("item_code", "qty"); }, - refresh: function(frm) { + refresh(frm) { frm.toggle_enable("item", frm.doc.__islocal); frm.set_indicator_formatter('item_code', @@ -152,7 +166,7 @@ frappe.ui.form.on("BOM", { } }, - make_work_order: function(frm) { + make_work_order(frm) { frm.events.setup_variant_prompt(frm, "Work Order", (frm, item, data, variant_items) => { frappe.call({ method: "erpnext.manufacturing.doctype.work_order.work_order.make_work_order", @@ -164,7 +178,7 @@ frappe.ui.form.on("BOM", { variant_items: variant_items }, freeze: true, - callback: function(r) { + callback(r) { if(r.message) { let doc = frappe.model.sync(r.message)[0]; frappe.set_route("Form", doc.doctype, doc.name); @@ -174,7 +188,7 @@ frappe.ui.form.on("BOM", { }); }, - make_variant_bom: function(frm) { + make_variant_bom(frm) { frm.events.setup_variant_prompt(frm, "Variant BOM", (frm, item, data, variant_items) => { frappe.call({ method: "erpnext.manufacturing.doctype.bom.bom.make_variant_bom", @@ -185,7 +199,7 @@ frappe.ui.form.on("BOM", { variant_items: variant_items }, freeze: true, - callback: function(r) { + callback(r) { if(r.message) { let doc = frappe.model.sync(r.message)[0]; frappe.set_route("Form", doc.doctype, doc.name); @@ -195,7 +209,7 @@ frappe.ui.form.on("BOM", { }, true); }, - setup_variant_prompt: function(frm, title, callback, skip_qty_field) { + setup_variant_prompt(frm, title, callback, skip_qty_field) { const fields = []; if (frm.doc.has_variants) { @@ -205,7 +219,7 @@ frappe.ui.form.on("BOM", { fieldname: 'item', options: "Item", reqd: 1, - get_query: function() { + get_query() { return { query: "erpnext.controllers.queries.item_query", filters: { @@ -273,7 +287,7 @@ frappe.ui.form.on("BOM", { fieldtype: "Link", in_list_view: 1, reqd: 1, - get_query: function(data) { + get_query(data) { if (!data.item_code) { frappe.throw(__("Select template item")); } @@ -308,7 +322,7 @@ frappe.ui.form.on("BOM", { ], in_place_edit: true, data: [], - get_data: function () { + get_data () { return []; }, }); @@ -343,14 +357,14 @@ frappe.ui.form.on("BOM", { } }, - make_quality_inspection: function(frm) { + make_quality_inspection(frm) { frappe.model.open_mapped_doc({ method: "erpnext.stock.doctype.quality_inspection.quality_inspection.make_quality_inspection", frm: frm }) }, - update_cost: function(frm, save_doc=false) { + update_cost(frm, save_doc=false) { return frappe.call({ doc: frm.doc, method: "update_cost", @@ -360,26 +374,26 @@ frappe.ui.form.on("BOM", { save: save_doc, from_child_bom: false }, - callback: function(r) { + callback(r) { refresh_field("items"); if(!r.exc) frm.refresh_fields(); } }); }, - rm_cost_as_per: function(frm) { + rm_cost_as_per(frm) { if (in_list(["Valuation Rate", "Last Purchase Rate"], frm.doc.rm_cost_as_per)) { frm.set_value("plc_conversion_rate", 1.0); } }, - routing: function(frm) { + routing(frm) { if (frm.doc.routing) { frappe.call({ doc: frm.doc, method: "get_routing", freeze: true, - callback: function(r) { + callback(r) { if (!r.exc) { frm.refresh_fields(); erpnext.bom.calculate_op_cost(frm.doc); @@ -388,6 +402,16 @@ frappe.ui.form.on("BOM", { } }); } + }, + + process_loss_percentage(frm) { + let qty = 0.0 + if (frm.doc.process_loss_percentage) { + qty = (frm.doc.quantity * frm.doc.process_loss_percentage) / 100; + } + + frm.set_value("process_loss_qty", qty); + frm.set_value("add_process_loss_cost_in_fg", qty ? 1: 0); } }); @@ -479,10 +503,6 @@ var get_bom_material_detail = function(doc, cdt, cdn, scrap_items) { }, callback: function(r) { d = locals[cdt][cdn]; - if (d.is_process_loss) { - r.message.rate = 0; - r.message.base_rate = 0; - } $.extend(d, r.message); refresh_field("items"); @@ -526,18 +546,25 @@ erpnext.bom.update_cost = function(doc) { }; erpnext.bom.calculate_op_cost = function(doc) { - var op = doc.operations || []; doc.operating_cost = 0.0; doc.base_operating_cost = 0.0; - for(var i=0;i { + let operating_cost = flt(flt(item.hour_rate) * flt(item.time_in_mins) / 60, 2); + let base_operating_cost = flt(operating_cost * doc.conversion_rate, 2); + frappe.model.set_value('BOM Operation',item.name, { + "operating_cost": operating_cost, + "base_operating_cost": base_operating_cost + }); - doc.operating_cost += operating_cost; - doc.base_operating_cost += base_operating_cost; + doc.operating_cost += operating_cost; + doc.base_operating_cost += base_operating_cost; + }); + } else if(doc.fg_based_operating_cost) { + let total_operating_cost = doc.quantity * flt(doc.operating_cost_per_bom_quantity); + doc.operating_cost = total_operating_cost; + doc.base_operating_cost = flt(total_operating_cost * doc.conversion_rate, 2); } refresh_field(['operating_cost', 'base_operating_cost']); }; @@ -717,10 +744,6 @@ frappe.tour['BOM'] = [ frappe.ui.form.on("BOM Scrap Item", { item_code(frm, cdt, cdn) { const { item_code } = locals[cdt][cdn]; - if (item_code === frm.doc.item) { - locals[cdt][cdn].is_process_loss = 1; - trigger_process_loss_qty_prompt(frm, cdt, cdn, item_code); - } }, }); diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 0b4419694002..db699b94d8fa 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -6,6 +6,7 @@ "document_type": "Setup", "engine": "InnoDB", "field_order": [ + "production_item_tab", "item", "company", "item_name", @@ -19,28 +20,33 @@ "quantity", "image", "currency_detail", - "currency", - "conversion_rate", - "column_break_12", "rm_cost_as_per", "buying_price_list", "price_list_currency", "plc_conversion_rate", + "column_break_ivyw", + "currency", + "conversion_rate", "section_break_21", + "operations_section_section", "with_operations", "column_break_23", "transfer_material_against", "routing", + "fg_based_operating_cost", + "fg_based_section_section", + "operating_cost_per_bom_quantity", "operations_section", "operations", "materials_section", - "inspection_required", - "quality_inspection_template", - "column_break_31", - "section_break_33", "items", "scrap_section", + "scrap_items_section", "scrap_items", + "process_loss_section", + "process_loss_percentage", + "column_break_ssj2", + "process_loss_qty", "costing", "operating_cost", "raw_material_cost", @@ -52,10 +58,14 @@ "column_break_26", "total_cost", "base_total_cost", - "section_break_25", + "more_info_tab", "description", "column_break_27", "has_variants", + "quality_inspection_section_break", + "inspection_required", + "column_break_dxp7", + "quality_inspection_template", "section_break0", "exploded_items", "website_section", @@ -68,7 +78,8 @@ "show_items", "show_operations", "web_long_description", - "amended_from" + "amended_from", + "connections_tab" ], "fields": [ { @@ -183,7 +194,7 @@ { "fieldname": "currency_detail", "fieldtype": "Section Break", - "label": "Currency and Price List" + "label": "Cost Configuration" }, { "fieldname": "company", @@ -208,10 +219,6 @@ "precision": "9", "reqd": 1 }, - { - "fieldname": "column_break_12", - "fieldtype": "Column Break" - }, { "fieldname": "currency", "fieldtype": "Link", @@ -261,7 +268,7 @@ { "fieldname": "materials_section", "fieldtype": "Section Break", - "label": "Materials", + "label": "Raw Materials", "oldfieldtype": "Section Break" }, { @@ -276,8 +283,8 @@ { "collapsible": 1, "fieldname": "scrap_section", - "fieldtype": "Section Break", - "label": "Scrap" + "fieldtype": "Tab Break", + "label": "Scrap & Process Loss" }, { "fieldname": "scrap_items", @@ -287,7 +294,7 @@ }, { "fieldname": "costing", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Costing", "oldfieldtype": "Section Break" }, @@ -379,10 +386,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "section_break_25", - "fieldtype": "Section Break" - }, { "fetch_from": "item.description", "fieldname": "description", @@ -478,8 +481,8 @@ }, { "fieldname": "section_break_21", - "fieldtype": "Section Break", - "label": "Operations" + "fieldtype": "Tab Break", + "label": "Operations & Materials" }, { "fieldname": "column_break_23", @@ -511,6 +514,7 @@ "fetch_from": "item.has_variants", "fieldname": "has_variants", "fieldtype": "Check", + "hidden": 1, "in_list_view": 1, "label": "Has Variants", "no_copy": 1, @@ -518,13 +522,82 @@ "read_only": 1 }, { - "fieldname": "column_break_31", + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 + }, + { + "fieldname": "operations_section_section", + "fieldtype": "Section Break", + "label": "Operations" + }, + { + "fieldname": "process_loss_section", + "fieldtype": "Section Break", + "label": "Process Loss" + }, + { + "fieldname": "process_loss_percentage", + "fieldtype": "Percent", + "label": "% Process Loss" + }, + { + "fieldname": "process_loss_qty", + "fieldtype": "Float", + "label": "Process Loss Qty", + "read_only": 1 + }, + { + "fieldname": "column_break_ssj2", + "fieldtype": "Column Break" + }, + { + "fieldname": "more_info_tab", + "fieldtype": "Tab Break", + "label": "More Info" + }, + { + "fieldname": "column_break_dxp7", + "fieldtype": "Column Break" + }, + { + "fieldname": "quality_inspection_section_break", + "fieldtype": "Section Break", + "label": "Quality Inspection" + }, + { + "fieldname": "production_item_tab", + "fieldtype": "Tab Break", + "label": "Production Item" + }, + { + "fieldname": "column_break_ivyw", "fieldtype": "Column Break" }, { - "fieldname": "section_break_33", + "fieldname": "scrap_items_section", + "fieldtype": "Section Break", + "hide_border": 1, + "label": "Scrap Items" + }, + { + "default": "0", + "fieldname": "fg_based_operating_cost", + "fieldtype": "Check", + "label": "FG based Operating Cost" + }, + { + "depends_on": "fg_based_operating_cost", + "fieldname": "fg_based_section_section", "fieldtype": "Section Break", - "hide_border": 1 + "label": "FG Based Operating Cost Section" + }, + { + "depends_on": "fg_based_operating_cost", + "fieldname": "operating_cost_per_bom_quantity", + "fieldtype": "Currency", + "label": "Operating Cost Per BOM Quantity" } ], "icon": "fa fa-sitemap", @@ -532,7 +605,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2022-01-30 21:27:54.727298", + "modified": "2023-02-13 17:31:37.504565", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index ca4f63df7721..a085af859a4b 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -31,7 +31,7 @@ class BOMTree: # specifying the attributes to save resources # ref: https://docs.python.org/3/reference/datamodel.html#slots - __slots__ = ["name", "child_items", "is_bom", "item_code", "exploded_qty", "qty"] + __slots__ = ["name", "child_items", "is_bom", "item_code", "qty", "exploded_qty", "bom_qty"] def __init__( self, name: str, is_bom: bool = True, exploded_qty: float = 1.0, qty: float = 1 @@ -50,9 +50,10 @@ def __init__( def __create_tree(self): bom = frappe.get_cached_doc("BOM", self.name) self.item_code = bom.item + self.bom_qty = bom.quantity for item in bom.get("items", []): - qty = item.qty / bom.quantity # quantity per unit + qty = item.stock_qty / bom.quantity # quantity per unit exploded_qty = self.exploded_qty * qty if item.bom_no: child = BOMTree(item.bom_no, exploded_qty=exploded_qty, qty=qty) @@ -193,6 +194,7 @@ def validate(self): self.update_exploded_items(save=False) self.update_stock_qty() self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate=False, save=False) + self.set_process_loss_qty() self.validate_scrap_items() def get_context(self, context): @@ -233,6 +235,7 @@ def get_routing(self): "sequence_id", "operation", "workstation", + "workstation_type", "description", "time_in_mins", "batch_size", @@ -612,18 +615,26 @@ def calculate_op_cost(self, update_hour_rate=False): """Update workstation rate and calculates totals""" self.operating_cost = 0 self.base_operating_cost = 0 - for d in self.get("operations"): - if d.workstation: - self.update_rate_and_time(d, update_hour_rate) - - operating_cost = d.operating_cost - base_operating_cost = d.base_operating_cost - if d.set_cost_based_on_bom_qty: - operating_cost = flt(d.cost_per_unit) * flt(self.quantity) - base_operating_cost = flt(d.base_cost_per_unit) * flt(self.quantity) - - self.operating_cost += flt(operating_cost) - self.base_operating_cost += flt(base_operating_cost) + if self.get("with_operations"): + for d in self.get("operations"): + if d.workstation: + self.update_rate_and_time(d, update_hour_rate) + + operating_cost = d.operating_cost + base_operating_cost = d.base_operating_cost + if d.set_cost_based_on_bom_qty: + operating_cost = flt(d.cost_per_unit) * flt(self.quantity) + base_operating_cost = flt(d.base_cost_per_unit) * flt(self.quantity) + + self.operating_cost += flt(operating_cost) + self.base_operating_cost += flt(base_operating_cost) + + elif self.get("fg_based_operating_cost"): + total_operating_cost = flt(self.get("quantity")) * flt( + self.get("operating_cost_per_bom_quantity") + ) + self.operating_cost = total_operating_cost + self.base_operating_cost = flt(total_operating_cost * self.conversion_rate, 2) def update_rate_and_time(self, row, update_hour_rate=False): if not row.hour_rate or update_hour_rate: @@ -876,36 +887,19 @@ def get_tree_representation(self) -> BOMTree: """Get a complete tree representation preserving order of child items.""" return BOMTree(self.name) + def set_process_loss_qty(self): + if self.process_loss_percentage: + self.process_loss_qty = flt(self.quantity) * flt(self.process_loss_percentage) / 100 + def validate_scrap_items(self): - for item in self.scrap_items: - msg = "" - if item.item_code == self.item and not item.is_process_loss: - msg = _( - "Scrap/Loss Item: {0} should have Is Process Loss checked as it is the same as the item to be manufactured or repacked." - ).format(frappe.bold(item.item_code)) - elif item.item_code != self.item and item.is_process_loss: - msg = _( - "Scrap/Loss Item: {0} should not have Is Process Loss checked as it is different from the item to be manufactured or repacked" - ).format(frappe.bold(item.item_code)) - - must_be_whole_number = frappe.get_value("UOM", item.stock_uom, "must_be_whole_number") - if item.is_process_loss and must_be_whole_number: - msg = _( - "Item: {0} with Stock UOM: {1} cannot be a Scrap/Loss Item as {1} is a whole UOM." - ).format(frappe.bold(item.item_code), frappe.bold(item.stock_uom)) - - if item.is_process_loss and (item.stock_qty >= self.quantity): - msg = _("Scrap/Loss Item: {0} should have Qty less than finished goods Quantity.").format( - frappe.bold(item.item_code) - ) + must_be_whole_number = frappe.get_value("UOM", self.uom, "must_be_whole_number") - if item.is_process_loss and (item.rate > 0): - msg = _( - "Scrap/Loss Item: {0} should have Rate set to 0 because Is Process Loss is checked." - ).format(frappe.bold(item.item_code)) + if self.process_loss_percentage and self.process_loss_percentage > 100: + frappe.throw(_("Process Loss Percentage cannot be greater than 100")) - if msg: - frappe.throw(msg, title=_("Note")) + if self.process_loss_qty and must_be_whole_number and self.process_loss_qty % 1 != 0: + msg = f"Item: {frappe.bold(self.item)} with Stock UOM: {frappe.bold(self.uom)} can't have fractional process loss qty as UOM {frappe.bold(self.uom)} is a whole Number." + frappe.throw(msg, title=_("Invalid Process Loss Configuration")) def get_bom_item_rate(args, bom_doc): @@ -949,7 +943,8 @@ def get_valuation_rate(data): 2) If no value, get last valuation rate from SLE 3) If no value, get valuation rate from Item """ - from frappe.query_builder.functions import Sum + from frappe.query_builder.functions import Count, IfNull, Sum + from pypika import Case item_code, company = data.get("item_code"), data.get("company") valuation_rate = 0.0 @@ -960,7 +955,14 @@ def get_valuation_rate(data): frappe.qb.from_(bin_table) .join(wh_table) .on(bin_table.warehouse == wh_table.name) - .select((Sum(bin_table.stock_value) / Sum(bin_table.actual_qty)).as_("valuation_rate")) + .select( + Case() + .when( + Count(bin_table.name) > 0, IfNull(Sum(bin_table.stock_value) / Sum(bin_table.actual_qty), 0.0) + ) + .else_(None) + .as_("valuation_rate") + ) .where((bin_table.item_code == item_code) & (wh_table.company == company)) ).run(as_dict=True)[0] @@ -1053,7 +1055,7 @@ def get_bom_items_as_dict( query = query.format( table="BOM Scrap Item", where_conditions="", - select_columns=", item.description, is_process_loss", + select_columns=", item.description", is_stock_item=is_stock_item, qty_field="stock_qty", ) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index e34ac12cd236..01bf2e4315f9 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -6,7 +6,7 @@ from functools import partial import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, timeout from frappe.utils import cstr, flt from erpnext.controllers.tests.test_subcontracting_controller import ( @@ -27,6 +27,7 @@ class TestBOM(FrappeTestCase): + @timeout def test_get_items(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict @@ -37,6 +38,7 @@ def test_get_items(self): self.assertTrue(test_records[2]["items"][1]["item_code"] in items_dict) self.assertEqual(len(items_dict.values()), 2) + @timeout def test_get_items_exploded(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict @@ -49,11 +51,13 @@ def test_get_items_exploded(self): self.assertTrue(test_records[0]["items"][1]["item_code"] in items_dict) self.assertEqual(len(items_dict.values()), 3) + @timeout def test_get_items_list(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items self.assertEqual(len(get_bom_items(bom=get_default_bom(), company="_Test Company")), 3) + @timeout def test_default_bom(self): def _get_default_bom_in_item(): return cstr(frappe.db.get_value("Item", "_Test FG Item 2", "default_bom")) @@ -71,6 +75,7 @@ def _get_default_bom_in_item(): self.assertTrue(_get_default_bom_in_item(), bom.name) + @timeout def test_update_bom_cost_in_all_boms(self): # get current rate for '_Test Item 2' bom_rates = frappe.db.get_values( @@ -99,6 +104,7 @@ def test_update_bom_cost_in_all_boms(self): ): self.assertEqual(d.base_rate, rm_base_rate + 10) + @timeout def test_bom_cost(self): bom = frappe.copy_doc(test_records[2]) bom.insert() @@ -127,6 +133,7 @@ def test_bom_cost(self): self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost) self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) + @timeout def test_bom_cost_with_batch_size(self): bom = frappe.copy_doc(test_records[2]) bom.docstatus = 0 @@ -145,6 +152,7 @@ def test_bom_cost_with_batch_size(self): self.assertAlmostEqual(bom.operating_cost, op_cost / 2) bom.delete() + @timeout def test_bom_cost_multi_uom_multi_currency_based_on_price_list(self): frappe.db.set_value("Price List", "_Test Price List", "price_not_uom_dependent", 1) for item_code, rate in (("_Test Item", 3600), ("_Test Item Home Desktop Manufactured", 3000)): @@ -181,6 +189,7 @@ def test_bom_cost_multi_uom_multi_currency_based_on_price_list(self): self.assertEqual(bom.base_raw_material_cost, 27000) self.assertEqual(bom.base_total_cost, 33000) + @timeout def test_bom_cost_multi_uom_based_on_valuation_rate(self): bom = frappe.copy_doc(test_records[2]) bom.set_rate_of_sub_assembly_item_based_on_bom = 0 @@ -202,6 +211,35 @@ def test_bom_cost_multi_uom_based_on_valuation_rate(self): self.assertEqual(bom.items[0].rate, 20) + @timeout + def test_bom_cost_with_fg_based_operating_cost(self): + bom = frappe.copy_doc(test_records[4]) + bom.insert() + + raw_material_cost = 0.0 + op_cost = 0.0 + + op_cost = bom.quantity * bom.operating_cost_per_bom_quantity + + for row in bom.items: + raw_material_cost += row.amount + + base_raw_material_cost = raw_material_cost * flt( + bom.conversion_rate, bom.precision("conversion_rate") + ) + base_op_cost = op_cost * flt(bom.conversion_rate, bom.precision("conversion_rate")) + + # test amounts in selected currency, almostEqual checks for 7 digits by default + self.assertAlmostEqual(bom.operating_cost, op_cost) + self.assertAlmostEqual(bom.raw_material_cost, raw_material_cost) + self.assertAlmostEqual(bom.total_cost, raw_material_cost + op_cost) + + # test amounts in selected currency + self.assertAlmostEqual(bom.base_operating_cost, base_op_cost) + self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost) + self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) + + @timeout def test_subcontractor_sourced_item(self): item_code = "_Test Subcontracted FG Item 1" set_backflush_based_on("Material Transferred for Subcontract") @@ -283,6 +321,7 @@ def test_subcontractor_sourced_item(self): supplied_items = sorted([d.rm_item_code for d in sco.supplied_items]) self.assertEqual(bom_items, supplied_items) + @timeout def test_bom_tree_representation(self): bom_tree = { "Assembly": { @@ -308,6 +347,7 @@ def test_bom_tree_representation(self): for reqd_item, created_item in zip(reqd_order, created_order): self.assertEqual(reqd_item, created_item.item_code) + @timeout def test_generated_variant_bom(self): from erpnext.controllers.item_variant import create_variant @@ -348,6 +388,7 @@ def test_generated_variant_bom(self): self.assertEqual(reqd_item.qty, created_item.qty) self.assertEqual(reqd_item.exploded_qty, created_item.exploded_qty) + @timeout def test_bom_recursion_1st_level(self): """BOM should not allow BOM item again in child""" item_code = make_item(properties={"is_stock_item": 1}).name @@ -360,6 +401,7 @@ def test_bom_recursion_1st_level(self): bom.items[0].bom_no = bom.name bom.save() + @timeout def test_bom_recursion_transitive(self): item1 = make_item(properties={"is_stock_item": 1}).name item2 = make_item(properties={"is_stock_item": 1}).name @@ -381,39 +423,21 @@ def test_bom_recursion_transitive(self): bom1.save() bom2.save() + @timeout def test_bom_with_process_loss_item(self): fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items() - if not frappe.db.exists("BOM", f"BOM-{fg_item_non_whole.item_code}-001"): - bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=0.25, scrap_rate=0, fg_qty=1 - ) - bom_doc.submit() - bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=2, scrap_rate=0 + fg_item_non_whole, bom_item, scrap_qty=2, scrap_rate=0, process_loss_percentage=110 ) - # PL Item qty can't be >= FG Item qty + # PL can't be > 100 self.assertRaises(frappe.ValidationError, bom_doc.submit) - bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=1, scrap_rate=100 - ) - # PL Item rate has to be 0 - self.assertRaises(frappe.ValidationError, bom_doc.submit) - - bom_doc = create_bom_with_process_loss_item( - fg_item_whole, bom_item, scrap_qty=0.25, scrap_rate=0 - ) + bom_doc = create_bom_with_process_loss_item(fg_item_whole, bom_item, process_loss_percentage=20) # Items with whole UOMs can't be PL Items self.assertRaises(frappe.ValidationError, bom_doc.submit) - bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=0.25, scrap_rate=0, is_process_loss=0 - ) - # FG Items in Scrap/Loss Table should have Is Process Loss set - self.assertRaises(frappe.ValidationError, bom_doc.submit) - + @timeout def test_bom_item_query(self): query = partial( item_query, @@ -433,6 +457,7 @@ def test_bom_item_query(self): ) self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results") + @timeout def test_exclude_exploded_items_from_bom(self): bom_no = get_default_bom() new_bom = frappe.copy_doc(frappe.get_doc("BOM", bom_no)) @@ -451,6 +476,7 @@ def test_exclude_exploded_items_from_bom(self): new_bom.delete() + @timeout def test_valid_transfer_defaults(self): bom_with_op = frappe.db.get_value( "BOM", {"item": "_Test FG Item 2", "with_operations": 1, "is_active": 1} @@ -482,11 +508,13 @@ def test_valid_transfer_defaults(self): self.assertEqual(bom.transfer_material_against, "Work Order") bom.delete() + @timeout def test_bom_name_length(self): """test >140 char names""" bom_tree = {"x" * 140: {" ".join(["abc"] * 35): {}}} create_nested_bom(bom_tree, prefix="") + @timeout def test_version_index(self): bom = frappe.new_doc("BOM") @@ -508,6 +536,7 @@ def test_version_index(self): msg=f"Incorrect index for {existing_boms}", ) + @timeout def test_bom_versioning(self): bom_tree = {frappe.generate_hash(length=10): {frappe.generate_hash(length=10): {}}} bom = create_nested_bom(bom_tree, prefix="") @@ -540,6 +569,7 @@ def test_bom_versioning(self): self.assertNotEqual(amendment.name, version.name) self.assertEqual(int(version.name.split("-")[-1]), 2) + @timeout def test_clear_inpection_quality(self): bom = frappe.copy_doc(test_records[2], ignore_no_copy=True) @@ -558,6 +588,7 @@ def test_clear_inpection_quality(self): self.assertEqual(bom.quality_inspection_template, None) + @timeout def test_bom_pricing_based_on_lpp(self): from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -578,6 +609,7 @@ def test_bom_pricing_based_on_lpp(self): bom.submit() self.assertEqual(bom.items[0].rate, 42) + @timeout def test_set_default_bom_for_item_having_single_bom(self): from erpnext.stock.doctype.item.test_item import make_item @@ -614,6 +646,7 @@ def test_set_default_bom_for_item_having_single_bom(self): bom.reload() self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name) + @timeout def test_exploded_items_rate(self): rm_item = make_item( properties={"is_stock_item": 1, "valuation_rate": 99, "last_purchase_rate": 89} @@ -642,6 +675,7 @@ def test_exploded_items_rate(self): bom.submit() self.assertEqual(bom.exploded_items[0].rate, bom.items[0].base_rate) + @timeout def test_bom_cost_update_flag(self): rm_item = make_item( properties={"is_stock_item": 1, "valuation_rate": 99, "last_purchase_rate": 89} @@ -744,7 +778,7 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non def create_bom_with_process_loss_item( - fg_item, bom_item, scrap_qty, scrap_rate, fg_qty=2, is_process_loss=1 + fg_item, bom_item, scrap_qty=0, scrap_rate=0, fg_qty=2, process_loss_percentage=0 ): bom_doc = frappe.new_doc("BOM") bom_doc.item = fg_item.item_code @@ -759,19 +793,22 @@ def create_bom_with_process_loss_item( "rate": 100.0, }, ) - bom_doc.append( - "scrap_items", - { - "item_code": fg_item.item_code, - "qty": scrap_qty, - "stock_qty": scrap_qty, - "uom": fg_item.stock_uom, - "stock_uom": fg_item.stock_uom, - "rate": scrap_rate, - "is_process_loss": is_process_loss, - }, - ) + + if scrap_qty: + bom_doc.append( + "scrap_items", + { + "item_code": fg_item.item_code, + "qty": scrap_qty, + "stock_qty": scrap_qty, + "uom": fg_item.stock_uom, + "stock_uom": fg_item.stock_uom, + "rate": scrap_rate, + }, + ) + bom_doc.currency = "INR" + bom_doc.process_loss_percentage = process_loss_percentage return bom_doc diff --git a/erpnext/manufacturing/doctype/bom/test_records.json b/erpnext/manufacturing/doctype/bom/test_records.json index 507d319b515a..e9cbdfe638a0 100644 --- a/erpnext/manufacturing/doctype/bom/test_records.json +++ b/erpnext/manufacturing/doctype/bom/test_records.json @@ -162,5 +162,31 @@ "item": "_Test Variant Item", "quantity": 1.0, "with_operations": 1 + }, + { + "items": [ + { + "amount": 5000.0, + "doctype": "BOM Item", + "item_code": "_Test Item", + "parentfield": "items", + "qty": 2.0, + "rate": 3000.0, + "uom": "_Test UOM", + "stock_uom": "_Test UOM", + "source_warehouse": "_Test Warehouse - _TC", + "include_item_in_manufacturing": 1 + } + ], + "docstatus": 1, + "doctype": "BOM", + "is_active": 1, + "is_default": 1, + "currency": "USD", + "item": "_Test Variant Item", + "quantity": 1.0, + "with_operations": 0, + "fg_based_operating_cost": 1, + "operating_cost_per_bom_quantity": 140 } ] diff --git a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json b/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json index 7018082e4027..b2ef19b20f05 100644 --- a/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json +++ b/erpnext/manufacturing/doctype/bom_scrap_item/bom_scrap_item.json @@ -8,7 +8,6 @@ "item_code", "column_break_2", "item_name", - "is_process_loss", "quantity_and_rate", "stock_qty", "rate", @@ -89,17 +88,11 @@ { "fieldname": "column_break_2", "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "is_process_loss", - "fieldtype": "Check", - "label": "Is Process Loss" } ], "istable": 1, "links": [], - "modified": "2021-06-22 16:46:12.153311", + "modified": "2023-01-03 14:19:28.460965", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Scrap Item", @@ -108,5 +101,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py index c3f52d45833a..7477f9528ecb 100644 --- a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py +++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py @@ -164,7 +164,7 @@ def queue_bom_cost_jobs( while current_boms_list: batch_no += 1 - batch_size = 20_000 + batch_size = 7_000 boms_to_process = current_boms_list[:batch_size] # slice out batch of 20k BOMs # update list to exclude 20K (queued) BOMs diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py index 5dd557f8ab13..2026f629147a 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py @@ -2,7 +2,7 @@ # License: GNU General Public License v3. See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, timeout from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import ( update_cost_in_all_boms_in_test, @@ -20,6 +20,7 @@ class TestBOMUpdateTool(FrappeTestCase): def tearDown(self): frappe.db.rollback() + @timeout def test_replace_bom(self): current_bom = "BOM-_Test Item Home Desktop Manufactured-001" @@ -33,6 +34,7 @@ def test_replace_bom(self): self.assertFalse(frappe.db.exists("BOM Item", {"bom_no": current_bom, "docstatus": 1})) self.assertTrue(frappe.db.exists("BOM Item", {"bom_no": bom_doc.name, "docstatus": 1})) + @timeout def test_bom_cost(self): for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]: item_doc = create_item(item, valuation_rate=100) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 619e6bd1a00d..5305db318b19 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -83,7 +83,7 @@ frappe.ui.form.on('Job Card', { // and if stock mvt for WIP is required if (frm.doc.work_order) { frappe.db.get_value('Work Order', frm.doc.work_order, ['skip_transfer', 'status'], (result) => { - if (result.skip_transfer === 1 || result.status == 'In Process') { + if (result.skip_transfer === 1 || result.status == 'In Process' || frm.doc.transferred_qty > 0) { frm.trigger("prepare_timer_buttons"); } }); diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 3133628cbf21..e82f37977cd6 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -561,7 +561,34 @@ def get_current_operation_data(self): ) def set_transferred_qty_in_job_card_item(self, ste_doc): - from frappe.query_builder.functions import Sum + def _get_job_card_items_transferred_qty(ste_doc): + from frappe.query_builder.functions import Sum + + job_card_items_transferred_qty = {} + job_card_items = [ + x.get("job_card_item") for x in ste_doc.get("items") if x.get("job_card_item") + ] + + if job_card_items: + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + + query = ( + frappe.qb.from_(sed) + .join(se) + .on(sed.parent == se.name) + .select(sed.job_card_item, Sum(sed.qty)) + .where( + (sed.job_card_item.isin(job_card_items)) + & (se.docstatus == 1) + & (se.purpose == "Material Transfer for Manufacture") + ) + .groupby(sed.job_card_item) + ) + + job_card_items_transferred_qty = frappe._dict(query.run(as_list=True)) + + return job_card_items_transferred_qty def _validate_over_transfer(row, transferred_qty): "Block over transfer of items if not allowed in settings." @@ -578,29 +605,23 @@ def _validate_over_transfer(row, transferred_qty): exc=JobCardOverTransferError, ) - for row in ste_doc.items: - if not row.job_card_item: - continue - - sed = frappe.qb.DocType("Stock Entry Detail") - se = frappe.qb.DocType("Stock Entry") - transferred_qty = ( - frappe.qb.from_(sed) - .join(se) - .on(sed.parent == se.name) - .select(Sum(sed.qty)) - .where( - (sed.job_card_item == row.job_card_item) - & (se.docstatus == 1) - & (se.purpose == "Material Transfer for Manufacture") - ) - ).run()[0][0] + job_card_items_transferred_qty = _get_job_card_items_transferred_qty(ste_doc) + if job_card_items_transferred_qty: allow_excess = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer") - if not allow_excess: - _validate_over_transfer(row, transferred_qty) - frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty)) + for row in ste_doc.items: + if not row.job_card_item: + continue + + transferred_qty = flt(job_card_items_transferred_qty.get(row.job_card_item)) + + if not allow_excess: + _validate_over_transfer(row, transferred_qty) + + frappe.db.set_value( + "Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty) + ) def set_transferred_qty(self, update_status=False): "Set total FG Qty in Job Card for which RM was transferred." diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 2624daa41e2d..fdaa4a2a1d46 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -344,6 +344,7 @@ { "fieldname": "prod_plan_references", "fieldtype": "Table", + "hidden": 1, "label": "Production Plan Item Reference", "options": "Production Plan Item Reference" }, @@ -397,7 +398,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-11-26 14:51:08.774372", + "modified": "2023-03-31 10:30:48.118932", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json index 84dee4ad284c..15ef20794cb8 100644 --- a/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json +++ b/erpnext/manufacturing/doctype/production_plan_item_reference/production_plan_item_reference.json @@ -28,7 +28,7 @@ "fieldname": "qty", "fieldtype": "Data", "in_list_view": 1, - "label": "qty" + "label": "Qty" }, { "fieldname": "item_reference", @@ -40,7 +40,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-07 17:03:49.707487", + "modified": "2023-03-31 10:30:14.604051", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Item Reference", @@ -48,5 +48,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index f568264c9081..729ed42f51a2 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -846,20 +846,20 @@ def test_wo_completion_with_pl_bom(self): create_process_loss_bom_items, ) - qty = 4 + qty = 10 scrap_qty = 0.25 # bom item qty = 1, consider as 25% of FG source_warehouse = "Stores - _TC" wip_warehouse = "_Test Warehouse - _TC" fg_item_non_whole, _, bom_item = create_process_loss_bom_items() test_stock_entry.make_stock_entry( - item_code=bom_item.item_code, target=source_warehouse, qty=4, basic_rate=100 + item_code=bom_item.item_code, target=source_warehouse, qty=qty, basic_rate=100 ) bom_no = f"BOM-{fg_item_non_whole.item_code}-001" if not frappe.db.exists("BOM", bom_no): bom_doc = create_bom_with_process_loss_item( - fg_item_non_whole, bom_item, scrap_qty=scrap_qty, scrap_rate=0, fg_qty=1, is_process_loss=1 + fg_item_non_whole, bom_item, fg_qty=1, process_loss_percentage=10 ) bom_doc.submit() @@ -883,19 +883,15 @@ def test_wo_completion_with_pl_bom(self): # Testing stock entry values items = se.get("items") - self.assertEqual(len(items), 3, "There should be 3 items including process loss.") + self.assertEqual(len(items), 2, "There should be 3 items including process loss.") + fg_item = items[1] - source_item, fg_item, pl_item = items + self.assertEqual(fg_item.qty, qty - 1) + self.assertEqual(se.process_loss_percentage, 10) + self.assertEqual(se.process_loss_qty, 1) - total_pl_qty = qty * scrap_qty - actual_fg_qty = qty - total_pl_qty - - self.assertEqual(pl_item.qty, total_pl_qty) - self.assertEqual(fg_item.qty, actual_fg_qty) - - # Testing Work Order values - self.assertEqual(frappe.db.get_value("Work Order", wo.name, "produced_qty"), qty) - self.assertEqual(frappe.db.get_value("Work Order", wo.name, "process_loss_qty"), total_pl_qty) + wo.load_from_db() + self.assertEqual(wo.status, "In Process") @timeout(seconds=60) def test_job_card_scrap_item(self): diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 4aff42cb73d7..97480b294546 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -506,7 +506,7 @@ frappe.ui.form.on("Work Order Item", { callback: function(r) { if (r.message) { frappe.model.set_value(cdt, cdn, { - "required_qty": 1, + "required_qty": row.required_qty || 1, "item_name": r.message.item_name, "description": r.message.description, "source_warehouse": r.message.default_warehouse, diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 9452a63d70b7..25e16d633764 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -14,13 +14,13 @@ "item_name", "image", "bom_no", + "sales_order", "column_break1", "company", "qty", "material_transferred_for_manufacturing", "produced_qty", "process_loss_qty", - "sales_order", "project", "serial_no_and_batch_for_finished_good_section", "has_serial_no", @@ -28,6 +28,7 @@ "column_break_17", "serial_no", "batch_size", + "work_order_configuration", "settings_section", "allow_alternative_item", "use_multi_level_bom", @@ -42,7 +43,11 @@ "fg_warehouse", "scrap_warehouse", "required_items_section", + "materials_and_operations_tab", "required_items", + "operations_section", + "operations", + "transfer_material_against", "time", "planned_start_date", "planned_end_date", @@ -51,9 +56,6 @@ "actual_start_date", "actual_end_date", "lead_time", - "operations_section", - "transfer_material_against", - "operations", "section_break_22", "planned_operating_cost", "actual_operating_cost", @@ -72,12 +74,14 @@ "production_plan_item", "production_plan_sub_assembly_item", "product_bundle_item", - "amended_from" + "amended_from", + "connections_tab" ], "fields": [ { "fieldname": "item", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", + "label": "Production Item", "options": "fa fa-gift" }, { @@ -236,7 +240,7 @@ { "fieldname": "warehouses", "fieldtype": "Section Break", - "label": "Warehouses", + "label": "Warehouse", "options": "fa fa-building" }, { @@ -390,8 +394,8 @@ { "collapsible": 1, "fieldname": "more_info", - "fieldtype": "Section Break", - "label": "More Information", + "fieldtype": "Tab Break", + "label": "More Info", "options": "fa fa-file-text" }, { @@ -474,8 +478,7 @@ }, { "fieldname": "settings_section", - "fieldtype": "Section Break", - "label": "Settings" + "fieldtype": "Section Break" }, { "fieldname": "column_break_18", @@ -568,6 +571,22 @@ "no_copy": 1, "non_negative": 1, "read_only": 1 + }, + { + "fieldname": "connections_tab", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 + }, + { + "fieldname": "work_order_configuration", + "fieldtype": "Tab Break", + "label": "Configuration" + }, + { + "fieldname": "materials_and_operations_tab", + "fieldtype": "Tab Break", + "label": "Materials & Operations" } ], "icon": "fa fa-cogs", @@ -575,7 +594,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2022-01-24 21:18:12.160114", + "modified": "2023-01-03 14:16:35.427731", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 52753a092d4e..66b871c746ff 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -246,21 +246,11 @@ def get_status(self, status=None): status = "Draft" elif self.docstatus == 1: if status != "Stopped": - stock_entries = frappe._dict( - frappe.db.sql( - """select purpose, sum(fg_completed_qty) - from `tabStock Entry` where work_order=%s and docstatus=1 - group by purpose""", - self.name, - ) - ) - status = "Not Started" - if stock_entries: + if flt(self.material_transferred_for_manufacturing) > 0: status = "In Process" - produced_qty = stock_entries.get("Manufacture") - if flt(produced_qty) >= flt(self.qty): - status = "Completed" + if flt(self.produced_qty) >= flt(self.qty): + status = "Completed" else: status = "Cancelled" @@ -285,14 +275,7 @@ def update_work_order_qty(self): ): continue - qty = flt( - frappe.db.sql( - """select sum(fg_completed_qty) - from `tabStock Entry` where work_order=%s and docstatus=1 - and purpose=%s""", - (self.name, purpose), - )[0][0] - ) + qty = self.get_transferred_or_manufactured_qty(purpose) completed_qty = self.qty + (allowance_percentage / 100 * self.qty) if qty > completed_qty: @@ -314,26 +297,30 @@ def update_work_order_qty(self): if self.production_plan: self.update_production_plan_status() - def set_process_loss_qty(self): - process_loss_qty = flt( - frappe.db.sql( - """ - SELECT sum(qty) FROM `tabStock Entry Detail` - WHERE - is_process_loss=1 - AND parent IN ( - SELECT name FROM `tabStock Entry` - WHERE - work_order=%s - AND purpose='Manufacture' - AND docstatus=1 - ) - """, - (self.name,), - )[0][0] + def get_transferred_or_manufactured_qty(self, purpose): + table = frappe.qb.DocType("Stock Entry") + query = frappe.qb.from_(table).where( + (table.work_order == self.name) & (table.docstatus == 1) & (table.purpose == purpose) ) - if process_loss_qty is not None: - self.db_set("process_loss_qty", process_loss_qty) + + if purpose == "Manufacture": + query = query.select(Sum(table.fg_completed_qty) - Sum(table.process_loss_qty)) + else: + query = query.select(Sum(table.fg_completed_qty)) + + return flt(query.run()[0][0]) + + def set_process_loss_qty(self): + table = frappe.qb.DocType("Stock Entry") + process_loss_qty = ( + frappe.qb.from_(table) + .select(Sum(table.process_loss_qty)) + .where( + (table.work_order == self.name) & (table.purpose == "Manufacture") & (table.docstatus == 1) + ) + ).run()[0][0] + + self.db_set("process_loss_qty", flt(process_loss_qty)) def update_production_plan_status(self): production_plan = frappe.get_doc("Production Plan", self.production_plan) @@ -352,6 +339,7 @@ def update_production_plan_status(self): produced_qty = total_qty[0][0] if total_qty else 0 + self.update_status() production_plan.run_method( "update_produced_pending_qty", produced_qty, self.production_plan_item ) @@ -694,7 +682,7 @@ def _get_operations(bom_no, qty=1): for node in bom_traversal: if node.is_bom: - operations.extend(_get_operations(node.name, qty=node.exploded_qty)) + operations.extend(_get_operations(node.name, qty=node.exploded_qty / node.bom_qty)) bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity") operations.extend(_get_operations(self.bom_no, qty=1.0 / bom_qty)) diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js index 7beecaceedf5..e7f67caf249b 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js @@ -25,8 +25,9 @@ frappe.query_reports["BOM Stock Report"] = { ], "formatter": function(value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); + if (column.id == "item") { - if (data["enough_parts_to_build"] > 0) { + if (data["in_stock_qty"] >= data["required_qty"]) { value = `${data['item']}`; } else { value = `${data['item']}`; diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py index cdf1541f8881..3573a3a93d88 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py @@ -4,7 +4,8 @@ import frappe from frappe import _ -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import Floor, Sum +from frappe.utils import cint from pypika.terms import ExistsCriterion @@ -34,57 +35,55 @@ def get_columns(): def get_bom_stock(filters): - qty_to_produce = filters.get("qty_to_produce") or 1 - if int(qty_to_produce) < 0: - frappe.throw(_("Quantity to Produce can not be less than Zero")) + qty_to_produce = filters.get("qty_to_produce") + if cint(qty_to_produce) <= 0: + frappe.throw(_("Quantity to Produce should be greater than zero.")) if filters.get("show_exploded_view"): bom_item_table = "BOM Explosion Item" else: bom_item_table = "BOM Item" - bin = frappe.qb.DocType("Bin") - bom = frappe.qb.DocType("BOM") - bom_item = frappe.qb.DocType(bom_item_table) + warehouse_details = frappe.db.get_value( + "Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1 + ) - query = ( - frappe.qb.from_(bom) - .inner_join(bom_item) - .on(bom.name == bom_item.parent) - .left_join(bin) - .on(bom_item.item_code == bin.item_code) + BOM = frappe.qb.DocType("BOM") + BOM_ITEM = frappe.qb.DocType(bom_item_table) + BIN = frappe.qb.DocType("Bin") + WH = frappe.qb.DocType("Warehouse") + CONDITIONS = () + + if warehouse_details: + CONDITIONS = ExistsCriterion( + frappe.qb.from_(WH) + .select(WH.name) + .where( + (WH.lft >= warehouse_details.lft) + & (WH.rgt <= warehouse_details.rgt) + & (BIN.warehouse == WH.name) + ) + ) + else: + CONDITIONS = BIN.warehouse == filters.get("warehouse") + + QUERY = ( + frappe.qb.from_(BOM) + .inner_join(BOM_ITEM) + .on(BOM.name == BOM_ITEM.parent) + .left_join(BIN) + .on((BOM_ITEM.item_code == BIN.item_code) & (CONDITIONS)) .select( - bom_item.item_code, - bom_item.description, - bom_item.stock_qty, - bom_item.stock_uom, - (bom_item.stock_qty / bom.quantity) * qty_to_produce, - Sum(bin.actual_qty), - Sum(bin.actual_qty) / (bom_item.stock_qty / bom.quantity), + BOM_ITEM.item_code, + BOM_ITEM.description, + BOM_ITEM.stock_qty, + BOM_ITEM.stock_uom, + BOM_ITEM.stock_qty * qty_to_produce / BOM.quantity, + Sum(BIN.actual_qty).as_("actual_qty"), + Sum(Floor(BIN.actual_qty / (BOM_ITEM.stock_qty * qty_to_produce / BOM.quantity))), ) - .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) - .groupby(bom_item.item_code) + .where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM")) + .groupby(BOM_ITEM.item_code) ) - if filters.get("warehouse"): - warehouse_details = frappe.db.get_value( - "Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1 - ) - - if warehouse_details: - wh = frappe.qb.DocType("Warehouse") - query = query.where( - ExistsCriterion( - frappe.qb.from_(wh) - .select(wh.name) - .where( - (wh.lft >= warehouse_details.lft) - & (wh.rgt <= warehouse_details.rgt) - & (bin.warehouse == wh.name) - ) - ) - ) - else: - query = query.where(bin.warehouse == filters.get("warehouse")) - - return query.run() + return QUERY.run() diff --git a/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py new file mode 100644 index 000000000000..1c56ebe24d4e --- /dev/null +++ b/erpnext/manufacturing/report/bom_stock_report/test_bom_stock_report.py @@ -0,0 +1,108 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + + +import frappe +from frappe.exceptions import ValidationError +from frappe.tests.utils import FrappeTestCase +from frappe.utils import floor + +from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.manufacturing.report.bom_stock_report.bom_stock_report import ( + get_bom_stock as bom_stock_report, +) +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + +class TestBomStockReport(FrappeTestCase): + def setUp(self): + self.warehouse = "_Test Warehouse - _TC" + self.fg_item, self.rm_items = create_items() + make_stock_entry(target=self.warehouse, item_code=self.rm_items[0], qty=20, basic_rate=100) + make_stock_entry(target=self.warehouse, item_code=self.rm_items[1], qty=40, basic_rate=200) + self.bom = make_bom(item=self.fg_item, quantity=1, raw_materials=self.rm_items, rm_qty=10) + + def test_bom_stock_report(self): + # Test 1: When `qty_to_produce` is 0. + filters = frappe._dict( + { + "bom": self.bom.name, + "warehouse": "Stores - _TC", + "qty_to_produce": 0, + } + ) + self.assertRaises(ValidationError, bom_stock_report, filters) + + # Test 2: When stock is not available. + data = bom_stock_report( + frappe._dict( + { + "bom": self.bom.name, + "warehouse": "Stores - _TC", + "qty_to_produce": 1, + } + ) + ) + expected_data = get_expected_data(self.bom, "Stores - _TC", 1) + self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + # Test 3: When stock is available. + data = bom_stock_report( + frappe._dict( + { + "bom": self.bom.name, + "warehouse": self.warehouse, + "qty_to_produce": 1, + } + ) + ) + expected_data = get_expected_data(self.bom, self.warehouse, 1) + self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data)) + + +def create_items(): + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item1 = make_item( + properties={ + "is_stock_item": 1, + "standard_rate": 100, + "opening_stock": 100, + "last_purchase_rate": 100, + } + ).name + rm_item2 = make_item( + properties={ + "is_stock_item": 1, + "standard_rate": 200, + "opening_stock": 200, + "last_purchase_rate": 200, + } + ).name + + return fg_item, [rm_item1, rm_item2] + + +def get_expected_data(bom, warehouse, qty_to_produce, show_exploded_view=False): + expected_data = [] + + for item in bom.get("exploded_items") if show_exploded_view else bom.get("items"): + in_stock_qty = frappe.get_cached_value( + "Bin", {"item_code": item.item_code, "warehouse": warehouse}, "actual_qty" + ) + + expected_data.append( + [ + item.item_code, + item.description, + item.stock_qty, + item.stock_uom, + item.stock_qty * qty_to_produce / bom.quantity, + in_stock_qty, + floor(in_stock_qty / (item.stock_qty * qty_to_produce / bom.quantity)) + if in_stock_qty + else None, + ] + ) + + return expected_data diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.js b/erpnext/manufacturing/report/work_order_summary/work_order_summary.js index 832be2301c19..67bd24dd805e 100644 --- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.js +++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.js @@ -13,38 +13,24 @@ frappe.query_reports["Work Order Summary"] = { reqd: 1 }, { - fieldname: "fiscal_year", - label: __("Fiscal Year"), - fieldtype: "Link", - options: "Fiscal Year", - default: frappe.defaults.get_user_default("fiscal_year"), - reqd: 1, - on_change: function(query_report) { - var fiscal_year = query_report.get_values().fiscal_year; - if (!fiscal_year) { - return; - } - frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) { - var fy = frappe.model.get_doc("Fiscal Year", fiscal_year); - frappe.query_report.set_filter_value({ - from_date: fy.year_start_date, - to_date: fy.year_end_date - }); - }); - } + label: __("Based On"), + fieldname:"based_on", + fieldtype: "Select", + options: "Creation Date\nPlanned Date\nActual Date", + default: "Creation Date" }, { label: __("From Posting Date"), fieldname:"from_date", fieldtype: "Date", - default: frappe.defaults.get_user_default("year_start_date"), + default: frappe.datetime.add_months(frappe.datetime.get_today(), -3), reqd: 1 }, { label: __("To Posting Date"), fieldname:"to_date", fieldtype: "Date", - default: frappe.defaults.get_user_default("year_end_date"), + default: frappe.datetime.get_today(), reqd: 1, }, { diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py index b69ad070e166..97f30ef62e9d 100644 --- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py +++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py @@ -31,6 +31,7 @@ def get_data(filters): "sales_order", "production_item", "qty", + "creation", "produced_qty", "planned_start_date", "planned_end_date", @@ -47,11 +48,17 @@ def get_data(filters): if filters.get(field): query_filters[field] = filters.get(field) - query_filters["planned_start_date"] = (">=", filters.get("from_date")) - query_filters["planned_end_date"] = ("<=", filters.get("to_date")) + if filters.get("based_on") == "Planned Date": + query_filters["planned_start_date"] = (">=", filters.get("from_date")) + query_filters["planned_end_date"] = ("<=", filters.get("to_date")) + elif filters.get("based_on") == "Actual Date": + query_filters["actual_start_date"] = (">=", filters.get("from_date")) + query_filters["actual_end_date"] = ("<=", filters.get("to_date")) + else: + query_filters["creation"] = ("between", [filters.get("from_date"), filters.get("to_date")]) data = frappe.get_all( - "Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc" + "Work Order", fields=fields, filters=query_filters, order_by="planned_start_date asc", debug=1 ) res = [] @@ -213,6 +220,12 @@ def get_columns(filters): "options": "Sales Order", "width": 90, }, + { + "label": _("Created On"), + "fieldname": "creation", + "fieldtype": "Date", + "width": 150, + }, { "label": _("Planned Start Date"), "fieldname": "planned_start_date", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index f7d2dedb1b32..5803f46dea35 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -195,7 +195,6 @@ erpnext.patches.v13_0.update_project_template_tasks erpnext.patches.v13_0.convert_qi_parameter_to_link_field erpnext.patches.v13_0.add_naming_series_to_old_projects # 1-02-2021 erpnext.patches.v13_0.update_payment_terms_outstanding -erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl erpnext.patches.v13_0.delete_old_bank_reconciliation_doctypes erpnext.patches.v13_0.update_vehicle_no_reqd_condition erpnext.patches.v13_0.rename_membership_settings_to_non_profit_settings @@ -269,6 +268,7 @@ erpnext.patches.v13_0.show_india_localisation_deprecation_warning erpnext.patches.v13_0.show_hr_payroll_deprecation_warning erpnext.patches.v13_0.reset_corrupt_defaults erpnext.patches.v13_0.create_accounting_dimensions_for_asset_repair +erpnext.patches.v14_0.update_reference_due_date_in_journal_entry [post_model_sync] execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') @@ -291,6 +291,7 @@ erpnext.patches.v13_0.update_exchange_rate_settings erpnext.patches.v14_0.delete_amazon_mws_doctype erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr erpnext.patches.v13_0.update_accounts_in_loan_docs +erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl erpnext.patches.v14_0.update_batch_valuation_flag erpnext.patches.v14_0.delete_non_profit_doctypes erpnext.patches.v13_0.add_cost_center_in_loans @@ -305,7 +306,6 @@ erpnext.patches.v13_0.set_per_billed_in_return_delivery_note execute:frappe.delete_doc("DocType", "Naming Series") erpnext.patches.v13_0.job_card_status_on_hold erpnext.patches.v14_0.copy_is_subcontracted_value_to_is_old_subcontracting_flow -erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.patches.v14_0.crm_ux_cleanup erpnext.patches.v14_0.migrate_existing_lead_notes_as_per_the_new_format erpnext.patches.v14_0.remove_india_localisation # 14-07-2022 @@ -314,10 +314,17 @@ erpnext.patches.v14_0.remove_hr_and_payroll_modules # 20-07-2022 erpnext.patches.v14_0.fix_crm_no_of_employees erpnext.patches.v14_0.create_accounting_dimensions_in_subcontracting_doctypes erpnext.patches.v14_0.fix_subcontracting_receipt_gl_entries -erpnext.patches.v14_0.migrate_remarks_from_gl_to_payment_ledger -erpnext.patches.v14_0.create_accounting_dimensions_for_asset_capitalization erpnext.patches.v13_0.update_schedule_type_in_loans +erpnext.patches.v13_0.drop_unused_sle_index_parts +erpnext.patches.v14_0.create_accounting_dimensions_for_asset_capitalization erpnext.patches.v14_0.update_partial_tds_fields erpnext.patches.v14_0.create_incoterms_and_migrate_shipment erpnext.patches.v14_0.setup_clear_repost_logs -erpnext.patches.v14_0.create_accounting_dimensions_for_payment_request \ No newline at end of file +erpnext.patches.v14_0.create_accounting_dimensions_for_payment_request +erpnext.patches.v14_0.update_entry_type_for_journal_entry +erpnext.patches.v14_0.change_autoname_for_tax_withheld_vouchers +erpnext.patches.v14_0.update_asset_value_for_manual_depr_entries +erpnext.patches.v14_0.set_pick_list_status +erpnext.patches.v13_0.update_docs_link +# below migration patches should always run last +erpnext.patches.v14_0.migrate_gl_to_payment_ledger diff --git a/erpnext/patches/v11_0/update_sales_partner_type.py b/erpnext/patches/v11_0/update_sales_partner_type.py index 2d37fd69b192..72fd424b2452 100644 --- a/erpnext/patches/v11_0/update_sales_partner_type.py +++ b/erpnext/patches/v11_0/update_sales_partner_type.py @@ -1,16 +1,17 @@ import frappe -from frappe import _ def execute(): - from erpnext.setup.setup_wizard.operations.install_fixtures import default_sales_partner_type + from erpnext.setup.setup_wizard.operations.install_fixtures import read_lines frappe.reload_doc("selling", "doctype", "sales_partner_type") frappe.local.lang = frappe.db.get_default("lang") or "en" + default_sales_partner_type = read_lines("sales_partner_type.txt") + for s in default_sales_partner_type: - insert_sales_partner_type(_(s)) + insert_sales_partner_type(s) # get partner type in existing forms (customized) # and create a document if not created diff --git a/erpnext/patches/v13_0/delete_old_purchase_reports.py b/erpnext/patches/v13_0/delete_old_purchase_reports.py index 987f53f37c1f..60621fbc9cad 100644 --- a/erpnext/patches/v13_0/delete_old_purchase_reports.py +++ b/erpnext/patches/v13_0/delete_old_purchase_reports.py @@ -17,10 +17,11 @@ def execute(): for report in reports_to_delete: if frappe.db.exists("Report", report): + delete_links_from_desktop_icons(report) delete_auto_email_reports(report) check_and_delete_linked_reports(report) - frappe.delete_doc("Report", report) + frappe.delete_doc("Report", report, force=True) def delete_auto_email_reports(report): @@ -28,3 +29,10 @@ def delete_auto_email_reports(report): auto_email_reports = frappe.db.get_values("Auto Email Report", {"report": report}, ["name"]) for auto_email_report in auto_email_reports: frappe.delete_doc("Auto Email Report", auto_email_report[0]) + + +def delete_links_from_desktop_icons(report): + """Check for one or multiple Desktop Icons and delete""" + desktop_icons = frappe.db.get_values("Desktop Icon", {"_report": report}, ["name"]) + for desktop_icon in desktop_icons: + frappe.delete_doc("Desktop Icon", desktop_icon[0], force=True) diff --git a/erpnext/patches/v13_0/drop_unused_sle_index_parts.py b/erpnext/patches/v13_0/drop_unused_sle_index_parts.py new file mode 100644 index 000000000000..fa8a98ce16ca --- /dev/null +++ b/erpnext/patches/v13_0/drop_unused_sle_index_parts.py @@ -0,0 +1,14 @@ +import frappe + +from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import on_doctype_update + + +def execute(): + try: + frappe.db.sql_ddl("ALTER TABLE `tabStock Ledger Entry` DROP INDEX `posting_sort_index`") + except Exception: + frappe.log_error("Failed to drop index") + return + + # Recreate indexes + on_doctype_update() diff --git a/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py b/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py index 75a5477be8f3..c0d715063a82 100644 --- a/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py +++ b/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py @@ -7,6 +7,7 @@ def execute(): doctypes_to_reload = [ + ("setup", "company"), ("stock", "repost_item_valuation"), ("stock", "stock_entry_detail"), ("stock", "purchase_receipt_item"), diff --git a/erpnext/patches/v13_0/update_docs_link.py b/erpnext/patches/v13_0/update_docs_link.py new file mode 100644 index 000000000000..4bc5c053d27a --- /dev/null +++ b/erpnext/patches/v13_0/update_docs_link.py @@ -0,0 +1,14 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + + +import frappe + + +def execute(): + navbar_settings = frappe.get_single("Navbar Settings") + for item in navbar_settings.help_dropdown: + if item.is_standard and item.route == "https://erpnext.com/docs/user/manual": + item.route = "https://docs.erpnext.com/docs/v14/user/manual/en/introduction" + + navbar_settings.save() diff --git a/erpnext/patches/v14_0/change_autoname_for_tax_withheld_vouchers.py b/erpnext/patches/v14_0/change_autoname_for_tax_withheld_vouchers.py new file mode 100644 index 000000000000..e20ba73dbbfb --- /dev/null +++ b/erpnext/patches/v14_0/change_autoname_for_tax_withheld_vouchers.py @@ -0,0 +1,12 @@ +import frappe + + +def execute(): + if ( + frappe.db.sql( + """select data_type FROM information_schema.columns + where column_name = 'name' and table_name = 'tabTax Withheld Vouchers'""" + )[0][0] + == "bigint" + ): + frappe.db.change_column_type("Tax Withheld Vouchers", "name", "varchar(140)") diff --git a/erpnext/patches/v14_0/migrate_cost_center_allocations.py b/erpnext/patches/v14_0/migrate_cost_center_allocations.py index 3bd26933bada..48f4e6d98938 100644 --- a/erpnext/patches/v14_0/migrate_cost_center_allocations.py +++ b/erpnext/patches/v14_0/migrate_cost_center_allocations.py @@ -18,9 +18,11 @@ def create_new_cost_center_allocation_records(cc_allocations): cca = frappe.new_doc("Cost Center Allocation") cca.main_cost_center = main_cc cca.valid_from = today() + cca._skip_from_date_validation = True for child_cc, percentage in allocations.items(): cca.append("allocation_percentages", ({"cost_center": child_cc, "percentage": percentage})) + cca.save() cca.submit() diff --git a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py index e15aa4a1f41e..72c8c074d2c9 100644 --- a/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py +++ b/erpnext/patches/v14_0/migrate_gl_to_payment_ledger.py @@ -1,8 +1,9 @@ import frappe from frappe import qb -from frappe.query_builder import Case, CustomFunction +from frappe.query_builder import CustomFunction from frappe.query_builder.custom import ConstantColumn -from frappe.query_builder.functions import IfNull +from frappe.query_builder.functions import Count, IfNull +from frappe.utils import flt from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_dimensions, @@ -17,9 +18,21 @@ def create_accounting_dimension_fields(): make_dimension_in_accounting_doctypes(dimension, ["Payment Ledger Entry"]) -def generate_name_for_payment_ledger_entries(gl_entries): - for index, entry in enumerate(gl_entries, 1): - entry.name = index +def generate_name_and_calculate_amount(gl_entries, start, receivable_accounts): + for index, entry in enumerate(gl_entries, 0): + entry.name = start + index + if entry.account in receivable_accounts: + entry.account_type = "Receivable" + entry.amount = entry.debit - entry.credit + entry.amount_in_account_currency = ( + entry.debit_in_account_currency - entry.credit_in_account_currency + ) + else: + entry.account_type = "Payable" + entry.amount = entry.credit - entry.debit + entry.amount_in_account_currency = ( + entry.credit_in_account_currency - entry.debit_in_account_currency + ) def get_columns(): @@ -48,6 +61,9 @@ def get_columns(): "finance_book", ] + if frappe.db.has_column("Payment Ledger Entry", "remarks"): + columns.append("remarks") + dimensions_and_defaults = get_dimensions() if dimensions_and_defaults: for dimension in dimensions_and_defaults[0]: @@ -81,6 +97,14 @@ def insert_chunk_into_payment_ledger(insert_query, gl_entries): def execute(): + """ + Description: + Migrate records from `tabGL Entry` to `tabPayment Ledger Entry`. + Patch is non-resumable. if patch failed or is terminatted abnormally, clear 'tabPayment Ledger Entry' table manually before re-running. Re-running is safe only during V13->V14 update. + + Note: Post successful migration to V14, re-running is NOT-SAFE and SHOULD NOT be attempted. + """ + if frappe.reload_doc("accounts", "doctype", "payment_ledger_entry"): # create accounting dimension fields in Payment Ledger create_accounting_dimension_fields() @@ -89,52 +113,79 @@ def execute(): account = qb.DocType("Account") ifelse = CustomFunction("IF", ["condition", "then", "else"]) - gl_entries = ( - qb.from_(gl) - .inner_join(account) - .on((gl.account == account.name) & (account.account_type.isin(["Receivable", "Payable"]))) - .select( - gl.star, - ConstantColumn(1).as_("docstatus"), - account.account_type.as_("account_type"), - IfNull( - ifelse(gl.against_voucher_type == "", None, gl.against_voucher_type), gl.voucher_type - ).as_("against_voucher_type"), - IfNull(ifelse(gl.against_voucher == "", None, gl.against_voucher), gl.voucher_no).as_( - "against_voucher_no" - ), - # convert debit/credit to amount - Case() - .when(account.account_type == "Receivable", gl.debit - gl.credit) - .else_(gl.credit - gl.debit) - .as_("amount"), - # convert debit/credit in account currency to amount in account currency - Case() - .when( - account.account_type == "Receivable", - gl.debit_in_account_currency - gl.credit_in_account_currency, - ) - .else_(gl.credit_in_account_currency - gl.debit_in_account_currency) - .as_("amount_in_account_currency"), - ) - .where(gl.is_cancelled == 0) - .orderby(gl.creation) + # Get Records Count + relavant_accounts = ( + qb.from_(account) + .select(account.name, account.account_type) + .where((account.account_type == "Receivable") | (account.account_type == "Payable")) + .orderby(account.name) .run(as_dict=True) ) - # primary key(name) for payment ledger records - generate_name_for_payment_ledger_entries(gl_entries) - - # split data into chunks - chunk_size = 1000 - try: - for i in range(0, len(gl_entries), chunk_size): - insert_query = build_insert_query() - insert_chunk_into_payment_ledger(insert_query, gl_entries[i : i + chunk_size]) - frappe.db.commit() - except Exception as err: - frappe.db.rollback() - ple = qb.DocType("Payment Ledger Entry") - qb.from_(ple).delete().where(ple.docstatus >= 0).run() - frappe.db.commit() - raise err + receivable_accounts = [x.name for x in relavant_accounts if x.account_type == "Receivable"] + accounts = [x.name for x in relavant_accounts] + + un_processed = ( + qb.from_(gl) + .select(Count(gl.name)) + .where((gl.is_cancelled == 0) & (gl.account.isin(accounts))) + .run() + )[0][0] + + if un_processed: + print(f"Migrating {un_processed} GL Entries to Payment Ledger") + + processed = 0 + last_update_percent = 0 + batch_size = 5000 + last_name = None + + while True: + if last_name: + where_clause = gl.name.gt(last_name) & gl.account.isin(accounts) & gl.is_cancelled == 0 + else: + where_clause = gl.account.isin(accounts) & gl.is_cancelled == 0 + + gl_entries = ( + qb.from_(gl) + .select( + gl.star, + ConstantColumn(1).as_("docstatus"), + IfNull( + ifelse(gl.against_voucher_type == "", None, gl.against_voucher_type), gl.voucher_type + ).as_("against_voucher_type"), + IfNull(ifelse(gl.against_voucher == "", None, gl.against_voucher), gl.voucher_no).as_( + "against_voucher_no" + ), + ) + .where(where_clause) + .orderby(gl.name) + .limit(batch_size) + .run(as_dict=True) + ) + + if gl_entries: + last_name = gl_entries[-1].name + + # add primary key(name) and calculate based on debit and credit + generate_name_and_calculate_amount(gl_entries, processed, receivable_accounts) + + try: + insert_query = build_insert_query() + insert_chunk_into_payment_ledger(insert_query, gl_entries) + frappe.db.commit() + + processed += len(gl_entries) + + # Progress message + percent = flt((processed / un_processed) * 100, 2) + if percent - last_update_percent > 1: + print(f"{percent}% ({processed}) records processed") + last_update_percent = percent + + except Exception as err: + print("Migration Failed. Clear `tabPayment Ledger Entry` table before re-running") + raise err + else: + break + print(f"{processed} records have been sucessfully migrated") diff --git a/erpnext/patches/v14_0/migrate_remarks_from_gl_to_payment_ledger.py b/erpnext/patches/v14_0/migrate_remarks_from_gl_to_payment_ledger.py deleted file mode 100644 index fd2a2a39cc68..000000000000 --- a/erpnext/patches/v14_0/migrate_remarks_from_gl_to_payment_ledger.py +++ /dev/null @@ -1,81 +0,0 @@ -import frappe -from frappe import qb -from frappe.utils import create_batch - - -def remove_duplicate_entries(pl_entries): - unique_vouchers = set() - for x in pl_entries: - unique_vouchers.add( - (x.company, x.account, x.party_type, x.party, x.voucher_type, x.voucher_no, x.gle_remarks) - ) - - entries = [] - for x in unique_vouchers: - entries.append( - frappe._dict( - company=x[0], - account=x[1], - party_type=x[2], - party=x[3], - voucher_type=x[4], - voucher_no=x[5], - gle_remarks=x[6], - ) - ) - return entries - - -def execute(): - if frappe.reload_doc("accounts", "doctype", "payment_ledger_entry"): - - gle = qb.DocType("GL Entry") - ple = qb.DocType("Payment Ledger Entry") - - # get ple and their remarks from GL Entry - pl_entries = ( - qb.from_(ple) - .left_join(gle) - .on( - (ple.account == gle.account) - & (ple.party_type == gle.party_type) - & (ple.party == gle.party) - & (ple.voucher_type == gle.voucher_type) - & (ple.voucher_no == gle.voucher_no) - & (ple.company == gle.company) - ) - .select( - ple.company, - ple.account, - ple.party_type, - ple.party, - ple.voucher_type, - ple.voucher_no, - gle.remarks.as_("gle_remarks"), - ) - .where((ple.delinked == 0) & (gle.is_cancelled == 0)) - .run(as_dict=True) - ) - - pl_entries = remove_duplicate_entries(pl_entries) - - if pl_entries: - # split into multiple batches, update and commit for each batch - batch_size = 1000 - for batch in create_batch(pl_entries, batch_size): - for entry in batch: - query = ( - qb.update(ple) - .set(ple.remarks, entry.gle_remarks) - .where( - (ple.company == entry.company) - & (ple.account == entry.account) - & (ple.party_type == entry.party_type) - & (ple.party == entry.party) - & (ple.voucher_type == entry.voucher_type) - & (ple.voucher_no == entry.voucher_no) - ) - ) - query.run() - - frappe.db.commit() diff --git a/erpnext/patches/v14_0/set_pick_list_status.py b/erpnext/patches/v14_0/set_pick_list_status.py new file mode 100644 index 000000000000..eea5745c23a9 --- /dev/null +++ b/erpnext/patches/v14_0/set_pick_list_status.py @@ -0,0 +1,40 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + + +import frappe +from pypika.terms import ExistsCriterion + + +def execute(): + pl = frappe.qb.DocType("Pick List") + se = frappe.qb.DocType("Stock Entry") + dn = frappe.qb.DocType("Delivery Note") + + ( + frappe.qb.update(pl).set( + pl.status, + ( + frappe.qb.terms.Case() + .when(pl.docstatus == 0, "Draft") + .when(pl.docstatus == 2, "Cancelled") + .else_("Completed") + ), + ) + ).run() + + ( + frappe.qb.update(pl) + .set(pl.status, "Open") + .where( + ( + ExistsCriterion( + frappe.qb.from_(se).select(se.name).where((se.docstatus == 1) & (se.pick_list == pl.name)) + ) + | ExistsCriterion( + frappe.qb.from_(dn).select(dn.name).where((dn.docstatus == 1) & (dn.pick_list == pl.name)) + ) + ).negate() + & (pl.docstatus == 1) + ) + ).run() diff --git a/erpnext/patches/v14_0/update_asset_value_for_manual_depr_entries.py b/erpnext/patches/v14_0/update_asset_value_for_manual_depr_entries.py new file mode 100644 index 000000000000..5d7b5cf19c18 --- /dev/null +++ b/erpnext/patches/v14_0/update_asset_value_for_manual_depr_entries.py @@ -0,0 +1,38 @@ +import frappe +from frappe.query_builder.functions import IfNull, Sum + + +def execute(): + asset = frappe.qb.DocType("Asset") + gle = frappe.qb.DocType("GL Entry") + aca = frappe.qb.DocType("Asset Category Account") + company = frappe.qb.DocType("Company") + + asset_total_depr_value_map = ( + frappe.qb.from_(gle) + .join(asset) + .on(gle.against_voucher == asset.name) + .join(aca) + .on((aca.parent == asset.asset_category) & (aca.company_name == asset.company)) + .join(company) + .on(company.name == asset.company) + .select(Sum(gle.debit).as_("value"), asset.name.as_("asset_name")) + .where( + gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account) + ) + .where(gle.debit != 0) + .where(gle.is_cancelled == 0) + .where(asset.docstatus == 1) + .where(asset.calculate_depreciation == 0) + .groupby(asset.name) + ) + + frappe.qb.update(asset).join(asset_total_depr_value_map).on( + asset_total_depr_value_map.asset_name == asset.name + ).set( + asset.value_after_depreciation, asset.value_after_depreciation - asset_total_depr_value_map.value + ).where( + asset.docstatus == 1 + ).where( + asset.calculate_depreciation == 0 + ).run() diff --git a/erpnext/patches/v14_0/update_entry_type_for_journal_entry.py b/erpnext/patches/v14_0/update_entry_type_for_journal_entry.py new file mode 100644 index 000000000000..bce92555577e --- /dev/null +++ b/erpnext/patches/v14_0/update_entry_type_for_journal_entry.py @@ -0,0 +1,18 @@ +import frappe + + +def execute(): + """ + Update Propery Setters for Journal Entry with new 'Entry Type' + """ + new_voucher_type = "Exchange Gain Or Loss" + prop_setter = frappe.db.get_list( + "Property Setter", + filters={"doc_type": "Journal Entry", "field_name": "voucher_type", "property": "options"}, + ) + if prop_setter: + property_setter_doc = frappe.get_doc("Property Setter", prop_setter[0].get("name")) + + if new_voucher_type not in property_setter_doc.value.split("\n"): + property_setter_doc.value += "\n" + new_voucher_type + property_setter_doc.save() diff --git a/erpnext/patches/v14_0/update_opportunity_currency_fields.py b/erpnext/patches/v14_0/update_opportunity_currency_fields.py index b803e9fa2dd2..af736919d83b 100644 --- a/erpnext/patches/v14_0/update_opportunity_currency_fields.py +++ b/erpnext/patches/v14_0/update_opportunity_currency_fields.py @@ -7,6 +7,9 @@ def execute(): + frappe.reload_doc( + "accounts", "doctype", "currency_exchange_settings" + ) # get_exchange_rate depends on Currency Exchange Settings frappe.reload_doctype("Opportunity") opportunities = frappe.db.get_list( "Opportunity", diff --git a/erpnext/patches/v14_0/update_reference_due_date_in_journal_entry.py b/erpnext/patches/v14_0/update_reference_due_date_in_journal_entry.py new file mode 100644 index 000000000000..70003125a54b --- /dev/null +++ b/erpnext/patches/v14_0/update_reference_due_date_in_journal_entry.py @@ -0,0 +1,12 @@ +import frappe + + +def execute(): + if frappe.db.get_value("Journal Entry Account", {"reference_due_date": ""}): + frappe.db.sql( + """ + UPDATE `tabJournal Entry Account` + SET reference_due_date = NULL + WHERE reference_due_date = '' + """ + ) diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json index 37d98ad8ea1b..ba7aa850825f 100644 --- a/erpnext/projects/doctype/project/project.json +++ b/erpnext/projects/doctype/project/project.json @@ -408,7 +408,7 @@ "depends_on": "eval:(doc.frequency == \"Daily\" && doc.collect_progress == true)", "fieldname": "daily_time_to_send", "fieldtype": "Time", - "label": "Time to send" + "label": "Daily Time to send" }, { "depends_on": "eval:(doc.frequency == \"Weekly\" && doc.collect_progress == true)", @@ -421,7 +421,7 @@ "depends_on": "eval:(doc.frequency == \"Weekly\" && doc.collect_progress == true)", "fieldname": "weekly_time_to_send", "fieldtype": "Time", - "label": "Time to send" + "label": "Weekly Time to send" }, { "fieldname": "column_break_45", @@ -451,7 +451,7 @@ "index_web_pages_for_search": 1, "links": [], "max_attachments": 4, - "modified": "2022-06-23 16:45:06.108499", + "modified": "2023-02-14 04:54:25.819620", "modified_by": "Administrator", "module": "Projects", "name": "Project", @@ -497,4 +497,4 @@ "timeline_field": "customer", "title_field": "project_name", "track_seen": 1 -} +} \ No newline at end of file diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index fa507854a695..1a7834257caf 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -97,7 +97,7 @@ def validate_status(self): if frappe.db.get_value("Task", d.task, "status") not in ("Completed", "Cancelled"): frappe.throw( _( - "Cannot complete task {0} as its dependant task {1} are not ccompleted / cancelled." + "Cannot complete task {0} as its dependant task {1} are not completed / cancelled." ).format(frappe.bold(self.name), frappe.bold(d.task)) ) diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index e098c3e3c45e..828a55e7bc13 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -161,6 +161,37 @@ def test_to_time(self): to_time = timesheet.time_logs[0].to_time self.assertEqual(to_time, add_to_date(from_time, hours=2, as_datetime=True)) + def test_per_billed_hours(self): + """If amounts are 0, per_billed should be calculated based on hours.""" + ts = frappe.new_doc("Timesheet") + ts.total_billable_amount = 0 + ts.total_billed_amount = 0 + ts.total_billable_hours = 2 + + ts.total_billed_hours = 0.5 + ts.calculate_percentage_billed() + self.assertEqual(ts.per_billed, 25) + + ts.total_billed_hours = 2 + ts.calculate_percentage_billed() + self.assertEqual(ts.per_billed, 100) + + def test_per_billed_amount(self): + """If amounts are > 0, per_billed should be calculated based on amounts, regardless of hours.""" + ts = frappe.new_doc("Timesheet") + ts.total_billable_hours = 2 + ts.total_billed_hours = 1 + ts.total_billable_amount = 200 + ts.total_billed_amount = 50 + ts.calculate_percentage_billed() + self.assertEqual(ts.per_billed, 25) + + ts.total_billed_hours = 3 + ts.total_billable_amount = 200 + ts.total_billed_amount = 200 + ts.calculate_percentage_billed() + self.assertEqual(ts.per_billed, 100) + def make_timesheet( employee, diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js index a376bf46a5bd..d1d07a79d678 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.js +++ b/erpnext/projects/doctype/timesheet/timesheet.js @@ -5,6 +5,8 @@ frappe.ui.form.on("Timesheet", { setup: function(frm) { frappe.require("/assets/erpnext/js/projects/timer.js"); + frm.ignore_doctypes_on_cancel_all = ['Sales Invoice']; + frm.fields_dict.employee.get_query = function() { return { filters:{ diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json index 0cce129034e6..468300661a04 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.json +++ b/erpnext/projects/doctype/timesheet/timesheet.json @@ -282,21 +282,21 @@ { "fieldname": "base_total_costing_amount", "fieldtype": "Currency", - "label": "Total Costing Amount", + "label": "Base Total Costing Amount", "print_hide": 1, "read_only": 1 }, { "fieldname": "base_total_billable_amount", "fieldtype": "Currency", - "label": "Total Billable Amount", + "label": "Base Total Billable Amount", "print_hide": 1, "read_only": 1 }, { "fieldname": "base_total_billed_amount", "fieldtype": "Currency", - "label": "Total Billed Amount", + "label": "Base Total Billed Amount", "print_hide": 1, "read_only": 1 }, @@ -311,10 +311,11 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2022-06-15 22:08:53.930200", + "modified": "2023-02-14 04:55:41.735991", "modified_by": "Administrator", "module": "Projects", "name": "Timesheet", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -388,5 +389,6 @@ ], "sort_field": "modified", "sort_order": "ASC", + "states": [], "title_field": "title" } \ No newline at end of file diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index b9bb37a05cf0..4a27807e4ec9 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -58,6 +58,8 @@ def calculate_percentage_billed(self): self.per_billed = 0 if self.total_billed_amount > 0 and self.total_billable_amount > 0: self.per_billed = (self.total_billed_amount * 100) / self.total_billable_amount + elif self.total_billed_hours > 0 and self.total_billable_hours > 0: + self.per_billed = (self.total_billed_hours * 100) / self.total_billable_hours def update_billing_hours(self, args): if args.is_billable: diff --git a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js index 9ef8ce6b63e0..0cda93880fa5 100644 --- a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js @@ -5,7 +5,12 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager { Object.assign(this, opts); this.dialog_manager = new erpnext.accounts.bank_reconciliation.DialogManager( this.company, - this.bank_account + this.bank_account, + this.bank_statement_from_date, + this.bank_statement_to_date, + this.filter_by_reference_date, + this.from_reference_date, + this.to_reference_date ); this.make_dt(); } @@ -17,6 +22,8 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager { "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions", args: { bank_account: this.bank_account, + from_date: this.bank_statement_from_date, + to_date: this.bank_statement_to_date }, callback: function (response) { me.format_data(response.message); @@ -175,6 +182,9 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager { ); } else { this.transactions.splice(transaction_index, 1); + for (const [k, v] of Object.entries(this.transaction_dt_map)) { + if (v > transaction_index) this.transaction_dt_map[k] = v - 1; + } } this.datatable.refresh(this.transactions, this.columns); diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index b5e6ab871d12..1271e38049a0 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -1,12 +1,16 @@ frappe.provide("erpnext.accounts.bank_reconciliation"); erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { - constructor(company, bank_account) { + constructor(company, bank_account, bank_statement_from_date, bank_statement_to_date, filter_by_reference_date, from_reference_date, to_reference_date) { this.bank_account = bank_account; this.company = company; this.make_dialog(); + this.bank_statement_from_date = bank_statement_from_date; + this.bank_statement_to_date = bank_statement_to_date; + this.filter_by_reference_date = filter_by_reference_date; + this.from_reference_date = from_reference_date; + this.to_reference_date = to_reference_date; } - show_dialog(bank_transaction_name, update_dt_cards) { this.bank_transaction_name = bank_transaction_name; this.update_dt_cards = update_dt_cards; @@ -16,7 +20,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { doctype: "Bank Transaction", filters: { name: this.bank_transaction_name }, fieldname: [ - "date as reference_date", + "date", "deposit", "withdrawal", "currency", @@ -29,19 +33,32 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { "party", "unallocated_amount", "allocated_amount", + "transaction_type", ], }, callback: (r) => { if (r.message) { this.bank_transaction = r.message; r.message.payment_entry = 1; + r.message.journal_entry = 1; this.dialog.set_values(r.message); + this.copy_data_to_voucher(); this.dialog.show(); } }, }); } + copy_data_to_voucher() { + let copied = { + reference_number: this.bank_transaction.reference_number || this.bank_transaction.description, + posting_date: this.bank_transaction.date, + reference_date: this.bank_transaction.date, + mode_of_payment: this.bank_transaction.transaction_type, + }; + this.dialog.set_values(copied); + } + get_linked_vouchers(document_types) { frappe.call({ method: @@ -49,6 +66,11 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { args: { bank_transaction_name: this.bank_transaction_name, document_types: document_types, + from_date: this.bank_statement_from_date, + to_date: this.bank_statement_to_date, + filter_by_reference_date: this.filter_by_reference_date, + from_reference_date:this.from_reference_date, + to_reference_date:this.to_reference_date }, callback: (result) => { @@ -67,8 +89,8 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { row[2], reference_date, format_currency(row[3], row[9]), - row[6], row[4], + row[6], ]); }); this.get_dt_columns(); @@ -94,7 +116,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { { name: __("Document Name"), editable: false, - width: 150, + width: 1, }, { name: __("Reference Date"), @@ -102,20 +124,19 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { width: 120, }, { - name: __("Amount"), + name: __("Remaining"), editable: false, width: 100, }, { - name: __("Party"), + name: __("Reference Number"), editable: false, - width: 120, + width: 200, }, - { - name: __("Reference Number"), + name: __("Party"), editable: false, - width: 140, + width: 100, }, ]; } @@ -209,6 +230,16 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "exact_match", onchange: () => this.update_options(), }, + { + fieldname: "column_break_5", + fieldtype: "Column Break", + }, + { + fieldtype: "Check", + label: "Bank Transaction", + fieldname: "bank_transaction", + onchange: () => this.update_options(), + }, { fieldtype: "Section Break", fieldname: "section_break_1", @@ -274,7 +305,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldtype: "Column Break", }, { - default: "Journal Entry Type", + default: "Bank Entry", fieldname: "journal_entry_type", fieldtype: "Select", label: "Journal Entry Type", @@ -349,50 +380,54 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldtype: "Section Break", fieldname: "details_section", label: "Transaction Details", - collapsible: 1, + }, + { + fieldname: "date", + fieldtype: "Date", + label: "Date", + read_only: 1, }, { fieldname: "deposit", fieldtype: "Currency", label: "Deposit", - options: "currency", + options: "account_currency", read_only: 1, }, { fieldname: "withdrawal", fieldtype: "Currency", label: "Withdrawal", - options: "currency", + options: "account_currency", read_only: 1, }, { - fieldname: "description", - fieldtype: "Small Text", - label: "Description", + fieldname: "column_break_17", + fieldtype: "Column Break", read_only: 1, }, { - fieldname: "column_break_17", - fieldtype: "Column Break", + fieldname: "description", + fieldtype: "Small Text", + label: "Description", read_only: 1, }, { fieldname: "allocated_amount", fieldtype: "Currency", label: "Allocated Amount", - options: "Currency", + options: "account_currency", read_only: 1, }, - { fieldname: "unallocated_amount", fieldtype: "Currency", label: "Unallocated Amount", - options: "Currency", + options: "account_currency", read_only: 1, }, { - fieldname: "currency", + fieldname: "account_currency", fieldtype: "Link", label: "Currency", options: "Currency", diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js index c1fe72bb4889..d943126018a6 100644 --- a/erpnext/public/js/controllers/accounts.js +++ b/erpnext/public/js/controllers/accounts.js @@ -55,6 +55,14 @@ frappe.ui.form.on(cur_frm.doctype, { }, allocate_advances_automatically: function(frm) { + frm.trigger('fetch_advances'); + }, + + only_include_allocated_payments: function(frm) { + frm.trigger('fetch_advances'); + }, + + fetch_advances: function(frm) { if(frm.doc.allocate_advances_automatically) { frappe.call({ doc: frm.doc, @@ -143,6 +151,12 @@ var get_payment_mode_account = function(frm, mode_of_payment, callback) { cur_frm.cscript.account_head = function(doc, cdt, cdn) { var d = locals[cdt][cdn]; + + if (doc.docstatus == 1) { + // Should not trigger any changes on change post submit + return; + } + if(!d.charge_type && d.account_head){ frappe.msgprint(__("Please select Charge Type first")); frappe.model.set_value(cdt, cdn, "account_head", ""); diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 1f8a5e39f25c..8efc47d18e5d 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -91,6 +91,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } _calculate_taxes_and_totals() { + const is_quotation = this.frm.doc.doctype == "Quotation"; + this.frm.doc._items = is_quotation ? this.filtered_items() : this.frm.doc.items; + this.validate_conversion_rate(); this.calculate_item_values(); this.initialize_taxes(); @@ -122,24 +125,25 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { calculate_item_values() { var me = this; if (!this.discount_amount_applied) { - $.each(this.frm.doc["items"] || [], function(i, item) { + for (const item of this.frm.doc._items || []) { frappe.model.round_floats_in(item); item.net_rate = item.rate; + item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty; - if ((!item.qty) && me.frm.doc.is_return) { - item.amount = flt(item.rate * -1, precision("amount", item)); - } else if ((!item.qty) && me.frm.doc.is_debit_note) { - item.amount = flt(item.rate, precision("amount", item)); - } else { - item.amount = flt(item.rate * item.qty, precision("amount", item)); + if (!(me.frm.doc.is_return || me.frm.doc.is_debit_note)) { + item.net_amount = item.amount = flt(item.rate * item.qty, precision("amount", item)); + } + else { + // allow for '0' qty on Credit/Debit notes + let qty = item.qty || (me.frm.doc.is_debit_note ? 1 : -1); + item.net_amount = item.amount = flt(item.rate * qty, precision("amount", item)); } - item.net_amount = item.amount; item.item_tax_amount = 0.0; item.total_weight = flt(item.weight_per_unit * item.stock_qty); me.set_in_company_currency(item, ["price_list_rate", "rate", "amount", "net_rate", "net_amount"]); - }); + } } } @@ -205,7 +209,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { }); if(has_inclusive_tax==false) return; - $.each(me.frm.doc["items"] || [], function(n, item) { + $.each(me.frm.doc._items || [], function(n, item) { var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); var cumulated_tax_fraction = 0.0; var total_inclusive_tax_amount_per_qty = 0; @@ -276,7 +280,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { var me = this; this.frm.doc.total_qty = this.frm.doc.total = this.frm.doc.base_total = this.frm.doc.net_total = this.frm.doc.base_net_total = 0.0; - $.each(this.frm.doc["items"] || [], function(i, item) { + $.each(this.frm.doc._items || [], function(i, item) { me.frm.doc.total += item.amount; me.frm.doc.total_qty += item.qty; me.frm.doc.base_total += item.base_amount; @@ -329,7 +333,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } }); - $.each(this.frm.doc["items"] || [], function(n, item) { + $.each(this.frm.doc._items || [], function(n, item) { var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); $.each(me.frm.doc["taxes"] || [], function(i, tax) { // tax_amount represents the amount of tax for the current step @@ -338,7 +342,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { // Adjust divisional loss to the last item if (tax.charge_type == "Actual") { actual_tax_dict[tax.idx] -= current_tax_amount; - if (n == me.frm.doc["items"].length - 1) { + if (n == me.frm.doc._items.length - 1) { current_tax_amount += actual_tax_dict[tax.idx]; } } @@ -375,7 +379,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } // set precision in the last item iteration - if (n == me.frm.doc["items"].length - 1) { + if (n == me.frm.doc._items.length - 1) { me.round_off_totals(tax); me.set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"]); @@ -598,10 +602,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { _cleanup() { this.frm.doc.base_in_words = this.frm.doc.in_words = ""; + let items = this.frm.doc._items; - if(this.frm.doc["items"] && this.frm.doc["items"].length) { - if(!frappe.meta.get_docfield(this.frm.doc["items"][0].doctype, "item_tax_amount", this.frm.doctype)) { - $.each(this.frm.doc["items"] || [], function(i, item) { + if(items && items.length) { + if(!frappe.meta.get_docfield(items[0].doctype, "item_tax_amount", this.frm.doctype)) { + $.each(items || [], function(i, item) { delete item["item_tax_amount"]; }); } @@ -654,7 +659,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { var net_total = 0; // calculate item amount after Discount Amount if (total_for_discount_amount) { - $.each(this.frm.doc["items"] || [], function(i, item) { + $.each(this.frm.doc._items || [], function(i, item) { distributed_amount = flt(me.frm.doc.discount_amount) * item.net_amount / total_for_discount_amount; item.net_amount = flt(item.net_amount - distributed_amount, precision("base_amount", item)); @@ -662,7 +667,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { // discount amount rounding loss adjustment if no taxes if ((!(me.frm.doc.taxes || []).length || total_for_discount_amount==me.frm.doc.net_total || (me.frm.doc.apply_discount_on == "Net Total")) - && i == (me.frm.doc.items || []).length - 1) { + && i == (me.frm.doc._items || []).length - 1) { var discount_amount_loss = flt(me.frm.doc.net_total - net_total - me.frm.doc.discount_amount, precision("net_total")); item.net_amount = flt(item.net_amount + discount_amount_loss, @@ -891,4 +896,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } } + + filtered_items() { + return this.frm.doc.items.filter(item => !item["is_alternative"]); + } }; diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index aa57bc2168e2..07d1955bfafd 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -488,7 +488,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe () => { var d = locals[cdt][cdn]; me.add_taxes_from_item_tax_template(d.item_tax_rate); - if (d.free_item_data) { + if (d.free_item_data && d.free_item_data.length > 0) { me.apply_product_discount(d); } }, @@ -1473,6 +1473,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe "parenttype": d.parenttype, "parent": d.parent, "pricing_rules": d.pricing_rules, + "is_free_item": d.is_free_item, "warehouse": d.warehouse, "serial_no": d.serial_no, "batch_no": d.batch_no, @@ -1690,6 +1691,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe var me = this; var valid = true; + if (frappe.flags.ignore_company_party_validation) { + return valid; + } + $.each(["company", "customer"], function(i, fieldname) { if(frappe.meta.has_field(me.frm.doc.doctype, fieldname) && me.frm.doc.doctype != "Purchase Order") { if (!me.frm.doc[fieldname]) { @@ -1879,31 +1884,73 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe get_advances() { if(!this.frm.is_return) { + var me = this; return this.frm.call({ method: "set_advances", doc: this.frm.doc, callback: function(r, rt) { refresh_field("advances"); + me.frm.dirty(); } }) } } make_payment_entry() { + let via_journal_entry = this.frm.doc.__onload && this.frm.doc.__onload.make_payment_via_journal_entry; + if(this.has_discount_in_schedule() && !via_journal_entry) { + // If early payment discount is applied, ask user for reference date + this.prompt_user_for_reference_date(); + } else { + this.make_mapped_payment_entry(); + } + } + + make_mapped_payment_entry(args) { + var me = this; + args = args || { "dt": this.frm.doc.doctype, "dn": this.frm.doc.name }; return frappe.call({ - method: cur_frm.cscript.get_method_for_payment(), - args: { - "dt": cur_frm.doc.doctype, - "dn": cur_frm.doc.name - }, + method: me.get_method_for_payment(), + args: args, callback: function(r) { var doclist = frappe.model.sync(r.message); frappe.set_route("Form", doclist[0].doctype, doclist[0].name); - // cur_frm.refresh_fields() } }); } + prompt_user_for_reference_date(){ + var me = this; + frappe.prompt({ + label: __("Cheque/Reference Date"), + fieldname: "reference_date", + fieldtype: "Date", + reqd: 1, + }, (values) => { + let args = { + "dt": me.frm.doc.doctype, + "dn": me.frm.doc.name, + "reference_date": values.reference_date + } + me.make_mapped_payment_entry(args); + }, + __("Reference Date for Early Payment Discount"), + __("Continue") + ); + } + + has_discount_in_schedule() { + let is_eligible = in_list( + ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"], + this.frm.doctype + ); + let has_payment_schedule = this.frm.doc.payment_schedule && this.frm.doc.payment_schedule.length; + if(!is_eligible || !has_payment_schedule) return false; + + let has_discount = this.frm.doc.payment_schedule.some(row => row.discount_date); + return has_discount; + } + make_quality_inspection() { let data = []; const fields = [ diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js index 14a088e405c9..7b230af26990 100644 --- a/erpnext/public/js/erpnext.bundle.js +++ b/erpnext/public/js/erpnext.bundle.js @@ -13,6 +13,7 @@ import "./help_links"; import "./agriculture/ternary_plot"; import "./templates/item_quick_entry.html"; import "./utils/item_quick_entry"; +import "./utils/contact_address_quick_entry"; import "./utils/customer_quick_entry"; import "./utils/supplier_quick_entry"; import "./call_popup/call_popup"; diff --git a/erpnext/public/js/templates/crm_notes.html b/erpnext/public/js/templates/crm_notes.html index fddeb1c1cc2f..53df93307840 100644 --- a/erpnext/public/js/templates/crm_notes.html +++ b/erpnext/public/js/templates/crm_notes.html @@ -17,7 +17,7 @@ {{ frappe.avatar(notes[i].added_by) }}
-
+
{{ strip_html(notes[i].added_by) }}
diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 6d64625270bf..58aa8d7da233 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -221,9 +221,9 @@ $.extend(erpnext.utils, { callback: function(r) { if (r.message && r.message.length) { r.message.forEach((dimension) => { - let found = filters.some(el => el.fieldname === dimension['fieldname']); + let existing_filter = filters.filter(el => el.fieldname === dimension['fieldname']); - if (!found) { + if (!existing_filter.length) { filters.splice(index, 0, { "fieldname": dimension["fieldname"], "label": __(dimension["doctype"]), @@ -232,6 +232,11 @@ $.extend(erpnext.utils, { return frappe.db.get_link_options(dimension["doctype"], txt); }, }); + } else { + existing_filter[0]['fieldtype'] = "MultiSelectList"; + existing_filter[0]['get_data'] = function(txt) { + return frappe.db.get_link_options(dimension["doctype"], txt); + } } }); } @@ -333,8 +338,18 @@ $.extend(erpnext.utils, { } frappe.ui.form.make_quick_entry(doctype, null, null, new_doc); }); - } + }, + + // check if payments app is installed on site, if not warn user. + check_payments_app: () => { + if (frappe.boot.versions && !frappe.boot.versions.payments) { + const marketplace_link = 'Marketplace' + const github_link = 'GitHub' + const msg = __("payments app is not installed. Please install it from {0} or {1}", [marketplace_link, github_link]) + frappe.msgprint(msg); + } + }, }); erpnext.utils.select_alternate_items = function(opts) { @@ -481,7 +496,20 @@ erpnext.utils.update_child_items = function(opts) { const child_meta = frappe.get_meta(`${frm.doc.doctype} Item`); const get_precision = (fieldname) => child_meta.fields.find(f => f.fieldname == fieldname).precision; - this.data = []; + this.data = frm.doc[opts.child_docname].map((d) => { + return { + "docname": d.name, + "name": d.name, + "item_code": d.item_code, + "delivery_date": d.delivery_date, + "schedule_date": d.schedule_date, + "conversion_factor": d.conversion_factor, + "qty": d.qty, + "rate": d.rate, + "uom": d.uom + } + }); + const fields = [{ fieldtype:'Data', fieldname:"docname", @@ -578,7 +606,7 @@ erpnext.utils.update_child_items = function(opts) { }) } - const dialog = new frappe.ui.Dialog({ + new frappe.ui.Dialog({ title: __("Update Items"), fields: [ { @@ -614,24 +642,7 @@ erpnext.utils.update_child_items = function(opts) { refresh_field("items"); }, primary_action_label: __('Update') - }); - - frm.doc[opts.child_docname].forEach(d => { - dialog.fields_dict.trans_items.df.data.push({ - "docname": d.name, - "name": d.name, - "item_code": d.item_code, - "delivery_date": d.delivery_date, - "schedule_date": d.schedule_date, - "conversion_factor": d.conversion_factor, - "qty": d.qty, - "rate": d.rate, - "uom": d.uom - }); - this.data = dialog.fields_dict.trans_items.df.data; - dialog.fields_dict.trans_items.grid.refresh(); - }) - dialog.show(); + }).show(); } erpnext.utils.map_current_doc = function(opts) { diff --git a/erpnext/public/js/utils/contact_address_quick_entry.js b/erpnext/public/js/utils/contact_address_quick_entry.js new file mode 100644 index 000000000000..adabf08c2036 --- /dev/null +++ b/erpnext/public/js/utils/contact_address_quick_entry.js @@ -0,0 +1,100 @@ +frappe.provide('frappe.ui.form'); + +frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm extends frappe.ui.form.QuickEntryForm { + constructor(doctype, after_insert, init_callback, doc, force) { + super(doctype, after_insert, init_callback, doc, force); + this.skip_redirect_on_error = true; + } + + render_dialog() { + this.mandatory = this.mandatory.concat(this.get_variant_fields()); + super.render_dialog(); + } + + insert() { + /** + * Using alias fieldnames because the doctype definition define "email_id" and "mobile_no" as readonly fields. + * Therefor, resulting in the fields being "hidden". + */ + const map_field_names = { + "email_address": "email_id", + "mobile_number": "mobile_no", + }; + + Object.entries(map_field_names).forEach(([fieldname, new_fieldname]) => { + this.dialog.doc[new_fieldname] = this.dialog.doc[fieldname]; + delete this.dialog.doc[fieldname]; + }); + + return super.insert(); + } + + get_variant_fields() { + var variant_fields = [{ + fieldtype: "Section Break", + label: __("Primary Contact Details"), + collapsible: 1 + }, + { + label: __("Email Id"), + fieldname: "email_address", + fieldtype: "Data", + options: "Email", + }, + { + fieldtype: "Column Break" + }, + { + label: __("Mobile Number"), + fieldname: "mobile_number", + fieldtype: "Data" + }, + { + fieldtype: "Section Break", + label: __("Primary Address Details"), + collapsible: 1 + }, + { + label: __("Address Line 1"), + fieldname: "address_line1", + fieldtype: "Data" + }, + { + label: __("Address Line 2"), + fieldname: "address_line2", + fieldtype: "Data" + }, + { + label: __("ZIP Code"), + fieldname: "pincode", + fieldtype: "Data" + }, + { + fieldtype: "Column Break" + }, + { + label: __("City"), + fieldname: "city", + fieldtype: "Data" + }, + { + label: __("State"), + fieldname: "state", + fieldtype: "Data" + }, + { + label: __("Country"), + fieldname: "country", + fieldtype: "Link", + options: "Country" + }, + { + label: __("Customer POS Id"), + fieldname: "customer_pos_id", + fieldtype: "Data", + hidden: 1 + }]; + + return variant_fields; + } +} diff --git a/erpnext/public/js/utils/customer_quick_entry.js b/erpnext/public/js/utils/customer_quick_entry.js index d2c5c721cc48..b2532085f652 100644 --- a/erpnext/public/js/utils/customer_quick_entry.js +++ b/erpnext/public/js/utils/customer_quick_entry.js @@ -1,81 +1,3 @@ frappe.provide('frappe.ui.form'); -frappe.ui.form.CustomerQuickEntryForm = class CustomerQuickEntryForm extends frappe.ui.form.QuickEntryForm { - constructor(doctype, after_insert, init_callback, doc, force) { - super(doctype, after_insert, init_callback, doc, force); - this.skip_redirect_on_error = true; - } - - render_dialog() { - this.mandatory = this.mandatory.concat(this.get_variant_fields()); - super.render_dialog(); - } - - get_variant_fields() { - var variant_fields = [{ - fieldtype: "Section Break", - label: __("Primary Contact Details"), - collapsible: 1 - }, - { - label: __("Email Id"), - fieldname: "email_id", - fieldtype: "Data" - }, - { - fieldtype: "Column Break" - }, - { - label: __("Mobile Number"), - fieldname: "mobile_no", - fieldtype: "Data" - }, - { - fieldtype: "Section Break", - label: __("Primary Address Details"), - collapsible: 1 - }, - { - label: __("Address Line 1"), - fieldname: "address_line1", - fieldtype: "Data" - }, - { - label: __("Address Line 2"), - fieldname: "address_line2", - fieldtype: "Data" - }, - { - label: __("ZIP Code"), - fieldname: "pincode", - fieldtype: "Data" - }, - { - fieldtype: "Column Break" - }, - { - label: __("City"), - fieldname: "city", - fieldtype: "Data" - }, - { - label: __("State"), - fieldname: "state", - fieldtype: "Data" - }, - { - label: __("Country"), - fieldname: "country", - fieldtype: "Link", - options: "Country" - }, - { - label: __("Customer POS Id"), - fieldname: "customer_pos_id", - fieldtype: "Data", - hidden: 1 - }]; - - return variant_fields; - } -} +frappe.ui.form.CustomerQuickEntryForm = frappe.ui.form.ContactAddressQuickEntryForm; diff --git a/erpnext/public/js/utils/supplier_quick_entry.js b/erpnext/public/js/utils/supplier_quick_entry.js index 8d591a96510c..687b01454a22 100644 --- a/erpnext/public/js/utils/supplier_quick_entry.js +++ b/erpnext/public/js/utils/supplier_quick_entry.js @@ -1,77 +1,3 @@ frappe.provide('frappe.ui.form'); -frappe.ui.form.SupplierQuickEntryForm = class SupplierQuickEntryForm extends frappe.ui.form.QuickEntryForm { - constructor(doctype, after_insert, init_callback, doc, force) { - super(doctype, after_insert, init_callback, doc, force); - this.skip_redirect_on_error = true; - } - - render_dialog() { - this.mandatory = this.mandatory.concat(this.get_variant_fields()); - super.render_dialog(); - } - - get_variant_fields() { - var variant_fields = [ - { - fieldtype: "Section Break", - label: __("Primary Contact Details"), - collapsible: 1 - }, - { - label: __("Email Id"), - fieldname: "email_id", - fieldtype: "Data" - }, - { - fieldtype: "Column Break" - }, - { - label: __("Mobile Number"), - fieldname: "mobile_no", - fieldtype: "Data" - }, - { - fieldtype: "Section Break", - label: __("Primary Address Details"), - collapsible: 1 - }, - { - label: __("Address Line 1"), - fieldname: "address_line1", - fieldtype: "Data" - }, - { - label: __("Address Line 2"), - fieldname: "address_line2", - fieldtype: "Data" - }, - { - label: __("ZIP Code"), - fieldname: "pincode", - fieldtype: "Data" - }, - { - fieldtype: "Column Break" - }, - { - label: __("City"), - fieldname: "city", - fieldtype: "Data" - }, - { - label: __("State"), - fieldname: "state", - fieldtype: "Data" - }, - { - label: __("Country"), - fieldname: "country", - fieldtype: "Link", - options: "Country" - } - ]; - - return variant_fields; - } -}; +frappe.ui.form.SupplierQuickEntryForm = frappe.ui.form.ContactAddressQuickEntryForm; diff --git a/erpnext/public/js/website_utils.js b/erpnext/public/js/website_utils.js index b5416065d791..2bb5255eebc7 100644 --- a/erpnext/public/js/website_utils.js +++ b/erpnext/public/js/website_utils.js @@ -3,18 +3,6 @@ if(!window.erpnext) window.erpnext = {}; -// Add / update a new Lead / Communication -// subject, sender, description -frappe.send_message = function(opts, btn) { - return frappe.call({ - type: "POST", - method: "erpnext.templates.utils.send_message", - btn: btn, - args: opts, - callback: opts.callback - }); -}; - erpnext.subscribe_to_newsletter = function(opts, btn) { return frappe.call({ type: "POST", @@ -24,6 +12,3 @@ erpnext.subscribe_to_newsletter = function(opts, btn) { callback: opts.callback }); } - -// for backward compatibility -erpnext.send_message = frappe.send_message; diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js index 107e4a4759d1..b53f339229b2 100644 --- a/erpnext/selling/doctype/customer/customer.js +++ b/erpnext/selling/doctype/customer/customer.js @@ -123,7 +123,7 @@ frappe.ui.form.on("Customer", { frm.add_custom_button(__('Accounting Ledger'), function () { frappe.set_route('query-report', 'General Ledger', - {party_type: 'Customer', party: frm.doc.name}); + {party_type: 'Customer', party: frm.doc.name, party_name: frm.doc.customer_name}); }, __('View')); frm.add_custom_button(__('Pricing Rule'), function () { diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 146ebbd7dfe8..a8665c5d0404 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -275,18 +275,9 @@ def validate_credit_limit_on_change(self): def on_trash(self): if self.customer_primary_contact: - frappe.db.sql( - """ - UPDATE `tabCustomer` - SET - customer_primary_contact=null, - customer_primary_address=null, - mobile_no=null, - email_id=null, - primary_address=null - WHERE name=%(name)s""", - {"name": self.name}, - ) + self.db_set("customer_primary_contact", None) + if self.customer_primary_address: + self.db_set("customer_primary_address", None) delete_contact_and_address("Customer", self.name) if self.lead_name: @@ -460,8 +451,14 @@ def get_nested_links(link_doctype, link_name, ignore_permissions=False): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_customer_list(doctype, txt, searchfield, start, page_len, filters=None): + from frappe.utils.deprecations import deprecation_warning + from erpnext.controllers.queries import get_fields + deprecation_warning( + "`get_customer_list` is deprecated and will be removed in version 15. Use `erpnext.controllers.queries.customer_query` instead." + ) + fields = ["name", "customer_name", "customer_group", "territory"] if frappe.db.get_default("cust_master_name") == "Customer Name": diff --git a/erpnext/selling/doctype/industry_type/industry_type.json b/erpnext/selling/doctype/industry_type/industry_type.json index 6c49f0f6ddab..3c8ab8e47ae1 100644 --- a/erpnext/selling/doctype/industry_type/industry_type.json +++ b/erpnext/selling/doctype/industry_type/industry_type.json @@ -1,123 +1,68 @@ { - "allow_copy": 0, - "allow_import": 1, - "allow_rename": 1, - "autoname": "field:industry", - "beta": 0, - "creation": "2012-03-27 14:36:09", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:industry", + "creation": "2012-03-27 14:36:09", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "industry" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "industry", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Industry", - "length": 0, - "no_copy": 0, - "oldfieldname": "industry", - "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "industry", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Industry", + "oldfieldname": "industry", + "oldfieldtype": "Data", + "reqd": 1, + "unique": 1 } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-flag", - "idx": 1, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2020-09-18 17:26:09.703215", - "modified_by": "Administrator", - "module": "Selling", - "name": "Industry Type", - "owner": "Administrator", + ], + "icon": "fa fa-flag", + "idx": 1, + "links": [], + "modified": "2023-02-10 03:14:40.735763", + "modified_by": "Administrator", + "module": "Selling", + "name": "Industry Type", + "naming_rule": "By fieldname", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User" + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 0, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales Master Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Master Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "track_seen": 0 + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "translated_doctype": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 6b42e4daead2..83fa472d68ba 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -85,11 +85,15 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. } if (doc.docstatus == 1 && !["Lost", "Ordered"].includes(doc.status)) { - this.frm.add_custom_button( - __("Sales Order"), - this.frm.cscript["Make Sales Order"], - __("Create") - ); + if (frappe.boot.sysdefaults.allow_sales_order_creation_for_expired_quotation + || (!doc.valid_till) + || frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) { + this.frm.add_custom_button( + __("Sales Order"), + () => this.make_sales_order(), + __("Create") + ); + } if(doc.status!=="Ordered") { this.frm.add_custom_button(__('Set as Lost'), () => { @@ -141,6 +145,20 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. } + make_sales_order() { + var me = this; + + let has_alternative_item = this.frm.doc.items.some((item) => item.is_alternative); + if (has_alternative_item) { + this.show_alternative_items_dialog(); + } else { + frappe.model.open_mapped_doc({ + method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", + frm: me.frm + }); + } + } + set_dynamic_field_label(){ if (this.frm.doc.quotation_to == "Customer") { @@ -216,17 +234,112 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. } }) } + + show_alternative_items_dialog() { + let me = this; + + const table_fields = [ + { + fieldtype:"Data", + fieldname:"name", + label: __("Name"), + read_only: 1, + }, + { + fieldtype:"Link", + fieldname:"item_code", + options: "Item", + label: __("Item Code"), + read_only: 1, + in_list_view: 1, + columns: 2, + formatter: (value, df, options, doc) => { + return doc.is_alternative ? `${value}` : value; + } + }, + { + fieldtype:"Data", + fieldname:"description", + label: __("Description"), + in_list_view: 1, + read_only: 1, + }, + { + fieldtype:"Currency", + fieldname:"amount", + label: __("Amount"), + options: "currency", + in_list_view: 1, + read_only: 1, + }, + { + fieldtype:"Check", + fieldname:"is_alternative", + label: __("Is Alternative"), + read_only: 1, + }]; + + + this.data = this.frm.doc.items.filter( + (item) => item.is_alternative || item.has_alternative_item + ).map((item) => { + return { + "name": item.name, + "item_code": item.item_code, + "description": item.description, + "amount": item.amount, + "is_alternative": item.is_alternative, + } + }); + + const dialog = new frappe.ui.Dialog({ + title: __("Select Alternative Items for Sales Order"), + fields: [ + { + fieldname: "info", + fieldtype: "HTML", + read_only: 1 + }, + { + fieldname: "alternative_items", + fieldtype: "Table", + cannot_add_rows: true, + cannot_delete_rows: true, + in_place_edit: true, + reqd: 1, + data: this.data, + description: __("Select an item from each set to be used in the Sales Order."), + get_data: () => { + return this.data; + }, + fields: table_fields + }, + ], + primary_action: function() { + frappe.model.open_mapped_doc({ + method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", + frm: me.frm, + args: { + selected_items: dialog.fields_dict.alternative_items.grid.get_selected_children() + } + }); + dialog.hide(); + }, + primary_action_label: __('Continue') + }); + + dialog.fields_dict.info.$wrapper.html( + `

+ + ${__("Alternative Items")} +

` + ) + dialog.show(); + } }; cur_frm.script_manager.make(erpnext.selling.QuotationController); -cur_frm.cscript['Make Sales Order'] = function() { - frappe.model.open_mapped_doc({ - method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", - frm: cur_frm - }) -} - frappe.ui.form.on("Quotation Item", "items_on_form_rendered", "packed_items_on_form_rendered", function(frm, cdt, cdn) { // enable tax_amount field if Actual }) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 484b8c9f08d4..fc66db20d29e 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -35,6 +35,9 @@ def validate(self): make_packing_list(self) + def before_submit(self): + self.set_has_alternative_item() + def validate_valid_till(self): if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date): frappe.throw(_("Valid till date cannot be before transaction date")) @@ -59,7 +62,18 @@ def validate_shopping_cart_items(self): title=_("Unpublished Item"), ) + def set_has_alternative_item(self): + """Mark 'Has Alternative Item' for rows.""" + if not any(row.is_alternative for row in self.get("items")): + return + + items_with_alternatives = self.get_rows_with_alternatives() + for row in self.get("items"): + if not row.is_alternative and row.name in items_with_alternatives: + row.has_alternative_item = 1 + def get_ordered_status(self): + status = "Open" ordered_items = frappe._dict( frappe.db.get_all( "Sales Order Item", @@ -70,16 +84,40 @@ def get_ordered_status(self): ) ) - status = "Open" - if ordered_items: - status = "Ordered" + if not ordered_items: + return status - for item in self.get("items"): - if item.qty > ordered_items.get(item.item_code, 0.0): - status = "Partially Ordered" + has_alternatives = any(row.is_alternative for row in self.get("items")) + self._items = self.get_valid_items() if has_alternatives else self.get("items") + + if any(row.qty > ordered_items.get(row.item_code, 0.0) for row in self._items): + status = "Partially Ordered" + else: + status = "Ordered" return status + def get_valid_items(self): + """ + Filters out items in an alternatives set that were not ordered. + """ + + def is_in_sales_order(row): + in_sales_order = bool( + frappe.db.exists( + "Sales Order Item", {"quotation_item": row.name, "item_code": row.item_code, "docstatus": 1} + ) + ) + return in_sales_order + + def can_map(row) -> bool: + if row.is_alternative or row.has_alternative_item: + return is_in_sales_order(row) + + return True + + return list(filter(can_map, self.get("items"))) + def is_fully_ordered(self): return self.get_ordered_status() == "Ordered" @@ -176,6 +214,22 @@ def print_other_charges(self, docname): def on_recurring(self, reference_doc, auto_repeat_doc): self.valid_till = None + def get_rows_with_alternatives(self): + rows_with_alternatives = [] + table_length = len(self.get("items")) + + for idx, row in enumerate(self.get("items")): + if row.is_alternative: + continue + + if idx == (table_length - 1): + break + + if self.get("items")[idx + 1].is_alternative: + rows_with_alternatives.append(row.name) + + return rows_with_alternatives + def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context @@ -194,14 +248,18 @@ def get_list_context(context=None): @frappe.whitelist() -def make_sales_order(source_name, target_doc=None): - quotation = frappe.db.get_value( - "Quotation", source_name, ["transaction_date", "valid_till"], as_dict=1 - ) - if quotation.valid_till and ( - quotation.valid_till < quotation.transaction_date or quotation.valid_till < getdate(nowdate()) +def make_sales_order(source_name: str, target_doc=None): + if not frappe.db.get_singles_value( + "Selling Settings", "allow_sales_order_creation_for_expired_quotation" ): - frappe.throw(_("Validity period of this quotation has ended.")) + quotation = frappe.db.get_value( + "Quotation", source_name, ["transaction_date", "valid_till"], as_dict=1 + ) + if quotation.valid_till and ( + quotation.valid_till < quotation.transaction_date or quotation.valid_till < getdate(nowdate()) + ): + frappe.throw(_("Validity period of this quotation has ended.")) + return _make_sales_order(source_name, target_doc) @@ -217,6 +275,8 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): ) ) + selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])] + def set_missing_values(source, target): if customer: target.customer = customer.name @@ -240,6 +300,24 @@ def update_item(obj, target, source_parent): target.blanket_order = obj.blanket_order target.blanket_order_rate = obj.blanket_order_rate + def can_map_row(item) -> bool: + """ + Row mapping from Quotation to Sales order: + 1. If no selections, map all non-alternative rows (that sum up to the grand total) + 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty + 3. If selections: Simple row: Map if adequate qty + """ + has_qty = item.qty > 0 + + if not selected_rows: + return not item.is_alternative + + if selected_rows and (item.is_alternative or item.has_alternative_item): + return (item.name in selected_rows) and has_qty + + # Simple row + return has_qty + doclist = get_mapped_doc( "Quotation", source_name, @@ -249,7 +327,7 @@ def update_item(obj, target, source_parent): "doctype": "Sales Order Item", "field_map": {"parent": "prevdoc_docname", "name": "quotation_item"}, "postprocess": update_item, - "condition": lambda doc: doc.qty > 0, + "condition": can_map_row, }, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, @@ -318,7 +396,11 @@ def update_item(obj, target, source_parent): source_name, { "Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}}, - "Quotation Item": {"doctype": "Sales Invoice Item", "postprocess": update_item}, + "Quotation Item": { + "doctype": "Sales Invoice Item", + "postprocess": update_item, + "condition": lambda row: not row.is_alternative, + }, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, }, diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index b151dd5e79c4..67f6518657eb 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -136,18 +136,31 @@ def test_make_sales_order_with_terms(self): sales_order.payment_schedule[1].due_date, getdate(add_days(quotation.transaction_date, 30)) ) - def test_valid_till(self): - from erpnext.selling.doctype.quotation.quotation import make_sales_order - + def test_valid_till_before_transaction_date(self): quotation = frappe.copy_doc(test_records[0]) quotation.valid_till = add_days(quotation.transaction_date, -1) self.assertRaises(frappe.ValidationError, quotation.validate) + def test_so_from_expired_quotation(self): + from erpnext.selling.doctype.quotation.quotation import make_sales_order + + frappe.db.set_single_value( + "Selling Settings", "allow_sales_order_creation_for_expired_quotation", 0 + ) + + quotation = frappe.copy_doc(test_records[0]) quotation.valid_till = add_days(nowdate(), -1) quotation.insert() quotation.submit() + self.assertRaises(frappe.ValidationError, make_sales_order, quotation.name) + frappe.db.set_single_value( + "Selling Settings", "allow_sales_order_creation_for_expired_quotation", 1 + ) + + make_sales_order(quotation.name) + def test_shopping_cart_without_website_item(self): if frappe.db.exists("Website Item", {"item_code": "_Test Item Home Desktop 100"}): frappe.get_last_doc("Website Item", {"item_code": "_Test Item Home Desktop 100"}).delete() @@ -444,6 +457,139 @@ def test_packed_items_indices_are_reset_when_product_bundle_is_deleted_from_item expected_index = id + 1 self.assertEqual(item.idx, expected_index) + def test_alternative_items_with_stock_items(self): + """ + Check if taxes & totals considers only non-alternative items with: + - One set of non-alternative & alternative items [first 3 rows] + - One simple stock item + """ + from erpnext.stock.doctype.item.test_item import make_item + + item_list = [] + stock_items = { + "_Test Simple Item 1": 100, + "_Test Alt 1": 120, + "_Test Alt 2": 110, + "_Test Simple Item 2": 200, + } + + for item, rate in stock_items.items(): + make_item(item, {"is_stock_item": 1}) + item_list.append( + { + "item_code": item, + "qty": 1, + "rate": rate, + "is_alternative": bool("Alt" in item), + } + ) + + quotation = make_quotation(item_list=item_list, do_not_submit=1) + quotation.append( + "taxes", + { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 10, + }, + ) + quotation.submit() + + self.assertEqual(quotation.net_total, 300) + self.assertEqual(quotation.grand_total, 330) + + def test_alternative_items_with_service_items(self): + """ + Check if taxes & totals considers only non-alternative items with: + - One set of non-alternative & alternative service items [first 3 rows] + - One simple non-alternative service item + All having the same item code and unique item name/description due to + dynamic services + """ + from erpnext.stock.doctype.item.test_item import make_item + + item_list = [] + service_items = { + "Tiling with Standard Tiles": 100, + "Alt Tiling with Durable Tiles": 150, + "Alt Tiling with Premium Tiles": 180, + "False Ceiling with Material #234": 190, + } + + make_item("_Test Dynamic Service Item", {"is_stock_item": 0}) + + for name, rate in service_items.items(): + item_list.append( + { + "item_code": "_Test Dynamic Service Item", + "item_name": name, + "description": name, + "qty": 1, + "rate": rate, + "is_alternative": bool("Alt" in name), + } + ) + + quotation = make_quotation(item_list=item_list, do_not_submit=1) + quotation.append( + "taxes", + { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 10, + }, + ) + quotation.submit() + + self.assertEqual(quotation.net_total, 290) + self.assertEqual(quotation.grand_total, 319) + + def test_alternative_items_sales_order_mapping_with_stock_items(self): + from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.stock.doctype.item.test_item import make_item + + frappe.flags.args = frappe._dict() + item_list = [] + stock_items = { + "_Test Simple Item 1": 100, + "_Test Alt 1": 120, + "_Test Alt 2": 110, + "_Test Simple Item 2": 200, + } + + for item, rate in stock_items.items(): + make_item(item, {"is_stock_item": 1}) + item_list.append( + { + "item_code": item, + "qty": 1, + "rate": rate, + "is_alternative": bool("Alt" in item), + "warehouse": "_Test Warehouse - _TC", + } + ) + + quotation = make_quotation(item_list=item_list) + + frappe.flags.args.selected_items = [quotation.items[2]] + sales_order = make_sales_order(quotation.name) + sales_order.delivery_date = add_days(sales_order.transaction_date, 10) + sales_order.save() + + self.assertEqual(sales_order.items[0].item_code, "_Test Alt 2") + self.assertEqual(sales_order.items[1].item_code, "_Test Simple Item 2") + self.assertEqual(sales_order.net_total, 310) + + sales_order.submit() + quotation.reload() + self.assertEqual(quotation.status, "Ordered") + test_records = frappe.get_test_records("Quotation") diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 31a95896bc16..fb810318e930 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -49,6 +49,8 @@ "pricing_rules", "stock_uom_rate", "is_free_item", + "is_alternative", + "has_alternative_item", "section_break_43", "valuation_rate", "column_break_45", @@ -644,12 +646,28 @@ "no_copy": 1, "options": "currency", "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_alternative", + "fieldtype": "Check", + "label": "Is Alternative", + "print_hide": 1 + }, + { + "default": "0", + "fieldname": "has_alternative_item", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Alternative Item", + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2021-07-15 12:40:51.074820", + "modified": "2023-02-06 11:00:07.042364", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", @@ -657,5 +675,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index fb64772479b5..449d461561af 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -275,7 +275,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex if (this.frm.doc.docstatus===0) { this.frm.add_custom_button(__('Quotation'), function() { - erpnext.utils.map_current_doc({ + let d = erpnext.utils.map_current_doc({ method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", source_doctype: "Quotation", target: me.frm, @@ -293,7 +293,16 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex docstatus: 1, status: ["!=", "Lost"] } - }) + }); + + setTimeout(() => { + d.$parent.append(` + + ${__("Note: Please create Sales Orders from individual Quotations to select from among Alternative Items.")} + + `); + }, 200); + }, __("Get Items From")); } @@ -309,9 +318,12 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex make_work_order() { var me = this; - this.frm.call({ - doc: this.frm.doc, - method: 'get_work_order_items', + me.frm.call({ + method: "erpnext.selling.doctype.sales_order.sales_order.get_work_order_items", + args: { + sales_order: this.frm.docname, + }, + freeze: true, callback: function(r) { if(!r.message) { frappe.msgprint({ @@ -321,14 +333,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex }); return; } - else if(!r.message) { - frappe.msgprint({ - title: __('Work Order not created'), - message: __('Work Order already created for all items with BOM'), - indicator: 'orange' - }); - return; - } else { + else { const fields = [{ label: 'Items', fieldtype: 'Table', @@ -429,9 +434,9 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex make_raw_material_request() { var me = this; this.frm.call({ - doc: this.frm.doc, - method: 'get_work_order_items', + method: "erpnext.selling.doctype.sales_order.sales_order.get_work_order_items", args: { + sales_order: this.frm.docname, for_raw_material_request: 1 }, callback: function(r) { @@ -450,6 +455,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } make_raw_material_request_dialog(r) { + var me = this; var fields = [ {fieldtype:'Check', fieldname:'include_exploded_items', label: __('Include Exploded Items')}, diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 7c0601e3dd54..ee9161bee48e 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -6,11 +6,12 @@ import frappe import frappe.utils -from frappe import _ +from frappe import _, qb from frappe.contacts.doctype.address.address import get_company_address from frappe.desk.notifications import clear_doctype_notifications from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values +from frappe.query_builder.functions import Sum from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( @@ -20,13 +21,16 @@ ) from erpnext.accounts.party import get_party_account from erpnext.controllers.selling_controller import SellingController +from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( + validate_against_blanket_order, +) from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_items_for_material_requests, ) from erpnext.selling.doctype.customer.customer import check_credit_limit from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.item.item import get_item_defaults -from erpnext.stock.get_item_details import get_default_bom +from erpnext.stock.get_item_details import get_default_bom, get_price_list_rate from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -51,6 +55,7 @@ def validate(self): self.validate_warehouse() self.validate_drop_ship() self.validate_serial_no_based_delivery() + validate_against_blanket_order(self) validate_inter_company_party( self.doctype, self.customer, self.company, self.inter_company_order_reference ) @@ -208,7 +213,7 @@ def update_prevdoc_status(self, flag=None): for quotation in set(d.prevdoc_docname for d in self.get("items")): if quotation: doc = frappe.get_doc("Quotation", quotation) - if doc.docstatus == 2: + if doc.docstatus.is_cancelled(): frappe.throw(_("Quotation {0} is cancelled").format(quotation)) doc.set_status(update=True) @@ -414,51 +419,6 @@ def set_indicator(self): self.indicator_color = "green" self.indicator_title = _("Paid") - @frappe.whitelist() - def get_work_order_items(self, for_raw_material_request=0): - """Returns items with BOM that already do not have a linked work order""" - items = [] - item_codes = [i.item_code for i in self.items] - product_bundle_parents = [ - pb.new_item_code - for pb in frappe.get_all( - "Product Bundle", {"new_item_code": ["in", item_codes]}, ["new_item_code"] - ) - ] - - for table in [self.items, self.packed_items]: - for i in table: - bom = get_default_bom(i.item_code) - stock_qty = i.qty if i.doctype == "Packed Item" else i.stock_qty - - if not for_raw_material_request: - total_work_order_qty = flt( - frappe.db.sql( - """select sum(qty) from `tabWork Order` - where production_item=%s and sales_order=%s and sales_order_item = %s and docstatus<2""", - (i.item_code, self.name, i.name), - )[0][0] - ) - pending_qty = stock_qty - total_work_order_qty - else: - pending_qty = stock_qty - - if pending_qty and i.item_code not in product_bundle_parents: - items.append( - dict( - name=i.name, - item_code=i.item_code, - description=i.description, - bom=bom or "", - warehouse=i.warehouse, - pending_qty=pending_qty, - required_qty=pending_qty if for_raw_material_request else 0, - sales_order_item=i.name, - ) - ) - - return items - def on_recurring(self, reference_doc, auto_repeat_doc): def _get_delivery_date(ref_doc_delivery_date, red_doc_transaction_date, transaction_date): delivery_date = auto_repeat_doc.get_next_schedule_date(schedule_date=ref_doc_delivery_date) @@ -590,6 +550,23 @@ def update_item(source, target, source_parent): target.qty = qty - requested_item_qty.get(source.name, 0) target.stock_qty = flt(target.qty) * flt(target.conversion_factor) + args = target.as_dict().copy() + args.update( + { + "company": source_parent.get("company"), + "price_list": frappe.db.get_single_value("Buying Settings", "buying_price_list"), + "currency": source_parent.get("currency"), + "conversion_rate": source_parent.get("conversion_rate"), + } + ) + + target.rate = flt( + get_price_list_rate(args=args, item_doc=frappe.get_cached_doc("Item", target.item_code)).get( + "price_list_rate" + ) + ) + target.amount = target.qty * target.rate + doc = get_mapped_doc( "Sales Order", source_name, @@ -1333,3 +1310,57 @@ def update_produced_qty_in_so_item(sales_order, sales_order_item): return frappe.db.set_value("Sales Order Item", sales_order_item, "produced_qty", total_produced_qty) + + +@frappe.whitelist() +def get_work_order_items(sales_order, for_raw_material_request=0): + """Returns items with BOM that already do not have a linked work order""" + if sales_order: + so = frappe.get_doc("Sales Order", sales_order) + + wo = qb.DocType("Work Order") + + items = [] + item_codes = [i.item_code for i in so.items] + product_bundle_parents = [ + pb.new_item_code + for pb in frappe.get_all( + "Product Bundle", {"new_item_code": ["in", item_codes]}, ["new_item_code"] + ) + ] + + for table in [so.items, so.packed_items]: + for i in table: + bom = get_default_bom(i.item_code) + stock_qty = i.qty if i.doctype == "Packed Item" else i.stock_qty + + if not for_raw_material_request: + total_work_order_qty = flt( + qb.from_(wo) + .select(Sum(wo.qty)) + .where( + (wo.production_item == i.item_code) + & (wo.sales_order == so.name) * (wo.sales_order_item == i.name) + & (wo.docstatus.lte(2)) + ) + .run()[0][0] + ) + pending_qty = stock_qty - total_work_order_qty + else: + pending_qty = stock_qty + + if pending_qty and i.item_code not in product_bundle_parents: + items.append( + dict( + name=i.name, + item_code=i.item_code, + description=i.description, + bom=bom or "", + warehouse=i.warehouse, + pending_qty=pending_qty, + required_qty=pending_qty if for_raw_material_request else 0, + sales_order_item=i.name, + ) + ) + + return items diff --git a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py index 5c4b57813d31..cbc40bbf90be 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py +++ b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py @@ -14,7 +14,6 @@ def get_data(): }, "internal_links": { "Quotation": ["items", "prevdoc_docname"], - "Material Request": ["items", "material_request"], }, "transactions": [ { diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index e777f52f7a34..627914f0c7ea 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -552,6 +552,42 @@ def test_update_child_qty_rate_with_workflow(self): workflow.is_active = 0 workflow.save() + def test_bin_details_of_packed_item(self): + # test Update Items with product bundle + if not frappe.db.exists("Item", "_Test Product Bundle Item New"): + bundle_item = make_item("_Test Product Bundle Item New", {"is_stock_item": 0}) + bundle_item.append( + "item_defaults", {"company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC"} + ) + bundle_item.save(ignore_permissions=True) + + make_item("_Packed Item New 1", {"is_stock_item": 1}) + make_product_bundle("_Test Product Bundle Item New", ["_Packed Item New 1"], 2) + + so = make_sales_order( + item_code="_Test Product Bundle Item New", + warehouse="_Test Warehouse - _TC", + transaction_date=add_days(nowdate(), -1), + do_not_submit=1, + ) + + make_stock_entry(item="_Packed Item New 1", target="_Test Warehouse - _TC", qty=120, rate=100) + + bin_details = frappe.db.get_value( + "Bin", + {"item_code": "_Packed Item New 1", "warehouse": "_Test Warehouse - _TC"}, + ["actual_qty", "projected_qty", "ordered_qty"], + as_dict=1, + ) + + so.transaction_date = nowdate() + so.save() + + packed_item = so.packed_items[0] + self.assertEqual(flt(bin_details.actual_qty), flt(packed_item.actual_qty)) + self.assertEqual(flt(bin_details.projected_qty), flt(packed_item.projected_qty)) + self.assertEqual(flt(bin_details.ordered_qty), flt(packed_item.ordered_qty)) + def test_update_child_product_bundle(self): # test Update Items with product bundle if not frappe.db.exists("Item", "_Product Bundle Item"): @@ -1181,6 +1217,8 @@ def test_terms_copied(self): self.assertTrue(si.get("payment_schedule")) def test_make_work_order(self): + from erpnext.selling.doctype.sales_order.sales_order import get_work_order_items + # Make a new Sales Order so = make_sales_order( **{ @@ -1194,7 +1232,7 @@ def test_make_work_order(self): # Raise Work Orders po_items = [] so_item_name = {} - for item in so.get_work_order_items(): + for item in get_work_order_items(so.name): po_items.append( { "warehouse": item.get("warehouse"), @@ -1412,6 +1450,7 @@ def test_work_order_pop_up_from_sales_order(self): from erpnext.controllers.item_variant import create_variant from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.selling.doctype.sales_order.sales_order import get_work_order_items make_item( # template item "Test-WO-Tshirt", @@ -1451,7 +1490,7 @@ def test_work_order_pop_up_from_sales_order(self): ] } ) - wo_items = so.get_work_order_items() + wo_items = get_work_order_items(so.name) self.assertEqual(wo_items[0].get("item_code"), "Test-WO-Tshirt-R") self.assertEqual(wo_items[0].get("bom"), red_var_bom.name) @@ -1461,6 +1500,8 @@ def test_work_order_pop_up_from_sales_order(self): self.assertEqual(wo_items[1].get("bom"), template_bom.name) def test_request_for_raw_materials(self): + from erpnext.selling.doctype.sales_order.sales_order import get_work_order_items + item = make_item( "_Test Finished Item", { @@ -1493,7 +1534,7 @@ def test_request_for_raw_materials(self): so = make_sales_order(**{"item_list": [{"item_code": item.item_code, "qty": 1, "rate": 1000}]}) so.submit() mr_dict = frappe._dict() - items = so.get_work_order_items(1) + items = get_work_order_items(so.name, 1) mr_dict["items"] = items mr_dict["include_exploded_items"] = 0 mr_dict["ignore_existing_ordered_qty"] = 1 diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index b801de314cc8..50ae3a3f1a92 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -865,7 +865,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2022-11-18 11:39:01.741665", + "modified": "2022-12-25 02:51:10.247569", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", @@ -876,4 +876,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/selling/doctype/sales_partner_type/sales_partner_type.json b/erpnext/selling/doctype/sales_partner_type/sales_partner_type.json index e7dd0d84a0ad..a9b500a625f6 100644 --- a/erpnext/selling/doctype/sales_partner_type/sales_partner_type.json +++ b/erpnext/selling/doctype/sales_partner_type/sales_partner_type.json @@ -1,94 +1,47 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:sales_partner_type", - "beta": 0, - "creation": "2018-06-11 13:15:57.404716", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "field:sales_partner_type", + "creation": "2018-06-11 13:15:57.404716", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "sales_partner_type" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "sales_partner_type", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Sales Partner Type", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "sales_partner_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Sales Partner Type", + "reqd": 1, + "unique": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-06-11 13:45:13.554307", - "modified_by": "Administrator", - "module": "Selling", - "name": "Sales Partner Type", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "modified": "2023-02-10 01:00:20.110800", + "modified_by": "Administrator", + "module": "Selling", + "name": "Sales Partner Type", + "naming_rule": "By fieldname", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "translated_doctype": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 2abb169b8a06..45ad7d95a155 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -24,9 +24,11 @@ "so_required", "dn_required", "sales_update_frequency", + "over_order_allowance", "column_break_5", "allow_multiple_items", "allow_against_multiple_purchase_orders", + "allow_sales_order_creation_for_expired_quotation", "hide_tax_id", "enable_discount_accounting" ], @@ -172,6 +174,18 @@ "fieldname": "enable_discount_accounting", "fieldtype": "Check", "label": "Enable Discount Accounting for Selling" + }, + { + "default": "0", + "fieldname": "allow_sales_order_creation_for_expired_quotation", + "fieldtype": "Check", + "label": "Allow Sales Order Creation For Expired Quotation" + }, + { + "description": "Percentage you are allowed to order more against the Blanket Order Quantity. For example: If you have a Blanket Order of Quantity 100 units. and your Allowance is 10% then you are allowed to order 110 units.", + "fieldname": "over_order_allowance", + "fieldtype": "Float", + "label": "Over Order Allowance (%)" } ], "icon": "fa fa-cog", @@ -179,7 +193,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-05-31 19:39:48.398738", + "modified": "2023-03-03 11:16:54.333615", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index 5ddebf96f6cf..6ccf45f476cb 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -16,46 +16,72 @@ def search_by_term(search_term, warehouse, price_list): result = search_for_serial_or_batch_or_barcode_number(search_term) or {} + item_code = result.get("item_code", search_term) + serial_no = result.get("serial_no", "") + batch_no = result.get("batch_no", "") + barcode = result.get("barcode", "") + if not result: + return + item_doc = frappe.get_doc("Item", item_code) + if not item_doc: + return + item = { + "barcode": barcode, + "batch_no": batch_no, + "description": item_doc.description, + "is_stock_item": item_doc.is_stock_item, + "item_code": item_doc.name, + "item_image": item_doc.image, + "item_name": item_doc.item_name, + "serial_no": serial_no, + "stock_uom": item_doc.stock_uom, + "uom": item_doc.stock_uom, + } + if barcode: + barcode_info = next(filter(lambda x: x.barcode == barcode, item_doc.get("barcodes", [])), None) + if barcode_info and barcode_info.uom: + uom = next(filter(lambda x: x.uom == barcode_info.uom, item_doc.uoms), {}) + item.update( + { + "uom": barcode_info.uom, + "conversion_factor": uom.get("conversion_factor", 1), + } + ) - item_code = result.get("item_code") or search_term - serial_no = result.get("serial_no") or "" - batch_no = result.get("batch_no") or "" - barcode = result.get("barcode") or "" - - if result: - item_info = frappe.db.get_value( - "Item", - item_code, - [ - "name as item_code", - "item_name", - "description", - "stock_uom", - "image as item_image", - "is_stock_item", - ], - as_dict=1, - ) - - item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse) - price_list_rate, currency = frappe.db.get_value( - "Item Price", - {"price_list": price_list, "item_code": item_code}, - ["price_list_rate", "currency"], - ) or [None, None] + item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse) + item_stock_qty = item_stock_qty // item.get("conversion_factor") + item_stock_qty = item_stock_qty // item.get("conversion_factor", 1) + item.update({"actual_qty": item_stock_qty}) + + price = frappe.get_list( + doctype="Item Price", + filters={ + "price_list": price_list, + "item_code": item_code, + }, + fields=["uom", "stock_uom", "currency", "price_list_rate"], + ) - item_info.update( + def __sort(p): + p_uom = p.get("uom") + if p_uom == item.get("uom"): + return 0 + elif p_uom == item.get("stock_uom"): + return 1 + else: + return 2 + + # sort by fallback preference. always pick exact uom match if available + price = sorted(price, key=__sort) + if len(price) > 0: + p = price.pop(0) + item.update( { - "serial_no": serial_no, - "batch_no": batch_no, - "barcode": barcode, - "price_list_rate": price_list_rate, - "currency": currency, - "actual_qty": item_stock_qty, + "currency": p.get("currency"), + "price_list_rate": p.get("price_list_rate"), } ) - - return {"items": [item_info]} + return {"items": [item]} @frappe.whitelist() diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 595b9196e848..da798ab6d2d3 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -522,7 +522,7 @@ erpnext.PointOfSale.Controller = class { const from_selector = field === 'qty' && value === "+1"; if (from_selector) - value = flt(item_row.qty) + flt(value); + value = flt(item_row.stock_qty) + flt(value); if (item_row_exists) { if (field === 'qty') diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 0a356b9a6fba..89ce61ab1680 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -322,6 +322,11 @@ erpnext.PointOfSale.Payment = class { this.focus_on_default_mop(); } + after_render() { + const frm = this.events.get_frm(); + frm.script_manager.trigger("after_payment_render", frm.doc.doctype, frm.doc.docname); + } + edit_cart() { this.events.toggle_other_sections(false); this.toggle_component(false); @@ -332,6 +337,7 @@ erpnext.PointOfSale.Payment = class { this.toggle_component(true); this.render_payment_section(); + this.after_render(); } toggle_remarks_control() { diff --git a/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py b/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py index e10df2acbb5e..2624db3191df 100644 --- a/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py +++ b/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py @@ -41,8 +41,20 @@ def get_columns(filters): {"label": _("Description"), "fieldtype": "Data", "fieldname": "description", "width": 150}, {"label": _("Quantity"), "fieldtype": "Float", "fieldname": "quantity", "width": 150}, {"label": _("UOM"), "fieldtype": "Link", "fieldname": "uom", "options": "UOM", "width": 100}, - {"label": _("Rate"), "fieldname": "rate", "options": "Currency", "width": 120}, - {"label": _("Amount"), "fieldname": "amount", "options": "Currency", "width": 120}, + { + "label": _("Rate"), + "fieldname": "rate", + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, + { + "label": _("Amount"), + "fieldname": "amount", + "fieldtype": "Currency", + "options": "currency", + "width": 120, + }, { "label": _("Sales Order"), "fieldtype": "Link", @@ -93,8 +105,9 @@ def get_columns(filters): }, { "label": _("Billed Amount"), - "fieldtype": "currency", + "fieldtype": "Currency", "fieldname": "billed_amount", + "options": "currency", "width": 120, }, { @@ -104,6 +117,13 @@ def get_columns(filters): "options": "Company", "width": 100, }, + { + "label": _("Currency"), + "fieldtype": "Link", + "fieldname": "currency", + "options": "Currency", + "hidden": 1, + }, ] @@ -141,31 +161,12 @@ def get_data(filters): "billed_amount": flt(record.get("billed_amt")), "company": record.get("company"), } + row["currency"] = frappe.get_cached_value("Company", row["company"], "default_currency") data.append(row) return data -def get_conditions(filters): - conditions = "" - if filters.get("item_group"): - conditions += "AND so_item.item_group = %s" % frappe.db.escape(filters.item_group) - - if filters.get("from_date"): - conditions += "AND so.transaction_date >= '%s'" % filters.from_date - - if filters.get("to_date"): - conditions += "AND so.transaction_date <= '%s'" % filters.to_date - - if filters.get("item_code"): - conditions += "AND so_item.item_code = %s" % frappe.db.escape(filters.item_code) - - if filters.get("customer"): - conditions += "AND so.customer = %s" % frappe.db.escape(filters.customer) - - return conditions - - def get_customer_details(): details = frappe.get_all("Customer", fields=["name", "customer_name", "customer_group"]) customer_details = {} @@ -187,29 +188,50 @@ def get_item_details(): def get_sales_order_details(company_list, filters): - conditions = get_conditions(filters) - - return frappe.db.sql( - """ - SELECT - so_item.item_code, so_item.description, so_item.qty, - so_item.uom, so_item.base_rate, so_item.base_amount, - so.name, so.transaction_date, so.customer,so.territory, - so.project, so_item.delivered_qty, - so_item.billed_amt, so.company - FROM - `tabSales Order` so, `tabSales Order Item` so_item - WHERE - so.name = so_item.parent - AND so.company in ({0}) - AND so.docstatus = 1 {1} - """.format( - ",".join(["%s"] * len(company_list)), conditions - ), - tuple(company_list), - as_dict=1, + db_so = frappe.qb.DocType("Sales Order") + db_so_item = frappe.qb.DocType("Sales Order Item") + + query = ( + frappe.qb.from_(db_so) + .inner_join(db_so_item) + .on(db_so_item.parent == db_so.name) + .select( + db_so.name, + db_so.customer, + db_so.transaction_date, + db_so.territory, + db_so.project, + db_so.company, + db_so_item.item_code, + db_so_item.description, + db_so_item.qty, + db_so_item.uom, + db_so_item.base_rate, + db_so_item.base_amount, + db_so_item.delivered_qty, + (db_so_item.billed_amt * db_so.conversion_rate).as_("billed_amt"), + ) + .where(db_so.docstatus == 1) + .where(db_so.company.isin(tuple(company_list))) ) + if filters.get("item_group"): + query = query.where(db_so_item.item_group == filters.item_group) + + if filters.get("from_date"): + query = query.where(db_so.transaction_date >= filters.from_date) + + if filters.get("to_date"): + query = query.where(db_so.transaction_date <= filters.to_date) + + if filters.get("item_code"): + query = query.where(db_so_item.item_code == filters.item_code) + + if filters.get("customer"): + query = query.where(db_so.customer == filters.customer) + + return query.run(as_dict=1) + def get_chart_data(data): item_wise_sales_map = {} diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js index 991ac719cdc8..990d736baa4e 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js @@ -103,6 +103,11 @@ function get_filters() { return options } }, + { + "fieldname":"only_immediate_upcoming_term", + "label": __("Show only the Immediate Upcoming Term"), + "fieldtype": "Check", + }, ] return filters; } diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py index 8bf56865a7d6..3682c5fd62e1 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py @@ -4,6 +4,7 @@ import frappe from frappe import _, qb, query_builder from frappe.query_builder import Criterion, functions +from frappe.utils.dateutils import getdate def get_columns(): @@ -208,6 +209,7 @@ def get_so_with_invoices(filters): ) .where( (so.docstatus == 1) + & (so.status.isin(["To Deliver and Bill", "To Bill"])) & (so.payment_terms_template != "NULL") & (so.company == conditions.company) & (so.transaction_date[conditions.start_date : conditions.end_date]) @@ -291,6 +293,18 @@ def filter_on_calculated_status(filters, sales_orders): return sales_orders +def filter_for_immediate_upcoming_term(filters, sales_orders): + if filters.only_immediate_upcoming_term and sales_orders: + immediate_term_found = set() + filtered_data = [] + for order in sales_orders: + if order.name not in immediate_term_found and order.due_date > getdate(): + filtered_data.append(order) + immediate_term_found.add(order.name) + return filtered_data + return sales_orders + + def execute(filters=None): columns = get_columns() sales_orders, so_invoices = get_so_with_invoices(filters) @@ -298,6 +312,8 @@ def execute(filters=None): sales_orders = filter_on_calculated_status(filters, sales_orders) + sales_orders = filter_for_immediate_upcoming_term(filters, sales_orders) + prepare_chart(sales_orders) data = sales_orders diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py index 525ae8e7ea72..58516f6f625a 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py @@ -2,7 +2,7 @@ import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import add_days, nowdate +from frappe.utils import add_days, add_months, nowdate from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order @@ -15,9 +15,16 @@ class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): + def setUp(self): + self.cleanup_old_entries() + def tearDown(self): frappe.db.rollback() + def cleanup_old_entries(self): + frappe.db.delete("Sales Invoice", filters={"company": "_Test Company"}) + frappe.db.delete("Sales Order", filters={"company": "_Test Company"}) + def create_payment_terms_template(self): # create template for 50-50 payments template = None @@ -348,7 +355,7 @@ def test_04_due_date_filter(self): item = create_item(item_code="_Test Excavator 1", is_stock_item=0) transaction_date = nowdate() so = make_sales_order( - transaction_date=add_days(transaction_date, -30), + transaction_date=add_months(transaction_date, -1), delivery_date=add_days(transaction_date, -15), item=item.item_code, qty=10, @@ -369,13 +376,15 @@ def test_04_due_date_filter(self): sinv.items[0].qty = 6 sinv.insert() sinv.submit() + + first_due_date = add_days(add_months(transaction_date, -1), 15) columns, data, message, chart = execute( frappe._dict( { "company": "_Test Company", "item": item.item_code, - "from_due_date": add_days(transaction_date, -30), - "to_due_date": add_days(transaction_date, -15), + "from_due_date": add_months(transaction_date, -1), + "to_due_date": first_due_date, } ) ) @@ -384,11 +393,11 @@ def test_04_due_date_filter(self): { "name": so.name, "customer": so.customer, - "submitted": datetime.date.fromisoformat(add_days(transaction_date, -30)), + "submitted": datetime.date.fromisoformat(add_months(transaction_date, -1)), "status": "Completed", "payment_term": None, "description": "_Test 50-50", - "due_date": datetime.date.fromisoformat(add_days(transaction_date, -15)), + "due_date": datetime.date.fromisoformat(first_due_date), "invoice_portion": 50.0, "currency": "INR", "base_payment_amount": 500000.0, diff --git a/erpnext/selling/report/sales_partner_target_variance_based_on_item_group/item_group_wise_sales_target_variance.py b/erpnext/selling/report/sales_partner_target_variance_based_on_item_group/item_group_wise_sales_target_variance.py index f34f3e34e2ce..7d28f2b90d22 100644 --- a/erpnext/selling/report/sales_partner_target_variance_based_on_item_group/item_group_wise_sales_target_variance.py +++ b/erpnext/selling/report/sales_partner_target_variance_based_on_item_group/item_group_wise_sales_target_variance.py @@ -44,20 +44,30 @@ def get_data(filters, period_list, partner_doctype): if not sales_users_data: return - sales_users, item_groups = [], [] + sales_users = [] + sales_user_wise_item_groups = {} for d in sales_users_data: if d.parent not in sales_users: sales_users.append(d.parent) - if d.item_group not in item_groups: - item_groups.append(d.item_group) + sales_user_wise_item_groups.setdefault(d.parent, []) + if d.item_group: + sales_user_wise_item_groups[d.parent].append(d.item_group) date_field = "transaction_date" if filters.get("doctype") == "Sales Order" else "posting_date" - actual_data = get_actual_data(filters, item_groups, sales_users, date_field, sales_field) + actual_data = get_actual_data(filters, sales_users, date_field, sales_field) - return prepare_data(filters, sales_users_data, actual_data, date_field, period_list, sales_field) + return prepare_data( + filters, + sales_users_data, + sales_user_wise_item_groups, + actual_data, + date_field, + period_list, + sales_field, + ) def get_columns(filters, period_list, partner_doctype): @@ -142,7 +152,15 @@ def get_columns(filters, period_list, partner_doctype): return columns -def prepare_data(filters, sales_users_data, actual_data, date_field, period_list, sales_field): +def prepare_data( + filters, + sales_users_data, + sales_user_wise_item_groups, + actual_data, + date_field, + period_list, + sales_field, +): rows = {} target_qty_amt_field = "target_qty" if filters.get("target_on") == "Quantity" else "target_amount" @@ -173,9 +191,9 @@ def prepare_data(filters, sales_users_data, actual_data, date_field, period_list for r in actual_data: if ( r.get(sales_field) == d.parent - and r.item_group == d.item_group and period.from_date <= r.get(date_field) and r.get(date_field) <= period.to_date + and (not sales_user_wise_item_groups.get(d.parent) or r.item_group == d.item_group) ): details[p_key] += r.get(qty_or_amount_field, 0) details[variance_key] = details.get(p_key) - details.get(target_key) @@ -186,7 +204,7 @@ def prepare_data(filters, sales_users_data, actual_data, date_field, period_list return rows -def get_actual_data(filters, item_groups, sales_users_or_territory_data, date_field, sales_field): +def get_actual_data(filters, sales_users_or_territory_data, date_field, sales_field): fiscal_year = get_fiscal_year(fiscal_year=filters.get("fiscal_year"), as_dict=1) dates = [fiscal_year.year_start_date, fiscal_year.year_end_date] @@ -213,7 +231,6 @@ def get_actual_data(filters, item_groups, sales_users_or_territory_data, date_fi WHERE `tab{child_doc}`.parent = `tab{parent_doc}`.name and `tab{parent_doc}`.docstatus = 1 and {cond} - and `tab{child_doc}`.item_group in ({item_groups}) and `tab{parent_doc}`.{date_field} between %s and %s""".format( cond=cond, date_field=date_field, @@ -221,9 +238,8 @@ def get_actual_data(filters, item_groups, sales_users_or_territory_data, date_fi child_table=child_table, parent_doc=filters.get("doctype"), child_doc=filters.get("doctype") + " Item", - item_groups=",".join(["%s"] * len(item_groups)), ), - tuple(sales_users_or_territory_data + item_groups + dates), + tuple(sales_users_or_territory_data + dates), as_dict=1, ) diff --git a/erpnext/selling/report/sales_person_target_variance_based_on_item_group/sales_person_target_variance_based_on_item_group.py b/erpnext/selling/report/sales_person_target_variance_based_on_item_group/sales_person_target_variance_based_on_item_group.py index dda24662bb23..820712234a59 100644 --- a/erpnext/selling/report/sales_person_target_variance_based_on_item_group/sales_person_target_variance_based_on_item_group.py +++ b/erpnext/selling/report/sales_person_target_variance_based_on_item_group/sales_person_target_variance_based_on_item_group.py @@ -8,6 +8,4 @@ def execute(filters=None): - data = [] - return get_data_column(filters, "Sales Person") diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js index 8ff01f5cb4c6..f1df3a11de48 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -253,7 +253,7 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran } calculate_commission() { - if(!this.frm.fields_dict.commission_rate) return; + if(!this.frm.fields_dict.commission_rate || this.frm.doc.docstatus === 1) return; if(this.frm.doc.commission_rate > 100) { this.frm.set_value("commission_rate", 100); @@ -418,8 +418,6 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran callback: function(r) { if(r.message) { frappe.model.set_value(doc.doctype, doc.name, 'batch_no', r.message); - } else { - frappe.model.set_value(doc.doctype, doc.name, 'batch_no', r.message); } } }); diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 07ee2890c466..fcdf245659bb 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -808,7 +808,7 @@ def get_default_company_address(name, sort_key="is_primary_address", existing_ad return existing_address if out: - return min(out, key=lambda x: x[1])[0] # find min by sort_key + return max(out, key=lambda x: x[1])[0] # find max by sort_key else: return None diff --git a/erpnext/setup/doctype/company/test_company.py b/erpnext/setup/doctype/company/test_company.py index 29e056e34f04..fd2fe300fac0 100644 --- a/erpnext/setup/doctype/company/test_company.py +++ b/erpnext/setup/doctype/company/test_company.py @@ -11,6 +11,7 @@ from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import ( get_charts_for_country, ) +from erpnext.setup.doctype.company.company import get_default_company_address test_ignore = ["Account", "Cost Center", "Payment Terms Template", "Salary Component", "Warehouse"] test_dependencies = ["Fiscal Year"] @@ -132,6 +133,38 @@ def test_basic_tree(self, records=None): self.assertTrue(lft >= min_lft) self.assertTrue(rgt <= max_rgt) + def test_primary_address(self): + company = "_Test Company" + + secondary = frappe.get_doc( + { + "address_title": "Non Primary", + "doctype": "Address", + "address_type": "Billing", + "address_line1": "Something", + "city": "Mumbai", + "state": "Maharashtra", + "country": "India", + "is_primary_address": 1, + "pincode": "400098", + "links": [ + { + "link_doctype": "Company", + "link_name": company, + } + ], + } + ) + secondary.insert() + self.addCleanup(secondary.delete) + + primary = frappe.copy_doc(secondary) + primary.is_primary_address = 1 + primary.insert() + self.addCleanup(primary.delete) + + self.assertEqual(get_default_company_address(company), primary.name) + def get_no_of_children(self, company): def get_no_of_children(companies, no_of_children): children = [] diff --git a/erpnext/setup/doctype/designation/designation.json b/erpnext/setup/doctype/designation/designation.json index 2cbbb04ed912..a5b2ac9128ac 100644 --- a/erpnext/setup/doctype/designation/designation.json +++ b/erpnext/setup/doctype/designation/designation.json @@ -31,7 +31,7 @@ "icon": "fa fa-bookmark", "idx": 1, "links": [], - "modified": "2022-06-28 17:10:26.853753", + "modified": "2023-02-10 01:53:41.319386", "modified_by": "Administrator", "module": "Setup", "name": "Designation", @@ -58,5 +58,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "ASC", - "states": [] + "states": [], + "translated_doctype": 1 } \ No newline at end of file diff --git a/erpnext/setup/doctype/item_group/item_group.json b/erpnext/setup/doctype/item_group/item_group.json index 50f923d87e0b..2986087277c8 100644 --- a/erpnext/setup/doctype/item_group/item_group.json +++ b/erpnext/setup/doctype/item_group/item_group.json @@ -123,6 +123,7 @@ "fieldname": "route", "fieldtype": "Data", "label": "Route", + "no_copy": 1, "unique": 1 }, { @@ -232,11 +233,10 @@ "is_tree": 1, "links": [], "max_attachments": 3, - "modified": "2022-03-09 12:27:11.055782", + "modified": "2023-01-05 12:21:30.458628", "modified_by": "Administrator", "module": "Setup", "name": "Item Group", - "name_case": "Title Case", "naming_rule": "By fieldname", "nsm_parent_field": "parent_item_group", "owner": "Administrator", diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index 95bbf84616b4..2eca5cad8e2e 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -148,7 +148,12 @@ def get_item_for_list_in_html(context): def get_parent_item_groups(item_group_name, from_item=False): - base_nav_page = {"name": _("Shop by Category"), "route": "/shop-by-category"} + settings = frappe.get_cached_doc("E Commerce Settings") + + if settings.enable_field_filters: + base_nav_page = {"name": _("Shop by Category"), "route": "/shop-by-category"} + else: + base_nav_page = {"name": _("All Products"), "route": "/all-products"} if from_item and frappe.request.environ.get("HTTP_REFERER"): # base page after 'Home' will vary on Item page diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.js b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.js index 3680906057fa..c3605bf0e8bc 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.js +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.js @@ -1,13 +1,6 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt - - -//--------- ONLOAD ------------- -cur_frm.cscript.onload = function(doc, cdt, cdn) { - -} - -cur_frm.cscript.refresh = function(doc, cdt, cdn) { - -} +// frappe.ui.form.on("Terms and Conditions", { +// refresh(frm) {} +// }); diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json index f14b243512f5..f884864acfa7 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json @@ -33,7 +33,6 @@ "default": "0", "fieldname": "disabled", "fieldtype": "Check", - "in_list_view": 1, "label": "Disabled" }, { @@ -60,12 +59,14 @@ "default": "1", "fieldname": "selling", "fieldtype": "Check", + "in_list_view": 1, "label": "Selling" }, { "default": "1", "fieldname": "buying", "fieldtype": "Check", + "in_list_view": 1, "label": "Buying" }, { @@ -76,10 +77,11 @@ "icon": "icon-legal", "idx": 1, "links": [], - "modified": "2022-06-16 15:07:38.094844", + "modified": "2023-02-01 14:33:39.246532", "modified_by": "Administrator", "module": "Setup", "name": "Terms and Conditions", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -133,5 +135,6 @@ "quick_entry": 1, "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py index 4256a7d83127..481a3a5ebea7 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py @@ -3,13 +3,17 @@ import frappe -from frappe import _ +from frappe import _, qb from frappe.desk.notifications import clear_notifications from frappe.model.document import Document -from frappe.utils import cint +from frappe.utils import cint, create_batch class TransactionDeletionRecord(Document): + def __init__(self, *args, **kwargs): + super(TransactionDeletionRecord, self).__init__(*args, **kwargs) + self.batch_size = 5000 + def validate(self): frappe.only_for("System Manager") self.validate_doctypes_to_be_ignored() @@ -155,8 +159,9 @@ def delete_child_tables(self, doctype, company_fieldname): "DocField", filters={"fieldtype": "Table", "parent": doctype}, pluck="options" ) - for table in child_tables: - frappe.db.delete(table, {"parent": ["in", parent_docs_to_be_deleted]}) + for batch in create_batch(parent_docs_to_be_deleted, self.batch_size): + for table in child_tables: + frappe.db.delete(table, {"parent": ["in", batch]}) def delete_docs_linked_with_specified_company(self, doctype, company_fieldname): frappe.db.delete(doctype, {company_fieldname: self.company}) @@ -181,13 +186,16 @@ def update_naming_series(self, naming_series, doctype_name): frappe.db.sql("""update `tabSeries` set current = %s where name=%s""", (last, prefix)) def delete_version_log(self, doctype, company_fieldname): - frappe.db.sql( - """delete from `tabVersion` where ref_doctype=%s and docname in - (select name from `tab{0}` where `{1}`=%s)""".format( - doctype, company_fieldname - ), - (doctype, self.company), - ) + dt = qb.DocType(doctype) + names = qb.from_(dt).select(dt.name).where(dt[company_fieldname] == self.company).run(as_list=1) + names = [x[0] for x in names] + + if names: + versions = qb.DocType("Version") + for batch in create_batch(names, self.batch_size): + qb.from_(versions).delete().where( + (versions.ref_doctype == doctype) & (versions.docname.isin(batch)) + ).run() def delete_communications(self, doctype, company_fieldname): reference_docs = frappe.get_all(doctype, filters={company_fieldname: self.company}) @@ -199,7 +207,8 @@ def delete_communications(self, doctype, company_fieldname): ) communication_names = [c.name for c in communications] - frappe.delete_doc("Communication", communication_names, ignore_permissions=True) + for batch in create_batch(communication_names, self.batch_size): + frappe.delete_doc("Communication", batch, ignore_permissions=True) @frappe.whitelist() diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 1f7dddfb95bc..088958d1b266 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -155,7 +155,7 @@ def add_standard_navbar_items(): { "item_label": "Documentation", "item_type": "Route", - "route": "https://erpnext.com/docs/user/manual", + "route": "https://docs.erpnext.com/docs/v14/user/manual/en/introduction", "is_standard": 1, }, { diff --git a/erpnext/setup/setup_wizard/data/designation.txt b/erpnext/setup/setup_wizard/data/designation.txt new file mode 100644 index 000000000000..4c6d7bdea8a0 --- /dev/null +++ b/erpnext/setup/setup_wizard/data/designation.txt @@ -0,0 +1,31 @@ +Accountant +Administrative Assistant +Administrative Officer +Analyst +Associate +Business Analyst +Business Development Manager +Consultant +Chief Executive Officer +Chief Financial Officer +Chief Operating Officer +Chief Technology Officer +Customer Service Representative +Designer +Engineer +Executive Assistant +Finance Manager +HR Manager +Head of Marketing and Sales +Manager +Managing Director +Marketing Manager +Marketing Specialist +President +Product Manager +Project Manager +Researcher +Sales Representative +Secretary +Software Developer +Vice President diff --git a/erpnext/setup/setup_wizard/data/industry_type.py b/erpnext/setup/setup_wizard/data/industry_type.py deleted file mode 100644 index 0bc3f32eb09b..000000000000 --- a/erpnext/setup/setup_wizard/data/industry_type.py +++ /dev/null @@ -1,57 +0,0 @@ -from frappe import _ - - -def get_industry_types(): - return [ - _("Accounting"), - _("Advertising"), - _("Aerospace"), - _("Agriculture"), - _("Airline"), - _("Apparel & Accessories"), - _("Automotive"), - _("Banking"), - _("Biotechnology"), - _("Broadcasting"), - _("Brokerage"), - _("Chemical"), - _("Computer"), - _("Consulting"), - _("Consumer Products"), - _("Cosmetics"), - _("Defense"), - _("Department Stores"), - _("Education"), - _("Electronics"), - _("Energy"), - _("Entertainment & Leisure"), - _("Executive Search"), - _("Financial Services"), - _("Food, Beverage & Tobacco"), - _("Grocery"), - _("Health Care"), - _("Internet Publishing"), - _("Investment Banking"), - _("Legal"), - _("Manufacturing"), - _("Motion Picture & Video"), - _("Music"), - _("Newspaper Publishers"), - _("Online Auctions"), - _("Pension Funds"), - _("Pharmaceuticals"), - _("Private Equity"), - _("Publishing"), - _("Real Estate"), - _("Retail & Wholesale"), - _("Securities & Commodity Exchanges"), - _("Service"), - _("Soap & Detergent"), - _("Software"), - _("Sports"), - _("Technology"), - _("Telecommunications"), - _("Television"), - _("Transportation"), - _("Venture Capital"), - ] diff --git a/erpnext/setup/setup_wizard/data/industry_type.txt b/erpnext/setup/setup_wizard/data/industry_type.txt new file mode 100644 index 000000000000..eadc689e3121 --- /dev/null +++ b/erpnext/setup/setup_wizard/data/industry_type.txt @@ -0,0 +1,51 @@ +Accounting +Advertising +Aerospace +Agriculture +Airline +Apparel & Accessories +Automotive +Banking +Biotechnology +Broadcasting +Brokerage +Chemical +Computer +Consulting +Consumer Products +Cosmetics +Defense +Department Stores +Education +Electronics +Energy +Entertainment & Leisure +Executive Search +Financial Services +Food, Beverage & Tobacco +Grocery +Health Care +Internet Publishing +Investment Banking +Legal +Manufacturing +Motion Picture & Video +Music +Newspaper Publishers +Online Auctions +Pension Funds +Pharmaceuticals +Private Equity +Publishing +Real Estate +Retail & Wholesale +Securities & Commodity Exchanges +Service +Soap & Detergent +Software +Sports +Technology +Telecommunications +Television +Transportation +Venture Capital diff --git a/erpnext/setup/setup_wizard/data/lead_source.txt b/erpnext/setup/setup_wizard/data/lead_source.txt new file mode 100644 index 000000000000..00ca1808bb51 --- /dev/null +++ b/erpnext/setup/setup_wizard/data/lead_source.txt @@ -0,0 +1,10 @@ +Existing Customer +Reference +Advertisement +Cold Calling +Exhibition +Supplier Reference +Mass Mailing +Customer's Vendor +Campaign +Walk In diff --git a/erpnext/setup/setup_wizard/data/sales_partner_type.txt b/erpnext/setup/setup_wizard/data/sales_partner_type.txt new file mode 100644 index 000000000000..68e9b9ac732c --- /dev/null +++ b/erpnext/setup/setup_wizard/data/sales_partner_type.txt @@ -0,0 +1,7 @@ +Channel Partner +Distributor +Dealer +Agent +Retailer +Implementation Partner +Reseller diff --git a/erpnext/setup/setup_wizard/data/sales_stage.txt b/erpnext/setup/setup_wizard/data/sales_stage.txt new file mode 100644 index 000000000000..2808ce79855e --- /dev/null +++ b/erpnext/setup/setup_wizard/data/sales_stage.txt @@ -0,0 +1,8 @@ +Prospecting +Qualification +Needs Analysis +Value Proposition +Identifying Decision Makers +Perception Analysis +Proposal/Price Quote +Negotiation/Review diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 4d9b871e5e7b..6bc17718ae01 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -4,6 +4,7 @@ import json import os +from pathlib import Path import frappe from frappe import _ @@ -16,28 +17,10 @@ from erpnext.accounts.doctype.account.account import RootNotEditable from erpnext.regional.address_template.setup import set_up_address_templates -default_lead_sources = [ - "Existing Customer", - "Reference", - "Advertisement", - "Cold Calling", - "Exhibition", - "Supplier Reference", - "Mass Mailing", - "Customer's Vendor", - "Campaign", - "Walk In", -] - -default_sales_partner_type = [ - "Channel Partner", - "Distributor", - "Dealer", - "Agent", - "Retailer", - "Implementation Partner", - "Reseller", -] + +def read_lines(filename: str) -> list[str]: + """Return a list of lines from a file in the data directory.""" + return (Path(__file__).parent.parent / "data" / filename).read_text().splitlines() def install(country=None): @@ -85,7 +68,11 @@ def install(country=None): # Stock Entry Type {"doctype": "Stock Entry Type", "name": "Material Issue", "purpose": "Material Issue"}, {"doctype": "Stock Entry Type", "name": "Material Receipt", "purpose": "Material Receipt"}, - {"doctype": "Stock Entry Type", "name": "Material Transfer", "purpose": "Material Transfer"}, + { + "doctype": "Stock Entry Type", + "name": "Material Transfer", + "purpose": "Material Transfer", + }, {"doctype": "Stock Entry Type", "name": "Manufacture", "purpose": "Manufacture"}, {"doctype": "Stock Entry Type", "name": "Repack", "purpose": "Repack"}, { @@ -103,22 +90,6 @@ def install(country=None): "name": "Material Consumption for Manufacture", "purpose": "Material Consumption for Manufacture", }, - # Designation - {"doctype": "Designation", "designation_name": _("CEO")}, - {"doctype": "Designation", "designation_name": _("Manager")}, - {"doctype": "Designation", "designation_name": _("Analyst")}, - {"doctype": "Designation", "designation_name": _("Engineer")}, - {"doctype": "Designation", "designation_name": _("Accountant")}, - {"doctype": "Designation", "designation_name": _("Secretary")}, - {"doctype": "Designation", "designation_name": _("Associate")}, - {"doctype": "Designation", "designation_name": _("Administrative Officer")}, - {"doctype": "Designation", "designation_name": _("Business Development Manager")}, - {"doctype": "Designation", "designation_name": _("HR Manager")}, - {"doctype": "Designation", "designation_name": _("Project Manager")}, - {"doctype": "Designation", "designation_name": _("Head of Marketing and Sales")}, - {"doctype": "Designation", "designation_name": _("Software Developer")}, - {"doctype": "Designation", "designation_name": _("Designer")}, - {"doctype": "Designation", "designation_name": _("Researcher")}, # territory: with two default territories, one for home country and one named Rest of the World { "doctype": "Territory", @@ -291,28 +262,18 @@ def install(country=None): {"doctype": "Market Segment", "market_segment": _("Lower Income")}, {"doctype": "Market Segment", "market_segment": _("Middle Income")}, {"doctype": "Market Segment", "market_segment": _("Upper Income")}, - # Sales Stages - {"doctype": "Sales Stage", "stage_name": _("Prospecting")}, - {"doctype": "Sales Stage", "stage_name": _("Qualification")}, - {"doctype": "Sales Stage", "stage_name": _("Needs Analysis")}, - {"doctype": "Sales Stage", "stage_name": _("Value Proposition")}, - {"doctype": "Sales Stage", "stage_name": _("Identifying Decision Makers")}, - {"doctype": "Sales Stage", "stage_name": _("Perception Analysis")}, - {"doctype": "Sales Stage", "stage_name": _("Proposal/Price Quote")}, - {"doctype": "Sales Stage", "stage_name": _("Negotiation/Review")}, # Warehouse Type {"doctype": "Warehouse Type", "name": "Transit"}, ] - from erpnext.setup.setup_wizard.data.industry_type import get_industry_types - - records += [{"doctype": "Industry Type", "industry": d} for d in get_industry_types()] - # records += [{"doctype":"Operation", "operation": d} for d in get_operations()] - records += [{"doctype": "Lead Source", "source_name": _(d)} for d in default_lead_sources] - - records += [ - {"doctype": "Sales Partner Type", "sales_partner_type": _(d)} for d in default_sales_partner_type - ] + for doctype, title_field, filename in ( + ("Designation", "designation_name", "designation.txt"), + ("Sales Stage", "stage_name", "sales_stage.txt"), + ("Industry Type", "industry", "industry_type.txt"), + ("Lead Source", "source_name", "lead_source.txt"), + ("Sales Partner Type", "sales_partner_type", "sales_partner_type.txt"), + ): + records += [{"doctype": doctype, title_field: title} for title in read_lines(filename)] base_path = frappe.get_app_path("erpnext", "stock", "doctype") response = frappe.read_file( @@ -335,16 +296,11 @@ def install(country=None): make_default_records() make_records(records) set_up_address_templates(default_country=country) - set_more_defaults() - update_global_search_doctypes() - - -def set_more_defaults(): - # Do more setup stuff that can be done here with no dependencies update_selling_defaults() update_buying_defaults() add_uom_data() update_item_variant_settings() + update_global_search_doctypes() def update_selling_defaults(): @@ -381,7 +337,7 @@ def add_uom_data(): ) for d in uoms: if not frappe.db.exists("UOM", _(d.get("uom_name"))): - uom_doc = frappe.get_doc( + frappe.get_doc( { "doctype": "UOM", "uom_name": _(d.get("uom_name")), @@ -402,9 +358,10 @@ def add_uom_data(): frappe.get_doc({"doctype": "UOM Category", "category_name": _(d.get("category"))}).db_insert() if not frappe.db.exists( - "UOM Conversion Factor", {"from_uom": _(d.get("from_uom")), "to_uom": _(d.get("to_uom"))} + "UOM Conversion Factor", + {"from_uom": _(d.get("from_uom")), "to_uom": _(d.get("to_uom"))}, ): - uom_conversion = frappe.get_doc( + frappe.get_doc( { "doctype": "UOM Conversion Factor", "category": _(d.get("category")), @@ -412,7 +369,7 @@ def add_uom_data(): "to_uom": _(d.get("to_uom")), "value": d.get("value"), } - ).insert(ignore_permissions=True) + ).db_insert() def add_market_segments(): @@ -468,7 +425,7 @@ def install_company(args): make_records(records) -def install_defaults(args=None): +def install_defaults(args=None): # nosemgrep records = [ # Price Lists { @@ -493,7 +450,7 @@ def install_defaults(args=None): # enable default currency frappe.db.set_value("Currency", args.get("currency"), "enabled", 1) - frappe.db.set_value("Stock Settings", None, "email_footer_address", args.get("company_name")) + frappe.db.set_single_value("Stock Settings", "email_footer_address", args.get("company_name")) set_global_defaults(args) update_stock_settings() @@ -540,7 +497,8 @@ def create_bank_account(args): company_name = args.get("company_name") bank_account_group = frappe.db.get_value( - "Account", {"account_type": "Bank", "is_group": 1, "root_type": "Asset", "company": company_name} + "Account", + {"account_type": "Bank", "is_group": 1, "root_type": "Asset", "company": company_name}, ) if bank_account_group: bank_account = frappe.get_doc( diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py index 2f77dd6ae567..49ba78c63a42 100644 --- a/erpnext/setup/setup_wizard/operations/taxes_setup.py +++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py @@ -158,6 +158,7 @@ def make_taxes_and_charges_template(company_name, doctype, template): # Ingone validations to make doctypes faster doc.flags.ignore_links = True doc.flags.ignore_validate = True + doc.flags.ignore_mandatory = True doc.insert(ignore_permissions=True) return doc diff --git a/erpnext/startup/boot.py b/erpnext/startup/boot.py index bb120eaa6b31..62936fcfb896 100644 --- a/erpnext/startup/boot.py +++ b/erpnext/startup/boot.py @@ -25,6 +25,12 @@ def boot_session(bootinfo): frappe.db.get_single_value("CRM Settings", "default_valid_till") ) + bootinfo.sysdefaults.allow_sales_order_creation_for_expired_quotation = cint( + frappe.db.get_single_value( + "Selling Settings", "allow_sales_order_creation_for_expired_quotation" + ) + ) + # if no company, show a dialog box to create a new company bootinfo.customer_count = frappe.db.sql("""SELECT count(*) FROM `tabCustomer`""")[0][0] diff --git a/erpnext/stock/dashboard/item_dashboard.js b/erpnext/stock/dashboard/item_dashboard.js index 6e7622c067f0..bef438f9fd72 100644 --- a/erpnext/stock/dashboard/item_dashboard.js +++ b/erpnext/stock/dashboard/item_dashboard.js @@ -42,7 +42,7 @@ erpnext.stock.ItemDashboard = class ItemDashboard { let warehouse = unescape(element.attr('data-warehouse')); let actual_qty = unescape(element.attr('data-actual_qty')); let disable_quick_entry = Number(unescape(element.attr('data-disable_quick_entry'))); - let entry_type = action === "Move" ? "Material Transfer" : null; + let entry_type = action === "Move" ? "Material Transfer" : "Material Receipt"; if (disable_quick_entry) { open_stock_entry(item, warehouse, entry_type); @@ -63,11 +63,19 @@ erpnext.stock.ItemDashboard = class ItemDashboard { function open_stock_entry(item, warehouse, entry_type) { frappe.model.with_doctype('Stock Entry', function () { var doc = frappe.model.get_new_doc('Stock Entry'); - if (entry_type) doc.stock_entry_type = entry_type; + if (entry_type) { + doc.stock_entry_type = entry_type; + } var row = frappe.model.add_child(doc, 'items'); row.item_code = item; - row.s_warehouse = warehouse; + + if (entry_type === "Material Transfer") { + row.s_warehouse = warehouse; + } + else { + row.t_warehouse = warehouse; + } frappe.set_route('Form', doc.doctype, doc.name); }); diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index f14288beb20f..4a165212dcec 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -6,7 +6,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.naming import make_autoname, revert_series_if_last -from frappe.utils import cint, flt, get_link_to_form +from frappe.utils import cint, flt, get_link_to_form, nowtime from frappe.utils.data import add_days from frappe.utils.jinja import render_template @@ -179,7 +179,11 @@ def get_batch_qty( out = 0 if batch_no and warehouse: cond = "" - if posting_date and posting_time: + + if posting_date: + if posting_time is None: + posting_time = nowtime() + cond = " and timestamp(posting_date, posting_time) <= timestamp('{0}', '{1}')".format( posting_date, posting_time ) diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 9f409d4b96a0..72654e6f8168 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -159,13 +159,18 @@ def update_qty(bin_name, args): last_sle_qty = ( frappe.qb.from_(sle) .select(sle.qty_after_transaction) - .where((sle.item_code == args.get("item_code")) & (sle.warehouse == args.get("warehouse"))) + .where( + (sle.item_code == args.get("item_code")) + & (sle.warehouse == args.get("warehouse")) + & (sle.is_cancelled == 0) + ) .orderby(CombineDatetime(sle.posting_date, sle.posting_time), order=Order.desc) .orderby(sle.creation, order=Order.desc) .limit(1) .run() ) + actual_qty = 0.0 if last_sle_qty: actual_qty = last_sle_qty[0][0] diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index ea3cf1948b3c..ae56645b7306 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -97,12 +97,12 @@ frappe.ui.form.on("Delivery Note", { } if (frm.doc.docstatus == 1 && !frm.doc.inter_company_reference) { - let internal = me.frm.doc.is_internal_customer; + let internal = frm.doc.is_internal_customer; if (internal) { - let button_label = (me.frm.doc.company === me.frm.doc.represents_company) ? "Internal Purchase Receipt" : + let button_label = (frm.doc.company === frm.doc.represents_company) ? "Internal Purchase Receipt" : "Inter Company Purchase Receipt"; - me.frm.add_custom_button(button_label, function() { + frm.add_custom_button(__(button_label), function() { frappe.model.open_mapped_doc({ method: 'erpnext.stock.doctype.delivery_note.delivery_note.make_inter_company_purchase_receipt', frm: frm, diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 165a56b78390..0c1f82029e61 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -521,6 +521,7 @@ "allow_bulk_edit": 1, "fieldname": "items", "fieldtype": "Table", + "label": "Delivery Note Item", "oldfieldname": "delivery_note_details", "oldfieldtype": "Table", "options": "Delivery Note Item", @@ -666,6 +667,7 @@ { "fieldname": "taxes", "fieldtype": "Table", + "label": "Sales Taxes and Charges", "oldfieldname": "other_charges", "oldfieldtype": "Table", "options": "Sales Taxes and Charges" @@ -1401,7 +1403,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2022-12-12 18:38:53.067799", + "modified": "2023-02-14 04:45:44.179670", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index a1df764ea9d2..9f9f5cbe2a4c 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -228,6 +228,7 @@ def update_current_stock(self): def on_submit(self): self.validate_packed_qty() + self.update_pick_list_status() # Check for Approving Authority frappe.get_doc("Authorization Control").validate_approving_authority( @@ -313,6 +314,11 @@ def validate_packed_qty(self): if has_error: raise frappe.ValidationError + def update_pick_list_status(self): + from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status + + update_pick_list_status(self.pick_list) + def check_next_docstatus(self): submit_rv = frappe.db.sql( """select t1.name diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js index 9e6f3bc93217..6ff3ed3e8e5a 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js @@ -14,7 +14,7 @@ frappe.listview_settings['Delivery Note'] = { return [__("Completed"), "green", "per_billed,=,100"]; } }, - onload: function (listview) { + onload: function (doclist) { const action = () => { const selected_docs = doclist.get_checked_items(); const docnames = doclist.get_checked_items(true); @@ -56,14 +56,14 @@ frappe.listview_settings['Delivery Note'] = { // doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false); - listview.page.add_action_item(__('Create Delivery Trip'), action); + doclist.page.add_action_item(__('Create Delivery Trip'), action); - listview.page.add_action_item(__("Sales Invoice"), ()=>{ - erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Sales Invoice"); + doclist.page.add_action_item(__("Sales Invoice"), ()=>{ + erpnext.bulk_transaction_processing.create(doclist, "Delivery Note", "Sales Invoice"); }); - listview.page.add_action_item(__("Packaging Slip From Delivery Note"), ()=>{ - erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Packing Slip"); + doclist.page.add_action_item(__("Packaging Slip From Delivery Note"), ()=>{ + erpnext.bulk_transaction_processing.create(doclist, "Delivery Note", "Packing Slip"); }); } }; diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index d747383d6a53..903e2af3cb3a 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -490,6 +490,46 @@ def test_return_entire_bundled_items(self): self.assertEqual(gle_warehouse_amount, 1400) + def test_bin_details_of_packed_item(self): + from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle + from erpnext.stock.doctype.item.test_item import make_item + + # test Update Items with product bundle + if not frappe.db.exists("Item", "_Test Product Bundle Item New"): + bundle_item = make_item("_Test Product Bundle Item New", {"is_stock_item": 0}) + bundle_item.append( + "item_defaults", {"company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC"} + ) + bundle_item.save(ignore_permissions=True) + + make_item("_Packed Item New 1", {"is_stock_item": 1}) + make_product_bundle("_Test Product Bundle Item New", ["_Packed Item New 1"], 2) + + si = create_delivery_note( + item_code="_Test Product Bundle Item New", + update_stock=1, + warehouse="_Test Warehouse - _TC", + transaction_date=add_days(nowdate(), -1), + do_not_submit=1, + ) + + make_stock_entry(item="_Packed Item New 1", target="_Test Warehouse - _TC", qty=120, rate=100) + + bin_details = frappe.db.get_value( + "Bin", + {"item_code": "_Packed Item New 1", "warehouse": "_Test Warehouse - _TC"}, + ["actual_qty", "projected_qty", "ordered_qty"], + as_dict=1, + ) + + si.transaction_date = nowdate() + si.save() + + packed_item = si.packed_items[0] + self.assertEqual(flt(bin_details.actual_qty), flt(packed_item.actual_qty)) + self.assertEqual(flt(bin_details.projected_qty), flt(packed_item.projected_qty)) + self.assertEqual(flt(bin_details.ordered_qty), flt(packed_item.ordered_qty)) + def test_return_for_serialized_items(self): se = make_serialized_item() serial_no = get_serial_nos(se.get("items")[0].serial_no)[0] @@ -650,6 +690,11 @@ def test_closed_delivery_note(self): update_delivery_note_status(dn.name, "Closed") self.assertEqual(frappe.db.get_value("Delivery Note", dn.name, "Status"), "Closed") + # Check cancelling closed delivery note + dn.load_from_db() + dn.cancel() + self.assertEqual(dn.status, "Cancelled") + def test_dn_billing_status_case1(self): # SO -> DN -> SI so = make_sales_order() diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index 916ab2a05bea..1763269193ad 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -636,7 +636,8 @@ "no_copy": 1, "options": "Sales Invoice", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "so_detail", @@ -837,7 +838,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-11-09 12:17:50.850142", + "modified": "2023-03-20 14:24:10.406746", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js index ba1023ac691f..0310682a2c17 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js @@ -37,7 +37,7 @@ frappe.ui.form.on('Inventory Dimension', { if (frm.doc.__onload && frm.doc.__onload.has_stock_ledger && frm.doc.__onload.has_stock_ledger.length) { let allow_to_edit_fields = ['disabled', 'fetch_from_parent', - 'type_of_transaction', 'condition']; + 'type_of_transaction', 'condition', 'mandatory_depends_on']; frm.fields.forEach((field) => { if (!in_list(allow_to_edit_fields, field.df.fieldname)) { diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json index 4397e11f540c..eb6102a436e3 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json @@ -24,6 +24,9 @@ "istable", "applicable_condition_example_section", "condition", + "conditional_mandatory_section", + "reqd", + "mandatory_depends_on", "conditional_rule_examples_section", "html_19" ], @@ -153,11 +156,28 @@ "fieldname": "conditional_rule_examples_section", "fieldtype": "Section Break", "label": "Conditional Rule Examples" + }, + { + "description": "To apply condition on parent field use parent.field_name and to apply condition on child table use doc.field_name. Here field_name could be based on the actual column name of the respective field.", + "fieldname": "mandatory_depends_on", + "fieldtype": "Small Text", + "label": "Mandatory Depends On" + }, + { + "fieldname": "conditional_mandatory_section", + "fieldtype": "Section Break", + "label": "Mandatory Section" + }, + { + "default": "0", + "fieldname": "reqd", + "fieldtype": "Check", + "label": "Mandatory" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2022-11-15 15:50:16.767105", + "modified": "2023-01-31 13:44:38.507698", "modified_by": "Administrator", "module": "Stock", "name": "Inventory Dimension", diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index 009548abf26d..db2b5d0a6b60 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -126,6 +126,8 @@ def add_custom_fields(self): insert_after="inventory_dimension", options=self.reference_document, label=self.dimension_name, + reqd=self.reqd, + mandatory_depends_on=self.mandatory_depends_on, ), ] @@ -142,6 +144,8 @@ def add_custom_fields(self): "Custom Field", {"dt": "Stock Ledger Entry", "fieldname": self.target_fieldname} ) and not field_exists("Stock Ledger Entry", self.target_fieldname): dimension_field = dimension_fields[1] + dimension_field["mandatory_depends_on"] = "" + dimension_field["reqd"] = 0 dimension_field["fieldname"] = self.target_fieldname custom_fields["Stock Ledger Entry"] = dimension_field diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index edff3fd556c1..28b1ed96f0d4 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -85,6 +85,9 @@ def test_inventory_dimension(self): condition="parent.purpose == 'Material Issue'", ) + inv_dim1.reqd = 0 + inv_dim1.save() + create_inventory_dimension( reference_document="Shelf", type_of_transaction="Inward", @@ -205,6 +208,48 @@ def test_check_standard_dimensions(self): ) ) + def test_check_mandatory_dimensions(self): + doc = create_inventory_dimension( + reference_document="Pallet", + type_of_transaction="Outward", + dimension_name="Pallet", + apply_to_all_doctypes=0, + document_type="Stock Entry Detail", + ) + + doc.reqd = 1 + doc.save() + + self.assertTrue( + frappe.db.get_value( + "Custom Field", {"fieldname": "pallet", "dt": "Stock Entry Detail", "reqd": 1}, "name" + ) + ) + + doc.load_from_db + doc.reqd = 0 + doc.save() + + def test_check_mandatory_depends_on_dimensions(self): + doc = create_inventory_dimension( + reference_document="Pallet", + type_of_transaction="Outward", + dimension_name="Pallet", + apply_to_all_doctypes=0, + document_type="Stock Entry Detail", + ) + + doc.mandatory_depends_on = "t_warehouse" + doc.save() + + self.assertTrue( + frappe.db.get_value( + "Custom Field", + {"fieldname": "pallet", "dt": "Stock Entry Detail", "mandatory_depends_on": "t_warehouse"}, + "name", + ) + ) + def prepare_test_data(): if not frappe.db.exists("DocType", "Shelf"): @@ -251,6 +296,22 @@ def prepare_test_data(): create_warehouse("Rack Warehouse") + if not frappe.db.exists("DocType", "Pallet"): + frappe.get_doc( + { + "doctype": "DocType", + "name": "Pallet", + "module": "Stock", + "custom": 1, + "naming_rule": "By fieldname", + "autoname": "field:pallet_name", + "fields": [{"label": "Pallet Name", "fieldname": "pallet_name", "fieldtype": "Data"}], + "permissions": [ + {"role": "System Manager", "permlevel": 0, "read": 1, "write": 1, "create": 1, "delete": 1} + ], + } + ).insert(ignore_permissions=True) + def create_inventory_dimension(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index e61f0f514e31..9a9ddf440443 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -33,6 +33,9 @@ frappe.ui.form.on("Item", { 'Material Request': () => { open_form(frm, "Material Request", "Material Request Item", "items"); }, + 'Stock Entry': () => { + open_form(frm, "Stock Entry", "Stock Entry Detail", "items"); + }, }; }, @@ -893,7 +896,16 @@ function open_form(frm, doctype, child_doctype, parentfield) { new_child_doc.item_name = frm.doc.item_name; new_child_doc.uom = frm.doc.stock_uom; new_child_doc.description = frm.doc.description; + if (!new_child_doc.qty) { + new_child_doc.qty = 1.0; + } - frappe.ui.form.make_quick_entry(doctype, null, null, new_doc); + frappe.run_serially([ + () => frappe.ui.form.make_quick_entry(doctype, null, null, new_doc), + () => { + frappe.flags.ignore_company_party_validation = true; + frappe.model.trigger("item_code", frm.doc.name, new_child_doc); + } + ]) }); } diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index d1d228dfdc65..e519b9b2133f 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -706,7 +706,7 @@ "depends_on": "enable_deferred_expense", "fieldname": "no_of_months_exp", "fieldtype": "Int", - "label": "No of Months" + "label": "No of Months (Expense)" }, { "collapsible": 1, @@ -911,7 +911,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2022-09-13 04:08:17.431731", + "modified": "2023-02-14 04:48:26.343620", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 20bc9d9b2c92..423b9defc191 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -164,10 +164,7 @@ def set_opening_stock(self): if not self.is_stock_item or self.has_serial_no or self.has_batch_no: return - if not self.valuation_rate and self.standard_rate: - self.valuation_rate = self.standard_rate - - if not self.valuation_rate and not self.is_customer_provided_item: + if not self.valuation_rate and not self.standard_rate and not self.is_customer_provided_item: frappe.throw(_("Valuation Rate is mandatory if Opening Stock entered")) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry @@ -192,7 +189,7 @@ def set_opening_stock(self): item_code=self.name, target=default_warehouse, qty=self.opening_stock, - rate=self.valuation_rate, + rate=self.valuation_rate or self.standard_rate, company=default.company, posting_date=getdate(), posting_time=nowtime(), @@ -279,7 +276,7 @@ def validate_item_tax_net_rate_range(self): frappe.throw(_("Row #{0}: Maximum Net Rate cannot be greater than Minimum Net Rate")) def update_template_tables(self): - template = frappe.get_doc("Item", self.variant_of) + template = frappe.get_cached_doc("Item", self.variant_of) # add item taxes from template for d in template.get("taxes"): diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 7e426ae4af8e..53f6b7f8f17e 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -106,7 +106,6 @@ def test_get_item_details(self): "conversion_factor": 1.0, "reserved_qty": 1, "actual_qty": 5, - "ordered_qty": 10, "projected_qty": 14, } diff --git a/erpnext/stock/doctype/item_alternative/item_alternative.py b/erpnext/stock/doctype/item_alternative/item_alternative.py index fb1a28d846bd..0c24d3c780f1 100644 --- a/erpnext/stock/doctype/item_alternative/item_alternative.py +++ b/erpnext/stock/doctype/item_alternative/item_alternative.py @@ -54,7 +54,7 @@ def validate_alternative_item(self): if not item_data.allow_alternative_item: frappe.throw(alternate_item_check_msg.format(self.item_code)) if self.two_way and not alternative_item_data.allow_alternative_item: - frappe.throw(alternate_item_check_msg.format(self.item_code)) + frappe.throw(alternate_item_check_msg.format(self.alternative_item_code)) def validate_duplicate(self): if frappe.db.get_value( diff --git a/erpnext/stock/doctype/item_attribute/item_attribute.py b/erpnext/stock/doctype/item_attribute/item_attribute.py index 391ff06918a9..ac4c313e28a8 100644 --- a/erpnext/stock/doctype/item_attribute/item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/item_attribute.py @@ -74,11 +74,10 @@ def validate_numeric(self): def validate_duplication(self): values, abbrs = [], [] for d in self.item_attribute_values: - d.abbr = d.abbr.upper() - if d.attribute_value in values: - frappe.throw(_("{0} must appear only once").format(d.attribute_value)) + if d.attribute_value.lower() in map(str.lower, values): + frappe.throw(_("Attribute value: {0} must appear only once").format(d.attribute_value.title())) values.append(d.attribute_value) - if d.abbr in abbrs: - frappe.throw(_("{0} must appear only once").format(d.abbr)) + if d.abbr.lower() in map(str.lower, abbrs): + frappe.throw(_("Abbreviation: {0} must appear only once").format(d.abbr.title())) abbrs.append(d.abbr) diff --git a/erpnext/stock/doctype/item_price/item_price.js b/erpnext/stock/doctype/item_price/item_price.js index 12cf6cf84d59..ce489ff52b4d 100644 --- a/erpnext/stock/doctype/item_price/item_price.js +++ b/erpnext/stock/doctype/item_price/item_price.js @@ -2,7 +2,18 @@ // License: GNU General Public License v3. See license.txt frappe.ui.form.on("Item Price", { - onload: function (frm) { + setup(frm) { + frm.set_query("item_code", function() { + return { + filters: { + "disabled": 0, + "has_variants": 0 + } + }; + }); + }, + + onload(frm) { // Fetch price list details frm.add_fetch("price_list", "buying", "buying"); frm.add_fetch("price_list", "selling", "selling"); diff --git a/erpnext/stock/doctype/item_price/item_price.py b/erpnext/stock/doctype/item_price/item_price.py index bcd31ada83e1..54d1ae634f53 100644 --- a/erpnext/stock/doctype/item_price/item_price.py +++ b/erpnext/stock/doctype/item_price/item_price.py @@ -3,7 +3,7 @@ import frappe -from frappe import _ +from frappe import _, bold from frappe.model.document import Document from frappe.query_builder import Criterion from frappe.query_builder.functions import Cast_ @@ -21,6 +21,7 @@ def validate(self): self.update_price_list_details() self.update_item_details() self.check_duplicates() + self.validate_item_template() def validate_item(self): if not frappe.db.exists("Item", self.item_code): @@ -49,6 +50,12 @@ def update_item_details(self): "Item", self.item_code, ["item_name", "description"] ) + def validate_item_template(self): + if frappe.get_cached_value("Item", self.item_code, "has_variants"): + msg = f"Item Price cannot be created for the template item {bold(self.item_code)}" + + frappe.throw(_(msg)) + def check_duplicates(self): item_price = frappe.qb.DocType("Item Price") diff --git a/erpnext/stock/doctype/item_price/test_item_price.py b/erpnext/stock/doctype/item_price/test_item_price.py index 30d933e247de..8fd4938fa353 100644 --- a/erpnext/stock/doctype/item_price/test_item_price.py +++ b/erpnext/stock/doctype/item_price/test_item_price.py @@ -16,6 +16,28 @@ def setUp(self): frappe.db.sql("delete from `tabItem Price`") make_test_records_for_doctype("Item Price", force=True) + def test_template_item_price(self): + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item( + "Test Template Item 1", + { + "has_variants": 1, + "variant_based_on": "Manufacturer", + }, + ) + + doc = frappe.get_doc( + { + "doctype": "Item Price", + "price_list": "_Test Price List", + "item_code": item.name, + "price_list_rate": 100, + } + ) + + self.assertRaises(frappe.ValidationError, doc.save) + def test_duplicate_item(self): doc = frappe.copy_doc(test_records[0]) self.assertRaises(ItemPriceDuplicateItem, doc.save) diff --git a/erpnext/stock/doctype/item_price/test_records.json b/erpnext/stock/doctype/item_price/test_records.json index 0a3d7e81985f..afe5ad65b756 100644 --- a/erpnext/stock/doctype/item_price/test_records.json +++ b/erpnext/stock/doctype/item_price/test_records.json @@ -38,5 +38,19 @@ "price_list_rate": 1000, "valid_from": "2017-04-10", "valid_upto": "2017-04-17" + }, + { + "doctype": "Item Price", + "item_code": "_Test Item", + "price_list": "_Test Buying Price List", + "price_list_rate": 100, + "supplier": "_Test Supplier" + }, + { + "doctype": "Item Price", + "item_code": "_Test Item", + "price_list": "_Test Selling Price List", + "price_list_rate": 200, + "customer": "_Test Customer" } ] diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index b3af309359a3..111a0861b719 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -55,7 +55,6 @@ def validate(self): self.get_items_from_purchase_receipts() self.set_applicable_charges_on_item() - self.validate_applicable_charges_for_item() def check_mandatory(self): if not self.get("purchase_receipts"): @@ -115,6 +114,13 @@ def set_applicable_charges_on_item(self): total_item_cost += item.get(based_on_field) for item in self.get("items"): + if not total_item_cost and not item.get(based_on_field): + frappe.throw( + _( + "It's not possible to distribute charges equally when total amount is zero, please set 'Distribute Charges Based On' as 'Quantity'" + ) + ) + item.applicable_charges = flt( flt(item.get(based_on_field)) * (flt(self.total_taxes_and_charges) / flt(total_item_cost)), item.precision("applicable_charges"), @@ -162,6 +168,7 @@ def validate_applicable_charges_for_item(self): ) def on_submit(self): + self.validate_applicable_charges_for_item() self.update_landed_cost() def on_cancel(self): diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 979b5c4f838d..00fa1686c0d3 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -175,6 +175,59 @@ def test_landed_cost_voucher_stock_impact(self): ) self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 50.0) + def test_landed_cost_voucher_for_zero_purchase_rate(self): + "Test impact of LCV on future stock balances." + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item("LCV Stock Item", {"is_stock_item": 1}) + warehouse = "Stores - _TC" + + pr = make_purchase_receipt( + item_code=item.name, + warehouse=warehouse, + qty=10, + rate=0, + posting_date=add_days(frappe.utils.nowdate(), -2), + ) + + self.assertEqual( + frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0}, + "stock_value_difference", + ), + 0, + ) + + lcv = make_landed_cost_voucher( + company=pr.company, + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=100, + distribute_charges_based_on="Distribute Manually", + do_not_save=True, + ) + + lcv.get_items_from_purchase_receipts() + lcv.items[0].applicable_charges = 100 + lcv.save() + lcv.submit() + + self.assertTrue( + frappe.db.exists( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0}, + ) + ) + self.assertEqual( + frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0}, + "stock_value_difference", + ), + 100, + ) + def test_landed_cost_voucher_against_purchase_invoice(self): pi = make_purchase_invoice( @@ -516,7 +569,7 @@ def make_landed_cost_voucher(**args): lcv = frappe.new_doc("Landed Cost Voucher") lcv.company = args.company or "_Test Company" - lcv.distribute_charges_based_on = "Amount" + lcv.distribute_charges_based_on = args.distribute_charges_based_on or "Amount" lcv.set( "purchase_receipts", diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 5f05de6991b2..b096b024f44c 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -108,10 +108,13 @@ frappe.ui.form.on('Material Request', { () => frm.events.create_pick_list(frm), __('Create')); } - if (frm.doc.material_request_type === "Material Transfer") { + if (frm.doc.material_request_type === 'Material Transfer') { add_create_pick_list_button(); - frm.add_custom_button(__("Transfer Material"), + frm.add_custom_button(__('Material Transfer'), () => frm.events.make_stock_entry(frm), __('Create')); + + frm.add_custom_button(__('Material Transfer (In Transit)'), + () => frm.events.make_in_transit_stock_entry(frm), __('Create')); } if (frm.doc.material_request_type === "Material Issue") { @@ -333,6 +336,46 @@ frappe.ui.form.on('Material Request', { }); }, + make_in_transit_stock_entry(frm) { + frappe.prompt( + [ + { + label: __('In Transit Warehouse'), + fieldname: 'in_transit_warehouse', + fieldtype: 'Link', + options: 'Warehouse', + reqd: 1, + get_query: () => { + return{ + filters: { + 'company': frm.doc.company, + 'is_group': 0, + 'warehouse_type': 'Transit' + } + } + } + } + ], + (values) => { + frappe.call({ + method: "erpnext.stock.doctype.material_request.material_request.make_in_transit_stock_entry", + args: { + source_name: frm.doc.name, + in_transit_warehouse: values.in_transit_warehouse + }, + callback: function(r) { + if (r.message) { + let doc = frappe.model.sync(r.message); + frappe.set_route('Form', doc[0].doctype, doc[0].name); + } + } + }) + }, + __('In Transit Transfer'), + __('Create Stock Entry') + ) + }, + create_pick_list: (frm) => { frappe.model.open_mapped_doc({ method: "erpnext.stock.doctype.material_request.material_request.create_pick_list", @@ -366,10 +409,11 @@ frappe.ui.form.on('Material Request', { frappe.ui.form.on("Material Request Item", { qty: function (frm, doctype, name) { - var d = locals[doctype][name]; - if (flt(d.qty) < flt(d.min_order_qty)) { + const item = locals[doctype][name]; + if (flt(item.qty) < flt(item.min_order_qty)) { frappe.msgprint(__("Warning: Material Requested Qty is less than Minimum Order Qty")); } + frm.events.get_item_data(frm, item, false); }, from_warehouse: function(frm, doctype, name) { diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index afad7511be67..3548148145e9 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -10,6 +10,7 @@ import frappe from frappe import _, msgprint from frappe.model.mapper import get_mapped_doc +from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_sep, nowdate from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items @@ -183,6 +184,34 @@ def on_cancel(self): self.update_requested_qty() self.update_requested_qty_in_production_plan() + def get_mr_items_ordered_qty(self, mr_items): + mr_items_ordered_qty = {} + mr_items = [d.name for d in self.get("items") if d.name in mr_items] + + doctype = qty_field = None + if self.material_request_type in ("Material Issue", "Material Transfer", "Customer Provided"): + doctype = frappe.qb.DocType("Stock Entry Detail") + qty_field = doctype.transfer_qty + elif self.material_request_type == "Manufacture": + doctype = frappe.qb.DocType("Work Order") + qty_field = doctype.qty + + if doctype and qty_field: + query = ( + frappe.qb.from_(doctype) + .select(doctype.material_request_item, Sum(qty_field)) + .where( + (doctype.material_request == self.name) + & (doctype.material_request_item.isin(mr_items)) + & (doctype.docstatus == 1) + ) + .groupby(doctype.material_request_item) + ) + + mr_items_ordered_qty = frappe._dict(query.run()) + + return mr_items_ordered_qty + def update_completed_qty(self, mr_items=None, update_modified=True): if self.material_request_type == "Purchase": return @@ -190,18 +219,13 @@ def update_completed_qty(self, mr_items=None, update_modified=True): if not mr_items: mr_items = [d.name for d in self.get("items")] + mr_items_ordered_qty = self.get_mr_items_ordered_qty(mr_items) + mr_qty_allowance = frappe.db.get_single_value("Stock Settings", "mr_qty_allowance") + for d in self.get("items"): if d.name in mr_items: if self.material_request_type in ("Material Issue", "Material Transfer", "Customer Provided"): - d.ordered_qty = flt( - frappe.db.sql( - """select sum(transfer_qty) - from `tabStock Entry Detail` where material_request = %s - and material_request_item = %s and docstatus = 1""", - (self.name, d.name), - )[0][0] - ) - mr_qty_allowance = frappe.db.get_single_value("Stock Settings", "mr_qty_allowance") + d.ordered_qty = flt(mr_items_ordered_qty.get(d.name)) if mr_qty_allowance: allowed_qty = d.qty + (d.qty * (mr_qty_allowance / 100)) @@ -220,14 +244,7 @@ def update_completed_qty(self, mr_items=None, update_modified=True): ) elif self.material_request_type == "Manufacture": - d.ordered_qty = flt( - frappe.db.sql( - """select sum(qty) - from `tabWork Order` where material_request = %s - and material_request_item = %s and docstatus = 1""", - (self.name, d.name), - )[0][0] - ) + d.ordered_qty = flt(mr_items_ordered_qty.get(d.name)) frappe.db.set_value(d.doctype, d.name, "ordered_qty", d.ordered_qty) @@ -590,6 +607,9 @@ def update_item(obj, target, source_parent): def set_missing_values(source, target): target.purpose = source.material_request_type + target.from_warehouse = source.set_from_warehouse + target.to_warehouse = source.set_warehouse + if source.job_card: target.purpose = "Material Transfer for Manufacture" @@ -719,3 +739,15 @@ def create_pick_list(source_name, target_doc=None): doc.set_item_locations() return doc + + +@frappe.whitelist() +def make_in_transit_stock_entry(source_name, in_transit_warehouse): + ste_doc = make_stock_entry(source_name) + ste_doc.add_to_transit = 1 + ste_doc.to_warehouse = in_transit_warehouse + + for row in ste_doc.items: + row.t_warehouse = in_transit_warehouse + + return ste_doc diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index f0a94997fe83..a707c74c7db4 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -11,6 +11,7 @@ from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.material_request.material_request import ( + make_in_transit_stock_entry, make_purchase_order, make_stock_entry, make_supplier_quotation, @@ -56,6 +57,22 @@ def test_make_stock_entry(self): self.assertEqual(se.doctype, "Stock Entry") self.assertEqual(len(se.get("items")), len(mr.get("items"))) + def test_in_transit_make_stock_entry(self): + mr = frappe.copy_doc(test_records[0]).insert() + + self.assertRaises(frappe.ValidationError, make_stock_entry, mr.name) + + mr = frappe.get_doc("Material Request", mr.name) + mr.material_request_type = "Material Transfer" + mr.submit() + + in_transit_warehouse = get_in_transit_warehouse(mr.company) + se = make_in_transit_stock_entry(mr.name, in_transit_warehouse) + + self.assertEqual(se.doctype, "Stock Entry") + for row in se.get("items"): + self.assertEqual(row.t_warehouse, in_transit_warehouse) + def _insert_stock_entry(self, qty1, qty2, warehouse=None): se = frappe.get_doc( { @@ -742,6 +759,36 @@ def test_customer_provided_parts_mr(self): self.assertEqual(existing_requested_qty, current_requested_qty) +def get_in_transit_warehouse(company): + if not frappe.db.exists("Warehouse Type", "Transit"): + frappe.get_doc( + { + "doctype": "Warehouse Type", + "name": "Transit", + } + ).insert() + + in_transit_warehouse = frappe.db.exists( + "Warehouse", {"warehouse_type": "Transit", "company": company} + ) + + if not in_transit_warehouse: + in_transit_warehouse = ( + frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": "Transit", + "warehouse_type": "Transit", + "company": company, + } + ) + .insert() + .name + ) + + return in_transit_warehouse + + def make_material_request(**args): args = frappe._dict(args) mr = frappe.new_doc("Material Request") diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index d60675166604..dbd8de4fcb0e 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -83,8 +83,8 @@ def reset_packing_list(doc): # 1. items were deleted # 2. if bundle item replaced by another item (same no. of items but different items) # we maintain list to track recurring item rows as well - items_before_save = [item.item_code for item in doc_before_save.get("items")] - items_after_save = [item.item_code for item in doc.get("items")] + items_before_save = [(item.name, item.item_code) for item in doc_before_save.get("items")] + items_after_save = [(item.name, item.item_code) for item in doc.get("items")] reset_table = items_before_save != items_after_save else: # reset: if via Update Items OR diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index e1c3f0f50618..7259dc00a81b 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -26,7 +26,8 @@ "locations", "amended_from", "print_settings_section", - "group_same_items" + "group_same_items", + "status" ], "fields": [ { @@ -168,11 +169,26 @@ "fieldtype": "Data", "label": "Customer Name", "read_only": 1 + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "in_standard_filter": 1, + "label": "Status", + "no_copy": 1, + "options": "Draft\nOpen\nCompleted\nCancelled", + "print_hide": 1, + "read_only": 1, + "report_hide": 1, + "reqd": 1, + "search_index": 1 } ], "is_submittable": 1, "links": [], - "modified": "2022-07-19 11:03:04.442174", + "modified": "2023-01-24 10:33:43.244476", "modified_by": "Administrator", "module": "Stock", "name": "Pick List", @@ -244,4 +260,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 65a792fb46b7..bf3b5ddc54a4 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -4,14 +4,15 @@ import json from collections import OrderedDict, defaultdict from itertools import groupby -from typing import Dict, List, Set +from typing import Dict, List import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import map_child_doc from frappe.query_builder import Case -from frappe.query_builder.functions import Locate +from frappe.query_builder.custom import GROUP_CONCAT +from frappe.query_builder.functions import Coalesce, IfNull, Locate, Replace, Sum from frappe.utils import cint, floor, flt, today from frappe.utils.nestedset import get_descendants_of @@ -41,7 +42,9 @@ def before_save(self): ) def before_submit(self): - update_sales_orders = set() + self.validate_picked_items() + + def validate_picked_items(self): for item in self.locations: if self.scan_mode and item.picked_qty < item.stock_qty: frappe.throw( @@ -50,17 +53,14 @@ def before_submit(self): ).format(item.idx, item.stock_qty - item.picked_qty, item.stock_uom), title=_("Pick List Incomplete"), ) - elif not self.scan_mode and item.picked_qty == 0: + + if not self.scan_mode and item.picked_qty == 0: # if the user has not entered any picked qty, set it to stock_qty, before submit item.picked_qty = item.stock_qty - if item.sales_order_item: - # update the picked_qty in SO Item - self.update_sales_order_item(item, item.picked_qty, item.item_code) - update_sales_orders.add(item.sales_order) - if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"): continue + if not item.serial_no: frappe.throw( _("Row #{0}: {1} does not have any available serial numbers in {2}").format( @@ -68,63 +68,119 @@ def before_submit(self): ), title=_("Serial Nos Required"), ) - if len(item.serial_no.split("\n")) == item.picked_qty: - continue - frappe.throw( - _( - "For item {0} at row {1}, count of serial numbers does not match with the picked quantity" - ).format(frappe.bold(item.item_code), frappe.bold(item.idx)), - title=_("Quantity Mismatch"), - ) + if len(item.serial_no.split("\n")) != item.picked_qty: + frappe.throw( + _( + "For item {0} at row {1}, count of serial numbers does not match with the picked quantity" + ).format(frappe.bold(item.item_code), frappe.bold(item.idx)), + title=_("Quantity Mismatch"), + ) + + def on_submit(self): + self.update_status() self.update_bundle_picked_qty() - self.update_sales_order_picking_status(update_sales_orders) + self.update_reference_qty() + self.update_sales_order_picking_status() - def before_cancel(self): - """Deduct picked qty on cancelling pick list""" - updated_sales_orders = set() + def on_cancel(self): + self.update_status() + self.update_bundle_picked_qty() + self.update_reference_qty() + self.update_sales_order_picking_status() + + def update_status(self, status=None, update_modified=True): + if not status: + if self.docstatus == 0: + status = "Draft" + elif self.docstatus == 1: + if self.status == "Draft": + status = "Open" + elif target_document_exists(self.name, self.purpose): + status = "Completed" + elif self.docstatus == 2: + status = "Cancelled" + + if status: + frappe.db.set_value("Pick List", self.name, "status", status, update_modified=update_modified) + + def update_reference_qty(self): + packed_items = [] + so_items = [] - for item in self.get("locations"): - if item.sales_order_item: - self.update_sales_order_item(item, -1 * item.picked_qty, item.item_code) - updated_sales_orders.add(item.sales_order) + for item in self.locations: + if item.product_bundle_item: + packed_items.append(item.sales_order_item) + elif item.sales_order_item: + so_items.append(item.sales_order_item) - self.update_bundle_picked_qty() - self.update_sales_order_picking_status(updated_sales_orders) + if packed_items: + self.update_packed_items_qty(packed_items) + + if so_items: + self.update_sales_order_item_qty(so_items) + + def update_packed_items_qty(self, packed_items): + picked_items = get_picked_items_qty(packed_items) + self.validate_picked_qty(picked_items) + + picked_qty = frappe._dict() + for d in picked_items: + picked_qty[d.sales_order_item] = d.picked_qty + + for packed_item in packed_items: + frappe.db.set_value( + "Packed Item", + packed_item, + "picked_qty", + flt(picked_qty.get(packed_item)), + update_modified=False, + ) - def update_sales_order_item(self, item, picked_qty, item_code): - item_table = "Sales Order Item" if not item.product_bundle_item else "Packed Item" - stock_qty_field = "stock_qty" if not item.product_bundle_item else "qty" + def update_sales_order_item_qty(self, so_items): + picked_items = get_picked_items_qty(so_items) + self.validate_picked_qty(picked_items) - already_picked, actual_qty = frappe.db.get_value( - item_table, - item.sales_order_item, - ["picked_qty", stock_qty_field], - for_update=True, + picked_qty = frappe._dict() + for d in picked_items: + picked_qty[d.sales_order_item] = d.picked_qty + + for so_item in so_items: + frappe.db.set_value( + "Sales Order Item", + so_item, + "picked_qty", + flt(picked_qty.get(so_item)), + update_modified=False, + ) + + def update_sales_order_picking_status(self) -> None: + sales_orders = [] + for row in self.locations: + if row.sales_order and row.sales_order not in sales_orders: + sales_orders.append(row.sales_order) + + for sales_order in sales_orders: + frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status() + + def validate_picked_qty(self, data): + over_delivery_receipt_allowance = 100 + flt( + frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance") ) - if self.docstatus == 1: - if (((already_picked + picked_qty) / actual_qty) * 100) > ( - 100 + flt(frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance")) - ): + for row in data: + if (row.picked_qty / row.stock_qty) * 100 > over_delivery_receipt_allowance: frappe.throw( _( - "You are picking more than required quantity for {}. Check if there is any other pick list created for {}" - ).format(item_code, item.sales_order) + f"You are picking more than required quantity for the item {row.item_code}. Check if there is any other pick list created for the sales order {row.sales_order}." + ) ) - frappe.db.set_value(item_table, item.sales_order_item, "picked_qty", already_picked + picked_qty) - - @staticmethod - def update_sales_order_picking_status(sales_orders: Set[str]) -> None: - for sales_order in sales_orders: - if sales_order: - frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status() - @frappe.whitelist() def set_item_locations(self, save=False): self.validate_for_qty() items = self.aggregate_item_qty() + picked_items_details = self.get_picked_items_details(items) self.item_location_map = frappe._dict() from_warehouses = None @@ -143,7 +199,11 @@ def set_item_locations(self, save=False): self.item_location_map.setdefault( item_code, get_available_item_locations( - item_code, from_warehouses, self.item_count_map.get(item_code), self.company + item_code, + from_warehouses, + self.item_count_map.get(item_code), + self.company, + picked_item_details=picked_items_details.get(item_code), ), ) @@ -230,7 +290,8 @@ def validate_for_qty(self): frappe.throw(_("Qty of Finished Goods Item should be greater than 0.")) def before_print(self, settings=None): - self.group_similar_items() + if self.group_same_items: + self.group_similar_items() def group_similar_items(self): group_item_qty = defaultdict(float) @@ -271,6 +332,56 @@ def update_bundle_picked_qty(self): already_picked + (picked_qty * (1 if self.docstatus == 1 else -1)), ) + def get_picked_items_details(self, items): + picked_items = frappe._dict() + + if items: + pi = frappe.qb.DocType("Pick List") + pi_item = frappe.qb.DocType("Pick List Item") + query = ( + frappe.qb.from_(pi) + .inner_join(pi_item) + .on(pi.name == pi_item.parent) + .select( + pi_item.item_code, + pi_item.warehouse, + pi_item.batch_no, + Sum(Case().when(pi_item.picked_qty > 0, pi_item.picked_qty).else_(pi_item.stock_qty)).as_( + "picked_qty" + ), + Replace(GROUP_CONCAT(pi_item.serial_no), ",", "\n").as_("serial_no"), + ) + .where( + (pi_item.item_code.isin([x.item_code for x in items])) + & ((pi_item.picked_qty > 0) | (pi_item.stock_qty > 0)) + & (pi.status != "Completed") + & (pi_item.docstatus != 2) + ) + .groupby( + pi_item.item_code, + pi_item.warehouse, + pi_item.batch_no, + ) + ) + + if self.name: + query = query.where(pi_item.parent != self.name) + + items_data = query.run(as_dict=True) + + for item_data in items_data: + key = (item_data.warehouse, item_data.batch_no) if item_data.batch_no else item_data.warehouse + serial_no = [x for x in item_data.serial_no.split("\n") if x] if item_data.serial_no else None + data = {"picked_qty": item_data.picked_qty} + if serial_no: + data["serial_no"] = serial_no + if item_data.item_code not in picked_items: + picked_items[item_data.item_code] = {key: data} + else: + picked_items[item_data.item_code][key] = data + + return picked_items + def _get_product_bundles(self) -> Dict[str, str]: # Dict[so_item_row: item_code] product_bundles = {} @@ -308,6 +419,32 @@ def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int: return int(flt(min(possible_bundles), precision or 6)) +def update_pick_list_status(pick_list): + if pick_list: + doc = frappe.get_doc("Pick List", pick_list) + doc.run_method("update_status") + + +def get_picked_items_qty(items) -> List[Dict]: + pi_item = frappe.qb.DocType("Pick List Item") + return ( + frappe.qb.from_(pi_item) + .select( + pi_item.sales_order_item, + pi_item.item_code, + pi_item.sales_order, + Sum(pi_item.stock_qty).as_("stock_qty"), + Sum(pi_item.picked_qty).as_("picked_qty"), + ) + .where((pi_item.docstatus == 1) & (pi_item.sales_order_item.isin(items))) + .groupby( + pi_item.sales_order_item, + pi_item.sales_order, + ) + .for_update() + ).run(as_dict=True) + + def validate_item_locations(pick_list): if not pick_list.locations: frappe.throw(_("Add items in the Item Locations table")) @@ -371,31 +508,38 @@ def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus) def get_available_item_locations( - item_code, from_warehouses, required_qty, company, ignore_validation=False + item_code, + from_warehouses, + required_qty, + company, + ignore_validation=False, + picked_item_details=None, ): locations = [] + total_picked_qty = ( + sum([v.get("picked_qty") for k, v in picked_item_details.items()]) if picked_item_details else 0 + ) has_serial_no = frappe.get_cached_value("Item", item_code, "has_serial_no") has_batch_no = frappe.get_cached_value("Item", item_code, "has_batch_no") if has_batch_no and has_serial_no: locations = get_available_item_locations_for_serial_and_batched_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty ) elif has_serial_no: locations = get_available_item_locations_for_serialized_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty ) elif has_batch_no: locations = get_available_item_locations_for_batched_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty ) else: locations = get_available_item_locations_for_other_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty ) total_qty_available = sum(location.get("qty") for location in locations) - remaining_qty = required_qty - total_qty_available if remaining_qty > 0 and not ignore_validation: @@ -406,25 +550,60 @@ def get_available_item_locations( title=_("Insufficient Stock"), ) + if picked_item_details: + for location in list(locations): + key = ( + (location["warehouse"], location["batch_no"]) + if location.get("batch_no") + else location["warehouse"] + ) + + if key in picked_item_details: + picked_detail = picked_item_details[key] + + if picked_detail.get("serial_no") and location.get("serial_no"): + location["serial_no"] = list( + set(location["serial_no"]).difference(set(picked_detail["serial_no"])) + ) + location["qty"] = len(location["serial_no"]) + else: + location["qty"] -= picked_detail.get("picked_qty") + + if location["qty"] < 1: + locations.remove(location) + + total_qty_available = sum(location.get("qty") for location in locations) + remaining_qty = required_qty - total_qty_available + + if remaining_qty > 0 and not ignore_validation: + frappe.msgprint( + _("{0} units of Item {1} is picked in another Pick List.").format( + remaining_qty, frappe.get_desk_link("Item", item_code) + ), + title=_("Already Picked"), + ) + return locations def get_available_item_locations_for_serialized_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty=0 ): - filters = frappe._dict({"item_code": item_code, "company": company, "warehouse": ["!=", ""]}) + sn = frappe.qb.DocType("Serial No") + query = ( + frappe.qb.from_(sn) + .select(sn.name, sn.warehouse) + .where((sn.item_code == item_code) & (sn.company == company)) + .orderby(sn.purchase_date) + .limit(cint(required_qty + total_picked_qty)) + ) if from_warehouses: - filters.warehouse = ["in", from_warehouses] - - serial_nos = frappe.get_all( - "Serial No", - fields=["name", "warehouse"], - filters=filters, - limit=required_qty, - order_by="purchase_date", - as_list=1, - ) + query = query.where(sn.warehouse.isin(from_warehouses)) + else: + query = query.where(Coalesce(sn.warehouse, "") != "") + + serial_nos = query.run(as_list=True) warehouse_serial_nos_map = frappe._dict() for serial_no, warehouse in serial_nos: @@ -438,94 +617,88 @@ def get_available_item_locations_for_serialized_item( def get_available_item_locations_for_batched_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty=0 ): - warehouse_condition = "and warehouse in %(warehouses)s" if from_warehouses else "" - batch_locations = frappe.db.sql( - """ - SELECT - sle.`warehouse`, - sle.`batch_no`, - SUM(sle.`actual_qty`) AS `qty` - FROM - `tabStock Ledger Entry` sle, `tabBatch` batch - WHERE - sle.batch_no = batch.name - and sle.`item_code`=%(item_code)s - and sle.`company` = %(company)s - and batch.disabled = 0 - and sle.is_cancelled=0 - and IFNULL(batch.`expiry_date`, '2200-01-01') > %(today)s - {warehouse_condition} - GROUP BY - sle.`warehouse`, - sle.`batch_no`, - sle.`item_code` - HAVING `qty` > 0 - ORDER BY IFNULL(batch.`expiry_date`, '2200-01-01'), batch.`creation`, sle.`batch_no`, sle.`warehouse` - """.format( - warehouse_condition=warehouse_condition - ), - { # nosec - "item_code": item_code, - "company": company, - "today": today(), - "warehouses": from_warehouses, - }, - as_dict=1, + sle = frappe.qb.DocType("Stock Ledger Entry") + batch = frappe.qb.DocType("Batch") + + query = ( + frappe.qb.from_(sle) + .from_(batch) + .select(sle.warehouse, sle.batch_no, Sum(sle.actual_qty).as_("qty")) + .where( + (sle.batch_no == batch.name) + & (sle.item_code == item_code) + & (sle.company == company) + & (batch.disabled == 0) + & (sle.is_cancelled == 0) + & (IfNull(batch.expiry_date, "2200-01-01") > today()) + ) + .groupby(sle.warehouse, sle.batch_no, sle.item_code) + .having(Sum(sle.actual_qty) > 0) + .orderby(IfNull(batch.expiry_date, "2200-01-01"), batch.creation, sle.batch_no, sle.warehouse) + .limit(cint(required_qty + total_picked_qty)) ) - return batch_locations + if from_warehouses: + query = query.where(sle.warehouse.isin(from_warehouses)) + + return query.run(as_dict=True) def get_available_item_locations_for_serial_and_batched_item( - item_code, from_warehouses, required_qty, company + item_code, from_warehouses, required_qty, company, total_picked_qty=0 ): # Get batch nos by FIFO locations = get_available_item_locations_for_batched_item( item_code, from_warehouses, required_qty, company ) - filters = frappe._dict( - {"item_code": item_code, "company": company, "warehouse": ["!=", ""], "batch_no": ""} - ) + if locations: + sn = frappe.qb.DocType("Serial No") + conditions = (sn.item_code == item_code) & (sn.company == company) - # Get Serial Nos by FIFO for Batch No - for location in locations: - filters.batch_no = location.batch_no - filters.warehouse = location.warehouse - location.qty = ( - required_qty if location.qty > required_qty else location.qty - ) # if extra qty in batch + for location in locations: + location.qty = ( + required_qty if location.qty > required_qty else location.qty + ) # if extra qty in batch - serial_nos = frappe.get_list( - "Serial No", fields=["name"], filters=filters, limit=location.qty, order_by="purchase_date" - ) + serial_nos = ( + frappe.qb.from_(sn) + .select(sn.name) + .where( + (conditions) & (sn.batch_no == location.batch_no) & (sn.warehouse == location.warehouse) + ) + .orderby(sn.purchase_date) + .limit(cint(location.qty + total_picked_qty)) + ).run(as_dict=True) - serial_nos = [sn.name for sn in serial_nos] - location.serial_no = serial_nos + serial_nos = [sn.name for sn in serial_nos] + location.serial_no = serial_nos + location.qty = len(serial_nos) return locations -def get_available_item_locations_for_other_item(item_code, from_warehouses, required_qty, company): - # gets all items available in different warehouses - warehouses = [x.get("name") for x in frappe.get_list("Warehouse", {"company": company}, "name")] - - filters = frappe._dict( - {"item_code": item_code, "warehouse": ["in", warehouses], "actual_qty": [">", 0]} +def get_available_item_locations_for_other_item( + item_code, from_warehouses, required_qty, company, total_picked_qty=0 +): + bin = frappe.qb.DocType("Bin") + query = ( + frappe.qb.from_(bin) + .select(bin.warehouse, bin.actual_qty.as_("qty")) + .where((bin.item_code == item_code) & (bin.actual_qty > 0)) + .orderby(bin.creation) + .limit(cint(required_qty + total_picked_qty)) ) if from_warehouses: - filters.warehouse = ["in", from_warehouses] - - item_locations = frappe.get_all( - "Bin", - fields=["warehouse", "actual_qty as qty"], - filters=filters, - limit=required_qty, - order_by="creation", - ) + query = query.where(bin.warehouse.isin(from_warehouses)) + else: + wh = frappe.qb.DocType("Warehouse") + query = query.from_(wh).where((bin.warehouse == wh.name) & (wh.company == company)) + + item_locations = query.run(as_dict=True) return item_locations diff --git a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py index 92e57bed2205..7fbcbafbac1f 100644 --- a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py +++ b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py @@ -1,7 +1,10 @@ def get_data(): return { "fieldname": "pick_list", + "internal_links": { + "Sales Order": ["locations", "sales_order"], + }, "transactions": [ - {"items": ["Stock Entry", "Delivery Note"]}, + {"items": ["Stock Entry", "Sales Order", "Delivery Note"]}, ], } diff --git a/erpnext/stock/doctype/pick_list/pick_list_list.js b/erpnext/stock/doctype/pick_list/pick_list_list.js new file mode 100644 index 000000000000..ad88b0a682f5 --- /dev/null +++ b/erpnext/stock/doctype/pick_list/pick_list_list.js @@ -0,0 +1,14 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.listview_settings['Pick List'] = { + get_indicator: function (doc) { + const status_colors = { + "Draft": "grey", + "Open": "orange", + "Completed": "green", + "Cancelled": "red", + }; + return [__(doc.status), status_colors[doc.status], "status,=," + doc.status]; + }, +}; \ No newline at end of file diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index f552299806c3..1254fe3927fa 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -414,6 +414,7 @@ def test_pick_list_for_items_with_multiple_UOM(self): pick_list.submit() delivery_note = create_delivery_note(pick_list.name) + pick_list.load_from_db() self.assertEqual(pick_list.locations[0].qty, delivery_note.items[0].qty) self.assertEqual(pick_list.locations[1].qty, delivery_note.items[1].qty) @@ -445,10 +446,10 @@ def _compare_dicts(a, b): pl.before_print() self.assertEqual(len(pl.locations), 4) - # grouping should halve the number of items + # grouping should not happen if group_same_items is False pl = frappe.get_doc( doctype="Pick List", - group_same_items=True, + group_same_items=False, locations=[ _dict(item_code="A", warehouse="X", qty=5, picked_qty=1), _dict(item_code="B", warehouse="Y", qty=4, picked_qty=2), @@ -457,6 +458,11 @@ def _compare_dicts(a, b): ], ) pl.before_print() + self.assertEqual(len(pl.locations), 4) + + # grouping should halve the number of items + pl.group_same_items = True + pl.before_print() self.assertEqual(len(pl.locations), 2) expected_items = [ @@ -658,3 +664,147 @@ def test_picklist_with_partial_bundles(self): self.assertEqual(dn.items[0].rate, 42) so.reload() self.assertEqual(so.per_delivered, 100) + + def test_pick_list_status(self): + warehouse = "_Test Warehouse - _TC" + item = make_item(properties={"maintain_stock": 1}).name + make_stock_entry(item=item, to_warehouse=warehouse, qty=10) + + so = make_sales_order(item_code=item, qty=10, rate=100) + + pl = create_pick_list(so.name) + pl.save() + pl.reload() + self.assertEqual(pl.status, "Draft") + + pl.submit() + pl.reload() + self.assertEqual(pl.status, "Open") + + dn = create_delivery_note(pl.name) + dn.save() + pl.reload() + self.assertEqual(pl.status, "Open") + + dn.submit() + pl.reload() + self.assertEqual(pl.status, "Completed") + + dn.cancel() + pl.reload() + self.assertEqual(pl.status, "Completed") + + pl.cancel() + pl.reload() + self.assertEqual(pl.status, "Cancelled") + + def test_consider_existing_pick_list(self): + def create_items(items_properties): + items = [] + + for properties in items_properties: + properties.update({"maintain_stock": 1}) + item_code = make_item(properties=properties).name + properties.update({"item_code": item_code}) + items.append(properties) + + return items + + def create_stock_entries(items): + warehouses = ["Stores - _TC", "Finished Goods - _TC"] + + for item in items: + for warehouse in warehouses: + se = make_stock_entry( + item=item.get("item_code"), + to_warehouse=warehouse, + qty=5, + ) + + def get_item_list(items, qty, warehouse="All Warehouses - _TC"): + return [ + { + "item_code": item.get("item_code"), + "qty": qty, + "warehouse": warehouse, + } + for item in items + ] + + def get_picked_items_details(pick_list_doc): + items_data = {} + + for location in pick_list_doc.locations: + key = (location.warehouse, location.batch_no) if location.batch_no else location.warehouse + serial_no = [x for x in location.serial_no.split("\n") if x] if location.serial_no else None + data = {"picked_qty": location.picked_qty} + if serial_no: + data["serial_no"] = serial_no + if location.item_code not in items_data: + items_data[location.item_code] = {key: data} + else: + items_data[location.item_code][key] = data + + return items_data + + # Step - 1: Setup - Create Items and Stock Entries + items_properties = [ + { + "valuation_rate": 100, + }, + { + "valuation_rate": 200, + "has_batch_no": 1, + "create_new_batch": 1, + }, + { + "valuation_rate": 300, + "has_serial_no": 1, + "serial_no_series": "SNO.###", + }, + { + "valuation_rate": 400, + "has_batch_no": 1, + "create_new_batch": 1, + "has_serial_no": 1, + "serial_no_series": "SNO.###", + }, + ] + + items = create_items(items_properties) + create_stock_entries(items) + + # Step - 2: Create Sales Order [1] + so1 = make_sales_order(item_list=get_item_list(items, qty=6)) + + # Step - 3: Create and Submit Pick List [1] for Sales Order [1] + pl1 = create_pick_list(so1.name) + pl1.submit() + + # Step - 4: Create Sales Order [2] with same Item(s) as Sales Order [1] + so2 = make_sales_order(item_list=get_item_list(items, qty=4)) + + # Step - 5: Create Pick List [2] for Sales Order [2] + pl2 = create_pick_list(so2.name) + pl2.save() + + # Step - 6: Assert + picked_items_details = get_picked_items_details(pl1) + + for location in pl2.locations: + key = (location.warehouse, location.batch_no) if location.batch_no else location.warehouse + item_data = picked_items_details.get(location.item_code, {}).get(key, {}) + picked_qty = item_data.get("picked_qty", 0) + picked_serial_no = picked_items_details.get("serial_no", []) + bin_actual_qty = frappe.db.get_value( + "Bin", {"item_code": location.item_code, "warehouse": location.warehouse}, "actual_qty" + ) + + # Available Qty to pick should be equal to [Actual Qty - Picked Qty] + self.assertEqual(location.stock_qty, bin_actual_qty - picked_qty) + + # Serial No should not be in the Picked Serial No list + if location.serial_no: + a = set(picked_serial_no) + b = set([x for x in location.serial_no.split("\n") if x]) + self.assertSetEqual(b, b.difference(a)) diff --git a/erpnext/stock/doctype/price_list/test_records.json b/erpnext/stock/doctype/price_list/test_records.json index 7ca949c40263..e02a7adbd8ba 100644 --- a/erpnext/stock/doctype/price_list/test_records.json +++ b/erpnext/stock/doctype/price_list/test_records.json @@ -31,5 +31,21 @@ "enabled": 1, "price_list_name": "_Test Price List Rest of the World", "selling": 1 + }, + { + "buying": 0, + "currency": "USD", + "doctype": "Price List", + "enabled": 1, + "price_list_name": "_Test Selling Price List", + "selling": 1 + }, + { + "buying": 1, + "currency": "USD", + "doctype": "Price List", + "enabled": 1, + "price_list_name": "_Test Buying Price List", + "selling": 0 } ] diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 3739cb8c9dd7..c1abd31bcc1a 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -293,6 +293,7 @@ def make_item_gl_entries(self, gl_entries, warehouse_account=None): get_purchase_document_details, ) + stock_rbnb = None if erpnext.is_perpetual_inventory_enabled(self.company): stock_rbnb = self.get_company_default("stock_received_but_not_billed") landed_cost_entries = get_item_account_wise_additional_cost(self.name) @@ -450,6 +451,21 @@ def make_item_gl_entries(self, gl_entries, warehouse_account=None): item=d, ) + if d.rate_difference_with_purchase_invoice and stock_rbnb: + account_currency = get_account_currency(stock_rbnb) + self.add_gl_entry( + gl_entries=gl_entries, + account=stock_rbnb, + cost_center=d.cost_center, + debit=0.0, + credit=flt(d.rate_difference_with_purchase_invoice), + remarks=_("Adjustment based on Purchase Invoice rate"), + against_account=warehouse_account_name, + account_currency=account_currency, + project=d.project, + item=d, + ) + # sub-contracting warehouse if flt(d.rm_supp_cost) and warehouse_account.get(self.supplier_warehouse): self.add_gl_entry( @@ -470,10 +486,11 @@ def make_item_gl_entries(self, gl_entries, warehouse_account=None): + flt(d.landed_cost_voucher_amount) + flt(d.rm_supp_cost) + flt(d.item_tax_amount) + + flt(d.rate_difference_with_purchase_invoice) ) divisional_loss = flt( - valuation_amount_as_per_doc - stock_value_diff, d.precision("base_net_amount") + valuation_amount_as_per_doc - flt(stock_value_diff), d.precision("base_net_amount") ) if divisional_loss: @@ -765,7 +782,7 @@ def update_billing_status(self, update_modified=True): updated_pr += update_billed_amount_based_on_po(po_details, update_modified) for pr in set(updated_pr): - pr_doc = self if (pr == self.name) else frappe.get_cached_doc("Purchase Receipt", pr) + pr_doc = self if (pr == self.name) else frappe.get_doc("Purchase Receipt", pr) update_billing_percentage(pr_doc, update_modified=update_modified) self.load_from_db() @@ -881,30 +898,28 @@ def get_billed_amount_against_po(po_items): return {d.po_detail: flt(d.billed_amt) for d in query} -def update_billing_percentage(pr_doc, update_modified=True): +def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate=False): # Reload as billed amount was set in db directly pr_doc.load_from_db() # Update Billing % based on pending accepted qty total_amount, total_billed_amount = 0, 0 - for item in pr_doc.items: - return_data = frappe.db.get_list( - "Purchase Receipt", - fields=["sum(abs(`tabPurchase Receipt Item`.qty)) as qty"], - filters=[ - ["Purchase Receipt", "docstatus", "=", 1], - ["Purchase Receipt", "is_return", "=", 1], - ["Purchase Receipt Item", "purchase_receipt_item", "=", item.name], - ], - ) + item_wise_returned_qty = get_item_wise_returned_qty(pr_doc) - returned_qty = return_data[0].qty if return_data else 0 + for item in pr_doc.items: + returned_qty = flt(item_wise_returned_qty.get(item.name)) returned_amount = flt(returned_qty) * flt(item.rate) pending_amount = flt(item.amount) - returned_amount total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt total_amount += total_billable_amount total_billed_amount += flt(item.billed_amt) + if adjust_incoming_rate: + adjusted_amt = 0.0 + if item.billed_amt and item.amount: + adjusted_amt = flt(item.billed_amt) - flt(item.amount) + + item.db_set("rate_difference_with_purchase_invoice", adjusted_amt, update_modified=False) percent_billed = round(100 * (total_billed_amount / (total_amount or 1)), 6) pr_doc.db_set("per_billed", percent_billed) @@ -914,6 +929,47 @@ def update_billing_percentage(pr_doc, update_modified=True): pr_doc.set_status(update=True) pr_doc.notify_update() + if adjust_incoming_rate: + adjust_incoming_rate_for_pr(pr_doc) + + +def adjust_incoming_rate_for_pr(doc): + doc.update_valuation_rate(reset_outgoing_rate=False) + + for item in doc.get("items"): + item.db_update() + + doc.docstatus = 2 + doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=True) + doc.make_gl_entries_on_cancel() + + # update stock & gl entries for submit state of PR + doc.docstatus = 1 + doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=True) + doc.make_gl_entries() + doc.repost_future_sle_and_gle() + + +def get_item_wise_returned_qty(pr_doc): + items = [d.name for d in pr_doc.items] + + return frappe._dict( + frappe.get_all( + "Purchase Receipt", + fields=[ + "`tabPurchase Receipt Item`.purchase_receipt_item", + "sum(abs(`tabPurchase Receipt Item`.qty)) as qty", + ], + filters=[ + ["Purchase Receipt", "docstatus", "=", 1], + ["Purchase Receipt", "is_return", "=", 1], + ["Purchase Receipt Item", "purchase_receipt_item", "in", items], + ], + group_by="`tabPurchase Receipt Item`.purchase_receipt_item", + as_list=1, + ) + ) + @frappe.whitelist() def make_purchase_invoice(source_name, target_doc=None): @@ -1121,13 +1177,25 @@ def get_item_account_wise_additional_cost(purchase_document): account.expense_account, {"amount": 0.0, "base_amount": 0.0} ) - item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][account.expense_account][ - "amount" - ] += (account.amount * item.get(based_on_field) / total_item_cost) + if total_item_cost > 0: + item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][ + account.expense_account + ]["amount"] += ( + account.amount * item.get(based_on_field) / total_item_cost + ) - item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][account.expense_account][ - "base_amount" - ] += (account.base_amount * item.get(based_on_field) / total_item_cost) + item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][ + account.expense_account + ]["base_amount"] += ( + account.base_amount * item.get(based_on_field) / total_item_cost + ) + else: + item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][ + account.expense_account + ]["amount"] += item.applicable_charges + item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][ + account.expense_account + ]["base_amount"] += item.applicable_charges return item_account_wise_cost diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index dc9f2b21177c..b6341466f876 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1501,6 +1501,49 @@ def test_batch_expiry_for_purchase_receipt(self): self.assertTrue(return_pi.docstatus == 1) + def test_disable_last_purchase_rate(self): + from erpnext.stock.get_item_details import get_item_details + + item = make_item( + "_Test Disable Last Purchase Rate", + {"is_purchase_item": 1, "is_stock_item": 1}, + ) + + frappe.db.set_single_value("Buying Settings", "disable_last_purchase_rate", 1) + + pr = make_purchase_receipt( + qty=1, + rate=100, + item_code=item.name, + ) + + args = pr.items[0].as_dict() + args.update( + { + "supplier": pr.supplier, + "doctype": pr.doctype, + "conversion_rate": pr.conversion_rate, + "currency": pr.currency, + "company": pr.company, + "posting_date": pr.posting_date, + "posting_time": pr.posting_time, + } + ) + + res = get_item_details(args) + self.assertEqual(res.get("last_purchase_rate"), 0) + + frappe.db.set_single_value("Buying Settings", "disable_last_purchase_rate", 0) + + pr = make_purchase_receipt( + qty=1, + rate=100, + item_code=item.name, + ) + + res = get_item_details(args) + self.assertEqual(res.get("last_purchase_rate"), 100) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 557bb594bf0f..cd320fdfcd02 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -69,6 +69,7 @@ "item_tax_amount", "rm_supp_cost", "landed_cost_voucher_amount", + "rate_difference_with_purchase_invoice", "billed_amt", "warehouse_and_reference", "warehouse", @@ -859,7 +860,8 @@ "label": "Purchase Receipt Item", "no_copy": 1, "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "collapsible": 1, @@ -974,7 +976,8 @@ "label": "Purchase Invoice Item", "no_copy": 1, "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "product_bundle", @@ -1005,12 +1008,20 @@ "fieldtype": "Check", "label": "Has Item Scanned", "read_only": 1 + }, + { + "fieldname": "rate_difference_with_purchase_invoice", + "fieldtype": "Currency", + "label": "Rate Difference with Purchase Invoice", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2022-11-02 12:49:28.746701", + "modified": "2023-02-28 15:43:04.470104", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 9321c2c166b5..2a9f091bd091 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -221,7 +221,7 @@ def calculate_mean(self, reading): def item_query(doctype, txt, searchfield, start, page_len, filters): from frappe.desk.reportview import get_match_cond - from_doctype = cstr(filters.get("doctype")) + from_doctype = cstr(filters.get("from")) if not from_doctype or not frappe.db.exists("DocType", from_doctype): return [] diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index d4b4efa4cdd2..a82c709b60fd 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -169,6 +169,8 @@ frappe.ui.form.on('Stock Entry', { }, refresh: function(frm) { + frm.trigger("get_items_from_transit_entry"); + if(!frm.doc.docstatus) { frm.trigger('validate_purpose_consumption'); frm.add_custom_button(__('Material Request'), function() { @@ -337,6 +339,28 @@ frappe.ui.form.on('Stock Entry', { } }, + get_items_from_transit_entry: function(frm) { + if (frm.doc.docstatus===0) { + frm.add_custom_button(__('Transit Entry'), function() { + erpnext.utils.map_current_doc({ + method: "erpnext.stock.doctype.stock_entry.stock_entry.make_stock_in_entry", + source_doctype: "Stock Entry", + target: frm, + date_field: "posting_date", + setters: { + stock_entry_type: "Material Transfer", + purpose: "Material Transfer", + }, + get_query_filters: { + docstatus: 1, + purpose: "Material Transfer", + add_to_transit: 1, + } + }) + }, __("Get Items From")); + } + }, + before_save: function(frm) { frm.doc.items.forEach((item) => { item.uom = item.uom || item.stock_uom; diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 7e9420d50350..9c0f1fc03f48 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -7,7 +7,7 @@ "document_type": "Document", "engine": "InnoDB", "field_order": [ - "items_section", + "stock_entry_details_tab", "naming_series", "stock_entry_type", "outgoing_stock_entry", @@ -26,15 +26,20 @@ "posting_time", "set_posting_time", "inspection_required", - "from_bom", "apply_putaway_rule", - "sb1", + "items_tab", + "bom_info_section", + "from_bom", + "use_multi_level_bom", "bom_no", - "fg_completed_qty", "cb1", - "use_multi_level_bom", + "fg_completed_qty", "get_items", - "section_break_12", + "section_break_7qsm", + "process_loss_percentage", + "column_break_e92r", + "process_loss_qty", + "section_break_jwgn", "from_warehouse", "source_warehouse_address", "source_address_display", @@ -44,6 +49,7 @@ "target_address_display", "sb0", "scan_barcode", + "items_section", "items", "get_stock_and_rate", "section_break_19", @@ -54,6 +60,7 @@ "additional_costs_section", "additional_costs", "total_additional_costs", + "supplier_info_tab", "contact_section", "supplier", "supplier_name", @@ -61,7 +68,7 @@ "address_display", "accounting_dimensions_section", "project", - "dimension_col_break", + "other_info_tab", "printing_settings", "select_print_heading", "print_settings_col_break", @@ -78,11 +85,6 @@ "is_return" ], "fields": [ - { - "fieldname": "items_section", - "fieldtype": "Section Break", - "oldfieldtype": "Section Break" - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -236,17 +238,12 @@ }, { "default": "0", - "depends_on": "eval:in_list([\"Material Issue\", \"Material Transfer\", \"Manufacture\", \"Repack\", \t\t\t\t\t\"Send to Subcontractor\", \"Material Transfer for Manufacture\", \"Material Consumption for Manufacture\"], doc.purpose)", + "depends_on": "eval:in_list([\"Material Issue\", \"Material Transfer\", \"Manufacture\", \"Repack\", \"Send to Subcontractor\", \"Material Transfer for Manufacture\", \"Material Consumption for Manufacture\"], doc.purpose)", "fieldname": "from_bom", "fieldtype": "Check", "label": "From BOM", "print_hide": 1 }, - { - "depends_on": "eval: doc.from_bom && (doc.purpose!==\"Sales Return\" && doc.purpose!==\"Purchase Return\")", - "fieldname": "sb1", - "fieldtype": "Section Break" - }, { "depends_on": "from_bom", "fieldname": "bom_no", @@ -285,10 +282,6 @@ "oldfieldtype": "Button", "print_hide": 1 }, - { - "fieldname": "section_break_12", - "fieldtype": "Section Break" - }, { "description": "Sets 'Source Warehouse' in each row of the items table.", "fieldname": "from_warehouse", @@ -411,7 +404,7 @@ "collapsible": 1, "collapsible_depends_on": "total_additional_costs", "fieldname": "additional_costs_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Additional Costs" }, { @@ -576,13 +569,9 @@ { "collapsible": 1, "fieldname": "accounting_dimensions_section", - "fieldtype": "Section Break", + "fieldtype": "Tab Break", "label": "Accounting Dimensions" }, - { - "fieldname": "dimension_col_break", - "fieldtype": "Column Break" - }, { "fieldname": "pick_list", "fieldtype": "Link", @@ -621,6 +610,66 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "items_tab", + "fieldtype": "Tab Break", + "label": "Items" + }, + { + "fieldname": "bom_info_section", + "fieldtype": "Section Break", + "label": "BOM Info" + }, + { + "collapsible": 1, + "fieldname": "section_break_jwgn", + "fieldtype": "Section Break", + "label": "Default Warehouse" + }, + { + "fieldname": "other_info_tab", + "fieldtype": "Tab Break", + "label": "Other Info" + }, + { + "fieldname": "supplier_info_tab", + "fieldtype": "Tab Break", + "label": "Supplier Info" + }, + { + "fieldname": "stock_entry_details_tab", + "fieldtype": "Tab Break", + "label": "Details", + "oldfieldtype": "Section Break" + }, + { + "fieldname": "section_break_7qsm", + "fieldtype": "Section Break" + }, + { + "depends_on": "process_loss_percentage", + "fieldname": "process_loss_qty", + "fieldtype": "Float", + "label": "Process Loss Qty", + "read_only": 1 + }, + { + "fieldname": "column_break_e92r", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.from_bom && doc.fg_completed_qty", + "fetch_from": "bom_no.process_loss_percentage", + "fetch_if_empty": 1, + "fieldname": "process_loss_percentage", + "fieldtype": "Percent", + "label": "% Process Loss" + }, + { + "fieldname": "items_section", + "fieldtype": "Section Break", + "label": "Items" } ], "icon": "fa fa-file-text", @@ -628,7 +677,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-10-07 14:39:51.943770", + "modified": "2023-01-03 16:02:50.741816", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index fc3a50ededbc..7e39cb92f706 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -116,6 +116,7 @@ def validate(self): self.validate_warehouse() self.validate_work_order() self.validate_bom() + self.set_process_loss_qty() self.validate_purchase_order() self.validate_subcontracting_order() @@ -126,7 +127,7 @@ def validate(self): self.validate_with_material_request() self.validate_batch() self.validate_inspection() - # self.validate_fg_completed_qty() + self.validate_fg_completed_qty() self.validate_difference_account() self.set_job_card_data() self.set_purpose_for_stock_entry() @@ -160,6 +161,7 @@ def on_submit(self): self.validate_subcontract_order() self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() + self.update_pick_list_status() self.make_gl_entries() @@ -388,11 +390,20 @@ def validate_fg_completed_qty(self): item_wise_qty = {} if self.purpose == "Manufacture" and self.work_order: for d in self.items: - if d.is_finished_item or d.is_process_loss: + if d.is_finished_item: item_wise_qty.setdefault(d.item_code, []).append(d.qty) + precision = frappe.get_precision("Stock Entry Detail", "qty") for item_code, qty_list in item_wise_qty.items(): - total = flt(sum(qty_list), frappe.get_precision("Stock Entry Detail", "qty")) + total = flt(sum(qty_list), precision) + + if (self.fg_completed_qty - total) > 0: + self.process_loss_qty = flt(self.fg_completed_qty - total, precision) + self.process_loss_percentage = flt(self.process_loss_qty * 100 / self.fg_completed_qty) + + if self.process_loss_qty: + total += flt(self.process_loss_qty, precision) + if self.fg_completed_qty != total: frappe.throw( _("The finished product {0} quantity {1} and For Quantity {2} cannot be different").format( @@ -471,7 +482,7 @@ def validate_warehouse(self): if self.purpose == "Manufacture": if validate_for_manufacture: - if d.is_finished_item or d.is_scrap_item or d.is_process_loss: + if d.is_finished_item or d.is_scrap_item: d.s_warehouse = None if not d.t_warehouse: frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx)) @@ -648,10 +659,9 @@ def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): outgoing_items_cost = self.set_rate_for_outgoing_items( reset_outgoing_rate, raise_error_if_no_rate ) - finished_item_qty = sum( - d.transfer_qty for d in self.items if d.is_finished_item or d.is_process_loss - ) + finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item) + items = [] # Set basic rate for incoming items for d in self.get("items"): if d.s_warehouse or d.set_basic_rate_manually: @@ -659,12 +669,7 @@ def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): if d.allow_zero_valuation_rate: d.basic_rate = 0.0 - frappe.msgprint( - _( - "Row {0}: Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {1}" - ).format(d.idx, d.item_code), - alert=1, - ) + items.append(d.item_code) elif d.is_finished_item: if self.purpose == "Manufacture": @@ -689,10 +694,22 @@ def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): # do not round off basic rate to avoid precision loss d.basic_rate = flt(d.basic_rate) - if d.is_process_loss: - d.basic_rate = flt(0.0) d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) + if items: + message = "" + + if len(items) > 1: + message = _( + "Items rate has been updated to zero as Allow Zero Valuation Rate is checked for the following items: {0}" + ).format(", ".join(frappe.bold(item) for item in items)) + else: + message = _( + "Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {0}" + ).format(frappe.bold(items[0])) + + frappe.msgprint(message, alert=True) + def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): outgoing_items_cost = 0.0 for d in self.get("items"): @@ -992,7 +1009,9 @@ def validate_subcontracting_order(self): ) def mark_finished_and_scrap_items(self): - if any([d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)]): + if self.purpose != "Repack" and any( + [d.item_code for d in self.items if (d.is_finished_item and d.t_warehouse)] + ): return finished_item = self.get_finished_item() @@ -1241,7 +1260,6 @@ def _validate_work_order(pro_doc): if self.work_order: pro_doc = frappe.get_doc("Work Order", self.work_order) _validate_work_order(pro_doc) - pro_doc.run_method("update_status") if self.fg_completed_qty: pro_doc.run_method("update_work_order_qty") @@ -1249,6 +1267,7 @@ def _validate_work_order(pro_doc): pro_doc.run_method("update_planned_qty") pro_doc.update_batch_produced_qty(self) + pro_doc.run_method("update_status") if not pro_doc.operations: pro_doc.set_actual_dates() @@ -1469,11 +1488,11 @@ def get_items(self): # add finished goods item if self.purpose in ("Manufacture", "Repack"): + self.set_process_loss_qty() self.load_items_from_bom() self.set_scrap_items() self.set_actual_qty() - self.update_items_for_process_loss() self.validate_customer_provided_item() self.calculate_rate_and_amount(raise_error_if_no_rate=False) @@ -1486,6 +1505,21 @@ def set_scrap_items(self): self.add_to_stock_entry_detail(scrap_item_dict, bom_no=self.bom_no) + def set_process_loss_qty(self): + if self.purpose not in ("Manufacture", "Repack"): + return + + self.process_loss_qty = 0.0 + if not self.process_loss_percentage: + self.process_loss_percentage = frappe.get_cached_value( + "BOM", self.bom_no, "process_loss_percentage" + ) + + if self.process_loss_percentage: + self.process_loss_qty = flt( + (flt(self.fg_completed_qty) * flt(self.process_loss_percentage)) / 100 + ) + def set_work_order_details(self): if not getattr(self, "pro_doc", None): self.pro_doc = frappe._dict() @@ -1518,7 +1552,7 @@ def load_items_from_bom(self): args = { "to_warehouse": to_warehouse, "from_warehouse": "", - "qty": self.fg_completed_qty, + "qty": flt(self.fg_completed_qty) - flt(self.process_loss_qty), "item_name": item.item_name, "description": item.description, "stock_uom": item.stock_uom, @@ -1966,7 +2000,6 @@ def add_to_stock_entry_detail(self, item_dict, bom_no=None): ) se_child.is_finished_item = item_row.get("is_finished_item", 0) se_child.is_scrap_item = item_row.get("is_scrap_item", 0) - se_child.is_process_loss = item_row.get("is_process_loss", 0) se_child.po_detail = item_row.get("po_detail") se_child.sco_rm_detail = item_row.get("sco_rm_detail") @@ -2213,31 +2246,6 @@ def set_material_request_transfer_status(self, status): material_requests.append(material_request) frappe.db.set_value("Material Request", material_request, "transfer_status", status) - def update_items_for_process_loss(self): - process_loss_dict = {} - for d in self.get("items"): - if not d.is_process_loss: - continue - - scrap_warehouse = frappe.db.get_single_value( - "Manufacturing Settings", "default_scrap_warehouse" - ) - if scrap_warehouse is not None: - d.t_warehouse = scrap_warehouse - d.is_scrap_item = 0 - - if d.item_code not in process_loss_dict: - process_loss_dict[d.item_code] = [flt(0), flt(0)] - process_loss_dict[d.item_code][0] += flt(d.transfer_qty) - process_loss_dict[d.item_code][1] += flt(d.qty) - - for d in self.get("items"): - if not d.is_finished_item or d.item_code not in process_loss_dict: - continue - # Assumption: 1 finished item has 1 row. - d.transfer_qty -= process_loss_dict[d.item_code][0] - d.qty -= process_loss_dict[d.item_code][1] - def set_serial_no_batch_for_finished_good(self): serial_nos = [] if self.pro_doc.serial_no: @@ -2282,6 +2290,11 @@ def update_subcontracting_order_status(self): update_subcontracting_order_status(self.subcontracting_order) + def update_pick_list_status(self): + from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status + + update_pick_list_status(self.pick_list) + def set_missing_values(self): "Updates rate and availability of all the items of mapped doc." self.set_transfer_qty() @@ -2494,7 +2507,7 @@ def get_uom_details(item_code, uom, qty): if not conversion_factor: frappe.msgprint( - _("UOM coversion factor required for UOM: {0} in Item: {1}").format(uom, item_code) + _("UOM conversion factor required for UOM: {0} in Item: {1}").format(uom, item_code) ) ret = {"uom": ""} else: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index 41a3b8916ded..0f9001392df0 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -117,6 +117,7 @@ def process_serial_numbers(serial_nos_list): args.item = "_Test Item" s.company = args.company or erpnext.get_default_company() + s.add_to_transit = args.add_to_transit or 0 s.purchase_receipt_no = args.purchase_receipt_no s.delivery_note_no = args.delivery_note_no s.sales_invoice_no = args.sales_invoice_no diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index b574b718fe1c..cc06bd709ad4 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -17,6 +17,7 @@ from erpnext.stock.doctype.serial_no.serial_no import * # noqa from erpnext.stock.doctype.stock_entry.stock_entry import ( FinishedGoodError, + make_stock_in_entry, move_sample_to_retention_warehouse, ) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry @@ -160,6 +161,53 @@ def _test_auto_material_request( self.assertTrue(item_code in items) + def test_add_to_transit_entry(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + item_code = "_Test Transit Item" + company = "_Test Company" + + create_warehouse("Test From Warehouse") + create_warehouse("Test Transit Warehouse") + create_warehouse("Test To Warehouse") + + create_item( + item_code=item_code, + is_stock_item=1, + is_purchase_item=1, + company=company, + ) + + # create inward stock entry + make_stock_entry( + item_code=item_code, + target="Test From Warehouse - _TC", + qty=10, + basic_rate=100, + expense_account="Stock Adjustment - _TC", + cost_center="Main - _TC", + ) + + transit_entry = make_stock_entry( + item_code=item_code, + source="Test From Warehouse - _TC", + target="Test Transit Warehouse - _TC", + add_to_transit=1, + stock_entry_type="Material Transfer", + purpose="Material Transfer", + qty=10, + basic_rate=100, + expense_account="Stock Adjustment - _TC", + cost_center="Main - _TC", + ) + + end_transit_entry = make_stock_in_entry(transit_entry.name) + self.assertEqual(transit_entry.name, end_transit_entry.outgoing_stock_entry) + self.assertEqual(transit_entry.name, end_transit_entry.items[0].against_stock_entry) + self.assertEqual(transit_entry.items[0].name, end_transit_entry.items[0].ste_detail) + + # create add to transit + def test_material_receipt_gl_entry(self): company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") @@ -1614,6 +1662,48 @@ def test_batch_expiry(self): self.assertRaises(BatchExpiredError, se.save) + def test_negative_stock_reco(self): + from erpnext.controllers.stock_controller import BatchExpiredError + from erpnext.stock.doctype.batch.test_batch import make_new_batch + + frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0) + + item_code = "Test Negative Item - 001" + item_doc = create_item(item_code=item_code, is_stock_item=1, valuation_rate=10) + + make_stock_entry( + item_code=item_code, + posting_date=add_days(today(), -3), + posting_time="00:00:00", + purpose="Material Receipt", + qty=10, + to_warehouse="_Test Warehouse - _TC", + do_not_save=True, + ) + + make_stock_entry( + item_code=item_code, + posting_date=today(), + posting_time="00:00:00", + purpose="Material Receipt", + qty=8, + from_warehouse="_Test Warehouse - _TC", + do_not_save=True, + ) + + sr_doc = create_stock_reconciliation( + purpose="Stock Reconciliation", + posting_date=add_days(today(), -3), + posting_time="00:00:00", + item_code=item_code, + warehouse="_Test Warehouse - _TC", + valuation_rate=10, + qty=7, + do_not_submit=True, + ) + + self.assertRaises(frappe.ValidationError, sr_doc.submit) + def make_serialized_item(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 95f4f5fd3696..fe81a87558ca 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -20,7 +20,6 @@ "is_finished_item", "is_scrap_item", "quality_inspection", - "is_process_loss", "subcontracted_item", "section_break_8", "description", @@ -559,12 +558,6 @@ "print_hide": 1, "read_only": 1 }, - { - "default": "0", - "fieldname": "is_process_loss", - "fieldtype": "Check", - "label": "Is Process Loss" - }, { "default": "0", "depends_on": "barcode", @@ -578,7 +571,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-11-02 13:00:34.258828", + "modified": "2023-01-03 14:51:16.575515", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index c64370dcdf28..052f7781c130 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -221,14 +221,9 @@ def on_cancel(self): def on_doctype_update(): - if not frappe.db.has_index("tabStock Ledger Entry", "posting_sort_index"): - frappe.db.commit() - frappe.db.add_index( - "Stock Ledger Entry", - fields=["posting_date", "posting_time", "name"], - index_name="posting_sort_index", - ) - + frappe.db.add_index( + "Stock Ledger Entry", fields=["posting_date", "posting_time"], index_name="posting_sort_index" + ) frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"]) frappe.db.add_index("Stock Ledger Entry", ["batch_no", "item_code", "warehouse"]) frappe.db.add_index("Stock Ledger Entry", ["warehouse", "item_code"], "item_warehouse") diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 398b3c98e383..482b103d1e4b 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -4,7 +4,8 @@ from typing import Optional import frappe -from frappe import _, msgprint +from frappe import _, bold, msgprint +from frappe.query_builder.functions import Sum from frappe.utils import cint, cstr, flt import erpnext @@ -89,7 +90,7 @@ def _changed(item): if item_dict.get("serial_nos"): item.current_serial_no = item_dict.get("serial_nos") - if self.purpose == "Stock Reconciliation" and not item.serial_no: + if self.purpose == "Stock Reconciliation" and not item.serial_no and item.qty: item.serial_no = item.current_serial_no item.current_qty = item_dict.get("qty") @@ -140,6 +141,14 @@ def _get_msg(row_num, msg): self.validate_item(row.item_code, row) + if row.serial_no and not row.qty: + self.validation_messages.append( + _get_msg( + row_num, + f"Quantity should not be zero for the {bold(row.item_code)} since serial nos are specified", + ) + ) + # validate warehouse if not frappe.db.get_value("Warehouse", row.warehouse): self.validation_messages.append(_get_msg(row_num, _("Warehouse not found in the system"))) @@ -397,6 +406,7 @@ def get_sle_for_items(self, row, serial_nos=None): "voucher_type": self.doctype, "voucher_no": self.name, "voucher_detail_no": row.name, + "actual_qty": 0, "company": self.company, "stock_uom": frappe.db.get_value("Item", row.item_code, "stock_uom"), "is_cancelled": 1 if self.docstatus == 2 else 0, @@ -423,6 +433,8 @@ def get_sle_for_items(self, row, serial_nos=None): data.valuation_rate = flt(row.valuation_rate) data.stock_value_difference = -1 * flt(row.amount_difference) + self.update_inventory_dimensions(row, data) + return data def make_sle_on_cancel(self): @@ -558,6 +570,54 @@ def cancel(self): else: self._cancel() + def recalculate_current_qty(self, item_code, batch_no): + for row in self.items: + if not (row.item_code == item_code and row.batch_no == batch_no): + continue + + row.current_qty = get_batch_qty_for_stock_reco(item_code, row.warehouse, batch_no) + + qty, val_rate = get_stock_balance( + item_code, + row.warehouse, + self.posting_date, + self.posting_time, + with_valuation_rate=True, + ) + + row.current_valuation_rate = val_rate + + row.db_set( + { + "current_qty": row.current_qty, + "current_valuation_rate": row.current_valuation_rate, + "current_amount": flt(row.current_qty * row.current_valuation_rate), + } + ) + + +def get_batch_qty_for_stock_reco(item_code, warehouse, batch_no): + ledger = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(ledger) + .select( + Sum(ledger.actual_qty).as_("batch_qty"), + ) + .where( + (ledger.item_code == item_code) + & (ledger.warehouse == warehouse) + & (ledger.docstatus == 1) + & (ledger.is_cancelled == 0) + & (ledger.batch_no == batch_no) + ) + .groupby(ledger.batch_no) + ) + + sle = query.run(as_dict=True) + + return flt(sle[0].batch_qty) if sle else 0 + @frappe.whitelist() def get_items( diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.js b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.js index 42d0723d4276..5f81679bade8 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.js +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.js @@ -2,7 +2,22 @@ // For license information, please see license.txt frappe.ui.form.on('Stock Reposting Settings', { - // refresh: function(frm) { + refresh: function(frm) { + frm.trigger('convert_to_item_based_reposting'); + }, - // } + convert_to_item_based_reposting: function(frm) { + frm.add_custom_button(__('Convert to Item Based Reposting'), function() { + frm.call({ + method: 'convert_to_item_wh_reposting', + frezz: true, + doc: frm.doc, + callback: function(r) { + if (!r.exc) { + frm.reload_doc(); + } + } + }) + }) + } }); diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index e0c8ed12e7d5..51fb5ac4c409 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +import frappe +from frappe import _ from frappe.model.document import Document from frappe.utils import add_to_date, get_datetime, get_time_str, time_diff_in_hours @@ -24,3 +26,62 @@ def set_minimum_reposting_time_slot(self): if diff < 10: self.end_time = get_time_str(add_to_date(self.start_time, hours=10, as_datetime=True)) + + @frappe.whitelist() + def convert_to_item_wh_reposting(self): + """Convert Transaction reposting to Item Warehouse based reposting if Item Based Reposting has enabled.""" + + reposting_data = get_reposting_entries() + + vouchers = [d.voucher_no for d in reposting_data] + + item_warehouses = {} + + for ledger in get_stock_ledgers(vouchers): + key = (ledger.item_code, ledger.warehouse) + if key not in item_warehouses: + item_warehouses[key] = ledger.posting_date + elif frappe.utils.getdate(item_warehouses.get(key)) > frappe.utils.getdate(ledger.posting_date): + item_warehouses[key] = ledger.posting_date + + for key, posting_date in item_warehouses.items(): + item_code, warehouse = key + create_repost_item_valuation(item_code, warehouse, posting_date) + + for row in reposting_data: + frappe.db.set_value("Repost Item Valuation", row.name, "status", "Skipped") + + self.db_set("item_based_reposting", 1) + frappe.msgprint(_("Item Warehouse based reposting has been enabled.")) + + +def get_reposting_entries(): + return frappe.get_all( + "Repost Item Valuation", + fields=["voucher_no", "name"], + filters={"status": ("in", ["Queued", "In Progress"]), "docstatus": 1, "based_on": "Transaction"}, + ) + + +def get_stock_ledgers(vouchers): + return frappe.get_all( + "Stock Ledger Entry", + fields=["item_code", "warehouse", "posting_date"], + filters={"voucher_no": ("in", vouchers)}, + ) + + +def create_repost_item_valuation(item_code, warehouse, posting_date): + frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "company": frappe.get_cached_value("Warehouse", warehouse, "company"), + "posting_date": posting_date, + "based_on": "Item and Warehouse", + "posting_time": "00:00:01", + "item_code": item_code, + "warehouse": warehouse, + "allow_negative_stock": True, + "status": "Queued", + } + ).submit() diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 8561dc2e91e1..2df39c818326 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -8,6 +8,7 @@ from frappe import _, throw from frappe.model import child_table_fields, default_fields from frappe.model.meta import get_field_precision +from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.utils import add_days, add_months, cint, cstr, flt, getdate from erpnext import get_company_currency @@ -34,7 +35,14 @@ @frappe.whitelist() -def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True): +def get_item_details( + args, + doc=None, + for_validate=False, + overwrite_warehouse=True, + return_basic_details=False, + basic_details=None, +): """ args = { "item_code": "", @@ -72,7 +80,13 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru if doc.get("doctype") == "Purchase Invoice": args["bill_date"] = doc.get("bill_date") - out = get_basic_details(args, item, overwrite_warehouse) + if not basic_details: + out = get_basic_details(args, item, overwrite_warehouse) + else: + out = basic_details + + basic_details = out.copy() + get_item_tax_template(args, item, out) out["item_tax_rate"] = get_item_tax_map( args.company, @@ -88,8 +102,15 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru update_party_blanket_order(args, out) + # Never try to find a customer price if customer is set in these Doctype + current_customer = args.customer + if args.get("doctype") in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]: + args.customer = None + out.update(get_price_list_rate(args, item)) + args.customer = current_customer + if args.customer and cint(args.is_pos): out.update(get_pos_profile_item_details(args.company, args, update_data=True)) @@ -133,7 +154,11 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru out.amount = flt(args.qty) * flt(out.rate) out = remove_standard_fields(out) - return out + + if return_basic_details: + return out, basic_details + else: + return out def remove_standard_fields(details): @@ -236,8 +261,10 @@ def validate_item_details(args, item): validate_end_of_life(item.name, item.end_of_life, item.disabled) - if args.transaction_type == "selling" and cint(item.has_variants): - throw(_("Item {0} is a template, please select one of its variants").format(item.name)) + if cint(item.has_variants): + msg = f"Item {item.name} is a template, please select one of its variants" + + throw(_(msg), title=_("Template Item Selected")) elif args.transaction_type == "buying" and args.doctype != "Material Request": if args.get("is_subcontracted"): @@ -411,7 +438,9 @@ def get_basic_details(args, item, overwrite_warehouse=True): args.stock_qty = out.stock_qty # calculate last purchase rate - if args.get("doctype") in purchase_doctypes: + if args.get("doctype") in purchase_doctypes and not frappe.db.get_single_value( + "Buying Settings", "disable_last_purchase_rate" + ): from erpnext.buying.doctype.purchase_order.purchase_order import item_last_purchase_rate out.last_purchase_rate = item_last_purchase_rate( @@ -515,12 +544,8 @@ def get_barcode_data(items_list): itemwise_barcode = {} for item in items_list: - barcodes = frappe.db.sql( - """ - select barcode from `tabItem Barcode` where parent = %s - """, - item.item_code, - as_dict=1, + barcodes = frappe.db.get_all( + "Item Barcode", filters={"parent": item.item_code}, fields="barcode" ) for barcode in barcodes: @@ -813,6 +838,9 @@ def get_price_list_rate(args, item_doc, out=None): flt(price_list_rate) * flt(args.plc_conversion_rate) / flt(args.conversion_rate) ) + if frappe.db.get_single_value("Buying Settings", "disable_last_purchase_rate"): + return out + if not out.price_list_rate and args.transaction_type == "buying": from erpnext.stock.doctype.item.item import get_last_purchase_details @@ -877,34 +905,36 @@ def get_item_price(args, item_code, ignore_party=False): :param item_code: str, Item Doctype field item_code """ - args["item_code"] = item_code - - conditions = """where item_code=%(item_code)s - and price_list=%(price_list)s - and ifnull(uom, '') in ('', %(uom)s)""" - - conditions += "and ifnull(batch_no, '') in ('', %(batch_no)s)" + ip = frappe.qb.DocType("Item Price") + query = ( + frappe.qb.from_(ip) + .select(ip.name, ip.price_list_rate, ip.uom) + .where( + (ip.item_code == item_code) + & (ip.price_list == args.get("price_list")) + & (IfNull(ip.uom, "").isin(["", args.get("uom")])) + & (IfNull(ip.batch_no, "").isin(["", args.get("batch_no")])) + ) + .orderby(ip.valid_from, order=frappe.qb.desc) + .orderby(IfNull(ip.batch_no, ""), order=frappe.qb.desc) + .orderby(ip.uom, order=frappe.qb.desc) + ) if not ignore_party: if args.get("customer"): - conditions += " and customer=%(customer)s" + query = query.where(ip.customer == args.get("customer")) elif args.get("supplier"): - conditions += " and supplier=%(supplier)s" + query = query.where(ip.supplier == args.get("supplier")) else: - conditions += "and (customer is null or customer = '') and (supplier is null or supplier = '')" + query = query.where((IfNull(ip.customer, "") == "") & (IfNull(ip.supplier, "") == "")) if args.get("transaction_date"): - conditions += """ and %(transaction_date)s between - ifnull(valid_from, '2000-01-01') and ifnull(valid_upto, '2500-12-31')""" - - return frappe.db.sql( - """ select name, price_list_rate, uom - from `tabItem Price` {conditions} - order by valid_from desc, ifnull(batch_no, '') desc, uom desc """.format( - conditions=conditions - ), - args, - ) + query = query.where( + (IfNull(ip.valid_from, "2000-01-01") <= args["transaction_date"]) + & (IfNull(ip.valid_upto, "2500-12-31") >= args["transaction_date"]) + ) + + return query.run() def get_price_list_rate_for(args, item_code): @@ -1077,91 +1107,68 @@ def get_pos_profile(company, pos_profile=None, user=None): if not user: user = frappe.session["user"] - condition = "pfu.user = %(user)s AND pfu.default=1" - if user and company: - condition = "pfu.user = %(user)s AND pf.company = %(company)s AND pfu.default=1" - - pos_profile = frappe.db.sql( - """SELECT pf.* - FROM - `tabPOS Profile` pf LEFT JOIN `tabPOS Profile User` pfu - ON - pf.name = pfu.parent - WHERE - {cond} AND pf.disabled = 0 - """.format( - cond=condition - ), - {"user": user, "company": company}, - as_dict=1, + pf = frappe.qb.DocType("POS Profile") + pfu = frappe.qb.DocType("POS Profile User") + + query = ( + frappe.qb.from_(pf) + .left_join(pfu) + .on(pf.name == pfu.parent) + .select(pf.star) + .where((pfu.user == user) & (pfu.default == 1)) ) + if company: + query = query.where(pf.company == company) + + pos_profile = query.run(as_dict=True) + if not pos_profile and company: - pos_profile = frappe.db.sql( - """SELECT pf.* - FROM - `tabPOS Profile` pf LEFT JOIN `tabPOS Profile User` pfu - ON - pf.name = pfu.parent - WHERE - pf.company = %(company)s AND pf.disabled = 0 - """, - {"company": company}, - as_dict=1, - ) + pos_profile = ( + frappe.qb.from_(pf) + .left_join(pfu) + .on(pf.name == pfu.parent) + .select(pf.star) + .where((pf.company == company) & (pf.disabled == 0)) + ).run(as_dict=True) return pos_profile and pos_profile[0] or None def get_serial_nos_by_fifo(args, sales_order=None): if frappe.db.get_single_value("Stock Settings", "automatically_set_serial_nos_based_on_fifo"): - return "\n".join( - frappe.db.sql_list( - """select name from `tabSerial No` - where item_code=%(item_code)s and warehouse=%(warehouse)s and - sales_order=IF(%(sales_order)s IS NULL, sales_order, %(sales_order)s) - order by timestamp(purchase_date, purchase_time) - asc limit %(qty)s""", - { - "item_code": args.item_code, - "warehouse": args.warehouse, - "qty": abs(cint(args.stock_qty)), - "sales_order": sales_order, - }, - ) + sn = frappe.qb.DocType("Serial No") + query = ( + frappe.qb.from_(sn) + .select(sn.name) + .where((sn.item_code == args.item_code) & (sn.warehouse == args.warehouse)) + .orderby(CombineDatetime(sn.purchase_date, sn.purchase_time)) + .limit(abs(cint(args.stock_qty))) ) + if sales_order: + query = query.where(sn.sales_order == sales_order) + if args.batch_no: + query = query.where(sn.batch_no == args.batch_no) -def get_serial_no_batchwise(args, sales_order=None): - if frappe.db.get_single_value("Stock Settings", "automatically_set_serial_nos_based_on_fifo"): - return "\n".join( - frappe.db.sql_list( - """select name from `tabSerial No` - where item_code=%(item_code)s and warehouse=%(warehouse)s and - sales_order=IF(%(sales_order)s IS NULL, sales_order, %(sales_order)s) - and batch_no=IF(%(batch_no)s IS NULL, batch_no, %(batch_no)s) order - by timestamp(purchase_date, purchase_time) asc limit %(qty)s""", - { - "item_code": args.item_code, - "warehouse": args.warehouse, - "batch_no": args.batch_no, - "qty": abs(cint(args.stock_qty)), - "sales_order": sales_order, - }, - ) - ) + serial_nos = query.run(as_list=True) + serial_nos = [s[0] for s in serial_nos] + + return "\n".join(serial_nos) @frappe.whitelist() def get_conversion_factor(item_code, uom): variant_of = frappe.db.get_value("Item", item_code, "variant_of", cache=True) filters = {"parent": item_code, "uom": uom} + if variant_of: filters["parent"] = ("in", (item_code, variant_of)) conversion_factor = frappe.db.get_value("UOM Conversion Detail", filters, "conversion_factor") if not conversion_factor: stock_uom = frappe.db.get_value("Item", item_code, "stock_uom") conversion_factor = get_uom_conv_factor(uom, stock_uom) + return {"conversion_factor": conversion_factor or 1.0} @@ -1176,7 +1183,7 @@ def get_projected_qty(item_code, warehouse): @frappe.whitelist() def get_bin_details(item_code, warehouse, company=None, include_child_warehouses=False): - bin_details = {"projected_qty": 0, "actual_qty": 0, "reserved_qty": 0, "ordered_qty": 0} + bin_details = {"projected_qty": 0, "actual_qty": 0, "reserved_qty": 0} if warehouse: from frappe.query_builder.functions import Coalesce, Sum @@ -1192,7 +1199,6 @@ def get_bin_details(item_code, warehouse, company=None, include_child_warehouses Coalesce(Sum(bin.projected_qty), 0).as_("projected_qty"), Coalesce(Sum(bin.actual_qty), 0).as_("actual_qty"), Coalesce(Sum(bin.reserved_qty), 0).as_("reserved_qty"), - Coalesce(Sum(bin.ordered_qty), 0).as_("ordered_qty"), ) .where((bin.item_code == item_code) & (bin.warehouse.isin(warehouses))) ).run(as_dict=True)[0] @@ -1204,12 +1210,16 @@ def get_bin_details(item_code, warehouse, company=None, include_child_warehouses def get_company_total_stock(item_code, company): - return frappe.db.sql( - """SELECT sum(actual_qty) from - (`tabBin` INNER JOIN `tabWarehouse` ON `tabBin`.warehouse = `tabWarehouse`.name) - WHERE `tabWarehouse`.company = %s and `tabBin`.item_code = %s""", - (company, item_code), - )[0][0] + bin = frappe.qb.DocType("Bin") + wh = frappe.qb.DocType("Warehouse") + + return ( + frappe.qb.from_(bin) + .inner_join(wh) + .on(bin.warehouse == wh.name) + .select(Sum(bin.actual_qty)) + .where((wh.company == company) & (bin.item_code == item_code)) + ).run()[0][0] @frappe.whitelist() @@ -1218,6 +1228,7 @@ def get_serial_no_details(item_code, warehouse, stock_qty, serial_no): {"item_code": item_code, "warehouse": warehouse, "stock_qty": stock_qty, "serial_no": serial_no} ) serial_no = get_serial_no(args) + return {"serial_no": serial_no} @@ -1237,6 +1248,7 @@ def get_bin_details_and_serial_nos( bin_details_and_serial_nos.update( get_serial_no_details(item_code, warehouse, stock_qty, serial_no) ) + return bin_details_and_serial_nos @@ -1251,6 +1263,7 @@ def get_batch_qty_and_serial_no(batch_no, stock_qty, warehouse, item_code, has_s ) serial_no = get_serial_no(args) batch_qty_and_serial_no.update({"serial_no": serial_no}) + return batch_qty_and_serial_no @@ -1323,7 +1336,6 @@ def apply_price_list(args, as_doc=False): def apply_price_list_on_item(args): item_doc = frappe.db.get_value("Item", args.item_code, ["name", "variant_of"], as_dict=1) item_details = get_price_list_rate(args, item_doc) - item_details.update(get_pricing_rule_for_item(args)) return item_details @@ -1407,12 +1419,12 @@ def get_valuation_rate(item_code, company, warehouse=None): ) or {"valuation_rate": 0} elif not item.get("is_stock_item"): - valuation_rate = frappe.db.sql( - """select sum(base_net_amount) / sum(qty*conversion_factor) - from `tabPurchase Invoice Item` - where item_code = %s and docstatus=1""", - item_code, - ) + pi_item = frappe.qb.DocType("Purchase Invoice Item") + valuation_rate = ( + frappe.qb.from_(pi_item) + .select((Sum(pi_item.base_net_amount) / Sum(pi_item.qty * pi_item.conversion_factor))) + .where((pi_item.docstatus == 1) & (pi_item.item_code == item_code)) + ).run() if valuation_rate: return {"valuation_rate": valuation_rate[0][0] or 0.0} @@ -1438,7 +1450,7 @@ def get_serial_no(args, serial_nos=None, sales_order=None): if args.get("warehouse") and args.get("stock_qty") and args.get("item_code"): has_serial_no = frappe.get_value("Item", {"item_code": args.item_code}, "has_serial_no") if args.get("batch_no") and has_serial_no == 1: - return get_serial_no_batchwise(args, sales_order) + return get_serial_nos_by_fifo(args, sales_order) elif has_serial_no == 1: args = json.dumps( { @@ -1470,31 +1482,35 @@ def get_blanket_order_details(args): args = frappe._dict(json.loads(args)) blanket_order_details = None - condition = "" + if args.item_code: + bo = frappe.qb.DocType("Blanket Order") + bo_item = frappe.qb.DocType("Blanket Order Item") + + query = ( + frappe.qb.from_(bo) + .from_(bo_item) + .select(bo_item.rate.as_("blanket_order_rate"), bo.name.as_("blanket_order")) + .where( + (bo.company == args.company) + & (bo_item.item_code == args.item_code) + & (bo.docstatus == 1) + & (bo.name == bo_item.parent) + ) + ) + if args.customer and args.doctype == "Sales Order": - condition = " and bo.customer=%(customer)s" + query = query.where(bo.customer == args.customer) elif args.supplier and args.doctype == "Purchase Order": - condition = " and bo.supplier=%(supplier)s" + query = query.where(bo.supplier == args.supplier) if args.blanket_order: - condition += " and bo.name =%(blanket_order)s" + query = query.where(bo.name == args.blanket_order) if args.transaction_date: - condition += " and bo.to_date>=%(transaction_date)s" - - blanket_order_details = frappe.db.sql( - """ - select boi.rate as blanket_order_rate, bo.name as blanket_order - from `tabBlanket Order` bo, `tabBlanket Order Item` boi - where bo.company=%(company)s and boi.item_code=%(item_code)s - and bo.docstatus=1 and bo.name = boi.parent {0} - """.format( - condition - ), - args, - as_dict=True, - ) + query = query.where(bo.to_date >= args.transaction_date) + blanket_order_details = query.run(as_dict=True) blanket_order_details = blanket_order_details[0] if blanket_order_details else "" + return blanket_order_details @@ -1504,10 +1520,10 @@ def get_so_reservation_for_item(args): if get_reserved_qty_for_so(args.get("against_sales_order"), args.get("item_code")): reserved_so = args.get("against_sales_order") elif args.get("against_sales_invoice"): - sales_order = frappe.db.sql( - """select sales_order from `tabSales Invoice Item` where - parent=%s and item_code=%s""", - (args.get("against_sales_invoice"), args.get("item_code")), + sales_order = frappe.db.get_all( + "Sales Invoice Item", + filters={"parent": args.get("against_sales_invoice"), "item_code": args.get("item_code")}, + fields="sales_order", ) if sales_order and sales_order[0]: if get_reserved_qty_for_so(sales_order[0][0], args.get("item_code")): @@ -1519,13 +1535,14 @@ def get_so_reservation_for_item(args): def get_reserved_qty_for_so(sales_order, item_code): - reserved_qty = frappe.db.sql( - """select sum(qty) from `tabSales Order Item` - where parent=%s and item_code=%s and ensure_delivery_based_on_produced_serial_no=1 - """, - (sales_order, item_code), + reserved_qty = frappe.db.get_value( + "Sales Order Item", + filters={ + "parent": sales_order, + "item_code": item_code, + "ensure_delivery_based_on_produced_serial_no": 1, + }, + fieldname="sum(qty)", ) - if reserved_qty and reserved_qty[0][0]: - return reserved_qty[0][0] - else: - return 0 + + return reserved_qty or 0 diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index 99f820ecac62..106e877c4cd4 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -41,7 +41,7 @@ def get_data(report_filters): key = (d.voucher_type, d.voucher_no) gl_data = voucher_wise_gl_data.get(key) or {} d.account_value = gl_data.get("account_value", 0) - d.difference_value = abs(d.stock_value - d.account_value) + d.difference_value = d.stock_value - d.account_value if abs(d.difference_value) > 0.1: data.append(d) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 0fc642ef207f..66991a907fdc 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -7,7 +7,7 @@ import frappe from frappe import _ -from frappe.query_builder.functions import CombineDatetime +from frappe.query_builder.functions import Coalesce, CombineDatetime from frappe.utils import cint, date_diff, flt, getdate from frappe.utils.nestedset import get_descendants_of @@ -322,6 +322,34 @@ def get_stock_ledger_entries(filters: StockBalanceFilter, items: List[str]) -> L return query.run(as_dict=True) +def get_opening_vouchers(to_date): + opening_vouchers = {"Stock Entry": [], "Stock Reconciliation": []} + + se = frappe.qb.DocType("Stock Entry") + sr = frappe.qb.DocType("Stock Reconciliation") + + vouchers_data = ( + frappe.qb.from_( + ( + frappe.qb.from_(se) + .select(se.name, Coalesce("Stock Entry").as_("voucher_type")) + .where((se.docstatus == 1) & (se.posting_date <= to_date) & (se.is_opening == "Yes")) + ) + + ( + frappe.qb.from_(sr) + .select(sr.name, Coalesce("Stock Reconciliation").as_("voucher_type")) + .where((sr.docstatus == 1) & (sr.posting_date <= to_date) & (sr.purpose == "Opening Stock")) + ) + ).select("voucher_type", "name") + ).run(as_dict=True) + + if vouchers_data: + for d in vouchers_data: + opening_vouchers[d.voucher_type].append(d.name) + + return opening_vouchers + + def get_inventory_dimension_fields(): return [dimension.fieldname for dimension in get_inventory_dimensions()] @@ -330,9 +358,8 @@ def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]): iwb_map = {} from_date = getdate(filters.get("from_date")) to_date = getdate(filters.get("to_date")) - + opening_vouchers = get_opening_vouchers(to_date) float_precision = cint(frappe.db.get_default("float_precision")) or 3 - inventory_dimensions = get_inventory_dimension_fields() for d in sle: @@ -363,11 +390,7 @@ def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]): value_diff = flt(d.stock_value_difference) - if d.posting_date < from_date or ( - d.posting_date == from_date - and d.voucher_type == "Stock Reconciliation" - and frappe.db.get_value("Stock Reconciliation", d.voucher_no, "purpose") == "Opening Stock" - ): + if d.posting_date < from_date or d.voucher_no in opening_vouchers.get(d.voucher_type, []): qty_dict.opening_qty += qty_diff qty_dict.opening_val += value_diff diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 8b63c0f99869..77bc4e004de7 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -34,6 +34,9 @@ def execute(filters=None): conversion_factors.append(0) actual_qty = stock_value = 0 + if opening_row: + actual_qty = opening_row.get("qty_after_transaction") + stock_value = opening_row.get("stock_value") available_serial_nos = {} inventory_dimension_filters_applied = check_inventory_dimension_filters_applied(filters) @@ -306,7 +309,7 @@ def get_stock_ledger_entries(filters, items): query = query.where(sle.item_code.isin(items)) for field in ["voucher_no", "batch_no", "project", "company"]: - if filters.get(field): + if filters.get(field) and field not in inventory_dimension_fields: query = query.where(sle[field] == filters.get(field)) query = apply_warehouse_filter(query, sle, filters) diff --git a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py index b5c6764224bd..abbb33b2f16c 100644 --- a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py +++ b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py @@ -62,7 +62,7 @@ def execute(filters=None): continue total_stock_value = sum(item_value[(item, item_group)]) - row = [item, item_group, total_stock_value] + row = [item, item_map[item]["item_name"], item_group, total_stock_value] fifo_queue = item_ageing[item]["fifo_queue"] average_age = 0.00 @@ -89,10 +89,11 @@ def get_columns(filters): """return columns""" columns = [ - _("Item") + ":Link/Item:180", - _("Item Group") + "::100", + _("Item") + ":Link/Item:150", + _("Item Name") + ":Link/Item:150", + _("Item Group") + "::120", _("Value") + ":Currency:120", - _("Age") + ":Float:80", + _("Age") + ":Float:120", ] return columns @@ -123,7 +124,7 @@ def get_warehouse_list(filters): def add_warehouse_column(columns, warehouse_list): if len(warehouse_list) > 1: - columns += [_("Total Qty") + ":Int:90"] + columns += [_("Total Qty") + ":Int:120"] for wh in warehouse_list: - columns += [_(wh.name) + ":Int:120"] + columns += [_(wh.name) + ":Int:100"] diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index 14cedd2e8a9f..439ed7a8e097 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -121,7 +121,7 @@ def get_reserved_qty(item_code, warehouse): and parenttype='Sales Order' and item_code != parent_item and exists (select * from `tabSales Order` so - where name = dnpi_in.parent and docstatus = 1 and status != 'Closed') + where name = dnpi_in.parent and docstatus = 1 and status not in ('On Hold', 'Closed')) ) dnpi) union (select stock_qty as dnpi_qty, qty as so_item_qty, @@ -131,7 +131,7 @@ def get_reserved_qty(item_code, warehouse): and (so_item.delivered_by_supplier is null or so_item.delivered_by_supplier = 0) and exists(select * from `tabSales Order` so where so.name = so_item.parent and so.docstatus = 1 - and so.status != 'Closed')) + and so.status not in ('On Hold', 'Closed'))) ) tab where so_item_qty >= so_item_delivered_qty diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 55a11a186716..c954befdc297 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1050,7 +1050,7 @@ def update_bin(self): frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True) -def get_previous_sle_of_current_voucher(args, exclude_current_voucher=False): +def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_voucher=False): """get stock ledger entries filtered by specific posting datetime conditions""" args["time_format"] = "%H:%i:%s" @@ -1076,13 +1076,13 @@ def get_previous_sle_of_current_voucher(args, exclude_current_voucher=False): posting_date < %(posting_date)s or ( posting_date = %(posting_date)s and - time_format(posting_time, %(time_format)s) < time_format(%(posting_time)s, %(time_format)s) + time_format(posting_time, %(time_format)s) {operator} time_format(%(posting_time)s, %(time_format)s) ) ) order by timestamp(posting_date, posting_time) desc, creation desc limit 1 for update""".format( - voucher_condition=voucher_condition + operator=operator, voucher_condition=voucher_condition ), args, as_dict=1, @@ -1179,7 +1179,7 @@ def get_stock_ledger_entries( def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): return frappe.db.get_value( "Stock Ledger Entry", - {"voucher_detail_no": voucher_detail_no, "name": ["!=", excluded_sle]}, + {"voucher_detail_no": voucher_detail_no, "name": ["!=", excluded_sle], "is_cancelled": 0}, [ "item_code", "warehouse", @@ -1270,20 +1270,6 @@ def get_valuation_rate( (item_code, warehouse, voucher_no, voucher_type), ) - if not last_valuation_rate: - # Get valuation rate from last sle for the item against any warehouse - last_valuation_rate = frappe.db.sql( - """select valuation_rate - from `tabStock Ledger Entry` force index (item_code) - where - item_code = %s - AND valuation_rate > 0 - AND is_cancelled = 0 - AND NOT(voucher_no = %s AND voucher_type = %s) - order by posting_date desc, posting_time desc, name desc limit 1""", - (item_code, voucher_no, voucher_type), - ) - if last_valuation_rate: return flt(last_valuation_rate[0][0]) @@ -1351,6 +1337,9 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): next_stock_reco_detail = get_next_stock_reco(args) if next_stock_reco_detail: detail = next_stock_reco_detail[0] + if detail.batch_no: + regenerate_sle_for_batch_stock_reco(detail) + # add condition to update SLEs before this date & time datetime_limit_condition = get_datetime_limit_condition(detail) @@ -1378,6 +1367,16 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): validate_negative_qty_in_future_sle(args, allow_negative_stock) +def regenerate_sle_for_batch_stock_reco(detail): + doc = frappe.get_cached_doc("Stock Reconciliation", detail.voucher_no) + doc.docstatus = 2 + doc.update_stock_ledger() + + doc.recalculate_current_qty(detail.item_code, detail.batch_no) + doc.docstatus = 1 + doc.update_stock_ledger() + + def get_stock_reco_qty_shift(args): stock_reco_qty_shift = 0 if args.get("is_cancelled"): @@ -1389,7 +1388,7 @@ def get_stock_reco_qty_shift(args): stock_reco_qty_shift = flt(args.actual_qty) else: # reco is being submitted - last_balance = get_previous_sle_of_current_voucher(args, exclude_current_voucher=True).get( + last_balance = get_previous_sle_of_current_voucher(args, "<=", exclude_current_voucher=True).get( "qty_after_transaction" ) @@ -1407,7 +1406,7 @@ def get_next_stock_reco(args): return frappe.db.sql( """ select - name, posting_date, posting_time, creation, voucher_no + name, posting_date, posting_time, creation, voucher_no, item_code, batch_no, actual_qty from `tabStock Ledger Entry` where diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py new file mode 100644 index 000000000000..b53e29e9e8e1 --- /dev/null +++ b/erpnext/stock/tests/test_get_item_details.py @@ -0,0 +1,40 @@ +import json + +import frappe +from frappe.test_runner import make_test_records +from frappe.tests.utils import FrappeTestCase + +from erpnext.stock.get_item_details import get_item_details + +test_ignore = ["BOM"] +test_dependencies = ["Customer", "Supplier", "Item", "Price List", "Item Price"] + + +class TestGetItemDetail(FrappeTestCase): + def setUp(self): + make_test_records("Price List") + super().setUp() + + def test_get_item_detail_purchase_order(self): + + args = frappe._dict( + { + "item_code": "_Test Item", + "company": "_Test Company", + "customer": "_Test Customer", + "conversion_rate": 1.0, + "price_list_currency": "USD", + "plc_conversion_rate": 1.0, + "doctype": "Purchase Order", + "name": None, + "supplier": "_Test Supplier", + "transaction_date": None, + "conversion_rate": 1.0, + "price_list": "_Test Buying Price List", + "is_subcontracted": 0, + "ignore_pricing_rule": 1, + "qty": 1, + } + ) + details = get_item_details(args) + self.assertEqual(details.get("price_list_rate"), 100) diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py index e60c1caac34a..05f153b4a0cd 100644 --- a/erpnext/stock/tests/test_valuation.py +++ b/erpnext/stock/tests/test_valuation.py @@ -132,7 +132,7 @@ def test_fifo_qty_hypothesis(self, stock_queue): total_qty = 0 for qty, rate in stock_queue: - if qty == 0: + if round_off_if_near_zero(qty) == 0: continue if qty > 0: self.queue.add_stock(qty, rate) @@ -154,7 +154,7 @@ def test_fifo_qty_value_nonneg_hypothesis(self, stock_queue): for qty, rate in stock_queue: # don't allow negative stock - if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1: + if round_off_if_near_zero(qty) == 0 or total_qty + qty < 0 or abs(qty) < 0.1: continue if qty > 0: self.queue.add_stock(qty, rate) @@ -179,7 +179,7 @@ def test_fifo_qty_value_nonneg_hypothesis_with_outgoing_rate(self, stock_queue, for qty, rate in stock_queue: # don't allow negative stock - if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1: + if round_off_if_near_zero(qty) == 0 or total_qty + qty < 0 or abs(qty) < 0.1: continue if qty > 0: self.queue.add_stock(qty, rate) @@ -282,7 +282,7 @@ def test_lifo_qty_hypothesis(self, stock_stack): total_qty = 0 for qty, rate in stock_stack: - if qty == 0: + if round_off_if_near_zero(qty) == 0: continue if qty > 0: self.stack.add_stock(qty, rate) @@ -304,7 +304,7 @@ def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack): for qty, rate in stock_stack: # don't allow negative stock - if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1: + if round_off_if_near_zero(qty) == 0 or total_qty + qty < 0 or abs(qty) < 0.1: continue if qty > 0: self.stack.add_stock(qty, rate) diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order_list.js b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order_list.js index aab2fc927d03..7ca12642c5f5 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order_list.js +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order_list.js @@ -11,6 +11,7 @@ frappe.listview_settings['Subcontracting Order'] = { "Partial Material Transferred": "purple", "Material Transferred": "blue", "Closed": "red", + "Cancelled": "red", }; return [__(doc.status), status_colors[doc.status], "status,=," + doc.status]; }, diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index d054ce0f9d4f..6a2983faaaf2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -2,6 +2,7 @@ # See license.txt import copy +from collections import defaultdict import frappe from frappe.tests.utils import FrappeTestCase @@ -186,6 +187,40 @@ def test_make_rm_stock_entry_for_batch_items(self): ) self.assertEqual(len(ste.items), len(rm_items)) + def test_make_rm_stock_entry_for_batch_items_with_less_transfer(self): + set_backflush_based_on("BOM") + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 4", + "qty": 5, + "rate": 100, + "fg_item": "Subcontracted Item SA4", + "fg_item_qty": 5, + } + ] + + sco = get_subcontracting_order(service_items=service_items) + rm_items = get_rm_items(sco.supplied_items) + itemwise_details = make_stock_in_entry(rm_items=rm_items) + + itemwise_transfer_qty = defaultdict(int) + for item in rm_items: + item["qty"] -= 1 + itemwise_transfer_qty[item["item_code"]] += item["qty"] + + ste = make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + + scr = make_subcontracting_receipt(sco.name) + + for row in scr.supplied_items: + self.assertEqual(row.consumed_qty, itemwise_transfer_qty.get(row.rm_item_code) + 1) + def test_update_reserved_qty_for_subcontracting(self): # Create RM Material Receipt make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=10, basic_rate=100) diff --git a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json index 3675a4ea08a3..d77e77440e04 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_order_item/subcontracting_order_item.json @@ -1,352 +1,353 @@ { - "actions": [], - "autoname": "hash", - "creation": "2022-04-01 19:26:31.475015", - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "item_code", - "item_name", - "bom", - "include_exploded_items", - "column_break_3", - "schedule_date", - "expected_delivery_date", - "description_section", - "description", - "column_break_8", - "image", - "image_view", - "quantity_and_rate_section", - "qty", - "received_qty", - "returned_qty", - "column_break_13", - "stock_uom", - "conversion_factor", - "section_break_16", - "rate", - "amount", - "column_break_19", - "rm_cost_per_qty", - "service_cost_per_qty", - "additional_cost_per_qty", - "warehouse_section", - "warehouse", - "accounting_details_section", - "expense_account", - "manufacture_section", - "manufacturer", - "manufacturer_part_no", - "accounting_dimensions_section", - "cost_center", - "dimension_col_break", - "project", - "section_break_34", - "page_break" - ], - "fields": [ - { - "bold": 1, - "columns": 2, - "fieldname": "item_code", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Item Code", - "options": "Item", - "read_only": 1, - "reqd": 1, - "search_index": 1 - }, - { - "fetch_from": "item_code.item_name", - "fetch_if_empty": 1, - "fieldname": "item_name", - "fieldtype": "Data", - "in_global_search": 1, - "label": "Item Name", - "print_hide": 1, - "reqd": 1 - }, - { - "fieldname": "column_break_3", - "fieldtype": "Column Break" - }, - { - "bold": 1, - "columns": 2, - "fieldname": "schedule_date", - "fieldtype": "Date", - "label": "Required By", - "print_hide": 1, - "read_only": 1 - }, - { - "allow_on_submit": 1, - "bold": 1, - "fieldname": "expected_delivery_date", - "fieldtype": "Date", - "label": "Expected Delivery Date", - "search_index": 1 - }, - { - "collapsible": 1, - "fieldname": "description_section", - "fieldtype": "Section Break", - "label": "Description" - }, - { - "fetch_from": "item_code.description", - "fetch_if_empty": 1, - "fieldname": "description", - "fieldtype": "Text Editor", - "label": "Description", - "print_width": "300px", - "reqd": 1, - "width": "300px" - }, - { - "fieldname": "column_break_8", - "fieldtype": "Column Break" - }, - { - "fieldname": "image", - "fieldtype": "Attach", - "hidden": 1, - "label": "Image" - }, - { - "fieldname": "image_view", - "fieldtype": "Image", - "label": "Image View", - "options": "image", - "print_hide": 1 - }, - { - "fieldname": "quantity_and_rate_section", - "fieldtype": "Section Break", - "label": "Quantity and Rate" - }, - { - "bold": 1, - "columns": 1, - "default": "1", - "fieldname": "qty", - "fieldtype": "Float", - "in_list_view": 1, - "label": "Quantity", - "print_width": "60px", - "read_only": 1, - "reqd": 1, - "width": "60px" - }, - { - "fieldname": "column_break_13", - "fieldtype": "Column Break", - "print_hide": 1 - }, - { - "fieldname": "stock_uom", - "fieldtype": "Link", - "label": "Stock UOM", - "options": "UOM", - "print_width": "100px", - "read_only": 1, - "reqd": 1, - "width": "100px" - }, - { - "default": "1", - "fieldname": "conversion_factor", - "fieldtype": "Float", - "hidden": 1, - "label": "Conversion Factor", - "read_only": 1 - }, - { - "fieldname": "section_break_16", - "fieldtype": "Section Break" - }, - { - "bold": 1, - "columns": 2, - "fetch_from": "item_code.standard_rate", - "fetch_if_empty": 1, - "fieldname": "rate", - "fieldtype": "Currency", - "in_list_view": 1, - "label": "Rate", - "options": "currency", - "read_only": 1, - "reqd": 1 - }, - { - "fieldname": "column_break_19", - "fieldtype": "Column Break" - }, - { - "columns": 2, - "fieldname": "amount", - "fieldtype": "Currency", - "in_list_view": 1, - "label": "Amount", - "options": "currency", - "read_only": 1, - "reqd": 1 - }, - { - "fieldname": "warehouse_section", - "fieldtype": "Section Break", - "label": "Warehouse Details" - }, - { - "fieldname": "warehouse", - "fieldtype": "Link", - "label": "Warehouse", - "options": "Warehouse", - "print_hide": 1, - "reqd": 1 - }, - { - "collapsible": 1, - "fieldname": "accounting_details_section", - "fieldtype": "Section Break", - "label": "Accounting Details" - }, - { - "fieldname": "expense_account", - "fieldtype": "Link", - "label": "Expense Account", - "options": "Account", - "print_hide": 1 - }, - { - "collapsible": 1, - "fieldname": "manufacture_section", - "fieldtype": "Section Break", - "label": "Manufacture" - }, - { - "fieldname": "manufacturer", - "fieldtype": "Link", - "label": "Manufacturer", - "options": "Manufacturer" - }, - { - "fieldname": "manufacturer_part_no", - "fieldtype": "Data", - "label": "Manufacturer Part Number" - }, - { - "depends_on": "item_code", - "fetch_from": "item_code.default_bom", - "fieldname": "bom", - "fieldtype": "Link", - "in_list_view": 1, - "label": "BOM", - "options": "BOM", - "print_hide": 1, - "reqd": 1 - }, - { - "default": "0", - "fieldname": "include_exploded_items", - "fieldtype": "Check", - "label": "Include Exploded Items", - "print_hide": 1 - }, - { - "fieldname": "service_cost_per_qty", - "fieldtype": "Currency", - "label": "Service Cost Per Qty", - "read_only": 1, - "reqd": 1 - }, - { - "default": "0", - "fieldname": "additional_cost_per_qty", - "fieldtype": "Currency", - "label": "Additional Cost Per Qty", - "read_only": 1 - }, - { - "fieldname": "rm_cost_per_qty", - "fieldtype": "Currency", - "label": "Raw Material Cost Per Qty", - "no_copy": 1, - "read_only": 1 - }, - { - "allow_on_submit": 1, - "default": "0", - "fieldname": "page_break", - "fieldtype": "Check", - "label": "Page Break", - "no_copy": 1, - "print_hide": 1 - }, - { - "fieldname": "section_break_34", - "fieldtype": "Section Break" - }, - { - "depends_on": "received_qty", - "fieldname": "received_qty", - "fieldtype": "Float", - "label": "Received Qty", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - }, - { - "depends_on": "returned_qty", - "fieldname": "returned_qty", - "fieldtype": "Float", - "label": "Returned Qty", - "no_copy": 1, - "print_hide": 1, - "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" - } - ], - "idx": 1, - "index_web_pages_for_search": 1, - "istable": 1, - "links": [], - "modified": "2022-08-15 14:25:45.177703", - "modified_by": "Administrator", - "module": "Subcontracting", - "name": "Subcontracting Order Item", - "naming_rule": "Random", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "search_fields": "item_name", - "sort_field": "modified", - "sort_order": "DESC", - "states": [], - "track_changes": 1 + "actions": [], + "autoname": "hash", + "creation": "2022-04-01 19:26:31.475015", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "bom", + "include_exploded_items", + "column_break_3", + "schedule_date", + "expected_delivery_date", + "description_section", + "description", + "column_break_8", + "image", + "image_view", + "quantity_and_rate_section", + "qty", + "received_qty", + "returned_qty", + "column_break_13", + "stock_uom", + "conversion_factor", + "section_break_16", + "rate", + "amount", + "column_break_19", + "rm_cost_per_qty", + "service_cost_per_qty", + "additional_cost_per_qty", + "warehouse_section", + "warehouse", + "accounting_details_section", + "expense_account", + "manufacture_section", + "manufacturer", + "manufacturer_part_no", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "project", + "section_break_34", + "page_break" + ], + "fields": [ + { + "bold": 1, + "columns": 2, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "read_only": 1, + "reqd": 1, + "search_index": 1 + }, + { + "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, + "fieldname": "item_name", + "fieldtype": "Data", + "in_global_search": 1, + "label": "Item Name", + "print_hide": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "bold": 1, + "columns": 2, + "fieldname": "schedule_date", + "fieldtype": "Date", + "label": "Required By", + "print_hide": 1, + "read_only": 1 + }, + { + "allow_on_submit": 1, + "bold": 1, + "fieldname": "expected_delivery_date", + "fieldtype": "Date", + "label": "Expected Delivery Date", + "search_index": 1 + }, + { + "collapsible": 1, + "fieldname": "description_section", + "fieldtype": "Section Break", + "label": "Description" + }, + { + "fetch_from": "item_code.description", + "fetch_if_empty": 1, + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description", + "print_width": "300px", + "reqd": 1, + "width": "300px" + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, + { + "fieldname": "image", + "fieldtype": "Attach", + "hidden": 1, + "label": "Image" + }, + { + "fieldname": "image_view", + "fieldtype": "Image", + "label": "Image View", + "options": "image", + "print_hide": 1 + }, + { + "fieldname": "quantity_and_rate_section", + "fieldtype": "Section Break", + "label": "Quantity and Rate" + }, + { + "bold": 1, + "columns": 1, + "default": "1", + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Quantity", + "print_width": "60px", + "read_only": 1, + "reqd": 1, + "width": "60px" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break", + "print_hide": 1 + }, + { + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "print_width": "100px", + "read_only": 1, + "reqd": 1, + "width": "100px" + }, + { + "default": "1", + "fieldname": "conversion_factor", + "fieldtype": "Float", + "hidden": 1, + "label": "Conversion Factor", + "read_only": 1 + }, + { + "fieldname": "section_break_16", + "fieldtype": "Section Break" + }, + { + "bold": 1, + "columns": 2, + "fetch_from": "item_code.standard_rate", + "fetch_if_empty": 1, + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate", + "options": "currency", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_19", + "fieldtype": "Column Break" + }, + { + "columns": 2, + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "options": "currency", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "warehouse_section", + "fieldtype": "Section Break", + "label": "Warehouse Details" + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "options": "Warehouse", + "print_hide": 1, + "reqd": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_details_section", + "fieldtype": "Section Break", + "label": "Accounting Details" + }, + { + "fieldname": "expense_account", + "fieldtype": "Link", + "label": "Expense Account", + "options": "Account", + "print_hide": 1 + }, + { + "collapsible": 1, + "fieldname": "manufacture_section", + "fieldtype": "Section Break", + "label": "Manufacture" + }, + { + "fieldname": "manufacturer", + "fieldtype": "Link", + "label": "Manufacturer", + "options": "Manufacturer" + }, + { + "fieldname": "manufacturer_part_no", + "fieldtype": "Data", + "label": "Manufacturer Part Number" + }, + { + "depends_on": "item_code", + "fetch_from": "item_code.default_bom", + "fetch_if_empty": 1, + "fieldname": "bom", + "fieldtype": "Link", + "in_list_view": 1, + "label": "BOM", + "options": "BOM", + "print_hide": 1, + "reqd": 1 + }, + { + "default": "0", + "fieldname": "include_exploded_items", + "fieldtype": "Check", + "label": "Include Exploded Items", + "print_hide": 1 + }, + { + "fieldname": "service_cost_per_qty", + "fieldtype": "Currency", + "label": "Service Cost Per Qty", + "read_only": 1, + "reqd": 1 + }, + { + "default": "0", + "fieldname": "additional_cost_per_qty", + "fieldtype": "Currency", + "label": "Additional Cost Per Qty", + "read_only": 1 + }, + { + "fieldname": "rm_cost_per_qty", + "fieldtype": "Currency", + "label": "Raw Material Cost Per Qty", + "no_copy": 1, + "read_only": 1 + }, + { + "allow_on_submit": 1, + "default": "0", + "fieldname": "page_break", + "fieldtype": "Check", + "label": "Page Break", + "no_copy": 1, + "print_hide": 1 + }, + { + "fieldname": "section_break_34", + "fieldtype": "Section Break" + }, + { + "depends_on": "received_qty", + "fieldname": "received_qty", + "fieldtype": "Float", + "label": "Received Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "returned_qty", + "fieldname": "returned_qty", + "fieldtype": "Float", + "label": "Returned Qty", + "no_copy": 1, + "print_hide": 1, + "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" + } + ], + "idx": 1, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-01-20 23:25:45.363281", + "modified_by": "Administrator", + "module": "Subcontracting", + "name": "Subcontracting Order Item", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "search_fields": "item_name", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index b6bef8c4a028..3a2c53f4e44a 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -51,13 +51,31 @@ frappe.ui.form.on('Subcontracting Receipt', { } })); - frm.set_query("expense_account", "items", function () { + frm.set_query('expense_account', 'items', function () { return { - query: "erpnext.controllers.queries.get_expense_account", + query: 'erpnext.controllers.queries.get_expense_account', filters: { 'company': frm.doc.company } }; }); + frm.set_query('batch_no', 'items', function(doc, cdt, cdn) { + var row = locals[cdt][cdn]; + return { + filters: { + item: row.item_code + } + } + }); + + let batch_no_field = frm.get_docfield("items", "batch_no"); + if (batch_no_field) { + batch_no_field.get_route_options_for_new_doc = function(row) { + return { + "item": row.doc.item_code + } + }; + } + frappe.db.get_single_value('Buying Settings', 'backflush_raw_materials_of_subcontract_based_on').then(val => { if (val == 'Material Transferred for Subcontract') { frm.fields_dict['supplied_items'].grid.grid_rows.forEach((grid_row) => { @@ -73,7 +91,7 @@ frappe.ui.form.on('Subcontracting Receipt', { refresh: (frm) => { if (frm.doc.docstatus > 0) { - frm.add_custom_button(__("Stock Ledger"), function () { + frm.add_custom_button(__('Stock Ledger'), function () { frappe.route_options = { voucher_no: frm.doc.name, from_date: frm.doc.posting_date, @@ -81,8 +99,8 @@ frappe.ui.form.on('Subcontracting Receipt', { company: frm.doc.company, show_cancelled_entries: frm.doc.docstatus === 2 }; - frappe.set_route("query-report", "Stock Ledger"); - }, __("View")); + frappe.set_route('query-report', 'Stock Ledger'); + }, __('View')); frm.add_custom_button(__('Accounting Ledger'), function () { frappe.route_options = { @@ -90,11 +108,11 @@ frappe.ui.form.on('Subcontracting Receipt', { from_date: frm.doc.posting_date, to_date: moment(frm.doc.modified).format('YYYY-MM-DD'), company: frm.doc.company, - group_by: "Group by Voucher (Consolidated)", + group_by: 'Group by Voucher (Consolidated)', show_cancelled_entries: frm.doc.docstatus === 2 }; - frappe.set_route("query-report", "General Ledger"); - }, __("View")); + frappe.set_route('query-report', 'General Ledger'); + }, __('View')); } if (!frm.doc.is_return && frm.doc.docstatus == 1 && frm.doc.per_returned < 100) { @@ -111,25 +129,25 @@ frappe.ui.form.on('Subcontracting Receipt', { frm.add_custom_button(__('Subcontracting Order'), function () { if (!frm.doc.supplier) { frappe.throw({ - title: __("Mandatory"), - message: __("Please Select a Supplier") + title: __('Mandatory'), + message: __('Please Select a Supplier') }); } erpnext.utils.map_current_doc({ method: 'erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order.make_subcontracting_receipt', - source_doctype: "Subcontracting Order", + source_doctype: 'Subcontracting Order', target: frm, setters: { supplier: frm.doc.supplier, }, get_query_filters: { docstatus: 1, - per_received: ["<", 100], + per_received: ['<', 100], company: frm.doc.company } }); - }, __("Get Items From")); + }, __('Get Items From')); } }, diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index bce53608beb9..95dbc83bf808 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -57,11 +57,17 @@ def update_status_updater_args(self): def before_validate(self): super(SubcontractingReceipt, self).before_validate() + self.validate_items_qty() self.set_items_bom() self.set_items_cost_center() self.set_items_expense_account() def validate(self): + if ( + frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") + == "BOM" + ): + self.supplied_items = [] super(SubcontractingReceipt, self).validate() self.set_missing_values() self.validate_posting_time() @@ -157,7 +163,7 @@ def set_missing_values_in_items(self): total_qty = total_amount = 0 for item in self.items: - if item.name in rm_supp_cost: + if item.qty and item.name in rm_supp_cost: item.rm_supp_cost = rm_supp_cost[item.name] item.rm_cost_per_qty = item.rm_supp_cost / item.qty rm_supp_cost.pop(item.name) @@ -185,13 +191,23 @@ def validate_rejected_warehouse(self): def validate_available_qty_for_consumption(self): for item in self.get("supplied_items"): + precision = item.precision("consumed_qty") if ( - item.available_qty_for_consumption and item.available_qty_for_consumption < item.consumed_qty + item.available_qty_for_consumption + and flt(item.available_qty_for_consumption, precision) - flt(item.consumed_qty, precision) < 0 ): + msg = f"""Row {item.idx}: Consumed Qty {flt(item.consumed_qty, precision)} + must be less than or equal to Available Qty For Consumption + {flt(item.available_qty_for_consumption, precision)} + in Consumed Items Table.""" + + frappe.throw(_(msg)) + + def validate_items_qty(self): + for item in self.items: + if not (item.qty or item.rejected_qty): frappe.throw( - _( - "Row {0}: Consumed Qty must be less than or equal to Available Qty For Consumption in Consumed Items Table." - ).format(item.idx) + _("Row {0}: Accepted Qty and Rejected Qty can't be zero at the same time.").format(item.idx) ) def set_items_bom(self): @@ -249,15 +265,17 @@ def update_status(self, status=None, update_modified=False): def get_gl_entries(self, warehouse_account=None): from erpnext.accounts.general_ledger import process_gl_map + if not erpnext.is_perpetual_inventory_enabled(self.company): + return [] + gl_entries = [] self.make_item_gl_entries(gl_entries, warehouse_account) return process_gl_map(gl_entries) def make_item_gl_entries(self, gl_entries, warehouse_account=None): - if erpnext.is_perpetual_inventory_enabled(self.company): - stock_rbnb = self.get_company_default("stock_received_but_not_billed") - expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") + stock_rbnb = self.get_company_default("stock_received_but_not_billed") + expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") warehouse_with_no_account = [] diff --git a/erpnext/templates/generators/item/item_configure.js b/erpnext/templates/generators/item/item_configure.js index 231ae0587ed4..613c967e3d60 100644 --- a/erpnext/templates/generators/item/item_configure.js +++ b/erpnext/templates/generators/item/item_configure.js @@ -186,14 +186,14 @@ class ItemConfigure { this.dialog.$status_area.empty(); } - get_html_for_item_found({ filtered_items_count, filtered_items, exact_match, product_info }) { + get_html_for_item_found({ filtered_items_count, filtered_items, exact_match, product_info, available_qty, settings }) { const one_item = exact_match.length === 1 ? exact_match[0] : filtered_items_count === 1 ? filtered_items[0] : ''; - const item_add_to_cart = one_item ? ` + let item_add_to_cart = one_item ? `
${__('Clear Values')} @@ -233,6 +236,10 @@ class ItemConfigure {
`; /* eslint-disable indent */ + if (!product_info?.allow_items_not_in_stock && available_qty === 0) { + item_add_to_cart = ''; + } + return ` ${item_found_status} ${item_add_to_cart} @@ -257,12 +264,15 @@ class ItemConfigure { btn_clear_values() { this.dialog.fields_list.forEach(f => { - f.df.options = f.df.options.map(option => { - option.disabled = false; - return option; - }); + if (f.df?.options) { + f.df.options = f.df.options.map(option => { + option.disabled = false; + return option; + }); + } }); this.dialog.clear(); + this.dialog.$status_area.empty(); this.on_attribute_selection(); } diff --git a/erpnext/templates/pages/order.html b/erpnext/templates/pages/order.html index 6b354b2fab6d..bc34ad5ac500 100644 --- a/erpnext/templates/pages/order.html +++ b/erpnext/templates/pages/order.html @@ -34,16 +34,18 @@

{{ doc.name }}

-
- + {% endif %}
{% endblock %} diff --git a/erpnext/templates/pages/order.py b/erpnext/templates/pages/order.py index e1e12bd60d88..13772d31295d 100644 --- a/erpnext/templates/pages/order.py +++ b/erpnext/templates/pages/order.py @@ -38,22 +38,28 @@ def get_context(context): if not frappe.has_website_permission(context.doc): frappe.throw(_("Not Permitted"), frappe.PermissionError) - # check for the loyalty program of the customer - customer_loyalty_program = frappe.db.get_value( - "Customer", context.doc.customer, "loyalty_program" - ) - if customer_loyalty_program: - from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( - get_loyalty_program_details_with_points, + context.available_loyalty_points = 0.0 + if context.doc.get("customer"): + # check for the loyalty program of the customer + customer_loyalty_program = frappe.db.get_value( + "Customer", context.doc.customer, "loyalty_program" ) - loyalty_program_details = get_loyalty_program_details_with_points( - context.doc.customer, customer_loyalty_program - ) - context.available_loyalty_points = int(loyalty_program_details.get("loyalty_points")) + if customer_loyalty_program: + from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( + get_loyalty_program_details_with_points, + ) + + loyalty_program_details = get_loyalty_program_details_with_points( + context.doc.customer, customer_loyalty_program + ) + context.available_loyalty_points = int(loyalty_program_details.get("loyalty_points")) - # show Make Purchase Invoice button based on permission - context.show_make_pi_button = frappe.has_permission("Purchase Invoice", "create") + context.show_pay_button = frappe.db.get_single_value("Buying Settings", "show_pay_button") + context.show_make_pi_button = False + if context.doc.get("supplier"): + # show Make Purchase Invoice button based on permission + context.show_make_pi_button = frappe.has_permission("Purchase Invoice", "create") def get_attachments(dt, dn): diff --git a/erpnext/templates/utils.py b/erpnext/templates/utils.py index 48b44802a8f8..57750a56f6f9 100644 --- a/erpnext/templates/utils.py +++ b/erpnext/templates/utils.py @@ -6,13 +6,12 @@ @frappe.whitelist(allow_guest=True) -def send_message(subject="Website Query", message="", sender="", status="Open"): +def send_message(sender, message, subject="Website Query"): from frappe.www.contact import send_message as website_send_message - lead = customer = None - - website_send_message(subject, message, sender) + website_send_message(sender, message, subject) + lead = customer = None customer = frappe.db.sql( """select distinct dl.link_name from `tabDynamic Link` dl left join `tabContact` c on dl.parent=c.name where dl.link_doctype='Customer' @@ -58,5 +57,3 @@ def send_message(subject="Website Query", message="", sender="", status="Open"): } ) comm.insert(ignore_permissions=True) - - return "okay" diff --git a/erpnext/translations/af.csv b/erpnext/translations/af.csv index 45435d860082..f2458e3c9658 100644 --- a/erpnext/translations/af.csv +++ b/erpnext/translations/af.csv @@ -3505,7 +3505,6 @@ Recipient,ontvanger, Reviews,resensies, Sender,sender, Shop,Winkel, -Sign Up,Teken aan, Subsidiary,filiaal, There is some problem with the file url: {0},Daar is 'n probleem met die lêer url: {0}, There were errors while sending email. Please try again.,Daar was foute tydens die stuur van e-pos. Probeer asseblief weer., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kwaliteit-terugvoersjabloon, Rules for applying different promotional schemes.,Reëls vir die toepassing van verskillende promosieskemas., Shift,verskuiwing, Show {0},Wys {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Spesiale karakters behalwe "-", "#", ".", "/", "{" En "}" word nie toegelaat in die naamreekse nie", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Spesiale karakters behalwe "-", "#", ".", "/", "{{" En "}}" word nie toegelaat in die naamreekse nie {0}", Target Details,Teikenbesonderhede, {0} already has a Parent Procedure {1}.,{0} het reeds 'n ouerprosedure {1}., API,API, diff --git a/erpnext/translations/am.csv b/erpnext/translations/am.csv index 554b0a54f1c4..d4db28586716 100644 --- a/erpnext/translations/am.csv +++ b/erpnext/translations/am.csv @@ -3505,7 +3505,6 @@ Recipient,ተቀባይ, Reviews,ግምገማዎች, Sender,የላኪ, Shop,ሱቅ, -Sign Up,ክፈት, Subsidiary,ተጪማሪ, There is some problem with the file url: {0},ፋይል ዩ አር ኤል ጋር አንድ ችግር አለ: {0}, There were errors while sending email. Please try again.,ኢሜይል በመላክ ላይ ሳለ ስህተቶች ነበሩ. እባክዎ ዳግም ይሞክሩ., @@ -3537,7 +3536,7 @@ Quality Feedback Template,የጥራት ግብረ መልስ አብነት።, Rules for applying different promotional schemes.,የተለያዩ የማስተዋወቂያ ዘዴዎችን ለመተግበር ህጎች።, Shift,ቀይር, Show {0},አሳይ {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",ከ "-" ፣ "#" ፣ "፣" ፣ "/" ፣ "{" እና "}" በስተቀር ልዩ ቁምፊዎች ከመለያ መሰየሚያ አይፈቀድም, +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",ከ "-" ፣ "#" ፣ "፣" ፣ "/" ፣ "{{" እና "}}" በስተቀር ልዩ ቁምፊዎች ከመለያ መሰየሚያ አይፈቀድም {0}, Target Details,የ Detailsላማ ዝርዝሮች።, {0} already has a Parent Procedure {1}.,{0} ቀድሞውኑ የወላጅ አሰራር ሂደት አለው {1}።, API,ኤ ፒ አይ, diff --git a/erpnext/translations/ar.csv b/erpnext/translations/ar.csv index e62f61a4f55e..ea2777faf03b 100644 --- a/erpnext/translations/ar.csv +++ b/erpnext/translations/ar.csv @@ -3505,7 +3505,6 @@ Recipient,مستلم, Reviews,التعليقات, Sender,مرسل, Shop,تسوق, -Sign Up,سجل, Subsidiary,شركة فرعية, There is some problem with the file url: {0},هناك بعض المشاكل مع رابط الملف: {0}, There were errors while sending email. Please try again.,كانت هناك أخطاء أثناء إرسال البريد الإلكتروني. يرجى المحاولة مرة أخرى., @@ -3537,7 +3536,7 @@ Quality Feedback Template,قالب ملاحظات الجودة, Rules for applying different promotional schemes.,قواعد تطبيق المخططات الترويجية المختلفة., Shift,تحول, Show {0},عرض {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",الأحرف الخاصة باستثناء "-" ، "#" ، "." ، "/" ، "{" و "}" غير مسموح في سلسلة التسمية, +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",{0} الأحرف الخاصة باستثناء "-" ، "#" ، "." ، "/" ، "{{" و "}}" غير مسموح في سلسلة التسمية, Target Details,تفاصيل الهدف, {0} already has a Parent Procedure {1}.,{0} يحتوي بالفعل على إجراء الأصل {1}., API,API, diff --git a/erpnext/translations/bg.csv b/erpnext/translations/bg.csv index 15278a6a40c7..6839129a319b 100644 --- a/erpnext/translations/bg.csv +++ b/erpnext/translations/bg.csv @@ -3505,7 +3505,6 @@ Recipient,Получател, Reviews,Отзиви, Sender,Подател, Shop,Магазин, -Sign Up,Регистрирай се, Subsidiary,Филиал, There is some problem with the file url: {0},Има някакъв проблем с адреса на файл: {0}, There were errors while sending email. Please try again.,"Имаше грешки при изпращане на имейл. Моля, опитайте отново.", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Качествен обратен шаблон, Rules for applying different promotional schemes.,Правила за прилагане на различни промоционални схеми., Shift,изместване, Show {0},Показване на {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Специални символи, с изключение на "-", "#", ".", "/", "{" И "}" не са позволени в именуването на серии", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Специални символи, с изключение на "-", "#", ".", "/", "{{" И "}}" не са позволени в именуването на серии {0}", Target Details,Детайли за целта, {0} already has a Parent Procedure {1}.,{0} вече има родителска процедура {1}., API,API, diff --git a/erpnext/translations/bn.csv b/erpnext/translations/bn.csv index cf0971667b00..a944d99ecbf2 100644 --- a/erpnext/translations/bn.csv +++ b/erpnext/translations/bn.csv @@ -3505,7 +3505,6 @@ Recipient,প্রাপক, Reviews,পর্যালোচনা, Sender,প্রেরকের, Shop,দোকান, -Sign Up,নিবন্ধন করুন, Subsidiary,সহায়ক, There is some problem with the file url: {0},ফাইলের URL সঙ্গে কিছু সমস্যা আছে: {0}, There were errors while sending email. Please try again.,ইমেইল পাঠানোর সময় কিছু সমস্যা হয়েছে. অনুগ্রহ করে আবার চেষ্টা করুন., @@ -3537,7 +3536,7 @@ Quality Feedback Template,গুণমান প্রতিক্রিয় Rules for applying different promotional schemes.,বিভিন্ন প্রচারমূলক স্কিম প্রয়োগ করার নিয়ম।, Shift,পরিবর্তন, Show {0},{0} দেখান, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","নামকরণ সিরিজে "-", "#", "।", "/", "{" এবং "}" ব্যতীত বিশেষ অক্ষর অনুমোদিত নয়", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","নামকরণ সিরিজে "-", "#", "।", "/", "{{" এবং "}}" ব্যতীত বিশেষ অক্ষর অনুমোদিত নয় {0}", Target Details,টার্গেটের বিশদ, {0} already has a Parent Procedure {1}.,{0} ইতিমধ্যে একটি মূল পদ্ধতি আছে {1}।, API,এপিআই, diff --git a/erpnext/translations/bs.csv b/erpnext/translations/bs.csv index 6ef445a1af0e..2d9c26d15b12 100644 --- a/erpnext/translations/bs.csv +++ b/erpnext/translations/bs.csv @@ -3505,7 +3505,6 @@ Recipient,Primalac, Reviews,Recenzije, Sender,Pošiljaoc, Shop,Prodavnica, -Sign Up,Prijaviti se, Subsidiary,Podružnica, There is some problem with the file url: {0},Postoji neki problem sa URL datoteku: {0}, There were errors while sending email. Please try again.,Bilo je grešaka tijekom slanja e-pošte. Molimo pokušajte ponovno ., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Predložak kvalitetne povratne informacije, Rules for applying different promotional schemes.,Pravila za primjenu različitih promotivnih shema., Shift,Shift, Show {0},Prikaži {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Posebni znakovi osim "-", "#", ".", "/", "{" I "}" nisu dozvoljeni u imenovanju serija", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Posebni znakovi osim "-", "#", ".", "/", "{{" I "}}" nisu dozvoljeni u imenovanju serija {0}", Target Details,Detalji cilja, {0} already has a Parent Procedure {1}.,{0} već ima roditeljsku proceduru {1}., API,API, diff --git a/erpnext/translations/ca.csv b/erpnext/translations/ca.csv index 18fa52a2b794..85c62851c8d5 100644 --- a/erpnext/translations/ca.csv +++ b/erpnext/translations/ca.csv @@ -3505,7 +3505,6 @@ Recipient,Receptor, Reviews,Ressenyes, Sender,Remitent, Shop,Botiga, -Sign Up,Registra't, Subsidiary,Filial, There is some problem with the file url: {0},Hi ha una mica de problema amb la url de l'arxiu: {0}, There were errors while sending email. Please try again.,"Hi ha hagut errors a l'enviar el correu electrònic. Si us plau, torna a intentar-ho.", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Plantilla de comentaris de qualitat, Rules for applying different promotional schemes.,Normes per aplicar diferents règims promocionals., Shift,Majúscules, Show {0},Mostra {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Caràcters especials, excepte "-", "#", ".", "/", "{" I "}" no estan permesos en nomenar sèries", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Caràcters especials, excepte "-", "#", ".", "/", "{{" I "}}" no estan permesos en nomenar sèries {0}", Target Details,Detalls de l'objectiu, {0} already has a Parent Procedure {1}.,{0} ja té un procediment progenitor {1}., API,API, diff --git a/erpnext/translations/cs.csv b/erpnext/translations/cs.csv index 705e471d2780..3fb67e787087 100644 --- a/erpnext/translations/cs.csv +++ b/erpnext/translations/cs.csv @@ -3505,7 +3505,6 @@ Recipient,Příjemce, Reviews,Recenze, Sender,Odesilatel, Shop,Obchod, -Sign Up,Přihlásit se, Subsidiary,Dceřiný, There is some problem with the file url: {0},Tam je nějaký problém s URL souboru: {0}, There were errors while sending email. Please try again.,Narazili jsme na problémy při odesílání emailu. Prosím zkuste to znovu., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Šablona zpětné vazby kvality, Rules for applying different promotional schemes.,Pravidla pro uplatňování různých propagačních programů., Shift,Posun, Show {0},Zobrazit {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Zvláštní znaky kromě "-", "#", ".", "/", "{" A "}" nejsou v názvových řadách povoleny", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Zvláštní znaky kromě "-", "#", ".", "/", "{{" A "}}" nejsou v názvových řadách povoleny {0}", Target Details,Podrobnosti o cíli, {0} already has a Parent Procedure {1}.,{0} již má rodičovský postup {1}., API,API, diff --git a/erpnext/translations/da.csv b/erpnext/translations/da.csv index c0d014669432..f0654b998da4 100644 --- a/erpnext/translations/da.csv +++ b/erpnext/translations/da.csv @@ -3505,7 +3505,6 @@ Recipient,Modtager, Reviews,Anmeldelser, Sender,Afsender, Shop,Butik, -Sign Up,Tilmelde, Subsidiary,Datterselskab, There is some problem with the file url: {0},Der er nogle problemer med filen url: {0}, There were errors while sending email. Please try again.,Der var fejl under afsendelse af e-mail. Prøv venligst igen., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kvalitetsfeedback-skabelon, Rules for applying different promotional schemes.,Regler for anvendelse af forskellige salgsfremmende ordninger., Shift,Flytte, Show {0},Vis {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Specialtegn undtagen "-", "#", ".", "/", "{" Og "}" er ikke tilladt i navngivningsserier", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Specialtegn undtagen "-", "#", ".", "/", "{{" Og "}}" er ikke tilladt i navngivningsserier {0}", Target Details,Måldetaljer, {0} already has a Parent Procedure {1}.,{0} har allerede en overordnet procedure {1}., API,API, diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index 4cba02a6accb..8472d04f1cac 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -2012,30 +2012,27 @@ Please identify/create Account (Ledger) for type - {0},Bitte identifizieren / er Please login as another user to register on Marketplace,"Bitte melden Sie sich als anderer Benutzer an, um sich auf dem Marktplatz zu registrieren", Please make sure you really want to delete all the transactions for this company. Your master data will remain as it is. This action cannot be undone.,"Bitte sicher stellen, dass wirklich alle Transaktionen dieses Unternehmens gelöscht werden sollen. Die Stammdaten bleiben bestehen. Diese Aktion kann nicht rückgängig gemacht werden.", Please mention Basic and HRA component in Company,Bitte erwähnen Sie die Basis- und HRA-Komponente in der Firma, -Please mention Round Off Account in Company,Bitte Abschlusskonto in Unternehmen vermerken, -Please mention Round Off Cost Center in Company,Bitte Abschlusskostenstelle in Unternehmen vermerken, -Please mention no of visits required,"Bitte bei ""Besuche erforderlich"" NEIN angeben", -Please mention the Lead Name in Lead {0},Bitte erwähnen Sie den Lead Name in Lead {0}, -Please pull items from Delivery Note,Bitte Artikel vom Lieferschein nehmen, +Please mention Round Off Account in Company,Bitte ein Standardkonto Konto für Rundungsdifferenzen in Unternehmen einstellen, +Please mention Round Off Cost Center in Company,Bitte eine Kostenstelle für Rundungsdifferenzen in Unternehmen einstellen, +Please mention no of visits required,Bitte die Anzahl der benötigten Wartungsbesuche angeben, +Please pull items from Delivery Note,Bitte Artikel aus dem Lieferschein ziehen, Please register the SIREN number in the company information file,Bitte registrieren Sie die SIREN-Nummer in der Unternehmensinformationsdatei, Please remove this Invoice {0} from C-Form {1},Bitte diese Rechnung {0} vom Kontaktformular {1} entfernen, Please save the patient first,Bitte speichern Sie den Patienten zuerst, Please save the report again to rebuild or update,"Speichern Sie den Bericht erneut, um ihn neu zu erstellen oder zu aktualisieren", "Please select Allocated Amount, Invoice Type and Invoice Number in atleast one row","Bitte zugewiesenen Betrag, Rechnungsart und Rechnungsnummer in mindestens einer Zeile auswählen", Please select Apply Discount On,"Bitte ""Rabatt anwenden auf"" auswählen", -Please select BOM against item {0},Bitte wählen Sie Stückliste gegen Artikel {0}, -Please select BOM for Item in Row {0},Bitte Stückliste für Artikel in Zeile {0} auswählen, -Please select BOM in BOM field for Item {0},Bitte aus dem Stücklistenfeld eine Stückliste für Artikel {0} auswählen, -Please select Category first,Bitte zuerst Kategorie auswählen, -Please select Charge Type first,Bitte zuerst Chargentyp auswählen, -Please select Company,Bitte Unternehmen auswählen, +Please select BOM against item {0},Bitte eine Stückliste für Artikel {0} auswählen, +Please select BOM for Item in Row {0},Bitte eine Stückliste für den Artikel in Zeile {0} auswählen, +Please select BOM in BOM field for Item {0},Bitte im Stücklistenfeld eine Stückliste für Artikel {0} auswählen, +Please select Category first,Bitte zuerst eine Kategorie auswählen, +Please select Charge Type first,Bitte zuerst einen Chargentyp auswählen, +Please select Company,Bitte ein Unternehmen auswählen, Please select Company and Designation,Bitte wählen Sie Unternehmen und Position, Please select Company and Posting Date to getting entries,"Bitte wählen Sie Unternehmen und Buchungsdatum, um Einträge zu erhalten", Please select Company first,Bitte zuerst Unternehmen auswählen, Please select Completion Date for Completed Asset Maintenance Log,Bitte wählen Sie Fertigstellungsdatum für das abgeschlossene Wartungsprotokoll für den Vermögenswert, Please select Completion Date for Completed Repair,Bitte wählen Sie das Abschlussdatum für die abgeschlossene Reparatur, -Please select Course,Bitte wählen Sie Kurs, -Please select Drug,Bitte wählen Sie Arzneimittel, Please select Employee,Bitte wählen Sie Mitarbeiter, Please select Existing Company for creating Chart of Accounts,Bitte wählen Sie Bestehende Unternehmen für die Erstellung von Konten, Please select Healthcare Service,Bitte wählen Sie Gesundheitsdienst, @@ -3514,7 +3511,6 @@ Recipient,Empfänger, Reviews,Bewertungen, Sender,Absender, Shop,Laden, -Sign Up,Anmelden, Subsidiary,Tochtergesellschaft, There is some problem with the file url: {0},Es gibt irgend ein Problem mit der Datei-URL: {0}, There were errors while sending email. Please try again.,Beim Versand der E-Mail ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut., @@ -3546,7 +3542,7 @@ Quality Feedback Template,Qualitäts-Feedback-Vorlage, Rules for applying different promotional schemes.,Regeln für die Anwendung verschiedener Werbemaßnahmen., Shift,Verschiebung, Show {0},{0} anzeigen, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Sonderzeichen außer "-", "#", ".", "/", "{" Und "}" sind bei der Benennung von Serien nicht zulässig", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Sonderzeichen außer "-", "#", ".", "/", "{{" Und "}}" sind bei der Benennung von Serien nicht zulässig {0}", Target Details,Zieldetails, {0} already has a Parent Procedure {1}.,{0} hat bereits eine übergeordnete Prozedur {1}., API,API, @@ -4053,7 +4049,7 @@ Server Error,Serverfehler, Service Level Agreement has been changed to {0}.,Service Level Agreement wurde in {0} geändert., Service Level Agreement was reset.,Service Level Agreement wurde zurückgesetzt., Service Level Agreement with Entity Type {0} and Entity {1} already exists.,Service Level Agreement mit Entitätstyp {0} und Entität {1} ist bereits vorhanden., -Set,Menge, +Set Loyalty Program,Treueprogramm eintragen, Set Meta Tags,Festlegen von Meta-Tags, Set {0} in company {1},{0} in Firma {1} festlegen, Setup,Einstellungen, @@ -4233,10 +4229,8 @@ To date cannot be before From date,Bis-Datum kann nicht vor Von-Datum liegen, Write Off,Abschreiben, {0} Created,{0} Erstellt, Email Id,E-Mail-ID, -No,Kein, Reference Doctype,Referenz-DocType, User Id,Benutzeridentifikation, -Yes,Ja, Actual ,Tatsächlich, Add to cart,In den Warenkorb legen, Budget,Budget, @@ -5069,7 +5063,7 @@ Price List Rate (Company Currency),Preisliste (Unternehmenswährung), Rate ,Preis, Rate (Company Currency),Preis (Unternehmenswährung), Amount (Company Currency),Betrag (Unternehmenswährung), -Is Free Item,Ist freies Einzelteil, +Is Free Item,Ist kostenlos, Net Rate,Nettopreis, Net Rate (Company Currency),Nettopreis (Unternehmenswährung), Net Amount (Company Currency),Nettobetrag (Unternehmenswährung), @@ -7807,7 +7801,7 @@ Default Employee Advance Account,Standardkonto für Vorschüsse an Arbeitnehmer, Default Cost of Goods Sold Account,Standard-Herstellkosten, Default Income Account,Standard-Ertragskonto, Default Deferred Revenue Account,Standardkonto für passive Rechnungsabgrenzung, -Default Deferred Expense Account,Standard-Rechnungsabgrenzungsposten, +Default Deferred Expense Account,Standardkonto für aktive Rechnungsabgrenzung, Default Payroll Payable Account,Standardkonto für Verbindlichkeiten aus Lohn und Gehalt, Default Expense Claim Payable Account,Standard-Expense Claim Zahlbares Konto, Stock Settings,Lager-Einstellungen, @@ -8875,7 +8869,7 @@ Add Topic to Courses,Hinzufügen eines Themas zu Kursen, This topic is already added to the existing courses,Dieses Thema wurde bereits zu den bestehenden Kursen hinzugefügt, "If Shopify does not have a customer in the order, then while syncing the orders, the system will consider the default customer for the order","Wenn Shopify keinen Kunden in der Bestellung hat, berücksichtigt das System beim Synchronisieren der Bestellungen den Standardkunden für die Bestellung", The accounts are set by the system automatically but do confirm these defaults,"Die Konten werden vom System automatisch festgelegt, bestätigen jedoch diese Standardeinstellungen", -Default Round Off Account,Standard-Rundungskonto, +Default Round Off Account,Standardkonto für Rundungsdifferenzen, Failed Import Log,Importprotokoll fehlgeschlagen, Fixed Error Log,Fehlerprotokoll behoben, Company {0} already exists. Continuing will overwrite the Company and Chart of Accounts,Firma {0} existiert bereits. Durch Fortfahren werden das Unternehmen und der Kontenplan überschrieben, @@ -9918,3 +9912,9 @@ Cost and Freight,Kosten und Fracht, Delivered at Place,Geliefert benannter Ort, Delivered at Place Unloaded,Geliefert benannter Ort entladen, Delivered Duty Paid,Geliefert verzollt, +Discount Validity,Frist für den Rabatt, +Discount Validity Based On,Frist für den Rabatt berechnet sich nach, +Select Alternative Items for Sales Order,Alternativpositionen für Auftragsbestätigung auswählen, +Select an item from each set to be used in the Sales Order.,"Wählen Sie aus den Alternativen jeweils einen Artikel aus, der in die Auftragsbestätigung übernommen werden soll.", +Is Alternative,Ist Alternative, +Alternative Items,Alternativpositionen, diff --git a/erpnext/translations/el.csv b/erpnext/translations/el.csv index acf5db5d467e..c241558e31c7 100644 --- a/erpnext/translations/el.csv +++ b/erpnext/translations/el.csv @@ -3505,7 +3505,6 @@ Recipient,Παραλήπτης, Reviews,Κριτικές, Sender,Αποστολέας, Shop,Κατάστημα, -Sign Up,Εγγραφείτε, Subsidiary,Θυγατρική, There is some problem with the file url: {0},Υπάρχει κάποιο πρόβλημα με το url αρχείο: {0}, There were errors while sending email. Please try again.,Υπήρξαν σφάλματα κατά την αποστολή ηλεκτρονικού ταχυδρομείου. Παρακαλώ δοκιμάστε ξανά ., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Πρότυπο σχολιασμού ποιότητας Rules for applying different promotional schemes.,Κανόνες εφαρμογής διαφορετικών προγραμμάτων προώθησης., Shift,Βάρδια, Show {0},Εμφάνιση {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Ειδικοί χαρακτήρες εκτός από "-", "#", ".", "/", "" Και "}" δεν επιτρέπονται στη σειρά ονομασίας", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Ειδικοί χαρακτήρες εκτός από "-", "#", ".", "/", "{" Και "}}" δεν επιτρέπονται στη σειρά ονομασίας {0}", Target Details,Στοιχεία στόχου, {0} already has a Parent Procedure {1}.,{0} έχει ήδη μια διαδικασία γονέα {1}., API,API, diff --git a/erpnext/translations/es.csv b/erpnext/translations/es.csv index 0f2259db21ea..9996fe54c15d 100644 --- a/erpnext/translations/es.csv +++ b/erpnext/translations/es.csv @@ -3505,7 +3505,6 @@ Recipient,Beneficiario, Reviews,Comentarios, Sender,Remitente, Shop,Tienda., -Sign Up,Regístrate, Subsidiary,Subsidiaria, There is some problem with the file url: {0},Hay un poco de problema con la url del archivo: {0}, There were errors while sending email. Please try again.,"Ha ocurrido un error al enviar el correo electrónico. Por favor, inténtelo de nuevo.", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Plantilla de comentarios de calidad, Rules for applying different promotional schemes.,Reglas para aplicar diferentes esquemas promocionales., Shift,Cambio, Show {0},Mostrar {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Caracteres especiales excepto "-", "#", ".", "/", "{" Y "}" no están permitidos en las series de nombres", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Caracteres especiales excepto "-", "#", ".", "/", "{{" Y "}}" no están permitidos en las series de nombres {0}", Target Details,Detalles del objetivo, {0} already has a Parent Procedure {1}.,{0} ya tiene un Procedimiento principal {1}., API,API, diff --git a/erpnext/translations/et.csv b/erpnext/translations/et.csv index ba32187c6805..6e60809fe7b9 100644 --- a/erpnext/translations/et.csv +++ b/erpnext/translations/et.csv @@ -3505,7 +3505,6 @@ Recipient,Saaja, Reviews,Ülevaated, Sender,Lähetaja, Shop,Kauplus, -Sign Up,Registreeri, Subsidiary,Tütarettevõte, There is some problem with the file url: {0},Seal on mõned probleem faili url: {0}, There were errors while sending email. Please try again.,Vigu samas saates email. Palun proovi uuesti., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kvaliteetse tagasiside mall, Rules for applying different promotional schemes.,Erinevate reklaamiskeemide rakenduseeskirjad., Shift,Vahetus, Show {0},Kuva {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Erimärgid, välja arvatud "-", "#", ".", "/", "{" Ja "}" pole sarjade nimetamisel lubatud", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Erimärgid, välja arvatud "-", "#", ".", "/", "{{" Ja "}}" pole sarjade nimetamisel lubatud {0}", Target Details,Sihtkoha üksikasjad, {0} already has a Parent Procedure {1}.,{0} juba on vanemamenetlus {1}., API,API, diff --git a/erpnext/translations/fa.csv b/erpnext/translations/fa.csv index 4a7c979499ec..7d18e27ad422 100644 --- a/erpnext/translations/fa.csv +++ b/erpnext/translations/fa.csv @@ -3505,7 +3505,6 @@ Recipient,گیرنده, Reviews,بررسی ها, Sender,فرستنده, Shop,فروشگاه, -Sign Up,ثبت نام, Subsidiary,فرعی, There is some problem with the file url: {0},بعضی از مشکل با آدرس فایل وجود دارد: {0}, There were errors while sending email. Please try again.,بودند خطاهای هنگام ارسال ایمیل وجود دارد. لطفا دوباره تلاش کنید., @@ -3537,7 +3536,7 @@ Quality Feedback Template,الگوی بازخورد کیفیت, Rules for applying different promotional schemes.,قوانین استفاده از طرح های تبلیغاتی مختلف., Shift,تغییر مکان, Show {0},نمایش {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",کاراکترهای خاص به جز "-" ، "#" ، "." ، "/" ، "{" و "}" در سریال نامگذاری مجاز نیستند, +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",{0} کاراکترهای خاص به جز "-" ، "#" ، "." ، "/" ، "{{" و "}}" در سریال نامگذاری مجاز نیستند, Target Details,جزئیات هدف, {0} already has a Parent Procedure {1}.,{0} در حال حاضر یک روش والدین {1} دارد., API,API, diff --git a/erpnext/translations/fi.csv b/erpnext/translations/fi.csv index 29eb56702d71..c700f60d15c0 100644 --- a/erpnext/translations/fi.csv +++ b/erpnext/translations/fi.csv @@ -3505,7 +3505,6 @@ Recipient,vastaanottaja, Reviews,Arvostelut, Sender,Lähettäjä, Shop,Osta, -Sign Up,Kirjaudu, Subsidiary,tytäryhtiö, There is some problem with the file url: {0},Tiedosto-URL:issa {0} on ongelma, There were errors while sending email. Please try again.,"Lähetettäessä sähköpostia oli virheitä, yrita uudelleen", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Laadun palautteen malli, Rules for applying different promotional schemes.,Säännöt erilaisten myynninedistämisjärjestelmien soveltamisesta., Shift,Siirtää, Show {0},Näytä {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Erikoismerkit paitsi "-", "#", ".", "/", "{" Ja "}" eivät ole sallittuja nimeämissarjoissa", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Erikoismerkit paitsi "-", "#", ".", "/", "{{" Ja "}}" eivät ole sallittuja nimeämissarjoissa {0}", Target Details,Kohteen yksityiskohdat, {0} already has a Parent Procedure {1}.,{0}: llä on jo vanhempainmenettely {1}., API,API, diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv index 3ba5ade62993..ab9bf7d9c147 100644 --- a/erpnext/translations/fr.csv +++ b/erpnext/translations/fr.csv @@ -2801,7 +2801,7 @@ Stock Ledger Entries and GL Entries are reposted for the selected Purchase Recei Stock Levels,Niveaux du Stocks, Stock Liabilities,Passif du Stock, Stock Options,Options du Stock, -Stock Qty,Qté en Stock, +Stock Qty,Qté en unité de stock, Stock Received But Not Billed,Stock Reçus Mais Non Facturés, Stock Reports,Rapports de stock, Stock Summary,Résumé du Stock, @@ -3505,7 +3505,6 @@ Recipient,Destinataire, Reviews,Avis, Sender,Expéditeur, Shop,Magasin, -Sign Up,S'inscrire, Subsidiary,Filiale, There is some problem with the file url: {0},Il y a un problème avec l'url du fichier : {0}, There were errors while sending email. Please try again.,Il y a eu des erreurs lors de l'envoi d’emails. Veuillez essayer à nouveau., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Modèle de commentaires sur la qualité, Rules for applying different promotional schemes.,Règles d'application de différents programmes promotionnels., Shift,Décalage, Show {0},Montrer {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Caractères spéciaux sauf "-", "#", ".", "/", "{" Et "}" non autorisés dans les séries de nommage", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Caractères spéciaux sauf "-", "#", ".", "/", "{{" Et "}}" non autorisés dans les séries de nommage {0}", Target Details,Détails de la cible, {0} already has a Parent Procedure {1}.,{0} a déjà une procédure parent {1}., API,API, diff --git a/erpnext/translations/gu.csv b/erpnext/translations/gu.csv index 5c2b520d9ec6..b26d2f3d0768 100644 --- a/erpnext/translations/gu.csv +++ b/erpnext/translations/gu.csv @@ -3505,7 +3505,6 @@ Recipient,પ્રાપ્તકર્તા, Reviews,સમીક્ષાઓ, Sender,પ્રેષક, Shop,દુકાન, -Sign Up,સાઇન અપ કરો, Subsidiary,સબસિડીયરી, There is some problem with the file url: {0},ફાઈલ URL સાથે કેટલાક સમસ્યા છે: {0}, There were errors while sending email. Please try again.,ઇમેઇલ મોકલતી વખતે ભૂલો આવી હતી. ફરી પ્રયત્ન કરો., @@ -3537,7 +3536,7 @@ Quality Feedback Template,ગુણવત્તા પ્રતિસાદ Temp Rules for applying different promotional schemes.,વિવિધ પ્રમોશનલ યોજનાઓ લાગુ કરવાના નિયમો., Shift,પાળી, Show {0},બતાવો {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", ".", "/", "{" અને "}" સિવાયના વિશેષ અક્ષરો નામકરણ શ્રેણીમાં મંજૂરી નથી", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", ".", "/", "{{" અને "}}" સિવાયના વિશેષ અક્ષરો નામકરણ શ્રેણીમાં મંજૂરી નથી {0}", Target Details,લક્ષ્યાંક વિગતો, {0} already has a Parent Procedure {1}.,{0} પાસે પહેલેથી જ પિતૃ કાર્યવાહી છે {1}., API,API, diff --git a/erpnext/translations/he.csv b/erpnext/translations/he.csv index 29e6f6afc322..e40b68e504f9 100644 --- a/erpnext/translations/he.csv +++ b/erpnext/translations/he.csv @@ -3505,7 +3505,6 @@ Recipient,נמען, Reviews,ביקורות, Sender,שולח, Shop,חנות, -Sign Up,הירשם, Subsidiary,חברת בת, There is some problem with the file url: {0},יש קצת בעיה עם כתובת אתר הקובץ: {0}, There were errors while sending email. Please try again.,היו שגיאות בעת שליחת דואר אלקטרוני. אנא נסה שוב., @@ -3537,7 +3536,7 @@ Quality Feedback Template,תבנית משוב איכותית, Rules for applying different promotional schemes.,כללים ליישום תוכניות קידום מכירות שונות., Shift,מִשׁמֶרֶת, Show {0},הצג את {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","תווים מיוחדים למעט "-", "#", ".", "/", "{" ו- "}" אינם מורשים בסדרות שמות", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","{0} תווים מיוחדים למעט "-", "#", ".", "/", "{{" ו- "}}" אינם מורשים בסדרות שמות", Target Details,פרטי יעד, {0} already has a Parent Procedure {1}.,{0} כבר יש נוהל הורים {1}., API,ממשק API, diff --git a/erpnext/translations/hi.csv b/erpnext/translations/hi.csv index c385fc6ef602..78094d735af0 100644 --- a/erpnext/translations/hi.csv +++ b/erpnext/translations/hi.csv @@ -3505,7 +3505,6 @@ Recipient,प्राप्तकर्ता, Reviews,समीक्षा, Sender,प्रेषक, Shop,दुकान, -Sign Up,साइन अप करें, Subsidiary,सहायक, There is some problem with the file url: {0},फ़ाइल यूआरएल के साथ कुछ समस्या है: {0}, There were errors while sending email. Please try again.,ईमेल भेजने के दौरान त्रुटि . पुन: प्रयास करें ., @@ -3537,7 +3536,7 @@ Quality Feedback Template,गुणवत्ता प्रतिक्रि Rules for applying different promotional schemes.,विभिन्न प्रचार योजनाओं को लागू करने के लिए नियम।, Shift,खिसक जाना, Show {0},{0} दिखाएं, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", "।", "/", "{" और "}" को छोड़कर विशेष वर्ण श्रृंखला में अनुमति नहीं है", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", "।", "/", "{{" और "}}" को छोड़कर विशेष वर्ण श्रृंखला {0} में अनुमति नहीं है", Target Details,लक्ष्य विवरण, {0} already has a Parent Procedure {1}.,{0} पहले से ही एक पेरेंट प्रोसीजर {1} है।, API,एपीआई, diff --git a/erpnext/translations/hr.csv b/erpnext/translations/hr.csv index a544e98868bb..232832f3f97d 100644 --- a/erpnext/translations/hr.csv +++ b/erpnext/translations/hr.csv @@ -3505,7 +3505,6 @@ Recipient,Primalac, Reviews,Recenzije, Sender,Pošiljalac, Shop,Dućan, -Sign Up,Prijavite se, Subsidiary,Podružnica, There is some problem with the file url: {0},Postoji neki problem s datotečnog URL: {0}, There were errors while sending email. Please try again.,Bilo je grešaka tijekom slanja e-pošte. Molimo pokušajte ponovno ., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Predložak povratne informacije o kvaliteti, Rules for applying different promotional schemes.,Pravila za primjenu različitih promotivnih shema., Shift,smjena, Show {0},Prikaži {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Posebni znakovi osim "-", "#", ".", "/", "{" I "}" nisu dopušteni u imenovanju serija", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Posebni znakovi osim "-", "#", ".", "/", "{{" I "}}" nisu dopušteni u imenovanju serija {0}", Target Details,Pojedinosti cilja, {0} already has a Parent Procedure {1}.,{0} već ima roditeljski postupak {1}., API,API, diff --git a/erpnext/translations/hu.csv b/erpnext/translations/hu.csv index 29f347ecbc69..e3dcd61fb2f5 100644 --- a/erpnext/translations/hu.csv +++ b/erpnext/translations/hu.csv @@ -3505,7 +3505,6 @@ Recipient,Címzett, Reviews,Vélemények, Sender,Küldő, Shop,Bolt, -Sign Up,Regisztrálj, Subsidiary,Leányvállalat, There is some problem with the file url: {0},Van valami probléma a fájl URL-el: {0}, There were errors while sending email. Please try again.,"Email küldés közben hibák voltak. Kérjük, próbálja újra.", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Minőségi visszajelző sablon, Rules for applying different promotional schemes.,Különböző promóciós rendszerek alkalmazásának szabályai., Shift,Váltás, Show {0},Mutasd {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Speciális karakterek, kivéve "-", "#", ".", "/", "{" És "}", a sorozatok elnevezése nem megengedett", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Speciális karakterek, kivéve "-", "#", ".", "/", "{{" És "}}", a sorozatok elnevezése nem megengedett {0}", Target Details,Cél részletei, {0} already has a Parent Procedure {1}.,A (z) {0} már rendelkezik szülői eljárással {1}., API,API, diff --git a/erpnext/translations/id.csv b/erpnext/translations/id.csv index 7175ad2fa741..ccbb002370f3 100644 --- a/erpnext/translations/id.csv +++ b/erpnext/translations/id.csv @@ -3505,7 +3505,6 @@ Recipient,Penerima, Reviews,Ulasan, Sender,Pengirim, Shop,Toko, -Sign Up,Daftar, Subsidiary,Anak Perusahaan, There is some problem with the file url: {0},Ada beberapa masalah dengan url berkas: {0}, There were errors while sending email. Please try again.,Ada kesalahan saat mengirim email. Silakan coba lagi., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Template Umpan Balik Kualitas, Rules for applying different promotional schemes.,Aturan untuk menerapkan berbagai skema promosi., Shift,Bergeser, Show {0},Tampilkan {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Karakter Khusus kecuali "-", "#", ".", "/", "{" Dan "}" tidak diizinkan dalam rangkaian penamaan", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Karakter Khusus kecuali "-", "#", ".", "/", "{{" Dan "}}" tidak diizinkan dalam rangkaian penamaan {0}", Target Details,Detail Target, {0} already has a Parent Procedure {1}.,{0} sudah memiliki Prosedur Induk {1}., API,API, diff --git a/erpnext/translations/is.csv b/erpnext/translations/is.csv index 5f56aff3dc79..5f11c63abafd 100644 --- a/erpnext/translations/is.csv +++ b/erpnext/translations/is.csv @@ -3505,7 +3505,6 @@ Recipient,viðtakandi, Reviews,Umsagnir, Sender,sendanda, Shop,Shop, -Sign Up,Skráðu þig, Subsidiary,dótturfélag, There is some problem with the file url: {0},Það er einhver vandamál með skrá url: {0}, There were errors while sending email. Please try again.,Það komu upp villur við að senda tölvupóst. Vinsamlegast reyndu aftur., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Sniðmát fyrir gæði gæða, Rules for applying different promotional schemes.,Reglur um beitingu mismunandi kynningarkerfa., Shift,Vakt, Show {0},Sýna {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Sérstafir nema "-", "#", ".", "/", "{" Og "}" ekki leyfðar í nafngiftiröð", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Sérstafir nema "-", "#", ".", "/", "{{" Og "}}" ekki leyfðar í nafngiftiröð {0}", Target Details,Upplýsingar um markmið, {0} already has a Parent Procedure {1}.,{0} er þegar með foreldraferli {1}., API,API, diff --git a/erpnext/translations/it.csv b/erpnext/translations/it.csv index 3a1d73f34475..0dbde45778a7 100644 --- a/erpnext/translations/it.csv +++ b/erpnext/translations/it.csv @@ -3505,7 +3505,6 @@ Recipient,Destinatario, Reviews,Recensioni, Sender,Mittente, Shop,Negozio, -Sign Up,Iscriviti, Subsidiary,Sussidiario, There is some problem with the file url: {0},C'è qualche problema con il file url: {0}, There were errors while sending email. Please try again.,Ci sono stati errori durante l'invio email. Riprova., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Modello di feedback sulla qualità, Rules for applying different promotional schemes.,Regole per l'applicazione di diversi schemi promozionali., Shift,Cambio, Show {0},Mostra {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Caratteri speciali tranne "-", "#", ".", "/", "{" E "}" non consentiti nelle serie di nomi", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Caratteri speciali tranne "-", "#", ".", "/", "{{" E "}}" non consentiti nelle serie di nomi {0}", Target Details,Dettagli target, {0} already has a Parent Procedure {1}.,{0} ha già una procedura padre {1}., API,API, diff --git a/erpnext/translations/ja.csv b/erpnext/translations/ja.csv index 6e2eaae4a448..210c78ee5df5 100644 --- a/erpnext/translations/ja.csv +++ b/erpnext/translations/ja.csv @@ -3505,7 +3505,6 @@ Recipient,受信者, Reviews,レビュー, Sender,送信者, Shop,店, -Sign Up,サインアップ, Subsidiary,子会社, There is some problem with the file url: {0},ファイルURLに問題があります:{0}, There were errors while sending email. Please try again.,メールの送信中にエラーが発生しました。もう一度お試しください。, @@ -3537,7 +3536,7 @@ Quality Feedback Template,品質フィードバックテンプレート, Rules for applying different promotional schemes.,さまざまなプロモーションスキームを適用するための規則。, Shift,シフト, Show {0},{0}を表示, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series"," - "、 "#"、 "。"、 "/"、 "{"、および "}"以外の特殊文字は、一連の名前付けでは使用できません, +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}"," - "、 "#"、 "。"、 "/"、 "{{"、および "}}"以外の特殊文字は、一連の名前付けでは使用できません {0}, Target Details,ターゲット詳細, {0} already has a Parent Procedure {1}.,{0}にはすでに親プロシージャー{1}があります。, API,API, diff --git a/erpnext/translations/km.csv b/erpnext/translations/km.csv index e2a528cea2ab..1eb85cca924e 100644 --- a/erpnext/translations/km.csv +++ b/erpnext/translations/km.csv @@ -3505,7 +3505,6 @@ Recipient,អ្នកទទួល, Reviews,ការពិនិត្យឡើងវិញ។, Sender,អ្នកផ្ញើ, Shop,ហាងលក់, -Sign Up,ចុះឈ្មោះ, Subsidiary,ក្រុមហ៊ុនបុត្រសម្ព័ន្ធ, There is some problem with the file url: {0},មានបញ្ហាមួយចំនួនជាមួយនឹងឯកសារ URL គឺ: {0}, There were errors while sending email. Please try again.,មានកំហុសខណៈពេលដែលការផ្ញើអ៊ីម៉ែលនោះទេ។ សូមព្យាយាមម្តងទៀត។, @@ -3537,7 +3536,7 @@ Quality Feedback Template,គំរូមតិយោបល់គុណភាព Rules for applying different promotional schemes.,វិធានសម្រាប់អនុវត្តគម្រោងផ្សព្វផ្សាយផ្សេងៗគ្នា។, Shift,ប្តូរ។, Show {0},បង្ហាញ {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","តួអក្សរពិសេសលើកលែងតែ "-", "#", "។ ", "/", "{" និង "}" មិនត្រូវបានអនុញ្ញាតក្នុងស៊េរីដាក់ឈ្មោះ", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","តួអក្សរពិសេសលើកលែងតែ "-", "#", "។ ", "/", "{{" និង "}}" មិនត្រូវបានអនុញ្ញាតក្នុងស៊េរីដាក់ឈ្មោះ {0}", Target Details,ព័ត៌មានលម្អិតគោលដៅ។, {0} already has a Parent Procedure {1}.,{0} មាននីតិវិធីឪពុកម្តាយរួចហើយ {1} ។, API,API, diff --git a/erpnext/translations/kn.csv b/erpnext/translations/kn.csv index 4a9173d4a03f..4e40c63e576b 100644 --- a/erpnext/translations/kn.csv +++ b/erpnext/translations/kn.csv @@ -3505,7 +3505,6 @@ Recipient,ಗ್ರಾಹಿ, Reviews,ವಿಮರ್ಶೆಗಳು, Sender,ಪ್ರೇಷಕ, Shop,ಅಂಗಡಿ, -Sign Up,ಸೈನ್ ಅಪ್, Subsidiary,ಸಹಕಾರಿ, There is some problem with the file url: {0},ಕಡತ URL ನೊಂದಿಗೆ ಕೆಲವು ಸಮಸ್ಯೆಯಿದೆ: {0}, There were errors while sending email. Please try again.,ಇಮೇಲ್ ಕಳುಹಿಸುವಾಗ ದೋಷಗಳು ಇದ್ದವು. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ ., @@ -3537,7 +3536,7 @@ Quality Feedback Template,ಗುಣಮಟ್ಟದ ಪ್ರತಿಕ್ರಿ Rules for applying different promotional schemes.,ವಿಭಿನ್ನ ಪ್ರಚಾರ ಯೋಜನೆಗಳನ್ನು ಅನ್ವಯಿಸುವ ನಿಯಮಗಳು., Shift,ಶಿಫ್ಟ್, Show {0},{0} ತೋರಿಸು, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", ".", "/", "{" ಮತ್ತು "}" ಹೊರತುಪಡಿಸಿ ವಿಶೇಷ ಅಕ್ಷರಗಳನ್ನು ಹೆಸರಿಸುವ ಸರಣಿಯಲ್ಲಿ ಅನುಮತಿಸಲಾಗುವುದಿಲ್ಲ", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", ".", "/", "{{" ಮತ್ತು "}}" ಹೊರತುಪಡಿಸಿ ವಿಶೇಷ ಅಕ್ಷರಗಳನ್ನು ಹೆಸರಿಸುವ ಸರಣಿಯಲ್ಲಿ ಅನುಮತಿಸಲಾಗುವುದಿಲ್ಲ {0}", Target Details,ಗುರಿ ವಿವರಗಳು, {0} already has a Parent Procedure {1}.,{0} ಈಗಾಗಲೇ ಪೋಷಕ ವಿಧಾನವನ್ನು ಹೊಂದಿದೆ {1}., API,API, diff --git a/erpnext/translations/ko.csv b/erpnext/translations/ko.csv index c051b07bea5c..36ec3affcec6 100644 --- a/erpnext/translations/ko.csv +++ b/erpnext/translations/ko.csv @@ -3505,7 +3505,6 @@ Recipient,받는 사람, Reviews,리뷰, Sender,보낸 사람, Shop,상점, -Sign Up,가입, Subsidiary,자회사, There is some problem with the file url: {0},파일 URL 몇 가지 문제가 있습니다 : {0}, There were errors while sending email. Please try again.,이메일을 보내는 동안 오류가 발생했습니다.재시도하십시오., @@ -3537,7 +3536,7 @@ Quality Feedback Template,품질 피드백 템플릿, Rules for applying different promotional schemes.,다양한 홍보 계획을 적용하기위한 규칙., Shift,시프트, Show {0},{0} 표시, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","이름 계열에 허용되지 않는 "-", "#", ".", "/", "{"및 "}"을 제외한 특수 문자", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","이름 계열에 허용되지 않는 "-", "#", ".", "/", "{{"및 "}}"을 제외한 특수 문자 {0}", Target Details,대상 세부 정보, {0} already has a Parent Procedure {1}.,{0}에 이미 상위 절차 {1}이 있습니다., API,API, diff --git a/erpnext/translations/ku.csv b/erpnext/translations/ku.csv index 6962ea1ef132..28927a08d105 100644 --- a/erpnext/translations/ku.csv +++ b/erpnext/translations/ku.csv @@ -3505,7 +3505,6 @@ Recipient,Girtevan, Reviews,Nirxandin, Sender,virrêkerî, Shop,Dikan, -Sign Up,Tomar kirin, Subsidiary,Şîrketa girêdayî, There is some problem with the file url: {0},e hinek pirsgirêk bi url file hene: {0}, There were errors while sending email. Please try again.,bûn çewtî dema şandina email heye. Ji kerema xwe careke din biceribîne., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Feedablonê nerazîbûna kalîteyê, Rules for applying different promotional schemes.,Qanûnên ji bo bicihanîna nexşeyên cûda yên danasînê, Shift,Tarloqî, Show {0},Show {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Di tîpa navnasî de ji bilî "-", "#", ".", "/", "{" Û "}" tîpên Taybet", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Di tîpa navnasî de ji bilî "-", "#", ".", "/", "{{" Û "}}" tîpên Taybet {0}", Target Details,Hûrgulên armancê, {0} already has a Parent Procedure {1}.,{0} ji berê ve heye Parent Procedure {1}., API,API, diff --git a/erpnext/translations/lo.csv b/erpnext/translations/lo.csv index b61476cf17b4..3904308af2fd 100644 --- a/erpnext/translations/lo.csv +++ b/erpnext/translations/lo.csv @@ -3505,7 +3505,6 @@ Recipient,ຜູ້ຮັບ, Reviews,ການທົບທວນຄືນ, Sender,ຜູ້ສົ່ງ, Shop,ບໍລິການຜ່ານ, -Sign Up,ລົງທະບຽນ, Subsidiary,ບໍລິສັດຍ່ອຍ, There is some problem with the file url: {0},ມີບັນຫາບາງຢ່າງກັບ url ໄຟລ໌ແມ່ນ: {0}, There were errors while sending email. Please try again.,ມີຄວາມຜິດພາດໃນຂະນະທີ່ການສົ່ງອີເມວໄດ້. ກະລຸນາພະຍາຍາມອີກເທື່ອຫນຶ່ງ., @@ -3537,7 +3536,7 @@ Quality Feedback Template,ແມ່ແບບ ຄຳ ຕິຊົມຄຸນນ Rules for applying different promotional schemes.,ກົດລະບຽບໃນການ ນຳ ໃຊ້ແຜນການໂຄສະນາທີ່ແຕກຕ່າງກັນ., Shift,ປ່ຽນ, Show {0},ສະແດງ {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","ຕົວລະຄອນພິເສດຍົກເວັ້ນ "-", "#", ".", "/", "{" ແລະ "}" ບໍ່ໄດ້ຖືກອະນຸຍາດໃນຊຸດຊື່", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","ຕົວລະຄອນພິເສດຍົກເວັ້ນ "-", "#", ".", "/", "{{" ແລະ "}}" ບໍ່ໄດ້ຖືກອະນຸຍາດໃນຊຸດຊື່ {0}", Target Details,ລາຍລະອຽດເປົ້າ ໝາຍ, {0} already has a Parent Procedure {1}.,{0} ມີຂັ້ນຕອນການເປັນພໍ່ແມ່ {1} ແລ້ວ., API,API, diff --git a/erpnext/translations/lt.csv b/erpnext/translations/lt.csv index 78571f962431..d05688c6537a 100644 --- a/erpnext/translations/lt.csv +++ b/erpnext/translations/lt.csv @@ -3505,7 +3505,6 @@ Recipient,Gavėjas, Reviews,Atsiliepimai, Sender,Siuntėjas, Shop,parduotuvė, -Sign Up,Registruotis, Subsidiary,filialas, There is some problem with the file url: {0},Yra kai kurie su URL failui problema: {0}, There were errors while sending email. Please try again.,"Nebuvo klaidos Nors siunčiant laišką. Prašau, pabandykite dar kartą.", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kokybės atsiliepimų šablonas, Rules for applying different promotional schemes.,Skirtingų reklamos schemų taikymo taisyklės., Shift,Pamaina, Show {0},Rodyti {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Specialieji simboliai, išskyrus „-“, „#“, „.“, „/“, „{“ Ir „}“, neleidžiami įvardyti serijomis", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Specialieji simboliai, išskyrus "-", "#", "।", "/", "{{" Ir "}}", neleidžiami įvardyti serijomis {0}", Target Details,Tikslinė informacija, {0} already has a Parent Procedure {1}.,{0} jau turi tėvų procedūrą {1}., API,API, diff --git a/erpnext/translations/lv.csv b/erpnext/translations/lv.csv index cbf04855d026..d5cf852bc6b9 100644 --- a/erpnext/translations/lv.csv +++ b/erpnext/translations/lv.csv @@ -3505,7 +3505,6 @@ Recipient,Saņēmējs, Reviews,Atsauksmes, Sender,Nosūtītājs, Shop,Veikals, -Sign Up,Pierakstīties, Subsidiary,Filiāle, There is some problem with the file url: {0},Ir dažas problēmas ar faila url: {0}, There were errors while sending email. Please try again.,"Bija kļūdas, nosūtot e-pastu. Lūdzu, mēģiniet vēlreiz.", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kvalitatīvas atsauksmes veidne, Rules for applying different promotional schemes.,Noteikumi dažādu reklāmas shēmu piemērošanai., Shift,Maiņa, Show {0},Rādīt {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Speciālās rakstzīmes, izņemot "-", "#", ".", "/", "{" Un "}", kas nav atļautas nosaukuma sērijās", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Speciālās rakstzīmes, izņemot "-", "#", ".", "/", "{{" Un "}}", kas nav atļautas nosaukuma sērijās {0}", Target Details,Mērķa informācija, {0} already has a Parent Procedure {1}.,{0} jau ir vecāku procedūra {1}., API,API, diff --git a/erpnext/translations/mk.csv b/erpnext/translations/mk.csv index 70080255346a..e01cb70e926d 100644 --- a/erpnext/translations/mk.csv +++ b/erpnext/translations/mk.csv @@ -3505,7 +3505,6 @@ Recipient,Примачот, Reviews,Прегледи, Sender,Испраќачот, Shop,Продавница, -Sign Up,Регистрирај се, Subsidiary,Подружница, There is some problem with the file url: {0},Има некој проблем со URL-то на фајл: {0}, There were errors while sending email. Please try again.,Имаше грешка при испраќање на е-мејл. Ве молиме обидете се повторно., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Шаблон за повратни информаци Rules for applying different promotional schemes.,Правила за примена на различни промотивни шеми., Shift,Смена, Show {0},Покажи {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Не се дозволени специјални карактери освен "-", "#", ".", "/", "{" И "}" во сериите за именување", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Не се дозволени специјални карактери освен "-", "#", ".", "/", "{{" И "}}" во сериите за именување {0}", Target Details,Цели детали, {0} already has a Parent Procedure {1}.,{0} веќе има Матична постапка {1}., API,API, diff --git a/erpnext/translations/ml.csv b/erpnext/translations/ml.csv index f917969345a3..c5a98b6d2560 100644 --- a/erpnext/translations/ml.csv +++ b/erpnext/translations/ml.csv @@ -3505,7 +3505,6 @@ Recipient,സ്വീകർത്താവ്, Reviews,അവലോകനങ്ങൾ, Sender,അയച്ചയാളുടെ, Shop,കട, -Sign Up,സൈൻ അപ്പ് ചെയ്യുക, Subsidiary,സഹായകന്, There is some problem with the file url: {0},ഫയൽ URL ഉള്ള ചില പ്രശ്നം ഉണ്ട്: {0}, There were errors while sending email. Please try again.,ഇമെയിൽ അയയ്ക്കുമ്പോൾ പിശകുകളുണ്ടായിരുന്നു. വീണ്ടും ശ്രമിക്കുക., @@ -3537,7 +3536,7 @@ Quality Feedback Template,ഗുണനിലവാരമുള്ള ഫീഡ Rules for applying different promotional schemes.,വ്യത്യസ്ത പ്രമോഷണൽ സ്കീമുകൾ പ്രയോഗിക്കുന്നതിനുള്ള നിയമങ്ങൾ., Shift,ഷിഫ്റ്റ്, Show {0},{0} കാണിക്കുക, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", ".", "/", "{", "}" എന്നിവ ഒഴികെയുള്ള പ്രത്യേക പ്രതീകങ്ങൾ നാമകരണ ശ്രേണിയിൽ അനുവദനീയമല്ല", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", ".", "/", "{{", "}}" എന്നിവ ഒഴികെയുള്ള പ്രത്യേക പ്രതീകങ്ങൾ നാമകരണ ശ്രേണിയിൽ അനുവദനീയമല്ല {0}", Target Details,ടാർഗെറ്റ് വിശദാംശങ്ങൾ, {0} already has a Parent Procedure {1}.,{0} ന് ഇതിനകം ഒരു രക്ഷാകർതൃ നടപടിക്രമം ഉണ്ട് {1}., API,API, diff --git a/erpnext/translations/mr.csv b/erpnext/translations/mr.csv index 9c41ce6f33d9..21aaa3f8bbf9 100644 --- a/erpnext/translations/mr.csv +++ b/erpnext/translations/mr.csv @@ -3505,7 +3505,6 @@ Recipient,प्राप्तकर्ता, Reviews,पुनरावलोकने, Sender,प्रेषक, Shop,दुकान, -Sign Up,साइन अप करा, Subsidiary,उपकंपनी, There is some problem with the file url: {0},फाइल URL सह काही समस्या आहे: {0}, There were errors while sending email. Please try again.,ई-मेल पाठविताना त्रुटी होत्या. कृपया पुन्हा प्रयत्न करा., @@ -3537,7 +3536,7 @@ Quality Feedback Template,गुणवत्ता अभिप्राय ट Rules for applying different promotional schemes.,वेगवेगळ्या जाहिरात योजना लागू करण्याचे नियम., Shift,शिफ्ट, Show {0},दर्शवा {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", ".", "/", "{" आणि "}" वगळता विशिष्ट वर्णांना नामांकन मालिकेमध्ये परवानगी नाही", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", ".", "/", "{{" आणि "}}" वगळता विशिष्ट वर्णांना नामांकन मालिकेमध्ये परवानगी नाही {0}", Target Details,लक्ष्य तपशील, {0} already has a Parent Procedure {1}.,{0} कडे आधीपासूनच पालक प्रक्रिया आहे {1}., API,API, diff --git a/erpnext/translations/ms.csv b/erpnext/translations/ms.csv index 1483844990e3..5a3d986f5b58 100644 --- a/erpnext/translations/ms.csv +++ b/erpnext/translations/ms.csv @@ -3505,7 +3505,6 @@ Recipient,Penerima, Reviews,Ulasan, Sender,Penghantar, Shop,Kedai, -Sign Up,Daftar, Subsidiary,Anak Syarikat, There is some problem with the file url: {0},Terdapat beberapa masalah dengan url fail: {0}, There were errors while sending email. Please try again.,Terdapat ralat semasa menghantar e-mel. Sila cuba sekali lagi., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Template Maklum Balas Kualiti, Rules for applying different promotional schemes.,Kaedah untuk memohon skim promosi yang berbeza., Shift,Shift, Show {0},Tunjukkan {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Watak Khas kecuali "-", "#", ".", "/", "{" Dan "}" tidak dibenarkan dalam siri penamaan", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Watak Khas kecuali "-", "#", ".", "/", "{{" Dan "}}" tidak dibenarkan dalam siri penamaan {0}", Target Details,Butiran Sasaran, {0} already has a Parent Procedure {1}.,{0} sudah mempunyai Tatacara Ibu Bapa {1}., API,API, diff --git a/erpnext/translations/my.csv b/erpnext/translations/my.csv index d15ec1ec711f..7638e762ba47 100644 --- a/erpnext/translations/my.csv +++ b/erpnext/translations/my.csv @@ -3505,7 +3505,6 @@ Recipient,လက်လံသူ, Reviews,reviews, Sender,ပေးပို့သူ, Shop,ကုန်ဆိုင်, -Sign Up,အကောင့်ဖွင့်ရန်, Subsidiary,ထောက်ခံသောကုမ္ပဏီ, There is some problem with the file url: {0},ဖိုင်ကို url နှင့်အတူအချို့သောပြဿနာကိုလည်းရှိ၏: {0}, There were errors while sending email. Please try again.,အီးမေးလ်ပို့သည့်အနေဖြင့်အမှားများရှိကြ၏။ ထပ်ကြိုးစားပါ။, @@ -3537,7 +3536,7 @@ Quality Feedback Template,အရည်အသွေးတုံ့ပြန်ခ Rules for applying different promotional schemes.,ကွဲပြားခြားနားသောပရိုမိုးရှင်းအစီအစဉ်များလျှောက်ထားမှုအတွက်စည်းကမ်းများ။, Shift,အဆိုင်း, Show {0},Show ကို {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","မှလွဲ. အထူးဇာတ်ကောင် "-" "။ ", "#", "/", "{" နှင့် "}" စီးရီးနာမည်အတွက်ခွင့်မပြု", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","မှလွဲ. အထူးဇာတ်ကောင် "-" "။ ", "#", "/", "{{" နှင့် "}}" စီးရီးနာမည်အတွက်ခွင့်မပြု {0}", Target Details,ပစ်မှတ်အသေးစိတ်, {0} already has a Parent Procedure {1}.,{0} ပြီးသားမိဘလုပ်ထုံးလုပ်နည်း {1} ရှိပါတယ်။, API,API ကို, diff --git a/erpnext/translations/nl.csv b/erpnext/translations/nl.csv index fbadc02327fe..b559c694094b 100644 --- a/erpnext/translations/nl.csv +++ b/erpnext/translations/nl.csv @@ -3505,7 +3505,6 @@ Recipient,Ontvanger, Reviews,beoordelingen, Sender,Afzender, Shop,Winkelen, -Sign Up,Inschrijven, Subsidiary,Dochteronderneming, There is some problem with the file url: {0},Er is een probleem met het bestand url: {0}, There were errors while sending email. Please try again.,Er zijn fouten opgetreden tijdens het versturen van e-mail. Probeer het opnieuw ., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kwaliteitsfeedbacksjabloon, Rules for applying different promotional schemes.,Regels voor het toepassen van verschillende promotieregelingen., Shift,Verschuiving, Show {0},Toon {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Speciale tekens behalve "-", "#", ".", "/", "{" En "}" niet toegestaan in naamgevingsreeks", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Speciale tekens behalve "-", "#", ".", "/", "{{" En "}}" niet toegestaan in naamgevingsreeks {0}", Target Details,Doelgegevens, {0} already has a Parent Procedure {1}.,{0} heeft al een ouderprocedure {1}., API,API, diff --git a/erpnext/translations/no.csv b/erpnext/translations/no.csv index 150e5ca4a2eb..20b8916eaf26 100644 --- a/erpnext/translations/no.csv +++ b/erpnext/translations/no.csv @@ -3505,7 +3505,6 @@ Recipient,Mottaker, Reviews,anmeldelser, Sender,Avsender, Shop,Butikk, -Sign Up,Melde deg på, Subsidiary,Datterselskap, There is some problem with the file url: {0},Det er noe problem med filen url: {0}, There were errors while sending email. Please try again.,"Det oppstod feil under sending epost. Vær så snill, prøv på nytt.", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kvalitet Tilbakemelding Mal, Rules for applying different promotional schemes.,Regler for anvendelse av forskjellige kampanjer., Shift,Skifte, Show {0},Vis {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Spesialtegn unntatt "-", "#", ".", "/", "{" Og "}" ikke tillatt i navneserier", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Spesialtegn unntatt "-", "#", ".", "/", "{{" Og "}}" ikke tillatt i navneserier {0}", Target Details,Måldetaljer, {0} already has a Parent Procedure {1}.,{0} har allerede en foreldreprosedyre {1}., API,API, diff --git a/erpnext/translations/pl.csv b/erpnext/translations/pl.csv index 8340b7272fef..4a93d4987567 100644 --- a/erpnext/translations/pl.csv +++ b/erpnext/translations/pl.csv @@ -3505,7 +3505,6 @@ Recipient,Adresat, Reviews,Recenzje, Sender,Nadawca, Shop,Sklep, -Sign Up,Zapisz się, Subsidiary,Pomocniczy, There is some problem with the file url: {0},Jest jakiś problem z adresem URL pliku: {0}, There were errors while sending email. Please try again.,Wystąpiły błędy podczas wysyłki e-mail. Proszę spróbuj ponownie., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Szablon opinii o jakości, Rules for applying different promotional schemes.,Zasady stosowania różnych programów promocyjnych., Shift,Przesunięcie, Show {0},Pokaż {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Znaki specjalne z wyjątkiem „-”, „#”, „.”, „/”, „{” I „}” niedozwolone w serii nazw", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Znaki specjalne z wyjątkiem "-", "#", "।", "/", "{{" I "}}" niedozwolone w serii nazw {0}", Target Details,Szczegóły celu, {0} already has a Parent Procedure {1}.,{0} ma już procedurę nadrzędną {1}., API,API, diff --git a/erpnext/translations/ps.csv b/erpnext/translations/ps.csv index 1dcaf48d792b..26cd0a9cbcb8 100644 --- a/erpnext/translations/ps.csv +++ b/erpnext/translations/ps.csv @@ -3505,7 +3505,6 @@ Recipient,دترلاسه کوونکي, Reviews,بیاکتنې, Sender,استوونکی, Shop,شاپ, -Sign Up,ګډون کول, Subsidiary,د متمم, There is some problem with the file url: {0},شتون سره د دوتنې url ځينو ستونزه دا ده: {0}, There were errors while sending email. Please try again.,ایمیل استولو په داسې حال کې تېروتنې شوې دي. لطفا بیا هڅه وکړې., @@ -3537,7 +3536,7 @@ Quality Feedback Template,د کیفیت فیډبیک ټیمپلیټ, Rules for applying different promotional schemes.,د مختلف پروموشنل سکیمونو پلي کولو قواعد., Shift,شفټ, Show {0},ښودل {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",ځانګړي نومونه د "-" ، "#" ، "." ، "/" ، "{" او "}" نوم لیکلو کې اجازه نه لري, +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",{0} ځانګړي نومونه د "-" ، "#" ، "." ، "/" ، "{{" او "}}" نوم لیکلو کې اجازه نه لري, Target Details,د هدف توضیحات, {0} already has a Parent Procedure {1}.,{0} د مخه د والدین پروسیجر {1} لري., API,API, diff --git a/erpnext/translations/pt-BR.csv b/erpnext/translations/pt-BR.csv index 957cb75752e3..edaaddd6a78f 100644 --- a/erpnext/translations/pt-BR.csv +++ b/erpnext/translations/pt-BR.csv @@ -3505,7 +3505,6 @@ Recipient,Destinatário, Reviews,Revisões, Sender,Remetente, Shop,Loja, -Sign Up,Inscrever-se, Subsidiary,Subsidiário, There is some problem with the file url: {0},Há algum problema com a url do arquivo: {0}, There were errors while sending email. Please try again.,Ocorreram erros durante o envio de email. Por favor tente novamente., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Modelo de Feedback de Qualidade, Rules for applying different promotional schemes.,Regras para aplicar diferentes esquemas promocionais., Shift,Mudança, Show {0},Mostrar {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Caracteres especiais, exceto "-", "#", ".", "/", "{" e "}" não permitidos na série de nomenclatura", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Caracteres especiais, exceto "-", "#", ".", "/", "{{" e "}}" não permitidos na série de nomenclatura {0}", Target Details,Detalhes do Alvo, {0} already has a Parent Procedure {1}.,{0} já tem um procedimento pai {1}., API,API, diff --git a/erpnext/translations/pt.csv b/erpnext/translations/pt.csv index 3b8a0a0f4362..5cc486d8be2d 100644 --- a/erpnext/translations/pt.csv +++ b/erpnext/translations/pt.csv @@ -3505,7 +3505,6 @@ Recipient,Destinatário, Reviews,Rever, Sender,Remetente, Shop,Loja, -Sign Up,Inscrever-se, Subsidiary,Subsidiário, There is some problem with the file url: {0},Há algum problema com a url do arquivo: {0}, There were errors while sending email. Please try again.,"Ocorreram erros durante o envio de e-mail. Por favor, tente novamente.", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Modelo de Feedback de Qualidade, Rules for applying different promotional schemes.,Regras para aplicar diferentes esquemas promocionais., Shift,Mudança, Show {0},Mostrar {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Caracteres especiais, exceto "-", "#", ".", "/", "{" E "}" não permitidos na série de nomenclatura", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Caracteres especiais, exceto "-", "#", ".", "/", "{{" E "}}" não permitidos na série de nomenclatura {0}", Target Details,Detalhes do Alvo, {0} already has a Parent Procedure {1}.,{0} já tem um procedimento pai {1}., API,API, diff --git a/erpnext/translations/ro.csv b/erpnext/translations/ro.csv index 643b8c5c3ed9..8f97d072021d 100644 --- a/erpnext/translations/ro.csv +++ b/erpnext/translations/ro.csv @@ -3505,7 +3505,6 @@ Recipient,Destinatar, Reviews,opinii, Sender,Expeditor, Shop,Magazin, -Sign Up,Inscrie-te, Subsidiary,Filială, There is some problem with the file url: {0},Există unele probleme cu URL-ul fișierului: {0}, There were errors while sending email. Please try again.,Au fost erori în timp ce trimiterea de e-mail. Încercaţi din nou., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Șablon de feedback de calitate, Rules for applying different promotional schemes.,Reguli pentru aplicarea diferitelor scheme promoționale., Shift,Schimb, Show {0},Afișați {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Caractere speciale, cu excepția "-", "#", ".", "/", "{" Și "}" nu sunt permise în numirea seriei", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Caractere speciale, cu excepția "-", "#", ".", "/", "{{" Și "}}" nu sunt permise în numirea seriei {0}", Target Details,Detalii despre țintă, {0} already has a Parent Procedure {1}.,{0} are deja o procedură părinte {1}., API,API-ul, diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv index 5f3af770cf19..1fc37ddedb6b 100644 --- a/erpnext/translations/ru.csv +++ b/erpnext/translations/ru.csv @@ -3503,7 +3503,6 @@ Recipient,Сторона-реципиент, Reviews,Отзывы, Sender,Отправитель, Shop,Магазин, -Sign Up,Регистрация, Subsidiary,Филиал, There is some problem with the file url: {0},Существует некоторая проблема с файловой URL: {0}, There were errors while sending email. Please try again.,"При отправке электронной почты возникли ошибки. Пожалуйста, попробуйте ещё раз.", @@ -3535,7 +3534,7 @@ Quality Feedback Template,Шаблон обратной связи по каче Rules for applying different promotional schemes.,Правила применения разных рекламных схем., Shift,Сдвиг, Show {0},Показать {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Специальные символы, кроме ""-"", ""#"", ""."", ""/"", ""{"" и ""}"", не допускаются в серийных номерах", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Специальные символы, кроме "-", "#", "।", "/", "{{" и "}}", не допускаются в серийных номерах {0}", Target Details,Детали цели, {0} already has a Parent Procedure {1}.,{0} уже имеет родительскую процедуру {1}., API,API, diff --git a/erpnext/translations/rw.csv b/erpnext/translations/rw.csv index 64591399afac..741f11792f0d 100644 --- a/erpnext/translations/rw.csv +++ b/erpnext/translations/rw.csv @@ -3505,7 +3505,6 @@ Recipient,Uwakiriye, Reviews,Isubiramo, Sender,Kohereza, Shop,Amaduka, -Sign Up,Iyandikishe, Subsidiary,Inkunga, There is some problem with the file url: {0},Hano hari ikibazo na dosiye url: {0}, There were errors while sending email. Please try again.,Habayeho amakosa mugihe wohereje imeri. Nyamuneka gerageza., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Inyandikorugero nziza, Rules for applying different promotional schemes.,Amategeko yo gukoresha gahunda zitandukanye zo kwamamaza., Shift,Shift, Show {0},Erekana {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Inyuguti zidasanzwe usibye "-", "#", ".", "/", "{" Na "}" ntibyemewe mu ruhererekane rwo kwita izina", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Inyuguti zidasanzwe usibye "-", "#", ".", "/", "{{" Na "}}" ntibyemewe mu ruhererekane rwo kwita izina {0}", Target Details,Intego Ibisobanuro, {0} already has a Parent Procedure {1}.,{0} isanzwe ifite uburyo bwababyeyi {1}., API,API, diff --git a/erpnext/translations/si.csv b/erpnext/translations/si.csv index 690c47332d62..e5ea9bff7b1f 100644 --- a/erpnext/translations/si.csv +++ b/erpnext/translations/si.csv @@ -3505,7 +3505,6 @@ Recipient,පලමු වරට පිරිනැමු, Reviews,සමාලෝචන, Sender,යවන්නාගේ, Shop,වෙළඳසැල්, -Sign Up,ලියාපදිංචි වන්න, Subsidiary,අනුබද්ධිත සමාගමක්, There is some problem with the file url: {0},ගොනුව url එක සමග යම් ගැටළුවක් වේ: {0}, There were errors while sending email. Please try again.,ඊ-තැපැල් යැවීම අතර දෝෂ ඇතිවිය. කරුණාකර නැවත උත්සාහ කරන්න., @@ -3537,7 +3536,7 @@ Quality Feedback Template,ගුණාත්මක ප්‍රතිපෝෂ Rules for applying different promotional schemes.,විවිධ ප්‍රවර්ධන යෝජනා ක්‍රම යෙදීම සඳහා නීති., Shift,මාරුව, Show {0},{0 Show පෙන්වන්න, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", ".", "/", "{" සහ "}" හැර විශේෂ අක්ෂර නම් කිරීමේ ශ්‍රේණියේ අවසර නැත", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", ".", "/", "{{" සහ "}}" හැර විශේෂ අක්ෂර නම් කිරීමේ ශ්‍රේණියේ අවසර නැත {0}", Target Details,ඉලක්ක විස්තර, {0} already has a Parent Procedure {1}.,{0} දැනටමත් දෙමාපිය ක්‍රියා පටිපාටියක් ඇත {1}., API,API, diff --git a/erpnext/translations/sk.csv b/erpnext/translations/sk.csv index cb4a7fed4b96..d16c49201a37 100644 --- a/erpnext/translations/sk.csv +++ b/erpnext/translations/sk.csv @@ -3505,7 +3505,6 @@ Recipient,Príjemca, Reviews,recenzia, Sender,Odosielateľ, Shop,Obchod, -Sign Up,Prihlásiť Se, Subsidiary,Dceřiný, There is some problem with the file url: {0},Tam je nejaký problém s URL súboru: {0}, There were errors while sending email. Please try again.,Narazili jsme na problémy při odesílání emailu. Prosím zkuste to znovu., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Šablóna spätnej väzby kvality, Rules for applying different promotional schemes.,Pravidlá uplatňovania rôznych propagačných programov., Shift,smena, Show {0},Zobraziť {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Špeciálne znaky s výnimkou „-“, „#“, „.“, „/“, „{“ A „}“ nie sú v názvových sériách povolené.", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Špeciálne znaky s výnimkou "-", "#", "।", "/", "{{" A "}}" nie sú v názvových sériách povolené {0}.", Target Details,Podrobnosti o cieli, {0} already has a Parent Procedure {1}.,{0} už má rodičovský postup {1}., API,API, diff --git a/erpnext/translations/sl.csv b/erpnext/translations/sl.csv index 8beec6be16ab..08901606c4fc 100644 --- a/erpnext/translations/sl.csv +++ b/erpnext/translations/sl.csv @@ -3505,7 +3505,6 @@ Recipient,Prejemnik, Reviews,Ocene, Sender,Sender, Shop,Trgovina, -Sign Up,Prijavite se, Subsidiary,Hčerinska družba, There is some problem with the file url: {0},Obstaja nekaj problem z datoteko url: {0}, There were errors while sending email. Please try again.,Tam so bile napake pri pošiljanju e-pošte. Prosim poskusi znova., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Predloga za povratne informacije o kakovosti, Rules for applying different promotional schemes.,Pravila za uporabo različnih promocijskih shem., Shift,Shift, Show {0},Prikaži {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Posebni znaki, razen "-", "#", ".", "/", "{" In "}" v poimenovanju ni dovoljen", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Posebni znaki, razen "-", "#", ".", "/", "{{" In "}}" v poimenovanju ni dovoljen {0}", Target Details,Podrobnosti cilja, {0} already has a Parent Procedure {1}.,{0} že ima nadrejeni postopek {1}., API,API, diff --git a/erpnext/translations/sq.csv b/erpnext/translations/sq.csv index 05aefa3602bb..987211ab7a85 100644 --- a/erpnext/translations/sq.csv +++ b/erpnext/translations/sq.csv @@ -3505,7 +3505,6 @@ Recipient,Marrës, Reviews,Shqyrtime, Sender,Dërgues, Shop,Dyqan, -Sign Up,Regjistrohuni, Subsidiary,Ndihmës, There is some problem with the file url: {0},Ka disa probleme me file url: {0}, There were errors while sending email. Please try again.,Ka pasur gabime ndërsa dërguar një email. Ju lutemi provoni përsëri., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Modeli i reagimit të cilësisë, Rules for applying different promotional schemes.,Rregulla për aplikimin e skemave të ndryshme promovuese., Shift,ndryshim, Show {0},Trego {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Karaktere speciale përveç "-", "#", ".", "/", "{" Dhe "}" nuk lejohen në seritë emërtuese", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Karaktere speciale përveç "-", "#", ".", "/", "{{" Dhe "}}" nuk lejohen në seritë emërtuese {0}", Target Details,Detaje të synuara, {0} already has a Parent Procedure {1}.,{0} tashmë ka një procedurë prindërore {1}., API,API, diff --git a/erpnext/translations/sr.csv b/erpnext/translations/sr.csv index b507f74f095b..f6cbb57d201d 100644 --- a/erpnext/translations/sr.csv +++ b/erpnext/translations/sr.csv @@ -3505,7 +3505,6 @@ Recipient,Прималац, Reviews,Коментара, Sender,Пошиљалац, Shop,Продавница, -Sign Up,Пријави се, Subsidiary,Подружница, There is some problem with the file url: {0},Постоји неки проблем са Филе УРЛ: {0}, There were errors while sending email. Please try again.,"Были ошибки при отправке электронной почты . Пожалуйста, попробуйте еще раз .", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Квалитетни образац за поврат Rules for applying different promotional schemes.,Правила за примену различитих промотивних шема., Shift,Смена, Show {0},Прикажи {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Посебни знакови осим "-", "#", ".", "/", "{" И "}" нису дозвољени у именовању серија", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Посебни знакови осим "-", "#", ".", "/", "{{" И "}}" нису дозвољени у именовању серија {0}", Target Details,Детаљи циља, {0} already has a Parent Procedure {1}.,{0} већ има родитељску процедуру {1}., API,АПИ, diff --git a/erpnext/translations/sv.csv b/erpnext/translations/sv.csv index 57e02792f44d..ec443e90be65 100644 --- a/erpnext/translations/sv.csv +++ b/erpnext/translations/sv.csv @@ -3505,7 +3505,6 @@ Recipient,Mottagare, Reviews,recensioner, Sender,Avsändare, Shop,Shop, -Sign Up,Bli Medlem, Subsidiary,Dotterbolag, There is some problem with the file url: {0},Det finns vissa problem med filen url: {0}, There were errors while sending email. Please try again.,Det fanns fel när du skickar e-post. Var god försök igen., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kvalitetsåterkopplingsmall, Rules for applying different promotional schemes.,Regler för tillämpning av olika kampanjprogram., Shift,Flytta, Show {0},Visa {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Specialtecken utom "-", "#", ".", "/", "{" Och "}" är inte tillåtna i namnserien", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Specialtecken utom "-", "#", ".", "/", "{{" Och "}}" är inte tillåtna i namnserien {0}", Target Details,Måldetaljer, {0} already has a Parent Procedure {1}.,{0} har redan en överordnad procedur {1}., API,API, diff --git a/erpnext/translations/sw.csv b/erpnext/translations/sw.csv index 3595727666a4..989f8b06c88c 100644 --- a/erpnext/translations/sw.csv +++ b/erpnext/translations/sw.csv @@ -3505,7 +3505,6 @@ Recipient,Mpokeaji, Reviews,Maoni, Sender,Sender, Shop,Duka, -Sign Up,Ingia, Subsidiary,Msaidizi, There is some problem with the file url: {0},Kuna tatizo fulani na url ya faili: {0}, There were errors while sending email. Please try again.,Kulikuwa na makosa wakati wa kutuma barua pepe. Tafadhali jaribu tena., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kiolezo cha Maoni ya Ubora, Rules for applying different promotional schemes.,Sheria za kutumia miradi tofauti ya uendelezaji., Shift,Shift, Show {0},Onyesha {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Tabia maalum isipokuwa "-", "#", ".", "/", "{" Na "}" hairuhusiwi katika kutaja mfululizo", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Tabia maalum isipokuwa "-", "#", ".", "/", "{{" Na "}}" hairuhusiwi katika kutaja mfululizo {0}", Target Details,Maelezo ya Lengo, {0} already has a Parent Procedure {1}.,{0} tayari ina Utaratibu wa Mzazi {1}., API,API, diff --git a/erpnext/translations/ta.csv b/erpnext/translations/ta.csv index 100f0e9300b4..7314d4be513d 100644 --- a/erpnext/translations/ta.csv +++ b/erpnext/translations/ta.csv @@ -3505,7 +3505,6 @@ Recipient,பெறுபவர், Reviews,விமர்சனங்கள், Sender,அனுப்புபவர், Shop,ஷாப்பிங், -Sign Up,பதிவு செய்யவும், Subsidiary,உப, There is some problem with the file url: {0},கோப்பு URL சில சிக்கல் உள்ளது: {0}, There were errors while sending email. Please try again.,மின்னஞ்சல் அனுப்பும் போது பிழைகள் இருந்தன . மீண்டும் முயற்சிக்கவும்., @@ -3537,7 +3536,7 @@ Quality Feedback Template,தரமான கருத்து வார்ப Rules for applying different promotional schemes.,வெவ்வேறு விளம்பர திட்டங்களைப் பயன்படுத்துவதற்கான விதிகள்., Shift,ஷிப்ட், Show {0},{0 Show ஐக் காட்டு, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", ".", "/", "{" மற்றும் "}" தவிர சிறப்பு எழுத்துக்கள் பெயரிடும் தொடரில் அனுமதிக்கப்படவில்லை", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", ".", "/", "{{" மற்றும் "}}" தவிர சிறப்பு எழுத்துக்கள் பெயரிடும் தொடரில் அனுமதிக்கப்படவில்லை {0}", Target Details,இலக்கு விவரங்கள், {0} already has a Parent Procedure {1}.,{0} ஏற்கனவே பெற்றோர் நடைமுறை {1 has ஐக் கொண்டுள்ளது., API,ஏபிஐ, diff --git a/erpnext/translations/te.csv b/erpnext/translations/te.csv index 047d07731e5b..3fb32dc093df 100644 --- a/erpnext/translations/te.csv +++ b/erpnext/translations/te.csv @@ -3505,7 +3505,6 @@ Recipient,స్వీకర్త, Reviews,సమీక్షలు, Sender,పంపినవారు, Shop,షాప్, -Sign Up,చేరడం, Subsidiary,అనుబంధ, There is some problem with the file url: {0},ఫైల్ URL కొన్ని సమస్య ఉంది: {0}, There were errors while sending email. Please try again.,ఇమెయిల్ పంపడం అయితే కొన్ని లోపాలు ఉన్నాయి. మళ్ళి ప్రయత్నించండి., @@ -3537,7 +3536,7 @@ Quality Feedback Template,నాణ్యమైన అభిప్రాయ మ Rules for applying different promotional schemes.,విభిన్న ప్రచార పథకాలను వర్తింపజేయడానికి నియమాలు., Shift,మార్పు, Show {0},{0 Show చూపించు, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", ".", "/", "{" మరియు "}" మినహా ప్రత్యేక అక్షరాలు పేరు పెట్టే సిరీస్‌లో అనుమతించబడవు", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", ".", "/", "{{" మరియు "}}" మినహా ప్రత్యేక అక్షరాలు పేరు పెట్టే సిరీస్‌లో అనుమతించబడవు {0}", Target Details,లక్ష్య వివరాలు, {0} already has a Parent Procedure {1}.,{0} ఇప్పటికే తల్లిదండ్రుల విధానం {1 has ను కలిగి ఉంది., API,API, diff --git a/erpnext/translations/th.csv b/erpnext/translations/th.csv index 71233ec8fa01..e371aed72844 100644 --- a/erpnext/translations/th.csv +++ b/erpnext/translations/th.csv @@ -3505,7 +3505,6 @@ Recipient,ผู้รับ, Reviews,ความคิดเห็น, Sender,ผู้ส่ง, Shop,ร้านค้า, -Sign Up,ลงชื่อ, Subsidiary,บริษัท สาขา, There is some problem with the file url: {0},มีปัญหากับ url ของไฟล์เป็น: {0}, There were errors while sending email. Please try again.,มีข้อผิดพลาด ในขณะที่ มี การส่งอีเมล์ โปรดลองอีกครั้ง, @@ -3537,7 +3536,7 @@ Quality Feedback Template,เทมเพลตข้อเสนอแนะค Rules for applying different promotional schemes.,กฎสำหรับการใช้รูปแบบการส่งเสริมการขายต่าง ๆ, Shift,เปลี่ยน, Show {0},แสดง {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","ห้ามใช้อักขระพิเศษยกเว้น "-", "#", ".", "/", "{" และ "}" ในซีรี่ส์", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","ห้ามใช้อักขระพิเศษยกเว้น "-", "#", ".", "/", "{{" และ "}}" ในซีรี่ส์ {0}", Target Details,รายละเอียดเป้าหมาย, {0} already has a Parent Procedure {1}.,{0} มี parent Parent {1} อยู่แล้ว, API,API, diff --git a/erpnext/translations/tr.csv b/erpnext/translations/tr.csv index 9e7ba4d1421e..66ea69a1b5b5 100644 --- a/erpnext/translations/tr.csv +++ b/erpnext/translations/tr.csv @@ -3505,7 +3505,6 @@ Recipient,Alıcı, Reviews,yorumlar, Sender,Gönderici, Shop,Mağaza, -Sign Up,Kaydol, Subsidiary,Yardımcı, There is some problem with the file url: {0},Dosya url ile bazı sorun var: {0}, There were errors while sending email. Please try again.,E-posta gönderirken hatalar vardı. Lütfen tekrar deneyin., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Kalite Geribildirim Şablonu, Rules for applying different promotional schemes.,Farklı promosyon programlarını uygulama kuralları., Shift,vardiya, Show {0},{0} göster, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", ".", "/", "{" Ve "}" dışındaki Özel Karakterler, seri dizisine izin verilmez", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", ".", "/", "{{" Ve "}}" dışındaki Özel Karakterler, seri dizisine izin verilmez {0}", Target Details,Hedef Detayları, {0} already has a Parent Procedure {1}.,{0} zaten bir {1} veli prosedürüne sahip., API,API, diff --git a/erpnext/translations/uk.csv b/erpnext/translations/uk.csv index 53e2df518cf3..83c8d41cb70f 100644 --- a/erpnext/translations/uk.csv +++ b/erpnext/translations/uk.csv @@ -3505,7 +3505,6 @@ Recipient,Одержувач, Reviews,Відгуки, Sender,Відправник, Shop,Магазин, -Sign Up,Зареєструватися, Subsidiary,Дочірня компанія, There is some problem with the file url: {0},Існує деяка проблема з файлової URL: {0}, There were errors while sending email. Please try again.,Були помилки при відправленні електронної пошти. Будь ласка спробуйте ще раз., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Шаблон зворотнього зв'язку Rules for applying different promotional schemes.,Правила застосування різних рекламних схем., Shift,Зміна, Show {0},Показати {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Спеціальні символи, окрім "-", "#", ".", "/", "{" Та "}", не дозволяються в іменуванні серій", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Спеціальні символи, окрім "-", "#", ".", "/", "{{" Та "}}", не дозволяються в іменуванні серій {0}", Target Details,Деталі цілі, {0} already has a Parent Procedure {1}.,{0} вже має батьківську процедуру {1}., API,API, diff --git a/erpnext/translations/ur.csv b/erpnext/translations/ur.csv index aaaef5895ffe..8cf0707e3656 100644 --- a/erpnext/translations/ur.csv +++ b/erpnext/translations/ur.csv @@ -3505,7 +3505,6 @@ Recipient,وصول کنندہ, Reviews,جائزہ, Sender,مرسل, Shop,دکان, -Sign Up,سائن اپ, Subsidiary,ماتحت, There is some problem with the file url: {0},فائل URL کے ساتھ کچھ مسئلہ ہے: {0}, There were errors while sending email. Please try again.,ای میل بھیجنے جبکہ غلطیاں تھیں. برائے مہربانی دوبارہ کوشش کریں., @@ -3537,7 +3536,7 @@ Quality Feedback Template,کوالٹی فیڈ بیک ٹیمپلیٹ۔, Rules for applying different promotional schemes.,مختلف پروموشنل اسکیموں کو لاگو کرنے کے قواعد۔, Shift,شفٹ۔, Show {0},دکھائیں {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","-" ، "#" ، "." ، "/" ، "{" اور "}" سوائے خصوصی حروف کی نام بندی سیریز میں اجازت نہیں ہے, +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",{0} "-" ، "#" ، "." ، "/" ، "{{" اور "}}" سوائے خصوصی حروف کی نام بندی سیریز میں اجازت نہیں ہے, Target Details,ہدف کی تفصیلات۔, {0} already has a Parent Procedure {1}.,{0} پہلے سے ہی والدین کا طریقہ کار {1} ہے., API,API, diff --git a/erpnext/translations/uz.csv b/erpnext/translations/uz.csv index c983797aae1e..1e503769cbd2 100644 --- a/erpnext/translations/uz.csv +++ b/erpnext/translations/uz.csv @@ -3505,7 +3505,6 @@ Recipient,Qabul qiluvchi, Reviews,Sharhlar, Sender,Yuboruvchi, Shop,Do'kon, -Sign Up,Ro'yxatdan o'tish, Subsidiary,Sho'ba korxonasi, There is some problem with the file url: {0},Fayl urlida muammo mavjud: {0}, There were errors while sending email. Please try again.,"E-pochtani yuborishda xatolik yuz berdi. Iltimos, yana bir bor urinib ko'ring.", @@ -3537,7 +3536,7 @@ Quality Feedback Template,Sifat bo'yicha fikrlar shablonlari, Rules for applying different promotional schemes.,Turli reklama sxemalarini qo'llash qoidalari., Shift,Shift, Show {0},{0} ni ko'rsatish, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",""-", "#", ".", "/", "{" Va "}" belgilaridan tashqari maxsus belgilarga ruxsat berilmaydi.", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}",""-", "#", ".", "/", "{{" Va "}}" belgilaridan tashqari maxsus belgilarga ruxsat berilmaydi {0}.", Target Details,Maqsad tafsilotlari, {0} already has a Parent Procedure {1}.,{0} allaqachon Ota-ona tartibiga ega {1}., API,API, diff --git a/erpnext/translations/vi.csv b/erpnext/translations/vi.csv index 03ff2ccc38d1..28fecb64271c 100644 --- a/erpnext/translations/vi.csv +++ b/erpnext/translations/vi.csv @@ -3505,7 +3505,6 @@ Recipient,Người nhận, Reviews,Nhận xét, Sender,Người gửi, Shop,Cửa hàng, -Sign Up,Đăng ký, Subsidiary,Công ty con, There is some problem with the file url: {0},Có một số vấn đề với các url của tệp: {0}, There were errors while sending email. Please try again.,Có lỗi khi gửi email. Vui lòng thử lại sau., @@ -3537,7 +3536,7 @@ Quality Feedback Template,Mẫu phản hồi chất lượng, Rules for applying different promotional schemes.,Quy tắc áp dụng các chương trình khuyến mãi khác nhau., Shift,Ca, Show {0},Hiển thị {0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series","Các ký tự đặc biệt ngoại trừ "-", "#", ".", "/", "{" Và "}" không được phép trong chuỗi đặt tên", +"Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}","Các ký tự đặc biệt ngoại trừ "-", "#", ".", "/", "{{" Và "}}" không được phép trong chuỗi đặt tên {0}", Target Details,Chi tiết mục tiêu, {0} already has a Parent Procedure {1}.,{0} đã có Quy trình dành cho phụ huynh {1}., API,API, diff --git a/erpnext/translations/zh.csv b/erpnext/translations/zh.csv index 716f1f2f31aa..9ed7420f4630 100644 --- a/erpnext/translations/zh.csv +++ b/erpnext/translations/zh.csv @@ -3505,7 +3505,6 @@ Recipient,收件人, Reviews,评测, Sender,发件人, Shop,商店, -Sign Up,注册, Subsidiary,子机构, There is some problem with the file url: {0},有一些问题与文件的URL:{0}, There were errors while sending email. Please try again.,邮件发送曾发生错误,请重试。, @@ -3537,7 +3536,6 @@ Quality Feedback Template,质量反馈模板, Rules for applying different promotional schemes.,适用不同促销计划的规则。, Shift,转移, Show {0},显示{0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",命名系列中不允许使用除“ - ”,“#”,“。”,“/”,“{”和“}”之外的特殊字符, Target Details,目标细节, {0} already has a Parent Procedure {1}.,{0}已有父程序{1}。, API,应用程序界面, diff --git a/erpnext/translations/zh_tw.csv b/erpnext/translations/zh_tw.csv index 1cc7d8773ee9..ef4967056773 100644 --- a/erpnext/translations/zh_tw.csv +++ b/erpnext/translations/zh_tw.csv @@ -3282,7 +3282,6 @@ Recipient,接受者, Reviews,評測, Sender,寄件人, Shop,店, -Sign Up,註冊, Subsidiary,副, There is some problem with the file url: {0},有一些問題與文件的URL:{0}, There were errors while sending email. Please try again.,還有在發送電子郵件是錯誤的。請再試一次。, @@ -3311,7 +3310,6 @@ Quality Feedback Template,質量反饋模板, Rules for applying different promotional schemes.,適用不同促銷計劃的規則。, Shift,轉移, Show {0},顯示{0}, -"Special Characters except ""-"", ""#"", ""."", ""/"", ""{"" and ""}"" not allowed in naming series",命名系列中不允許使用除“ - ”,“#”,“。”,“/”,“{”和“}”之外的特殊字符, Target Details,目標細節, API,API, Annual,年刊, diff --git a/erpnext/utilities/__init__.py b/erpnext/utilities/__init__.py index c2b4229f171b..24bfdc63af63 100644 --- a/erpnext/utilities/__init__.py +++ b/erpnext/utilities/__init__.py @@ -1,6 +1,9 @@ ## temp utility +from contextlib import contextmanager + import frappe +from frappe import _ from frappe.utils import cstr from erpnext.utilities.activation import get_level @@ -35,3 +38,16 @@ def get_site_info(site_info): domain = frappe.get_cached_value("Company", cstr(company), "domain") return {"company": company, "domain": domain, "activation": get_level()} + + +@contextmanager +def payment_app_import_guard(): + marketplace_link = 'Marketplace' + github_link = 'GitHub' + msg = _("payments app is not installed. Please install it from {} or {}").format( + marketplace_link, github_link + ) + try: + yield + except ImportError: + frappe.throw(msg, title=_("Missing Payments App")) diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 21a0a551b627..7eba35dedd93 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -58,11 +58,11 @@ def validate_with_previous_doc(self, ref): def compare_values(self, ref_doc, fields, doc=None): for reference_doctype, ref_dn_list in ref_doc.items(): + prev_doc_detail_map = self.get_prev_doc_reference_details( + ref_dn_list, reference_doctype, fields + ) for reference_name in ref_dn_list: - prevdoc_values = frappe.db.get_value( - reference_doctype, reference_name, [d[0] for d in fields], as_dict=1 - ) - + prevdoc_values = prev_doc_detail_map.get(reference_name) if not prevdoc_values: frappe.throw(_("Invalid reference {0} {1}").format(reference_doctype, reference_name)) @@ -70,6 +70,19 @@ def compare_values(self, ref_doc, fields, doc=None): if prevdoc_values[field] is not None and field not in self.exclude_fields: self.validate_value(field, condition, prevdoc_values[field], doc) + def get_prev_doc_reference_details(self, reference_names, reference_doctype, fields): + prev_doc_detail_map = {} + details = frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name"] + [d[0] for d in fields], + ) + + for d in details: + prev_doc_detail_map.setdefault(d.name, d) + + return prev_doc_detail_map + def validate_rate_with_reference_doc(self, ref_details): if self.get("is_internal_supplier"): return @@ -77,23 +90,23 @@ def validate_rate_with_reference_doc(self, ref_details): buying_doctypes = ["Purchase Order", "Purchase Invoice", "Purchase Receipt"] if self.doctype in buying_doctypes: - action = frappe.db.get_single_value("Buying Settings", "maintain_same_rate_action") - settings_doc = "Buying Settings" + action, role_allowed_to_override = frappe.get_cached_value( + "Buying Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] + ) else: - action = frappe.db.get_single_value("Selling Settings", "maintain_same_rate_action") - settings_doc = "Selling Settings" + action, role_allowed_to_override = frappe.get_cached_value( + "Selling Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"] + ) for ref_dt, ref_dn_field, ref_link_field in ref_details: + reference_names = [d.get(ref_link_field) for d in self.get("items") if d.get(ref_link_field)] + reference_details = self.get_reference_details(reference_names, ref_dt + " Item") for d in self.get("items"): if d.get(ref_link_field): - ref_rate = frappe.db.get_value(ref_dt + " Item", d.get(ref_link_field), "rate") + ref_rate = reference_details.get(d.get(ref_link_field)) if abs(flt(d.rate - ref_rate, d.precision("rate"))) >= 0.01: if action == "Stop": - role_allowed_to_override = frappe.db.get_single_value( - settings_doc, "role_to_override_stop_action" - ) - if role_allowed_to_override not in frappe.get_roles(): frappe.throw( _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( @@ -109,6 +122,16 @@ def validate_rate_with_reference_doc(self, ref_details): indicator="orange", ) + def get_reference_details(self, reference_names, reference_doctype): + return frappe._dict( + frappe.get_all( + reference_doctype, + filters={"name": ("in", reference_names)}, + fields=["name", "rate"], + as_list=1, + ) + ) + def get_link_filters(self, for_doctype): if hasattr(self, "prev_link_mapper") and self.prev_link_mapper.get(for_doctype): fieldname = self.prev_link_mapper[for_doctype]["fieldname"] @@ -186,12 +209,15 @@ def validate_uom_is_integer(doc, uom_field, qty_fields, child_dt=None): for f in qty_fields: qty = d.get(f) if qty: - if abs(cint(qty) - flt(qty)) > 0.0000001: + if abs(cint(qty) - flt(qty, d.precision(f))) > 0.0000001: frappe.throw( _( "Row {1}: Quantity ({0}) cannot be a fraction. To allow this, disable '{2}' in UOM {3}." ).format( - qty, d.idx, frappe.bold(_("Must be Whole Number")), frappe.bold(d.get(uom_field)) + flt(qty, d.precision(f)), + d.idx, + frappe.bold(_("Must be Whole Number")), + frappe.bold(d.get(uom_field)), ), UOMMustBeIntegerError, ) diff --git a/erpnext/www/shop-by-category/index.py b/erpnext/www/shop-by-category/index.py index 8a92418d25ed..913c1836acdf 100644 --- a/erpnext/www/shop-by-category/index.py +++ b/erpnext/www/shop-by-category/index.py @@ -51,21 +51,41 @@ def get_tabs(categories): return tab_values -def get_category_records(categories): +def get_category_records(categories: list): categorical_data = {} - for category in categories: - if category == "item_group": + website_item_meta = frappe.get_meta("Website Item", cached=True) + + for c in categories: + if c == "item_group": categorical_data["item_group"] = frappe.db.get_all( "Item Group", filters={"parent_item_group": "All Item Groups", "show_in_website": 1}, fields=["name", "parent_item_group", "is_group", "image", "route"], ) + + continue + + field_type = website_item_meta.get_field(c).fieldtype + + if field_type == "Table MultiSelect": + child_doc = website_item_meta.get_field(c).options + for field in frappe.get_meta(child_doc, cached=True).fields: + if field.fieldtype == "Link" and field.reqd: + doctype = field.options else: - doctype = frappe.unscrub(category) - fields = ["name"] - if frappe.get_meta(doctype, cached=True).get_field("image"): + doctype = website_item_meta.get_field(c).options + + fields = ["name"] + + try: + meta = frappe.get_meta(doctype, cached=True) + if meta.get_field("image"): fields += ["image"] - categorical_data[category] = frappe.db.get_all(doctype, fields=fields) + data = frappe.db.get_all(doctype, fields=fields) + categorical_data[c] = data + except BaseException: + frappe.throw(_("DocType {} not found").format(doctype)) + continue return categorical_data