diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..24f122a8d43 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# Root editor config file +root = true + +# Common settings +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# python, js indentation settings +[{*.py,*.js}] +indent_style = tab +indent_size = 4 diff --git a/.eslintrc b/.eslintrc index 757aa3caaf5..3b6ab7498d9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,7 +5,7 @@ "es6": true }, "parserOptions": { - "ecmaVersion": 6, + "ecmaVersion": 9, "sourceType": "module" }, "extends": "eslint:recommended", @@ -15,6 +15,14 @@ "tab", { "SwitchCase": 1 } ], + "brace-style": [ + "error", + "1tbs" + ], + "space-unary-ops": [ + "error", + { "words": true } + ], "linebreak-style": [ "error", "unix" @@ -44,12 +52,10 @@ "no-control-regex": [ "off" ], - "spaced-comment": [ - "warn" - ], - "no-trailing-spaces": [ - "warn" - ] + "space-before-blocks": "warn", + "keyword-spacing": "warn", + "comma-spacing": "warn", + "key-spacing": "warn" }, "root": true, "globals": { @@ -86,6 +92,7 @@ "cur_page": true, "cur_list": true, "cur_tree": true, + "cur_pos": true, "msg_dialog": true, "is_null": true, "in_list": true, @@ -143,6 +150,7 @@ "it": true, "context": true, "before": true, - "beforeEach": true + "beforeEach": true, + "onScan": true } } diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000000..399b176e1d0 --- /dev/null +++ b/.flake8 @@ -0,0 +1,32 @@ +[flake8] +ignore = + E121, + E126, + E127, + E128, + E203, + E225, + E226, + E231, + E241, + E251, + E261, + E265, + E302, + E303, + E305, + E402, + E501, + E741, + W291, + W292, + W293, + W391, + W503, + W504, + F403, + B007, + B950, + W191, + +max-line-length = 200 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..26bb7ab280c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Community Forum + url: https://discuss.erpnext.com/ + about: For general QnA, discussions and community help. diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py new file mode 100644 index 00000000000..9cc4663c394 --- /dev/null +++ b/.github/helper/documentation.py @@ -0,0 +1,48 @@ +import sys +import requests +from urllib.parse import urlparse + + +docs_repos = [ + "frappe_docs", + "erpnext_documentation", + "erpnext_com", + "frappe_io", +] + + +def uri_validator(x): + result = urlparse(x) + return all([result.scheme, result.netloc, result.path]) + +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 + + +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", "").lower() + head_sha = payload.get("head", {}).get("sha") + body = payload.get("body", "").lower() + + if title.startswith("feat") and head_sha and "no-docs" 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... 🏃") diff --git a/.github/helper/install.sh b/.github/helper/install.sh new file mode 100644 index 00000000000..7b0f944c669 --- /dev/null +++ b/.github/helper/install.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -e + +cd ~ || exit + +sudo apt-get install redis-server + +sudo apt install nodejs + +sudo apt install npm + +pip install frappe-bench + +git clone https://github.com/frappe/frappe --branch "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" --depth 1 +bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench + +mkdir ~/frappe-bench/sites/test_site +cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config.json" ~/frappe-bench/sites/test_site/ + +mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'" +mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + +mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" +mysql --host 127.0.0.1 --port 3306 -u root -e "CREATE DATABASE test_frappe" +mysql --host 127.0.0.1 --port 3306 -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" + +mysql --host 127.0.0.1 --port 3306 -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'" +mysql --host 127.0.0.1 --port 3306 -u root -e "FLUSH PRIVILEGES" + +wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz +tar -xf /tmp/wkhtmltox.tar.xz -C /tmp +sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf +sudo chmod o+x /usr/local/bin/wkhtmltopdf +sudo apt-get install libcups2-dev + +cd ~/frappe-bench || exit + +sed -i 's/watch:/# watch:/g' Procfile +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 erpnext "${GITHUB_WORKSPACE}" +bench start & +bench --site test_site reinstall --yes diff --git a/.travis/site_config.json b/.github/helper/site_config.json similarity index 75% rename from .travis/site_config.json rename to .github/helper/site_config.json index dae80095d45..60ef80cbad5 100644 --- a/.travis/site_config.json +++ b/.github/helper/site_config.json @@ -1,4 +1,6 @@ { + "db_host": "127.0.0.1", + "db_port": 3306, "db_name": "test_frappe", "db_password": "test_frappe", "auto_email_id": "test@example.com", @@ -9,5 +11,6 @@ "root_login": "root", "root_password": "travis", "host_name": "http://test_site:8000", - "install_apps": ["erpnext"] + "install_apps": ["erpnext"], + "throttle_user_limit": 100 } \ No newline at end of file diff --git a/.github/helper/translation.py b/.github/helper/translation.py new file mode 100644 index 00000000000..9146b3b32b8 --- /dev/null +++ b/.github/helper/translation.py @@ -0,0 +1,60 @@ +import re +import sys + +errors_encounter = 0 +pattern = re.compile(r"_\(([\"']{,3})(?P((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P((?!\5).)*)\5)*(\s*,(\s*?.*?\n*?)*(,\s*([\"'])(?P((?!\11).)*)\11)*)*\)") +words_pattern = re.compile(r"_{1,2}\([\"'`]{1,3}.*?[a-zA-Z]") +start_pattern = re.compile(r"_{1,2}\([f\"'`]{1,3}") +f_string_pattern = re.compile(r"_\(f[\"']") +starts_with_f_pattern = re.compile(r"_\(f") + +# skip first argument +files = sys.argv[1:] +files_to_scan = [_file for _file in files if _file.endswith(('.py', '.js'))] + +for _file in files_to_scan: + with open(_file, 'r') as f: + print(f'Checking: {_file}') + file_lines = f.readlines() + for line_number, line in enumerate(file_lines, 1): + if 'frappe-lint: disable-translate' in line: + continue + + start_matches = start_pattern.search(line) + if start_matches: + starts_with_f = starts_with_f_pattern.search(line) + + if starts_with_f: + has_f_string = f_string_pattern.search(line) + if has_f_string: + errors_encounter += 1 + print(f'\nF-strings are not supported for translations at line number {line_number}\n{line.strip()[:100]}') + continue + else: + continue + + match = pattern.search(line) + error_found = False + + if not match and line.endswith((',\n', '[\n')): + # concat remaining text to validate multiline pattern + line = "".join(file_lines[line_number - 1:]) + line = line[start_matches.start() + 1:] + match = pattern.match(line) + + if not match: + error_found = True + print(f'\nTranslation syntax error at line number {line_number}\n{line.strip()[:100]}') + + if not error_found and not words_pattern.search(line): + error_found = True + print(f'\nTranslation is useless because it has no words at line number {line_number}\n{line.strip()[:100]}') + + if error_found: + errors_encounter += 1 + +if errors_encounter > 0: + print('\nVisit "https://frappeframework.com/docs/user/en/translations" to learn about valid translation strings.') + sys.exit(1) +else: + print('\nGood To Go!') diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 00000000000..78c2f5a187b --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,94 @@ +name: CI + +on: [pull_request, workflow_dispatch, push] + +jobs: + test: + runs-on: ubuntu-18.04 + + strategy: + fail-fast: false + + matrix: + include: + - TYPE: "server" + JOB_NAME: "Server" + RUN_COMMAND: cd ~/frappe-bench/ && bench --site test_site run-tests --app erpnext --coverage + - TYPE: "patch" + JOB_NAME: "Patch" + RUN_COMMAND: cd ~/frappe-bench/ && wget http://build.erpnext.com/20171108_190013_955977f8_database.sql.gz && bench --site test_site --force restore ~/frappe-bench/20171108_190013_955977f8_database.sql.gz && bench --site test_site migrate + + name: ${{ matrix.JOB_NAME }} + + services: + mysql: + image: mariadb:10.3 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: YES + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.6 + + - name: Add to Hosts + run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + - name: Cache node modules + uses: actions/cache@v2 + env: + cache-name: cache-node-modules + with: + path: ~/.npm + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + + - uses: actions/cache@v2 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install + run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh + + - name: Run Tests + run: ${{ matrix.RUN_COMMAND }} + env: + TYPE: ${{ matrix.TYPE }} + + - name: Coverage + if: matrix.TYPE == 'server' + run: | + cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} + cd ${GITHUB_WORKSPACE} + pip install coveralls==2.2.0 + pip install coverage==4.5.4 + coveralls + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} + diff --git a/.github/workflows/docs-checker.yml b/.github/workflows/docs-checker.yml new file mode 100644 index 00000000000..cdf676dd674 --- /dev/null +++ b/.github/workflows/docs-checker.yml @@ -0,0 +1,24 @@ +name: 'Documentation Required' +on: + pull_request: + types: [ opened, synchronize, reopened, edited ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: 'Setup Environment' + uses: actions/setup-python@v2 + with: + python-version: 3.6 + + - name: 'Clone repo' + uses: actions/checkout@v2 + + - name: Validate Docs + env: + PR_NUMBER: ${{ github.event.number }} + run: | + pip install requests --quiet + python $GITHUB_WORKSPACE/.github/helper/documentation.py $PR_NUMBER diff --git a/.github/workflows/translation_linter.yml b/.github/workflows/translation_linter.yml new file mode 100644 index 00000000000..4becaebd6b4 --- /dev/null +++ b/.github/workflows/translation_linter.yml @@ -0,0 +1,22 @@ +name: Frappe Linter +on: + pull_request: + branches: + - develop + - version-12-hotfix + - version-11-hotfix +jobs: + check_translation: + name: Translation Syntax Check + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v2 + - name: Setup python3 + uses: actions/setup-python@v1 + with: + python-version: 3.6 + - name: Validating Translation Syntax + run: | + git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q + files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF) + python $GITHUB_WORKSPACE/.github/helper/translation.py $files diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 77d427e5a50..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,69 +0,0 @@ -language: python -dist: trusty - -git: - depth: 1 - -cache: - - pip - -addons: - hosts: test_site - mariadb: 10.3 - -jobs: - include: - - name: "Python 3.6 Server Side Test" - python: 3.6 - script: bench --site test_site run-tests --app erpnext --coverage - - - name: "Python 3.6 Patch Test" - python: 3.6 - before_script: - - wget http://build.erpnext.com/20171108_190013_955977f8_database.sql.gz - - bench --site test_site --force restore ~/frappe-bench/20171108_190013_955977f8_database.sql.gz - script: bench --site test_site migrate - -install: - - cd ~ - - nvm install 10 - - - pip install frappe-bench - - - git clone https://github.com/frappe/frappe --branch $TRAVIS_BRANCH --depth 1 - - bench init --skip-assets --frappe-path ~/frappe --python $(which python) frappe-bench - - - mkdir ~/frappe-bench/sites/test_site - - cp -r $TRAVIS_BUILD_DIR/.travis/site_config.json ~/frappe-bench/sites/test_site/ - - - mysql -u root -e "SET GLOBAL character_set_server = 'utf8mb4'" - - mysql -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" - - - mysql -u root -e "CREATE DATABASE test_frappe" - - mysql -u root -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" - - mysql -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" - - - mysql -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'" - - mysql -u root -e "FLUSH PRIVILEGES" - - - wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz - - tar -xf /tmp/wkhtmltox.tar.xz -C /tmp - - sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf - - sudo chmod o+x /usr/local/bin/wkhtmltopdf - - sudo apt-get install libcups2-dev - - - cd ~/frappe-bench - - - sed -i 's/watch:/# watch:/g' Procfile - - 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 erpnext $TRAVIS_BUILD_DIR - - bench start & - - bench --site test_site reinstall --yes - -after_script: - - pip install coverage==4.5.4 - - pip install python-coveralls - - coveralls -b apps/erpnext -d ../../sites/.coverage diff --git a/README.md b/README.md index 0f6a52142bf..bb592ae75c5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

ERP made simple

-[![Build Status](https://travis-ci.com/frappe/erpnext.svg)](https://travis-ci.com/frappe/erpnext) +[![CI](https://github.com/frappe/erpnext/actions/workflows/ci-tests.yml/badge.svg?branch=develop)](https://github.com/frappe/erpnext/actions/workflows/ci-tests.yml) [![Open Source Helpers](https://www.codetriage.com/frappe/erpnext/badges/users.svg)](https://www.codetriage.com/frappe/erpnext) [![Coverage Status](https://coveralls.io/repos/github/frappe/erpnext/badge.svg?branch=develop)](https://coveralls.io/github/frappe/erpnext?branch=develop) diff --git a/erpnext/.stylelintrc b/erpnext/.stylelintrc new file mode 100644 index 00000000000..1e05d1fb41d --- /dev/null +++ b/erpnext/.stylelintrc @@ -0,0 +1,9 @@ +{ + "extends": ["stylelint-config-recommended"], + "plugins": ["stylelint-scss"], + "rules": { + "at-rule-no-unknown": null, + "scss/at-rule-no-unknown": true, + "no-descending-specificity": null + } +} \ No newline at end of file diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 38d8a62f07f..199a183e479 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -109,7 +109,7 @@ def get_region(company=None): ''' if company or frappe.flags.company: return frappe.get_cached_value('Company', - company or frappe.flags.company, 'country') + company or frappe.flags.company, 'country') elif frappe.flags.country: return frappe.flags.country else: @@ -132,16 +132,10 @@ def allow_regional(fn): return caller -def get_last_membership(): +def get_last_membership(member): '''Returns last membership if exists''' last_membership = frappe.get_all('Membership', 'name,to_date,membership_type', - dict(member=frappe.session.user, paid=1), order_by='to_date desc', limit=1) + dict(member=member, paid=1), order_by='to_date desc', limit=1) - return last_membership and last_membership[0] - -def is_member(): - '''Returns true if the user is still a member''' - last_membership = get_last_membership() - if last_membership and getdate(last_membership.to_date) > getdate(): - return True - return False + if last_membership: + return last_membership[0] diff --git a/erpnext/accounts/custom/address.json b/erpnext/accounts/custom/address.json index 08f972d13b3..5c921da9b7a 100644 --- a/erpnext/accounts/custom/address.json +++ b/erpnext/accounts/custom/address.json @@ -1,58 +1,126 @@ { "custom_fields": [ { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2018-12-28 22:29:21.828090", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Address", - "fetch_from": null, - "fieldname": "tax_category", - "fieldtype": "Link", - "hidden": 0, - "idx": 14, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "insert_after": "fax", - "label": "Tax Category", - "modified": "2018-12-28 22:29:21.828090", - "modified_by": "Administrator", - "name": "Address-tax_category", - "no_copy": 0, - "options": "Tax Category", - "owner": "Administrator", - "parent": null, - "parentfield": null, - "parenttype": null, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "translatable": 0, - "unique": 0, + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2018-12-28 22:29:21.828090", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Address", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "tax_category", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 15, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "fax", + "label": "Tax Category", + "length": 0, + "mandatory_depends_on": null, + "modified": "2018-12-28 22:29:21.828090", + "modified_by": "Administrator", + "name": "Address-tax_category", + "no_copy": 0, + "options": "Tax Category", + "owner": "Administrator", + "parent": null, + "parentfield": null, + "parenttype": null, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2020-10-14 17:41:40.878179", + "default": "0", + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Address", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "is_your_company_address", + "fieldtype": "Check", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 20, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "linked_with", + "label": "Is Your Company Address", + "length": 0, + "mandatory_depends_on": null, + "modified": "2020-10-14 17:41:40.878179", + "modified_by": "Administrator", + "name": "Address-is_your_company_address", + "no_copy": 0, + "options": null, + "owner": "Administrator", + "parent": null, + "parentfield": null, + "parenttype": null, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, "width": null } - ], - "custom_perms": [], - "doctype": "Address", - "property_setters": [], + ], + "custom_perms": [], + "doctype": "Address", + "property_setters": [], "sync_on_migrate": 1 } \ No newline at end of file diff --git a/erpnext/accounts/custom/address.py b/erpnext/accounts/custom/address.py new file mode 100644 index 00000000000..5e764037a74 --- /dev/null +++ b/erpnext/accounts/custom/address.py @@ -0,0 +1,42 @@ +import frappe +from frappe import _ +from frappe.contacts.doctype.address.address import Address +from frappe.contacts.doctype.address.address import get_address_templates + +class ERPNextAddress(Address): + def validate(self): + self.validate_reference() + super(ERPNextAddress, self).validate() + + def link_address(self): + """Link address based on owner""" + if self.is_your_company_address: + return + + return super(ERPNextAddress, self).link_address() + + def validate_reference(self): + if self.is_your_company_address and not [ + row for row in self.links if row.link_doctype == "Company" + ]: + frappe.throw(_("Address needs to be linked to a Company. Please add a row for Company in the Links table."), + title=_("Company Not Linked")) + +@frappe.whitelist() +def get_shipping_address(company, address = None): + filters = [ + ["Dynamic Link", "link_doctype", "=", "Company"], + ["Dynamic Link", "link_name", "=", company], + ["Address", "is_your_company_address", "=", 1] + ] + fields = ["*"] + if address and frappe.db.get_value('Dynamic Link', + {'parent': address, 'link_name': company}): + filters.append(["Address", "name", "=", address]) + + address = frappe.get_all("Address", filters=filters, fields=fields) or {} + + if address: + address_as_dict = address[0] + name, address_template = get_address_templates(address_as_dict) + return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict) diff --git a/erpnext/accounts/dashboard_chart_source/account_balance_timeline/account_balance_timeline.py b/erpnext/accounts/dashboard_chart_source/account_balance_timeline/account_balance_timeline.py index 39bf4b053a7..85f54f98ba8 100644 --- a/erpnext/accounts/dashboard_chart_source/account_balance_timeline/account_balance_timeline.py +++ b/erpnext/accounts/dashboard_chart_source/account_balance_timeline/account_balance_timeline.py @@ -6,9 +6,8 @@ import frappe, json from frappe import _ from frappe.utils import add_to_date, date_diff, getdate, nowdate, get_last_day, formatdate, get_link_to_form from erpnext.accounts.report.general_ledger.general_ledger import execute -from frappe.utils.dashboard import cache_source, get_from_date_from_timespan -from frappe.desk.doctype.dashboard_chart.dashboard_chart import get_period_ending - +from frappe.utils.dashboard import cache_source +from frappe.utils.dateutils import get_from_date_from_timespan, get_period_ending from frappe.utils.nestedset import get_descendants_of @frappe.whitelist() diff --git a/erpnext/accounts/desk_page/accounting/accounting.json b/erpnext/accounts/desk_page/accounting/accounting.json deleted file mode 100644 index 3f23ba90197..00000000000 --- a/erpnext/accounts/desk_page/accounting/accounting.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Accounting Masters", - "links": "[\n {\n \"description\": \"Company (not Customer or Supplier) master.\",\n \"label\": \"Company\",\n \"name\": \"Company\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tree of financial accounts.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Chart of Accounts\",\n \"name\": \"Account\",\n \"onboard\": 1,\n \"route\": \"#Tree/Account\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Accounts Settings\",\n \"name\": \"Accounts Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Financial / accounting year.\",\n \"label\": \"Fiscal Year\",\n \"name\": \"Fiscal Year\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Accounting Dimension\",\n \"name\": \"Accounting Dimension\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Finance Book\",\n \"name\": \"Finance Book\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Accounting Period\",\n \"name\": \"Accounting Period\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Payment Terms based on conditions\",\n \"label\": \"Payment Term\",\n \"name\": \"Payment Term\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "General Ledger", - "links": "[\n {\n \"description\": \"Accounting journal entries.\",\n \"label\": \"Journal Entry\",\n \"name\": \"Journal Entry\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Make journal entries from a template.\",\n \"label\": \"Journal Entry Template\",\n \"name\": \"Journal Entry Template\",\n \"type\": \"doctype\"\n },\n \n {\n \"dependencies\": [\n \"GL Entry\"\n ],\n \"doctype\": \"GL Entry\",\n \"is_query_report\": true,\n \"label\": \"General Ledger\",\n \"name\": \"General Ledger\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Customer Ledger Summary\",\n \"name\": \"Customer Ledger Summary\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Supplier Ledger Summary\",\n \"name\": \"Supplier Ledger Summary\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Accounts Receivable", - "links": "[\n {\n \"description\": \"Bills raised to Customers.\",\n \"label\": \"Sales Invoice\",\n \"name\": \"Sales Invoice\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Customer database.\",\n \"label\": \"Customer\",\n \"name\": \"Customer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Bank/Cash transactions against party or for internal transfer\",\n \"label\": \"Payment Entry\",\n \"name\": \"Payment Entry\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Payment Request\",\n \"label\": \"Payment Request\",\n \"name\": \"Payment Request\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Accounts Receivable\",\n \"name\": \"Accounts Receivable\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Accounts Receivable Summary\",\n \"name\": \"Accounts Receivable Summary\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Sales Register\",\n \"name\": \"Sales Register\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Item-wise Sales Register\",\n \"name\": \"Item-wise Sales Register\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Order Analysis\",\n \"name\": \"Sales Order Analysis\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Delivered Items To Be Billed\",\n \"name\": \"Delivered Items To Be Billed\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Accounts Payable", - "links": "[\n {\n \"description\": \"Bills raised by Suppliers.\",\n \"label\": \"Purchase Invoice\",\n \"name\": \"Purchase Invoice\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Supplier database.\",\n \"label\": \"Supplier\",\n \"name\": \"Supplier\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Bank/Cash transactions against party or for internal transfer\",\n \"label\": \"Payment Entry\",\n \"name\": \"Payment Entry\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Purchase Invoice\"\n ],\n \"doctype\": \"Purchase Invoice\",\n \"is_query_report\": true,\n \"label\": \"Accounts Payable\",\n \"name\": \"Accounts Payable\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Purchase Invoice\"\n ],\n \"doctype\": \"Purchase Invoice\",\n \"is_query_report\": true,\n \"label\": \"Accounts Payable Summary\",\n \"name\": \"Accounts Payable Summary\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Purchase Invoice\"\n ],\n \"doctype\": \"Purchase Invoice\",\n \"is_query_report\": true,\n \"label\": \"Purchase Register\",\n \"name\": \"Purchase Register\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Purchase Invoice\"\n ],\n \"doctype\": \"Purchase Invoice\",\n \"is_query_report\": true,\n \"label\": \"Item-wise Purchase Register\",\n \"name\": \"Item-wise Purchase Register\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Purchase Order\"\n ],\n \"doctype\": \"Purchase Order\",\n \"is_query_report\": true,\n \"label\": \"Purchase Order Analysis\",\n \"name\": \"Purchase Order Analysis\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Purchase Invoice\"\n ],\n \"doctype\": \"Purchase Invoice\",\n \"is_query_report\": true,\n \"label\": \"Received Items To Be Billed\",\n \"name\": \"Received Items To Be Billed\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Reports", - "links": "[\n {\n \"dependencies\": [\n \"GL Entry\"\n ],\n \"doctype\": \"GL Entry\",\n \"is_query_report\": true,\n \"label\": \"Trial Balance for Party\",\n \"name\": \"Trial Balance for Party\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Journal Entry\"\n ],\n \"doctype\": \"Journal Entry\",\n \"is_query_report\": true,\n \"label\": \"Payment Period Based On Invoice Date\",\n \"name\": \"Payment Period Based On Invoice Date\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Sales Partners Commission\",\n \"name\": \"Sales Partners Commission\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Customer\"\n ],\n \"doctype\": \"Customer\",\n \"is_query_report\": true,\n \"label\": \"Customer Credit Balance\",\n \"name\": \"Customer Credit Balance\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Sales Payment Summary\",\n \"name\": \"Sales Payment Summary\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Address\"\n ],\n \"doctype\": \"Address\",\n \"is_query_report\": true,\n \"label\": \"Address And Contacts\",\n \"name\": \"Address And Contacts\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Financial Statements", - "links": "[\n {\n \"dependencies\": [\n \"GL Entry\"\n ],\n \"doctype\": \"GL Entry\",\n \"is_query_report\": true,\n \"label\": \"Trial Balance\",\n \"name\": \"Trial Balance\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"GL Entry\"\n ],\n \"doctype\": \"GL Entry\",\n \"is_query_report\": true,\n \"label\": \"Profit and Loss Statement\",\n \"name\": \"Profit and Loss Statement\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"GL Entry\"\n ],\n \"doctype\": \"GL Entry\",\n \"is_query_report\": true,\n \"label\": \"Balance Sheet\",\n \"name\": \"Balance Sheet\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"GL Entry\"\n ],\n \"doctype\": \"GL Entry\",\n \"is_query_report\": true,\n \"label\": \"Cash Flow\",\n \"name\": \"Cash Flow\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"GL Entry\"\n ],\n \"doctype\": \"GL Entry\",\n \"is_query_report\": true,\n \"label\": \"Consolidated Financial Statement\",\n \"name\": \"Consolidated Financial Statement\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Multi Currency", - "links": "[\n {\n \"description\": \"Enable / disable currencies.\",\n \"label\": \"Currency\",\n \"name\": \"Currency\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Currency exchange rate master.\",\n \"label\": \"Currency Exchange\",\n \"name\": \"Currency Exchange\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Exchange Rate Revaluation master.\",\n \"label\": \"Exchange Rate Revaluation\",\n \"name\": \"Exchange Rate Revaluation\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"description\": \"Setup Gateway accounts.\",\n \"label\": \"Payment Gateway Account\",\n \"name\": \"Payment Gateway Account\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Template of terms or contract.\",\n \"label\": \"Terms and Conditions Template\",\n \"name\": \"Terms and Conditions\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"e.g. Bank, Cash, Credit Card\",\n \"label\": \"Mode of Payment\",\n \"name\": \"Mode of Payment\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Bank Statement", - "links": "[\n {\n \"label\": \"Bank\",\n \"name\": \"Bank\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Bank Account\",\n \"name\": \"Bank Account\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Bank Statement Transaction Entry\",\n \"name\": \"Bank Statement Transaction Entry\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Bank Statement Settings\",\n \"name\": \"Bank Statement Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Subscription Management", - "links": "[\n {\n \"label\": \"Subscription Plan\",\n \"name\": \"Subscription Plan\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Subscription\",\n \"name\": \"Subscription\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Subscription Settings\",\n \"name\": \"Subscription Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Goods and Services Tax (GST India)", - "links": "[\n {\n \"label\": \"GST Settings\",\n \"name\": \"GST Settings\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"GST HSN Code\",\n \"name\": \"GST HSN Code\",\n \"type\": \"doctype\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"GSTR-1\",\n \"name\": \"GSTR-1\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"GSTR-2\",\n \"name\": \"GSTR-2\",\n \"type\": \"report\"\n },\n {\n \"label\": \"GSTR 3B Report\",\n \"name\": \"GSTR 3B Report\",\n \"type\": \"doctype\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"GST Sales Register\",\n \"name\": \"GST Sales Register\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"GST Purchase Register\",\n \"name\": \"GST Purchase Register\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"GST Itemised Sales Register\",\n \"name\": \"GST Itemised Sales Register\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"GST Itemised Purchase Register\",\n \"name\": \"GST Itemised Purchase Register\",\n \"type\": \"report\"\n },\n {\n \"country\": \"India\",\n \"description\": \"C-Form records\",\n \"label\": \"C-Form\",\n \"name\": \"C-Form\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Share Management", - "links": "[\n {\n \"description\": \"List of available Shareholders with folio numbers\",\n \"label\": \"Shareholder\",\n \"name\": \"Shareholder\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"List of all share transactions\",\n \"label\": \"Share Transfer\",\n \"name\": \"Share Transfer\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Share Transfer\"\n ],\n \"doctype\": \"Share Transfer\",\n \"is_query_report\": true,\n \"label\": \"Share Ledger\",\n \"name\": \"Share Ledger\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Share Transfer\"\n ],\n \"doctype\": \"Share Transfer\",\n \"is_query_report\": true,\n \"label\": \"Share Balance\",\n \"name\": \"Share Balance\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Cost Center and Budgeting", - "links": "[\n {\n \"description\": \"Tree of financial Cost Centers.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Chart of Cost Centers\",\n \"name\": \"Cost Center\",\n \"route\": \"#Tree/Cost Center\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Define budget for a financial year.\",\n \"label\": \"Budget\",\n \"name\": \"Budget\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Accounting Dimension\",\n \"name\": \"Accounting Dimension\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Cost Center\"\n ],\n \"doctype\": \"Cost Center\",\n \"is_query_report\": true,\n \"label\": \"Budget Variance Report\",\n \"name\": \"Budget Variance Report\",\n \"type\": \"report\"\n },\n {\n \"description\": \"Seasonality for setting budgets, targets etc.\",\n \"label\": \"Monthly Distribution\",\n \"name\": \"Monthly Distribution\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Opening and Closing", - "links": "[\n {\n \"label\": \"Opening Invoice Creation Tool\",\n \"name\": \"Opening Invoice Creation Tool\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Chart of Accounts Importer\",\n \"name\": \"Chart of Accounts Importer\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Close Balance Sheet and book Profit or Loss.\",\n \"label\": \"Period Closing Voucher\",\n \"name\": \"Period Closing Voucher\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Taxes", - "links": "[\n {\n \"description\": \"Tax template for selling transactions.\",\n \"label\": \"Sales Taxes and Charges Template\",\n \"name\": \"Sales Taxes and Charges Template\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tax template for buying transactions.\",\n \"label\": \"Purchase Taxes and Charges Template\",\n \"name\": \"Purchase Taxes and Charges Template\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tax template for item tax rates.\",\n \"label\": \"Item Tax Template\",\n \"name\": \"Item Tax Template\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tax Category for overriding tax rates.\",\n \"label\": \"Tax Category\",\n \"name\": \"Tax Category\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tax Rule for transactions.\",\n \"label\": \"Tax Rule\",\n \"name\": \"Tax Rule\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tax Withholding rates to be applied on transactions.\",\n \"label\": \"Tax Withholding Category\",\n \"name\": \"Tax Withholding Category\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Profitability", - "links": "[\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Gross Profit\",\n \"name\": \"Gross Profit\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"GL Entry\"\n ],\n \"doctype\": \"GL Entry\",\n \"is_query_report\": true,\n \"label\": \"Profitability Analysis\",\n \"name\": \"Profitability Analysis\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Sales Invoice Trends\",\n \"name\": \"Sales Invoice Trends\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Purchase Invoice\"\n ],\n \"doctype\": \"Purchase Invoice\",\n \"is_query_report\": true,\n \"label\": \"Purchase Invoice Trends\",\n \"name\": \"Purchase Invoice Trends\",\n \"type\": \"report\"\n }\n]" - } - ], - "category": "Modules", - "charts": [ - { - "chart_name": "Profit and Loss", - "label": "Profit and Loss" - } - ], - "creation": "2020-03-02 15:41:59.515192", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Accounting", - "modified": "2020-09-09 11:45:33.766400", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Accounting", - "onboarding": "Accounts", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "label": "Chart Of Accounts", - "link_to": "Account", - "type": "DocType" - }, - { - "label": "Sales Invoice", - "link_to": "Sales Invoice", - "type": "DocType" - }, - { - "label": "Purchase Invoice", - "link_to": "Purchase Invoice", - "type": "DocType" - }, - { - "label": "Journal Entry", - "link_to": "Journal Entry", - "type": "DocType" - }, - { - "label": "Payment Entry", - "link_to": "Payment Entry", - "type": "DocType" - }, - { - "label": "Accounts Receivable", - "link_to": "Accounts Receivable", - "type": "Report" - }, - { - "label": "General Ledger", - "link_to": "General Ledger", - "type": "Report" - }, - { - "label": "Trial Balance", - "link_to": "Trial Balance", - "type": "Report" - }, - { - "label": "Dashboard", - "link_to": "Accounts", - "type": "Dashboard" - } - ] -} \ No newline at end of file diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index 2c151443d8e..c801cfcbba6 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -101,7 +101,7 @@ class Account(NestedSet): return if not frappe.db.get_value("Account", {'account_name': self.account_name, 'company': ancestors[0]}, 'name'): - frappe.throw(_("Please add the account to root level Company - %s" % ancestors[0])) + frappe.throw(_("Please add the account to root level Company - {}").format(ancestors[0])) elif self.parent_account: descendants = get_descendants_of('Company', self.company) if not descendants: return @@ -164,9 +164,19 @@ class Account(NestedSet): def create_account_for_child_company(self, parent_acc_name_map, descendants, parent_acc_name): for company in descendants: + company_bold = frappe.bold(company) + parent_acc_name_bold = frappe.bold(parent_acc_name) if not parent_acc_name_map.get(company): - frappe.throw(_("While creating account for child Company {0}, parent account {1} not found. Please create the parent account in corresponding COA") - .format(company, parent_acc_name)) + frappe.throw(_("While creating account for Child Company {0}, parent account {1} not found. Please create the parent account in corresponding COA") + .format(company_bold, parent_acc_name_bold), title=_("Account Not Found")) + + # validate if parent of child company account to be added is a group + if (frappe.db.get_value("Account", self.parent_account, "is_group") + and not frappe.db.get_value("Account", parent_acc_name_map[company], "is_group")): + msg = _("While creating account for Child Company {0}, parent account {1} found as a ledger account.").format(company_bold, parent_acc_name_bold) + msg += "

" + msg += _("Please convert the parent account in corresponding child company to a group account.") + frappe.throw(msg, title=_("Invalid Parent Account")) filters = { "account_name": self.account_name, @@ -309,8 +319,9 @@ def update_account_number(name, account_name, account_number=None, from_descenda allow_child_account_creation = _("Allow Account Creation Against Child Company") message = _("Account {0} exists in parent company {1}.").format(frappe.bold(old_acc_name), frappe.bold(ancestor)) - message += "
" + _("Renaming it is only allowed via parent company {0}, \ - to avoid mismatch.").format(frappe.bold(ancestor)) + "

" + message += "
" + message += _("Renaming it is only allowed via parent company {0}, to avoid mismatch.").format(frappe.bold(ancestor)) + message += "

" message += _("To overrule this, enable '{0}' in company {1}").format(allow_child_account_creation, frappe.bold(account.company)) frappe.throw(message, title=_("Rename Not Allowed")) diff --git a/erpnext/accounts/doctype/account/account_tree.js b/erpnext/accounts/doctype/account/account_tree.js index 28b090bdadb..7516134baf5 100644 --- a/erpnext/accounts/doctype/account/account_tree.js +++ b/erpnext/accounts/doctype/account/account_tree.js @@ -2,7 +2,7 @@ frappe.provide("frappe.treeview_settings") frappe.treeview_settings["Account"] = { breadcrumb: "Accounts", - title: __("Chart Of Accounts"), + title: __("Chart of Accounts"), get_tree_root: false, filters: [ { @@ -97,7 +97,7 @@ frappe.treeview_settings["Account"] = { treeview.page.add_inner_button(__("Journal Entry"), function() { frappe.new_doc('Journal Entry', {company: get_company()}); }, __('Create')); - treeview.page.add_inner_button(__("New Company"), function() { + treeview.page.add_inner_button(__("Company"), function() { frappe.new_doc('Company'); }, __('Create')); @@ -120,17 +120,17 @@ frappe.treeview_settings["Account"] = { } else { treeview.new_node(); } - }, "octicon octicon-plus"); + }, "add"); }, onrender: function(node) { - if(frappe.boot.user.can_read.indexOf("GL Entry") !== -1){ + if (frappe.boot.user.can_read.indexOf("GL Entry") !== -1) { // show Dr if positive since balance is calculated as debit - credit else show Cr let balance = node.data.balance_in_account_currency || node.data.balance; let dr_or_cr = balance > 0 ? "Dr": "Cr"; if (node.data && node.data.balance!==undefined) { - $('' + $('' + (node.data.balance_in_account_currency ? (format_currency(Math.abs(node.data.balance_in_account_currency), node.data.account_currency) + " / ") : "") 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 89465eedf0e..ee501f664b6 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 @@ -63,17 +63,21 @@ "Gewinnermittlung \u00a74/3 nicht Ergebniswirksam": { "account_number": "1371" }, - "Abziehbare VSt. 7%": { - "account_number": "1571" - }, - "Abziehbare VSt. 19%": { - "account_number": "1576" - }, - "Abziehbare VStr. nach \u00a713b UStG 19%": { - "account_number": "1577" - }, - "Leistungen \u00a713b UStG 19% Vorsteuer, 19% Umsatzsteuer": { - "account_number": "3120" + "Abziehbare Vorsteuer": { + "account_type": "Tax", + "is_group": 1, + "Abziehbare Vorsteuer 7%": { + "account_number": "1571" + }, + "Abziehbare Vorsteuer 19%": { + "account_number": "1576" + }, + "Abziehbare Vorsteuer nach \u00a713b UStG 19%": { + "account_number": "1577" + }, + "Leistungen \u00a713b UStG 19% Vorsteuer, 19% Umsatzsteuer": { + "account_number": "3120" + } } }, "III. Wertpapiere": { @@ -196,6 +200,7 @@ }, "Umsatzsteuer": { "is_group": 1, + "account_type": "Tax", "Umsatzsteuer 7%": { "account_number": "1771" }, diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR04.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR04.json index 7fa67081341..57e8bdd9dc7 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR04.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR04.json @@ -292,18 +292,21 @@ "Umsatzsteuerforderungen fr\u00fchere Jahre": {} }, "Sonstige Verm\u00f6gensgegenst\u00e4nde oder sonstige Verbindlichkeiten": { - "Abziehbare Vorsteuer": {}, - "Abziehbare Vorsteuer 16%": {}, - "Abziehbare Vorsteuer 19%": {}, - "Abziehbare Vorsteuer 7%": {}, - "Abziehbare Vorsteuer aus der Auslagerung von Gegenst\u00e4nden aus einem Unsatzsteuerlager": {}, - "Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb": {}, - "Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 16%": {}, - "Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 19%": {}, - "Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb von Neufahrzeugen von Lieferanten ohne Ust-Identifikationsnummer": {}, - "Abziehbare Vorsteuer nach \u00a7 13b UStG ": {}, - "Abziehbare Vorsteuer nach \u00a7 13b UStG 16%": {}, - "Abziehbare Vorsteuer nach \u00a7 13b UStG 19%": {}, + "Abziehbare Vorsteuer": { + "account_type": "Tax", + "is_group": 1, + "Abziehbare Vorsteuer 16%": {}, + "Abziehbare Vorsteuer 19%": {}, + "Abziehbare Vorsteuer 7%": {}, + "Abziehbare Vorsteuer aus der Auslagerung von Gegenst\u00e4nden aus einem Unsatzsteuerlager": {}, + "Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb": {}, + "Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 16%": {}, + "Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 19%": {}, + "Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb von Neufahrzeugen von Lieferanten ohne Ust-Identifikationsnummer": {}, + "Abziehbare Vorsteuer nach \u00a7 13b UStG ": {}, + "Abziehbare Vorsteuer nach \u00a7 13b UStG 16%": {}, + "Abziehbare Vorsteuer nach \u00a7 13b UStG 19%": {} + }, "Aufl\u00f6sung Vorsteuer aus Vorjahr \u00a7 4/3 EStG": {}, "Aufzuteilende Vorsteuer": {}, "Aufzuteilende Vorsteuer 16%": {}, @@ -673,23 +676,26 @@ "Sonstige Verrechnungskonten (Interimskonto)": { "account_type": "Stock Received But Not Billed" }, - "Umsatzsteuer": {}, - "Umsatzsteuer 16%": {}, - "Umsatzsteuer 19%": {}, - "Umsatzsteuer 7%": {}, - "Umsatzsteuer Vorjahr": {}, - "Umsatzsteuer aus der Auslagerung von Gegenst\u00e4nden aus einem Umsatzsteuerlager": {}, - "Umsatzsteuer aus im Inland steuerpflichtigen EG-Lieferungen": {}, - "Umsatzsteuer aus im Inland steuerpflichtigen EG-Lieferungen 19%": {}, - "Umsatzsteuer aus innergemeinschaftlichem Erwerb ": {}, - "Umsatzsteuer aus innergemeinschaftlichem Erwerb 16%": {}, - "Umsatzsteuer aus innergemeinschaftlichem Erwerb 19%": {}, - "Umsatzsteuer aus innergemeinschaftlichem Erwerb ohne Vorsteuerabzug": {}, - "Umsatzsteuer fr\u00fchere Jahre": {}, - "Umsatzsteuer laufendes Jahr": {}, - "Umsatzsteuer nach \u00a713b UStG": {}, - "Umsatzsteuer nach \u00a713b UStG 16%": {}, - "Umsatzsteuer nach \u00a713b UStG 19%": {}, + "Umsatzsteuer": { + "account_type": "Tax", + "is_group": 1, + "Umsatzsteuer 16%": {}, + "Umsatzsteuer 19%": {}, + "Umsatzsteuer 7%": {}, + "Umsatzsteuer Vorjahr": {}, + "Umsatzsteuer aus der Auslagerung von Gegenst\u00e4nden aus einem Umsatzsteuerlager": {}, + "Umsatzsteuer aus im Inland steuerpflichtigen EG-Lieferungen": {}, + "Umsatzsteuer aus im Inland steuerpflichtigen EG-Lieferungen 19%": {}, + "Umsatzsteuer aus innergemeinschaftlichem Erwerb ": {}, + "Umsatzsteuer aus innergemeinschaftlichem Erwerb 16%": {}, + "Umsatzsteuer aus innergemeinschaftlichem Erwerb 19%": {}, + "Umsatzsteuer aus innergemeinschaftlichem Erwerb ohne Vorsteuerabzug": {}, + "Umsatzsteuer fr\u00fchere Jahre": {}, + "Umsatzsteuer laufendes Jahr": {}, + "Umsatzsteuer nach \u00a713b UStG": {}, + "Umsatzsteuer nach \u00a713b UStG 16%": {}, + "Umsatzsteuer nach \u00a713b UStG 19%": {} + }, "Umsatzsteuer- Vorauszahlungen": {}, "Umsatzsteuer- Vorauszahlungen 1/11": {}, "Verbindlichkeiten aus Lohn- und Kirchensteuer": {} diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR04_with_account_number.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR04_with_account_number.json index 3fc109bfd67..2bf55cfcd04 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR04_with_account_number.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR04_with_account_number.json @@ -659,6 +659,7 @@ }, "Abziehbare Vorsteuer (Gruppe)": { "is_group": 1, + "account_type": "Tax", "Abziehbare Vorsteuer": { "account_number": "1400" }, @@ -910,98 +911,8 @@ }, "is_group": 1 }, - "Passiva": { + "Passiva - Verbindlichkeiten": { "root_type": "Liability", - "A - Eigenkapital": { - "account_type": "Equity", - "is_group": 1, - "I - Gezeichnetes Kapital": { - "account_type": "Equity", - "is_group": 1, - "Gezeichnetes Kapital": { - "account_type": "Equity", - "account_number": "2900" - }, - "Ausstehende Einlagen auf das gezeichnete Kapital": { - "account_number": "2910", - "is_group": 1 - } - }, - "II - Kapitalr\u00fccklage": { - "account_type": "Equity", - "is_group": 1, - "Kapitalr\u00fccklage": { - "account_number": "2920" - } - }, - "III - Gewinnr\u00fccklagen": { - "account_type": "Equity", - "1 - gesetzliche R\u00fccklage": { - "account_type": "Equity", - "is_group": 1, - "Gesetzliche R\u00fccklage": { - "account_number": "2930" - } - }, - "2 - R\u00fccklage f. Anteile an einem herrschenden oder mehrheitlich beteiligten Unternehmen": { - "account_type": "Equity", - "is_group": 1 - }, - "3 - satzungsm\u00e4\u00dfige R\u00fccklagen": { - "account_type": "Equity", - "is_group": 1, - "Satzungsm\u00e4\u00dfige R\u00fccklagen": { - "account_number": "2950" - } - }, - "4 - andere Gewinnr\u00fccklagen": { - "account_type": "Equity", - "is_group": 1, - "Gewinnr\u00fccklagen aus den \u00dcbergangsvorschriften BilMoG": { - "is_group": 1, - "Gewinnr\u00fccklagen (BilMoG)": { - "account_number": "2963" - }, - "Gewinnr\u00fccklagen aus Zuschreibung Sachanlageverm\u00f6gen (BilMoG)": { - "account_number": "2964" - }, - "Gewinnr\u00fccklagen aus Zuschreibung Finanzanlageverm\u00f6gen (BilMoG)": { - "account_number": "2965" - }, - "Gewinnr\u00fccklagen aus Aufl\u00f6sung der Sonderposten mit R\u00fccklageanteil (BilMoG)": { - "account_number": "2966" - } - }, - "Latente Steuern (Gewinnr\u00fccklage Haben) aus erfolgsneutralen Verrechnungen": { - "account_number": "2967" - }, - "Latente Steuern (Gewinnr\u00fccklage Soll) aus erfolgsneutralen Verrechnungen": { - "account_number": "2968" - }, - "Rechnungsabgrenzungsposten (Gewinnr\u00fccklage Soll) aus erfolgsneutralen Verrechnungen": { - "account_number": "2969" - } - }, - "is_group": 1 - }, - "IV - Gewinnvortrag/Verlustvortrag": { - "account_type": "Equity", - "is_group": 1, - "Gewinnvortrag vor Verwendung": { - "account_number": "2970" - }, - "Verlustvortrag vor Verwendung": { - "account_number": "2978" - } - }, - "V - Jahres\u00fcberschu\u00df/Jahresfehlbetrag": { - "account_type": "Equity", - "is_group": 1 - }, - "Einlagen stiller Gesellschafter": { - "account_number": "9295" - } - }, "B - R\u00fcckstellungen": { "is_group": 1, "1 - R\u00fcckstellungen f. Pensionen und \u00e4hnliche Verplicht.": { @@ -1618,6 +1529,143 @@ }, "is_group": 1 }, + "Passiva - Eigenkapital": { + "root_type": "Equity", + "A - Eigenkapital": { + "account_type": "Equity", + "is_group": 1, + "I - Gezeichnetes Kapital": { + "account_type": "Equity", + "is_group": 1, + "Gezeichnetes Kapital": { + "account_number": "2900", + "account_type": "Equity" + }, + "Gesch\u00e4ftsguthaben der verbleibenden Mitglieder": { + "account_number": "2901" + }, + "Gesch\u00e4ftsguthaben der ausscheidenden Mitglieder": { + "account_number": "2902" + }, + "Gesch\u00e4ftsguthaben aus gek\u00fcndigten Gesch\u00e4ftsanteilen": { + "account_number": "2903" + }, + "R\u00fcckst\u00e4ndige f\u00e4llige Einzahlungen auf Gesch\u00e4ftsanteile, vermerkt": { + "account_number": "2906" + }, + "Gegenkonto R\u00fcckst\u00e4ndige f\u00e4llige Einzahlungen auf Gesch\u00e4ftsanteile, vermerkt": { + "account_number": "2907" + }, + "Kapitalerh\u00f6hung aus Gesellschaftsmitteln": { + "account_number": "2908" + }, + "Ausstehende Einlagen auf das gezeichnete Kapital, nicht eingefordert": { + "account_number": "2910" + } + }, + "II - Kapitalr\u00fccklage": { + "account_type": "Equity", + "is_group": 1, + "Kapitalr\u00fccklage": { + "account_number": "2920" + }, + "Kapitalr\u00fccklage durch Ausgabe von Anteilen \u00fcber Nennbetrag": { + "account_number": "2925" + }, + "Kapitalr\u00fccklage durch Ausgabe von Schuldverschreibungen": { + "account_number": "2926" + }, + "Kapitalr\u00fccklage durch Zuzahlungen gegen Gew\u00e4hrung eines Vorzugs": { + "account_number": "2927" + }, + "Kapitalr\u00fccklage durch Zuzahlungen in das Eigenkapital": { + "account_number": "2928" + }, + "Nachschusskapital (Gegenkonto 1299)": { + "account_number": "2929" + } + }, + "III - Gewinnr\u00fccklagen": { + "account_type": "Equity", + "1 - gesetzliche R\u00fccklage": { + "account_type": "Equity", + "is_group": 1, + "Gesetzliche R\u00fccklage": { + "account_number": "2930" + } + }, + "2 - R\u00fccklage f. Anteile an einem herrschenden oder mehrheitlich beteiligten Unternehmen": { + "account_type": "Equity", + "is_group": 1, + "R\u00fccklage f. Anteile an einem herrschenden oder mehrheitlich beteiligten Unternehmen": { + "account_number": "2935" + } + }, + "3 - satzungsm\u00e4\u00dfige R\u00fccklagen": { + "account_type": "Equity", + "is_group": 1, + "Satzungsm\u00e4\u00dfige R\u00fccklagen": { + "account_number": "2950" + } + }, + "4 - andere Gewinnr\u00fccklagen": { + "account_type": "Equity", + "is_group": 1, + "Andere Gewinnr\u00fccklagen": { + "account_number": "2960" + }, + "Andere Gewinnr\u00fccklagen aus dem Erwerb eigener Anteile": { + "account_number": "2961" + }, + "Eigenkapitalanteil von Wertaufholungen": { + "account_number": "2962" + }, + "Gewinnr\u00fccklagen aus den \u00dcbergangsvorschriften BilMoG": { + "is_group": 1, + "Gewinnr\u00fccklagen (BilMoG)": { + "account_number": "2963" + }, + "Gewinnr\u00fccklagen aus Zuschreibung Sachanlageverm\u00f6gen (BilMoG)": { + "account_number": "2964" + }, + "Gewinnr\u00fccklagen aus Zuschreibung Finanzanlageverm\u00f6gen (BilMoG)": { + "account_number": "2965" + }, + "Gewinnr\u00fccklagen aus Aufl\u00f6sung der Sonderposten mit R\u00fccklageanteil (BilMoG)": { + "account_number": "2966" + } + }, + "Latente Steuern (Gewinnr\u00fccklage Haben) aus erfolgsneutralen Verrechnungen": { + "account_number": "2967" + }, + "Latente Steuern (Gewinnr\u00fccklage Soll) aus erfolgsneutralen Verrechnungen": { + "account_number": "2968" + }, + "Rechnungsabgrenzungsposten (Gewinnr\u00fccklage Soll) aus erfolgsneutralen Verrechnungen": { + "account_number": "2969" + } + }, + "is_group": 1 + }, + "IV - Gewinnvortrag/Verlustvortrag": { + "account_type": "Equity", + "is_group": 1, + "Gewinnvortrag vor Verwendung": { + "account_number": "2970" + }, + "Verlustvortrag vor Verwendung": { + "account_number": "2978" + } + }, + "V - Jahres\u00fcberschu\u00df/Jahresfehlbetrag": { + "account_type": "Equity", + "is_group": 1 + }, + "Einlagen stiller Gesellschafter": { + "account_number": "9295" + } + } + }, "1 - Umsatzerl\u00f6se": { "root_type": "Income", "is_group": 1, diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py index 6c83e3bd670..acb11e557a5 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py @@ -245,6 +245,9 @@ def get(): "account_number": "2200" }, _("Duties and Taxes"): { + _("TDS Payable"): { + "account_number": "2310" + }, "account_type": "Tax", "is_group": 1, "account_number": "2300" diff --git a/erpnext/accounts/doctype/account/test_account.py b/erpnext/accounts/doctype/account/test_account.py index b6a950b1b59..533eda31d58 100644 --- a/erpnext/accounts/doctype/account/test_account.py +++ b/erpnext/accounts/doctype/account/test_account.py @@ -111,6 +111,17 @@ class TestAccount(unittest.TestCase): self.assertEqual(acc_tc_4, "Test Sync Account - _TC4") self.assertEqual(acc_tc_5, "Test Sync Account - _TC5") + def test_add_account_to_a_group(self): + frappe.db.set_value("Account", "Office Rent - _TC3", "is_group", 1) + + acc = frappe.new_doc("Account") + acc.account_name = "Test Group Account" + acc.parent_account = "Office Rent - _TC3" + acc.company = "_Test Company 3" + self.assertRaises(frappe.ValidationError, acc.insert) + + frappe.db.set_value("Account", "Office Rent - _TC3", "is_group", 0) + def test_account_rename_sync(self): frappe.local.flags.pop("ignore_root_company_validation", None) @@ -160,7 +171,8 @@ class TestAccount(unittest.TestCase): for doc in to_delete: frappe.delete_doc("Account", doc) -def _make_test_records(verbose): + +def _make_test_records(verbose=None): from frappe.test_runner import make_test_objects accounts = [ @@ -242,7 +254,8 @@ def create_account(**kwargs): account_name = kwargs.get('account_name'), account_type = kwargs.get('account_type'), parent_account = kwargs.get('parent_account'), - company = kwargs.get('company') + company = kwargs.get('company'), + account_currency = kwargs.get('account_currency') )) account.save() diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js index 3c12f85f938..65c5ff1ceaf 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.js @@ -2,12 +2,11 @@ // For license information, please see license.txt frappe.ui.form.on('Accounting Dimension', { - refresh: function(frm) { frm.set_query('document_type', () => { let invalid_doctypes = frappe.model.core_doctypes_list; invalid_doctypes.push('Accounting Dimension', 'Project', - 'Cost Center', 'Accounting Dimension Detail'); + 'Cost Center', 'Accounting Dimension Detail', 'Company'); return { filters: { diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json index cf55d554fb3..5858f10bb0b 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.json @@ -30,6 +30,7 @@ "fieldtype": "Link", "label": "Reference Document Type", "options": "DocType", + "read_only_depends_on": "eval:!doc.__islocal", "reqd": 1 }, { @@ -48,7 +49,7 @@ } ], "links": [], - "modified": "2020-03-22 20:34:39.805728", + "modified": "2021-02-08 16:37:53.936656", "modified_by": "Administrator", "module": "Accounts", "name": "Accounting Dimension", diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index 8834385135f..0ebf0eb541d 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -19,7 +19,7 @@ class AccountingDimension(Document): def validate(self): if self.document_type in core_doctypes_list + ('Accounting Dimension', 'Project', - 'Cost Center', 'Accounting Dimension Detail') : + 'Cost Center', 'Accounting Dimension Detail', 'Company') : msg = _("Not allowed to create accounting dimension for {0}").format(self.document_type) frappe.throw(msg) @@ -29,15 +29,25 @@ class AccountingDimension(Document): if exists and self.is_new(): frappe.throw("Document Type already used as a dimension") + if not self.is_new(): + self.validate_document_type_change() + + def validate_document_type_change(self): + doctype_before_save = frappe.db.get_value("Accounting Dimension", self.name, "document_type") + if doctype_before_save != self.document_type: + message = _("Cannot change Reference Document Type.") + message += _("Please create a new Accounting Dimension if required.") + frappe.throw(message) + def after_insert(self): if frappe.flags.in_test: make_dimension_in_accounting_doctypes(doc=self) else: - frappe.enqueue(make_dimension_in_accounting_doctypes, doc=self) + frappe.enqueue(make_dimension_in_accounting_doctypes, doc=self, queue='long') def on_trash(self): if frappe.flags.in_test: - delete_accounting_dimension(doc=self) + delete_accounting_dimension(doc=self, queue='long') else: frappe.enqueue(delete_accounting_dimension, doc=self) @@ -48,8 +58,13 @@ class AccountingDimension(Document): if not self.fieldname: self.fieldname = scrub(self.label) -def make_dimension_in_accounting_doctypes(doc): - doclist = get_doctypes_with_dimensions() + def on_update(self): + frappe.flags.accounting_dimensions = None + +def make_dimension_in_accounting_doctypes(doc, doclist=None): + if not doclist: + doclist = get_doctypes_with_dimensions() + doc_count = len(get_accounting_dimensions()) count = 0 @@ -69,13 +84,13 @@ def make_dimension_in_accounting_doctypes(doc): "owner": "Administrator" } - if doctype == "Budget": - add_dimension_to_budget_doctype(df, doc) - else: - meta = frappe.get_meta(doctype, cached=False) - fieldnames = [d.fieldname for d in meta.get("fields")] + meta = frappe.get_meta(doctype, cached=False) + fieldnames = [d.fieldname for d in meta.get("fields")] - if df['fieldname'] not in fieldnames: + if df['fieldname'] not in fieldnames: + if doctype == "Budget": + add_dimension_to_budget_doctype(df.copy(), doc) + else: create_custom_field(doctype, df) count += 1 @@ -165,23 +180,17 @@ def toggle_disabling(doc): frappe.clear_cache(doctype=doctype) def get_doctypes_with_dimensions(): - doclist = ["GL Entry", "Sales Invoice", "Purchase Invoice", "Payment Entry", "Asset", - "Expense Claim", "Expense Claim Detail", "Expense Taxes and Charges", "Stock Entry", "Budget", "Payroll Entry", "Delivery Note", - "Sales Invoice Item", "Purchase Invoice Item", "Purchase Order Item", "Journal Entry Account", "Material Request Item", "Delivery Note Item", - "Purchase Receipt Item", "Stock Entry Detail", "Payment Entry Deduction", "Sales Taxes and Charges", "Purchase Taxes and Charges", "Shipping Rule", - "Landed Cost Item", "Asset Value Adjustment", "Loyalty Program", "Fee Schedule", "Fee Structure", "Stock Reconciliation", - "Travel Request", "Fees", "POS Profile", "Opening Invoice Creation Tool", "Opening Invoice Creation Tool Item", "Subscription", - "Subscription Plan"] - - return doclist + return frappe.get_hooks("accounting_dimension_doctypes") def get_accounting_dimensions(as_list=True): - accounting_dimensions = frappe.get_all("Accounting Dimension", fields=["label", "fieldname", "disabled", "document_type"]) + if frappe.flags.accounting_dimensions is None: + frappe.flags.accounting_dimensions = frappe.get_all("Accounting Dimension", + fields=["label", "fieldname", "disabled", "document_type"]) if as_list: - return [d.fieldname for d in accounting_dimensions] + return [d.fieldname for d in frappe.flags.accounting_dimensions] else: - return accounting_dimensions + return frappe.flags.accounting_dimensions def get_checks_for_pl_and_bs_accounts(): dimensions = frappe.db.sql("""SELECT p.label, p.disabled, p.fieldname, c.default_dimension, c.company, c.mandatory_for_pl, c.mandatory_for_bs @@ -203,7 +212,7 @@ def get_dimension_with_children(doctype, dimension): return all_dimensions @frappe.whitelist() -def get_dimension_filters(): +def get_dimensions(with_cost_center_and_project=False): dimension_filters = frappe.db.sql(""" SELECT label, fieldname, document_type FROM `tabAccounting Dimension` @@ -214,6 +223,18 @@ def get_dimension_filters(): FROM `tabAccounting Dimension Detail` c, `tabAccounting Dimension` p WHERE c.parent = p.name""", as_dict=1) + if with_cost_center_and_project: + dimension_filters.extend([ + { + 'fieldname': 'cost_center', + 'document_type': 'Cost Center' + }, + { + 'fieldname': 'project', + 'document_type': 'Project' + } + ]) + default_dimensions_map = {} for dimension in default_dimensions: default_dimensions_map.setdefault(dimension.company, {}) diff --git a/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py index 104880f6f34..fc1d7e344af 100644 --- a/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/test_accounting_dimension.py @@ -11,37 +11,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import d class TestAccountingDimension(unittest.TestCase): def setUp(self): - frappe.set_user("Administrator") - - if not frappe.db.exists("Accounting Dimension", {"document_type": "Department"}): - dimension = frappe.get_doc({ - "doctype": "Accounting Dimension", - "document_type": "Department", - }).insert() - else: - dimension1 = frappe.get_doc("Accounting Dimension", "Department") - dimension1.disabled = 0 - dimension1.save() - - if not frappe.db.exists("Accounting Dimension", {"document_type": "Location"}): - dimension1 = frappe.get_doc({ - "doctype": "Accounting Dimension", - "document_type": "Location", - }) - - dimension1.append("dimension_defaults", { - "company": "_Test Company", - "reference_document": "Location", - "default_dimension": "Block 1", - "mandatory_for_bs": 1 - }) - - dimension1.insert() - dimension1.save() - else: - dimension1 = frappe.get_doc("Accounting Dimension", "Location") - dimension1.disabled = 0 - dimension1.save() + create_dimension() def test_dimension_against_sales_invoice(self): si = create_sales_invoice(do_not_save=1) @@ -101,6 +71,38 @@ class TestAccountingDimension(unittest.TestCase): def tearDown(self): disable_dimension() +def create_dimension(): + frappe.set_user("Administrator") + + if not frappe.db.exists("Accounting Dimension", {"document_type": "Department"}): + frappe.get_doc({ + "doctype": "Accounting Dimension", + "document_type": "Department", + }).insert() + else: + dimension = frappe.get_doc("Accounting Dimension", "Department") + dimension.disabled = 0 + dimension.save() + + if not frappe.db.exists("Accounting Dimension", {"document_type": "Location"}): + dimension1 = frappe.get_doc({ + "doctype": "Accounting Dimension", + "document_type": "Location", + }) + + dimension1.append("dimension_defaults", { + "company": "_Test Company", + "reference_document": "Location", + "default_dimension": "Block 1", + "mandatory_for_bs": 1 + }) + + dimension1.insert() + dimension1.save() + else: + dimension1 = frappe.get_doc("Accounting Dimension", "Location") + dimension1.disabled = 0 + dimension1.save() def disable_dimension(): dimension1 = frappe.get_doc("Accounting Dimension", "Department") diff --git a/erpnext/accounts/doctype/bank_statement_settings/__init__.py b/erpnext/accounts/doctype/accounting_dimension_filter/__init__.py similarity index 100% rename from erpnext/accounts/doctype/bank_statement_settings/__init__.py rename to erpnext/accounts/doctype/accounting_dimension_filter/__init__.py diff --git a/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.js b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.js new file mode 100644 index 00000000000..74b7b516763 --- /dev/null +++ b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.js @@ -0,0 +1,82 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Accounting Dimension Filter', { + refresh: function(frm, cdt, cdn) { + if (frm.doc.accounting_dimension) { + frm.set_df_property('dimensions', 'label', frm.doc.accounting_dimension, cdn, 'dimension_value'); + } + + let help_content = + ` + +
+

+ + {{__('Note: On checking Is Mandatory the accounting dimension will become mandatory against that specific account for all accounting transactions')}} +

+
`; + + frm.set_df_property('dimension_filter_help', 'options', help_content); + }, + onload: function(frm) { + frm.set_query('applicable_on_account', 'accounts', function() { + return { + filters: { + 'company': frm.doc.company + } + }; + }); + + frappe.db.get_list('Accounting Dimension', + {fields: ['document_type']}).then((res) => { + let options = ['Cost Center', 'Project']; + + res.forEach((dimension) => { + options.push(dimension.document_type); + }); + + frm.set_df_property('accounting_dimension', 'options', options); + }); + + frm.trigger('setup_filters'); + }, + + setup_filters: function(frm) { + let filters = {}; + + if (frm.doc.accounting_dimension) { + frappe.model.with_doctype(frm.doc.accounting_dimension, function() { + if (frappe.model.is_tree(frm.doc.accounting_dimension)) { + filters['is_group'] = 0; + } + + if (frappe.meta.has_field(frm.doc.accounting_dimension, 'company')) { + filters['company'] = frm.doc.company; + } + + frm.set_query('dimension_value', 'dimensions', function() { + return { + filters: filters + }; + }); + }); + } + }, + + accounting_dimension: function(frm) { + frm.clear_table("dimensions"); + let row = frm.add_child("dimensions"); + row.accounting_dimension = frm.doc.accounting_dimension; + frm.refresh_field("dimensions"); + frm.trigger('setup_filters'); + }, +}); + +frappe.ui.form.on('Allowed Dimension', { + dimensions_add: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + row.accounting_dimension = frm.doc.accounting_dimension; + frm.refresh_field("dimensions"); + } +}); \ No newline at end of file diff --git a/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.json b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.json new file mode 100644 index 00000000000..0f3fbc0b8d3 --- /dev/null +++ b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.json @@ -0,0 +1,158 @@ +{ + "actions": [], + "autoname": "format:{accounting_dimension}-{#####}", + "creation": "2020-11-08 18:28:11.906146", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "accounting_dimension", + "disabled", + "column_break_2", + "company", + "allow_or_restrict", + "section_break_4", + "accounts", + "column_break_6", + "dimensions", + "section_break_10", + "dimension_filter_help" + ], + "fields": [ + { + "fieldname": "accounting_dimension", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Accounting Dimension", + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "section_break_4", + "fieldtype": "Section Break", + "hide_border": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "allow_or_restrict", + "fieldtype": "Select", + "label": "Allow Or Restrict Dimension", + "options": "Allow\nRestrict", + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "accounts", + "fieldtype": "Table", + "label": "Applicable On Account", + "options": "Applicable On Account", + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "depends_on": "eval:doc.accounting_dimension", + "fieldname": "dimensions", + "fieldtype": "Table", + "label": "Applicable Dimension", + "options": "Allowed Dimension", + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled", + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "dimension_filter_help", + "fieldtype": "HTML", + "label": "Dimension Filter Help", + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "section_break_10", + "fieldtype": "Section Break", + "show_days": 1, + "show_seconds": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-02-03 12:04:58.678402", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Accounting Dimension Filter", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.py b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.py new file mode 100644 index 00000000000..6aef9caa747 --- /dev/null +++ b/erpnext/accounts/doctype/accounting_dimension_filter/accounting_dimension_filter.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Copyright, (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _, scrub +from frappe.model.document import Document + +class AccountingDimensionFilter(Document): + def validate(self): + self.validate_applicable_accounts() + + def validate_applicable_accounts(self): + accounts = frappe.db.sql( + """ + SELECT a.applicable_on_account as account + FROM `tabApplicable On Account` a, `tabAccounting Dimension Filter` d + WHERE d.name = a.parent + and d.name != %s + and d.accounting_dimension = %s + """, (self.name, self.accounting_dimension), as_dict=1) + + account_list = [d.account for d in accounts] + + for account in self.get('accounts'): + if account.applicable_on_account in account_list: + frappe.throw(_("Row {0}: {1} account already applied for Accounting Dimension {2}").format( + account.idx, frappe.bold(account.applicable_on_account), frappe.bold(self.accounting_dimension))) + +def get_dimension_filter_map(): + filters = frappe.db.sql(""" + SELECT + a.applicable_on_account, d.dimension_value, p.accounting_dimension, + p.allow_or_restrict, a.is_mandatory + FROM + `tabApplicable On Account` a, `tabAllowed Dimension` d, + `tabAccounting Dimension Filter` p + WHERE + p.name = a.parent + AND p.disabled = 0 + AND p.name = d.parent + """, as_dict=1) + + dimension_filter_map = {} + + for f in filters: + f.fieldname = scrub(f.accounting_dimension) + + build_map(dimension_filter_map, f.fieldname, f.applicable_on_account, f.dimension_value, + f.allow_or_restrict, f.is_mandatory) + + return dimension_filter_map + +def build_map(map_object, dimension, account, filter_value, allow_or_restrict, is_mandatory): + map_object.setdefault((dimension, account), { + 'allowed_dimensions': [], + 'is_mandatory': is_mandatory, + 'allow_or_restrict': allow_or_restrict + }) + map_object[(dimension, account)]['allowed_dimensions'].append(filter_value) diff --git a/erpnext/accounts/doctype/accounting_dimension_filter/test_accounting_dimension_filter.py b/erpnext/accounts/doctype/accounting_dimension_filter/test_accounting_dimension_filter.py new file mode 100644 index 00000000000..7877abd0263 --- /dev/null +++ b/erpnext/accounts/doctype/accounting_dimension_filter/test_accounting_dimension_filter.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import create_dimension, disable_dimension +from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError + +class TestAccountingDimensionFilter(unittest.TestCase): + def setUp(self): + create_dimension() + create_accounting_dimension_filter() + self.invoice_list = [] + + def test_allowed_dimension_validation(self): + si = create_sales_invoice(do_not_save=1) + si.items[0].cost_center = 'Main - _TC' + si.department = 'Accounts - _TC' + si.location = 'Block 1' + si.save() + + self.assertRaises(InvalidAccountDimensionError, si.submit) + self.invoice_list.append(si) + + def test_mandatory_dimension_validation(self): + si = create_sales_invoice(do_not_save=1) + si.department = '' + si.location = 'Block 1' + + # Test with no department for Sales Account + si.items[0].department = '' + si.items[0].cost_center = '_Test Cost Center 2 - _TC' + si.save() + + self.assertRaises(MandatoryAccountDimensionError, si.submit) + self.invoice_list.append(si) + + def tearDown(self): + disable_dimension_filter() + disable_dimension() + + for si in self.invoice_list: + si.load_from_db() + if si.docstatus == 1: + si.cancel() + +def create_accounting_dimension_filter(): + if not frappe.db.get_value('Accounting Dimension Filter', + {'accounting_dimension': 'Cost Center'}): + frappe.get_doc({ + 'doctype': 'Accounting Dimension Filter', + 'accounting_dimension': 'Cost Center', + 'allow_or_restrict': 'Allow', + 'company': '_Test Company', + 'accounts': [{ + 'applicable_on_account': 'Sales - _TC', + }], + 'dimensions': [{ + 'accounting_dimension': 'Cost Center', + 'dimension_value': '_Test Cost Center 2 - _TC' + }] + }).insert() + else: + doc = frappe.get_doc('Accounting Dimension Filter', {'accounting_dimension': 'Cost Center'}) + doc.disabled = 0 + doc.save() + + if not frappe.db.get_value('Accounting Dimension Filter', + {'accounting_dimension': 'Department'}): + frappe.get_doc({ + 'doctype': 'Accounting Dimension Filter', + 'accounting_dimension': 'Department', + 'allow_or_restrict': 'Allow', + 'company': '_Test Company', + 'accounts': [{ + 'applicable_on_account': 'Sales - _TC', + 'is_mandatory': 1 + }], + 'dimensions': [{ + 'accounting_dimension': 'Department', + 'dimension_value': 'Accounts - _TC' + }] + }).insert() + else: + doc = frappe.get_doc('Accounting Dimension Filter', {'accounting_dimension': 'Department'}) + doc.disabled = 0 + doc.save() + +def disable_dimension_filter(): + doc = frappe.get_doc('Accounting Dimension Filter', {'accounting_dimension': 'Cost Center'}) + doc.disabled = 1 + doc.save() + + doc = frappe.get_doc('Accounting Dimension Filter', {'accounting_dimension': 'Department'}) + doc.disabled = 1 + doc.save() diff --git a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py index 022d7a7e804..10cd9398942 100644 --- a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py +++ b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py @@ -11,36 +11,36 @@ from erpnext.accounts.doctype.accounting_period.accounting_period import Overlap from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice class TestAccountingPeriod(unittest.TestCase): - def test_overlap(self): - ap1 = create_accounting_period(start_date = "2018-04-01", - end_date = "2018-06-30", company = "Wind Power LLC") - ap1.save() + def test_overlap(self): + ap1 = create_accounting_period(start_date = "2018-04-01", + end_date = "2018-06-30", company = "Wind Power LLC") + ap1.save() - ap2 = create_accounting_period(start_date = "2018-06-30", - end_date = "2018-07-10", company = "Wind Power LLC", period_name = "Test Accounting Period 1") - self.assertRaises(OverlapError, ap2.save) + ap2 = create_accounting_period(start_date = "2018-06-30", + end_date = "2018-07-10", company = "Wind Power LLC", period_name = "Test Accounting Period 1") + self.assertRaises(OverlapError, ap2.save) - def test_accounting_period(self): - ap1 = create_accounting_period(period_name = "Test Accounting Period 2") - ap1.save() + def test_accounting_period(self): + ap1 = create_accounting_period(period_name = "Test Accounting Period 2") + ap1.save() - doc = create_sales_invoice(do_not_submit=1, cost_center = "_Test Company - _TC", warehouse = "Stores - _TC") - self.assertRaises(ClosedAccountingPeriod, doc.submit) + doc = create_sales_invoice(do_not_submit=1, cost_center="_Test Company - _TC", warehouse="Stores - _TC") + self.assertRaises(ClosedAccountingPeriod, doc.submit) - def tearDown(self): - for d in frappe.get_all("Accounting Period"): - frappe.delete_doc("Accounting Period", d.name) + def tearDown(self): + for d in frappe.get_all("Accounting Period"): + frappe.delete_doc("Accounting Period", d.name) def create_accounting_period(**args): - args = frappe._dict(args) + args = frappe._dict(args) - accounting_period = frappe.new_doc("Accounting Period") - accounting_period.start_date = args.start_date or nowdate() - accounting_period.end_date = args.end_date or add_months(nowdate(), 1) - accounting_period.company = args.company or "_Test Company" - accounting_period.period_name =args.period_name or "_Test_Period_Name_1" - accounting_period.append("closed_documents", { - "document_type": 'Sales Invoice', "closed": 1 - }) + accounting_period = frappe.new_doc("Accounting Period") + accounting_period.start_date = args.start_date or nowdate() + accounting_period.end_date = args.end_date or add_months(nowdate(), 1) + accounting_period.company = args.company or "_Test Company" + accounting_period.period_name =args.period_name or "_Test_Period_Name_1" + accounting_period.append("closed_documents", { + "document_type": 'Sales Invoice', "closed": 1 + }) - return accounting_period \ No newline at end of file + return accounting_period diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.js b/erpnext/accounts/doctype/accounts_settings/accounts_settings.js index 0627675de79..541901c9abf 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.js +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.js @@ -6,3 +6,46 @@ frappe.ui.form.on('Accounts Settings', { } }); + +frappe.tour['Accounts Settings'] = [ + { + fieldname: "acc_frozen_upto", + title: "Accounts Frozen Upto", + description: __("Freeze accounting transactions up to specified date, nobody can make/modify entry except the specified Role."), + }, + { + fieldname: "frozen_accounts_modifier", + title: "Role Allowed to Set Frozen Accounts & Edit Frozen Entries", + description: __("Users with this Role are allowed to set frozen accounts and create/modify accounting entries against frozen accounts.") + }, + { + fieldname: "determine_address_tax_category_from", + title: "Determine Address Tax Category From", + description: __("Tax category can be set on Addresses. An address can be Shipping or Billing address. Set which addres to select when applying Tax Category.") + }, + { + fieldname: "over_billing_allowance", + title: "Over Billing Allowance Percentage", + description: __("The percentage by which you can overbill transactions. For example, if the order value is $100 for an Item and percentage here is set as 10% then you are allowed to bill for $110.") + }, + { + fieldname: "credit_controller", + title: "Credit Controller", + description: __("Select the role that is allowed to submit transactions that exceed credit limits set. The credit limit can be set in the Customer form.") + }, + { + fieldname: "make_payment_via_journal_entry", + title: "Make Payment via Journal Entry", + description: __("When checked, if user proceeds to make payment from an invoice, the system will open a Journal Entry instead of a Payment Entry.") + }, + { + fieldname: "unlink_payment_on_cancellation_of_invoice", + title: "Unlink Payment on Cancellation of Invoice", + description: __("If checked, system will unlink the payment against the respective invoice.") + }, + { + fieldname: "unlink_advance_payment_on_cancelation_of_order", + title: "Unlink Advance Payment on Cancellation of Order", + description: __("Similar to the previous option, this unlinks any advance payments made against Purchase/Sales Orders.") + } +]; \ No newline at end of file diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index b2e8b090c7c..a3c29b6d640 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -21,6 +21,7 @@ "book_asset_depreciation_entry_automatically", "add_taxes_from_item_tax_template", "automatically_fetch_payment_terms", + "delete_linked_ledger_entries", "deferred_accounting_settings_section", "automatically_process_deferred_accounting_entry", "book_deferred_entries_based_on", @@ -40,7 +41,7 @@ "fields": [ { "default": "1", - "description": "If enabled, the system will post accounting entries for inventory automatically.", + "description": "If enabled, the system will post accounting entries for inventory automatically", "fieldname": "auto_accounting_for_stock", "fieldtype": "Check", "hidden": 1, @@ -48,23 +49,23 @@ "label": "Make Accounting Entry For Every Stock Movement" }, { - "description": "Accounting entry frozen up to this date, nobody can do / modify entry except role specified below.", + "description": "Accounting entries are frozen up to this date. Nobody can create or modify entries except users with the role specified below", "fieldname": "acc_frozen_upto", "fieldtype": "Date", "in_list_view": 1, - "label": "Accounts Frozen Upto" + "label": "Accounts Frozen Till Date" }, { "description": "Users with this role are allowed to set frozen accounts and create / modify accounting entries against frozen accounts", "fieldname": "frozen_accounts_modifier", "fieldtype": "Link", "in_list_view": 1, - "label": "Role Allowed to Set Frozen Accounts & Edit Frozen Entries", + "label": "Role Allowed to Set Frozen Accounts and Edit Frozen Entries", "options": "Role" }, { "default": "Billing Address", - "description": "Address used to determine Tax Category in transactions.", + "description": "Address used to determine Tax Category in transactions", "fieldname": "determine_address_tax_category_from", "fieldtype": "Select", "label": "Determine Address Tax Category From", @@ -75,7 +76,7 @@ "fieldtype": "Column Break" }, { - "description": "Role that is allowed to submit transactions that exceed credit limits set.", + "description": "This role is allowed to submit transactions that exceed credit limits", "fieldname": "credit_controller", "fieldtype": "Link", "in_list_view": 1, @@ -104,7 +105,7 @@ "default": "1", "fieldname": "unlink_advance_payment_on_cancelation_of_order", "fieldtype": "Check", - "label": "Unlink Advance Payment on Cancelation of Order" + "label": "Unlink Advance Payment on Cancellation of Order" }, { "default": "1", @@ -127,7 +128,7 @@ "default": "0", "fieldname": "show_inclusive_tax_in_print", "fieldtype": "Check", - "label": "Show Inclusive Tax In Print" + "label": "Show Inclusive Tax in Print" }, { "fieldname": "column_break_12", @@ -165,7 +166,7 @@ }, { "default": "0", - "description": "Only select if you have setup Cash Flow Mapper documents", + "description": "Only select this if you have set up the Cash Flow Mapper documents", "fieldname": "use_custom_cash_flow", "fieldtype": "Check", "label": "Use Custom Cash Flow Format" @@ -177,7 +178,7 @@ "label": "Automatically Fetch Payment Terms" }, { - "description": "Percentage you are allowed to bill more against the amount ordered. For example: If the order value is $100 for an item and tolerance is set as 10% then you are allowed to bill for $110.", + "description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ", "fieldname": "over_billing_allowance", "fieldtype": "Currency", "label": "Over Billing Allowance (%)" @@ -199,7 +200,7 @@ }, { "default": "0", - "description": "If this is unchecked direct GL Entries will be created to book Deferred Revenue/Expense", + "description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense", "fieldname": "book_deferred_entries_via_journal_entry", "fieldtype": "Check", "label": "Book Deferred Entries Via Journal Entry" @@ -214,18 +215,25 @@ }, { "default": "Days", - "description": "If \"Months\" is selected then fixed amount will be booked as deferred revenue or expense for each month irrespective of number of days in a month. Will be prorated if deferred revenue or expense is not booked for an entire month.", + "description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month", "fieldname": "book_deferred_entries_based_on", "fieldtype": "Select", "label": "Book Deferred Entries Based On", "options": "Days\nMonths" + }, + { + "default": "0", + "fieldname": "delete_linked_ledger_entries", + "fieldtype": "Check", + "label": "Delete Accounting and Stock Ledger Entries on deletion of Transaction" } ], "icon": "icon-cog", "idx": 1, + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-08-03 20:13:26.043092", + "modified": "2021-01-05 13:04:00.118892", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/bank_statement_settings_item/__init__.py b/erpnext/accounts/doctype/allowed_dimension/__init__.py similarity index 100% rename from erpnext/accounts/doctype/bank_statement_settings_item/__init__.py rename to erpnext/accounts/doctype/allowed_dimension/__init__.py diff --git a/erpnext/accounts/doctype/allowed_dimension/allowed_dimension.json b/erpnext/accounts/doctype/allowed_dimension/allowed_dimension.json new file mode 100644 index 00000000000..7fe2a3c647e --- /dev/null +++ b/erpnext/accounts/doctype/allowed_dimension/allowed_dimension.json @@ -0,0 +1,43 @@ +{ + "actions": [], + "creation": "2020-11-08 18:22:36.001131", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "accounting_dimension", + "dimension_value" + ], + "fields": [ + { + "fieldname": "accounting_dimension", + "fieldtype": "Link", + "label": "Accounting Dimension", + "options": "DocType", + "read_only": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "dimension_value", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "options": "accounting_dimension", + "show_days": 1, + "show_seconds": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-11-23 09:56:19.744200", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Allowed Dimension", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_transaction_invoice_item/bank_statement_transaction_invoice_item.py b/erpnext/accounts/doctype/allowed_dimension/allowed_dimension.py similarity index 56% rename from erpnext/accounts/doctype/bank_statement_transaction_invoice_item/bank_statement_transaction_invoice_item.py rename to erpnext/accounts/doctype/allowed_dimension/allowed_dimension.py index cb1b15815fb..c2afc1a2621 100644 --- a/erpnext/accounts/doctype/bank_statement_transaction_invoice_item/bank_statement_transaction_invoice_item.py +++ b/erpnext/accounts/doctype/allowed_dimension/allowed_dimension.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2017, sathishpy@gmail.com and contributors +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt from __future__ import unicode_literals -import frappe +# import frappe from frappe.model.document import Document -class BankStatementTransactionInvoiceItem(Document): +class AllowedDimension(Document): pass diff --git a/erpnext/accounts/doctype/bank_statement_transaction_entry/__init__.py b/erpnext/accounts/doctype/applicable_on_account/__init__.py similarity index 100% rename from erpnext/accounts/doctype/bank_statement_transaction_entry/__init__.py rename to erpnext/accounts/doctype/applicable_on_account/__init__.py diff --git a/erpnext/accounts/doctype/applicable_on_account/applicable_on_account.json b/erpnext/accounts/doctype/applicable_on_account/applicable_on_account.json new file mode 100644 index 00000000000..95e98d0b673 --- /dev/null +++ b/erpnext/accounts/doctype/applicable_on_account/applicable_on_account.json @@ -0,0 +1,46 @@ +{ + "actions": [], + "creation": "2020-11-08 18:20:00.944449", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "applicable_on_account", + "is_mandatory" + ], + "fields": [ + { + "fieldname": "applicable_on_account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Accounts", + "options": "Account", + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "columns": 2, + "default": "0", + "fieldname": "is_mandatory", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Mandatory", + "show_days": 1, + "show_seconds": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-11-22 19:55:13.324136", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Applicable On Account", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_transaction_payment_item/bank_statement_transaction_payment_item.py b/erpnext/accounts/doctype/applicable_on_account/applicable_on_account.py similarity index 56% rename from erpnext/accounts/doctype/bank_statement_transaction_payment_item/bank_statement_transaction_payment_item.py rename to erpnext/accounts/doctype/applicable_on_account/applicable_on_account.py index 9840c0dbe37..0fccaf302fb 100644 --- a/erpnext/accounts/doctype/bank_statement_transaction_payment_item/bank_statement_transaction_payment_item.py +++ b/erpnext/accounts/doctype/applicable_on_account/applicable_on_account.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2017, sathishpy@gmail.com and contributors +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt from __future__ import unicode_literals -import frappe +# import frappe from frappe.model.document import Document -class BankStatementTransactionPaymentItem(Document): +class ApplicableOnAccount(Document): pass diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js index de9498e0752..49b2b186c4b 100644 --- a/erpnext/accounts/doctype/bank/bank.js +++ b/erpnext/accounts/doctype/bank/bank.js @@ -1,5 +1,6 @@ // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +frappe.provide('erpnext.integrations'); frappe.ui.form.on('Bank', { onload: function(frm) { @@ -20,7 +21,12 @@ frappe.ui.form.on('Bank', { frm.set_df_property('address_and_contact', 'hidden', 0); frappe.contacts.render_address_and_contact(frm); } - }, + if (frm.doc.plaid_access_token) { + frm.add_custom_button(__('Refresh Plaid Link'), () => { + new erpnext.integrations.refreshPlaidLink(frm.doc.plaid_access_token); + }); + } + } }); @@ -40,4 +46,79 @@ let add_fields_to_mapping_table = function (frm) { frm.doc.name).options = options; frm.fields_dict.bank_transaction_mapping.grid.refresh(); -}; \ No newline at end of file +}; + +erpnext.integrations.refreshPlaidLink = class refreshPlaidLink { + constructor(access_token) { + this.access_token = access_token; + this.plaidUrl = 'https://cdn.plaid.com/link/v2/stable/link-initialize.js'; + this.init_config(); + } + + async init_config() { + this.plaid_env = await frappe.db.get_single_value('Plaid Settings', 'plaid_env'); + this.token = await this.get_link_token_for_update(); + this.init_plaid(); + } + + async get_link_token_for_update() { + const token = frappe.xcall( + 'erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.get_link_token_for_update', + { access_token: this.access_token } + ) + if (!token) { + frappe.throw(__('Cannot retrieve link token for update. Check Error Log for more information')); + } + return token; + } + + init_plaid() { + const me = this; + me.loadScript(me.plaidUrl) + .then(() => { + me.onScriptLoaded(me); + }) + .then(() => { + if (me.linkHandler) { + me.linkHandler.open(); + } + }) + .catch((error) => { + me.onScriptError(error); + }); + } + + loadScript(src) { + return new Promise(function (resolve, reject) { + if (document.querySelector("script[src='" + src + "']")) { + resolve(); + return; + } + const el = document.createElement('script'); + el.type = 'text/javascript'; + el.async = true; + el.src = src; + el.addEventListener('load', resolve); + el.addEventListener('error', reject); + el.addEventListener('abort', reject); + document.head.appendChild(el); + }); + } + + onScriptLoaded(me) { + me.linkHandler = Plaid.create({ + env: me.plaid_env, + token: me.token, + onSuccess: me.plaid_success + }); + } + + onScriptError(error) { + frappe.msgprint(__("There was an issue connecting to Plaid's authentication server. Check browser console for more information")); + console.log(error); + } + + plaid_success(token, response) { + frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + } +}; diff --git a/erpnext/accounts/doctype/bank_account/bank_account.json b/erpnext/accounts/doctype/bank_account/bank_account.json index b42f1f9d583..de67ab1ce5d 100644 --- a/erpnext/accounts/doctype/bank_account/bank_account.json +++ b/erpnext/accounts/doctype/bank_account/bank_account.json @@ -86,6 +86,7 @@ }, { "default": "0", + "description": "Setting the account as a Company Account is necessary for Bank Reconciliation", "fieldname": "is_company_account", "fieldtype": "Check", "label": "Is Company Account" @@ -207,7 +208,7 @@ } ], "links": [], - "modified": "2020-07-17 13:59:50.795412", + "modified": "2020-10-23 16:48:06.303658", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Account", diff --git a/erpnext/accounts/doctype/bank_statement_transaction_invoice_item/__init__.py b/erpnext/accounts/doctype/bank_reconciliation_tool/__init__.py similarity index 100% rename from erpnext/accounts/doctype/bank_statement_transaction_invoice_item/__init__.py rename to erpnext/accounts/doctype/bank_reconciliation_tool/__init__.py diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js new file mode 100644 index 00000000000..10f660a140b --- /dev/null +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js @@ -0,0 +1,163 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +frappe.provide("erpnext.accounts.bank_reconciliation"); + +frappe.ui.form.on("Bank Reconciliation Tool", { + setup: function (frm) { + frm.set_query("bank_account", function () { + return { + filters: { + company: ["in", frm.doc.company], + 'is_company_account': 1 + }, + }; + }); + }, + + refresh: function (frm) { + frappe.require("assets/js/bank-reconciliation-tool.min.js", () => + frm.trigger("make_reconciliation_tool") + ); + frm.upload_statement_button = frm.page.set_secondary_action( + __("Upload Bank Statement"), + () => + frappe.call({ + method: + "erpnext.accounts.doctype.bank_statement_import.bank_statement_import.upload_bank_statement", + args: { + dt: frm.doc.doctype, + dn: frm.doc.name, + company: frm.doc.company, + bank_account: frm.doc.bank_account, + }, + callback: function (r) { + if (!r.exc) { + var doc = frappe.model.sync(r.message); + frappe.set_route( + "Form", + doc[0].doctype, + doc[0].name + ); + } + }, + }) + ); + }, + + after_save: function (frm) { + frm.trigger("make_reconciliation_tool"); + }, + + bank_account: function (frm) { + frappe.db.get_value( + "Bank Account", + frm.bank_account, + "account", + (r) => { + frappe.db.get_value( + "Account", + r.account, + "account_currency", + (r) => { + frm.currency = r.account_currency; + } + ); + } + ); + frm.trigger("get_account_opening_balance"); + }, + + bank_statement_from_date: function (frm) { + frm.trigger("get_account_opening_balance"); + }, + + make_reconciliation_tool(frm) { + frm.get_field("reconciliation_tool_cards").$wrapper.empty(); + if (frm.doc.bank_account && frm.doc.bank_statement_to_date) { + frm.trigger("get_cleared_balance").then(() => { + if ( + frm.doc.bank_account && + frm.doc.bank_statement_from_date && + frm.doc.bank_statement_to_date && + frm.doc.bank_statement_closing_balance + ) { + frm.trigger("render_chart"); + frm.trigger("render"); + frappe.utils.scroll_to( + frm.get_field("reconciliation_tool_cards").$wrapper, + true, + 30 + ); + } + }); + } + }, + + get_account_opening_balance(frm) { + if (frm.doc.bank_account && frm.doc.bank_statement_from_date) { + frappe.call({ + method: + "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance", + args: { + bank_account: frm.doc.bank_account, + till_date: frm.doc.bank_statement_from_date, + }, + callback: (response) => { + frm.set_value("account_opening_balance", response.message); + }, + }); + } + }, + + get_cleared_balance(frm) { + if (frm.doc.bank_account && frm.doc.bank_statement_to_date) { + return frappe.call({ + method: + "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance", + args: { + bank_account: frm.doc.bank_account, + till_date: frm.doc.bank_statement_to_date, + }, + callback: (response) => { + frm.cleared_balance = response.message; + }, + }); + } + }, + + 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, + cleared_balance: frm.cleared_balance, + currency: frm.currency, + } + ); + }, + + render(frm) { + if (frm.doc.bank_account) { + frm.bank_reconciliation_data_table_manager = new erpnext.accounts.bank_reconciliation.DataTableManager( + { + company: frm.doc.company, + bank_account: frm.doc.bank_account, + $reconciliation_tool_dt: frm.get_field( + "reconciliation_tool_dt" + ).$wrapper, + $no_bank_transactions: frm.get_field( + "no_bank_transactions" + ).$wrapper, + bank_statement_from_date: frm.doc.bank_statement_from_date, + bank_statement_to_date: frm.doc.bank_statement_to_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 new file mode 100644 index 00000000000..4837db3b867 --- /dev/null +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.json @@ -0,0 +1,113 @@ +{ + "actions": [], + "creation": "2020-12-02 10:13:02.148040", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "bank_account", + "column_break_1", + "bank_statement_from_date", + "bank_statement_to_date", + "column_break_2", + "account_opening_balance", + "bank_statement_closing_balance", + "section_break_1", + "reconciliation_tool_cards", + "reconciliation_tool_dt", + "no_bank_transactions" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "bank_account", + "fieldtype": "Link", + "label": "Bank Account", + "options": "Bank Account" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.bank_account", + "fieldname": "bank_statement_from_date", + "fieldtype": "Date", + "label": "Bank Statement From Date" + }, + { + "depends_on": "eval: doc.bank_statement_from_date", + "fieldname": "bank_statement_to_date", + "fieldtype": "Date", + "label": "Bank Statement To Date" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.bank_statement_from_date", + "fieldname": "account_opening_balance", + "fieldtype": "Currency", + "label": "Account Opening Balance", + "options": "Currency", + "read_only": 1 + }, + { + "depends_on": "eval: doc.bank_statement_to_date", + "fieldname": "bank_statement_closing_balance", + "fieldtype": "Currency", + "label": "Bank Statement Closing Balance", + "options": "Currency" + }, + { + "depends_on": "eval: doc.bank_statement_closing_balance", + "fieldname": "section_break_1", + "fieldtype": "Section Break", + "label": "Reconcile" + }, + { + "fieldname": "reconciliation_tool_cards", + "fieldtype": "HTML" + }, + { + "fieldname": "reconciliation_tool_dt", + "fieldtype": "HTML" + }, + { + "fieldname": "no_bank_transactions", + "fieldtype": "HTML", + "options": "
No Matching Bank Transactions Found
" + } + ], + "hide_toolbar": 1, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2021-02-02 01:35:53.043578", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Bank Reconciliation Tool", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC" +} \ 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 new file mode 100644 index 00000000000..8a17233cf74 --- /dev/null +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -0,0 +1,452 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import json + +import frappe +from frappe.model.document import Document +from frappe import _ +from frappe.utils import flt + +from erpnext import get_company_currency +from erpnext.accounts.utils import get_balance_on +from erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_statement import get_entries, get_amounts_not_reflected_in_system +from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_paid_amount + + +class BankReconciliationTool(Document): + pass + +@frappe.whitelist() +def get_bank_transactions(bank_account, from_date = None, to_date = None): + # returns bank transactions for a bank account + filters = [] + filters.append(['bank_account', '=', bank_account]) + filters.append(['docstatus', '=', 1]) + filters.append(['unallocated_amount', '>', 0]) + if to_date: + filters.append(['date', '<=', to_date]) + if from_date: + filters.append(['date', '>=', from_date]) + transactions = frappe.get_all( + 'Bank Transaction', + fields = ['date', 'deposit', 'withdrawal', 'currency', + 'description', 'name', 'bank_account', 'company', + 'unallocated_amount', 'reference_number', 'party_type', 'party'], + filters = filters + ) + return transactions + +@frappe.whitelist() +def get_account_balance(bank_account, till_date): + # returns account balance till the specified date + account = frappe.db.get_value('Bank Account', bank_account, 'account') + filters = frappe._dict({ + "account": account, + "report_date": till_date, + "include_pos_transactions": 1 + }) + data = get_entries(filters) + + balance_as_per_system = get_balance_on(filters["account"], filters["report_date"]) + + total_debit, total_credit = 0,0 + for d in data: + total_debit += flt(d.debit) + total_credit += flt(d.credit) + + amounts_not_reflected_in_system = get_amounts_not_reflected_in_system(filters) + + bank_bal = flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) \ + + amounts_not_reflected_in_system + + return bank_bal + + +@frappe.whitelist() +def update_bank_transaction(bank_transaction_name, reference_number, party_type=None, party=None): + # updates bank transaction based on the new parameters provided by the user from Vouchers + bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) + bank_transaction.reference_number = reference_number + bank_transaction.party_type = party_type + bank_transaction.party = party + bank_transaction.save() + return frappe.db.get_all('Bank Transaction', + filters={ + 'name': bank_transaction_name + }, + fields=['date', 'deposit', 'withdrawal', 'currency', + 'description', 'name', 'bank_account', 'company', + 'unallocated_amount', 'reference_number', + 'party_type', 'party'], + )[0] + + +@frappe.whitelist() +def create_journal_entry_bts( bank_transaction_name, reference_number=None, reference_date=None, posting_date=None, entry_type=None, + second_account=None, mode_of_payment=None, party_type=None, party=None, allow_edit=None): + # Create a new journal entry based on the bank transaction + bank_transaction = frappe.db.get_values( + "Bank Transaction", bank_transaction_name, + fieldname=["name", "deposit", "withdrawal", "bank_account"] , + as_dict=True + )[0] + company_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account") + account_type = frappe.db.get_value("Account", second_account, "account_type") + if account_type in ["Receivable", "Payable"]: + if not (party_type and party): + frappe.throw(_("Party Type and Party is required for Receivable / Payable account {0}").format( second_account)) + accounts = [] + # Multi Currency? + 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, + "party_type":party_type, + "party":party, + }) + + accounts.append({ + "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, + }) + + company = frappe.get_value("Account", company_account, "company") + + journal_entry_dict = { + "voucher_type" : entry_type, + "company" : company, + "posting_date" : posting_date, + "cheque_date" : reference_date, + "cheque_no" : reference_number, + "mode_of_payment" : mode_of_payment + } + journal_entry = frappe.new_doc('Journal Entry') + journal_entry.update(journal_entry_dict) + journal_entry.set("accounts", accounts) + + + if allow_edit: + return journal_entry + + journal_entry.insert() + journal_entry.submit() + + if bank_transaction.deposit > 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}]) + + return reconcile_vouchers(bank_transaction.name, vouchers) + +@frappe.whitelist() +def create_payment_entry_bts( bank_transaction_name, reference_number=None, reference_date=None, party_type=None, party=None, posting_date=None, + mode_of_payment=None, project=None, cost_center=None, allow_edit=None): + # Create a new payment entry based on the bank transaction + bank_transaction = frappe.db.get_values( + "Bank Transaction", bank_transaction_name, + fieldname=["name", "unallocated_amount", "deposit", "bank_account"] , + as_dict=True + )[0] + paid_amount = bank_transaction.unallocated_amount + payment_type = "Receive" if bank_transaction.deposit > 0 else "Pay" + + company_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account") + company = frappe.get_value("Account", company_account, "company") + payment_entry_dict = { + "company" : company, + "payment_type" : payment_type, + "reference_no" : reference_number, + "reference_date" : reference_date, + "party_type" : party_type, + "party" : party, + "posting_date" : posting_date, + "paid_amount": paid_amount, + "received_amount": paid_amount + } + payment_entry = frappe.new_doc("Payment Entry") + + + payment_entry.update(payment_entry_dict) + + if mode_of_payment: + payment_entry.mode_of_payment = mode_of_payment + if project: + payment_entry.project = project + if cost_center: + payment_entry.cost_center = cost_center + if payment_type == "Receive": + payment_entry.paid_to = company_account + else: + payment_entry.paid_from = company_account + + payment_entry.validate() + + if allow_edit: + return payment_entry + + payment_entry.insert() + + payment_entry.submit() + vouchers = json.dumps([{ + "payment_doctype":"Payment Entry", + "payment_name":payment_entry.name, + "amount":paid_amount}]) + 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) + 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({ + 'payment_document': voucher['payment_doctype'], + 'payment_entry': voucher['payment_name'], + }), transaction.currency) + + 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")) + account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") + + 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_entry'].doctype, + "payment_entry": voucher['payment_entry'].name, + "allocated_amount": allocated_amount + }) + + transaction.save() + transaction.update_allocations() + return frappe.get_doc("Bank Transaction", bank_transaction_name) + +@frappe.whitelist() +def get_linked_payments(bank_transaction_name, document_types = 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 + +def check_matching(bank_account, company, transaction, document_types): + # combine all types of vocuhers + subquery = get_queries(bank_account, company, transaction, document_types) + filters = { + "amount": transaction.unallocated_amount, + "payment_type" : "Receive" if transaction.deposit > 0 else "Pay", + "reference_no": transaction.reference_number, + "party_type": transaction.party_type, + "party": transaction.party, + "bank_account": bank_account + } + + matching_vouchers = [] + for query in subquery: + matching_vouchers.extend( + frappe.db.sql(query, 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): + # 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" + 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]) + + if "journal_entry" in document_types: + je_amount_matching = get_je_matching_query(amount_condition, transaction) + queries.extend([je_amount_matching]) + + 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.withdrawal > 0: + if "purchase_invoice" in document_types: + pi_amount_matching = get_pi_matching_query(amount_condition) + queries.extend([pi_amount_matching]) + + if "expense_claim" in document_types: + ec_amount_matching = get_ec_matching_query(bank_account, company, amount_condition) + queries.extend([ec_amount_matching]) + + return queries + +def get_pe_matching_query(amount_condition, account_from_to, transaction): + # get matching payment entries query + if transaction.deposit > 0: + currency_field = "paid_to_account_currency as currency" + else: + currency_field = "paid_from_account_currency as currency" + 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 + """ + + +def get_je_matching_query(amount_condition, transaction): + # get matching journal entry query + cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit" + return f""" + + SELECT + (CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END + + 1) AS rank , + '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.party_type, + je.posting_date, + jea.account_currency as currency + FROM + `tabJournal Entry Account` as jea + JOIN + `tabJournal Entry` as je + ON + jea.parent = je.name + WHERE + (je.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 je.docstatus = 1 + """ + + +def get_si_matching_query(amount_condition): + # get matchin sales invoice query + return f""" + SELECT + ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END + + 1 ) AS rank, + 'Sales Invoice' as doctype, + si.name, + sip.amount as paid_amount, + '' as reference_no, + '' as reference_date, + si.customer as party, + 'Customer' as party_type, + si.posting_date, + si.currency + + FROM + `tabSales Invoice Payment` as sip + JOIN + `tabSales Invoice` as si + ON + sip.parent = si.name + WHERE (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 + """ + +def get_pi_matching_query(amount_condition): + # get matching purchase invoice query + return f""" + SELECT + ( CASE WHEN supplier = %(party)s THEN 1 ELSE 0 END + + 1 ) AS rank, + 'Purchase Invoice' as doctype, + name, + paid_amount, + '' as reference_no, + '' as reference_date, + supplier as party, + 'Supplier' as party_type, + posting_date, + currency + FROM + `tabPurchase Invoice` + WHERE + paid_amount {amount_condition} %(amount)s + AND docstatus = 1 + AND is_paid = 1 + AND ifnull(clearance_date, '') = "" + AND cash_bank_account = %(bank_account)s + """ + +def get_ec_matching_query(bank_account, company, amount_condition): + # get matching Expense Claim query + mode_of_payments = [x["parent"] for x in frappe.db.get_list("Mode of Payment Account", + filters={"default_account": bank_account}, fields=["parent"])] + mode_of_payments = '(\'' + '\', \''.join(mode_of_payments) + '\' )' + company_currency = get_company_currency(company) + return f""" + SELECT + ( CASE WHEN employee = %(party)s THEN 1 ELSE 0 END + + 1 ) AS rank, + 'Expense Claim' as doctype, + name, + total_sanctioned_amount as paid_amount, + '' as reference_no, + '' as reference_date, + employee as party, + 'Employee' as party_type, + posting_date, + '{company_currency}' as currency + FROM + `tabExpense Claim` + WHERE + total_sanctioned_amount {amount_condition} %(amount)s + AND docstatus = 1 + AND is_paid = 1 + AND ifnull(clearance_date, '') = "" + AND mode_of_payment in {mode_of_payments} + """ diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py new file mode 100644 index 00000000000..d96950abbce --- /dev/null +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestBankReconciliationTool(unittest.TestCase): + pass diff --git a/erpnext/accounts/doctype/bank_statement_transaction_payment_item/__init__.py b/erpnext/accounts/doctype/bank_statement_import/__init__.py similarity index 100% rename from erpnext/accounts/doctype/bank_statement_transaction_payment_item/__init__.py rename to erpnext/accounts/doctype/bank_statement_import/__init__.py diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.css b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.css new file mode 100644 index 00000000000..5206540a33c --- /dev/null +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.css @@ -0,0 +1,3 @@ +.warnings .warning { + margin-bottom: 40px; +} diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js new file mode 100644 index 00000000000..ad4ff9ee60a --- /dev/null +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js @@ -0,0 +1,574 @@ +// Copyright (c) 2019, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Bank Statement Import", { + setup(frm) { + frappe.realtime.on("data_import_refresh", ({ data_import }) => { + frm.import_in_progress = false; + if (data_import !== frm.doc.name) return; + frappe.model.clear_doc("Bank Statement Import", frm.doc.name); + frappe.model + .with_doc("Bank Statement Import", frm.doc.name) + .then(() => { + frm.refresh(); + }); + }); + frappe.realtime.on("data_import_progress", (data) => { + frm.import_in_progress = true; + if (data.data_import !== frm.doc.name) { + return; + } + let percent = Math.floor((data.current * 100) / data.total); + let seconds = Math.floor(data.eta); + let minutes = Math.floor(data.eta / 60); + let eta_message = + // prettier-ignore + seconds < 60 + ? __('About {0} seconds remaining', [seconds]) + : minutes === 1 + ? __('About {0} minute remaining', [minutes]) + : __('About {0} minutes remaining', [minutes]); + + let message; + if (data.success) { + let message_args = [data.current, data.total, eta_message]; + message = + frm.doc.import_type === "Insert New Records" + ? __("Importing {0} of {1}, {2}", message_args) + : __("Updating {0} of {1}, {2}", message_args); + } + if (data.skipping) { + message = __( + "Skipping {0} of {1}, {2}", + [ + data.current, + data.total, + eta_message, + ] + ); + } + frm.dashboard.show_progress( + __("Import Progress"), + percent, + message + ); + frm.page.set_indicator(__("In Progress"), "orange"); + + // hide progress when complete + if (data.current === data.total) { + setTimeout(() => { + frm.dashboard.hide(); + frm.refresh(); + }, 2000); + } + }); + + frm.set_query("reference_doctype", () => { + return { + filters: { + name: ["in", frappe.boot.user.can_import], + }, + }; + }); + + frm.get_field("import_file").df.options = { + restrictions: { + allowed_file_types: [".csv", ".xls", ".xlsx"], + }, + }; + + frm.has_import_file = () => { + return frm.doc.import_file || frm.doc.google_sheets_url; + }; + }, + + refresh(frm) { + frm.page.hide_icon_group(); + frm.trigger("update_indicators"); + frm.trigger("import_file"); + frm.trigger("show_import_log"); + frm.trigger("show_import_warnings"); + frm.trigger("toggle_submit_after_import"); + frm.trigger("show_import_status"); + frm.trigger("show_report_error_button"); + + if (frm.doc.status === "Partial Success") { + frm.add_custom_button(__("Export Errored Rows"), () => + frm.trigger("export_errored_rows") + ); + } + + if (frm.doc.status.includes("Success")) { + frm.add_custom_button( + __("Go to {0} List", [frm.doc.reference_doctype]), + () => frappe.set_route("List", frm.doc.reference_doctype) + ); + } + }, + + onload_post_render(frm) { + frm.trigger("update_primary_action"); + }, + + update_primary_action(frm) { + if (frm.is_dirty()) { + frm.enable_save(); + return; + } + frm.disable_save(); + if (frm.doc.status !== "Success") { + if (!frm.is_new() && frm.has_import_file()) { + let label = + frm.doc.status === "Pending" + ? __("Start Import") + : __("Retry"); + frm.page.set_primary_action(label, () => + frm.events.start_import(frm) + ); + } else { + frm.page.set_primary_action(__("Save"), () => frm.save()); + } + } + }, + + update_indicators(frm) { + const indicator = frappe.get_indicator(frm.doc); + if (indicator) { + frm.page.set_indicator(indicator[0], indicator[1]); + } else { + frm.page.clear_indicator(); + } + }, + + show_import_status(frm) { + let import_log = JSON.parse(frm.doc.import_log || "[]"); + let successful_records = import_log.filter((log) => log.success); + let failed_records = import_log.filter((log) => !log.success); + if (successful_records.length === 0) return; + + let message; + if (failed_records.length === 0) { + let message_args = [successful_records.length]; + if (frm.doc.import_type === "Insert New Records") { + message = + successful_records.length > 1 + ? __("Successfully imported {0} records.", message_args) + : __("Successfully imported {0} record.", message_args); + } else { + message = + successful_records.length > 1 + ? __("Successfully updated {0} records.", message_args) + : __("Successfully updated {0} record.", message_args); + } + } else { + let message_args = [successful_records.length, import_log.length]; + if (frm.doc.import_type === "Insert New Records") { + message = + successful_records.length > 1 + ? __( + "Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.", + message_args + ) + : __( + "Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.", + message_args + ); + } else { + message = + successful_records.length > 1 + ? __( + "Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.", + message_args + ) + : __( + "Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.", + message_args + ); + } + } + frm.dashboard.set_headline(message); + }, + + show_report_error_button(frm) { + if (frm.doc.status === "Error") { + frappe.db + .get_list("Error Log", { + filters: { method: frm.doc.name }, + fields: ["method", "error"], + order_by: "creation desc", + limit: 1, + }) + .then((result) => { + if (result.length > 0) { + frm.add_custom_button("Report Error", () => { + let fake_xhr = { + responseText: JSON.stringify({ + exc: result[0].error, + }), + }; + frappe.request.report_error(fake_xhr, {}); + }); + } + }); + } + }, + + start_import(frm) { + frm.call({ + method: "form_start_import", + args: { data_import: frm.doc.name }, + btn: frm.page.btn_primary, + }).then((r) => { + if (r.message === true) { + frm.disable_save(); + } + }); + }, + + download_template() { + let method = + "/api/method/frappe.core.doctype.data_import.data_import.download_template"; + + open_url_post(method, { + doctype: "Bank Transaction", + export_records: "5_records", + export_fields: { + "Bank Transaction": [ + "date", + "deposit", + "withdrawal", + "description", + "reference_number", + ], + }, + }); + }, + + reference_doctype(frm) { + frm.trigger("toggle_submit_after_import"); + }, + + toggle_submit_after_import(frm) { + frm.toggle_display("submit_after_import", false); + let doctype = frm.doc.reference_doctype; + if (doctype) { + frappe.model.with_doctype(doctype, () => { + let meta = frappe.get_meta(doctype); + frm.toggle_display("submit_after_import", meta.is_submittable); + }); + } + }, + + google_sheets_url(frm) { + if (!frm.is_dirty()) { + frm.trigger("import_file"); + } else { + frm.trigger("update_primary_action"); + } + }, + + refresh_google_sheet(frm) { + frm.trigger("import_file"); + }, + + import_file(frm) { + frm.toggle_display("section_import_preview", frm.has_import_file()); + if (!frm.has_import_file()) { + frm.get_field("import_preview").$wrapper.empty(); + return; + } else { + frm.trigger("update_primary_action"); + } + + // load import preview + frm.get_field("import_preview").$wrapper.empty(); + $('') + .html(__("Loading import file...")) + .appendTo(frm.get_field("import_preview").$wrapper); + + frm.call({ + method: "get_preview_from_template", + args: { + data_import: frm.doc.name, + import_file: frm.doc.import_file, + google_sheets_url: frm.doc.google_sheets_url, + }, + error_handlers: { + TimestampMismatchError() { + // ignore this error + }, + }, + }).then((r) => { + let preview_data = r.message; + frm.events.show_import_preview(frm, preview_data); + frm.events.show_import_warnings(frm, preview_data); + }); + }, + // method: 'frappe.core.doctype.data_import.data_import.get_preview_from_template', + + show_import_preview(frm, preview_data) { + let import_log = JSON.parse(frm.doc.import_log || "[]"); + + if ( + frm.import_preview && + frm.import_preview.doctype === frm.doc.reference_doctype + ) { + frm.import_preview.preview_data = preview_data; + frm.import_preview.import_log = import_log; + frm.import_preview.refresh(); + return; + } + + frappe.require("/assets/js/data_import_tools.min.js", () => { + frm.import_preview = new frappe.data_import.ImportPreview({ + wrapper: frm.get_field("import_preview").$wrapper, + doctype: frm.doc.reference_doctype, + preview_data, + import_log, + frm, + events: { + remap_column(changed_map) { + let template_options = JSON.parse( + frm.doc.template_options || "{}" + ); + template_options.column_to_field_map = + template_options.column_to_field_map || {}; + Object.assign( + template_options.column_to_field_map, + changed_map + ); + frm.set_value( + "template_options", + JSON.stringify(template_options) + ); + frm.save().then(() => frm.trigger("import_file")); + }, + }, + }); + }); + }, + + export_errored_rows(frm) { + open_url_post( + "/api/method/frappe.core.doctype.data_import.data_import.download_errored_template", + { + data_import_name: frm.doc.name, + } + ); + }, + + show_import_warnings(frm, preview_data) { + let columns = preview_data.columns; + let warnings = JSON.parse(frm.doc.template_warnings || "[]"); + warnings = warnings.concat(preview_data.warnings || []); + + frm.toggle_display("import_warnings_section", warnings.length > 0); + if (warnings.length === 0) { + frm.get_field("import_warnings").$wrapper.html(""); + return; + } + + // group warnings by row + let warnings_by_row = {}; + let other_warnings = []; + for (let warning of warnings) { + if (warning.row) { + warnings_by_row[warning.row] = + warnings_by_row[warning.row] || []; + warnings_by_row[warning.row].push(warning); + } else { + other_warnings.push(warning); + } + } + + let html = ""; + html += Object.keys(warnings_by_row) + .map((row_number) => { + let message = warnings_by_row[row_number] + .map((w) => { + if (w.field) { + let label = + w.field.label + + (w.field.parent !== frm.doc.reference_doctype + ? ` (${w.field.parent})` + : ""); + return `
  • ${label}: ${w.message}
  • `; + } + return `
  • ${w.message}
  • `; + }) + .join(""); + return ` +
    +
    ${__("Row {0}", [row_number])}
    +
      ${message}
    +
    + `; + }) + .join(""); + + html += other_warnings + .map((warning) => { + let header = ""; + if (warning.col) { + let column_number = `${__( + "Column {0}", + [warning.col] + )}`; + let column_header = columns[warning.col].header_title; + header = `${column_number} (${column_header})`; + } + return ` +
    +
    ${header}
    +
    ${warning.message}
    +
    + `; + }) + .join(""); + frm.get_field("import_warnings").$wrapper.html(` +
    +
    ${html}
    +
    + `); + }, + + show_failed_logs(frm) { + frm.trigger("show_import_log"); + }, + + show_import_log(frm) { + let import_log = JSON.parse(frm.doc.import_log || "[]"); + let logs = import_log; + frm.toggle_display("import_log", false); + frm.toggle_display("import_log_section", logs.length > 0); + + if (logs.length === 0) { + frm.get_field("import_log_preview").$wrapper.empty(); + return; + } + + let rows = logs + .map((log) => { + let html = ""; + if (log.success) { + if (frm.doc.import_type === "Insert New Records") { + html = __( + "Successfully imported {0}", [ + `${frappe.utils.get_form_link( + frm.doc.reference_doctype, + log.docname, + true + )}`, + ] + ); + } else { + html = __( + "Successfully updated {0}", [ + `${frappe.utils.get_form_link( + frm.doc.reference_doctype, + log.docname, + true + )}`, + ] + ); + } + } else { + let messages = log.messages + .map(JSON.parse) + .map((m) => { + let title = m.title + ? `${m.title}` + : ""; + let message = m.message + ? `
    ${m.message}
    ` + : ""; + return title + message; + }) + .join(""); + let id = frappe.dom.get_unique_id(); + html = `${messages} + +
    +
    +
    ${log.exception}
    +
    +
    `; + } + let indicator_color = log.success ? "green" : "red"; + let title = log.success ? __("Success") : __("Failure"); + + if (frm.doc.show_failed_logs && log.success) { + return ""; + } + + return ` + ${log.row_indexes.join(", ")} + +
    ${title}
    + + + ${html} + + `; + }) + .join(""); + + if (!rows && frm.doc.show_failed_logs) { + rows = ` + ${__("No failed logs")} + `; + } + + frm.get_field("import_log_preview").$wrapper.html(` + + + + + + + ${rows} +
    ${__("Row Number")}${__("Status")}${__("Message")}
    + `); + }, + + show_missing_link_values(frm, missing_link_values) { + let can_be_created_automatically = missing_link_values.every( + (d) => d.has_one_mandatory_field + ); + + let html = missing_link_values + .map((d) => { + let doctype = d.doctype; + let values = d.missing_values; + return ` +
    ${doctype}
    +
      ${values.map((v) => `
    • ${v}
    • `).join("")}
    + `; + }) + .join(""); + + if (can_be_created_automatically) { + // prettier-ignore + let message = __('There are some linked records which needs to be created before we can import your file. Do you want to create the following missing records automatically?'); + frappe.confirm(message + html, () => { + frm.call("create_missing_link_values", { + missing_link_values, + }).then((r) => { + let records = r.message; + frappe.msgprint(__( + "Created {0} records successfully.", [ + records.length, + ] + )); + }); + }); + } else { + frappe.msgprint( + // prettier-ignore + __('The following records needs to be created before we can import your file.') + html + ); + } + }, +}); diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json new file mode 100644 index 00000000000..5e913cc2aac --- /dev/null +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json @@ -0,0 +1,227 @@ +{ + "actions": [], + "autoname": "format:Bank Statement Import on {creation}", + "beta": 1, + "creation": "2019-08-04 14:16:08.318714", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "bank_account", + "bank", + "column_break_4", + "google_sheets_url", + "refresh_google_sheet", + "html_5", + "import_file", + "download_template", + "status", + "template_options", + "import_warnings_section", + "template_warnings", + "import_warnings", + "section_import_preview", + "import_preview", + "import_log_section", + "import_log", + "show_failed_logs", + "import_log_preview", + "reference_doctype", + "import_type", + "submit_after_import", + "mute_emails" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "bank_account", + "fieldtype": "Link", + "label": "Bank Account", + "options": "Bank Account", + "reqd": 1, + "set_only_once": 1 + }, + { + "depends_on": "eval:doc.bank_account", + "fetch_from": "bank_account.bank", + "fieldname": "bank", + "fieldtype": "Link", + "label": "Bank", + "options": "Bank", + "read_only": 1, + "set_only_once": 1 + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "download_template", + "fieldtype": "Button", + "label": "Download Template" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "import_file", + "fieldtype": "Attach", + "in_list_view": 1, + "label": "Import File" + }, + { + "fieldname": "import_preview", + "fieldtype": "HTML", + "label": "Import Preview" + }, + { + "fieldname": "section_import_preview", + "fieldtype": "Section Break", + "label": "Preview" + }, + { + "fieldname": "template_options", + "fieldtype": "Code", + "hidden": 1, + "label": "Template Options", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "import_log", + "fieldtype": "Code", + "label": "Import Log", + "options": "JSON" + }, + { + "fieldname": "import_log_section", + "fieldtype": "Section Break", + "label": "Import Log" + }, + { + "fieldname": "import_log_preview", + "fieldtype": "HTML", + "label": "Import Log Preview" + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "label": "Status", + "options": "Pending\nSuccess\nPartial Success\nError", + "read_only": 1 + }, + { + "fieldname": "template_warnings", + "fieldtype": "Code", + "hidden": 1, + "label": "Template Warnings", + "options": "JSON" + }, + { + "fieldname": "import_warnings_section", + "fieldtype": "Section Break", + "label": "Import File Errors and Warnings" + }, + { + "fieldname": "import_warnings", + "fieldtype": "HTML", + "label": "Import Warnings" + }, + { + "default": "0", + "fieldname": "show_failed_logs", + "fieldtype": "Check", + "label": "Show Failed Logs" + }, + { + "depends_on": "eval:!doc.__islocal && !doc.import_file", + "fieldname": "html_5", + "fieldtype": "HTML", + "options": "
    Or
    " + }, + { + "depends_on": "eval:!doc.__islocal && !doc.import_file\n", + "description": "Must be a publicly accessible Google Sheets URL", + "fieldname": "google_sheets_url", + "fieldtype": "Data", + "label": "Import from Google Sheets" + }, + { + "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved", + "fieldname": "refresh_google_sheet", + "fieldtype": "Button", + "label": "Refresh Google Sheet" + }, + { + "default": "Bank Transaction", + "fieldname": "reference_doctype", + "fieldtype": "Link", + "hidden": 1, + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "reqd": 1, + "set_only_once": 1 + }, + { + "default": "Insert New Records", + "fieldname": "import_type", + "fieldtype": "Select", + "hidden": 1, + "in_list_view": 1, + "label": "Import Type", + "options": "\nInsert New Records\nUpdate Existing Records", + "reqd": 1, + "set_only_once": 1 + }, + { + "default": "1", + "fieldname": "submit_after_import", + "fieldtype": "Check", + "hidden": 1, + "label": "Submit After Import", + "set_only_once": 1 + }, + { + "default": "1", + "fieldname": "mute_emails", + "fieldtype": "Check", + "hidden": 1, + "label": "Don't Send Emails", + "set_only_once": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + } + ], + "hide_toolbar": 1, + "links": [], + "modified": "2021-02-10 19:29:59.027325", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Bank Statement Import", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py new file mode 100644 index 00000000000..9f41b13f4b6 --- /dev/null +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import csv +import json +import re + +import openpyxl +from openpyxl.styles import Font +from openpyxl.utils import get_column_letter +from six import string_types + +import frappe +from frappe.core.doctype.data_import.importer import Importer, ImportFile +from frappe.utils.background_jobs import enqueue +from frappe.utils.xlsxutils import handle_html, ILLEGAL_CHARACTERS_RE +from frappe import _ + +from frappe.core.doctype.data_import.data_import import DataImport + +class BankStatementImport(DataImport): + def __init__(self, *args, **kwargs): + super(BankStatementImport, self).__init__(*args, **kwargs) + + def validate(self): + doc_before_save = self.get_doc_before_save() + if ( + not (self.import_file or self.google_sheets_url) + or (doc_before_save and doc_before_save.import_file != self.import_file) + or (doc_before_save and doc_before_save.google_sheets_url != self.google_sheets_url) + ): + + template_options_dict = {} + column_to_field_map = {} + bank = frappe.get_doc("Bank", self.bank) + for i in bank.bank_transaction_mapping: + column_to_field_map[i.file_field] = i.bank_transaction_field + template_options_dict["column_to_field_map"] = column_to_field_map + self.template_options = json.dumps(template_options_dict) + + self.template_warnings = "" + + self.validate_import_file() + self.validate_google_sheets_url() + + def start_import(self): + + from frappe.core.page.background_jobs.background_jobs import get_info + from frappe.utils.scheduler import is_scheduler_inactive + + if is_scheduler_inactive() and not frappe.flags.in_test: + frappe.throw( + _("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive") + ) + + enqueued_jobs = [d.get("job_name") for d in get_info()] + + if self.name not in enqueued_jobs: + enqueue( + start_import, + queue="default", + timeout=6000, + event="data_import", + job_name=self.name, + data_import=self.name, + bank_account=self.bank_account, + import_file_path=self.import_file, + bank=self.bank, + template_options=self.template_options, + now=frappe.conf.developer_mode or frappe.flags.in_test, + ) + return True + + return False + +@frappe.whitelist() +def get_preview_from_template(data_import, import_file=None, google_sheets_url=None): + return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template( + import_file, google_sheets_url + ) + +@frappe.whitelist() +def form_start_import(data_import): + return frappe.get_doc("Bank Statement Import", data_import).start_import() + +@frappe.whitelist() +def download_errored_template(data_import_name): + data_import = frappe.get_doc("Bank Statement Import", data_import_name) + data_import.export_errored_rows() + +def start_import(data_import, bank_account, import_file_path, bank, template_options): + """This method runs in background job""" + + update_mapping_db(bank, template_options) + + data_import = frappe.get_doc("Bank Statement Import", data_import) + + import_file = ImportFile("Bank Transaction", file = import_file_path, import_type="Insert New Records") + data = import_file.raw_data + + add_bank_account(data, bank_account) + write_files(import_file, data) + + try: + i = Importer(data_import.reference_doctype, data_import=data_import) + i.import_data() + except Exception: + frappe.db.rollback() + data_import.db_set("status", "Error") + frappe.log_error(title=data_import.name) + finally: + frappe.flags.in_import = False + + frappe.publish_realtime("data_import_refresh", {"data_import": data_import.name}) + +def update_mapping_db(bank, template_options): + bank = frappe.get_doc("Bank", bank) + for d in bank.bank_transaction_mapping: + d.delete() + + for d in json.loads(template_options)["column_to_field_map"].items(): + bank.append("bank_transaction_mapping", {"bank_transaction_field": d[1] ,"file_field": d[0]} ) + + bank.save() + +def add_bank_account(data, bank_account): + bank_account_loc = None + if "Bank Account" not in data[0]: + data[0].append("Bank Account") + else: + for loc, header in enumerate(data[0]): + if header == "Bank Account": + bank_account_loc = loc + + for row in data[1:]: + if bank_account_loc: + row[bank_account_loc] = bank_account + else: + row.append(bank_account) + +def write_files(import_file, data): + full_file_path = import_file.file_doc.get_full_path() + parts = import_file.file_doc.get_extension() + extension = parts[1] + extension = extension.lstrip(".") + + if extension == "csv": + with open(full_file_path, 'w', newline='') as file: + writer = csv.writer(file) + writer.writerows(data) + elif extension == "xlsx" or "xls": + write_xlsx(data, "trans", file_path = full_file_path) + +def write_xlsx(data, sheet_name, wb=None, column_widths=None, file_path=None): + # from xlsx utils with changes + column_widths = column_widths or [] + if wb is None: + wb = openpyxl.Workbook(write_only=True) + + ws = wb.create_sheet(sheet_name, 0) + + for i, column_width in enumerate(column_widths): + if column_width: + ws.column_dimensions[get_column_letter(i + 1)].width = column_width + + row1 = ws.row_dimensions[1] + row1.font = Font(name='Calibri', bold=True) + + for row in data: + clean_row = [] + for item in row: + if isinstance(item, string_types) and (sheet_name not in ['Data Import Template', 'Data Export']): + value = handle_html(item) + else: + value = item + + if isinstance(item, string_types) and next(ILLEGAL_CHARACTERS_RE.finditer(value), None): + # Remove illegal characters from the string + value = re.sub(ILLEGAL_CHARACTERS_RE, '', value) + + clean_row.append(value) + + ws.append(clean_row) + + wb.save(file_path) + return True + +@frappe.whitelist() +def upload_bank_statement(**args): + args = frappe._dict(args) + bsi = frappe.new_doc("Bank Statement Import") + + if args.company: + bsi.update({ + "company": args.company, + }) + + if args.bank_account: + bsi.update({ + "bank_account": args.bank_account + }) + + return bsi diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import_list.js b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import_list.js new file mode 100644 index 00000000000..6c754022e68 --- /dev/null +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import_list.js @@ -0,0 +1,36 @@ +let imports_in_progress = []; + +frappe.listview_settings['Bank Statement Import'] = { + onload(listview) { + frappe.realtime.on('data_import_progress', data => { + if (!imports_in_progress.includes(data.data_import)) { + imports_in_progress.push(data.data_import); + } + }); + frappe.realtime.on('data_import_refresh', data => { + imports_in_progress = imports_in_progress.filter( + d => d !== data.data_import + ); + listview.refresh(); + }); + }, + get_indicator: function(doc) { + var colors = { + 'Pending': 'orange', + 'Not Started': 'orange', + 'Partial Success': 'orange', + 'Success': 'green', + 'In Progress': 'orange', + 'Error': 'red' + }; + let status = doc.status; + if (imports_in_progress.includes(doc.name)) { + status = 'In Progress'; + } + if (status == 'Pending') { + status = 'Not Started'; + } + return [__(status), colors[status], 'status,=,' + doc.status]; + }, + hide_name_column: true +}; diff --git a/erpnext/accounts/doctype/bank_statement_import/test_bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/test_bank_statement_import.py new file mode 100644 index 00000000000..cd5831412d9 --- /dev/null +++ b/erpnext/accounts/doctype/bank_statement_import/test_bank_statement_import.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestBankStatementImport(unittest.TestCase): + pass diff --git a/erpnext/accounts/doctype/bank_statement_settings/bank_statement_settings.js b/erpnext/accounts/doctype/bank_statement_settings/bank_statement_settings.js deleted file mode 100644 index 46aa4f20311..00000000000 --- a/erpnext/accounts/doctype/bank_statement_settings/bank_statement_settings.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2017, sathishpy@gmail.com and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Bank Statement Settings', { - refresh: function(frm) { - - } -}); diff --git a/erpnext/accounts/doctype/bank_statement_settings/bank_statement_settings.json b/erpnext/accounts/doctype/bank_statement_settings/bank_statement_settings.json deleted file mode 100644 index 53fbf7d446c..00000000000 --- a/erpnext/accounts/doctype/bank_statement_settings/bank_statement_settings.json +++ /dev/null @@ -1,272 +0,0 @@ -{ - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 1, - "beta": 0, - "creation": "2017-11-13 13:38:10.863592", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "bank", - "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": "Bank Account", - "length": 0, - "no_copy": 0, - "options": "Bank", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "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, - "default": "'%d/%m/%Y'", - "fieldname": "date_format", - "fieldtype": "Data", - "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": "Date Format", - "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": "statement_header_mapping", - "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, - "label": "Statement Header Mapping", - "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": "header_items", - "fieldtype": "Table", - "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": "Statement Headers", - "length": 0, - "no_copy": 0, - "options": "Bank Statement Settings Item", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "transaction_data_mapping", - "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, - "label": "Transaction Data Mapping", - "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": "mapped_items", - "fieldtype": "Table", - "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": "Mapped Items", - "length": 0, - "no_copy": 0, - "options": "Bank Statement Transaction Settings Item", - "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 - } - ], - "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-04-07 18:57:04.048423", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Bank Statement Settings", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "apply_user_permissions": 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, - "write": 1 - }, - { - "amend": 0, - "apply_user_permissions": 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": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "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": 1, - "track_seen": 0 -} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_settings/bank_statement_settings.py b/erpnext/accounts/doctype/bank_statement_settings/bank_statement_settings.py deleted file mode 100644 index 6c4dd1b85b3..00000000000 --- a/erpnext/accounts/doctype/bank_statement_settings/bank_statement_settings.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, sathishpy@gmail.com and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe.model.document import Document - -class BankStatementSettings(Document): - def autoname(self): - self.name = self.bank + "-Statement-Settings" diff --git a/erpnext/accounts/doctype/bank_statement_settings/test_bank_statement_settings.js b/erpnext/accounts/doctype/bank_statement_settings/test_bank_statement_settings.js deleted file mode 100644 index f2381c042ee..00000000000 --- a/erpnext/accounts/doctype/bank_statement_settings/test_bank_statement_settings.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Bank Statement Settings", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially([ - // insert a new Bank Statement Settings - () => frappe.tests.make('Bank Statement Settings', [ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/erpnext/accounts/doctype/bank_statement_settings/test_bank_statement_settings.py b/erpnext/accounts/doctype/bank_statement_settings/test_bank_statement_settings.py deleted file mode 100644 index aa7fe833285..00000000000 --- a/erpnext/accounts/doctype/bank_statement_settings/test_bank_statement_settings.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, sathishpy@gmail.com and Contributors -# See license.txt -from __future__ import unicode_literals - -import frappe -import unittest - -class TestBankStatementSettings(unittest.TestCase): - pass diff --git a/erpnext/accounts/doctype/bank_statement_settings_item/bank_statement_settings_item.json b/erpnext/accounts/doctype/bank_statement_settings_item/bank_statement_settings_item.json deleted file mode 100644 index 7c93f268f53..00000000000 --- a/erpnext/accounts/doctype/bank_statement_settings_item/bank_statement_settings_item.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2018-01-08 00:16:42.762980", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "mapped_header", - "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": "Mapped Header", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "stmt_header", - "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": "Bank Header", - "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, - "unique": 0 - } - ], - "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-01-08 00:19:14.841134", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Bank Statement Settings Item", - "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": 1, - "track_seen": 0 -} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_transaction_entry/bank_statement_transaction_entry.js b/erpnext/accounts/doctype/bank_statement_transaction_entry/bank_statement_transaction_entry.js deleted file mode 100644 index 736ed35ae13..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_entry/bank_statement_transaction_entry.js +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2017, sathishpy@gmail.com and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Bank Statement Transaction Entry', { - setup: function(frm) { - frm.events.account_filters(frm) - frm.events.invoice_filter(frm) - }, - refresh: function(frm) { - frm.set_df_property("bank_account", "read_only", frm.doc.__islocal ? 0 : 1); - frm.set_df_property("from_date", "read_only", frm.doc.__islocal ? 0 : 1); - frm.set_df_property("to_date", "read_only", frm.doc.__islocal ? 0 : 1); - }, - invoke_doc_function(frm, method) { - frappe.call({ - doc: frm.doc, - method: method, - callback: function(r) { - if(!r.exe) { - frm.refresh_fields(); - } - } - }); - }, - account_filters: function(frm) { - frm.fields_dict['bank_account'].get_query = function(doc, dt, dn) { - return { - filters:[ - ["Account", "account_type", "in", ["Bank"]] - ] - } - }; - frm.fields_dict['receivable_account'].get_query = function(doc, dt, dn) { - return { - filters: {"account_type": "Receivable"} - } - }; - frm.fields_dict['payable_account'].get_query = function(doc, dt, dn) { - return { - filters: {"account_type": "Payable"} - } - }; - }, - - invoice_filter: function(frm) { - frm.set_query("invoice", "payment_invoice_items", function(doc, cdt, cdn) { - let row = locals[cdt][cdn] - if (row.party_type == "Customer") { - return { - filters:[[row.invoice_type, "customer", "in", [row.party]], - [row.invoice_type, "status", "!=", "Cancelled" ], - [row.invoice_type, "posting_date", "<", row.transaction_date ], - [row.invoice_type, "outstanding_amount", ">", 0 ]] - } - } else if (row.party_type == "Supplier") { - return { - filters:[[row.invoice_type, "supplier", "in", [row.party]], - [row.invoice_type, "status", "!=", "Cancelled" ], - [row.invoice_type, "posting_date", "<", row.transaction_date ], - [row.invoice_type, "outstanding_amount", ">", 0 ]] - } - } - }); - }, - - match_invoices: function(frm) { - frm.events.invoke_doc_function(frm, "populate_matching_invoices"); - }, - create_payments: function(frm) { - frm.events.invoke_doc_function(frm, "create_payment_entries"); - }, - submit_payments: function(frm) { - frm.events.invoke_doc_function(frm, "submit_payment_entries"); - }, -}); - - -frappe.ui.form.on('Bank Statement Transaction Invoice Item', { - party_type: function(frm, cdt, cdn) { - let row = locals[cdt][cdn]; - if (row.party_type == "Customer") { - row.invoice_type = "Sales Invoice"; - } else if (row.party_type == "Supplier") { - row.invoice_type = "Purchase Invoice"; - } else if (row.party_type == "Account") { - row.invoice_type = "Journal Entry"; - } - refresh_field("invoice_type", row.name, "payment_invoice_items"); - - }, - invoice_type: function(frm, cdt, cdn) { - let row = locals[cdt][cdn]; - if (row.invoice_type == "Purchase Invoice") { - row.party_type = "Supplier"; - } else if (row.invoice_type == "Sales Invoice") { - row.party_type = "Customer"; - } - refresh_field("party_type", row.name, "payment_invoice_items"); - } -}); \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_transaction_entry/bank_statement_transaction_entry.json b/erpnext/accounts/doctype/bank_statement_transaction_entry/bank_statement_transaction_entry.json deleted file mode 100644 index fb80169c378..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_entry/bank_statement_transaction_entry.json +++ /dev/null @@ -1,792 +0,0 @@ -{ - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 1, - "beta": 0, - "creation": "2017-11-07 13:48:13.123185", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "bank_account", - "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": "Bank Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "from_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": "From 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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "to_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": "To 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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "bank_settings", - "fieldtype": "Link", - "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": "Bank Statement Settings", - "length": 0, - "no_copy": 0, - "options": "Bank Statement Settings", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_3", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "bank", - "fieldtype": "Link", - "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": "Bank", - "length": 0, - "no_copy": 0, - "options": "Bank", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "receivable_account", - "fieldtype": "Link", - "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": "Receivable Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payable_account", - "fieldtype": "Link", - "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": "Payable Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "bank_statement", - "fieldtype": "Attach", - "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": "Bank Statement", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "fieldname": "section_break_6", - "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, - "label": "Bank Transaction Entries", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "new_transaction_items", - "fieldtype": "Table", - "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": "New Transactions", - "length": 0, - "no_copy": 0, - "options": "Bank Statement Transaction Payment Item", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.new_transaction_items && doc.new_transaction_items.length", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "fieldname": "match_invoices", - "fieldtype": "Button", - "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": "Match Transaction to Invoices", - "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_in_quick_entry": 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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "create_payments", - "fieldtype": "Button", - "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": "Create New Payment/Journal Entry", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_16", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "submit_payments", - "fieldtype": "Button", - "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": "Submit/Reconcile Payments", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.new_transaction_items && doc.new_transaction_items.length", - "fieldname": "section_break_18", - "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, - "label": "Matching Invoices", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_invoice_items", - "fieldtype": "Table", - "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": "Payment Invoice Items", - "length": 0, - "no_copy": 0, - "options": "Bank Statement Transaction Invoice Item", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "reconciled_transactions", - "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, - "label": "Reconciled Transactions", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "reconciled_transaction_items", - "fieldtype": "Table", - "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": "Reconciled Transactions", - "length": 0, - "no_copy": 0, - "options": "Bank Statement Transaction Payment Item", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amended_from", - "fieldtype": "Link", - "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": "Amended From", - "length": 0, - "no_copy": 1, - "options": "Bank Statement Transaction Entry", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-09-14 18:04:44.170455", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Bank Statement Transaction Entry", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 1, - "cancel": 1, - "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": 1, - "write": 1 - }, - { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 1, - "write": 1 - } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 -} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_transaction_entry/bank_statement_transaction_entry.py b/erpnext/accounts/doctype/bank_statement_transaction_entry/bank_statement_transaction_entry.py deleted file mode 100644 index 27dd8e463f6..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_entry/bank_statement_transaction_entry.py +++ /dev/null @@ -1,443 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, sathishpy@gmail.com and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe import _ -from frappe.model.document import Document -from erpnext.accounts.utils import get_outstanding_invoices -from frappe.utils import nowdate -from datetime import datetime -import csv, os, re, io -import difflib -import copy - -class BankStatementTransactionEntry(Document): - def autoname(self): - self.name = self.bank_account + "-" + self.from_date + "-" + self.to_date - if self.bank: - mapper_name = self.bank + "-Statement-Settings" - if not frappe.db.exists("Bank Statement Settings", mapper_name): - self.create_settings(self.bank) - self.bank_settings = mapper_name - - def create_settings(self, bank): - mapper = frappe.new_doc("Bank Statement Settings") - mapper.bank = bank - mapper.date_format = "%Y-%m-%d" - mapper.bank_account = self.bank_account - for header in ["Date", "Particulars", "Withdrawals", "Deposits", "Balance"]: - header_item = mapper.append("header_items", {}) - header_item.mapped_header = header_item.stmt_header = header - mapper.save() - - def on_update(self): - if (not self.bank_statement): - self.reconciled_transaction_items = self.new_transaction_items = [] - return - - if len(self.new_transaction_items + self.reconciled_transaction_items) == 0: - self.populate_payment_entries() - else: - self.match_invoice_to_payment() - - def validate(self): - if not self.new_transaction_items: - self.populate_payment_entries() - - def get_statement_headers(self): - if not self.bank_settings: - frappe.throw(_("Bank Data mapper doesn't exist")) - mapper_doc = frappe.get_doc("Bank Statement Settings", self.bank_settings) - headers = {entry.mapped_header:entry.stmt_header for entry in mapper_doc.header_items} - return headers - - def populate_payment_entries(self): - if self.bank_statement is None: return - file_url = self.bank_statement - if (len(self.new_transaction_items + self.reconciled_transaction_items) > 0): - frappe.throw(_("Transactions already retreived from the statement")) - - date_format = frappe.get_value("Bank Statement Settings", self.bank_settings, "date_format") - if (date_format is None): - date_format = '%Y-%m-%d' - if self.bank_settings: - mapped_items = frappe.get_doc("Bank Statement Settings", self.bank_settings).mapped_items - statement_headers = self.get_statement_headers() - transactions = get_transaction_entries(file_url, statement_headers) - for entry in transactions: - date = entry[statement_headers["Date"]].strip() - #print("Processing entry DESC:{0}-W:{1}-D:{2}-DT:{3}".format(entry["Particulars"], entry["Withdrawals"], entry["Deposits"], entry["Date"])) - if (not date): continue - transaction_date = datetime.strptime(date, date_format).date() - if (self.from_date and transaction_date < datetime.strptime(self.from_date, '%Y-%m-%d').date()): continue - if (self.to_date and transaction_date > datetime.strptime(self.to_date, '%Y-%m-%d').date()): continue - bank_entry = self.append('new_transaction_items', {}) - bank_entry.transaction_date = transaction_date - bank_entry.description = entry[statement_headers["Particulars"]] - - mapped_item = next((entry for entry in mapped_items if entry.mapping_type == "Transaction" and frappe.safe_decode(entry.bank_data.lower()) in frappe.safe_decode(bank_entry.description.lower())), None) - if (mapped_item is not None): - bank_entry.party_type = mapped_item.mapped_data_type - bank_entry.party = mapped_item.mapped_data - else: - bank_entry.party_type = "Supplier" if not entry[statement_headers["Deposits"]].strip() else "Customer" - party_list = frappe.get_all(bank_entry.party_type, fields=["name"]) - parties = [party.name for party in party_list] - matches = difflib.get_close_matches(frappe.safe_decode(bank_entry.description.lower()), parties, 1, 0.4) - if len(matches) > 0: bank_entry.party = matches[0] - bank_entry.amount = -float(entry[statement_headers["Withdrawals"]]) if not entry[statement_headers["Deposits"]].strip() else float(entry[statement_headers["Deposits"]]) - self.map_unknown_transactions() - self.map_transactions_on_journal_entry() - - def map_transactions_on_journal_entry(self): - for entry in self.new_transaction_items: - vouchers = frappe.db.sql("""select name, posting_date from `tabJournal Entry` - where posting_date='{0}' and total_credit={1} and cheque_no='{2}' and docstatus != 2 - """.format(entry.transaction_date, abs(entry.amount), frappe.safe_decode(entry.description)), as_dict=True) - if (len(vouchers) == 1): - entry.reference_name = vouchers[0].name - - def populate_matching_invoices(self): - self.payment_invoice_items = [] - self.map_unknown_transactions() - added_invoices = [] - for entry in self.new_transaction_items: - if (not entry.party or entry.party_type == "Account"): continue - account = self.receivable_account if entry.party_type == "Customer" else self.payable_account - invoices = get_outstanding_invoices(entry.party_type, entry.party, account) - transaction_date = datetime.strptime(entry.transaction_date, "%Y-%m-%d").date() - outstanding_invoices = [invoice for invoice in invoices if invoice.posting_date <= transaction_date] - amount = abs(entry.amount) - matching_invoices = [invoice for invoice in outstanding_invoices if invoice.outstanding_amount == amount] - sorted(outstanding_invoices, key=lambda k: k['posting_date']) - for e in (matching_invoices + outstanding_invoices): - added = next((inv for inv in added_invoices if inv == e.get('voucher_no')), None) - if (added is not None): continue - ent = self.append('payment_invoice_items', {}) - ent.transaction_date = entry.transaction_date - ent.payment_description = frappe.safe_decode(entry.description) - ent.party_type = entry.party_type - ent.party = entry.party - ent.invoice = e.get('voucher_no') - added_invoices += [ent.invoice] - ent.invoice_type = "Sales Invoice" if entry.party_type == "Customer" else "Purchase Invoice" - ent.invoice_date = e.get('posting_date') - ent.outstanding_amount = e.get('outstanding_amount') - ent.allocated_amount = min(float(e.get('outstanding_amount')), amount) - amount -= float(e.get('outstanding_amount')) - if (amount <= 5): break - self.match_invoice_to_payment() - self.populate_matching_vouchers() - self.map_transactions_on_journal_entry() - - def match_invoice_to_payment(self): - added_payments = [] - for entry in self.new_transaction_items: - if (not entry.party or entry.party_type == "Account"): continue - entry.account = self.receivable_account if entry.party_type == "Customer" else self.payable_account - amount = abs(entry.amount) - payment, matching_invoices = None, [] - for inv_entry in self.payment_invoice_items: - if (inv_entry.payment_description != frappe.safe_decode(entry.description) or inv_entry.transaction_date != entry.transaction_date): continue - if (inv_entry.party != entry.party): continue - matching_invoices += [inv_entry.invoice_type + "|" + inv_entry.invoice] - payment = get_payments_matching_invoice(inv_entry.invoice, entry.amount, entry.transaction_date) - doc = frappe.get_doc(inv_entry.invoice_type, inv_entry.invoice) - inv_entry.invoice_date = doc.posting_date - inv_entry.outstanding_amount = doc.outstanding_amount - inv_entry.allocated_amount = min(float(doc.outstanding_amount), amount) - amount -= inv_entry.allocated_amount - if (amount < 0): break - - amount = abs(entry.amount) - if (payment is None): - order_doctype = "Sales Order" if entry.party_type=="Customer" else "Purchase Order" - from erpnext.controllers.accounts_controller import get_advance_payment_entries - payment_entries = get_advance_payment_entries(entry.party_type, entry.party, entry.account, order_doctype, against_all_orders=True) - payment_entries += self.get_matching_payments(entry.party, amount, entry.transaction_date) - payment = next((payment for payment in payment_entries if payment.amount == amount and payment not in added_payments), None) - if (payment is None): - print("Failed to find payments for {0}:{1}".format(entry.party, amount)) - continue - added_payments += [payment] - entry.reference_type = payment.reference_type - entry.reference_name = payment.reference_name - entry.mode_of_payment = "Wire Transfer" - entry.outstanding_amount = min(amount, 0) - if (entry.payment_reference is None): - entry.payment_reference = frappe.safe_decode(entry.description) - entry.invoices = ",".join(matching_invoices) - #print("Matching payment is {0}:{1}".format(entry.reference_type, entry.reference_name)) - - def get_matching_payments(self, party, amount, pay_date): - query = """select 'Payment Entry' as reference_type, name as reference_name, paid_amount as amount - from `tabPayment Entry` where party='{0}' and paid_amount={1} and posting_date='{2}' and docstatus != 2 - """.format(party, amount, pay_date) - matching_payments = frappe.db.sql(query, as_dict=True) - return matching_payments - - def map_unknown_transactions(self): - for entry in self.new_transaction_items: - if (entry.party): continue - inv_type = "Sales Invoice" if (entry.amount > 0) else "Purchase Invoice" - party_type = "customer" if (entry.amount > 0) else "supplier" - - query = """select posting_date, name, {0}, outstanding_amount - from `tab{1}` where ROUND(outstanding_amount)={2} and posting_date < '{3}' - """.format(party_type, inv_type, round(abs(entry.amount)), entry.transaction_date) - invoices = frappe.db.sql(query, as_dict = True) - if(len(invoices) > 0): - entry.party = invoices[0].get(party_type) - - def populate_matching_vouchers(self): - for entry in self.new_transaction_items: - if (not entry.party or entry.reference_name): continue - print("Finding matching voucher for {0}".format(frappe.safe_decode(entry.description))) - amount = abs(entry.amount) - invoices = [] - vouchers = get_matching_journal_entries(self.from_date, self.to_date, entry.party, self.bank_account, amount) - if len(vouchers) == 0: continue - for voucher in vouchers: - added = next((entry.invoice for entry in self.payment_invoice_items if entry.invoice == voucher.voucher_no), None) - if (added): - print("Found voucher {0}".format(added)) - continue - print("Adding voucher {0} {1} {2}".format(voucher.voucher_no, voucher.posting_date, voucher.debit)) - ent = self.append('payment_invoice_items', {}) - ent.invoice_date = voucher.posting_date - ent.invoice_type = "Journal Entry" - ent.invoice = voucher.voucher_no - ent.payment_description = frappe.safe_decode(entry.description) - ent.allocated_amount = max(voucher.debit, voucher.credit) - - invoices += [ent.invoice_type + "|" + ent.invoice] - entry.reference_type = "Journal Entry" - entry.mode_of_payment = "Wire Transfer" - entry.reference_name = ent.invoice - #entry.account = entry.party - entry.invoices = ",".join(invoices) - break - - - def create_payment_entries(self): - for payment_entry in self.new_transaction_items: - if (not payment_entry.party): continue - if (payment_entry.reference_name): continue - print("Creating payment entry for {0}".format(frappe.safe_decode(payment_entry.description))) - if (payment_entry.party_type == "Account"): - payment = self.create_journal_entry(payment_entry) - invoices = [payment.doctype + "|" + payment.name] - payment_entry.invoices = ",".join(invoices) - else: - payment = self.create_payment_entry(payment_entry) - invoices = [entry.reference_doctype + "|" + entry.reference_name for entry in payment.references if entry is not None] - payment_entry.invoices = ",".join(invoices) - payment_entry.mode_of_payment = payment.mode_of_payment - payment_entry.account = self.receivable_account if payment_entry.party_type == "Customer" else self.payable_account - payment_entry.reference_name = payment.name - payment_entry.reference_type = payment.doctype - frappe.msgprint(_("Successfully created payment entries")) - - def create_payment_entry(self, pe): - payment = frappe.new_doc("Payment Entry") - payment.posting_date = pe.transaction_date - payment.payment_type = "Receive" if pe.party_type == "Customer" else "Pay" - payment.mode_of_payment = "Wire Transfer" - payment.party_type = pe.party_type - payment.party = pe.party - payment.paid_to = self.bank_account if pe.party_type == "Customer" else self.payable_account - payment.paid_from = self.receivable_account if pe.party_type == "Customer" else self.bank_account - payment.paid_amount = payment.received_amount = abs(pe.amount) - payment.reference_no = pe.description - payment.reference_date = pe.transaction_date - payment.save() - for inv_entry in self.payment_invoice_items: - if (pe.description != inv_entry.payment_description or pe.transaction_date != inv_entry.transaction_date): continue - if (pe.party != inv_entry.party): continue - reference = payment.append("references", {}) - reference.reference_doctype = inv_entry.invoice_type - reference.reference_name = inv_entry.invoice - reference.allocated_amount = inv_entry.allocated_amount - print ("Adding invoice {0} {1}".format(reference.reference_name, reference.allocated_amount)) - payment.setup_party_account_field() - payment.set_missing_values() - #payment.set_exchange_rate() - #payment.set_amounts() - #print("Created payment entry {0}".format(payment.as_dict())) - payment.save() - return payment - - def create_journal_entry(self, pe): - je = frappe.new_doc("Journal Entry") - je.is_opening = "No" - je.voucher_type = "Bank Entry" - je.cheque_no = pe.description - je.cheque_date = pe.transaction_date - je.remark = pe.description - je.posting_date = pe.transaction_date - if (pe.amount < 0): - je.append("accounts", {"account": pe.party, "debit_in_account_currency": abs(pe.amount)}) - je.append("accounts", {"account": self.bank_account, "credit_in_account_currency": abs(pe.amount)}) - else: - je.append("accounts", {"account": pe.party, "credit_in_account_currency": pe.amount}) - je.append("accounts", {"account": self.bank_account, "debit_in_account_currency": pe.amount}) - je.save() - return je - - def update_payment_entry(self, payment): - lst = [] - invoices = payment.invoices.strip().split(',') - if (len(invoices) == 0): return - amount = float(abs(payment.amount)) - for invoice_entry in invoices: - if (not invoice_entry.strip()): continue - invs = invoice_entry.split('|') - invoice_type, invoice = invs[0], invs[1] - outstanding_amount = frappe.get_value(invoice_type, invoice, 'outstanding_amount') - - lst.append(frappe._dict({ - 'voucher_type': payment.reference_type, - 'voucher_no' : payment.reference_name, - 'against_voucher_type' : invoice_type, - 'against_voucher' : invoice, - 'account' : payment.account, - 'party_type': payment.party_type, - 'party': frappe.get_value("Payment Entry", payment.reference_name, "party"), - 'unadjusted_amount' : float(amount), - 'allocated_amount' : min(outstanding_amount, amount) - })) - amount -= outstanding_amount - if lst: - from erpnext.accounts.utils import reconcile_against_document - try: - reconcile_against_document(lst) - except: - frappe.throw(_("Exception occurred while reconciling {0}").format(payment.reference_name)) - - def submit_payment_entries(self): - for payment in self.new_transaction_items: - if payment.reference_name is None: continue - doc = frappe.get_doc(payment.reference_type, payment.reference_name) - if doc.docstatus == 1: - if (payment.reference_type == "Journal Entry"): continue - if doc.unallocated_amount == 0: continue - print("Reconciling payment {0}".format(payment.reference_name)) - self.update_payment_entry(payment) - else: - print("Submitting payment {0}".format(payment.reference_name)) - if (payment.reference_type == "Payment Entry"): - if (payment.payment_reference): - doc.reference_no = payment.payment_reference - doc.mode_of_payment = payment.mode_of_payment - doc.save() - doc.submit() - self.move_reconciled_entries() - self.populate_matching_invoices() - - def move_reconciled_entries(self): - idx = 0 - while idx < len(self.new_transaction_items): - entry = self.new_transaction_items[idx] - try: - print("Checking transaction {0}: {2} in {1} entries".format(idx, len(self.new_transaction_items), frappe.safe_decode(entry.description))) - except UnicodeEncodeError: - pass - idx += 1 - if entry.reference_name is None: continue - doc = frappe.get_doc(entry.reference_type, entry.reference_name) - if doc.docstatus == 1 and (entry.reference_type == "Journal Entry" or doc.unallocated_amount == 0): - self.remove(entry) - rc_entry = self.append('reconciled_transaction_items', {}) - dentry = entry.as_dict() - dentry.pop('idx', None) - rc_entry.update(dentry) - idx -= 1 - - -def get_matching_journal_entries(from_date, to_date, account, against, amount): - query = """select voucher_no, posting_date, account, against, debit_in_account_currency as debit, credit_in_account_currency as credit - from `tabGL Entry` - where posting_date between '{0}' and '{1}' and account = '{2}' and against = '{3}' and debit = '{4}' - """.format(from_date, to_date, account, against, amount) - jv_entries = frappe.db.sql(query, as_dict=True) - #print("voucher query:{0}\n Returned {1} entries".format(query, len(jv_entries))) - return jv_entries - -def get_payments_matching_invoice(invoice, amount, pay_date): - query = """select pe.name as reference_name, per.reference_doctype as reference_type, per.outstanding_amount, per.allocated_amount - from `tabPayment Entry Reference` as per JOIN `tabPayment Entry` as pe on pe.name = per.parent - where per.reference_name='{0}' and (posting_date='{1}' or reference_date='{1}') and pe.docstatus != 2 - """.format(invoice, pay_date) - payments = frappe.db.sql(query, as_dict=True) - if (len(payments) == 0): return - payment = next((payment for payment in payments if payment.allocated_amount == amount), payments[0]) - #Hack: Update the reference type which is set to invoice type - payment.reference_type = "Payment Entry" - return payment - -def is_headers_present(headers, row): - for header in headers: - if header not in row: - return False - return True - -def get_header_index(headers, row): - header_index = {} - for header in headers: - if header in row: - header_index[header] = row.index(header) - return header_index - -def get_transaction_info(headers, header_index, row): - transaction = {} - for header in headers: - transaction[header] = row[header_index[header]] - if (transaction[header] == None): - transaction[header] = "" - return transaction - -def get_transaction_entries(file_url, headers): - header_index = {} - rows, transactions = [], [] - - if (file_url.lower().endswith("xlsx")): - from frappe.utils.xlsxutils import read_xlsx_file_from_attached_file - rows = read_xlsx_file_from_attached_file(file_url=file_url) - elif (file_url.lower().endswith("csv")): - from frappe.utils.csvutils import read_csv_content - _file = frappe.get_doc("File", {"file_url": file_url}) - filepath = _file.get_full_path() - with open(filepath,'rb') as csvfile: - rows = read_csv_content(csvfile.read()) - elif (file_url.lower().endswith("xls")): - filename = file_url.split("/")[-1] - rows = get_rows_from_xls_file(filename) - else: - frappe.throw(_("Only .csv and .xlsx files are supported currently")) - - stmt_headers = headers.values() - for row in rows: - if len(row) == 0 or row[0] == None or not row[0]: continue - #print("Processing row {0}".format(row)) - if header_index: - transaction = get_transaction_info(stmt_headers, header_index, row) - transactions.append(transaction) - elif is_headers_present(stmt_headers, row): - header_index = get_header_index(stmt_headers, row) - return transactions - -def get_rows_from_xls_file(filename): - _file = frappe.get_doc("File", {"file_name": filename}) - filepath = _file.get_full_path() - import xlrd - book = xlrd.open_workbook(filepath) - sheets = book.sheets() - rows = [] - for row in range(1, sheets[0].nrows): - row_values = [] - for col in range(1, sheets[0].ncols): - row_values.append(sheets[0].cell_value(row, col)) - rows.append(row_values) - return rows diff --git a/erpnext/accounts/doctype/bank_statement_transaction_entry/test_bank_statement_transaction_entry.js b/erpnext/accounts/doctype/bank_statement_transaction_entry/test_bank_statement_transaction_entry.js deleted file mode 100644 index 46d570f515a..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_entry/test_bank_statement_transaction_entry.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Bank Statement Transaction Entry", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially([ - // insert a new Bank Statement Transaction Entry - () => frappe.tests.make('Bank Statement Transaction Entry', [ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/erpnext/accounts/doctype/bank_statement_transaction_entry/test_bank_statement_transaction_entry.py b/erpnext/accounts/doctype/bank_statement_transaction_entry/test_bank_statement_transaction_entry.py deleted file mode 100644 index 458948372fb..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_entry/test_bank_statement_transaction_entry.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, sathishpy@gmail.com and Contributors -# See license.txt -from __future__ import unicode_literals - -import frappe -import unittest - -class TestBankStatementTransactionEntry(unittest.TestCase): - pass diff --git a/erpnext/accounts/doctype/bank_statement_transaction_invoice_item/bank_statement_transaction_invoice_item.json b/erpnext/accounts/doctype/bank_statement_transaction_invoice_item/bank_statement_transaction_invoice_item.json deleted file mode 100644 index d96c94d8cac..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_invoice_item/bank_statement_transaction_invoice_item.json +++ /dev/null @@ -1,365 +0,0 @@ -{ - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2017-11-07 13:58:53.827058", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "transaction_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": "Transaction 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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 4, - "fieldname": "payment_description", - "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": "Payment Description", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "party_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": "Party Type", - "length": 0, - "no_copy": 0, - "options": "Customer\nSupplier\nAccount", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "party", - "fieldtype": "Dynamic Link", - "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": "Party", - "length": 0, - "no_copy": 0, - "options": "party_type", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fieldname": "invoice_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": "Invoice Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "invoice_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": "Invoice Type", - "length": 0, - "no_copy": 0, - "options": "Sales Invoice\nPurchase Invoice\nJournal Entry", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fieldname": "invoice", - "fieldtype": "Dynamic 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": "invoice", - "length": 0, - "no_copy": 0, - "options": "invoice_type", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 1, - "fieldname": "outstanding_amount", - "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": "Outstanding Amount", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 1, - "fieldname": "allocated_amount", - "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": "Allocated Amount", - "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 - } - ], - "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-09-14 19:03:30.949831", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Bank Statement Transaction Invoice Item", - "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": 1, - "track_seen": 0, - "track_views": 0 -} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_transaction_payment_item/bank_statement_transaction_payment_item.json b/erpnext/accounts/doctype/bank_statement_transaction_payment_item/bank_statement_transaction_payment_item.json deleted file mode 100644 index 177dccd82cd..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_payment_item/bank_statement_transaction_payment_item.json +++ /dev/null @@ -1,494 +0,0 @@ -{ - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2017-11-07 14:03:05.651413", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 1, - "fieldname": "transaction_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": "Transaction Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 4, - "fieldname": "description", - "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": "Description", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 1, - "fieldname": "amount", - "fieldtype": "Currency", - "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": "Amount", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_3", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 1, - "fieldname": "party_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": "Party Type", - "length": 0, - "no_copy": 0, - "options": "Customer\nSupplier\nAccount", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fieldname": "party", - "fieldtype": "Dynamic 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": "Party", - "length": 0, - "no_copy": 0, - "options": "party_type", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_6", - "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, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "reference_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": "Reference Type", - "length": 0, - "no_copy": 0, - "options": "Payment Entry\nJournal Entry", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "account", - "fieldtype": "Link", - "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": "Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "mode_of_payment", - "fieldtype": "Link", - "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": "Mode of Payment", - "length": 0, - "no_copy": 0, - "options": "Mode of Payment", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "outstanding_amount", - "fieldtype": "Currency", - "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": "outstanding_amount", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "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, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 2, - "fieldname": "reference_name", - "fieldtype": "Dynamic 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": "Reference Name", - "length": 0, - "no_copy": 0, - "options": "reference_type", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_reference", - "fieldtype": "Data", - "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": "Payment Reference", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "invoices", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Invoices", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - } - ], - "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": "2017-11-15 19:18:52.876221", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Bank Statement Transaction Payment Item", - "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": 1, - "track_seen": 0 -} diff --git a/erpnext/accounts/doctype/bank_statement_transaction_settings/bank_statement_transaction_settings.js b/erpnext/accounts/doctype/bank_statement_transaction_settings/bank_statement_transaction_settings.js deleted file mode 100644 index 46aa4f20311..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_settings/bank_statement_transaction_settings.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2017, sathishpy@gmail.com and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Bank Statement Settings', { - refresh: function(frm) { - - } -}); diff --git a/erpnext/accounts/doctype/bank_statement_transaction_settings/bank_statement_transaction_settings.json b/erpnext/accounts/doctype/bank_statement_transaction_settings/bank_statement_transaction_settings.json deleted file mode 100644 index 474bb90db75..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_settings/bank_statement_transaction_settings.json +++ /dev/null @@ -1,266 +0,0 @@ -{ - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 1, - "beta": 0, - "creation": "2017-11-13 13:38:10.863592", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "bank_account", - "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": "Bank Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "'%d/%m/%Y'", - "fieldname": "date_format", - "fieldtype": "Data", - "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": "Date Format", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "statement_header_mapping", - "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, - "label": "Statement Header Mapping", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "header_items", - "fieldtype": "Table", - "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": "Statement Headers", - "length": 0, - "no_copy": 0, - "options": "Bank Statement Settings Item", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "transaction_data_mapping", - "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, - "label": "Transaction Data Mapping", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "mapped_items", - "fieldtype": "Table", - "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": "Mapped Items", - "length": 0, - "no_copy": 0, - "options": "Bank Statement Transaction Settings Item", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - } - ], - "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-01-12 10:34:32.840487", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Bank Statement Settings", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "apply_user_permissions": 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, - "write": 1 - }, - { - "amend": 0, - "apply_user_permissions": 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": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "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": 1, - "track_seen": 0 -} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_transaction_settings/bank_statement_transaction_settings.py b/erpnext/accounts/doctype/bank_statement_transaction_settings/bank_statement_transaction_settings.py deleted file mode 100644 index de9a85fe5c6..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_settings/bank_statement_transaction_settings.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, sathishpy@gmail.com and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe.model.document import Document - -class BankStatementSettings(Document): - def autoname(self): - self.name = self.bank_account + "-Mappings" diff --git a/erpnext/accounts/doctype/bank_statement_transaction_settings/test_bank_statement_transaction_settings.js b/erpnext/accounts/doctype/bank_statement_transaction_settings/test_bank_statement_transaction_settings.js deleted file mode 100644 index f2381c042ee..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_settings/test_bank_statement_transaction_settings.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Bank Statement Settings", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially([ - // insert a new Bank Statement Settings - () => frappe.tests.make('Bank Statement Settings', [ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/erpnext/accounts/doctype/bank_statement_transaction_settings/test_bank_statement_transaction_settings.py b/erpnext/accounts/doctype/bank_statement_transaction_settings/test_bank_statement_transaction_settings.py deleted file mode 100644 index aa7fe833285..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_settings/test_bank_statement_transaction_settings.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, sathishpy@gmail.com and Contributors -# See license.txt -from __future__ import unicode_literals - -import frappe -import unittest - -class TestBankStatementSettings(unittest.TestCase): - pass diff --git a/erpnext/accounts/doctype/bank_statement_transaction_settings_item/bank_statement_transaction_settings_item.json b/erpnext/accounts/doctype/bank_statement_transaction_settings_item/bank_statement_transaction_settings_item.json deleted file mode 100644 index 47c32097a9e..00000000000 --- a/erpnext/accounts/doctype/bank_statement_transaction_settings_item/bank_statement_transaction_settings_item.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2017-11-13 13:42:00.335432", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Transaction", - "fieldname": "mapping_type", - "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": "Mapping Type", - "length": 0, - "no_copy": 0, - "options": "Transaction", - "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, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "bank_data", - "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": "Bank Data", - "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, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Account", - "fieldname": "mapped_data_type", - "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": "Mapped Data Type", - "length": 0, - "no_copy": 0, - "options": "Account\nCustomer\nSupplier\nAccount", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "mapped_data", - "fieldtype": "Dynamic 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": "Mapped Data", - "length": 0, - "no_copy": 0, - "options": "mapped_data_type", - "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, - "unique": 0 - } - ], - "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-01-08 00:13:49.973501", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Bank Statement Transaction Settings Item", - "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": 1, - "track_seen": 0 -} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js index 8b1bab16189..3758b524da5 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js @@ -1,32 +1,70 @@ // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -frappe.ui.form.on('Bank Transaction', { +frappe.ui.form.on("Bank Transaction", { onload(frm) { - frm.set_query('payment_document', 'payment_entries', function() { + frm.set_query("payment_document", "payment_entries", function () { return { - "filters": { - "name": ["in", ["Payment Entry", "Journal Entry", "Sales Invoice", "Purchase Invoice", "Expense Claim"]] - } + filters: { + name: [ + "in", + [ + "Payment Entry", + "Journal Entry", + "Sales Invoice", + "Purchase Invoice", + "Expense Claim", + ], + ], + }, }; }); - } + }, + bank_account: function (frm) { + set_bank_statement_filter(frm); + }, + + setup: function (frm) { + frm.set_query("party_type", function () { + return { + filters: { + name: ["in", Object.keys(frappe.boot.party_account_types)], + }, + }; + }); + }, }); -frappe.ui.form.on('Bank Transaction Payments', { - payment_entries_remove: function(frm, cdt, cdn) { +frappe.ui.form.on("Bank Transaction Payments", { + payment_entries_remove: function (frm, cdt, cdn) { update_clearance_date(frm, cdt, cdn); - } + }, }); const update_clearance_date = (frm, cdt, cdn) => { if (frm.doc.docstatus === 1) { - frappe.xcall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unclear_reference_payment', - {doctype: cdt, docname: cdn}) - .then(e => { + frappe + .xcall( + "erpnext.accounts.doctype.bank_transaction.bank_transaction.unclear_reference_payment", + { doctype: cdt, docname: cdn } + ) + .then((e) => { if (e == "success") { - frappe.show_alert({message:__("Document {0} successfully uncleared", [e]), indicator:'green'}); + frappe.show_alert({ + message: __("Document {0} successfully uncleared", [e]), + indicator: "green", + }); } }); } -}; \ No newline at end of file +}; + +function set_bank_statement_filter(frm) { + frm.set_query("bank_statement", function () { + return { + filters: { + bank_account: frm.doc.bank_account, + }, + }; + }); +} diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json index 39937bb3645..69ee4971cd5 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json @@ -1,833 +1,245 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, + "actions": [], "allow_import": 1, - "allow_rename": 0, "autoname": "naming_series:", - "beta": 0, "creation": "2018-10-22 18:19:02.784533", - "custom": 0, - "docstatus": 0, "doctype": "DocType", - "document_type": "", "editable_grid": 1, "engine": "InnoDB", + "field_order": [ + "naming_series", + "date", + "column_break_2", + "status", + "bank_account", + "company", + "section_break_4", + "deposit", + "withdrawal", + "column_break_7", + "currency", + "section_break_10", + "description", + "section_break_14", + "reference_number", + "transaction_id", + "payment_entries", + "section_break_18", + "allocated_amount", + "amended_from", + "column_break_17", + "unallocated_amount", + "party_section", + "party_type", + "party" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "ACC-BTN-.YYYY.-", - "fetch_if_empty": 0, "fieldname": "naming_series", "fieldtype": "Select", "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Series", - "length": 0, "no_copy": 1, "options": "ACC-BTN-.YYYY.-", - "permlevel": 0, - "precision": "", "print_hide": 1, - "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": 1, - "translatable": 0, - "unique": 0 + "set_only_once": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "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": "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 + "label": "Date" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 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 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "Pending", - "fetch_if_empty": 0, "fieldname": "status", "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": 1, "label": "Status", - "length": 0, - "no_copy": 0, - "options": "\nPending\nSettled\nUnreconciled\nReconciled", - "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 + "options": "\nPending\nSettled\nUnreconciled\nReconciled" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "bank_account", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, "in_standard_filter": 1, "label": "Bank Account", - "length": 0, - "no_copy": 0, - "options": "Bank Account", - "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 + "options": "Bank Account" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "", "fetch_from": "bank_account.company", - "fetch_if_empty": 0, "fieldname": "company", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, "in_standard_filter": 1, "label": "Company", - "length": 0, - "no_copy": 0, "options": "Company", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "read_only": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "section_break_4", - "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 + "fieldtype": "Section Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "debit", - "fieldtype": "Currency", - "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": "Debit", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "credit", - "fieldtype": "Currency", - "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": "Credit", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "column_break_7", - "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 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "currency", "fieldtype": "Link", - "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": "Currency", - "length": 0, - "no_copy": 0, - "options": "Currency", - "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 + "options": "Currency" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 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 + "fieldtype": "Section Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "description", "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, - "label": "Description", - "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 + "label": "Description" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "section_break_14", - "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 + "fieldtype": "Section Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, + "allow_on_submit": 1, "fieldname": "reference_number", "fieldtype": "Data", - "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": "Reference Number", - "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 + "label": "Reference Number" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "transaction_id", "fieldtype": "Data", - "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": "Transaction ID", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, "unique": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "payment_entries", "fieldtype": "Table", - "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": "Payment Entries", - "length": 0, - "no_copy": 0, - "options": "Bank Transaction Payments", - "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 + "options": "Bank Transaction Payments" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "section_break_18", - "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 + "fieldtype": "Section Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "allocated_amount", "fieldtype": "Currency", - "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": "Allocated Amount", - "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 + "label": "Allocated Amount" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "amended_from", "fieldtype": "Link", - "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": "Amended From", - "length": 0, "no_copy": 1, "options": "Bank Transaction", - "permlevel": 0, "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "read_only": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "column_break_17", - "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 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "fetch_if_empty": 0, "fieldname": "unallocated_amount", "fieldtype": "Currency", - "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": "Unallocated Amount", - "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 + "label": "Unallocated Amount" + }, + { + "fieldname": "party_section", + "fieldtype": "Section Break", + "label": "Payment From / To" + }, + { + "allow_on_submit": 1, + "fieldname": "party_type", + "fieldtype": "Link", + "label": "Party Type", + "options": "DocType" + }, + { + "allow_on_submit": 1, + "fieldname": "party", + "fieldtype": "Dynamic Link", + "label": "Party", + "options": "party_type" + }, + { + "fieldname": "deposit", + "oldfieldname": "debit", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Deposit" + }, + { + "fieldname": "withdrawal", + "oldfieldname": "credit", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Withdrawal" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2019-05-11 05:27:55.244721", + "links": [], + "modified": "2020-12-30 19:40:54.221070", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Transaction", - "name_case": "", "owner": "Administrator", "permissions": [ { - "amend": 0, "cancel": 1, "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": 1, "write": 1 }, { - "amend": 0, "cancel": 1, "create": 1, "delete": 1, "email": 1, "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "Accounts Manager", - "set_user_permissions": 0, "share": 1, "submit": 1, "write": 1 }, { - "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": "Accounts User", - "set_user_permissions": 0, "share": 1, "submit": 1, "write": 1 } ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, "sort_field": "date", "sort_order": "DESC", "title_field": "bank_account", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + "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 0e45db3dbc0..5246baa02b3 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -11,7 +11,7 @@ from frappe import _ class BankTransaction(StatusUpdater): def after_insert(self): - self.unallocated_amount = abs(flt(self.credit) - flt(self.debit)) + self.unallocated_amount = abs(flt(self.withdrawal) - flt(self.deposit)) def on_submit(self): self.clear_linked_payment_entries() @@ -30,13 +30,13 @@ class BankTransaction(StatusUpdater): 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.credit) - flt(self.debit)) - flt(allocated_amount)) + frappe.db.set_value(self.doctype, self.name, "unallocated_amount", abs(flt(self.withdrawal) - flt(self.deposit)) - flt(allocated_amount)) 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.credit) - flt(self.debit))) + frappe.db.set_value(self.doctype, self.name, "unallocated_amount", abs(flt(self.withdrawal) - flt(self.deposit))) - amount = self.debit or self.credit + amount = self.deposit or self.withdrawal if amount == self.allocated_amount: frappe.db.set_value(self.doctype, self.name, "status", "Reconciled") @@ -44,18 +44,11 @@ class BankTransaction(StatusUpdater): def clear_linked_payment_entries(self): for payment_entry in self.payment_entries: - allocated_amount = get_total_allocated_amount(payment_entry) - paid_amount = get_paid_amount(payment_entry, self.currency) + if payment_entry.payment_document in ["Payment Entry", "Journal Entry", "Purchase Invoice", "Expense Claim"]: + self.clear_simple_entry(payment_entry) - if paid_amount and allocated_amount: - if flt(allocated_amount[0]["allocated_amount"]) > flt(paid_amount): - frappe.throw(_("The total allocated amount ({0}) is greated than the paid amount ({1}).").format(flt(allocated_amount[0]["allocated_amount"]), flt(paid_amount))) - else: - if payment_entry.payment_document in ["Payment Entry", "Journal Entry", "Purchase Invoice", "Expense Claim"]: - self.clear_simple_entry(payment_entry) - - elif payment_entry.payment_document == "Sales Invoice": - self.clear_sales_invoice(payment_entry) + elif payment_entry.payment_document == "Sales Invoice": + self.clear_sales_invoice(payment_entry) def clear_simple_entry(self, payment_entry): frappe.db.set_value(payment_entry.payment_document, payment_entry.payment_entry, "clearance_date", self.date) @@ -112,3 +105,4 @@ def unclear_reference_payment(doctype, docname): frappe.db.set_value(doc.payment_document, doc.payment_entry, "clearance_date", None) return doc.payment_entry + diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 27546335c91..3b14e4efa02 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -5,17 +5,20 @@ from __future__ import unicode_literals import frappe import unittest +import json from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry -from erpnext.accounts.page.bank_reconciliation.bank_reconciliation import reconcile, get_linked_payments +from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import reconcile_vouchers, get_linked_payments +from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile test_dependencies = ["Item", "Cost Center"] class TestBankTransaction(unittest.TestCase): def setUp(self): + make_pos_profile() add_transactions() - add_payments() + add_vouchers() def tearDown(self): for bt in frappe.get_all("Bank Transaction"): @@ -27,20 +30,27 @@ class TestBankTransaction(unittest.TestCase): frappe.db.sql("""delete from `tabPayment Entry Reference`""") frappe.db.sql("""delete from `tabPayment Entry`""") + # Delete POS Profile + frappe.db.sql("delete from `tabPOS Profile`") + frappe.flags.test_bank_transactions_created = False frappe.flags.test_payments_created = False # This test checks if ERPNext is able to provide a linked payment for a bank transaction based on the amount of the bank transaction. def test_linked_payments(self): bank_transaction = frappe.get_doc("Bank Transaction", dict(description="Re 95282925234 FE/000002917 AT171513000281183046 Conrad Electronic")) - linked_payments = get_linked_payments(bank_transaction.name) - self.assertTrue(linked_payments[0].party == "Conrad Electronic") + linked_payments = get_linked_payments(bank_transaction.name, ['payment_entry', 'exact_match']) + 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 def test_reconcile(self): bank_transaction = frappe.get_doc("Bank Transaction", dict(description="1512567 BG/000002918 OPSKATTUZWXXX AT776000000098709837 Herr G")) payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1200)) - reconcile(bank_transaction.name, "Payment Entry", payment.name) + vouchers = json.dumps([{ + "payment_doctype":"Payment Entry", + "payment_name":payment.name, + "amount":bank_transaction.unallocated_amount}]) + reconcile_vouchers(bank_transaction.name, vouchers) unallocated_amount = frappe.db.get_value("Bank Transaction", bank_transaction.name, "unallocated_amount") self.assertTrue(unallocated_amount == 0) @@ -48,45 +58,40 @@ class TestBankTransaction(unittest.TestCase): clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date") self.assertTrue(clearance_date is not None) - # Check if ERPNext can correctly fetch a linked payment based on the party - def test_linked_payments_based_on_party(self): - bank_transaction = frappe.get_doc("Bank Transaction", dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G")) - linked_payments = get_linked_payments(bank_transaction.name) - self.assertTrue(len(linked_payments)==1) - # Check if ERPNext can correctly filter a linked payments based on the debit/credit amount def test_debit_credit_output(self): bank_transaction = frappe.get_doc("Bank Transaction", dict(description="Auszahlung Karte MC/000002916 AUTOMAT 698769 K002 27.10. 14:07")) - linked_payments = get_linked_payments(bank_transaction.name) - self.assertTrue(linked_payments[0].payment_type == "Pay") + linked_payments = get_linked_payments(bank_transaction.name, ['payment_entry', 'exact_match']) + print(linked_payments) + self.assertTrue(linked_payments[0][3]) # Check error if already reconciled def test_already_reconciled(self): bank_transaction = frappe.get_doc("Bank Transaction", dict(description="1512567 BG/000002918 OPSKATTUZWXXX AT776000000098709837 Herr G")) payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1200)) - reconcile(bank_transaction.name, "Payment Entry", payment.name) + vouchers = json.dumps([{ + "payment_doctype":"Payment Entry", + "payment_name":payment.name, + "amount":bank_transaction.unallocated_amount}]) + reconcile_vouchers(bank_transaction.name, vouchers) bank_transaction = frappe.get_doc("Bank Transaction", dict(description="1512567 BG/000002918 OPSKATTUZWXXX AT776000000098709837 Herr G")) payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1200)) - self.assertRaises(frappe.ValidationError, reconcile, bank_transaction=bank_transaction.name, payment_doctype="Payment Entry", payment_name=payment.name) - - # Raise an error if creditor transaction vs creditor payment - def test_invalid_creditor_reconcilation(self): - bank_transaction = frappe.get_doc("Bank Transaction", dict(description="I2015000011 VD/000002514 ATWWXXX AT4701345000003510057 Bio")) - payment = frappe.get_doc("Payment Entry", dict(party="Conrad Electronic", paid_amount=690)) - self.assertRaises(frappe.ValidationError, reconcile, bank_transaction=bank_transaction.name, payment_doctype="Payment Entry", payment_name=payment.name) - - # Raise an error if debitor transaction vs debitor payment - def test_invalid_debitor_reconcilation(self): - bank_transaction = frappe.get_doc("Bank Transaction", dict(description="Auszahlung Karte MC/000002916 AUTOMAT 698769 K002 27.10. 14:07")) - payment = frappe.get_doc("Payment Entry", dict(party="Fayva", paid_amount=109080)) - self.assertRaises(frappe.ValidationError, reconcile, bank_transaction=bank_transaction.name, payment_doctype="Payment Entry", payment_name=payment.name) + vouchers = json.dumps([{ + "payment_doctype":"Payment Entry", + "payment_name":payment.name, + "amount":bank_transaction.unallocated_amount}]) + self.assertRaises(frappe.ValidationError, reconcile_vouchers, bank_transaction_name=bank_transaction.name, vouchers=vouchers) # Raise an error if debitor transaction vs debitor payment def test_clear_sales_invoice(self): bank_transaction = frappe.get_doc("Bank Transaction", dict(description="I2015000011 VD/000002514 ATWWXXX AT4701345000003510057 Bio")) payment = frappe.get_doc("Sales Invoice", dict(customer="Fayva", status=["=", "Paid"])) - reconcile(bank_transaction.name, "Sales Invoice", payment.name) + vouchers = json.dumps([{ + "payment_doctype":"Sales Invoice", + "payment_name":payment.name, + "amount":bank_transaction.unallocated_amount}]) + reconcile_vouchers(bank_transaction.name, vouchers=vouchers) self.assertEqual(frappe.db.get_value("Bank Transaction", bank_transaction.name, "unallocated_amount"), 0) self.assertTrue(frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date") is not None) @@ -121,7 +126,7 @@ def add_transactions(): "doctype": "Bank Transaction", "description":"1512567 BG/000002918 OPSKATTUZWXXX AT776000000098709837 Herr G", "date": "2018-10-23", - "debit": 1200, + "deposit": 1200, "currency": "INR", "bank_account": "Checking Account - Citi Bank" }).insert() @@ -131,7 +136,7 @@ def add_transactions(): "doctype": "Bank Transaction", "description":"1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G", "date": "2018-10-23", - "debit": 1700, + "deposit": 1700, "currency": "INR", "bank_account": "Checking Account - Citi Bank" }).insert() @@ -141,7 +146,7 @@ def add_transactions(): "doctype": "Bank Transaction", "description":"Re 95282925234 FE/000002917 AT171513000281183046 Conrad Electronic", "date": "2018-10-26", - "debit": 690, + "withdrawal": 690, "currency": "INR", "bank_account": "Checking Account - Citi Bank" }).insert() @@ -151,7 +156,7 @@ def add_transactions(): "doctype": "Bank Transaction", "description":"Auszahlung Karte MC/000002916 AUTOMAT 698769 K002 27.10. 14:07", "date": "2018-10-27", - "debit": 3900, + "deposit": 3900, "currency": "INR", "bank_account": "Checking Account - Citi Bank" }).insert() @@ -161,7 +166,7 @@ def add_transactions(): "doctype": "Bank Transaction", "description":"I2015000011 VD/000002514 ATWWXXX AT4701345000003510057 Bio", "date": "2018-10-27", - "credit": 109080, + "withdrawal": 109080, "currency": "INR", "bank_account": "Checking Account - Citi Bank" }).insert() @@ -169,7 +174,7 @@ def add_transactions(): frappe.flags.test_bank_transactions_created = True -def add_payments(): +def add_vouchers(): if frappe.flags.test_payments_created: return @@ -187,6 +192,7 @@ def add_payments(): pass pi = make_purchase_invoice(supplier="Conrad Electronic", qty=1, rate=690) + pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC") pe.reference_no = "Conrad Oct 18" pe.reference_date = "2018-10-24" @@ -237,10 +243,15 @@ def add_payments(): except frappe.DuplicateEntryError: pass - pi = make_purchase_invoice(supplier="Poore Simon's", qty=1, rate=3900) + pi = make_purchase_invoice(supplier="Poore Simon's", qty=1, rate=3900, is_paid=1, do_not_save =1) + pi.cash_bank_account = "_Test Bank - _TC" + pi.insert() + pi.submit() pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Bank - _TC") pe.reference_no = "Poore Simon's Oct 18" pe.reference_date = "2018-10-28" + pe.paid_amount = 690 + pe.received_amount = 690 pe.insert() pe.submit() @@ -290,4 +301,4 @@ def add_payments(): si.save() si.submit() - frappe.flags.test_payments_created = True \ No newline at end of file + frappe.flags.test_payments_created = True diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js index cadf1e7e0ca..e162e3222d3 100644 --- a/erpnext/accounts/doctype/budget/budget.js +++ b/erpnext/accounts/doctype/budget/budget.js @@ -1,24 +1,9 @@ // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +frappe.provide("erpnext.accounts.dimensions"); frappe.ui.form.on('Budget', { onload: function(frm) { - frm.set_query("cost_center", function() { - return { - filters: { - company: frm.doc.company - } - } - }) - - frm.set_query("project", function() { - return { - filters: { - company: frm.doc.company - } - } - }) - frm.set_query("account", "accounts", function() { return { filters: { @@ -26,16 +11,18 @@ frappe.ui.form.on('Budget', { report_type: "Profit and Loss", is_group: 0 } - } - }) - + }; + }); + frm.set_query("monthly_distribution", function() { return { filters: { fiscal_year: frm.doc.fiscal_year } - } - }) + }; + }); + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, refresh: function(frm) { diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 50ad2e5ebce..fc4dd200ea2 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -1,814 +1,224 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 1, - "allow_rename": 0, - "beta": 0, - "creation": "2016-05-16 11:42:29.632528", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, + "actions": [], + "allow_import": 1, + "creation": "2016-05-16 11:42:29.632528", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "budget_against", + "company", + "cost_center", + "project", + "fiscal_year", + "column_break_3", + "monthly_distribution", + "amended_from", + "section_break_6", + "applicable_on_material_request", + "action_if_annual_budget_exceeded_on_mr", + "action_if_accumulated_monthly_budget_exceeded_on_mr", + "column_break_13", + "applicable_on_purchase_order", + "action_if_annual_budget_exceeded_on_po", + "action_if_accumulated_monthly_budget_exceeded_on_po", + "section_break_16", + "applicable_on_booking_actual_expenses", + "action_if_annual_budget_exceeded", + "action_if_accumulated_monthly_budget_exceeded", + "section_break_21", + "accounts" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Cost Center", - "fetch_if_empty": 0, - "fieldname": "budget_against", - "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": 1, - "label": "Budget Against", - "length": 0, - "no_copy": 0, - "options": "\nCost Center\nProject", - "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 - }, + "default": "Cost Center", + "fieldname": "budget_against", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Budget Against", + "options": "\nCost Center\nProject", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "company", - "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": 1, - "label": "Company", - "length": 0, - "no_copy": 0, - "options": "Company", - "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": "company", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.budget_against == 'Cost Center'", - "fetch_if_empty": 0, - "fieldname": "cost_center", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "Cost Center", - "length": 0, - "no_copy": 0, - "options": "Cost Center", - "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 - }, + "depends_on": "eval:doc.budget_against == 'Cost Center'", + "fieldname": "cost_center", + "fieldtype": "Link", + "in_global_search": 1, + "in_standard_filter": 1, + "label": "Cost Center", + "options": "Cost Center" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.budget_against == 'Project'", - "fetch_if_empty": 0, - "fieldname": "project", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "Project", - "length": 0, - "no_copy": 0, - "options": "Project", - "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 - }, + "depends_on": "eval:doc.budget_against == 'Project'", + "fieldname": "project", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Project", + "options": "Project" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "fiscal_year", - "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": 1, - "label": "Fiscal Year", - "length": 0, - "no_copy": 0, - "options": "Fiscal Year", - "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": "fiscal_year", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Fiscal Year", + "options": "Fiscal Year", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "column_break_3", - "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 - }, + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:in_list([\"Stop\", \"Warn\"], doc.action_if_accumulated_monthly_budget_exceeded_on_po || doc.action_if_accumulated_monthly_budget_exceeded_on_mr || doc.action_if_accumulated_monthly_budget_exceeded_on_actual)", - "fetch_if_empty": 0, - "fieldname": "monthly_distribution", - "fieldtype": "Link", - "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": "Monthly Distribution", - "length": 0, - "no_copy": 0, - "options": "Monthly Distribution", - "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 - }, + "depends_on": "eval:in_list([\"Stop\", \"Warn\"], doc.action_if_accumulated_monthly_budget_exceeded_on_po || doc.action_if_accumulated_monthly_budget_exceeded_on_mr || doc.action_if_accumulated_monthly_budget_exceeded_on_actual)", + "fieldname": "monthly_distribution", + "fieldtype": "Link", + "label": "Monthly Distribution", + "options": "Monthly Distribution" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "amended_from", - "fieldtype": "Link", - "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": "Amended From", - "length": 0, - "no_copy": 1, - "options": "Budget", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Budget", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "section_break_6", - "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, - "label": "Control Action", - "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 - }, + "fieldname": "section_break_6", + "fieldtype": "Section Break", + "label": "Control Action" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "applicable_on_material_request", - "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": "Applicable on Material Request", - "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 - }, + "default": "0", + "fieldname": "applicable_on_material_request", + "fieldtype": "Check", + "label": "Applicable on Material Request" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Stop", - "depends_on": "eval:doc.applicable_on_material_request == 1", - "fetch_if_empty": 0, - "fieldname": "action_if_annual_budget_exceeded_on_mr", - "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": "Action if Annual Budget Exceeded on MR", - "length": 0, - "no_copy": 0, - "options": "\nStop\nWarn\nIgnore", - "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_on_submit": 1, + "default": "Stop", + "depends_on": "eval:doc.applicable_on_material_request == 1", + "fieldname": "action_if_annual_budget_exceeded_on_mr", + "fieldtype": "Select", + "label": "Action if Annual Budget Exceeded on MR", + "options": "\nStop\nWarn\nIgnore" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Warn", - "depends_on": "eval:doc.applicable_on_material_request == 1", - "fetch_if_empty": 0, - "fieldname": "action_if_accumulated_monthly_budget_exceeded_on_mr", - "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": "Action if Accumulated Monthly Budget Exceeded on MR", - "length": 0, - "no_copy": 0, - "options": "\nStop\nWarn\nIgnore", - "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_on_submit": 1, + "default": "Warn", + "depends_on": "eval:doc.applicable_on_material_request == 1", + "fieldname": "action_if_accumulated_monthly_budget_exceeded_on_mr", + "fieldtype": "Select", + "label": "Action if Accumulated Monthly Budget Exceeded on MR", + "options": "\nStop\nWarn\nIgnore" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "column_break_13", - "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 - }, + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "applicable_on_purchase_order", - "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": "Applicable on Purchase Order", - "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 - }, + "default": "0", + "fieldname": "applicable_on_purchase_order", + "fieldtype": "Check", + "label": "Applicable on Purchase Order" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Stop", - "depends_on": "eval:doc.applicable_on_purchase_order == 1", - "fetch_if_empty": 0, - "fieldname": "action_if_annual_budget_exceeded_on_po", - "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": "Action if Annual Budget Exceeded on PO", - "length": 0, - "no_copy": 0, - "options": "\nStop\nWarn\nIgnore", - "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_on_submit": 1, + "default": "Stop", + "depends_on": "eval:doc.applicable_on_purchase_order == 1", + "fieldname": "action_if_annual_budget_exceeded_on_po", + "fieldtype": "Select", + "label": "Action if Annual Budget Exceeded on PO", + "options": "\nStop\nWarn\nIgnore" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Warn", - "depends_on": "eval:doc.applicable_on_purchase_order == 1", - "fetch_if_empty": 0, - "fieldname": "action_if_accumulated_monthly_budget_exceeded_on_po", - "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": "Action if Accumulated Monthly Budget Exceeded on PO", - "length": 0, - "no_copy": 0, - "options": "\nStop\nWarn\nIgnore", - "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_on_submit": 1, + "default": "Warn", + "depends_on": "eval:doc.applicable_on_purchase_order == 1", + "fieldname": "action_if_accumulated_monthly_budget_exceeded_on_po", + "fieldtype": "Select", + "label": "Action if Accumulated Monthly Budget Exceeded on PO", + "options": "\nStop\nWarn\nIgnore" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "section_break_16", - "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 - }, + "fieldname": "section_break_16", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "applicable_on_booking_actual_expenses", - "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": "Applicable on booking actual expenses", - "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 - }, + "default": "0", + "fieldname": "applicable_on_booking_actual_expenses", + "fieldtype": "Check", + "label": "Applicable on booking actual expenses" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Stop", - "depends_on": "eval:doc.applicable_on_booking_actual_expenses == 1", - "fetch_if_empty": 0, - "fieldname": "action_if_annual_budget_exceeded", - "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": "Action if Annual Budget Exceeded on Actual", - "length": 0, - "no_copy": 0, - "options": "\nStop\nWarn\nIgnore", - "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_on_submit": 1, + "default": "Stop", + "depends_on": "eval:doc.applicable_on_booking_actual_expenses == 1", + "fieldname": "action_if_annual_budget_exceeded", + "fieldtype": "Select", + "label": "Action if Annual Budget Exceeded on Actual", + "options": "\nStop\nWarn\nIgnore" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Warn", - "depends_on": "eval:doc.applicable_on_booking_actual_expenses == 1", - "fetch_if_empty": 0, - "fieldname": "action_if_accumulated_monthly_budget_exceeded", - "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": "Action if Accumulated Monthly Budget Exceeded on Actual", - "length": 0, - "no_copy": 0, - "options": "\nStop\nWarn\nIgnore", - "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_on_submit": 1, + "default": "Warn", + "depends_on": "eval:doc.applicable_on_booking_actual_expenses == 1", + "fieldname": "action_if_accumulated_monthly_budget_exceeded", + "fieldtype": "Select", + "label": "Action if Accumulated Monthly Budget Exceeded on Actual", + "options": "\nStop\nWarn\nIgnore" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "section_break_21", - "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 - }, + "fieldname": "section_break_21", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "fetch_if_empty": 0, - "fieldname": "accounts", - "fieldtype": "Table", - "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": "Budget Accounts", - "length": 0, - "no_copy": 0, - "options": "Budget Account", - "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": "accounts", + "fieldtype": "Table", + "label": "Budget Accounts", + "options": "Budget Account", + "reqd": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2019-03-22 12:06:02.323099", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Budget", - "name_case": "", - "owner": "Administrator", + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2020-10-06 15:13:54.055854", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Budget", + "owner": "Administrator", "permissions": [ { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 1, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "import": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "submit": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 61c48c74990..c5ec23c8295 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -122,8 +122,10 @@ class TestBudget(unittest.TestCase): frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") + project = frappe.get_value("Project", {"project_name": "_Test Project"}) + jv = make_journal_entry("_Test Account Cost for Goods Sold - _TC", - "_Test Bank - _TC", 40000, "_Test Cost Center - _TC", project="_Test Project", posting_date=nowdate()) + "_Test Bank - _TC", 40000, "_Test Cost Center - _TC", project=project, posting_date=nowdate()) self.assertRaises(BudgetError, jv.submit) @@ -147,8 +149,11 @@ class TestBudget(unittest.TestCase): budget = make_budget(budget_against="Project") + project = frappe.get_value("Project", {"project_name": "_Test Project"}) + jv = make_journal_entry("_Test Account Cost for Goods Sold - _TC", - "_Test Bank - _TC", 250000, "_Test Cost Center - _TC", project="_Test Project", posting_date=nowdate()) + "_Test Bank - _TC", 250000, "_Test Cost Center - _TC", + project=project, posting_date=nowdate()) self.assertRaises(BudgetError, jv.submit) @@ -158,8 +163,11 @@ class TestBudget(unittest.TestCase): set_total_expense_zero(nowdate(), "cost_center") budget = make_budget(budget_against="Cost Center") + month = now_datetime().month + if month > 9: + month = 9 - for i in range(now_datetime().month): + for i in range(month+1): jv = make_journal_entry("_Test Account Cost for Goods Sold - _TC", "_Test Bank - _TC", 20000, "_Test Cost Center - _TC", posting_date=nowdate(), submit=True) @@ -177,10 +185,15 @@ class TestBudget(unittest.TestCase): set_total_expense_zero(nowdate(), "project") budget = make_budget(budget_against="Project") + month = now_datetime().month + if month > 9: + month = 9 - for i in range(now_datetime().month): + project = frappe.get_value("Project", {"project_name": "_Test Project"}) + for i in range(month + 1): jv = make_journal_entry("_Test Account Cost for Goods Sold - _TC", - "_Test Bank - _TC", 20000, "_Test Cost Center - _TC", posting_date=nowdate(), submit=True, project="_Test Project") + "_Test Bank - _TC", 20000, "_Test Cost Center - _TC", posting_date=nowdate(), submit=True, + project=project) self.assertTrue(frappe.db.get_value("GL Entry", {"voucher_type": "Journal Entry", "voucher_no": jv.name})) @@ -283,7 +296,7 @@ def make_budget(**args): budget = frappe.new_doc("Budget") if budget_against == "Project": - budget.project = "_Test Project" + budget.project = frappe.get_value("Project", {"project_name": "_Test Project"}) else: budget.cost_center =cost_center or "_Test Cost Center - _TC" diff --git a/erpnext/accounts/doctype/cashier_closing/cashier_closing.py b/erpnext/accounts/doctype/cashier_closing/cashier_closing.py index 6de62ee5777..7ad1d3ab831 100644 --- a/erpnext/accounts/doctype/cashier_closing/cashier_closing.py +++ b/erpnext/accounts/doctype/cashier_closing/cashier_closing.py @@ -23,13 +23,13 @@ class CashierClosing(Document): where posting_date=%s and posting_time>=%s and posting_time<=%s and owner=%s """, (self.date, self.from_time, self.time, self.user)) self.outstanding_amount = flt(values[0][0] if values else 0) - + def make_calculations(self): total = 0.00 for i in self.payments: total += flt(i.amount) - self.net_amount = total + self.outstanding_amount + self.expense - self.custody + self.returns + self.net_amount = total + self.outstanding_amount + flt(self.expense) - flt(self.custody) + flt(self.returns) def validate_time(self): if self.from_time >= self.time: diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js index 2235298201f..f795dfa83e6 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js @@ -94,8 +94,7 @@ frappe.ui.form.on('Chart of Accounts Importer', { callback: function(r) { if(r.message===false) { frm.set_value("company", ""); - frappe.throw(__(`Transactions against the company already exist! - Chart Of accounts can be imported for company with no transactions`)); + frappe.throw(__("Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions.")); } else { frm.trigger("refresh"); } 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 e1b331be2b3..03c3eb0ac0b 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 @@ -22,9 +22,10 @@ def validate_company(company): 'allow_account_creation_against_child_company']) if parent_company and (not allow_account_creation_against_child_company): - frappe.throw(_("""{0} is a child company. Please import accounts against parent company - or enable {1} in company master""").format(frappe.bold(company), - frappe.bold('Allow Account Creation Against Child Company')), title='Wrong Company') + msg = _("{} is a child company. ").format(frappe.bold(company)) + msg += _("Please import accounts against parent company or enable {} in company master.").format( + frappe.bold('Allow Account Creation Against Child Company')) + frappe.throw(msg, title=_('Wrong Company')) if frappe.db.get_all('GL Entry', {"company": company}, "name", limit=1): return False @@ -74,7 +75,9 @@ def generate_data_from_csv(file_doc, as_dict=False): if as_dict: data.append({frappe.scrub(header): row[index] for index, header in enumerate(headers)}) else: - if not row[1]: row[1] = row[0] + if not row[1]: + row[1] = row[0] + row[3] = row[2] data.append(row) # convert csv data @@ -96,7 +99,9 @@ def generate_data_from_excel(file_doc, extension, as_dict=False): if as_dict: data.append({frappe.scrub(header): row[index] for index, header in enumerate(headers)}) else: - if not row[1]: row[1] = row[0] + if not row[1]: + row[1] = row[0] + row[3] = row[2] data.append(row) return data @@ -147,7 +152,13 @@ def build_forest(data): from frappe import _ for row in data: - account_name, parent_account = row[0:2] + account_name, parent_account, account_number, parent_account_number = row[0:4] + if account_number: + account_name = "{} - {}".format(account_number, account_name) + if parent_account_number: + parent_account_number = cstr(parent_account_number).strip() + parent_account = "{} - {}".format(parent_account_number, parent_account) + if parent_account == account_name == child: return [parent_account] elif account_name == child: @@ -159,20 +170,23 @@ def build_forest(data): charts_map, paths = {}, [] - line_no = 3 + line_no = 2 error_messages = [] for i in data: - account_name, dummy, account_number, is_group, account_type, root_type = i + account_name, parent_account, account_number, parent_account_number, is_group, account_type, root_type = i if not account_name: error_messages.append("Row {0}: Please enter Account Name".format(line_no)) + if account_number: + account_number = cstr(account_number).strip() + account_name = "{} - {}".format(account_number, account_name) + charts_map[account_name] = {} if cint(is_group) == 1: charts_map[account_name]["is_group"] = is_group if account_type: charts_map[account_name]["account_type"] = account_type if root_type: charts_map[account_name]["root_type"] = root_type - if account_number: charts_map[account_name]["account_number"] = account_number path = return_parent(data, account_name)[::-1] paths.append(path) # List of path is created line_no += 1 @@ -195,7 +209,7 @@ def build_response_as_excel(writer): reader = csv.reader(f) from frappe.utils.xlsxutils import make_xlsx - xlsx_file = make_xlsx(reader, "Chart Of Accounts Importer Template") + xlsx_file = make_xlsx(reader, "Chart of Accounts Importer Template") f.close() os.remove(filename) @@ -221,7 +235,7 @@ def download_template(file_type, template_type): def get_template(template_type): - fields = ["Account Name", "Parent Account", "Account Number", "Is Group", "Account Type", "Root Type"] + fields = ["Account Name", "Parent Account", "Account Number", "Parent Account Number", "Is Group", "Account Type", "Root Type"] writer = UnicodeWriter() writer.writerow(fields) @@ -241,23 +255,23 @@ def get_template(template_type): def get_sample_template(writer): template = [ - ["Application Of Funds(Assets)", "", "", 1, "", "Asset"], - ["Sources Of Funds(Liabilities)", "", "", 1, "", "Liability"], - ["Equity", "", "", 1, "", "Equity"], - ["Expenses", "", "", 1, "", "Expense"], - ["Income", "", "", 1, "", "Income"], - ["Bank Accounts", "Application Of Funds(Assets)", "", 1, "Bank", "Asset"], - ["Cash In Hand", "Application Of Funds(Assets)", "", 1, "Cash", "Asset"], - ["Stock Assets", "Application Of Funds(Assets)", "", 1, "Stock", "Asset"], - ["Cost Of Goods Sold", "Expenses", "", 0, "Cost of Goods Sold", "Expense"], - ["Asset Depreciation", "Expenses", "", 0, "Depreciation", "Expense"], - ["Fixed Assets", "Application Of Funds(Assets)", "", 0, "Fixed Asset", "Asset"], - ["Accounts Payable", "Sources Of Funds(Liabilities)", "", 0, "Payable", "Liability"], - ["Accounts Receivable", "Application Of Funds(Assets)", "", 1, "Receivable", "Asset"], - ["Stock Expenses", "Expenses", "", 0, "Stock Adjustment", "Expense"], - ["Sample Bank", "Bank Accounts", "", 0, "Bank", "Asset"], - ["Cash", "Cash In Hand", "", 0, "Cash", "Asset"], - ["Stores", "Stock Assets", "", 0, "Stock", "Asset"], + ["Application Of Funds(Assets)", "", "", "", 1, "", "Asset"], + ["Sources Of Funds(Liabilities)", "", "", "", 1, "", "Liability"], + ["Equity", "", "", "", 1, "", "Equity"], + ["Expenses", "", "", "", 1, "", "Expense"], + ["Income", "", "", "", 1, "", "Income"], + ["Bank Accounts", "Application Of Funds(Assets)", "", "", 1, "Bank", "Asset"], + ["Cash In Hand", "Application Of Funds(Assets)", "", "", 1, "Cash", "Asset"], + ["Stock Assets", "Application Of Funds(Assets)", "", "", 1, "Stock", "Asset"], + ["Cost Of Goods Sold", "Expenses", "", "", 0, "Cost of Goods Sold", "Expense"], + ["Asset Depreciation", "Expenses", "", "", 0, "Depreciation", "Expense"], + ["Fixed Assets", "Application Of Funds(Assets)", "", "", 0, "Fixed Asset", "Asset"], + ["Accounts Payable", "Sources Of Funds(Liabilities)", "", "", 0, "Payable", "Liability"], + ["Accounts Receivable", "Application Of Funds(Assets)", "", "", 1, "Receivable", "Asset"], + ["Stock Expenses", "Expenses", "", "", 0, "Stock Adjustment", "Expense"], + ["Sample Bank", "Bank Accounts", "", "", 0, "Bank", "Asset"], + ["Cash", "Cash In Hand", "", "", 0, "Cash", "Asset"], + ["Stores", "Stock Assets", "", "", 0, "Stock", "Asset"], ] for row in template: diff --git a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py index 340b9dd58ad..622bd33e20a 100644 --- a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py +++ b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py @@ -28,22 +28,22 @@ def test_create_test_data(): "item_group": "_Test Item Group", "item_name": "_Test Tesla Car", "apply_warehouse_wise_reorder_level": 0, - "warehouse":"Stores - TCP1", + "warehouse":"Stores - _TC", "gst_hsn_code": "999800", "valuation_rate": 5000, "standard_rate":5000, "item_defaults": [{ - "company": "_Test Company with perpetual inventory", - "default_warehouse": "Stores - TCP1", + "company": "_Test Company", + "default_warehouse": "Stores - _TC", "default_price_list":"_Test Price List", - "expense_account": "Cost of Goods Sold - TCP1", - "buying_cost_center": "Main - TCP1", - "selling_cost_center": "Main - TCP1", - "income_account": "Sales - TCP1" + "expense_account": "Cost of Goods Sold - _TC", + "buying_cost_center": "Main - _TC", + "selling_cost_center": "Main - _TC", + "income_account": "Sales - _TC" }], "show_in_website": 1, "route":"-test-tesla-car", - "website_warehouse": "Stores - TCP1" + "website_warehouse": "Stores - _TC" }) item.insert() # create test item price @@ -65,12 +65,12 @@ def test_create_test_data(): "items": [{ "item_code": "_Test Tesla Car" }], - "warehouse":"Stores - TCP1", + "warehouse":"Stores - _TC", "coupon_code_based":1, "selling": 1, "rate_or_discount": "Discount Percentage", "discount_percentage": 30, - "company": "_Test Company with perpetual inventory", + "company": "_Test Company", "currency":"INR", "for_price_list":"_Test Price List" }) @@ -85,7 +85,7 @@ def test_create_test_data(): }) sales_partner.insert() # create test item coupon code - if not frappe.db.exists("Coupon Code","SAVE30"): + if not frappe.db.exists("Coupon Code", "SAVE30"): coupon_code = frappe.get_doc({ "doctype": "Coupon Code", "coupon_name":"SAVE30", @@ -102,35 +102,27 @@ class TestCouponCode(unittest.TestCase): test_create_test_data() def tearDown(self): - frappe.set_user("Administrator") + frappe.set_user("Administrator") - def test_1_check_coupon_code_used_before_so(self): - coupon_code = frappe.get_doc("Coupon Code", frappe.db.get_value("Coupon Code", {"coupon_name":"SAVE30"})) - # reset used coupon code count - coupon_code.used=0 - coupon_code.save() - # check no coupon code is used before sales order is made - self.assertEqual(coupon_code.get("used"),0) + def test_sales_order_with_coupon_code(self): + frappe.db.set_value("Coupon Code", "SAVE30", "used", 0) - def test_2_sales_order_with_coupon_code(self): - so = make_sales_order(company='_Test Company with perpetual inventory', warehouse='Stores - TCP1', - customer="_Test Customer", selling_price_list="_Test Price List", item_code="_Test Tesla Car", rate=5000,qty=1, + so = make_sales_order(company='_Test Company', warehouse='Stores - _TC', + customer="_Test Customer", selling_price_list="_Test Price List", + item_code="_Test Tesla Car", rate=5000, qty=1, do_not_submit=True) - so = frappe.get_doc('Sales Order', so.name) - # check item price before coupon code is applied self.assertEqual(so.items[0].rate, 5000) + so.coupon_code='SAVE30' so.sales_partner='_Test Coupon Partner' so.save() + # check item price after coupon code is applied self.assertEqual(so.items[0].rate, 3500) + so.submit() - - def test_3_check_coupon_code_used_after_so(self): - doc = frappe.get_doc("Coupon Code", frappe.db.get_value("Coupon Code", {"coupon_name":"SAVE30"})) - # check no coupon code is used before sales order is made - self.assertEqual(doc.get("used"),1) + self.assertEqual(frappe.db.get_value("Coupon Code", "SAVE30", "used"), 1) diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.js b/erpnext/accounts/doctype/fiscal_year/fiscal_year.js index 152e17dbc88..bc77dac1cdd 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.js +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.js @@ -9,11 +9,7 @@ frappe.ui.form.on('Fiscal Year', { } }, refresh: function (frm) { - let doc = frm.doc; - frm.toggle_enable('year_start_date', doc.__islocal); - frm.toggle_enable('year_end_date', doc.__islocal); - - if (!doc.__islocal && (doc.name != frappe.sys_defaults.fiscal_year)) { + if (!frm.doc.__islocal && (frm.doc.name != frappe.sys_defaults.fiscal_year)) { frm.add_custom_button(__("Set as Default"), () => frm.events.set_as_default(frm)); frm.set_intro(__("To set this Fiscal Year as Default, click on 'Set as Default'")); } else { @@ -24,8 +20,10 @@ frappe.ui.form.on('Fiscal Year', { return frm.call('set_as_default'); }, year_start_date: function(frm) { - let year_end_date = - frappe.datetime.add_days(frappe.datetime.add_months(frm.doc.year_start_date, 12), -1); - frm.set_value("year_end_date", year_end_date); + if (!frm.doc.is_short_year) { + let year_end_date = + frappe.datetime.add_days(frappe.datetime.add_months(frm.doc.year_start_date, 12), -1); + frm.set_value("year_end_date", year_end_date); + } }, }); diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.json b/erpnext/accounts/doctype/fiscal_year/fiscal_year.json index 4ca9f6b96fb..5ab91f2506c 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.json +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.json @@ -1,347 +1,126 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 1, - "allow_rename": 0, - "autoname": "field:year", - "beta": 0, - "creation": "2013-01-22 16:50:25", - "custom": 0, - "description": "**Fiscal Year** represents a Financial Year. All accounting entries and other major transactions are tracked against **Fiscal Year**.", - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "allow_import": 1, + "autoname": "field:year", + "creation": "2013-01-22 16:50:25", + "description": "**Fiscal Year** represents a Financial Year. All accounting entries and other major transactions are tracked against **Fiscal Year**.", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "year", + "disabled", + "is_short_year", + "year_start_date", + "year_end_date", + "companies", + "auto_created" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "For e.g. 2012, 2012-13", - "fieldname": "year", - "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": "Year Name", - "length": 0, - "no_copy": 0, - "oldfieldname": "year", - "oldfieldtype": "Data", - "permlevel": 0, - "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 - }, + "description": "For e.g. 2012, 2012-13", + "fieldname": "year", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Year Name", + "oldfieldname": "year", + "oldfieldtype": "Data", + "reqd": 1, + "unique": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "disabled", - "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": "Disabled", - "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 - }, + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "year_start_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": "Year Start Date", - "length": 0, - "no_copy": 1, - "oldfieldname": "year_start_date", - "oldfieldtype": "Date", - "permlevel": 0, - "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": "year_start_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Year Start Date", + "no_copy": 1, + "oldfieldname": "year_start_date", + "oldfieldtype": "Date", + "reqd": 1, + "set_only_once": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "year_end_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": "Year End Date", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "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": "year_end_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Year End Date", + "no_copy": 1, + "reqd": 1, + "set_only_once": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "companies", - "fieldtype": "Table", - "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": "Companies", - "length": 0, - "no_copy": 0, - "options": "Fiscal Year Company", - "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": "companies", + "fieldtype": "Table", + "label": "Companies", + "options": "Fiscal Year Company" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "0", - "fieldname": "auto_created", - "fieldtype": "Check", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Auto Created", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "default": "0", + "fieldname": "auto_created", + "fieldtype": "Check", + "hidden": 1, + "label": "Auto Created", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "default": "0", + "description": "Less than 12 months.", + "fieldname": "is_short_year", + "fieldtype": "Check", + "label": "Is Short Year", + "set_only_once": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-calendar", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-04-25 14:21:41.273354", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Fiscal Year", - "owner": "Administrator", + ], + "icon": "fa fa-calendar", + "idx": 1, + "links": [], + "modified": "2020-11-05 12:16:53.081573", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Fiscal Year", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "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, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Sales User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "read": 1, + "role": "Sales User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Purchase User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "read": 1, + "role": "Purchase User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Accounts User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "read": 1, + "role": "Accounts User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Stock User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 - }, + "read": 1, + "role": "Stock User" + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Employee", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "read": 1, + "role": "Employee" } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 1, - "sort_field": "name", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "show_name_in_global_search": 1, + "sort_field": "name", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py index d80bc7fad10..da6a3fd2ef9 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py @@ -36,6 +36,11 @@ class FiscalYear(Document): frappe.throw(_("Cannot change Fiscal Year Start Date and Fiscal Year End Date once the Fiscal Year is saved.")) def validate_dates(self): + if self.is_short_year: + # Fiscal Year can be shorter than one year, in some jurisdictions + # under certain circumstances. For example, in the USA and Germany. + return + if getdate(self.year_start_date) > getdate(self.year_end_date): frappe.throw(_("Fiscal Year Start Date should be one year earlier than Fiscal Year End Date"), FiscalYearIncorrectDate) @@ -116,12 +121,8 @@ def auto_create_fiscal_year(): pass def get_from_and_to_date(fiscal_year): - from_and_to_date_tuple = frappe.db.sql("""select year_start_date, year_end_date - from `tabFiscal Year` where name=%s""", (fiscal_year))[0] - - from_and_to_date = { - "from_date": from_and_to_date_tuple[0], - "to_date": from_and_to_date_tuple[1] - } - - return from_and_to_date + fields = [ + "year_start_date as from_date", + "year_end_date as to_date" + ] + return frappe.db.get_value("Fiscal Year", fiscal_year, fields, as_dict=1) diff --git a/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py index f7b77827668..cec4f4492d6 100644 --- a/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py +++ b/erpnext/accounts/doctype/fiscal_year/test_fiscal_year.py @@ -11,6 +11,7 @@ test_records = frappe.get_test_records('Fiscal Year') test_ignore = ["Company"] class TestFiscalYear(unittest.TestCase): + def test_extra_year(self): if frappe.db.exists("Fiscal Year", "_Test Fiscal Year 2000"): frappe.delete_doc("Fiscal Year", "_Test Fiscal Year 2000") diff --git a/erpnext/accounts/doctype/fiscal_year/test_records.json b/erpnext/accounts/doctype/fiscal_year/test_records.json index d5723ca62ba..44052535cbd 100644 --- a/erpnext/accounts/doctype/fiscal_year/test_records.json +++ b/erpnext/accounts/doctype/fiscal_year/test_records.json @@ -1,4 +1,11 @@ [ + { + "doctype": "Fiscal Year", + "year": "_Test Short Fiscal Year 2011", + "is_short_year": 1, + "year_end_date": "2011-04-01", + "year_start_date": "2011-12-31" + }, { "doctype": "Fiscal Year", "year": "_Test Fiscal Year 2012", diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index def9ed6803e..ce76d0a39cc 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -11,8 +11,10 @@ from frappe.model.meta import get_field_precision from erpnext.accounts.party import validate_party_gle_currency, validate_party_frozen_disabled from erpnext.accounts.utils import get_account_currency from erpnext.accounts.utils import get_fiscal_year -from erpnext.exceptions import InvalidAccountCurrency +from erpnext.exceptions import InvalidAccountCurrency, InvalidAccountDimensionError, MandatoryAccountDimensionError from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_checks_for_pl_and_bs_accounts +from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import get_dimension_filter_map +from six import iteritems exclude_from_linked_with = True class GLEntry(Document): @@ -25,27 +27,30 @@ class GLEntry(Document): def validate(self): self.flags.ignore_submit_comment = True - self.check_mandatory() self.validate_and_set_fiscal_year() self.pl_must_have_cost_center() - self.validate_cost_center() - self.check_pl_account() - self.validate_party() - self.validate_currency() + if not self.flags.from_repost: + self.check_mandatory() + self.validate_cost_center() + self.check_pl_account() + self.validate_party() + self.validate_currency() - def on_update_with_args(self, adv_adj, update_outstanding = 'Yes'): - self.validate_account_details(adv_adj) - self.validate_dimensions_for_pl_and_bs() + def on_update(self): + adv_adj = self.flags.adv_adj + if not self.flags.from_repost: + self.validate_account_details(adv_adj) + self.validate_dimensions_for_pl_and_bs() + self.validate_allowed_dimensions() + validate_balance_type(self.account, adv_adj) + validate_frozen_account(self.account, adv_adj) - validate_frozen_account(self.account, adv_adj) - validate_balance_type(self.account, adv_adj) - - # Update outstanding amt on against voucher - if self.against_voucher_type in ['Journal Entry', 'Sales Invoice', 'Purchase Invoice', 'Fees'] \ - and self.against_voucher and update_outstanding == 'Yes': - update_outstanding_amt(self.account, self.party_type, self.party, self.against_voucher_type, - self.against_voucher) + # Update outstanding amt on against voucher + if (self.against_voucher_type in ['Journal Entry', 'Sales Invoice', 'Purchase Invoice', 'Fees'] + and self.against_voucher and self.flags.update_outstanding == 'Yes'): + update_outstanding_amt(self.account, self.party_type, self.party, self.against_voucher_type, + self.against_voucher) def check_mandatory(self): mandatory = ['account','voucher_type','voucher_no','company'] @@ -53,7 +58,7 @@ class GLEntry(Document): if not self.get(k): frappe.throw(_("{0} is required").format(_(self.meta.get_label(k)))) - account_type = frappe.db.get_value("Account", self.account, "account_type") + account_type = frappe.get_cached_value("Account", self.account, "account_type") if not (self.party_type and self.party): if account_type == "Receivable": frappe.throw(_("{0} {1}: Customer is required against Receivable account {2}") @@ -68,17 +73,15 @@ class GLEntry(Document): .format(self.voucher_type, self.voucher_no, self.account)) def pl_must_have_cost_center(self): - if frappe.db.get_value("Account", self.account, "report_type") == "Profit and Loss": + if frappe.get_cached_value("Account", self.account, "report_type") == "Profit and Loss": if not self.cost_center and self.voucher_type != 'Period Closing Voucher': frappe.throw(_("{0} {1}: Cost Center is required for 'Profit and Loss' account {2}. Please set up a default Cost Center for the Company.") .format(self.voucher_type, self.voucher_no, self.account)) def validate_dimensions_for_pl_and_bs(self): - account_type = frappe.db.get_value("Account", self.account, "report_type") for dimension in get_checks_for_pl_and_bs_accounts(): - if account_type == "Profit and Loss" \ and self.company == dimension.company and dimension.mandatory_for_pl and not dimension.disabled: if not self.get(dimension.fieldname): @@ -91,6 +94,25 @@ class GLEntry(Document): frappe.throw(_("Accounting Dimension {0} is required for 'Balance Sheet' account {1}.") .format(dimension.label, self.account)) + def validate_allowed_dimensions(self): + dimension_filter_map = get_dimension_filter_map() + for key, value in iteritems(dimension_filter_map): + dimension = key[0] + account = key[1] + + if self.account == account: + if value['is_mandatory'] and not self.get(dimension): + frappe.throw(_("{0} is mandatory for account {1}").format( + frappe.bold(frappe.unscrub(dimension)), frappe.bold(self.account)), MandatoryAccountDimensionError) + + if value['allow_or_restrict'] == 'Allow': + if self.get(dimension) and self.get(dimension) not in value['allowed_dimensions']: + frappe.throw(_("Invalid value {0} for {1} against account {2}").format( + frappe.bold(self.get(dimension)), frappe.bold(frappe.unscrub(dimension)), frappe.bold(self.account)), InvalidAccountDimensionError) + else: + if self.get(dimension) and self.get(dimension) in value['allowed_dimensions']: + frappe.throw(_("Invalid value {0} for {1} against account {2}").format( + frappe.bold(self.get(dimension)), frappe.bold(frappe.unscrub(dimension)), frappe.bold(self.account)), InvalidAccountDimensionError) def check_pl_account(self): if self.is_opening=='Yes' and \ @@ -106,8 +128,8 @@ class GLEntry(Document): from tabAccount where name=%s""", self.account, as_dict=1)[0] if ret.is_group==1: - frappe.throw(_('''{0} {1}: Account {2} is a Group Account and group accounts cannot be used in - transactions''').format(self.voucher_type, self.voucher_no, self.account)) + frappe.throw(_('''{0} {1}: Account {2} is a Group Account and group accounts cannot be used in transactions''') + .format(self.voucher_type, self.voucher_no, self.account)) if ret.docstatus==2: frappe.throw(_("{0} {1}: Account {2} is inactive") @@ -118,26 +140,18 @@ class GLEntry(Document): .format(self.voucher_type, self.voucher_no, self.account, self.company)) def validate_cost_center(self): - if not hasattr(self, "cost_center_company"): - self.cost_center_company = {} + if not self.cost_center: return - def _get_cost_center_company(): - if not self.cost_center_company.get(self.cost_center): - self.cost_center_company[self.cost_center] = frappe.db.get_value( - "Cost Center", self.cost_center, "company") + is_group, company = frappe.get_cached_value('Cost Center', + self.cost_center, ['is_group', 'company']) - return self.cost_center_company[self.cost_center] - - def _check_is_group(): - return cint(frappe.get_cached_value('Cost Center', self.cost_center, 'is_group')) - - if self.cost_center and _get_cost_center_company() != self.company: + if company != self.company: frappe.throw(_("{0} {1}: Cost Center {2} does not belong to Company {3}") .format(self.voucher_type, self.voucher_no, self.cost_center, self.company)) - if self.cost_center and _check_is_group(): - frappe.throw(_("""{0} {1}: Cost Center {2} is a group cost center and group cost centers cannot - be used in transactions""").format(self.voucher_type, self.voucher_no, frappe.bold(self.cost_center))) + if (self.voucher_type != 'Period Closing Voucher' and is_group): + frappe.throw(_("""{0} {1}: Cost Center {2} is a group cost center and group cost centers cannot be used in transactions""").format( + self.voucher_type, self.voucher_no, frappe.bold(self.cost_center))) def validate_party(self): validate_party_frozen_disabled(self.party_type, self.party) @@ -147,7 +161,7 @@ class GLEntry(Document): account_currency = get_account_currency(self.account) if not self.account_currency: - self.account_currency = company_currency + self.account_currency = account_currency or company_currency if account_currency != self.account_currency: frappe.throw(_("{0} {1}: Accounting Entry for {2} can only be made in currency: {3}") @@ -161,7 +175,6 @@ class GLEntry(Document): if not self.fiscal_year: self.fiscal_year = get_fiscal_year(self.posting_date, company=self.company)[0] - def validate_balance_type(account, adv_adj=False): if not adv_adj and account: balance_must_be = frappe.db.get_value("Account", account, "balance_must_be") @@ -227,7 +240,7 @@ def update_outstanding_amt(account, party_type, party, against_voucher_type, aga def validate_frozen_account(account, adv_adj=None): - frozen_account = frappe.db.get_value("Account", account, "freeze_account") + frozen_account = frappe.get_cached_value("Account", account, "freeze_account") if frozen_account == 'Yes' and not adv_adj: frozen_accounts_modifier = frappe.db.get_value( 'Accounts Settings', None, 'frozen_accounts_modifier') diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py index 8083b21f759..af8940cde5b 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py @@ -137,11 +137,12 @@ class InvoiceDiscounting(AccountsController): "cost_center": erpnext.get_default_cost_center(self.company) }) - je.append("accounts", { - "account": self.bank_charges_account, - "debit_in_account_currency": flt(self.bank_charges), - "cost_center": erpnext.get_default_cost_center(self.company) - }) + if self.bank_charges: + je.append("accounts", { + "account": self.bank_charges_account, + "debit_in_account_currency": flt(self.bank_charges), + "cost_center": erpnext.get_default_cost_center(self.company) + }) je.append("accounts", { "account": self.short_term_loan, diff --git a/erpnext/accounts/doctype/invoice_discounting/test_invoice_discounting.py b/erpnext/accounts/doctype/invoice_discounting/test_invoice_discounting.py index 3d74d9a3b24..919dd0cba77 100644 --- a/erpnext/accounts/doctype/invoice_discounting/test_invoice_discounting.py +++ b/erpnext/accounts/doctype/invoice_discounting/test_invoice_discounting.py @@ -80,6 +80,7 @@ class TestInvoiceDiscounting(unittest.TestCase): short_term_loan=self.short_term_loan, bank_charges_account=self.bank_charges_account, bank_account=self.bank_account, + bank_charges=100 ) je = inv_disc.create_disbursement_entry() @@ -289,6 +290,7 @@ def create_invoice_discounting(invoices, **args): inv_disc.bank_account=args.bank_account inv_disc.loan_start_date = args.start or nowdate() inv_disc.loan_period = args.period or 30 + inv_disc.bank_charges = flt(args.bank_charges) for d in invoices: inv_disc.append("invoices", { diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.json b/erpnext/accounts/doctype/item_tax_template/item_tax_template.json index 8915f79b926..77c9e95b759 100644 --- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.json +++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.json @@ -1,7 +1,7 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, - "autoname": "field:title", "creation": "2018-11-22 22:45:00.370913", "doctype": "DocType", "document_type": "Setup", @@ -20,8 +20,7 @@ "in_list_view": 1, "label": "Title", "no_copy": 1, - "reqd": 1, - "unique": 1 + "reqd": 1 }, { "fieldname": "taxes", @@ -33,12 +32,14 @@ { "fieldname": "company", "fieldtype": "Link", + "in_list_view": 1, "label": "Company", "options": "Company", "reqd": 1 } ], - "modified": "2020-09-18 17:26:09.703215", + "links": [], + "modified": "2021-03-08 19:50:21.416513", "modified_by": "Administrator", "module": "Accounts", "name": "Item Tax Template", @@ -81,5 +82,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "title_field": "title", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.py b/erpnext/accounts/doctype/item_tax_template/item_tax_template.py index e77481d44f5..d9155cbab4a 100644 --- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.py +++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.py @@ -11,6 +11,11 @@ class ItemTaxTemplate(Document): def validate(self): self.validate_tax_accounts() + def autoname(self): + if self.company and self.title: + abbr = frappe.get_cached_value('Company', self.company, 'abbr') + self.name = '{0} - {1}'.format(self.title, abbr) + def validate_tax_accounts(self): """Check whether Tax Rate is not entered twice for same Tax Type""" check_list = [] diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template_dashboard.py b/erpnext/accounts/doctype/item_tax_template/item_tax_template_dashboard.py index acc308e0e68..3d80a9785f0 100644 --- a/erpnext/accounts/doctype/item_tax_template/item_tax_template_dashboard.py +++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template_dashboard.py @@ -20,7 +20,8 @@ def get_data(): 'items': ['Purchase Invoice', 'Purchase Order', 'Purchase Receipt'] }, { - 'items': ['Item'] + 'label': _('Stock'), + 'items': ['Item Groups', 'Item'] } ] } diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 409c15f75ce..37b03f3f0e0 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -120,6 +120,8 @@ frappe.ui.form.on("Journal Entry", { } } }); + + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); }, voucher_type: function(frm){ @@ -197,6 +199,7 @@ erpnext.accounts.JournalEntry = frappe.ui.form.Controller.extend({ this.load_defaults(); this.setup_queries(); this.setup_balance_formatter(); + erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype); }, onload_post_render: function() { @@ -210,7 +213,7 @@ erpnext.accounts.JournalEntry = frappe.ui.form.Controller.extend({ $.each(this.frm.doc.accounts || [], function(i, jvd) { frappe.model.set_default_values(jvd); }); - var posting_date = this.frm.posting_date; + var posting_date = this.frm.doc.posting_date; if(!this.frm.doc.amended_from) this.frm.set_value('posting_date', posting_date || frappe.datetime.get_today()); } }, @@ -222,15 +225,6 @@ erpnext.accounts.JournalEntry = frappe.ui.form.Controller.extend({ return erpnext.journal_entry.account_query(me.frm); }); - me.frm.set_query("cost_center", "accounts", function(doc, cdt, cdn) { - return { - filters: { - company: me.frm.doc.company, - is_group: 0 - } - }; - }); - me.frm.set_query("party_type", "accounts", function(doc, cdt, cdn) { const row = locals[cdt][cdn]; @@ -406,6 +400,8 @@ erpnext.accounts.JournalEntry = frappe.ui.form.Controller.extend({ } } cur_frm.cscript.update_totals(doc); + + erpnext.accounts.dimensions.copy_dimension_from_first_row(this.frm, cdt, cdn, 'accounts'); }, }); diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json index 4573c50134a..b7bbb74ce94 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.json +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_auto_repeat": 1, "allow_import": 1, "autoname": "naming_series:", "creation": "2013-03-25 10:53:52", @@ -503,7 +504,7 @@ "idx": 176, "is_submittable": 1, "links": [], - "modified": "2020-06-02 18:15:46.955697", + "modified": "2020-10-30 13:56:01.121995", "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 34c262e27f5..3419bb6c3e9 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -6,14 +6,18 @@ import frappe, erpnext, json from frappe.utils import cstr, flt, fmt_money, formatdate, getdate, nowdate, cint, get_link_to_form from frappe import msgprint, _, scrub from erpnext.controllers.accounts_controller import AccountsController -from erpnext.accounts.utils import get_balance_on, get_account_currency +from erpnext.accounts.utils import get_balance_on, get_stock_accounts, get_stock_and_account_balance, \ + get_account_currency, check_if_stock_and_account_balance_synced from erpnext.accounts.party import get_party_account from erpnext.hr.doctype.expense_claim.expense_claim import update_reimbursed_amount -from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import get_party_account_based_on_invoice_discounting +from erpnext.accounts.doctype.invoice_discounting.invoice_discounting \ + import get_party_account_based_on_invoice_discounting from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts from six import string_types, iteritems +class StockAccountInvalidTransaction(frappe.ValidationError): pass + class JournalEntry(AccountsController): def __init__(self, *args, **kwargs): super(JournalEntry, self).__init__(*args, **kwargs) @@ -22,14 +26,19 @@ class JournalEntry(AccountsController): return self.voucher_type def validate(self): + if self.voucher_type == 'Opening Entry': + self.is_opening = 'Yes' + if not self.is_opening: self.is_opening='No' + self.clearance_date = None self.validate_party() self.validate_entries_for_advance() self.validate_multi_currency() self.set_amounts_in_company_currency() + self.validate_debit_credit_amount() self.validate_total_debit_and_credit() self.validate_against_jv() self.validate_reference_doc() @@ -41,6 +50,7 @@ class JournalEntry(AccountsController): self.validate_empty_accounts_table() self.set_account_and_party_balance() self.validate_inter_company_accounts() + self.validate_stock_accounts() if not self.title: self.title = self.get_title() @@ -52,6 +62,8 @@ class JournalEntry(AccountsController): self.update_expense_claim() self.update_inter_company_jv() self.update_invoice_discounting() + check_if_stock_and_account_balance_synced(self.posting_date, + self.company, self.doctype, self.name) def on_cancel(self): from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries @@ -91,6 +103,16 @@ class JournalEntry(AccountsController): if self.total_credit != doc.total_debit or self.total_debit != doc.total_credit: frappe.throw(_("Total Credit/ Debit Amount should be same as linked Journal Entry")) + def validate_stock_accounts(self): + stock_accounts = get_stock_accounts(self.company, self.doctype, self.name) + for account in stock_accounts: + account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(account, + self.posting_date, self.company) + + if account_bal == stock_bal: + frappe.throw(_("Account: {0} can only be updated via Stock Transactions") + .format(account), StockAccountInvalidTransaction) + def update_inter_company_jv(self): if self.voucher_type == "Inter Company Journal Entry" and self.inter_company_journal_entry_reference: frappe.db.set_value("Journal Entry", self.inter_company_journal_entry_reference,\ @@ -207,11 +229,11 @@ class JournalEntry(AccountsController): if d.reference_type=="Journal Entry": account_root_type = frappe.db.get_value("Account", d.account, "root_type") if account_root_type == "Asset" and flt(d.debit) > 0: - frappe.throw(_("For {0}, only credit accounts can be linked against another debit entry") - .format(d.account)) + frappe.throw(_("Row #{0}: For {1}, you can select reference document only if account gets credited") + .format(d.idx, d.account)) elif account_root_type == "Liability" and flt(d.credit) > 0: - frappe.throw(_("For {0}, only debit accounts can be linked against another credit entry") - .format(d.account)) + frappe.throw(_("Row #{0}: For {1}, you can select reference document only if account gets debited") + .format(d.idx, d.account)) if d.reference_name == self.name: frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column")) @@ -335,8 +357,7 @@ class JournalEntry(AccountsController): currency=account_currency) if flt(voucher_total) < (flt(order.advance_paid) + total): - frappe.throw(_("Advance paid against {0} {1} cannot be greater \ - than Grand Total {2}").format(reference_type, reference_name, formatted_voucher_total)) + frappe.throw(_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(reference_type, reference_name, formatted_voucher_total)) def validate_invoices(self): """Validate totals and docstatus for invoices""" @@ -365,6 +386,11 @@ class JournalEntry(AccountsController): if flt(d.debit > 0): d.against_account = ", ".join(list(set(accounts_credited))) if flt(d.credit > 0): d.against_account = ", ".join(list(set(accounts_debited))) + def validate_debit_credit_amount(self): + for d in self.get('accounts'): + if not flt(d.debit) and not flt(d.credit): + 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 self.difference: @@ -1051,4 +1077,4 @@ def make_reverse_journal_entry(source_name, target_doc=None): }, }, target_doc) - return doclist \ No newline at end of file + return doclist diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py index 53c07583d8e..5f003e022a0 100644 --- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py @@ -6,7 +6,7 @@ import unittest, frappe from frappe.utils import flt, nowdate from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.exceptions import InvalidAccountCurrency -from erpnext.accounts.general_ledger import StockAccountInvalidTransaction +from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction class TestJournalEntry(unittest.TestCase): def test_journal_entry_with_against_jv(self): @@ -75,54 +75,46 @@ class TestJournalEntry(unittest.TestCase): elif test_voucher.doctype in ["Sales Order", "Purchase Order"]: # if test_voucher is a Sales Order/Purchase Order, test error on cancellation of test_voucher + frappe.db.set_value("Accounts Settings", "Accounts Settings", + "unlink_advance_payment_on_cancelation_of_order", 0) submitted_voucher = frappe.get_doc(test_voucher.doctype, test_voucher.name) self.assertRaises(frappe.LinkExistsError, submitted_voucher.cancel) def test_jv_against_stock_account(self): - from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_perpetual_inventory - set_perpetual_inventory() + company = "_Test Company with perpetual inventory" + stock_account = get_inventory_account(company) - jv = frappe.copy_doc({ - "cheque_date": nowdate(), - "cheque_no": "33", - "company": "_Test Company with perpetual inventory", - "doctype": "Journal Entry", - "accounts": [ - { - "account": "Debtors - TCP1", - "party_type": "Customer", - "party": "_Test Customer", - "credit_in_account_currency": 400.0, - "debit_in_account_currency": 0.0, - "doctype": "Journal Entry Account", - "parentfield": "accounts", - "cost_center": "Main - TCP1" - }, - { - "account": "_Test Bank - TCP1", - "credit_in_account_currency": 0.0, - "debit_in_account_currency": 400.0, - "doctype": "Journal Entry Account", - "parentfield": "accounts", - "cost_center": "Main - TCP1" - } - ], - "naming_series": "_T-Journal Entry-", - "posting_date": nowdate(), - "user_remark": "test", - "voucher_type": "Bank Entry" - }) + from erpnext.accounts.utils import get_stock_and_account_balance + account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(stock_account, nowdate(), company) + diff = flt(account_bal) - flt(stock_bal) - jv.get("accounts")[0].update({ - "account": get_inventory_account('_Test Company with perpetual inventory'), - "company": "_Test Company with perpetual inventory", - "party_type": None, - "party": None + if not diff: + diff = 100 + + jv = frappe.new_doc("Journal Entry") + jv.company = company + jv.posting_date = nowdate() + jv.append("accounts", { + "account": stock_account, + "cost_center": "Main - TCP1", + "debit_in_account_currency": 0 if diff > 0 else abs(diff), + "credit_in_account_currency": diff if diff > 0 else 0 }) + + jv.append("accounts", { + "account": "Stock Adjustment - TCP1", + "cost_center": "Main - TCP1", + "debit_in_account_currency": diff if diff > 0 else 0, + "credit_in_account_currency": 0 if diff > 0 else abs(diff) + }) + jv.insert() - self.assertRaises(StockAccountInvalidTransaction, jv.submit) - jv.cancel() - set_perpetual_inventory(0) + if account_bal == stock_bal: + self.assertRaises(StockAccountInvalidTransaction, jv.submit) + frappe.db.rollback() + else: + jv.submit() + jv.cancel() def test_multi_currency(self): jv = make_journal_entry("_Test Bank USD - _TC", @@ -168,7 +160,7 @@ class TestJournalEntry(unittest.TestCase): self.assertFalse(gle) def test_reverse_journal_entry(self): - from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry + from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry jv = make_journal_entry("_Test Bank USD - _TC", "Sales - _TC", 100, exchange_rate=50, save=False) @@ -307,15 +299,20 @@ class TestJournalEntry(unittest.TestCase): def test_jv_with_project(self): from erpnext.projects.doctype.project.test_project import make_project - project = make_project({ - 'project_name': 'Journal Entry Project', - 'project_template_name': 'Test Project Template', - 'start_date': '2020-01-01' - }) + + if not frappe.db.exists("Project", {"project_name": "Journal Entry Project"}): + project = make_project({ + 'project_name': 'Journal Entry Project', + 'project_template_name': 'Test Project Template', + 'start_date': '2020-01-01' + }) + project_name = project.name + else: + project_name = frappe.get_value("Project", {"project_name": "_Test Project"}) jv = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, save=False) for d in jv.accounts: - d.project = project.project_name + d.project = project_name jv.voucher_type = "Bank Entry" jv.multi_currency = 0 jv.cheque_no = "112233" @@ -325,10 +322,10 @@ class TestJournalEntry(unittest.TestCase): expected_values = { "_Test Cash - _TC": { - "project": project.project_name + "project": project_name }, "_Test Bank - _TC": { - "project": project.project_name + "project": project_name } } diff --git a/erpnext/accounts/doctype/loyalty_program/loyalty_program.js b/erpnext/accounts/doctype/loyalty_program/loyalty_program.js index 524a671801b..f90f86728de 100644 --- a/erpnext/accounts/doctype/loyalty_program/loyalty_program.js +++ b/erpnext/accounts/doctype/loyalty_program/loyalty_program.js @@ -1,6 +1,8 @@ // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +frappe.provide("erpnext.accounts.dimensions"); + frappe.ui.form.on('Loyalty Program', { setup: function(frm) { var help_content = @@ -46,20 +48,17 @@ frappe.ui.form.on('Loyalty Program', { }; }); - frm.set_query("cost_center", function() { - return { - filters: { - company: frm.doc.company - } - }; - }); - frm.set_value("company", frappe.defaults.get_user_default("Company")); + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, refresh: function(frm) { if (frm.doc.loyalty_program_type === "Single Tier Program" && frm.doc.collection_rules.length > 1) { frappe.throw(__("Please select the Multiple Tier Program type for more than one collection rules.")); } + }, + + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); } }); diff --git a/erpnext/accounts/doctype/loyalty_program/test_loyalty_program.py b/erpnext/accounts/doctype/loyalty_program/test_loyalty_program.py index ee73ccaa611..31994885aa6 100644 --- a/erpnext/accounts/doctype/loyalty_program/test_loyalty_program.py +++ b/erpnext/accounts/doctype/loyalty_program/test_loyalty_program.py @@ -8,12 +8,10 @@ import unittest from frappe.utils import today, cint, flt, getdate from erpnext.accounts.doctype.loyalty_program.loyalty_program import get_loyalty_program_details_with_points from erpnext.accounts.party import get_dashboard_info -from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_perpetual_inventory class TestLoyaltyProgram(unittest.TestCase): @classmethod def setUpClass(self): - set_perpetual_inventory(0) # create relevant item, customer, loyalty program, etc create_records() @@ -195,88 +193,91 @@ def create_sales_invoice_record(qty=1): def create_records(): # create a new loyalty Account - if frappe.db.exists("Account", "Loyalty - _TC"): - return - - frappe.get_doc({ - "doctype": "Account", - "account_name": "Loyalty", - "parent_account": "Direct Expenses - _TC", - "company": "_Test Company", - "is_group": 0, - "account_type": "Expense Account", - }).insert() + if not frappe.db.exists("Account", "Loyalty - _TC"): + frappe.get_doc({ + "doctype": "Account", + "account_name": "Loyalty", + "parent_account": "Direct Expenses - _TC", + "company": "_Test Company", + "is_group": 0, + "account_type": "Expense Account", + }).insert() # create a new loyalty program Single tier - frappe.get_doc({ - "doctype": "Loyalty Program", - "loyalty_program_name": "Test Single Loyalty", - "auto_opt_in": 1, - "from_date": today(), - "loyalty_program_type": "Single Tier Program", - "conversion_factor": 1, - "expiry_duration": 10, - "company": "_Test Company", - "cost_center": "Main - _TC", - "expense_account": "Loyalty - _TC", - "collection_rules": [{ - 'tier_name': 'Silver', - 'collection_factor': 1000, - 'min_spent': 1000 - }] - }).insert() - - # create a new customer - frappe.get_doc({ - "customer_group": "_Test Customer Group", - "customer_name": "Test Loyalty Customer", - "customer_type": "Individual", - "doctype": "Customer", - "territory": "_Test Territory" - }).insert() - - # create a new loyalty program Multiple tier - frappe.get_doc({ - "doctype": "Loyalty Program", - "loyalty_program_name": "Test Multiple Loyalty", - "auto_opt_in": 1, - "from_date": today(), - "loyalty_program_type": "Multiple Tier Program", - "conversion_factor": 1, - "expiry_duration": 10, - "company": "_Test Company", - "cost_center": "Main - _TC", - "expense_account": "Loyalty - _TC", - "collection_rules": [ - { + if not frappe.db.exists("Loyalty Program","Test Single Loyalty"): + frappe.get_doc({ + "doctype": "Loyalty Program", + "loyalty_program_name": "Test Single Loyalty", + "auto_opt_in": 1, + "from_date": today(), + "loyalty_program_type": "Single Tier Program", + "conversion_factor": 1, + "expiry_duration": 10, + "company": "_Test Company", + "cost_center": "Main - _TC", + "expense_account": "Loyalty - _TC", + "collection_rules": [{ 'tier_name': 'Silver', 'collection_factor': 1000, - 'min_spent': 10000 - }, - { - 'tier_name': 'Gold', - 'collection_factor': 1000, - 'min_spent': 19000 - } - ] - }).insert() + 'min_spent': 1000 + }] + }).insert() + + # create a new customer + if not frappe.db.exists("Customer","Test Loyalty Customer"): + frappe.get_doc({ + "customer_group": "_Test Customer Group", + "customer_name": "Test Loyalty Customer", + "customer_type": "Individual", + "doctype": "Customer", + "territory": "_Test Territory" + }).insert() + + # create a new loyalty program Multiple tier + if not frappe.db.exists("Loyalty Program","Test Multiple Loyalty"): + frappe.get_doc({ + "doctype": "Loyalty Program", + "loyalty_program_name": "Test Multiple Loyalty", + "auto_opt_in": 1, + "from_date": today(), + "loyalty_program_type": "Multiple Tier Program", + "conversion_factor": 1, + "expiry_duration": 10, + "company": "_Test Company", + "cost_center": "Main - _TC", + "expense_account": "Loyalty - _TC", + "collection_rules": [ + { + 'tier_name': 'Silver', + 'collection_factor': 1000, + 'min_spent': 10000 + }, + { + 'tier_name': 'Gold', + 'collection_factor': 1000, + 'min_spent': 19000 + } + ] + }).insert() # create an item - item = frappe.get_doc({ - "doctype": "Item", - "item_code": "Loyal Item", - "item_name": "Loyal Item", - "item_group": "All Item Groups", - "company": "_Test Company", - "is_stock_item": 1, - "opening_stock": 100, - "valuation_rate": 10000, - }).insert() + if not frappe.db.exists("Item", "Loyal Item"): + frappe.get_doc({ + "doctype": "Item", + "item_code": "Loyal Item", + "item_name": "Loyal Item", + "item_group": "All Item Groups", + "company": "_Test Company", + "is_stock_item": 1, + "opening_stock": 100, + "valuation_rate": 10000, + }).insert() # create item price - frappe.get_doc({ - "doctype": "Item Price", - "price_list": "Standard Selling", - "item_code": item.item_code, - "price_list_rate": 10000 - }).insert() + if not frappe.db.exists("Item Price", {"price_list": "Standard Selling", "item_code": "Loyal Item"}): + frappe.get_doc({ + "doctype": "Item Price", + "price_list": "Standard Selling", + "item_code": "Loyal Item", + "price_list_rate": 10000 + }).insert() diff --git a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.js b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.js index d3040c8db87..7a06d3572a6 100644 --- a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.js +++ b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.js @@ -1,13 +1,17 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -cur_frm.set_query("default_account", "accounts", function(doc, cdt, cdn) { - var d = locals[cdt][cdn]; - return{ - filters: [ - ['Account', 'account_type', 'in', 'Bank, Cash, Receivable'], - ['Account', 'is_group', '=', 0], - ['Account', 'company', '=', d.company] - ] - } -}); +frappe.ui.form.on('Mode of Payment', { + setup: function(frm) { + frm.set_query("default_account", "accounts", function(doc, cdt, cdn) { + let d = locals[cdt][cdn]; + return { + filters: [ + ['Account', 'account_type', 'in', 'Bank, Cash, Receivable'], + ['Account', 'is_group', '=', 0], + ['Account', 'company', '=', d.company] + ] + }; + }); + }, +}); \ No newline at end of file diff --git a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json index 50fc3bbab75..51fc3f72cd1 100644 --- a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json +++ b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "field:mode_of_payment", @@ -28,7 +29,7 @@ "fieldtype": "Select", "in_standard_filter": 1, "label": "Type", - "options": "Cash\nBank\nGeneral" + "options": "Cash\nBank\nGeneral\nPhone" }, { "fieldname": "accounts", @@ -45,7 +46,9 @@ ], "icon": "fa fa-credit-card", "idx": 1, - "modified": "2020-09-18 17:26:09.703215", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-18 17:57:23.835236", "modified_by": "Administrator", "module": "Accounts", "name": "Mode of Payment", diff --git a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py index d54a47e3c96..32473694c80 100644 --- a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py +++ b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py @@ -12,7 +12,7 @@ class ModeofPayment(Document): self.validate_accounts() self.validate_repeating_companies() self.validate_pos_mode_of_payment() - + def validate_repeating_companies(self): """Error when Same Company is entered multiple times in accounts""" accounts_list = [] @@ -31,10 +31,10 @@ class ModeofPayment(Document): def validate_pos_mode_of_payment(self): if not self.enabled: - pos_profiles = frappe.db.sql("""SELECT sip.parent FROM `tabSales Invoice Payment` sip + pos_profiles = frappe.db.sql("""SELECT sip.parent FROM `tabSales Invoice Payment` sip WHERE sip.parenttype = 'POS Profile' and sip.mode_of_payment = %s""", (self.name)) pos_profiles = list(map(lambda x: x[0], pos_profiles)) - + if pos_profiles: message = "POS Profile " + frappe.bold(", ".join(pos_profiles)) + " contains \ Mode of Payment " + frappe.bold(str(self.name)) + ". Please remove them to disable this mode." diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js index 699eb08e178..b2e86267c8f 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.js @@ -6,7 +6,7 @@ frappe.ui.form.on('Opening Invoice Creation Tool', { frm.set_query('party_type', 'invoices', function(doc, cdt, cdn) { return { filters: { - 'name': ['in', 'Customer,Supplier'] + 'name': ['in', 'Customer, Supplier'] } }; }); @@ -14,29 +14,48 @@ frappe.ui.form.on('Opening Invoice Creation Tool', { if (frm.doc.company) { frm.trigger('setup_company_filters'); } + + frappe.realtime.on('opening_invoice_creation_progress', data => { + if (!frm.doc.import_in_progress) { + frm.dashboard.reset(); + frm.doc.import_in_progress = true; + } + if (data.user != frappe.session.user) return; + if (data.count == data.total) { + setTimeout((title) => { + frm.doc.import_in_progress = false; + frm.clear_table("invoices"); + frm.refresh_fields(); + frm.page.clear_indicator(); + frm.dashboard.hide_progress(title); + frappe.msgprint(__("Opening {0} Invoice created", [frm.doc.invoice_type])); + }, 1500, data.title); + return; + } + + frm.dashboard.show_progress(data.title, (data.count / data.total) * 100, data.message); + frm.page.set_indicator(__('In Progress'), 'orange'); + }); + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, refresh: function(frm) { frm.disable_save(); - frm.trigger("make_dashboard"); + !frm.doc.import_in_progress && frm.trigger("make_dashboard"); frm.page.set_primary_action(__('Create Invoices'), () => { let btn_primary = frm.page.btn_primary.get(0); return frm.call({ doc: frm.doc, - freeze: true, btn: $(btn_primary), method: "make_invoices", - freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]), - callback: (r) => { - if(!r.exc){ - frappe.msgprint(__("Opening {0} Invoice created", [frm.doc.invoice_type])); - frm.clear_table("invoices"); - frm.refresh_fields(); - frm.reload_doc(); - } - } + freeze_message: __("Creating {0} Invoice", [frm.doc.invoice_type]) }); }); + + if (frm.doc.create_missing_party) { + frm.set_df_property("party", "fieldtype", "Data", frm.doc.name, "invoices"); + } }, setup_company_filters: function(frm) { @@ -83,6 +102,7 @@ frappe.ui.form.on('Opening Invoice Creation Tool', { } }) } + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); }, invoice_type: function(frm) { @@ -101,7 +121,8 @@ frappe.ui.form.on('Opening Invoice Creation Tool', { frappe.render_template('opening_invoice_creation_tool_dashboard', { data: opening_invoices_summary, max_count: max_count - }) + }), + __("Opening Invoices Summary") ); section.on('click', '.invoice-link', function() { diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index a53417eedf9..e6449b78316 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -4,9 +4,12 @@ from __future__ import unicode_literals import frappe +import traceback +from json import dumps from frappe import _, scrub from frappe.utils import flt, nowdate from frappe.model.document import Document +from frappe.utils.background_jobs import enqueue from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions @@ -62,66 +65,47 @@ class OpeningInvoiceCreationTool(Document): return invoices_summary, max_count - def make_invoices(self): - names = [] - mandatory_error_msg = _("Row {0}: {1} is required to create the Opening {2} Invoices") + def validate_company(self): if not self.company: frappe.throw(_("Please select the Company")) - company_details = frappe.get_cached_value('Company', self.company, - ["default_currency", "default_letter_head"], as_dict=1) or {} + def set_missing_values(self, row): + row.qty = row.qty or 1.0 + row.temporary_opening_account = row.temporary_opening_account or get_temporary_opening_account(self.company) + row.party_type = "Customer" if self.invoice_type == "Sales" else "Supplier" + row.item_name = row.item_name or _("Opening Invoice Item") + row.posting_date = row.posting_date or nowdate() + row.due_date = row.due_date or nowdate() + def validate_mandatory_invoice_fields(self, row): + if not frappe.db.exists(row.party_type, row.party): + if self.create_missing_party: + self.add_party(row.party_type, row.party) + else: + frappe.throw(_("Row #{}: {} {} does not exist.").format(row.idx, frappe.bold(row.party_type), frappe.bold(row.party))) + + mandatory_error_msg = _("Row #{0}: {1} is required to create the Opening {2} Invoices") + for d in ("Party", "Outstanding Amount", "Temporary Opening Account"): + if not row.get(scrub(d)): + frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type)) + + def get_invoices(self): + invoices = [] for row in self.invoices: - if not row.qty: - row.qty = 1.0 - - # always mandatory fields for the invoices - if not row.temporary_opening_account: - row.temporary_opening_account = get_temporary_opening_account(self.company) - row.party_type = "Customer" if self.invoice_type == "Sales" else "Supplier" - - # Allow to create invoice even if no party present in customer or supplier. - if not frappe.db.exists(row.party_type, row.party): - if self.create_missing_party: - self.add_party(row.party_type, row.party) - else: - frappe.throw(_("{0} {1} does not exist.").format(frappe.bold(row.party_type), frappe.bold(row.party))) - - if not row.item_name: - row.item_name = _("Opening Invoice Item") - if not row.posting_date: - row.posting_date = nowdate() - if not row.due_date: - row.due_date = nowdate() - - for d in ("Party", "Outstanding Amount", "Temporary Opening Account"): - if not row.get(scrub(d)): - frappe.throw(mandatory_error_msg.format(row.idx, _(d), self.invoice_type)) - - args = self.get_invoice_dict(row=row) - if not args: + if not row: continue - + self.set_missing_values(row) + self.validate_mandatory_invoice_fields(row) + invoice = self.get_invoice_dict(row) + company_details = frappe.get_cached_value('Company', self.company, ["default_currency", "default_letter_head"], as_dict=1) or {} if company_details: - args.update({ + invoice.update({ "currency": company_details.get("default_currency"), "letter_head": company_details.get("default_letter_head") }) + invoices.append(invoice) - doc = frappe.get_doc(args).insert() - doc.submit() - names.append(doc.name) - - if len(self.invoices) > 5: - frappe.publish_realtime( - "progress", dict( - progress=[row.idx, len(self.invoices)], - title=_('Creating {0}').format(doc.doctype) - ), - user=frappe.session.user - ) - - return names + return invoices def add_party(self, party_type, party): party_doc = frappe.new_doc(party_type) @@ -140,14 +124,12 @@ class OpeningInvoiceCreationTool(Document): def get_invoice_dict(self, row=None): def get_item_dict(): - default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos") - cost_center = row.get('cost_center') or frappe.get_cached_value('Company', - self.company, "cost_center") - + cost_center = row.get('cost_center') or frappe.get_cached_value('Company', self.company, "cost_center") if not cost_center: - frappe.throw( - _("Please set the Default Cost Center in {0} company.").format(frappe.bold(self.company)) - ) + frappe.throw(_("Please set the Default Cost Center in {0} company.").format(frappe.bold(self.company))) + + income_expense_account_field = "income_account" if row.party_type == "Customer" else "expense_account" + default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos") rate = flt(row.outstanding_amount) / flt(row.qty) return frappe._dict({ @@ -161,18 +143,9 @@ class OpeningInvoiceCreationTool(Document): "cost_center": cost_center }) - if not row: - return None - - party_type = "Customer" - income_expense_account_field = "income_account" - if self.invoice_type == "Purchase": - party_type = "Supplier" - income_expense_account_field = "expense_account" - item = get_item_dict() - args = frappe._dict({ + invoice = frappe._dict({ "items": [item], "is_opening": "Yes", "set_posting_time": 1, @@ -180,21 +153,78 @@ class OpeningInvoiceCreationTool(Document): "cost_center": self.cost_center, "due_date": row.due_date, "posting_date": row.posting_date, - frappe.scrub(party_type): row.party, - "doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice" + frappe.scrub(row.party_type): row.party, + "is_pos": 0, + "doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice", + "update_stock": 0 }) accounting_dimension = get_accounting_dimensions() - for dimension in accounting_dimension: - args.update({ + invoice.update({ dimension: item.get(dimension) }) - if self.invoice_type == "Sales": - args["is_pos"] = 0 + return invoice - return args + def make_invoices(self): + self.validate_company() + invoices = self.get_invoices() + if len(invoices) < 50: + return start_import(invoices) + else: + from frappe.core.page.background_jobs.background_jobs import get_info + from frappe.utils.scheduler import is_scheduler_inactive + + if is_scheduler_inactive() and not frappe.flags.in_test: + frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive")) + + enqueued_jobs = [d.get("job_name") for d in get_info()] + if self.name not in enqueued_jobs: + enqueue( + start_import, + queue="default", + timeout=6000, + event="opening_invoice_creation", + job_name=self.name, + invoices=invoices, + now=frappe.conf.developer_mode or frappe.flags.in_test + ) + +def start_import(invoices): + errors = 0 + names = [] + for idx, d in enumerate(invoices): + try: + publish(idx, len(invoices), d.doctype) + doc = frappe.get_doc(d) + doc.flags.ignore_mandatory = True + doc.insert() + doc.submit() + frappe.db.commit() + names.append(doc.name) + except Exception: + errors += 1 + frappe.db.rollback() + message = "\n".join(["Data:", dumps(d, default=str, indent=4), "--" * 50, "\nException:", traceback.format_exc()]) + frappe.log_error(title="Error while creating Opening Invoice", message=message) + frappe.db.commit() + if errors: + frappe.msgprint(_("You had {} errors while creating opening invoices. Check {} for more details") + .format(errors, "Error Log"), indicator="red", title=_("Error Occured")) + return names + +def publish(index, total, doctype): + if total < 5: return + frappe.publish_realtime( + "opening_invoice_creation_progress", + dict( + title=_("Opening Invoice Creation In Progress"), + message=_('Creating {} out of {} {}').format(index + 1, total, doctype), + user=frappe.session.user, + count=index+1, + total=total + )) @frappe.whitelist() def get_temporary_opening_account(company=None): diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool_dashboard.html b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool_dashboard.html index 5b136d4f666..afbcfa5602a 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool_dashboard.html +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool_dashboard.html @@ -1,4 +1,3 @@ -
    {{ __("Opening Invoices Summary") }}
    {% $.each(data, (company, summary) => { %}
    {{ company }}
    diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index 3bfc10dda55..8d6de2d562f 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -6,26 +6,45 @@ from __future__ import unicode_literals import frappe import unittest -test_dependencies = ["Customer", "Supplier"] +from frappe.cache_manager import clear_doctype_cache +from frappe.custom.doctype.property_setter.property_setter import make_property_setter from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import get_temporary_opening_account +test_dependencies = ["Customer", "Supplier"] + class TestOpeningInvoiceCreationTool(unittest.TestCase): - def make_invoices(self, invoice_type="Sales"): + def setUp(self): + if not frappe.db.exists("Company", "_Test Opening Invoice Company"): + make_company() + + def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None): doc = frappe.get_single("Opening Invoice Creation Tool") - args = get_opening_invoice_creation_dict(invoice_type=invoice_type) + args = get_opening_invoice_creation_dict(invoice_type=invoice_type, company=company, + party_1=party_1, party_2=party_2) doc.update(args) return doc.make_invoices() def test_opening_sales_invoice_creation(self): - invoices = self.make_invoices() + property_setter = make_property_setter("Sales Invoice", "update_stock", "default", 1, "Check") + try: + invoices = self.make_invoices(company="_Test Opening Invoice Company") - self.assertEqual(len(invoices), 2) - expected_value = { - "keys": ["customer", "outstanding_amount", "status"], - 0: ["_Test Customer", 300, "Overdue"], - 1: ["_Test Customer 1", 250, "Overdue"], - } - self.check_expected_values(invoices, expected_value) + self.assertEqual(len(invoices), 2) + expected_value = { + "keys": ["customer", "outstanding_amount", "status"], + 0: ["_Test Customer", 300, "Overdue"], + 1: ["_Test Customer 1", 250, "Overdue"], + } + self.check_expected_values(invoices, expected_value) + + si = frappe.get_doc("Sales Invoice", invoices[0]) + + # Check if update stock is not enabled + self.assertEqual(si.update_stock, 0) + + finally: + property_setter.delete() + clear_doctype_cache("Sales Invoice") def check_expected_values(self, invoices, expected_value, invoice_type="Sales"): doctype = "Sales Invoice" if invoice_type == "Sales" else "Purchase Invoice" @@ -36,7 +55,7 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase): self.assertEqual(si.get(field, ""), expected_value[invoice_idx][field_idx]) def test_opening_purchase_invoice_creation(self): - invoices = self.make_invoices(invoice_type="Purchase") + invoices = self.make_invoices(invoice_type="Purchase", company="_Test Opening Invoice Company") self.assertEqual(len(invoices), 2) expected_value = { @@ -44,7 +63,33 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase): 0: ["_Test Supplier", 300, "Overdue"], 1: ["_Test Supplier 1", 250, "Overdue"], } - self.check_expected_values(invoices, expected_value, invoice_type="Purchase", ) + self.check_expected_values(invoices, expected_value, "Purchase") + + def test_opening_sales_invoice_creation_with_missing_debit_account(self): + company = "_Test Opening Invoice Company" + party_1, party_2 = make_customer("Customer A"), make_customer("Customer B") + + old_default_receivable_account = frappe.db.get_value("Company", company, "default_receivable_account") + frappe.db.set_value("Company", company, "default_receivable_account", "") + + if not frappe.db.exists("Cost Center", "_Test Opening Invoice Company - _TOIC"): + cc = frappe.get_doc({"doctype": "Cost Center", "cost_center_name": "_Test Opening Invoice Company", + "is_group": 1, "company": "_Test Opening Invoice Company"}) + cc.insert(ignore_mandatory=True) + cc2 = frappe.get_doc({"doctype": "Cost Center", "cost_center_name": "Main", "is_group": 0, + "company": "_Test Opening Invoice Company", "parent_cost_center": cc.name}) + cc2.insert() + + frappe.db.set_value("Company", company, "cost_center", "Main - _TOIC") + + self.make_invoices(company="_Test Opening Invoice Company", party_1=party_1, party_2=party_2) + + # Check if missing debit account error raised + error_log = frappe.db.exists("Error Log", {"error": ["like", "%erpnext.controllers.accounts_controller.AccountMissingError%"]}) + self.assertTrue(error_log) + + # teardown + frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account) def get_opening_invoice_creation_dict(**args): party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier" @@ -57,7 +102,7 @@ def get_opening_invoice_creation_dict(**args): { "qty": 1.0, "outstanding_amount": 300, - "party": "_Test {0}".format(party), + "party": args.get("party_1") or "_Test {0}".format(party), "item_name": "Opening Item", "due_date": "2016-09-10", "posting_date": "2016-09-05", @@ -66,7 +111,7 @@ def get_opening_invoice_creation_dict(**args): { "qty": 2.0, "outstanding_amount": 250, - "party": "_Test {0} 1".format(party), + "party": args.get("party_2") or "_Test {0} 1".format(party), "item_name": "Opening Item", "due_date": "2016-09-10", "posting_date": "2016-09-05", @@ -76,4 +121,31 @@ def get_opening_invoice_creation_dict(**args): }) invoice_dict.update(args) - return invoice_dict \ No newline at end of file + return invoice_dict + +def make_company(): + if frappe.db.exists("Company", "_Test Opening Invoice Company"): + return frappe.get_doc("Company", "_Test Opening Invoice Company") + + company = frappe.new_doc("Company") + company.company_name = "_Test Opening Invoice Company" + company.abbr = "_TOIC" + company.default_currency = "INR" + company.country = "India" + company.insert() + return company + +def make_customer(customer=None): + customer_name = customer or "Opening Customer" + customer = frappe.get_doc({ + "doctype": "Customer", + "customer_name": customer_name, + "customer_group": "All Customer Groups", + "customer_type": "Company", + "territory": "All Territories" + }) + if not frappe.db.exists("Customer", customer_name): + customer.insert(ignore_permissions=True) + return customer.name + else: + return frappe.db.exists("Customer", customer_name) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index e1174717382..b5f6a401df4 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -1,6 +1,7 @@ // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt {% include "erpnext/public/js/controllers/accounts.js" %} +frappe.provide("erpnext.accounts.dimensions"); frappe.ui.form.on('Payment Entry', { onload: function(frm) { @@ -8,6 +9,8 @@ frappe.ui.form.on('Payment Entry', { if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); if (!frm.doc.paid_to) frm.set_value("paid_to_account_currency", null); } + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, setup: function(frm) { @@ -88,24 +91,17 @@ frappe.ui.form.on('Payment Entry', { } }); - frm.set_query("cost_center", "deductions", function() { - return { - filters: { - "is_group": 0, - "company": frm.doc.company - } - } - }); - frm.set_query("reference_doctype", "references", function() { - if (frm.doc.party_type=="Customer") { + if (frm.doc.party_type == "Customer") { var doctypes = ["Sales Order", "Sales Invoice", "Journal Entry", "Dunning"]; - } else if (frm.doc.party_type=="Supplier") { + } else if (frm.doc.party_type == "Supplier") { var doctypes = ["Purchase Order", "Purchase Invoice", "Journal Entry"]; - } else if (frm.doc.party_type=="Employee") { + } else if (frm.doc.party_type == "Employee") { var doctypes = ["Expense Claim", "Journal Entry"]; - } else if (frm.doc.party_type=="Student") { + } else if (frm.doc.party_type == "Student") { var doctypes = ["Fees"]; + } else if (frm.doc.party_type == "Donor") { + var doctypes = ["Donation"]; } else { var doctypes = ["Journal Entry"]; } @@ -134,7 +130,7 @@ frappe.ui.form.on('Payment Entry', { const child = locals[cdt][cdn]; const filters = {"docstatus": 1, "company": doc.company}; const party_type_doctypes = ['Sales Invoice', 'Sales Order', 'Purchase Invoice', - 'Purchase Order', 'Expense Claim', 'Fees', 'Dunning']; + 'Purchase Order', 'Expense Claim', 'Fees', 'Dunning', 'Donation']; if (in_list(party_type_doctypes, child.reference_doctype)) { filters[doc.party_type.toLowerCase()] = doc.party; @@ -167,6 +163,7 @@ frappe.ui.form.on('Payment Entry', { company: function(frm) { frm.events.hide_unhide_fields(frm); frm.events.set_dynamic_labels(frm); + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); }, contact_person: function(frm) { @@ -286,7 +283,7 @@ frappe.ui.form.on('Payment Entry', { let party_types = Object.keys(frappe.boot.party_account_types); if(frm.doc.party_type && !party_types.includes(frm.doc.party_type)){ frm.set_value("party_type", ""); - frappe.throw(__("Party can only be one of "+ party_types.join(", "))); + frappe.throw(__("Party can only be one of {0}", [party_types.join(", ")])); } frm.set_query("party", function() { @@ -401,6 +398,8 @@ frappe.ui.form.on('Payment Entry', { set_account_currency_and_balance: function(frm, account, currency_field, balance_field, callback_function) { + + var company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; if (frm.doc.posting_date && account) { frappe.call({ method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_account_details", @@ -427,6 +426,14 @@ frappe.ui.form.on('Payment Entry', { if(!frm.doc.paid_amount && frm.doc.received_amount) frm.events.received_amount(frm); + + if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency + && frm.doc.paid_amount != frm.doc.received_amount) { + if (company_currency != frm.doc.paid_from_account_currency && + frm.doc.payment_type == "Pay") { + frm.doc.paid_amount = frm.doc.received_amount; + } + } } }, () => { @@ -598,12 +605,22 @@ frappe.ui.form.on('Payment Entry', { {fieldtype:"Column Break"}, {fieldtype:"Float", label: __("Less Than Amount"), fieldname:"outstanding_amt_less_than"}, {fieldtype:"Section Break"}, + {fieldtype:"Link", label:__("Cost Center"), fieldname:"cost_center", options:"Cost Center", + "get_query": function() { + return { + "filters": {"company": frm.doc.company} + } + } + }, + {fieldtype:"Column Break"}, + {fieldtype:"Section Break"}, {fieldtype:"Check", label: __("Allocate Payment Amount"), fieldname:"allocate_payment_amount", default:1}, ]; frappe.prompt(fields, function(filters){ frappe.flags.allocate_payment_amount = true; frm.events.validate_filters_data(frm, filters); + frm.doc.cost_center = filters.cost_center; frm.events.get_outstanding_documents(frm, filters); }, __("Filters"), __("Get Outstanding Documents")); }, @@ -700,7 +717,8 @@ frappe.ui.form.on('Payment Entry', { (frm.doc.payment_type=="Receive" && frm.doc.party_type=="Customer") || (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Supplier") || (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Employee") || - (frm.doc.payment_type=="Receive" && frm.doc.party_type=="Student") + (frm.doc.payment_type=="Receive" && frm.doc.party_type=="Student") || + (frm.doc.payment_type=="Receive" && frm.doc.party_type=="Donor") ) { if(total_positive_outstanding > total_negative_outstanding) if (!frm.doc.paid_amount) @@ -743,7 +761,8 @@ frappe.ui.form.on('Payment Entry', { (frm.doc.payment_type=="Receive" && frm.doc.party_type=="Customer") || (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Supplier") || (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Employee") || - (frm.doc.payment_type=="Receive" && frm.doc.party_type=="Student") + (frm.doc.payment_type=="Receive" && frm.doc.party_type=="Student") || + (frm.doc.payment_type=="Receive" && frm.doc.party_type=="Donor") ) { if(total_positive_outstanding_including_order > paid_amount) { var remaining_outstanding = total_positive_outstanding_including_order - paid_amount; @@ -900,6 +919,12 @@ frappe.ui.form.on('Payment Entry', { frappe.msgprint(__("Row #{0}: Reference Document Type must be one of Expense Claim or Journal Entry", [row.idx])); return false; } + + if (frm.doc.party_type == "Donor" && row.reference_doctype != "Donation") { + frappe.model.set_value(row.doctype, row.name, "reference_doctype", null); + frappe.msgprint(__("Row #{0}: Reference Document Type must be Donation", [row.idx])); + return false; + } } if (row) { @@ -1051,11 +1076,6 @@ frappe.ui.form.on('Payment Entry', { frm.set_value("paid_from_account_balance", r.message.paid_from_account_balance); frm.set_value("paid_to_account_balance", r.message.paid_to_account_balance); frm.set_value("party_balance", r.message.party_balance); - }, - () => { - if(frm.doc.payment_type != "Internal") { - frm.clear_table("references"); - } } ]); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index 72149a665df..328584a61a0 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_auto_repeat": 1, "allow_import": 1, "autoname": "naming_series:", "creation": "2016-06-01 14:38:51.012597", @@ -535,7 +536,8 @@ "fieldtype": "Data", "hidden": 1, "label": "Title", - "print_hide": 1 + "print_hide": 1, + "read_only": 1 }, { "depends_on": "party", @@ -587,7 +589,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-09-02 13:39:43.383705", + "modified": "2021-03-08 13:05:16.958866", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry", @@ -631,4 +633,4 @@ "sort_order": "DESC", "title_field": "title", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 11ab02021be..8acd92cb6b5 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -72,6 +72,7 @@ class PaymentEntry(AccountsController): self.update_outstanding_amounts() self.update_advance_paid() self.update_expense_claim() + self.update_donation() self.update_payment_schedule() self.set_status() @@ -82,6 +83,7 @@ class PaymentEntry(AccountsController): self.update_outstanding_amounts() self.update_advance_paid() self.update_expense_claim() + self.update_donation(cancel=1) self.delink_advance_entry_references() self.update_payment_schedule(cancel=1) self.set_payment_req_status() @@ -202,17 +204,32 @@ class PaymentEntry(AccountsController): # if account_type not in account_types: # frappe.throw(_("Account Type for {0} must be {1}").format(account, comma_or(account_types))) - def set_exchange_rate(self): + def set_exchange_rate(self, ref_doc=None): + self.set_source_exchange_rate(ref_doc) + 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_account_currency == self.company_currency: self.source_exchange_rate = 1 else: - self.source_exchange_rate = get_exchange_rate(self.paid_from_account_currency, - self.company_currency, self.posting_date) + if ref_doc: + if self.paid_from_account_currency == ref_doc.currency: + self.source_exchange_rate = ref_doc.get("exchange_rate") + if not self.source_exchange_rate: + self.source_exchange_rate = get_exchange_rate(self.paid_from_account_currency, + self.company_currency, self.posting_date) + + def set_target_exchange_rate(self, ref_doc=None): if self.paid_to and not self.target_exchange_rate: - self.target_exchange_rate = get_exchange_rate(self.paid_to_account_currency, - self.company_currency, self.posting_date) + if ref_doc: + if self.paid_to_account_currency == ref_doc.currency: + self.target_exchange_rate = ref_doc.get("exchange_rate") + + if not self.target_exchange_rate: + self.target_exchange_rate = get_exchange_rate(self.paid_to_account_currency, + self.company_currency, self.posting_date) def validate_mandatory(self): for field in ("paid_amount", "received_amount", "source_exchange_rate", "target_exchange_rate"): @@ -227,9 +244,11 @@ class PaymentEntry(AccountsController): elif self.party_type == "Supplier": valid_reference_doctypes = ("Purchase Order", "Purchase Invoice", "Journal Entry") elif self.party_type == "Employee": - valid_reference_doctypes = ("Expense Claim", "Journal Entry", "Employee Advance") + valid_reference_doctypes = ("Expense Claim", "Journal Entry", "Employee Advance", "Gratuity") elif self.party_type == "Shareholder": valid_reference_doctypes = ("Journal Entry") + elif self.party_type == "Donor": + valid_reference_doctypes = ("Donation") for d in self.get("references"): if not d.allocated_amount: @@ -282,9 +301,10 @@ class PaymentEntry(AccountsController): no_oustanding_refs.setdefault(d.reference_doctype, []).append(d) for k, v in no_oustanding_refs.items(): - frappe.msgprint(_("{} - {} now have {} as they had no outstanding amount left before submitting the Payment Entry.

    \ - If this is undesirable please cancel the corresponding Payment Entry.") - .format(k, frappe.bold(", ".join([d.reference_name for d in v])), frappe.bold("negative outstanding amount")), + frappe.msgprint( + _("{} - {} now have {} as they had no outstanding amount left before submitting the Payment Entry.") + .format(k, frappe.bold(", ".join([d.reference_name for d in v])), frappe.bold("negative outstanding amount")) + + "

    " + _("If this is undesirable please cancel the corresponding Payment Entry."), title=_("Warning"), indicator="orange") @@ -439,6 +459,10 @@ class PaymentEntry(AccountsController): .format(total_negative_outstanding), InvalidPaymentEntry) def set_title(self): + if frappe.flags.in_import and self.title: + # do not set title dynamically if title exists during data import. + return + if self.payment_type in ("Receive", "Pay"): self.title = self.party else: @@ -588,7 +612,7 @@ class PaymentEntry(AccountsController): if self.payment_type in ("Receive", "Pay") and self.party: for d in self.get("references"): if d.allocated_amount \ - and d.reference_doctype in ("Sales Order", "Purchase Order", "Employee Advance"): + and d.reference_doctype in ("Sales Order", "Purchase Order", "Employee Advance", "Gratuity"): frappe.get_doc(d.reference_doctype, d.reference_name).set_total_advance_paid() def update_expense_claim(self): @@ -598,6 +622,13 @@ class PaymentEntry(AccountsController): doc = frappe.get_doc("Expense Claim", d.reference_name) update_reimbursed_amount(doc, self.name) + def update_donation(self, cancel=0): + if self.payment_type == "Receive" and self.party_type == "Donor" and self.party: + for d in self.get("references"): + if d.reference_doctype=="Donation" and d.reference_name: + is_paid = 0 if cancel else 1 + frappe.db.set_value("Donation", d.reference_name, "paid", is_paid) + def on_recurring(self, reference_doc, auto_repeat_doc): self.reference_no = reference_doc.name self.reference_date = nowdate() @@ -897,6 +928,9 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre total_amount = ref_doc.get("grand_total") exchange_rate = 1 outstanding_amount = ref_doc.get("outstanding_amount") + elif reference_doctype == "Donation": + total_amount = ref_doc.get("amount") + exchange_rate = 1 elif reference_doctype == "Dunning": total_amount = ref_doc.get("dunning_amount") exchange_rate = 1 @@ -909,22 +943,26 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre exchange_rate = 1 outstanding_amount = get_outstanding_on_journal_entry(reference_name) elif reference_doctype != "Journal Entry": - if party_account_currency == company_currency: - if ref_doc.doctype == "Expense Claim": + if ref_doc.doctype == "Expense Claim": total_amount = flt(ref_doc.total_sanctioned_amount) + flt(ref_doc.total_taxes_and_charges) - elif ref_doc.doctype == "Employee Advance": - total_amount = ref_doc.advance_amount - else: + elif ref_doc.doctype == "Employee Advance": + total_amount = ref_doc.advance_amount + exchange_rate = ref_doc.get("exchange_rate") + if party_account_currency != ref_doc.currency: + total_amount = flt(total_amount) * flt(exchange_rate) + elif ref_doc.doctype == "Gratuity": + total_amount = ref_doc.amount + if not total_amount: + if party_account_currency == company_currency: total_amount = ref_doc.base_grand_total - exchange_rate = 1 - else: - total_amount = ref_doc.grand_total - + exchange_rate = 1 + else: + total_amount = ref_doc.grand_total + if not exchange_rate: # Get the exchange rate from the original ref doc - # or get it based on the posting date of the ref doc + # or get it based on the posting date of the ref doc. exchange_rate = ref_doc.get("conversion_rate") or \ get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date) - if reference_doctype in ("Sales Invoice", "Purchase Invoice"): outstanding_amount = ref_doc.get("outstanding_amount") bill_no = ref_doc.get("bill_no") @@ -932,11 +970,17 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre outstanding_amount = flt(ref_doc.get("total_sanctioned_amount")) + flt(ref_doc.get("total_taxes_and_charges"))\ - flt(ref_doc.get("total_amount_reimbursed")) - flt(ref_doc.get("total_advance_amount")) elif reference_doctype == "Employee Advance": - outstanding_amount = ref_doc.advance_amount - flt(ref_doc.paid_amount) + outstanding_amount = (flt(ref_doc.advance_amount) - flt(ref_doc.paid_amount)) + if party_account_currency != ref_doc.currency: + outstanding_amount = flt(outstanding_amount) * flt(exchange_rate) + if party_account_currency == company_currency: + exchange_rate = 1 + elif reference_doctype == "Gratuity": + outstanding_amount = ref_doc.amount - flt(ref_doc.paid_amount) else: outstanding_amount = flt(total_amount) - flt(ref_doc.advance_paid) else: - # Get the exchange rate based on the posting date of the ref doc + # Get the exchange rate based on the posting date of the ref doc. exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date) @@ -948,102 +992,104 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre "bill_no": bill_no }) +def get_amounts_based_on_reference_doctype(reference_doctype, ref_doc, party_account_currency, company_currency, reference_name): + total_amount, outstanding_amount, exchange_rate = None + if reference_doctype == "Fees": + total_amount = ref_doc.get("grand_total") + exchange_rate = 1 + outstanding_amount = ref_doc.get("outstanding_amount") + elif reference_doctype == "Dunning": + total_amount = ref_doc.get("dunning_amount") + exchange_rate = 1 + outstanding_amount = ref_doc.get("dunning_amount") + elif reference_doctype == "Journal Entry" and ref_doc.docstatus == 1: + total_amount = ref_doc.get("total_amount") + if ref_doc.multi_currency: + exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date) + else: + exchange_rate = 1 + outstanding_amount = get_outstanding_on_journal_entry(reference_name) + + return total_amount, outstanding_amount, exchange_rate + +def get_amounts_based_on_ref_doc(reference_doctype, ref_doc, party_account_currency, company_currency): + total_amount, outstanding_amount, exchange_rate = None + if ref_doc.doctype == "Expense Claim": + total_amount = flt(ref_doc.total_sanctioned_amount) + flt(ref_doc.total_taxes_and_charges) + elif ref_doc.doctype == "Employee Advance": + total_amount, exchange_rate = get_total_amount_exchange_rate_for_employee_advance(party_account_currency, ref_doc) + + if not total_amount: + total_amount, exchange_rate = get_total_amount_exchange_rate_base_on_currency( + party_account_currency, company_currency, ref_doc) + + if not exchange_rate: + # Get the exchange rate from the original ref doc + # or get it based on the posting date of the ref doc + exchange_rate = ref_doc.get("conversion_rate") or \ + get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date) + + outstanding_amount, exchange_rate, bill_no = get_bill_no_and_update_amounts( + reference_doctype, ref_doc, total_amount, exchange_rate, party_account_currency, company_currency) + + return total_amount, outstanding_amount, exchange_rate, bill_no + +def get_total_amount_exchange_rate_for_employee_advance(party_account_currency, ref_doc): + total_amount = ref_doc.advance_amount + exchange_rate = ref_doc.get("exchange_rate") + if party_account_currency != ref_doc.currency: + total_amount = flt(total_amount) * flt(exchange_rate) + + return total_amount, exchange_rate + +def get_total_amount_exchange_rate_base_on_currency(party_account_currency, company_currency, ref_doc): + exchange_rate = None + if party_account_currency == company_currency: + total_amount = ref_doc.base_grand_total + exchange_rate = 1 + else: + total_amount = ref_doc.grand_total + + return total_amount, exchange_rate + +def get_bill_no_and_update_amounts(reference_doctype, ref_doc, total_amount, exchange_rate, party_account_currency, company_currency): + outstanding_amount, bill_no = None + if reference_doctype in ("Sales Invoice", "Purchase Invoice"): + outstanding_amount = ref_doc.get("outstanding_amount") + bill_no = ref_doc.get("bill_no") + elif reference_doctype == "Expense Claim": + outstanding_amount = flt(ref_doc.get("total_sanctioned_amount")) + flt(ref_doc.get("total_taxes_and_charges"))\ + - flt(ref_doc.get("total_amount_reimbursed")) - flt(ref_doc.get("total_advance_amount")) + elif reference_doctype == "Employee Advance": + outstanding_amount = (flt(ref_doc.advance_amount) - flt(ref_doc.paid_amount)) + if party_account_currency != ref_doc.currency: + outstanding_amount = flt(outstanding_amount) * flt(exchange_rate) + if party_account_currency == company_currency: + exchange_rate = 1 + else: + outstanding_amount = flt(total_amount) - flt(ref_doc.advance_paid) + + return outstanding_amount, exchange_rate, bill_no + @frappe.whitelist() def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None): + reference_doc = None doc = frappe.get_doc(dt, dn) if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0: frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) - if dt in ("Sales Invoice", "Sales Order", "Dunning"): - party_type = "Customer" - elif dt in ("Purchase Invoice", "Purchase Order"): - party_type = "Supplier" - elif dt in ("Expense Claim", "Employee Advance"): - party_type = "Employee" - elif dt in ("Fees"): - party_type = "Student" - - # party account - if dt == "Sales Invoice": - party_account = get_party_account_based_on_invoice_discounting(dn) or doc.debit_to - elif dt == "Purchase Invoice": - party_account = doc.credit_to - elif dt == "Fees": - party_account = doc.receivable_account - elif dt == "Employee Advance": - party_account = doc.advance_account - elif dt == "Expense Claim": - party_account = doc.payable_account - else: - party_account = get_party_account(party_type, doc.get(party_type.lower()), doc.company) - - if dt not in ("Sales Invoice", "Purchase Invoice"): - party_account_currency = get_account_currency(party_account) - else: - party_account_currency = doc.get("party_account_currency") or get_account_currency(party_account) - - # payment type - if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning") and doc.outstanding_amount > 0)) \ - or (dt=="Purchase Invoice" and doc.outstanding_amount < 0): - payment_type = "Receive" - else: - payment_type = "Pay" - - # amounts - grand_total = outstanding_amount = 0 - if party_amount: - grand_total = outstanding_amount = party_amount - elif dt in ("Sales Invoice", "Purchase Invoice"): - if party_account_currency == doc.company_currency: - grand_total = doc.base_rounded_total or doc.base_grand_total - else: - grand_total = doc.rounded_total or doc.grand_total - outstanding_amount = doc.outstanding_amount - elif dt in ("Expense Claim"): - grand_total = doc.total_sanctioned_amount + doc.total_taxes_and_charges - outstanding_amount = doc.grand_total \ - - doc.total_amount_reimbursed - elif dt == "Employee Advance": - grand_total = doc.advance_amount - outstanding_amount = flt(doc.advance_amount) - flt(doc.paid_amount) - elif dt == "Fees": - grand_total = doc.grand_total - outstanding_amount = doc.outstanding_amount - elif dt == "Dunning": - grand_total = doc.grand_total - outstanding_amount = doc.grand_total - else: - if party_account_currency == doc.company_currency: - grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total) - else: - grand_total = flt(doc.get("rounded_total") or doc.grand_total) - outstanding_amount = grand_total - flt(doc.advance_paid) + party_type = set_party_type(dt) + party_account = set_party_account(dt, dn, doc, party_type) + party_account_currency = set_party_account_currency(dt, party_account, doc) + payment_type = set_payment_type(dt, doc) + grand_total, outstanding_amount = set_grand_total_and_outstanding_amount(party_amount, dt, party_account_currency, doc) # bank or cash - bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment"), - account=bank_account) + bank = get_bank_cash_account(doc, bank_account) - if not bank: - bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment"), - account=bank_account) - - paid_amount = received_amount = 0 - if party_account_currency == bank.account_currency: - paid_amount = received_amount = abs(outstanding_amount) - elif payment_type == "Receive": - paid_amount = abs(outstanding_amount) - if bank_amount: - received_amount = bank_amount - else: - received_amount = paid_amount * doc.get('conversion_rate', 1) - else: - received_amount = abs(outstanding_amount) - if bank_amount: - paid_amount = bank_amount - else: - # if party account currency and bank currency is different then populate paid amount as well - paid_amount = received_amount * doc.get('conversion_rate', 1) + paid_amount, received_amount = set_paid_amount_and_received_amount( + dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc) pe = frappe.new_doc("Payment Entry") pe.payment_type = payment_type @@ -1115,10 +1161,130 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= pe.setup_party_account_field() pe.set_missing_values() if party_account and bank: - pe.set_exchange_rate() + if dt == "Employee Advance": + reference_doc = doc + pe.set_exchange_rate(ref_doc=reference_doc) pe.set_amounts() return pe +def get_bank_cash_account(doc, bank_account): + bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment"), + account=bank_account) + + if not bank: + bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment"), + account=bank_account) + + return bank + +def set_party_type(dt): + if dt in ("Sales Invoice", "Sales Order", "Dunning"): + party_type = "Customer" + elif dt in ("Purchase Invoice", "Purchase Order"): + party_type = "Supplier" + elif dt in ("Expense Claim", "Employee Advance", "Gratuity"): + party_type = "Employee" + elif dt == "Fees": + party_type = "Student" + elif dt == "Donation": + party_type = "Donor" + return party_type + +def set_party_account(dt, dn, doc, party_type): + if dt == "Sales Invoice": + party_account = get_party_account_based_on_invoice_discounting(dn) or doc.debit_to + elif dt == "Purchase Invoice": + party_account = doc.credit_to + elif dt == "Fees": + party_account = doc.receivable_account + elif dt == "Employee Advance": + party_account = doc.advance_account + elif dt == "Expense Claim": + party_account = doc.payable_account + elif dt == "Gratuity": + party_account = doc.payable_account + else: + party_account = get_party_account(party_type, doc.get(party_type.lower()), doc.company) + return party_account + +def set_party_account_currency(dt, party_account, doc): + if dt not in ("Sales Invoice", "Purchase Invoice"): + party_account_currency = get_account_currency(party_account) + else: + party_account_currency = doc.get("party_account_currency") or get_account_currency(party_account) + return party_account_currency + +def set_payment_type(dt, doc): + if (dt in ("Sales Order", "Donation") or (dt in ("Sales Invoice", "Fees", "Dunning") and doc.outstanding_amount > 0)) \ + or (dt=="Purchase Invoice" and doc.outstanding_amount < 0): + payment_type = "Receive" + else: + payment_type = "Pay" + return payment_type + +def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_currency, doc): + grand_total = outstanding_amount = 0 + if party_amount: + grand_total = outstanding_amount = party_amount + elif dt in ("Sales Invoice", "Purchase Invoice"): + if party_account_currency == doc.company_currency: + grand_total = doc.base_rounded_total or doc.base_grand_total + else: + grand_total = doc.rounded_total or doc.grand_total + outstanding_amount = doc.outstanding_amount + elif dt in ("Expense Claim"): + grand_total = doc.total_sanctioned_amount + doc.total_taxes_and_charges + outstanding_amount = doc.grand_total \ + - doc.total_amount_reimbursed + elif dt == "Employee Advance": + grand_total = flt(doc.advance_amount) + outstanding_amount = flt(doc.advance_amount) - flt(doc.paid_amount) + if party_account_currency != doc.currency: + grand_total = flt(doc.advance_amount) * flt(doc.exchange_rate) + outstanding_amount = (flt(doc.advance_amount) - flt(doc.paid_amount)) * flt(doc.exchange_rate) + elif dt == "Fees": + grand_total = doc.grand_total + outstanding_amount = doc.outstanding_amount + elif dt == "Dunning": + grand_total = doc.grand_total + outstanding_amount = doc.grand_total + elif dt == "Donation": + grand_total = doc.amount + outstanding_amount = doc.amount + elif dt == "Gratuity": + grand_total = doc.amount + outstanding_amount = flt(doc.amount) - flt(doc.paid_amount) + else: + if party_account_currency == doc.company_currency: + grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total) + else: + grand_total = flt(doc.get("rounded_total") or doc.grand_total) + outstanding_amount = grand_total - flt(doc.advance_paid) + return grand_total, outstanding_amount + +def set_paid_amount_and_received_amount(dt, party_account_currency, bank, outstanding_amount, payment_type, bank_amount, doc): + paid_amount = received_amount = 0 + if party_account_currency == bank.account_currency: + paid_amount = received_amount = abs(outstanding_amount) + elif payment_type == "Receive": + paid_amount = abs(outstanding_amount) + if bank_amount: + received_amount = bank_amount + else: + received_amount = paid_amount * doc.get('conversion_rate', 1) + if dt == "Employee Advance": + received_amount = paid_amount * doc.get('exchange_rate', 1) + else: + received_amount = abs(outstanding_amount) + if bank_amount: + paid_amount = bank_amount + else: + # if party account currency and bank currency is different then populate paid amount as well + paid_amount = received_amount * doc.get('conversion_rate', 1) + if dt == "Employee Advance": + paid_amount = received_amount * doc.get('exchange_rate', 1) + return paid_amount, received_amount + def get_reference_as_per_payment_terms(payment_schedule, dt, dn, doc, grand_total, outstanding_amount): references = [] for payment_term in payment_schedule: @@ -1192,4 +1358,4 @@ def make_payment_order(source_name, target_doc=None): }, target_doc, set_missing_values) - return doclist \ No newline at end of file + return doclist diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry_list.js b/erpnext/accounts/doctype/payment_entry/payment_entry_list.js index 7ea60bb48ed..e6d83b9f683 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry_list.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry_list.js @@ -1,12 +1,14 @@ frappe.listview_settings['Payment Entry'] = { onload: function(listview) { - listview.page.fields_dict.party_type.get_query = function() { - return { - "filters": { - "name": ["in", Object.keys(frappe.boot.party_account_types)], - } + if (listview.page.fields_dict.party_type) { + listview.page.fields_dict.party_type.get_query = function() { + return { + "filters": { + "name": ["in", Object.keys(frappe.boot.party_account_types)], + } + }; }; - }; + } } }; \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json index 8dc26288206..12e6f5ef22d 100644 --- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json +++ b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json @@ -1,313 +1,98 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2015-12-23 21:31:52.699821", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, + "actions": [], + "creation": "2015-12-23 21:31:52.699821", + "doctype": "DocType", + "editable_grid": 1, + "field_order": [ + "payment_gateway", + "payment_channel", + "is_default", + "column_break_4", + "payment_account", + "currency", + "payment_request_message", + "message", + "message_examples" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_gateway", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, + "fieldname": "payment_gateway", + "fieldtype": "Link", "in_list_view": 1, - "in_standard_filter": 0, - "label": "Payment Gateway", - "length": 0, - "no_copy": 0, - "options": "Payment Gateway", - "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 - }, + "label": "Payment Gateway", + "options": "Payment Gateway", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "is_default", - "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": "Is Default", - "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 - }, + "default": "0", + "fieldname": "is_default", + "fieldtype": "Check", + "label": "Is Default" + }, { - "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 - }, + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_account", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, + "fieldname": "payment_account", + "fieldtype": "Link", "in_list_view": 1, - "in_standard_filter": 0, - "label": "Payment Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "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 - }, + "label": "Payment Account", + "options": "Account", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fetch_from": "payment_account.account_currency", "fieldname": "currency", - "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": "Currency", - "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 - }, + "fieldtype": "Read Only", + "label": "Currency" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_request_message", - "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, - "label": "", - "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 - }, + "depends_on": "eval: doc.payment_channel !== \"Phone\"", + "fieldname": "payment_request_message", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Please click on the link below to make your payment", - "fieldname": "message", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Default Payment Request Message", - "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 - }, + "default": "Please click on the link below to make your payment", + "fieldname": "message", + "fieldtype": "Small Text", + "label": "Default Payment Request Message" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "message_examples", - "fieldtype": "HTML", - "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": "Message Examples", - "length": 0, - "no_copy": 0, - "options": "
    Message Example
    \n\n<p> Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.</p>\n\n<p> Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.</p>\n\n<p> We don't want you to be spending time running around in order to pay for your Bill.
    After all, life is beautiful and the time you have in hand should be spent to enjoy it!
    So here are our little ways to help you get more time for life! </p>\n\n<a href=\"{{ payment_url }}\"> click here to pay </a>\n\n
    \n", - "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": "message_examples", + "fieldtype": "HTML", + "label": "Message Examples", + "options": "
    Message Example
    \n\n<p> Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.</p>\n\n<p> Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.</p>\n\n<p> We don't want you to be spending time running around in order to pay for your Bill.
    After all, life is beautiful and the time you have in hand should be spent to enjoy it!
    So here are our little ways to help you get more time for life! </p>\n\n<a href=\"{{ payment_url }}\"> click here to pay </a>\n\n
    \n" + }, + { + "default": "Email", + "fieldname": "payment_channel", + "fieldtype": "Select", + "label": "Payment Channel", + "options": "\nEmail\nPhone" } - ], - "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-05-16 22:43:34.970491", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Payment Gateway Account", - "name_case": "", - "owner": "Administrator", + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-20 13:30:27.722852", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Payment Gateway Account", + "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": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index 355fe96c967..6b07197ec10 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -37,6 +37,11 @@ frappe.ui.form.on("Payment Reconciliation Payment", { erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.extend({ onload: function() { var me = this; + + this.frm.set_query("party", function() { + check_mandatory(me.frm); + }); + this.frm.set_query("party_type", function() { return { "filters": { @@ -46,37 +51,39 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext }); this.frm.set_query('receivable_payable_account', function() { - if(!me.frm.doc.company || !me.frm.doc.party_type) { - frappe.msgprint(__("Please select Company and Party Type first")); - } else { - return{ - filters: { - "company": me.frm.doc.company, - "is_group": 0, - "account_type": frappe.boot.party_account_types[me.frm.doc.party_type] - } - }; - } - + check_mandatory(me.frm); + return { + filters: { + "company": me.frm.doc.company, + "is_group": 0, + "account_type": frappe.boot.party_account_types[me.frm.doc.party_type] + } + }; }); this.frm.set_query('bank_cash_account', function() { - if(!me.frm.doc.company) { - frappe.msgprint(__("Please select Company first")); - } else { - return{ - filters:[ - ['Account', 'company', '=', me.frm.doc.company], - ['Account', 'is_group', '=', 0], - ['Account', 'account_type', 'in', ['Bank', 'Cash']] - ] - }; - } + check_mandatory(me.frm, true); + return { + filters:[ + ['Account', 'company', '=', me.frm.doc.company], + ['Account', 'is_group', '=', 0], + ['Account', 'account_type', 'in', ['Bank', 'Cash']] + ] + }; }); this.frm.set_value('party_type', ''); this.frm.set_value('party', ''); this.frm.set_value('receivable_payable_account', ''); + + var check_mandatory = (frm, only_company=false) => { + var title = __("Mandatory"); + if (only_company && !frm.doc.company) { + frappe.throw({message: __("Please Select a Company First"), title: title}); + } else if (!frm.doc.company || !frm.doc.party_type) { + frappe.throw({message: __("Please Select Both Company and Party Type First"), title: title}); + } + }; }, refresh: function() { @@ -90,7 +97,7 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext party: function() { var me = this - if(!me.frm.doc.receivable_payable_account && me.frm.doc.party_type && me.frm.doc.party) { + if (!me.frm.doc.receivable_payable_account && me.frm.doc.party_type && me.frm.doc.party) { return frappe.call({ method: "erpnext.accounts.party.get_party_account", args: { @@ -99,7 +106,7 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext party: me.frm.doc.party }, callback: function(r) { - if(!r.exc && r.message) { + if (!r.exc && r.message) { me.frm.set_value("receivable_payable_account", r.message); } } diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 2f8b634664c..f7a15c04faa 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -88,18 +88,19 @@ class PaymentReconciliation(Document): voucher_type = ('Sales Invoice' if self.party_type == 'Customer' else "Purchase Invoice") - return frappe.db.sql(""" SELECT `tab{doc}`.name as reference_name, %(voucher_type)s as reference_type, - (sum(`tabGL Entry`.{dr_or_cr}) - sum(`tabGL Entry`.{reconciled_dr_or_cr})) as amount, + return frappe.db.sql(""" SELECT doc.name as reference_name, %(voucher_type)s as reference_type, + (sum(gl.{dr_or_cr}) - sum(gl.{reconciled_dr_or_cr})) as amount, account_currency as currency - FROM `tab{doc}`, `tabGL Entry` + FROM `tab{doc}` doc, `tabGL Entry` gl WHERE - (`tab{doc}`.name = `tabGL Entry`.against_voucher or `tab{doc}`.name = `tabGL Entry`.voucher_no) - and `tab{doc}`.{party_type_field} = %(party)s - and `tab{doc}`.is_return = 1 and `tab{doc}`.return_against IS NULL - and `tabGL Entry`.against_voucher_type = %(voucher_type)s - and `tab{doc}`.docstatus = 1 and `tabGL Entry`.party = %(party)s - and `tabGL Entry`.party_type = %(party_type)s and `tabGL Entry`.account = %(account)s - GROUP BY `tab{doc}`.name + (doc.name = gl.against_voucher or doc.name = gl.voucher_no) + and doc.{party_type_field} = %(party)s + and doc.is_return = 1 and ifnull(doc.return_against, "") = "" + and gl.against_voucher_type = %(voucher_type)s + and doc.docstatus = 1 and gl.party = %(party)s + and gl.party_type = %(party_type)s and gl.account = %(account)s + and gl.is_cancelled = 0 + GROUP BY doc.name Having amount > 0 """.format( @@ -112,7 +113,7 @@ class PaymentReconciliation(Document): 'party_type': self.party_type, 'voucher_type': voucher_type, 'account': self.receivable_payable_account - }, as_dict=1) + }, as_dict=1, debug=1) def add_payment_entries(self, entries): self.set('payments', []) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js index e1e43140c01..901ef1987b4 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.js +++ b/erpnext/accounts/doctype/payment_request/payment_request.js @@ -25,7 +25,7 @@ frappe.ui.form.on("Payment Request", "onload", function(frm, dt, dn){ }) frappe.ui.form.on("Payment Request", "refresh", function(frm) { - if(frm.doc.payment_request_type == 'Inward' && + if(frm.doc.payment_request_type == 'Inward' && frm.doc.payment_channel !== "Phone" && !in_list(["Initiated", "Paid"], frm.doc.status) && !frm.doc.__islocal && frm.doc.docstatus==1){ frm.add_custom_button(__('Resend Payment Email'), function(){ frappe.call({ diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json index 8eadfd0b24a..2ee356aaf40 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.json +++ b/erpnext/accounts/doctype/payment_request/payment_request.json @@ -48,6 +48,7 @@ "section_break_7", "payment_gateway", "payment_account", + "payment_channel", "payment_order", "amended_from" ], @@ -230,6 +231,7 @@ "label": "Recipient Message And Payment Details" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "print_format", "fieldtype": "Select", "label": "Print Format" @@ -241,6 +243,7 @@ "label": "To" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "subject", "fieldtype": "Data", "in_global_search": 1, @@ -277,16 +280,18 @@ "read_only": 1 }, { - "depends_on": "eval: doc.payment_request_type == 'Inward'", + "depends_on": "eval: doc.payment_request_type == 'Inward' || doc.payment_channel != \"Phone\"", "fieldname": "section_break_10", "fieldtype": "Section Break" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "message", "fieldtype": "Text", "label": "Message" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "message_examples", "fieldtype": "HTML", "label": "Message Examples", @@ -347,12 +352,21 @@ "options": "Payment Request", "print_hide": 1, "read_only": 1 + }, + { + "fetch_from": "payment_gateway_account.payment_channel", + "fieldname": "payment_channel", + "fieldtype": "Select", + "label": "Payment Channel", + "options": "\nEmail\nPhone", + "read_only": 1 } ], "in_create": 1, + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-07-17 14:06:42.185763", + "modified": "2020-09-18 12:24:14.178853", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Request", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index e93ec951fb0..53ac996290b 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -3,6 +3,7 @@ # For license information, please see license.txt from __future__ import unicode_literals +import json import frappe from frappe import _ from frappe.model.document import Document @@ -36,7 +37,7 @@ class PaymentRequest(Document): 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) + 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") @@ -76,11 +77,44 @@ class PaymentRequest(Document): or self.flags.mute_email: send_mail = False - if send_mail: + if send_mail and self.payment_channel != "Phone": self.set_payment_request_url() self.send_email() self.make_communication_entry() + elif self.payment_channel == "Phone": + self.request_phone_payment() + + def request_phone_payment(self): + controller = get_payment_gateway_controller(self.payment_gateway) + request_amount = self.get_request_amount() + + payment_record = dict( + reference_doctype="Payment Request", + reference_docname=self.name, + payment_reference=self.reference_name, + request_amount=request_amount, + sender=self.email_to, + currency=self.currency, + payment_gateway=self.payment_gateway + ) + + controller.validate_transaction_currency(self.currency) + controller.request_for_payment(**payment_record) + + def get_request_amount(self): + data_of_completed_requests = frappe.get_all("Integration Request", filters={ + 'reference_doctype': self.doctype, + 'reference_docname': self.name, + 'status': 'Completed' + }, pluck="data") + + if not data_of_completed_requests: + return self.grand_total + + request_amounts = sum([json.loads(d).get('request_amount') for d in data_of_completed_requests]) + return request_amounts + def on_cancel(self): self.check_if_payment_entry_exists() self.set_as_cancelled() @@ -105,13 +139,14 @@ class PaymentRequest(Document): return False def set_payment_request_url(self): - if self.payment_account: + if self.payment_account and self.payment_channel != "Phone": self.payment_url = self.get_payment_url() if self.payment_url: self.db_set('payment_url', self.payment_url) - if self.payment_url or not self.payment_gateway_account: + if self.payment_url or not self.payment_gateway_account \ + or (self.payment_gateway_account and self.payment_channel == "Phone"): self.db_set('status', 'Initiated') def get_payment_url(self): @@ -140,10 +175,14 @@ class PaymentRequest(Document): }) def set_as_paid(self): - payment_entry = self.create_payment_entry() - self.make_invoice() + if self.payment_channel == "Phone": + self.db_set("status", "Paid") - return payment_entry + else: + payment_entry = self.create_payment_entry() + self.make_invoice() + + return payment_entry def create_payment_entry(self, submit=True): """create entry""" @@ -151,7 +190,7 @@ class PaymentRequest(Document): ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) - if self.reference_doctype == "Sales Invoice": + if self.reference_doctype in ["Sales Invoice", "POS Invoice"]: party_account = ref_doc.debit_to elif self.reference_doctype == "Purchase Invoice": party_account = ref_doc.credit_to @@ -166,8 +205,8 @@ class PaymentRequest(Document): else: party_amount = self.grand_total - payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, - party_amount=party_amount, bank_account=self.payment_account, bank_amount=bank_amount) + payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, party_amount=party_amount, + bank_account=self.payment_account, bank_amount=bank_amount) payment_entry.update({ "reference_no": self.name, @@ -255,7 +294,7 @@ class PaymentRequest(Document): # if shopping cart enabled and in session if (shopping_cart_settings.enabled and hasattr(frappe.local, "session") - and frappe.local.session.user != "Guest"): + and frappe.local.session.user != "Guest") and self.payment_channel != "Phone": success_url = shopping_cart_settings.payment_success_url if success_url: @@ -280,7 +319,9 @@ def make_payment_request(**args): args = frappe._dict(args) ref_doc = frappe.get_doc(args.dt, args.dn) - grand_total = get_amount(ref_doc) + gateway_account = get_gateway_details(args) or frappe._dict() + + grand_total = get_amount(ref_doc, gateway_account.get("payment_account")) if args.loyalty_points and args.dt == "Sales Order": from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points loyalty_amount = validate_loyalty_points(ref_doc, int(args.loyalty_points)) @@ -288,8 +329,6 @@ def make_payment_request(**args): frappe.db.set_value("Sales Order", args.dn, "loyalty_amount", loyalty_amount, update_modified=False) grand_total = grand_total - loyalty_amount - gateway_account = get_gateway_details(args) or frappe._dict() - bank_account = (get_party_bank_account(args.get('party_type'), args.get('party')) if args.get('party_type') else '') @@ -314,9 +353,11 @@ def make_payment_request(**args): "payment_gateway_account": gateway_account.get("name"), "payment_gateway": gateway_account.get("payment_gateway"), "payment_account": gateway_account.get("payment_account"), + "payment_channel": gateway_account.get("payment_channel"), "payment_request_type": args.get("payment_request_type"), "currency": ref_doc.currency, "grand_total": grand_total, + "mode_of_payment": args.mode_of_payment, "email_to": args.recipient_id or ref_doc.owner, "subject": _("Payment Request for {0}").format(args.dn), "message": gateway_account.get("message") or get_dummy_message(ref_doc), @@ -330,8 +371,8 @@ def make_payment_request(**args): if args.order_type == "Shopping Cart" or args.mute_email: pr.flags.mute_email = True + pr.insert(ignore_permissions=True) if args.submit_doc: - pr.insert(ignore_permissions=True) pr.submit() if args.order_type == "Shopping Cart": @@ -344,7 +385,7 @@ def make_payment_request(**args): return pr.as_dict() -def get_amount(ref_doc): +def get_amount(ref_doc, payment_account=None): """get amount based on doctype""" dt = ref_doc.doctype if dt in ["Sales Order", "Purchase Order"]: @@ -356,6 +397,12 @@ def get_amount(ref_doc): else: grand_total = flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate + 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 @@ -366,6 +413,10 @@ def get_amount(ref_doc): frappe.throw(_("Payment Entry is already created")) def get_existing_payment_request_amount(ref_dt, ref_dn): + """ + Get the existing payment request which are unpaid or partially paid for payment channel other than Phone + and get the summation of existing paid payment request for Phone payment channel. + """ existing_payment_request_amount = frappe.db.sql(""" select sum(grand_total) from `tabPayment Request` @@ -373,14 +424,16 @@ def get_existing_payment_request_amount(ref_dt, ref_dn): reference_doctype = %s and reference_name = %s and docstatus = 1 - and status != 'Paid' + and (status != 'Paid' + or (payment_channel = 'Phone' + and status = 'Paid')) """, (ref_dt, ref_dn)) return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0 def get_gateway_details(args): """return gateway and payment account of default payment gateway""" - if args.get("payment_gateway"): - return get_payment_gateway_account(args.get("payment_gateway")) + if args.get("payment_gateway_account"): + return get_payment_gateway_account(args.get("payment_gateway_account")) if args.order_type == "Shopping Cart": payment_gateway_account = frappe.get_doc("Shopping Cart Settings").payment_gateway_account diff --git a/erpnext/accounts/doctype/payment_request/payment_request_list.js b/erpnext/accounts/doctype/payment_request/payment_request_list.js index 72833d235f8..85d729cd61c 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request_list.js +++ b/erpnext/accounts/doctype/payment_request/payment_request_list.js @@ -2,7 +2,7 @@ frappe.listview_settings['Payment Request'] = { add_fields: ["status"], get_indicator: function(doc) { if(doc.status == "Draft") { - return [__("Draft"), "darkgrey", "status,=,Draft"]; + return [__("Draft"), "gray", "status,=,Draft"]; } if(doc.status == "Requested") { return [__("Requested"), "green", "status,=,Requested"]; @@ -19,5 +19,5 @@ frappe.listview_settings['Payment Request'] = { else if(doc.status == "Cancelled") { return [__("Cancelled"), "red", "status,=,Cancelled"]; } - } + } } diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 8a10e2cbd95..5eba62c0b31 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -45,7 +45,8 @@ class TestPaymentRequest(unittest.TestCase): def test_payment_request_linkings(self): so_inr = make_sales_order(currency="INR") - pr = make_payment_request(dt="Sales Order", dn=so_inr.name, recipient_id="saurabh@erpnext.com") + pr = make_payment_request(dt="Sales Order", dn=so_inr.name, recipient_id="saurabh@erpnext.com", + payment_gateway_account="_Test Gateway - INR") self.assertEqual(pr.reference_doctype, "Sales Order") self.assertEqual(pr.reference_name, so_inr.name) @@ -54,7 +55,8 @@ class TestPaymentRequest(unittest.TestCase): conversion_rate = get_exchange_rate("USD", "INR") si_usd = create_sales_invoice(currency="USD", conversion_rate=conversion_rate) - pr = make_payment_request(dt="Sales Invoice", dn=si_usd.name, recipient_id="saurabh@erpnext.com") + pr = make_payment_request(dt="Sales Invoice", dn=si_usd.name, recipient_id="saurabh@erpnext.com", + payment_gateway_account="_Test Gateway - USD") self.assertEqual(pr.reference_doctype, "Sales Invoice") self.assertEqual(pr.reference_name, si_usd.name) @@ -68,7 +70,7 @@ class TestPaymentRequest(unittest.TestCase): so_inr = make_sales_order(currency="INR") pr = make_payment_request(dt="Sales Order", dn=so_inr.name, recipient_id="saurabh@erpnext.com", - mute_email=1, submit_doc=1, return_doc=1) + mute_email=1, payment_gateway_account="_Test Gateway - INR", submit_doc=1, return_doc=1) pe = pr.set_as_paid() so_inr = frappe.get_doc("Sales Order", so_inr.name) @@ -79,7 +81,7 @@ class TestPaymentRequest(unittest.TestCase): currency="USD", conversion_rate=50) pr = make_payment_request(dt="Sales Invoice", dn=si_usd.name, recipient_id="saurabh@erpnext.com", - mute_email=1, payment_gateway="_Test Gateway - USD", submit_doc=1, return_doc=1) + mute_email=1, payment_gateway_account="_Test Gateway - USD", submit_doc=1, return_doc=1) pe = pr.set_as_paid() @@ -106,7 +108,7 @@ class TestPaymentRequest(unittest.TestCase): currency="USD", conversion_rate=50) pr = make_payment_request(dt="Sales Invoice", dn=si_usd.name, recipient_id="saurabh@erpnext.com", - mute_email=1, payment_gateway="_Test Gateway - USD", submit_doc=1, return_doc=1) + mute_email=1, payment_gateway_account="_Test Gateway - USD", submit_doc=1, return_doc=1) pe = pr.create_payment_entry() pr.load_from_db() diff --git a/erpnext/accounts/doctype/payment_term/payment_term.json b/erpnext/accounts/doctype/payment_term/payment_term.json index 723d3bd72cd..e77c244d3dc 100644 --- a/erpnext/accounts/doctype/payment_term/payment_term.json +++ b/erpnext/accounts/doctype/payment_term/payment_term.json @@ -45,6 +45,7 @@ "unique": 0 }, { + "description": "Provide the invoice portion in percent", "allow_bulk_edit": 0, "allow_on_submit": 0, "bold": 1, @@ -170,6 +171,7 @@ "unique": 0 }, { + "description": "Give number of days according to prior selection", "allow_bulk_edit": 0, "allow_on_submit": 0, "bold": 1, @@ -305,7 +307,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2018-03-08 10:47:32.830478", + "modified": "2020-10-14 10:47:32.830478", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Term", @@ -381,4 +383,4 @@ "sort_order": "DESC", "track_changes": 1, "track_seen": 0 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index 7dd5b017703..a74fa062b6a 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -8,7 +8,7 @@ from frappe import _ from erpnext.accounts.utils import get_account_currency from erpnext.controllers.accounts_controller import AccountsController from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (get_accounting_dimensions, - get_dimension_filters) + get_dimensions) class PeriodClosingVoucher(AccountsController): def validate(self): @@ -58,7 +58,7 @@ class PeriodClosingVoucher(AccountsController): for dimension in accounting_dimensions: dimension_fields.append('t1.{0}'.format(dimension)) - dimension_filters, default_dimensions = get_dimension_filters() + dimension_filters, default_dimensions = get_dimensions() pl_accounts = self.get_pl_balances(dimension_fields) diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js index 9336fc37068..9ea616f8e77 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js @@ -3,6 +3,7 @@ frappe.ui.form.on('POS Closing Entry', { onload: function(frm) { + frm.ignore_doctypes_on_cancel_all = ['POS Invoice Merge Log']; frm.set_query("pos_profile", function(doc) { return { filters: { 'user': doc.user } @@ -20,7 +21,7 @@ frappe.ui.form.on('POS Closing Entry', { return { filters: { 'status': 'Open', 'docstatus': 1 } }; }); - if (frm.doc.docstatus === 0) frm.set_value("period_end_date", frappe.datetime.now_datetime()); + if (frm.doc.docstatus === 0 && !frm.doc.amended_from) frm.set_value("period_end_date", frappe.datetime.now_datetime()); if (frm.doc.docstatus === 1) set_html_data(frm); }, @@ -51,6 +52,7 @@ frappe.ui.form.on('POS Closing Entry', { args: { start: frappe.datetime.get_datetime_as_string(frm.doc.period_start_date), end: frappe.datetime.get_datetime_as_string(frm.doc.period_end_date), + pos_profile: frm.doc.pos_profile, user: frm.doc.user }, callback: (r) => { diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json index 32bca3b8407..a9b91e02a9d 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json @@ -6,11 +6,13 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "period_details_section", "period_start_date", "period_end_date", "column_break_3", "posting_date", "pos_opening_entry", + "status", "section_break_5", "company", "column_break_7", @@ -64,7 +66,8 @@ }, { "fieldname": "section_break_5", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "User Details" }, { "fieldname": "company", @@ -120,7 +123,7 @@ "collapsible_depends_on": "eval:doc.docstatus==0", "fieldname": "section_break_13", "fieldtype": "Section Break", - "label": "Details" + "label": "Totals" }, { "default": "0", @@ -184,11 +187,32 @@ "label": "POS Opening Entry", "options": "POS Opening Entry", "reqd": 1 + }, + { + "allow_on_submit": 1, + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "label": "Status", + "options": "Draft\nSubmitted\nQueued\nCancelled", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "period_details_section", + "fieldtype": "Section Break", + "label": "Period Details" } ], "is_submittable": 1, - "links": [], - "modified": "2020-05-29 15:03:22.226113", + "links": [ + { + "link_doctype": "POS Invoice Merge Log", + "link_fieldname": "pos_closing_entry" + } + ], + "modified": "2021-02-01 13:47:20.722104", "modified_by": "Administrator", "module": "Accounts", "name": "POS Closing Entry", 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 9899219bdcb..f5224a269e1 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py @@ -6,39 +6,84 @@ from __future__ import unicode_literals import frappe import json from frappe import _ -from frappe.model.document import Document -from frappe.utils import getdate, get_datetime, flt -from collections import defaultdict +from frappe.utils import get_datetime, flt +from erpnext.controllers.status_updater import StatusUpdater from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data -from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import merge_pos_invoices +from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices, unconsolidate_pos_invoices -class POSClosingEntry(Document): +class POSClosingEntry(StatusUpdater): def validate(self): - user = frappe.get_all('POS Closing Entry', - filters = { 'user': self.user, 'docstatus': 1 }, - or_filters = { - 'period_start_date': ('between', [self.period_start_date, self.period_end_date]), - 'period_end_date': ('between', [self.period_start_date, self.period_end_date]) - }) - - if user: - frappe.throw(_("POS Closing Entry {} against {} between selected period" - .format(frappe.bold("already exists"), frappe.bold(self.user))), title=_("Invalid Period")) - 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")) - def on_submit(self): - merge_pos_invoices(self.pos_transactions) - opening_entry = frappe.get_doc("POS Opening Entry", self.pos_opening_entry) - opening_entry.pos_closing_entry = self.name - opening_entry.set_status() - opening_entry.save() + self.validate_pos_closing() + self.validate_pos_invoices() + + def validate_pos_closing(self): + user = frappe.db.sql(""" + SELECT name FROM `tabPOS Closing Entry` + WHERE + user = %(user)s AND docstatus = 1 AND pos_profile = %(profile)s AND + (period_start_date between %(start)s and %(end)s OR period_end_date between %(start)s and %(end)s) + """, { + 'user': self.user, + 'profile': self.pos_profile, + 'start': self.period_start_date, + 'end': self.period_end_date + }) + + if user: + bold_already_exists = frappe.bold(_("already exists")) + bold_user = frappe.bold(self.user) + frappe.throw(_("POS Closing Entry {} against {} between selected period") + .format(bold_already_exists, bold_user), title=_("Invalid Period")) + + def validate_pos_invoices(self): + invalid_rows = [] + for d in self.pos_transactions: + invalid_row = {'idx': d.idx} + pos_invoice = frappe.db.get_values("POS Invoice", d.pos_invoice, + ["consolidated_invoice", "pos_profile", "docstatus", "owner"], as_dict=1)[0] + if pos_invoice.consolidated_invoice: + invalid_row.setdefault('msg', []).append(_('POS Invoice is {}').format(frappe.bold("already consolidated"))) + invalid_rows.append(invalid_row) + continue + if pos_invoice.pos_profile != self.pos_profile: + invalid_row.setdefault('msg', []).append(_("POS Profile doesn't matches {}").format(frappe.bold(self.pos_profile))) + if pos_invoice.docstatus != 1: + invalid_row.setdefault('msg', []).append(_('POS Invoice is not {}').format(frappe.bold("submitted"))) + if pos_invoice.owner != self.user: + invalid_row.setdefault('msg', []).append(_("POS Invoice isn't created by user {}").format(frappe.bold(self.owner))) + + if invalid_row.get('msg'): + invalid_rows.append(invalid_row) + + if not invalid_rows: + return + + error_list = [] + for row in invalid_rows: + for msg in row.get('msg'): + error_list.append(_("Row #{}: {}").format(row.get('idx'), msg)) + + frappe.throw(error_list, title=_("Invalid POS Invoices"), as_list=True) def get_payment_reconciliation_details(self): currency = frappe.get_cached_value('Company', self.company, "default_currency") return frappe.render_template("erpnext/accounts/doctype/pos_closing_entry/closing_voucher_details.html", {"data": self, "currency": currency}) + + def on_submit(self): + consolidate_pos_invoices(closing_entry=self) + + def on_cancel(self): + unconsolidate_pos_invoices(closing_entry=self) + + def update_opening_entry(self, for_cancel=False): + opening_entry = frappe.get_doc("POS Opening Entry", self.pos_opening_entry) + opening_entry.pos_closing_entry = self.name if not for_cancel else None + opening_entry.set_status() + opening_entry.save() @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs @@ -47,16 +92,15 @@ def get_cashiers(doctype, txt, searchfield, start, page_len, filters): return [c['user'] for c in cashiers_list] @frappe.whitelist() -def get_pos_invoices(start, end, user): +def get_pos_invoices(start, end, pos_profile, user): data = frappe.db.sql(""" select name, timestamp(posting_date, posting_time) as "timestamp" from `tabPOS Invoice` where - owner = %s and docstatus = 1 and - (consolidated_invoice is NULL or consolidated_invoice = '') - """, (user), as_dict=1) + owner = %s and docstatus = 1 and pos_profile = %s and ifnull(consolidated_invoice,'') = '' + """, (user, pos_profile), as_dict=1) data = list(filter(lambda d: get_datetime(start) <= get_datetime(d.timestamp) <= get_datetime(end), data)) # need to get taxes and payments so can't avoid get_doc @@ -76,7 +120,8 @@ def make_closing_entry_from_opening(opening_entry): closing_entry.net_total = 0 closing_entry.total_quantity = 0 - invoices = get_pos_invoices(closing_entry.period_start_date, closing_entry.period_end_date, closing_entry.user) + invoices = get_pos_invoices(closing_entry.period_start_date, closing_entry.period_end_date, + closing_entry.pos_profile, closing_entry.user) pos_transactions = [] taxes = [] diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry_list.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry_list.js new file mode 100644 index 00000000000..20fd610899e --- /dev/null +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry_list.js @@ -0,0 +1,16 @@ +// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +// render +frappe.listview_settings['POS Closing Entry'] = { + get_indicator: function(doc) { + var status_color = { + "Draft": "red", + "Submitted": "blue", + "Queued": "orange", + "Cancelled": "red" + + }; + return [__(doc.status), status_color[doc.status], "status,=,"+doc.status]; + } +}; diff --git a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py index aa6a388df5f..b596c0cf25a 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py @@ -5,15 +5,23 @@ from __future__ import unicode_literals import frappe import unittest from frappe.utils import nowdate +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import make_closing_entry_from_opening from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile class TestPOSClosingEntry(unittest.TestCase): + def setUp(self): + # Make stock available for POS Sales + make_stock_entry(target="_Test Warehouse - _TC", qty=2, basic_rate=100) + + def tearDown(self): + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + def test_pos_closing_entry(self): test_user, pos_profile = init_user_and_profile() - opening_entry = create_opening_entry(pos_profile, test_user.name) pos_inv1 = create_pos_invoice(rate=3500, do_not_submit=1) @@ -42,10 +50,48 @@ class TestPOSClosingEntry(unittest.TestCase): self.assertEqual(pcv_doc.total_quantity, 2) self.assertEqual(pcv_doc.net_total, 6700) - frappe.set_user("Administrator") - frappe.db.sql("delete from `tabPOS Profile`") + def test_cancelling_of_pos_closing_entry(self): + test_user, pos_profile = init_user_and_profile() + opening_entry = create_opening_entry(pos_profile, test_user.name) -def init_user_and_profile(): + pos_inv1 = create_pos_invoice(rate=3500, do_not_submit=1) + pos_inv1.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3500 + }) + pos_inv1.submit() + + pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1) + pos_inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3200 + }) + pos_inv2.submit() + + pcv_doc = make_closing_entry_from_opening(opening_entry) + payment = pcv_doc.payment_reconciliation[0] + + self.assertEqual(payment.mode_of_payment, 'Cash') + + for d in pcv_doc.payment_reconciliation: + if d.mode_of_payment == 'Cash': + d.closing_amount = 6700 + + pcv_doc.submit() + + pos_inv1.load_from_db() + self.assertRaises(frappe.ValidationError, pos_inv1.cancel) + + si_doc = frappe.get_doc("Sales Invoice", pos_inv1.consolidated_invoice) + self.assertRaises(frappe.ValidationError, si_doc.cancel) + + pcv_doc.load_from_db() + pcv_doc.cancel() + si_doc.load_from_db() + pos_inv1.load_from_db() + self.assertEqual(si_doc.docstatus, 2) + self.assertEqual(pos_inv1.status, 'Paid') + + +def init_user_and_profile(**args): user = 'test@example.com' test_user = frappe.get_doc('User', user) @@ -53,7 +99,7 @@ def init_user_and_profile(): test_user.add_roles(*roles) frappe.set_user(user) - pos_profile = make_pos_profile() + pos_profile = make_pos_profile(**args) pos_profile.append('applicable_for_users', { 'default': 1, 'user': user @@ -61,4 +107,4 @@ def init_user_and_profile(): pos_profile.save() - return test_user, pos_profile \ No newline at end of file + return test_user, pos_profile diff --git a/erpnext/accounts/doctype/pos_closing_entry_detail/pos_closing_entry_detail.json b/erpnext/accounts/doctype/pos_closing_entry_detail/pos_closing_entry_detail.json index 798637a840c..6e7768dc542 100644 --- a/erpnext/accounts/doctype/pos_closing_entry_detail/pos_closing_entry_detail.json +++ b/erpnext/accounts/doctype/pos_closing_entry_detail/pos_closing_entry_detail.json @@ -7,8 +7,8 @@ "field_order": [ "mode_of_payment", "opening_amount", - "closing_amount", "expected_amount", + "closing_amount", "difference" ], "fields": [ @@ -26,8 +26,7 @@ "in_list_view": 1, "label": "Expected Amount", "options": "company:company_currency", - "read_only": 1, - "reqd": 1 + "read_only": 1 }, { "fieldname": "difference", @@ -55,9 +54,10 @@ "reqd": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-05-29 15:03:34.533607", + "modified": "2020-10-23 16:45:43.662034", "modified_by": "Administrator", "module": "Accounts", "name": "POS Closing Entry Detail", diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 3be43044aad..493bd448024 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -2,6 +2,7 @@ // For license information, please see license.txt {% include 'erpnext/selling/sales_common.js' %}; +frappe.provide("erpnext.accounts"); erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend({ setup(doc) { @@ -9,80 +10,70 @@ erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend( this._super(doc); }, - onload() { - this._super(); - if(this.frm.doc.__islocal && this.frm.doc.is_pos) { - //Load pos profile data on the invoice if the default value of Is POS is 1 + company: function() { + erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); + }, - me.frm.script_manager.trigger("is_pos"); - me.frm.refresh_fields(); + onload(doc) { + this._super(); + this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice Merge Log']; + if(doc.__islocal && doc.is_pos && frappe.get_route_str() !== 'point-of-sale') { + this.frm.script_manager.trigger("is_pos"); + this.frm.refresh_fields(); } + + erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype); }, refresh(doc) { this._super(); if (doc.docstatus == 1 && !doc.is_return) { - if(doc.outstanding_amount >= 0 || Math.abs(flt(doc.outstanding_amount)) < flt(doc.grand_total)) { - cur_frm.add_custom_button(__('Return'), - this.make_sales_return, __('Create')); - cur_frm.page.set_inner_btn_group_as_primary(__('Create')); - } + this.frm.add_custom_button(__('Return'), this.make_sales_return, __('Create')); + this.frm.page.set_inner_btn_group_as_primary(__('Create')); } - if (this.frm.doc.is_return) { + if (doc.is_return && doc.__islocal) { this.frm.return_print_format = "Sales Invoice Return"; - cur_frm.set_value('consolidated_invoice', ''); + this.frm.set_value('consolidated_invoice', ''); } }, - is_pos: function(frm){ + is_pos: function() { this.set_pos_data(); }, - set_pos_data: function() { + set_pos_data: async function() { if(this.frm.doc.is_pos) { this.frm.set_value("allocate_advances_automatically", 0); if(!this.frm.doc.company) { this.frm.set_value("is_pos", 0); frappe.msgprint(__("Please specify Company to proceed")); } else { - var me = this; - return this.frm.call({ - doc: me.frm.doc, + const r = await this.frm.call({ + doc: this.frm.doc, method: "set_missing_values", - callback: function(r) { - if(!r.exc) { - if(r.message) { - me.frm.pos_print_format = r.message.print_format || ""; - me.frm.meta.default_print_format = r.message.print_format || ""; - me.frm.allow_edit_rate = r.message.allow_edit_rate; - me.frm.allow_edit_discount = r.message.allow_edit_discount; - me.frm.doc.campaign = r.message.campaign; - me.frm.allow_print_before_pay = r.message.allow_print_before_pay; - } - me.frm.script_manager.trigger("update_stock"); - me.calculate_taxes_and_totals(); - if(me.frm.doc.taxes_and_charges) { - me.frm.script_manager.trigger("taxes_and_charges"); - } - frappe.model.set_default_values(me.frm.doc); - me.set_dynamic_labels(); - - } - } + freeze: true }); + if(!r.exc) { + if(r.message) { + this.frm.pos_print_format = r.message.print_format || ""; + this.frm.meta.default_print_format = r.message.print_format || ""; + this.frm.doc.campaign = r.message.campaign; + this.frm.allow_print_before_pay = r.message.allow_print_before_pay; + } + this.frm.script_manager.trigger("update_stock"); + this.calculate_taxes_and_totals(); + this.frm.doc.taxes_and_charges && this.frm.script_manager.trigger("taxes_and_charges"); + frappe.model.set_default_values(this.frm.doc); + this.set_dynamic_labels(); + } } } - else this.frm.trigger("refresh"); }, customer() { if (!this.frm.doc.customer) return - - if (this.frm.doc.is_pos){ - var pos_profile = this.frm.doc.pos_profile; - } - var me = this; + const pos_profile = this.frm.doc.pos_profile; if(this.frm.updating_party_details) return; erpnext.utils.get_party_details(this.frm, "erpnext.accounts.party.get_party_details", { @@ -92,8 +83,8 @@ erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend( account: this.frm.doc.debit_to, price_list: this.frm.doc.selling_price_list, pos_profile: pos_profile - }, function() { - me.apply_pricing_rule(); + }, () => { + this.apply_pricing_rule(); }); }, @@ -201,5 +192,47 @@ frappe.ui.form.on('POS Invoice', { } frm.set_value("loyalty_amount", loyalty_amount); } + }, + + request_for_payment: function (frm) { + if (!frm.doc.contact_mobile) { + frappe.throw(__('Please enter mobile number first.')); + } + frm.dirty(); + frm.save().then(() => { + frappe.dom.freeze(__('Waiting for payment...')); + frappe + .call({ + method: 'create_payment_request', + doc: frm.doc + }) + .fail(() => { + frappe.dom.unfreeze(); + frappe.msgprint(__('Payment request failed')); + }) + .then(({ message }) => { + const payment_request_name = message.name; + setTimeout(() => { + frappe.db.get_value('Payment Request', payment_request_name, ['status', 'grand_total']).then(({ message }) => { + if (message.status != 'Paid') { + frappe.dom.unfreeze(); + frappe.msgprint({ + message: __('Payment Request took too long to respond. Please try requesting for payment again.'), + title: __('Request Timeout') + }); + } else if (frappe.dom.freeze_count != 0) { + frappe.dom.unfreeze(); + cur_frm.reload_doc(); + cur_pos.payment.events.submit_invoice(); + + frappe.show_alert({ + message: __("Payment of {0} received successfully.", [format_currency(message.grand_total, frm.doc.currency, 0)]), + indicator: 'green' + }); + } + }); + }, 60000); + }); + }); } }); \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index 4780688471c..7459c11d4d9 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_auto_repeat": 1, "allow_import": 1, "autoname": "naming_series:", "creation": "2020-01-24 15:29:29.933693", @@ -12,11 +13,11 @@ "customer", "customer_name", "tax_id", - "is_pos", "pos_profile", - "offline_pos_name", - "is_return", "consolidated_invoice", + "is_pos", + "is_return", + "update_billed_amount_in_sales_order", "column_break1", "company", "posting_date", @@ -24,10 +25,7 @@ "set_posting_time", "due_date", "amended_from", - "returns", "return_against", - "column_break_21", - "update_billed_amount_in_sales_order", "accounting_dimensions_section", "project", "dimension_col_break", @@ -182,8 +180,7 @@ "column_break_140", "auto_repeat", "update_auto_repeat_reference", - "against_income_account", - "pos_total_qty" + "against_income_account" ], "fields": [ { @@ -264,14 +261,6 @@ "options": "POS Profile", "print_hide": 1 }, - { - "fieldname": "offline_pos_name", - "fieldtype": "Data", - "hidden": 1, - "label": "Offline POS Name", - "print_hide": 1, - "read_only": 1 - }, { "allow_on_submit": 1, "default": "0", @@ -279,8 +268,7 @@ "fieldtype": "Check", "label": "Is Return (Credit Note)", "no_copy": 1, - "print_hide": 1, - "set_only_once": 1 + "print_hide": 1 }, { "fieldname": "column_break1", @@ -348,26 +336,16 @@ "print_hide": 1, "read_only": 1 }, - { - "depends_on": "return_against", - "fieldname": "returns", - "fieldtype": "Section Break", - "label": "Returns" - }, { "depends_on": "return_against", "fieldname": "return_against", "fieldtype": "Link", - "label": "Return Against POS Invoice", + "label": "Return Against", "no_copy": 1, "options": "POS Invoice", "print_hide": 1, "read_only": 1 }, - { - "fieldname": "column_break_21", - "fieldtype": "Column Break" - }, { "default": "0", "depends_on": "eval: doc.is_return && doc.return_against", @@ -461,7 +439,7 @@ }, { "fieldname": "contact_mobile", - "fieldtype": "Small Text", + "fieldtype": "Data", "hidden": 1, "label": "Mobile No", "read_only": 1 @@ -587,19 +565,21 @@ }, { "fieldname": "sec_warehouse", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Warehouse" }, { "depends_on": "update_stock", "fieldname": "set_warehouse", "fieldtype": "Link", - "label": "Set Source Warehouse", + "label": "Source Warehouse", "options": "Warehouse", "print_hide": 1 }, { "fieldname": "items_section", "fieldtype": "Section Break", + "label": "Items", "oldfieldtype": "Section Break", "options": "fa fa-shopping-cart" }, @@ -1501,7 +1481,7 @@ "allow_on_submit": 1, "fieldname": "sales_team", "fieldtype": "Table", - "label": "Sales Team1", + "label": "Sales Team", "oldfieldname": "sales_team", "oldfieldtype": "Table", "options": "Sales Team", @@ -1560,15 +1540,6 @@ "print_hide": 1, "report_hide": 1 }, - { - "fieldname": "pos_total_qty", - "fieldtype": "Float", - "hidden": 1, - "label": "Total Qty", - "print_hide": 1, - "print_hide_if_no_value": 1, - "read_only": 1 - }, { "allow_on_submit": 1, "fieldname": "consolidated_invoice", @@ -1579,10 +1550,9 @@ } ], "icon": "fa fa-file-text", - "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-09-07 12:43:09.138720", + "modified": "2021-02-01 15:03:33.800707", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", @@ -1627,7 +1597,6 @@ "role": "All" } ], - "quick_entry": 1, "search_fields": "posting_date, due_date, customer, base_grand_total, outstanding_amount", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index ba68df7673f..402d1570097 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -6,15 +6,13 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.model.document import Document -from erpnext.controllers.selling_controller import SellingController -from frappe.utils import cint, flt, add_months, today, date_diff, getdate, add_days, cstr, nowdate from erpnext.accounts.utils import get_account_currency from erpnext.accounts.party import get_party_account, get_due_date -from erpnext.accounts.doctype.loyalty_program.loyalty_program import \ - get_loyalty_program_details_with_points, validate_loyalty_points - -from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice, get_bank_cash_account, update_multi_mode_option -from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos +from frappe.utils import cint, flt, getdate, nowdate, get_link_to_form +from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request +from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points +from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos, get_serial_nos +from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice, get_bank_cash_account, update_multi_mode_option, get_mode_of_payment_info from six import iteritems @@ -29,8 +27,7 @@ class POSInvoice(SalesInvoice): # run on validate method of selling controller super(SalesInvoice, self).validate() self.validate_auto_set_posting_time() - self.validate_pos_paid_amount() - self.validate_pos_return() + self.validate_mode_of_payment() self.validate_uom_is_integer("stock_uom", "stock_qty") self.validate_uom_is_integer("uom", "qty") self.validate_debit_to_acc() @@ -40,11 +37,12 @@ class POSInvoice(SalesInvoice): self.validate_item_cost_centers() self.validate_serialised_or_batched_item() self.validate_stock_availablility() - self.validate_return_items() + self.validate_return_items_qty() + self.validate_non_stock_items() self.set_status() self.set_account_for_mode_of_payment() self.validate_pos() - self.verify_payment_amount() + self.validate_payment_amount() self.validate_loyalty_transaction() def on_submit(self): @@ -57,8 +55,25 @@ class POSInvoice(SalesInvoice): against_psi_doc.make_loyalty_point_entry() if self.redeem_loyalty_points and self.loyalty_points: self.apply_loyalty_points() + self.check_phone_payments() self.set_status(update=True) + def before_cancel(self): + if self.consolidated_invoice and frappe.db.get_value('Sales Invoice', self.consolidated_invoice, 'docstatus') == 1: + pos_closing_entry = frappe.get_all( + "POS Invoice Reference", + ignore_permissions=True, + filters={ 'pos_invoice': self.name }, + pluck="parent", + limit=1 + ) + frappe.throw( + _('You need to cancel POS Closing Entry {} to be able to cancel this document.').format( + get_link_to_form("POS Closing Entry", pos_closing_entry[0]) + ), + title=_('Not Allowed') + ) + def on_cancel(self): # run on cancel method of selling controller super(SalesInvoice, self).on_cancel() @@ -69,77 +84,136 @@ class POSInvoice(SalesInvoice): against_psi_doc.delete_loyalty_point_entry() against_psi_doc.make_loyalty_point_entry() - def validate_stock_availablility(self): - allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock') + def check_phone_payments(self): + for pay in self.payments: + if pay.type == "Phone" and pay.amount >= 0: + paid_amt = frappe.db.get_value("Payment Request", + filters=dict( + reference_doctype="POS Invoice", reference_name=self.name, + mode_of_payment=pay.mode_of_payment, status="Paid"), + fieldname="grand_total") + if paid_amt and pay.amount != paid_amt: + return frappe.throw(_("Payment related to {0} is not completed").format(pay.mode_of_payment)) + + def validate_stock_availablility(self): + if self.is_return: + return + + allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock') + error_msg = [] for d in self.get('items'): + msg = "" if d.serial_no: - filters = { - "item_code": d.item_code, - "warehouse": d.warehouse, - "delivery_document_no": "", - "sales_invoice": "" - } + filters = { "item_code": d.item_code, "warehouse": d.warehouse } if d.batch_no: filters["batch_no"] = d.batch_no - reserved_serial_nos, unreserved_serial_nos = get_pos_reserved_serial_nos(filters) - serial_nos = d.serial_no.split("\n") - serial_nos = ' '.join(serial_nos).split() # remove whitespaces - invalid_serial_nos = [] - for s in serial_nos: - if s in reserved_serial_nos: - invalid_serial_nos.append(s) - if len(invalid_serial_nos): - multiple_nos = 's' if len(invalid_serial_nos) > 1 else '' - frappe.throw(_("Row #{}: Serial No{}. {} has already been transacted into another POS Invoice. \ - Please select valid serial no.".format(d.idx, multiple_nos, - frappe.bold(', '.join(invalid_serial_nos)))), title=_("Not Available")) + reserved_serial_nos = get_pos_reserved_serial_nos(filters) + serial_nos = get_serial_nos(d.serial_no) + invalid_serial_nos = [s for s in serial_nos if s in reserved_serial_nos] + + bold_invalid_serial_nos = frappe.bold(', '.join(invalid_serial_nos)) + if len(invalid_serial_nos) == 1: + msg = (_("Row #{}: Serial No. {} has already been transacted into another POS Invoice. Please select valid serial no.") + .format(d.idx, bold_invalid_serial_nos)) + elif invalid_serial_nos: + msg = (_("Row #{}: Serial Nos. {} has already been transacted into another POS Invoice. Please select valid serial no.") + .format(d.idx, bold_invalid_serial_nos)) + else: if allow_negative_stock: return available_stock = get_stock_availability(d.item_code, d.warehouse) - if not (flt(available_stock) > 0): - frappe.throw(_('Row #{}: Item Code: {} is not available under warehouse {}.' - .format(d.idx, frappe.bold(d.item_code), frappe.bold(d.warehouse))), title=_("Not Available")) + item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty) + if flt(available_stock) <= 0: + msg = (_('Row #{}: Item Code: {} is not available under warehouse {}.').format(d.idx, item_code, warehouse)) elif flt(available_stock) < flt(d.qty): - frappe.msgprint(_('Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}. \ - Available quantity {}.'.format(d.idx, frappe.bold(d.item_code), - frappe.bold(d.warehouse), frappe.bold(d.qty))), title=_("Not Available")) + msg = (_('Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}. Available quantity {}.') + .format(d.idx, item_code, warehouse, qty)) + if msg: + error_msg.append(msg) + + if error_msg: + frappe.throw(error_msg, title=_("Item Unavailable"), as_list=True) def validate_serialised_or_batched_item(self): + error_msg = [] for d in self.get("items"): serialized = d.get("has_serial_no") batched = d.get("has_batch_no") no_serial_selected = not d.get("serial_no") no_batch_selected = not d.get("batch_no") - + msg = "" + item_code = frappe.bold(d.item_code) + serial_nos = get_serial_nos(d.serial_no) if serialized and batched and (no_batch_selected or no_serial_selected): - frappe.throw(_('Row #{}: Please select a serial no and batch against item: {} or remove it to complete transaction.' - .format(d.idx, frappe.bold(d.item_code))), title=_("Invalid Item")) - if serialized and no_serial_selected: - frappe.throw(_('Row #{}: No serial number selected against item: {}. Please select one or remove it to complete transaction.' - .format(d.idx, frappe.bold(d.item_code))), title=_("Invalid Item")) - if batched and no_batch_selected: - frappe.throw(_('Row #{}: No batch selected against item: {}. Please select a batch or remove it to complete transaction.' - .format(d.idx, frappe.bold(d.item_code))), title=_("Invalid Item")) + msg = (_('Row #{}: Please select a serial no and batch against item: {} or remove it to complete transaction.') + .format(d.idx, item_code)) + elif serialized and no_serial_selected: + msg = (_('Row #{}: No serial number selected against item: {}. Please select one or remove it to complete transaction.') + .format(d.idx, item_code)) + elif batched and no_batch_selected: + msg = (_('Row #{}: No batch selected against item: {}. Please select a batch or remove it to complete transaction.') + .format(d.idx, item_code)) + elif serialized and not no_serial_selected and len(serial_nos) != d.qty: + msg = (_("Row #{}: You must select {} serial numbers for item {}.").format(d.idx, frappe.bold(cint(d.qty)), item_code)) - def validate_return_items(self): + if msg: + error_msg.append(msg) + + if error_msg: + frappe.throw(error_msg, title=_("Invalid Item"), as_list=True) + + def validate_return_items_qty(self): if not self.get("is_return"): return for d in self.get("items"): if d.get("qty") > 0: - frappe.throw(_("Row #{}: You cannot add postive quantities in a return invoice. Please remove item {} to complete the return.") - .format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item")) + frappe.throw( + _("Row #{}: You cannot add postive quantities in a return invoice. Please remove item {} to complete the return.") + .format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item") + ) + if d.get("serial_no"): + serial_nos = get_serial_nos(d.serial_no) + for sr in serial_nos: + serial_no_exists = frappe.db.sql(""" + SELECT name + FROM `tabPOS Invoice Item` + WHERE + parent = %s + and (serial_no = %s + or serial_no like %s + or serial_no like %s + or serial_no like %s + ) + """, (self.return_against, sr, sr+'\n%', '%\n'+sr, '%\n'+sr+'\n%')) - def validate_pos_paid_amount(self): - if len(self.payments) == 0 and self.is_pos: + if not serial_no_exists: + bold_return_against = frappe.bold(self.return_against) + bold_serial_no = frappe.bold(sr) + frappe.throw( + _("Row #{}: Serial No {} cannot be returned since it was not transacted in original invoice {}") + .format(d.idx, bold_serial_no, bold_return_against) + ) + + def validate_non_stock_items(self): + for d in self.get("items"): + is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item") + if not is_stock_item: + frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice. ").format( + d.idx, frappe.bold(d.item_code) + ), title=_("Invalid Item")) + + def validate_mode_of_payment(self): + if len(self.payments) == 0: frappe.throw(_("At least one mode of payment is required for POS invoice.")) def validate_change_account(self): - if frappe.db.get_value("Account", self.account_for_change_amount, "company") != self.company: + if self.change_amount and self.account_for_change_amount and \ + frappe.db.get_value("Account", self.account_for_change_amount, "company") != self.company: frappe.throw(_("The selected change account {} doesn't belongs to Company {}.").format(self.account_for_change_amount, self.company)) def validate_change_amount(self): @@ -147,26 +221,24 @@ class POSInvoice(SalesInvoice): base_grand_total = flt(self.base_rounded_total) or flt(self.base_grand_total) if not flt(self.change_amount) and grand_total < flt(self.paid_amount): self.change_amount = flt(self.paid_amount - grand_total + flt(self.write_off_amount)) - self.base_change_amount = flt(self.base_paid_amount - base_grand_total + flt(self.base_write_off_amount)) + self.base_change_amount = flt(self.base_paid_amount) - base_grand_total + flt(self.base_write_off_amount) if flt(self.change_amount) and not self.account_for_change_amount: - msgprint(_("Please enter Account for Change Amount"), raise_exception=1) + frappe.msgprint(_("Please enter Account for Change Amount"), raise_exception=1) - def verify_payment_amount(self): + def validate_payment_amount(self): + total_amount_in_payments = 0 for entry in self.payments: + total_amount_in_payments += entry.amount if not self.is_return and entry.amount < 0: frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx)) if self.is_return and entry.amount > 0: frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx)) - def validate_pos_return(self): - if self.is_pos and self.is_return: - total_amount_in_payments = 0 - for payment in self.payments: - total_amount_in_payments += payment.amount + if self.is_return: invoice_total = self.rounded_total or self.grand_total - if total_amount_in_payments < invoice_total: - frappe.throw(_("Total payments amount can't be greater than {}".format(-invoice_total))) + if total_amount_in_payments and total_amount_in_payments < invoice_total: + frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total)) def validate_loyalty_transaction(self): if self.redeem_loyalty_points and (not self.loyalty_redemption_account or not self.loyalty_redemption_cost_center): @@ -218,57 +290,54 @@ class POSInvoice(SalesInvoice): from erpnext.stock.get_item_details import get_pos_profile_item_details, get_pos_profile if not self.pos_profile: pos_profile = get_pos_profile(self.company) or {} + if not pos_profile: + frappe.throw(_("No POS Profile found. Please create a New POS Profile first")) self.pos_profile = pos_profile.get('name') - pos = {} + profile = {} if self.pos_profile: - pos = frappe.get_doc('POS Profile', self.pos_profile) + profile = frappe.get_doc('POS Profile', self.pos_profile) if not self.get('payments') and not for_validate: - update_multi_mode_option(self, pos) + update_multi_mode_option(self, profile) - if not self.account_for_change_amount: - self.account_for_change_amount = frappe.get_cached_value('Company', self.company, 'default_cash_account') - - if pos: - if not for_validate: - self.tax_category = pos.get("tax_category") + if self.is_return and not for_validate: + add_return_modes(self, profile) + if profile: if not for_validate and not self.customer: - self.customer = pos.customer + self.customer = profile.customer - self.ignore_pricing_rule = pos.ignore_pricing_rule - if pos.get('account_for_change_amount'): - self.account_for_change_amount = pos.get('account_for_change_amount') - if pos.get('warehouse'): - self.set_warehouse = pos.get('warehouse') + self.ignore_pricing_rule = profile.ignore_pricing_rule + self.account_for_change_amount = profile.get('account_for_change_amount') or self.account_for_change_amount + self.set_warehouse = profile.get('warehouse') or self.set_warehouse - for fieldname in ('naming_series', 'currency', 'letter_head', 'tc_name', + for fieldname in ('currency', 'letter_head', 'tc_name', 'company', 'select_print_heading', 'write_off_account', 'taxes_and_charges', - 'write_off_cost_center', 'apply_discount_on', 'cost_center'): - if (not for_validate) or (for_validate and not self.get(fieldname)): - self.set(fieldname, pos.get(fieldname)) - - if pos.get("company_address"): - self.company_address = pos.get("company_address") + 'write_off_cost_center', 'apply_discount_on', 'cost_center', 'tax_category', + 'ignore_pricing_rule', 'company_address', 'update_stock'): + if not for_validate: + self.set(fieldname, profile.get(fieldname)) if self.customer: - customer_price_list, customer_group = frappe.db.get_value("Customer", self.customer, ['default_price_list', 'customer_group']) + customer_price_list, customer_group, customer_currency = frappe.db.get_value( + "Customer", self.customer, ['default_price_list', 'customer_group', 'default_currency'] + ) customer_group_price_list = frappe.db.get_value("Customer Group", customer_group, 'default_price_list') - selling_price_list = customer_price_list or customer_group_price_list or pos.get('selling_price_list') + selling_price_list = customer_price_list or customer_group_price_list or profile.get('selling_price_list') + if customer_currency != profile.get('currency'): + self.set('currency', customer_currency) + else: - selling_price_list = pos.get('selling_price_list') + selling_price_list = profile.get('selling_price_list') if selling_price_list: self.set('selling_price_list', selling_price_list) - if not for_validate: - self.update_stock = cint(pos.get("update_stock")) - # set pos values in items for item in self.get("items"): if item.get('item_code'): - profile_details = get_pos_profile_item_details(pos, frappe._dict(item.as_dict()), pos) + profile_details = get_pos_profile_item_details(profile.get("company"), frappe._dict(item.as_dict()), profile) for fname, val in iteritems(profile_details): if (not for_validate) or (for_validate and not item.get(fname)): item.set(fname, val) @@ -281,10 +350,13 @@ class POSInvoice(SalesInvoice): if self.taxes_and_charges and not len(self.get("taxes")): self.set_taxes() - return pos + if not self.account_for_change_amount: + self.account_for_change_amount = frappe.get_cached_value('Company', self.company, 'default_cash_account') + + return profile def set_missing_values(self, for_validate=False): - pos = self.set_pos_fields(for_validate) + profile = self.set_pos_fields(for_validate) if not self.debit_to: self.debit_to = get_party_account("Customer", self.customer, self.company) @@ -294,25 +366,81 @@ class POSInvoice(SalesInvoice): super(SalesInvoice, self).set_missing_values(for_validate) - print_format = pos.get("print_format") if pos else None + print_format = profile.get("print_format") if profile else None if not print_format and not cint(frappe.db.get_value('Print Format', 'POS Invoice', 'disabled')): print_format = 'POS Invoice' - if pos: + if profile: return { "print_format": print_format, - "allow_edit_rate": pos.get("allow_user_to_edit_rate"), - "allow_edit_discount": pos.get("allow_user_to_edit_discount"), - "campaign": pos.get("campaign"), - "allow_print_before_pay": pos.get("allow_print_before_pay") + "campaign": profile.get("campaign"), + "allow_print_before_pay": profile.get("allow_print_before_pay") } + def reset_mode_of_payments(self): + if self.pos_profile: + pos_profile = frappe.get_cached_doc('POS Profile', self.pos_profile) + update_multi_mode_option(self, pos_profile) + self.paid_amount = 0 + def set_account_for_mode_of_payment(self): self.payments = [d for d in self.payments if d.amount or d.base_amount or d.default] for pay in self.payments: if not pay.account: pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account") + def create_payment_request(self): + for pay in self.payments: + if pay.type == "Phone": + if pay.amount <= 0: + frappe.throw(_("Payment amount cannot be less than or equal to 0")) + + if not self.contact_mobile: + frappe.throw(_("Please enter the phone number first")) + + pay_req = self.get_existing_payment_request(pay) + if not pay_req: + pay_req = self.get_new_payment_request(pay) + pay_req.submit() + else: + pay_req.request_phone_payment() + + return pay_req + + def get_new_payment_request(self, mop): + payment_gateway_account = frappe.db.get_value("Payment Gateway Account", { + "payment_account": mop.account, + }, ["name"]) + + args = { + "dt": "POS Invoice", + "dn": self.name, + "recipient_id": self.contact_mobile, + "mode_of_payment": mop.mode_of_payment, + "payment_gateway_account": payment_gateway_account, + "payment_request_type": "Inward", + "party_type": "Customer", + "party": self.customer, + "return_doc": True + } + return make_payment_request(**args) + + def get_existing_payment_request(self, pay): + payment_gateway_account = frappe.db.get_value("Payment Gateway Account", { + "payment_account": pay.account, + }, ["name"]) + + args = { + 'doctype': 'Payment Request', + 'reference_doctype': 'POS Invoice', + 'reference_name': self.name, + 'payment_gateway_account': payment_gateway_account, + 'email_to': self.contact_mobile + } + pr = frappe.db.exists(args) + if pr: + return frappe.get_doc('Payment Request', pr[0][0]) + @frappe.whitelist() def get_stock_availability(item_code, warehouse): latest_sle = frappe.db.sql("""select qty_after_transaction @@ -334,11 +462,9 @@ def get_stock_availability(item_code, warehouse): sle_qty = latest_sle[0].qty_after_transaction or 0 if latest_sle else 0 pos_sales_qty = pos_sales_qty[0].qty or 0 if pos_sales_qty else 0 - if sle_qty and pos_sales_qty and sle_qty > pos_sales_qty: + if sle_qty and pos_sales_qty: return sle_qty - pos_sales_qty else: - # when sle_qty is 0 - # when sle_qty > 0 and pos_sales_qty is 0 return sle_qty @frappe.whitelist() @@ -371,4 +497,19 @@ def make_merge_log(invoices): }) if merge_log.get('pos_invoices'): - return merge_log.as_dict() \ No newline at end of file + return merge_log.as_dict() + +def add_return_modes(doc, pos_profile): + def append_payment(payment_mode): + payment = doc.append('payments', {}) + payment.default = payment_mode.default + payment.mode_of_payment = payment_mode.parent + payment.account = payment_mode.default_account + payment.type = payment_mode.type + + for pos_payment_method in pos_profile.get('payments'): + pos_payment_method = pos_payment_method.as_dict() + mode_of_payment = pos_payment_method.mode_of_payment + if pos_payment_method.allow_in_returns and not [d for d in doc.get('payments') if d.mode_of_payment == mode_of_payment]: + payment_mode = get_mode_of_payment_info(mode_of_payment, doc.company) + append_payment(payment_mode[0]) \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 514a2acd8c7..054afe5bbba 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -7,8 +7,18 @@ import frappe import unittest, copy, time from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.doctype.item.test_item import make_item class TestPOSInvoice(unittest.TestCase): + def tearDown(self): + if frappe.session.user != "Administrator": + frappe.set_user("Administrator") + + if frappe.db.get_single_value("Selling Settings", "validate_selling_price"): + frappe.db.set_value("Selling Settings", None, "validate_selling_price", 0) + def test_timestamp_change(self): w = create_pos_invoice(do_not_save=1) w.docstatus = 0 @@ -97,10 +107,10 @@ class TestPOSInvoice(unittest.TestCase): item_row = inv.get("items")[0] add_items = [ - (54, '_Test Account Excise Duty @ 12'), - (288, '_Test Account Excise Duty @ 15'), - (144, '_Test Account Excise Duty @ 20'), - (430, '_Test Item Tax Template 1') + (54, '_Test Account Excise Duty @ 12 - _TC'), + (288, '_Test Account Excise Duty @ 15 - _TC'), + (144, '_Test Account Excise Duty @ 20 - _TC'), + (430, '_Test Item Tax Template 1 - _TC') ] for qty, item_tax_template in add_items: item_row_copy = copy.deepcopy(item_row) @@ -196,6 +206,65 @@ class TestPOSInvoice(unittest.TestCase): self.assertEqual(pos_return.get('payments')[0].amount, -500) self.assertEqual(pos_return.get('payments')[1].amount, -500) + def test_pos_return_for_serialized_item(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + se = make_serialized_item(company='_Test Company', + target_warehouse="Stores - _TC", cost_center='Main - _TC', expense_account='Cost of Goods Sold - _TC') + + serial_nos = get_serial_nos(se.get("items")[0].serial_no) + + pos = create_pos_invoice(company='_Test Company', debit_to='Debtors - _TC', + account_for_change_amount='Cash - _TC', warehouse='Stores - _TC', income_account='Sales - _TC', + expense_account='Cost of Goods Sold - _TC', cost_center='Main - _TC', + item=se.get("items")[0].item_code, rate=1000, do_not_save=1) + + pos.get("items")[0].serial_no = serial_nos[0] + pos.append("payments", {'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000, 'default': 1}) + + pos.insert() + pos.submit() + + pos_return = make_sales_return(pos.name) + + pos_return.insert() + pos_return.submit() + self.assertEqual(pos_return.get('items')[0].serial_no, serial_nos[0]) + + def test_partial_pos_returns(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + se = make_serialized_item(company='_Test Company', + target_warehouse="Stores - _TC", cost_center='Main - _TC', expense_account='Cost of Goods Sold - _TC') + + serial_nos = get_serial_nos(se.get("items")[0].serial_no) + + pos = create_pos_invoice(company='_Test Company', debit_to='Debtors - _TC', + account_for_change_amount='Cash - _TC', warehouse='Stores - _TC', income_account='Sales - _TC', + expense_account='Cost of Goods Sold - _TC', cost_center='Main - _TC', + item=se.get("items")[0].item_code, qty=2, rate=1000, do_not_save=1) + + pos.get("items")[0].serial_no = serial_nos[0] + "\n" + serial_nos[1] + pos.append("payments", {'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000, 'default': 1}) + + pos.insert() + pos.submit() + + pos_return1 = make_sales_return(pos.name) + + # partial return 1 + pos_return1.get('items')[0].qty = -1 + pos_return1.get('items')[0].serial_no = serial_nos[0] + pos_return1.insert() + pos_return1.submit() + + # partial return 2 + pos_return2 = make_sales_return(pos.name) + self.assertEqual(pos_return2.get('items')[0].qty, -1) + self.assertEqual(pos_return2.get('items')[0].serial_no, serial_nos[1]) + def test_pos_change_amount(self): pos = create_pos_invoice(company= "_Test Company", debit_to="Debtors - _TC", income_account = "Sales - _TC", expense_account = "Cost of Goods Sold - _TC", rate=105, @@ -221,29 +290,29 @@ class TestPOSInvoice(unittest.TestCase): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos - se = make_serialized_item(company='_Test Company with perpetual inventory', - target_warehouse="Stores - TCP1", cost_center='Main - TCP1', expense_account='Cost of Goods Sold - TCP1') + se = make_serialized_item(company='_Test Company', + target_warehouse="Stores - _TC", cost_center='Main - _TC', expense_account='Cost of Goods Sold - _TC') serial_nos = get_serial_nos(se.get("items")[0].serial_no) - pos = create_pos_invoice(company='_Test Company with perpetual inventory', debit_to='Debtors - TCP1', - account_for_change_amount='Cash - TCP1', warehouse='Stores - TCP1', income_account='Sales - TCP1', - expense_account='Cost of Goods Sold - TCP1', cost_center='Main - TCP1', + pos = create_pos_invoice(company='_Test Company', debit_to='Debtors - _TC', + account_for_change_amount='Cash - _TC', warehouse='Stores - _TC', income_account='Sales - _TC', + expense_account='Cost of Goods Sold - _TC', cost_center='Main - _TC', item=se.get("items")[0].item_code, rate=1000, do_not_save=1) pos.get("items")[0].serial_no = serial_nos[0] - pos.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - TCP1', 'amount': 1000}) + pos.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - _TC', 'amount': 1000}) pos.insert() pos.submit() - pos2 = create_pos_invoice(company='_Test Company with perpetual inventory', debit_to='Debtors - TCP1', - account_for_change_amount='Cash - TCP1', warehouse='Stores - TCP1', income_account='Sales - TCP1', - expense_account='Cost of Goods Sold - TCP1', cost_center='Main - TCP1', + pos2 = create_pos_invoice(company='_Test Company', debit_to='Debtors - _TC', + account_for_change_amount='Cash - _TC', warehouse='Stores - _TC', income_account='Sales - _TC', + expense_account='Cost of Goods Sold - _TC', cost_center='Main - _TC', item=se.get("items")[0].item_code, rate=1000, do_not_save=1) pos2.get("items")[0].serial_no = serial_nos[0] - pos2.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - TCP1', 'amount': 1000}) + pos2.append("payments", {'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - _TC', 'amount': 1000}) self.assertRaises(frappe.ValidationError, pos2.insert) @@ -286,6 +355,117 @@ class TestPOSInvoice(unittest.TestCase): after_redeem_lp_details = get_loyalty_program_details_with_points(inv.customer, company=inv.company, loyalty_program=inv.loyalty_program) self.assertEqual(after_redeem_lp_details.loyalty_points, 9) + def test_merging_into_sales_invoice_with_discount(self): + from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile + from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices + + frappe.db.sql("delete from `tabPOS Invoice`") + test_user, pos_profile = init_user_and_profile() + pos_inv = create_pos_invoice(rate=300, additional_discount_percentage=10, do_not_submit=1) + pos_inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 270 + }) + pos_inv.submit() + + pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1) + pos_inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3200 + }) + pos_inv2.submit() + + consolidate_pos_invoices() + + pos_inv.load_from_db() + rounded_total = frappe.db.get_value("Sales Invoice", pos_inv.consolidated_invoice, "rounded_total") + self.assertEqual(rounded_total, 3470) + + def test_merging_into_sales_invoice_with_discount_and_inclusive_tax(self): + from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile + from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices + + frappe.db.sql("delete from `tabPOS Invoice`") + test_user, pos_profile = init_user_and_profile() + pos_inv = create_pos_invoice(rate=300, do_not_submit=1) + pos_inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 300 + }) + pos_inv.append('taxes', { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 14, + 'included_in_print_rate': 1 + }) + pos_inv.submit() + + pos_inv2 = create_pos_invoice(rate=300, qty=2, do_not_submit=1) + pos_inv2.additional_discount_percentage = 10 + pos_inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 540 + }) + pos_inv2.append('taxes', { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 14, + 'included_in_print_rate': 1 + }) + pos_inv2.submit() + + consolidate_pos_invoices() + + pos_inv.load_from_db() + rounded_total = frappe.db.get_value("Sales Invoice", pos_inv.consolidated_invoice, "rounded_total") + self.assertEqual(rounded_total, 840) + + def test_merging_with_validate_selling_price(self): + from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile + from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices + + if not frappe.db.get_single_value("Selling Settings", "validate_selling_price"): + frappe.db.set_value("Selling Settings", "Selling Settings", "validate_selling_price", 1) + + item = "Test Selling Price Validation" + make_item(item, {"is_stock_item": 1}) + make_purchase_receipt(item_code=item, warehouse="_Test Warehouse - _TC", qty=1, rate=300) + frappe.db.sql("delete from `tabPOS Invoice`") + test_user, pos_profile = init_user_and_profile() + pos_inv = create_pos_invoice(item=item, rate=300, do_not_submit=1) + pos_inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 300 + }) + pos_inv.append('taxes', { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 14, + 'included_in_print_rate': 1 + }) + self.assertRaises(frappe.ValidationError, pos_inv.submit) + + pos_inv2 = create_pos_invoice(item=item, rate=400, do_not_submit=1) + pos_inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 400 + }) + pos_inv2.append('taxes', { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Service Tax", + "rate": 14, + 'included_in_print_rate': 1 + }) + pos_inv2.submit() + + consolidate_pos_invoices() + + pos_inv2.load_from_db() + rounded_total = frappe.db.get_value("Sales Invoice", pos_inv2.consolidated_invoice, "rounded_total") + self.assertEqual(rounded_total, 400) + def create_pos_invoice(**args): args = frappe._dict(args) pos_profile = None @@ -294,12 +474,11 @@ def create_pos_invoice(**args): pos_profile.save() pos_inv = frappe.new_doc("POS Invoice") + pos_inv.update(args) pos_inv.update_stock = 1 pos_inv.is_pos = 1 pos_inv.pos_profile = args.pos_profile or pos_profile.name - pos_inv.set_missing_values() - if args.posting_date: pos_inv.set_posting_time = 1 pos_inv.posting_date = args.posting_date or frappe.utils.nowdate() @@ -313,6 +492,8 @@ def create_pos_invoice(**args): pos_inv.conversion_rate = args.conversion_rate or 1 pos_inv.account_for_change_amount = args.account_for_change_amount or "Cash - _TC" + pos_inv.set_missing_values() + pos_inv.append("items", { "item_code": args.item or args.item_code or "_Test Item", "warehouse": args.warehouse or "_Test Warehouse - _TC", @@ -333,4 +514,4 @@ def create_pos_invoice(**args): else: pos_inv.payment_schedule = [] - return pos_inv \ No newline at end of file + return pos_inv diff --git a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json index 2b6e7de118a..8b71eb02fd7 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json @@ -87,6 +87,7 @@ "edit_references", "sales_order", "so_detail", + "pos_invoice_item", "column_break_74", "delivery_note", "dn_detail", @@ -790,11 +791,20 @@ "fieldtype": "Link", "label": "Project", "options": "Project" + }, + { + "fieldname": "pos_invoice_item", + "fieldtype": "Data", + "ignore_user_permissions": 1, + "label": "POS Invoice Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2020-07-22 13:40:34.418346", + "modified": "2021-01-04 17:34:49.924531", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Item", diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json index 8f97639bbc9..da2984f05af 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.json @@ -7,6 +7,8 @@ "field_order": [ "posting_date", "customer", + "column_break_3", + "pos_closing_entry", "section_break_3", "pos_invoices", "references_section", @@ -76,11 +78,22 @@ "label": "Consolidated Credit Note", "options": "Sales Invoice", "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "pos_closing_entry", + "fieldtype": "Link", + "label": "POS Closing Entry", + "options": "POS Closing Entry" } ], + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-05-29 15:08:41.317100", + "modified": "2020-12-01 11:53:57.267579", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Merge Log", 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 11b9d2509e3..40f77b4088d 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 @@ -5,10 +5,13 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import cint, flt, add_months, today, date_diff, getdate, add_days, cstr, nowdate -from frappe.model.document import Document -from frappe.model.mapper import map_doc from frappe.model import default_fields +from frappe.model.document import Document +from frappe.utils import flt, getdate, nowdate +from frappe.utils.background_jobs import enqueue +from frappe.model.mapper import map_doc, map_child_doc +from frappe.utils.scheduler import is_scheduler_inactive +from frappe.core.page.background_jobs.background_jobs import get_info from six import iteritems @@ -27,17 +30,24 @@ class POSInvoiceMergeLog(Document): status, docstatus, is_return, return_against = frappe.db.get_value( 'POS Invoice', d.pos_invoice, ['status', 'docstatus', 'is_return', 'return_against']) + bold_pos_invoice = frappe.bold(d.pos_invoice) + bold_status = frappe.bold(status) if docstatus != 1: - frappe.throw(_("Row #{}: POS Invoice {} is not submitted yet").format(d.idx, d.pos_invoice)) + frappe.throw(_("Row #{}: POS Invoice {} is not submitted yet").format(d.idx, bold_pos_invoice)) if status == "Consolidated": - frappe.throw(_("Row #{}: POS Invoice {} has been {}").format(d.idx, d.pos_invoice, status)) - if is_return and return_against not in [d.pos_invoice for d in self.pos_invoices] and status != "Consolidated": - # if return entry is not getting merged in the current pos closing and if it is not consolidated - frappe.throw( - _("Row #{}: Return Invoice {} cannot be made against unconsolidated invoice. \ - You can add original invoice {} manually to proceed.") - .format(d.idx, frappe.bold(d.pos_invoice), frappe.bold(return_against)) - ) + frappe.throw(_("Row #{}: POS Invoice {} has been {}").format(d.idx, bold_pos_invoice, bold_status)) + if is_return and return_against and return_against not in [d.pos_invoice for d in self.pos_invoices]: + bold_return_against = frappe.bold(return_against) + return_against_status = frappe.db.get_value('POS Invoice', return_against, "status") + if return_against_status != "Consolidated": + # if return entry is not getting merged in the current pos closing and if it is not consolidated + bold_unconsolidated = frappe.bold("not Consolidated") + msg = (_("Row #{}: Original Invoice {} of return invoice {} is {}. ") + .format(d.idx, bold_return_against, bold_pos_invoice, bold_unconsolidated)) + msg += _("Original invoice should be consolidated before or along with the return invoice.") + msg += "

    " + msg += _("You can add original invoice {} manually to proceed.").format(bold_return_against) + frappe.throw(msg) def on_submit(self): pos_invoice_docs = [frappe.get_doc("POS Invoice", d.pos_invoice) for d in self.pos_invoices] @@ -48,17 +58,23 @@ class POSInvoiceMergeLog(Document): sales_invoice, credit_note = "", "" if sales: sales_invoice = self.process_merging_into_sales_invoice(sales) - + if returns: credit_note = self.process_merging_into_credit_note(returns) self.save() # save consolidated_sales_invoice & consolidated_credit_note ref in merge log - self.update_pos_invoices(sales_invoice, credit_note) + self.update_pos_invoices(pos_invoice_docs, sales_invoice, credit_note) + + def on_cancel(self): + pos_invoice_docs = [frappe.get_doc("POS Invoice", d.pos_invoice) for d in self.pos_invoices] + + self.update_pos_invoices(pos_invoice_docs) + self.cancel_linked_invoices() def process_merging_into_sales_invoice(self, data): sales_invoice = self.get_new_sales_invoice() - + sales_invoice = self.merge_pos_invoice_into(sales_invoice, data) sales_invoice.is_consolidated = 1 @@ -76,37 +92,51 @@ class POSInvoiceMergeLog(Document): credit_note.is_consolidated = 1 # TODO: return could be against multiple sales invoice which could also have been consolidated? - credit_note.return_against = self.consolidated_invoice + # credit_note.return_against = self.consolidated_invoice credit_note.save() credit_note.submit() self.consolidated_credit_note = credit_note.name return credit_note.name - + def merge_pos_invoice_into(self, invoice, data): items, payments, taxes = [], [], [] loyalty_amount_sum, loyalty_points_sum = 0, 0 for doc in data: map_doc(doc, invoice, table_map={ "doctype": invoice.doctype }) - + if doc.redeem_loyalty_points: invoice.loyalty_redemption_account = doc.loyalty_redemption_account invoice.loyalty_redemption_cost_center = doc.loyalty_redemption_cost_center loyalty_points_sum += doc.loyalty_points loyalty_amount_sum += doc.loyalty_amount - + for item in doc.get('items'): - items.append(item) - + found = False + for i in items: + if (i.item_code == item.item_code and not i.serial_no and not i.batch_no and + i.uom == item.uom and i.net_rate == item.net_rate): + found = True + i.qty = i.qty + item.qty + + if not found: + item.rate = item.net_rate + item.price_list_rate = 0 + si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"}) + items.append(si_item) + for tax in doc.get('taxes'): found = False for t in taxes: - if t.account_head == tax.account_head and t.cost_center == tax.cost_center and t.rate == tax.rate: - t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount) - t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount) + if t.account_head == tax.account_head and t.cost_center == tax.cost_center: + t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount) + t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount) found = True if not found: tax.charge_type = 'Actual' + tax.included_in_print_rate = 0 + tax.tax_amount = tax.tax_amount_after_discount_amount + tax.base_tax_amount = tax.base_tax_amount_after_discount_amount taxes.append(tax) for payment in doc.get('payments'): @@ -127,9 +157,13 @@ class POSInvoiceMergeLog(Document): invoice.set('items', items) invoice.set('payments', payments) invoice.set('taxes', taxes) + invoice.additional_discount_percentage = 0 + invoice.discount_amount = 0.0 + invoice.taxes_and_charges = None + invoice.ignore_pricing_rule = 1 return invoice - + def get_new_sales_invoice(self): sales_invoice = frappe.new_doc('Sales Invoice') sales_invoice.customer = self.customer @@ -139,17 +173,21 @@ class POSInvoiceMergeLog(Document): return sales_invoice - def update_pos_invoices(self, sales_invoice, credit_note): - for d in self.pos_invoices: - doc = frappe.get_doc('POS Invoice', d.pos_invoice) - if not doc.is_return: - doc.update({'consolidated_invoice': sales_invoice}) - else: - doc.update({'consolidated_invoice': credit_note}) + def update_pos_invoices(self, invoice_docs, sales_invoice='', credit_note=''): + for doc in invoice_docs: + doc.load_from_db() + doc.update({ 'consolidated_invoice': None if self.docstatus==2 else (credit_note if doc.is_return else sales_invoice) }) doc.set_status(update=True) doc.save() -def get_all_invoices(): + def cancel_linked_invoices(self): + for si_name in [self.consolidated_invoice, self.consolidated_credit_note]: + if not si_name: continue + si = frappe.get_doc('Sales Invoice', si_name) + si.flags.ignore_validate = True + si.cancel() + +def get_all_unconsolidated_invoices(): filters = { 'consolidated_invoice': [ 'in', [ '', None ]], 'status': ['not in', ['Consolidated']], @@ -157,33 +195,95 @@ def get_all_invoices(): } pos_invoices = frappe.db.get_all('POS Invoice', filters=filters, fields=["name as pos_invoice", 'posting_date', 'grand_total', 'customer']) - + return pos_invoices -def get_invoices_customer_map(pos_invoices): +def get_invoice_customer_map(pos_invoices): # pos_invoice_customer_map = { 'Customer 1': [{}, {}, {}], 'Custoemr 2' : [{}] } pos_invoice_customer_map = {} for invoice in pos_invoices: customer = invoice.get('customer') pos_invoice_customer_map.setdefault(customer, []) pos_invoice_customer_map[customer].append(invoice) - + return pos_invoice_customer_map -def merge_pos_invoices(pos_invoices=[]): - if not pos_invoices: - pos_invoices = get_all_invoices() - - pos_invoice_map = get_invoices_customer_map(pos_invoices) - create_merge_logs(pos_invoice_map) +def consolidate_pos_invoices(pos_invoices=[], closing_entry={}): + invoices = pos_invoices or closing_entry.get('pos_transactions') or get_all_unconsolidated_invoices() + invoice_by_customer = get_invoice_customer_map(invoices) -def create_merge_logs(pos_invoice_customer_map): - for customer, invoices in iteritems(pos_invoice_customer_map): + if len(invoices) >= 5 and closing_entry: + closing_entry.set_status(update=True, status='Queued') + enqueue_job(create_merge_logs, invoice_by_customer, closing_entry) + else: + create_merge_logs(invoice_by_customer, closing_entry) + +def unconsolidate_pos_invoices(closing_entry): + merge_logs = frappe.get_all( + 'POS Invoice Merge Log', + filters={ 'pos_closing_entry': closing_entry.name }, + pluck='name' + ) + + if len(merge_logs) >= 5: + closing_entry.set_status(update=True, status='Queued') + enqueue_job(cancel_merge_logs, merge_logs, closing_entry) + else: + cancel_merge_logs(merge_logs, closing_entry) + +def create_merge_logs(invoice_by_customer, closing_entry={}): + for customer, invoices in iteritems(invoice_by_customer): merge_log = frappe.new_doc('POS Invoice Merge Log') merge_log.posting_date = getdate(nowdate()) merge_log.customer = customer + merge_log.pos_closing_entry = closing_entry.get('name', None) merge_log.set('pos_invoices', invoices) merge_log.save(ignore_permissions=True) merge_log.submit() + + if closing_entry: + closing_entry.set_status(update=True, status='Submitted') + closing_entry.update_opening_entry() +def cancel_merge_logs(merge_logs, closing_entry={}): + for log in merge_logs: + merge_log = frappe.get_doc('POS Invoice Merge Log', log) + merge_log.flags.ignore_permissions = True + merge_log.cancel() + + if closing_entry: + closing_entry.set_status(update=True, status='Cancelled') + closing_entry.update_opening_entry(for_cancel=True) + +def enqueue_job(job, invoice_by_customer, closing_entry): + check_scheduler_status() + + job_name = closing_entry.get("name") + if not job_already_enqueued(job_name): + enqueue( + job, + queue="long", + timeout=10000, + event="processing_merge_logs", + job_name=job_name, + closing_entry=closing_entry, + invoice_by_customer=invoice_by_customer, + now=frappe.conf.developer_mode or frappe.flags.in_test + ) + + if job == create_merge_logs: + msg = _('POS Invoices will be consolidated in a background process') + else: + msg = _('POS Invoices will be unconsolidated in a background process') + + frappe.msgprint(msg, alert=1) + +def check_scheduler_status(): + if is_scheduler_inactive() and not frappe.flags.in_test: + frappe.throw(_("Scheduler is inactive. Cannot enqueue job."), title=_("Scheduler Inactive")) + +def job_already_enqueued(job_name): + enqueued_jobs = [d.get("job_name") for d in get_info()] + if job_name in enqueued_jobs: + return True \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py index 0f34272eb49..d880caa3c73 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py @@ -7,92 +7,96 @@ import frappe import unittest from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return -from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import merge_pos_invoices +from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import consolidate_pos_invoices from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile class TestPOSInvoiceMergeLog(unittest.TestCase): def test_consolidated_invoice_creation(self): frappe.db.sql("delete from `tabPOS Invoice`") - test_user, pos_profile = init_user_and_profile() + try: + test_user, pos_profile = init_user_and_profile() - pos_inv = create_pos_invoice(rate=300, do_not_submit=1) - pos_inv.append('payments', { - 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 300 - }) - pos_inv.submit() + pos_inv = create_pos_invoice(rate=300, do_not_submit=1) + pos_inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 300 + }) + pos_inv.submit() - pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1) - pos_inv2.append('payments', { - 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3200 - }) - pos_inv2.submit() + pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1) + pos_inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3200 + }) + pos_inv2.submit() - pos_inv3 = create_pos_invoice(customer="_Test Customer 2", rate=2300, do_not_submit=1) - pos_inv3.append('payments', { - 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 2300 - }) - pos_inv3.submit() + pos_inv3 = create_pos_invoice(customer="_Test Customer 2", rate=2300, do_not_submit=1) + pos_inv3.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 2300 + }) + pos_inv3.submit() - merge_pos_invoices() + consolidate_pos_invoices() - pos_inv.load_from_db() - self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv.consolidated_invoice)) + pos_inv.load_from_db() + self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv.consolidated_invoice)) - pos_inv3.load_from_db() - self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice)) + pos_inv3.load_from_db() + self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice)) - self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice) + self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice) + + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") - frappe.set_user("Administrator") - frappe.db.sql("delete from `tabPOS Profile`") - frappe.db.sql("delete from `tabPOS Invoice`") - def test_consolidated_credit_note_creation(self): frappe.db.sql("delete from `tabPOS Invoice`") - test_user, pos_profile = init_user_and_profile() + try: + test_user, pos_profile = init_user_and_profile() - pos_inv = create_pos_invoice(rate=300, do_not_submit=1) - pos_inv.append('payments', { - 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 300 - }) - pos_inv.submit() + pos_inv = create_pos_invoice(rate=300, do_not_submit=1) + pos_inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 300 + }) + pos_inv.submit() - pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1) - pos_inv2.append('payments', { - 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3200 - }) - pos_inv2.submit() + pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1) + pos_inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 3200 + }) + pos_inv2.submit() - pos_inv3 = create_pos_invoice(customer="_Test Customer 2", rate=2300, do_not_submit=1) - pos_inv3.append('payments', { - 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 2300 - }) - pos_inv3.submit() + pos_inv3 = create_pos_invoice(customer="_Test Customer 2", rate=2300, do_not_submit=1) + pos_inv3.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 2300 + }) + pos_inv3.submit() - pos_inv_cn = make_sales_return(pos_inv.name) - pos_inv_cn.set("payments", []) - pos_inv_cn.append('payments', { - 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': -300 - }) - pos_inv_cn.paid_amount = -300 - pos_inv_cn.submit() + pos_inv_cn = make_sales_return(pos_inv.name) + pos_inv_cn.set("payments", []) + pos_inv_cn.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': -300 + }) + pos_inv_cn.paid_amount = -300 + pos_inv_cn.submit() - merge_pos_invoices() + consolidate_pos_invoices() - pos_inv.load_from_db() - self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv.consolidated_invoice)) + pos_inv.load_from_db() + self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv.consolidated_invoice)) - pos_inv3.load_from_db() - self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice)) + pos_inv3.load_from_db() + self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice)) - pos_inv_cn.load_from_db() - self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv_cn.consolidated_invoice)) - self.assertTrue(frappe.db.get_value("Sales Invoice", pos_inv_cn.consolidated_invoice, "is_return")) + pos_inv_cn.load_from_db() + self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv_cn.consolidated_invoice)) + self.assertTrue(frappe.db.get_value("Sales Invoice", pos_inv_cn.consolidated_invoice, "is_return")) - frappe.set_user("Administrator") - frappe.db.sql("delete from `tabPOS Profile`") - frappe.db.sql("delete from `tabPOS Invoice`") + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") diff --git a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py index b9e07b8030a..0023a84a46e 100644 --- a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py +++ b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py @@ -6,7 +6,6 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.utils import cint, get_link_to_form -from frappe.model.document import Document from erpnext.controllers.status_updater import StatusUpdater class POSOpeningEntry(StatusUpdater): @@ -17,18 +16,26 @@ class POSOpeningEntry(StatusUpdater): def validate_pos_profile_and_cashier(self): if self.company != frappe.db.get_value("POS Profile", self.pos_profile, "company"): - frappe.throw(_("POS Profile {} does not belongs to company {}".format(self.pos_profile, self.company))) + frappe.throw(_("POS Profile {} does not belongs to company {}").format(self.pos_profile, self.company)) if not cint(frappe.db.get_value("User", self.user, "enabled")): - frappe.throw(_("User {} has been disabled. Please select valid user/cashier".format(self.user))) - + frappe.throw(_("User {} is disabled. Please select valid user/cashier").format(self.user)) + def validate_payment_method_account(self): + invalid_modes = [] for d in self.balance_details: - account = frappe.db.get_value("Mode of Payment Account", - {"parent": d.mode_of_payment, "company": self.company}, "default_account") - if not account: - frappe.throw(_("Please set default Cash or Bank account in Mode of Payment {0}") - .format(get_link_to_form("Mode of Payment", mode_of_payment)), title=_("Missing Account")) + if d.mode_of_payment: + account = frappe.db.get_value("Mode of Payment Account", + {"parent": d.mode_of_payment, "company": self.company}, "default_account") + if not account: + invalid_modes.append(get_link_to_form("Mode of Payment", d.mode_of_payment)) + + if invalid_modes: + if invalid_modes == 1: + msg = _("Please set default Cash or Bank account in Mode of Payment {}") + else: + msg = _("Please set default Cash or Bank account in Mode of Payments {}") + frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account")) def on_submit(self): self.set_status(update=True) \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry_list.js b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry_list.js index 6c26dedc54b..1ad3c919b71 100644 --- a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry_list.js +++ b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry_list.js @@ -5,7 +5,7 @@ frappe.listview_settings['POS Opening Entry'] = { get_indicator: function(doc) { var status_color = { - "Draft": "grey", + "Draft": "red", "Open": "orange", "Closed": "green", "Cancelled": "red" diff --git a/erpnext/accounts/doctype/pos_payment_method/pos_payment_method.json b/erpnext/accounts/doctype/pos_payment_method/pos_payment_method.json index 4d5e1eb798c..30ebd307c4f 100644 --- a/erpnext/accounts/doctype/pos_payment_method/pos_payment_method.json +++ b/erpnext/accounts/doctype/pos_payment_method/pos_payment_method.json @@ -6,6 +6,7 @@ "engine": "InnoDB", "field_order": [ "default", + "allow_in_returns", "mode_of_payment" ], "fields": [ @@ -24,11 +25,19 @@ "label": "Mode of Payment", "options": "Mode of Payment", "reqd": 1 + }, + { + "default": "0", + "fieldname": "allow_in_returns", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Allow In Returns" } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-05-29 15:08:41.704844", + "modified": "2020-10-20 12:58:46.114456", "modified_by": "Administrator", "module": "Accounts", "name": "POS Payment Method", diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.js b/erpnext/accounts/doctype/pos_profile/pos_profile.js index 8ec6a536269..efdeb1a5e81 100755 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.js +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.js @@ -15,15 +15,6 @@ frappe.ui.form.on("POS Profile", "onload", function(frm) { erpnext.queries.setup_queries(frm, "Warehouse", function() { return erpnext.queries.warehouse(frm.doc); }); - - frm.call({ - method: "erpnext.accounts.doctype.pos_profile.pos_profile.get_series", - callback: function(r) { - if(!r.exc) { - set_field_options("naming_series", r.message); - } - } - }); }); frappe.ui.form.on('POS Profile', { @@ -44,6 +35,15 @@ frappe.ui.form.on('POS Profile', { }; }); + frm.set_query("taxes_and_charges", function() { + return { + filters: [ + ['Sales Taxes and Charges Template', 'company', '=', frm.doc.company], + ['Sales Taxes and Charges Template', 'docstatus', '!=', 2] + ] + }; + }); + frm.set_query('company_address', function(doc) { if(!doc.company) { frappe.throw(__('Please set Company')); @@ -57,6 +57,8 @@ frappe.ui.form.on('POS Profile', { } }; }); + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, refresh: function(frm) { @@ -67,6 +69,7 @@ frappe.ui.form.on('POS Profile', { company: function(frm) { frm.trigger("toggle_display_account_head"); + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); }, toggle_display_account_head: function(frm) { diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index d4c17917899..8afa0abd36c 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -6,15 +6,11 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "disabled", - "section_break_2", - "naming_series", - "customer", "company", + "customer", "country", + "disabled", "column_break_9", - "update_stock", - "ignore_pricing_rule", "warehouse", "campaign", "company_address", @@ -23,8 +19,17 @@ "section_break_11", "payments", "section_break_14", - "item_groups", + "hide_images", + "hide_unavailable_items", + "auto_add_item_to_cart", "column_break_16", + "update_stock", + "ignore_pricing_rule", + "allow_rate_change", + "allow_discount_change", + "section_break_23", + "item_groups", + "column_break_25", "customer_groups", "section_break_16", "print_format", @@ -55,21 +60,6 @@ "fieldtype": "Check", "label": "Disabled" }, - { - "fieldname": "section_break_2", - "fieldtype": "Section Break" - }, - { - "fieldname": "naming_series", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Series", - "no_copy": 1, - "oldfieldname": "naming_series", - "oldfieldtype": "Select", - "options": "[Select]", - "reqd": 1 - }, { "fieldname": "customer", "fieldtype": "Link", @@ -135,7 +125,8 @@ }, { "fieldname": "section_break_14", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Configuration" }, { "description": "Only show Items from these Item Groups", @@ -302,28 +293,91 @@ "fieldname": "warehouse", "fieldtype": "Link", "label": "Warehouse", - "mandatory_depends_on": "update_stock", "oldfieldname": "warehouse", "oldfieldtype": "Link", - "options": "Warehouse" - }, - { - "default": "0", - "fieldname": "update_stock", - "fieldtype": "Check", - "label": "Update Stock" + "options": "Warehouse", + "reqd": 1 }, { "default": "0", "fieldname": "ignore_pricing_rule", "fieldtype": "Check", "label": "Ignore Pricing Rule" + }, + { + "default": "1", + "fieldname": "update_stock", + "fieldtype": "Check", + "hidden": 1, + "label": "Update Stock", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "hide_unavailable_items", + "fieldtype": "Check", + "label": "Hide Unavailable Items" + }, + { + "default": "0", + "fieldname": "hide_images", + "fieldtype": "Check", + "label": "Hide Images" + }, + { + "default": "0", + "fieldname": "auto_add_item_to_cart", + "fieldtype": "Check", + "label": "Automatically Add Filtered Item To Cart" + }, + { + "default": "0", + "fieldname": "allow_rate_change", + "fieldtype": "Check", + "label": "Allow User to Edit Rate" + }, + { + "default": "0", + "fieldname": "allow_discount_change", + "fieldtype": "Check", + "label": "Allow User to Edit Discount" + }, + { + "fieldname": "section_break_23", + "fieldtype": "Section Break", + "label": "Filters" + }, + { + "fieldname": "column_break_25", + "fieldtype": "Column Break" } ], "icon": "icon-cog", "idx": 1, - "links": [], - "modified": "2020-06-29 12:20:30.977272", + "index_web_pages_for_search": 1, + "links": [ + { + "group": "Invoices", + "link_doctype": "Sales Invoice", + "link_fieldname": "pos_profile" + }, + { + "group": "Invoices", + "link_doctype": "POS Invoice", + "link_fieldname": "pos_profile" + }, + { + "group": "Opening & Closing", + "link_doctype": "POS Opening Entry", + "link_fieldname": "pos_profile" + }, + { + "group": "Opening & Closing", + "link_doctype": "POS Closing Entry", + "link_fieldname": "pos_profile" + } + ], + "modified": "2021-02-01 13:52:51.081311", "modified_by": "Administrator", "module": "Accounts", "name": "POS Profile", @@ -350,4 +404,4 @@ ], "sort_field": "modified", "sort_order": "DESC" -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index 1386b70f555..ee76bba7500 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -56,19 +56,29 @@ class POSProfile(Document): if not self.payments: frappe.throw(_("Payment methods are mandatory. Please add at least one payment method.")) - default_mode_of_payment = [d.default for d in self.payments if d.default] - if not default_mode_of_payment: + default_mode = [d.default for d in self.payments if d.default] + if not default_mode: frappe.throw(_("Please select a default mode of payment")) - if len(default_mode_of_payment) > 1: + if len(default_mode) > 1: frappe.throw(_("You can only select one mode of payment as default")) + invalid_modes = [] for d in self.payments: - account = frappe.db.get_value("Mode of Payment Account", - {"parent": d.mode_of_payment, "company": self.company}, "default_account") + account = frappe.db.get_value( + "Mode of Payment Account", + {"parent": d.mode_of_payment, "company": self.company}, + "default_account" + ) if not account: - frappe.throw(_("Please set default Cash or Bank account in Mode of Payment {0}") - .format(get_link_to_form("Mode of Payment", mode_of_payment)), title=_("Missing Account")) + invalid_modes.append(get_link_to_form("Mode of Payment", d.mode_of_payment)) + + if invalid_modes: + if invalid_modes == 1: + msg = _("Please set default Cash or Bank account in Mode of Payment {}") + else: + msg = _("Please set default Cash or Bank account in Mode of Payments {}") + frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account")) def on_update(self): self.set_defaults() @@ -109,10 +119,6 @@ def get_child_nodes(group_type, root): return frappe.db.sql(""" Select name, lft, rgt from `tab{tab}` where lft >= {lft} and rgt <= {rgt} order by lft""".format(tab=group_type, lft=lft, rgt=rgt), as_dict=1) -@frappe.whitelist() -def get_series(): - return frappe.get_meta("POS Invoice").get_field("naming_series").options or "s" - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def pos_profile_query(doctype, txt, searchfield, start, page_len, filters): diff --git a/erpnext/accounts/doctype/pos_profile/test_pos_profile.py b/erpnext/accounts/doctype/pos_profile/test_pos_profile.py index edf86590c8b..62dc1fcb209 100644 --- a/erpnext/accounts/doctype/pos_profile/test_pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/test_pos_profile.py @@ -70,6 +70,7 @@ def get_items_list(pos_profile, company): """.format(cond=cond), tuple([company] + args_list), as_dict=1) def make_pos_profile(**args): + frappe.db.sql("delete from `tabPOS Payment Method`") frappe.db.sql("delete from `tabPOS Profile`") args = frappe._dict(args) diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.js b/erpnext/accounts/doctype/pos_settings/pos_settings.js index 05cb7f0b4b5..8890d594036 100644 --- a/erpnext/accounts/doctype/pos_settings/pos_settings.js +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.js @@ -9,8 +9,7 @@ frappe.ui.form.on('POS Settings', { get_invoice_fields: function(frm) { frappe.model.with_doctype("POS Invoice", () => { var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function(d) { - if (frappe.model.no_value_type.indexOf(d.fieldtype) === -1 || - ['Table', 'Button'].includes(d.fieldtype)) { + if (frappe.model.no_value_type.indexOf(d.fieldtype) === -1 || ['Button'].includes(d.fieldtype)) { return { label: d.label + ' (' + d.fieldtype + ')', value: d.fieldname }; } else { return null; diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.js b/erpnext/accounts/doctype/pricing_rule/pricing_rule.js index c92b58b5809..d79ad5f528f 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.js +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.js @@ -42,56 +42,56 @@ frappe.ui.form.on('Pricing Rule', {

    - ${__('Notes')} + {{__('Notes')}}

    • - ${__("Pricing Rule is made to overwrite Price List / define discount percentage, based on some criteria.")} + {{__("Pricing Rule is made to overwrite Price List / define discount percentage, based on some criteria.")}}
    • - ${__("If selected Pricing Rule is made for 'Rate', it will overwrite Price List. Pricing Rule rate is the final rate, so no further discount should be applied. Hence, in transactions like Sales Order, Purchase Order etc, it will be fetched in 'Rate' field, rather than 'Price List Rate' field.")} + {{__("If selected Pricing Rule is made for 'Rate', it will overwrite Price List. Pricing Rule rate is the final rate, so no further discount should be applied. Hence, in transactions like Sales Order, Purchase Order etc, it will be fetched in 'Rate' field, rather than 'Price List Rate' field.")}}
    • - ${__('Discount Percentage can be applied either against a Price List or for all Price List.')} + {{__('Discount Percentage can be applied either against a Price List or for all Price List.')}}
    • - ${__('To not apply Pricing Rule in a particular transaction, all applicable Pricing Rules should be disabled.')} + {{__('To not apply Pricing Rule in a particular transaction, all applicable Pricing Rules should be disabled.')}}

    - ${__('How Pricing Rule is applied?')} + {{__('How Pricing Rule is applied?')}}

    1. - ${__("Pricing Rule is first selected based on 'Apply On' field, which can be Item, Item Group or Brand.")} + {{__("Pricing Rule is first selected based on 'Apply On' field, which can be Item, Item Group or Brand.")}}
    2. - ${__("Then Pricing Rules are filtered out based on Customer, Customer Group, Territory, Supplier, Supplier Type, Campaign, Sales Partner etc.")} + {{__("Then Pricing Rules are filtered out based on Customer, Customer Group, Territory, Supplier, Supplier Type, Campaign, Sales Partner etc.")}}
    3. - ${__('Pricing Rules are further filtered based on quantity.')} + {{__('Pricing Rules are further filtered based on quantity.')}}
    4. - ${__('If two or more Pricing Rules are found based on the above conditions, Priority is applied. Priority is a number between 0 to 20 while default value is zero (blank). Higher number means it will take precedence if there are multiple Pricing Rules with same conditions.')} + {{__('If two or more Pricing Rules are found based on the above conditions, Priority is applied. Priority is a number between 0 to 20 while default value is zero (blank). Higher number means it will take precedence if there are multiple Pricing Rules with same conditions.')}}
    5. - ${__('Even if there are multiple Pricing Rules with highest priority, then following internal priorities are applied:')} + {{__('Even if there are multiple Pricing Rules with highest priority, then following internal priorities are applied:')}}
      • - ${__('Item Code > Item Group > Brand')} + {{__('Item Code > Item Group > Brand')}}
      • - ${__('Customer > Customer Group > Territory')} + {{__('Customer > Customer Group > Territory')}}
      • - ${__('Supplier > Supplier Type')} + {{__('Supplier > Supplier Type')}}
    6. - ${__('If multiple Pricing Rules continue to prevail, users are asked to set Priority manually to resolve conflict.')} + {{__('If multiple Pricing Rules continue to prevail, users are asked to set Priority manually to resolve conflict.')}}
    diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json index 29d83783d07..428989aa965 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "field:title", @@ -43,6 +44,14 @@ "column_break_21", "min_amt", "max_amt", + "product_discount_scheme_section", + "same_item", + "free_item", + "free_qty", + "free_item_rate", + "column_break_42", + "free_item_uom", + "is_recursive", "section_break_23", "valid_from", "valid_upto", @@ -61,16 +70,10 @@ "discount_amount", "discount_percentage", "for_price_list", - "product_discount_scheme_section", - "same_item", - "free_item", - "free_qty", - "column_break_51", - "free_item_uom", - "free_item_rate", "section_break_13", "threshold_percentage", "priority", + "condition", "column_break_66", "apply_multiple_pricing_rules", "apply_discount_on_rate", @@ -355,7 +358,6 @@ "reqd": 1 }, { - "depends_on": "eval: doc.selling == 1", "fieldname": "margin", "fieldtype": "Section Break", "label": "Margin" @@ -404,6 +406,7 @@ "fieldtype": "Column Break" }, { + "default": "0", "depends_on": "eval:doc.rate_or_discount==\"Rate\"", "fieldname": "rate", "fieldtype": "Currency", @@ -456,10 +459,6 @@ "fieldtype": "Float", "label": "Qty" }, - { - "fieldname": "column_break_51", - "fieldtype": "Column Break" - }, { "fieldname": "free_item_uom", "fieldtype": "Link", @@ -467,6 +466,7 @@ "options": "UOM" }, { + "description": "If rate is zero them item will be treated as \"Free Item\"", "fieldname": "free_item_rate", "fieldtype": "Currency", "label": "Rate" @@ -502,10 +502,10 @@ }, { "default": "0", - "depends_on": "eval:in_list(['Discount Percentage', 'Discount Amount'], doc.rate_or_discount) && doc.apply_multiple_pricing_rules", + "depends_on": "eval:in_list(['Discount Percentage'], doc.rate_or_discount) && doc.apply_multiple_pricing_rules", "fieldname": "apply_discount_on_rate", "fieldtype": "Check", - "label": "Apply Discount on Rate" + "label": "Apply Discount on Discounted Rate" }, { "default": "0", @@ -549,12 +549,33 @@ "fieldname": "promotional_scheme", "fieldtype": "Link", "label": "Promotional Scheme", - "options": "Promotional Scheme" + "no_copy": 1, + "options": "Promotional Scheme", + "print_hide": 1, + "read_only": 1 + }, + { + "description": "Simple Python Expression, Example: territory != 'All Territories'", + "fieldname": "condition", + "fieldtype": "Code", + "label": "Condition" + }, + { + "fieldname": "column_break_42", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Discounts to be applied in sequential ranges like buy 1 get 1, buy 2 get 2, buy 3 get 3 and so on", + "fieldname": "is_recursive", + "fieldtype": "Check", + "label": "Is Recursive" } ], "icon": "fa fa-gift", "idx": 1, - "modified": "2019-12-18 17:29:22.957077", + "links": [], + "modified": "2021-03-06 22:01:24.840422", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule", diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index aa6194cbc3f..aedf1c6f1a6 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -6,9 +6,10 @@ from __future__ import unicode_literals import frappe import json import copy +import re + from frappe import throw, _ from frappe.utils import flt, cint, getdate - from frappe.model.document import Document from six import string_types @@ -30,6 +31,7 @@ class PricingRule(Document): self.validate_max_discount() self.validate_price_list_with_currency() self.validate_dates() + self.validate_condition() if not self.margin_type: self.margin_rate_or_amount = 0.0 @@ -58,6 +60,15 @@ class PricingRule(Document): if self.price_or_product_discount == 'Price' and not self.rate_or_discount: throw(_("Rate or Discount is required for the price discount."), frappe.MandatoryError) + if self.apply_discount_on_rate: + if not self.priority: + throw(_("As the field {0} is enabled, the field {1} is mandatory.") + .format(frappe.bold("Apply Discount on Discounted Rate"), frappe.bold("Priority"))) + + if self.priority and cint(self.priority) == 1: + throw(_("As the field {0} is enabled, the value of the field {1} should be more than 1.") + .format(frappe.bold("Apply Discount on Discounted Rate"), frappe.bold("Priority"))) + def validate_applicable_for_selling_or_buying(self): if not self.selling and not self.buying: throw(_("Atleast one of the Selling or Buying must be selected")) @@ -125,7 +136,7 @@ class PricingRule(Document): for d in self.items: max_discount = frappe.get_cached_value("Item", d.item_code, "max_discount") if max_discount and flt(self.discount_percentage) > flt(max_discount): - throw(_("Max discount allowed for item: {0} is {1}%").format(self.item_code, max_discount)) + throw(_("Max discount allowed for item: {0} is {1}%").format(d.item_code, max_discount)) def validate_price_list_with_currency(self): if self.currency and self.for_price_list: @@ -140,6 +151,10 @@ class PricingRule(Document): if self.valid_from and self.valid_upto and getdate(self.valid_from) > getdate(self.valid_upto): frappe.throw(_("Valid from date must be less than valid upto date")) + def validate_condition(self): + if self.condition and ("=" in self.condition) and re.match("""[\w\.:_]+\s*={1}\s*[\w\.@'"]+""", self.condition): + frappe.throw(_("Invalid condition expression")) + #-------------------------------------------------------------------------------- @frappe.whitelist() @@ -220,12 +235,12 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa item_details = frappe._dict({ "doctype": args.doctype, + "has_margin": False, "name": args.name, + "free_item_data": [], "parent": args.parent, "parenttype": args.parenttype, - "child_docname": args.get('child_docname'), - "discount_percentage_on_rate": [], - "discount_amount_on_rate": [] + "child_docname": args.get('child_docname') }) if args.ignore_pricing_rule or not args.item_code: @@ -273,6 +288,10 @@ def get_pricing_rule_for_item(args, price_list_rate=0, doc=None, for_validate=Fa else: get_product_discount_rule(pricing_rule, item_details, args, doc) + if not item_details.get("has_margin"): + item_details.margin_type = None + item_details.margin_rate_or_amount = 0.0 + item_details.has_pricing_rule = 1 item_details.pricing_rules = frappe.as_json([d.pricing_rule for d in rules]) @@ -324,20 +343,28 @@ def get_pricing_rule_details(args, pricing_rule): def apply_price_discount_rule(pricing_rule, item_details, args): item_details.pricing_rule_for = pricing_rule.rate_or_discount - if ((pricing_rule.margin_type == 'Amount' and pricing_rule.currency == args.currency) + if ((pricing_rule.margin_type in ['Amount', 'Percentage'] and pricing_rule.currency == args.currency) or (pricing_rule.margin_type == 'Percentage')): item_details.margin_type = pricing_rule.margin_type - item_details.margin_rate_or_amount = pricing_rule.margin_rate_or_amount - else: - item_details.margin_type = None - item_details.margin_rate_or_amount = 0.0 + item_details.has_margin = True + + if pricing_rule.apply_multiple_pricing_rules and item_details.margin_rate_or_amount is not None: + item_details.margin_rate_or_amount += pricing_rule.margin_rate_or_amount + else: + item_details.margin_rate_or_amount = pricing_rule.margin_rate_or_amount if pricing_rule.rate_or_discount == 'Rate': pricing_rule_rate = 0.0 if pricing_rule.currency == args.currency: pricing_rule_rate = pricing_rule.rate + + if pricing_rule_rate: + # Override already set price list rate (from item price) + # if pricing_rule_rate > 0 + item_details.update({ + "price_list_rate": pricing_rule_rate * args.get("conversion_factor", 1), + }) item_details.update({ - "price_list_rate": pricing_rule_rate * args.get("conversion_factor", 1), "discount_percentage": 0.0 }) @@ -345,9 +372,9 @@ def apply_price_discount_rule(pricing_rule, item_details, args): if pricing_rule.rate_or_discount != apply_on: continue field = frappe.scrub(apply_on) - if pricing_rule.apply_discount_on_rate: - discount_field = "{0}_on_rate".format(field) - item_details[discount_field].append(pricing_rule.get(field, 0)) + if pricing_rule.apply_discount_on_rate and item_details.get("discount_percentage"): + # Apply discount on discounted rate + item_details[field] += ((100 - item_details[field]) * (pricing_rule.get(field, 0) / 100)) else: if field not in item_details: item_details.setdefault(field, 0) @@ -355,14 +382,6 @@ def apply_price_discount_rule(pricing_rule, item_details, args): item_details[field] += (pricing_rule.get(field, 0) if pricing_rule else args.get(field, 0)) -def set_discount_amount(rate, item_details): - for field in ['discount_percentage_on_rate', 'discount_amount_on_rate']: - for d in item_details.get(field): - dis_amount = (rate * d / 100 - if field == 'discount_percentage_on_rate' else d) - rate -= dis_amount - item_details.rate = rate - def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None): from erpnext.accounts.doctype.pricing_rule.utils import (get_applied_pricing_rules, get_pricing_rule_items) diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index 2bf0b725635..f28cee7c5af 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -56,6 +56,7 @@ class TestPricingRule(unittest.TestCase): self.assertEqual(details.get("discount_percentage"), 10) prule = frappe.get_doc(test_record.copy()) + prule.priority = 1 prule.applicable_for = "Customer" prule.title = "_Test Pricing Rule for Customer" self.assertRaises(MandatoryError, prule.insert) @@ -261,6 +262,7 @@ class TestPricingRule(unittest.TestCase): "rate_or_discount": "Discount Percentage", "rate": 0, "discount_percentage": 17.5, + "priority": 1, "company": "_Test Company" }).insert() @@ -385,7 +387,7 @@ class TestPricingRule(unittest.TestCase): so.load_from_db() self.assertEqual(so.items[1].is_free_item, 1) self.assertEqual(so.items[1].item_code, "_Test Item 2") - + def test_cumulative_pricing_rule(self): frappe.delete_doc_if_exists('Pricing Rule', '_Test Cumulative Pricing Rule') test_record = { @@ -430,6 +432,113 @@ class TestPricingRule(unittest.TestCase): self.assertTrue(details) + def test_pricing_rule_for_condition(self): + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule") + + make_pricing_rule(selling=1, margin_type="Percentage", \ + condition="customer=='_Test Customer 1' and is_return==0", discount_percentage=10) + + # Incorrect Customer and Correct is_return value + si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 2", is_return=0) + si.items[0].price_list_rate = 1000 + si.submit() + item = si.items[0] + self.assertEquals(item.rate, 100) + + # Correct Customer and Incorrect is_return value + si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", is_return=1, qty=-1) + si.items[0].price_list_rate = 1000 + si.submit() + item = si.items[0] + self.assertEquals(item.rate, 100) + + # Correct Customer and correct is_return value + si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", is_return=0) + si.items[0].price_list_rate = 1000 + si.submit() + item = si.items[0] + self.assertEquals(item.rate, 900) + + def test_multiple_pricing_rules(self): + make_pricing_rule(discount_percentage=20, selling=1, priority=1, apply_multiple_pricing_rules=1, + title="_Test Pricing Rule 1") + make_pricing_rule(discount_percentage=10, selling=1, title="_Test Pricing Rule 2", priority=2, + apply_multiple_pricing_rules=1) + si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1) + self.assertEqual(si.items[0].discount_percentage, 30) + si.delete() + + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule 1") + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule 2") + + def test_multiple_pricing_rules_with_apply_discount_on_discounted_rate(self): + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule") + + make_pricing_rule(discount_percentage=20, selling=1, priority=1, apply_multiple_pricing_rules=1, + title="_Test Pricing Rule 1") + make_pricing_rule(discount_percentage=10, selling=1, priority=2, + apply_discount_on_rate=1, title="_Test Pricing Rule 2", apply_multiple_pricing_rules=1) + + si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1) + self.assertEqual(si.items[0].discount_percentage, 28) + si.delete() + + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule 1") + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule 2") + + def test_item_price_with_pricing_rule(self): + item = make_item("Water Flask") + make_item_price("Water Flask", "_Test Price List", 100) + + pricing_rule_record = { + "doctype": "Pricing Rule", + "title": "_Test Water Flask Rule", + "apply_on": "Item Code", + "items": [{ + "item_code": "Water Flask", + }], + "selling": 1, + "currency": "INR", + "rate_or_discount": "Rate", + "rate": 0, + "margin_type": "Percentage", + "margin_rate_or_amount": 2, + "company": "_Test Company" + } + rule = frappe.get_doc(pricing_rule_record) + rule.insert() + + si = create_sales_invoice(do_not_save=True, item_code="Water Flask") + si.selling_price_list = "_Test Price List" + si.save() + + # If rate in Rule is 0, give preference to Item Price if it exists + self.assertEqual(si.items[0].price_list_rate, 100) + self.assertEqual(si.items[0].margin_rate_or_amount, 2) + self.assertEqual(si.items[0].rate_with_margin, 102) + self.assertEqual(si.items[0].rate, 102) + + si.delete() + rule.delete() + frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete() + item.delete() + + def test_pricing_rule_for_transaction(self): + make_item("Water Flask 1") + frappe.delete_doc_if_exists('Pricing Rule', '_Test Pricing Rule') + make_pricing_rule(selling=1, min_qty=5, price_or_product_discount="Product", + apply_on="Transaction", free_item="Water Flask 1", free_qty=1, free_item_rate=10) + + si = create_sales_invoice(qty=5, do_not_submit=True) + self.assertEquals(len(si.items), 2) + self.assertEquals(si.items[1].rate, 10) + + si1 = create_sales_invoice(qty=2, do_not_submit=True) + self.assertEquals(len(si1.items), 1) + + for doc in [si, si1]: + doc.delete() + def make_pricing_rule(**args): args = frappe._dict(args) @@ -441,21 +550,31 @@ def make_pricing_rule(**args): "applicable_for": args.applicable_for, "selling": args.selling or 0, "currency": "USD", + "apply_discount_on_rate": args.apply_discount_on_rate or 0, "buying": args.buying or 0, "min_qty": args.min_qty or 0.0, "max_qty": args.max_qty or 0.0, "rate_or_discount": args.rate_or_discount or "Discount Percentage", "discount_percentage": args.discount_percentage or 0.0, "rate": args.rate or 0.0, - "margin_type": args.margin_type, - "margin_rate_or_amount": args.margin_rate_or_amount or 0.0 + "margin_rate_or_amount": args.margin_rate_or_amount or 0.0, + "condition": args.condition or '', + "priority": 1, + "apply_multiple_pricing_rules": args.apply_multiple_pricing_rules or 0 }) + for field in ["free_item", "free_qty", "free_item_rate", "priority", + "margin_type", "price_or_product_discount"]: + if args.get(field): + doc.set(field, args.get(field)) + apply_on = doc.apply_on.replace(' ', '_').lower() child_table = {'Item Code': 'items', 'Item Group': 'item_groups', 'Brand': 'brands'} - doc.append(child_table.get(doc.apply_on), { - apply_on: args.get(apply_on) or "_Test Item" - }) + + if doc.apply_on != "Transaction": + doc.append(child_table.get(doc.apply_on), { + apply_on: args.get(apply_on) or "_Test Item" + }) doc.insert(ignore_permissions=True) if args.get(apply_on) and apply_on != "item_code": diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 53b0cf7bbac..c676abd4c61 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -14,9 +14,8 @@ import frappe from erpnext.setup.doctype.item_group.item_group import get_child_item_groups from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses from erpnext.stock.get_item_details import get_conversion_factor -from frappe import _, throw -from frappe.utils import cint, flt, get_datetime, get_link_to_form, getdate, today - +from frappe import _, bold +from frappe.utils import cint, flt, get_link_to_form, getdate, today, fmt_money class MultiplePricingRuleConflict(frappe.ValidationError): pass @@ -37,12 +36,16 @@ def get_pricing_rules(args, doc=None): rules = [] + pricing_rules = filter_pricing_rule_based_on_condition(pricing_rules, doc) + if not pricing_rules: return [] if apply_multiple_pricing_rules(pricing_rules): + pricing_rules = sorted_by_priority(pricing_rules, args, doc) for pricing_rule in pricing_rules: - pricing_rule = filter_pricing_rules(args, pricing_rule, doc) - if pricing_rule: + if isinstance(pricing_rule, list): + rules.extend(pricing_rule) + else: rules.append(pricing_rule) else: pricing_rule = filter_pricing_rules(args, pricing_rules, doc) @@ -51,6 +54,42 @@ def get_pricing_rules(args, doc=None): return rules +def sorted_by_priority(pricing_rules, args, doc=None): + # If more than one pricing rules, then sort by priority + pricing_rules_list = [] + pricing_rule_dict = {} + + for pricing_rule in pricing_rules: + pricing_rule = filter_pricing_rules(args, pricing_rule, doc) + if pricing_rule: + if not pricing_rule.get('priority'): + pricing_rule['priority'] = 1 + + if pricing_rule.get('apply_multiple_pricing_rules'): + pricing_rule_dict.setdefault(cint(pricing_rule.get("priority")), []).append(pricing_rule) + + for key in sorted(pricing_rule_dict): + pricing_rules_list.extend(pricing_rule_dict.get(key)) + + return pricing_rules_list or pricing_rules + +def filter_pricing_rule_based_on_condition(pricing_rules, doc=None): + filtered_pricing_rules = [] + if doc: + for pricing_rule in pricing_rules: + if pricing_rule.condition: + try: + if frappe.safe_eval(pricing_rule.condition, None, doc.as_dict()): + filtered_pricing_rules.append(pricing_rule) + except: + pass + else: + filtered_pricing_rules.append(pricing_rule) + else: + filtered_pricing_rules = pricing_rules + + return filtered_pricing_rules + def _get_pricing_rules(apply_on, args, values): apply_on_field = frappe.scrub(apply_on) @@ -111,9 +150,7 @@ def apply_multiple_pricing_rules(pricing_rules): if not apply_multiple_rule: return False - if (apply_multiple_rule - and len(apply_multiple_rule) == len(pricing_rules)): - return True + return True def _get_tree_conditions(args, parenttype, table, allow_blank=True): field = frappe.scrub(parenttype) @@ -131,7 +168,15 @@ def _get_tree_conditions(args, parenttype, table, allow_blank=True): frappe.throw(_("Invalid {0}").format(args.get(field))) parent_groups = frappe.db.sql_list("""select name from `tab%s` - where lft<=%s and rgt>=%s""" % (parenttype, '%s', '%s'), (lft, rgt)) + where lft>=%s and rgt<=%s""" % (parenttype, '%s', '%s'), (lft, rgt)) + + if parenttype in ["Customer Group", "Item Group", "Territory"]: + parent_field = "parent_{0}".format(frappe.scrub(parenttype)) + root_name = frappe.db.get_list(parenttype, + {"is_group": 1, parent_field: ("is", "not set")}, "name", as_list=1) + + if root_name and root_name[0][0]: + parent_groups.append(root_name[0][0]) if parent_groups: if allow_blank: parent_groups.append('') @@ -223,18 +268,6 @@ def filter_pricing_rules(args, pricing_rules, doc=None): if max_priority: pricing_rules = list(filter(lambda x: cint(x.priority)==max_priority, pricing_rules)) - # apply internal priority - all_fields = ["item_code", "item_group", "brand", "customer", "customer_group", "territory", - "supplier", "supplier_group", "campaign", "sales_partner", "variant_of"] - - if len(pricing_rules) > 1: - for field_set in [["item_code", "variant_of", "item_group", "brand"], - ["customer", "customer_group", "territory"], ["supplier", "supplier_group"]]: - remaining_fields = list(set(all_fields) - set(field_set)) - if if_all_rules_same(pricing_rules, remaining_fields): - pricing_rules = apply_internal_priority(pricing_rules, field_set, args) - break - if pricing_rules and not isinstance(pricing_rules, list): pricing_rules = list(pricing_rules) @@ -265,12 +298,13 @@ def validate_quantity_and_amount_for_suggestion(args, qty, amount, item_code, tr fieldname = field if fieldname: - msg = _("""If you {0} {1} quantities of the item {2}, the scheme {3} - will be applied on the item.""").format(type_of_transaction, args.get(fieldname), item_code, args.rule_description) + msg = (_("If you {0} {1} quantities of the item {2}, the scheme {3} will be applied on the item.") + .format(type_of_transaction, args.get(fieldname), bold(item_code), bold(args.rule_description))) if fieldname in ['min_amt', 'max_amt']: - msg = _("""If you {0} {1} worth item {2}, the scheme {3} will be applied on the item. - """).format(frappe.fmt_money(type_of_transaction, args.get(fieldname)), item_code, args.rule_description) + msg = (_("If you {0} {1} worth item {2}, the scheme {3} will be applied on the item.") + .format(type_of_transaction, fmt_money(args.get(fieldname), currency=args.get("currency")), + bold(item_code), bold(args.rule_description))) frappe.msgprint(msg) @@ -333,7 +367,7 @@ def get_qty_and_rate_for_mixed_conditions(doc, pr_doc, args): if items and doc.get("items"): for row in doc.get('items'): - if row.get(apply_on) not in items: continue + if (row.get(apply_on) or args.get(apply_on)) not in items: continue if pr_doc.mixed_conditions: amt = args.get('qty') * args.get("price_list_rate") @@ -423,6 +457,9 @@ def apply_pricing_rule_on_transaction(doc): pricing_rules = filter_pricing_rules_for_qty_amount(doc.total_qty, doc.total, pricing_rules) + if not pricing_rules: + remove_free_item(doc) + for d in pricing_rules: if d.price_or_product_discount == 'Price': if d.apply_discount_on: @@ -442,10 +479,16 @@ def apply_pricing_rule_on_transaction(doc): doc.calculate_taxes_and_totals() elif d.price_or_product_discount == 'Product': - item_details = frappe._dict({'parenttype': doc.doctype}) + item_details = frappe._dict({'parenttype': doc.doctype, 'free_item_data': []}) get_product_discount_rule(d, item_details, doc=doc) apply_pricing_rule_for_free_items(doc, item_details.free_item_data) doc.set_missing_values() + doc.calculate_taxes_and_totals() + +def remove_free_item(doc): + for d in doc.items: + if d.is_free_item: + doc.remove(d) def get_applied_pricing_rules(pricing_rules): if pricing_rules: @@ -458,16 +501,23 @@ def get_applied_pricing_rules(pricing_rules): def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None): free_item = pricing_rule.free_item - if pricing_rule.same_item: + if pricing_rule.same_item and pricing_rule.get("apply_on") != 'Transaction': free_item = item_details.item_code or args.item_code if not free_item: frappe.throw(_("Free item not set in the pricing rule {0}") .format(get_link_to_form("Pricing Rule", pricing_rule.name))) - item_details.free_item_data = { + qty = pricing_rule.free_qty or 1 + if pricing_rule.is_recursive: + transaction_qty = args.get('qty') if args else doc.total_qty + if transaction_qty: + qty = flt(transaction_qty) * qty + + free_item_data_args = { 'item_code': free_item, - 'qty': pricing_rule.free_qty or 1, + 'qty': qty, + 'pricing_rules': pricing_rule.name, 'rate': pricing_rule.free_item_rate or 0, 'price_list_rate': pricing_rule.free_item_rate or 0, 'is_free_item': 1 @@ -476,24 +526,26 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None): item_data = frappe.get_cached_value('Item', free_item, ['item_name', 'description', 'stock_uom'], as_dict=1) - item_details.free_item_data.update(item_data) - item_details.free_item_data['uom'] = pricing_rule.free_item_uom or item_data.stock_uom - item_details.free_item_data['conversion_factor'] = get_conversion_factor(free_item, - item_details.free_item_data['uom']).get("conversion_factor", 1) + free_item_data_args.update(item_data) + free_item_data_args['uom'] = pricing_rule.free_item_uom or item_data.stock_uom + free_item_data_args['conversion_factor'] = get_conversion_factor(free_item, + free_item_data_args['uom']).get("conversion_factor", 1) if item_details.get("parenttype") == 'Purchase Order': - item_details.free_item_data['schedule_date'] = doc.schedule_date if doc else today() + free_item_data_args['schedule_date'] = doc.schedule_date if doc else today() if item_details.get("parenttype") == 'Sales Order': - item_details.free_item_data['delivery_date'] = doc.delivery_date if doc else today() + free_item_data_args['delivery_date'] = doc.delivery_date if doc else today() + + item_details.free_item_data.append(free_item_data_args) def apply_pricing_rule_for_free_items(doc, pricing_rule_args, set_missing_values=False): - if pricing_rule_args.get('item_code'): - items = [d.item_code for d in doc.items - if d.item_code == (pricing_rule_args.get("item_code")) and d.is_free_item] + if pricing_rule_args: + items = tuple([(d.item_code, d.pricing_rules) for d in doc.items if d.is_free_item]) - if not items: - doc.append('items', 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) def get_pricing_rule_items(pr_doc): apply_on_data = [] diff --git a/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py b/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py index 31356c6e8b6..e08a0e5cc2b 100644 --- a/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py +++ b/erpnext/accounts/doctype/process_deferred_accounting/test_process_deferred_accounting.py @@ -21,7 +21,7 @@ class TestProcessDeferredAccounting(unittest.TestCase): item.no_of_months = 12 item.save() - si = create_sales_invoice(item=item.name, posting_date="2019-01-10", do_not_submit=True) + si = create_sales_invoice(item=item.name, update_stock=0, posting_date="2019-01-10", do_not_submit=True) si.items[0].enable_deferred_revenue = 1 si.items[0].service_start_date = "2019-01-10" si.items[0].service_end_date = "2019-03-15" diff --git a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py index 89f7238a061..523e9ee08ae 100644 --- a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py +++ b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py @@ -12,16 +12,16 @@ from frappe.model.document import Document pricing_rule_fields = ['apply_on', 'mixed_conditions', 'is_cumulative', 'other_item_code', 'other_item_group' 'apply_rule_on_other', 'other_brand', 'selling', 'buying', 'applicable_for', 'valid_from', 'valid_upto', 'customer', 'customer_group', 'territory', 'sales_partner', 'campaign', 'supplier', - 'supplier_group', 'company', 'currency'] + 'supplier_group', 'company', 'currency', 'apply_multiple_pricing_rules'] other_fields = ['min_qty', 'max_qty', 'min_amt', 'max_amt', 'priority','warehouse', 'threshold_percentage', 'rule_description'] price_discount_fields = ['rate_or_discount', 'apply_discount_on', 'apply_discount_on_rate', - 'rate', 'discount_amount', 'discount_percentage', 'validate_applied_rule'] + 'rate', 'discount_amount', 'discount_percentage', 'validate_applied_rule', 'apply_multiple_pricing_rules'] product_discount_fields = ['free_item', 'free_qty', 'free_item_uom', - 'free_item_rate', 'same_item'] + 'free_item_rate', 'same_item', 'is_recursive', 'apply_multiple_pricing_rules'] class PromotionalScheme(Document): def validate(self): diff --git a/erpnext/accounts/doctype/promotional_scheme_price_discount/promotional_scheme_price_discount.json b/erpnext/accounts/doctype/promotional_scheme_price_discount/promotional_scheme_price_discount.json index 224b8de779d..795fb1c6f46 100644 --- a/erpnext/accounts/doctype/promotional_scheme_price_discount/promotional_scheme_price_discount.json +++ b/erpnext/accounts/doctype/promotional_scheme_price_discount/promotional_scheme_price_discount.json @@ -1,792 +1,181 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, + "actions": [], "creation": "2019-03-24 14:48:59.649168", - "custom": 0, - "docstatus": 0, "doctype": "DocType", - "document_type": "", "editable_grid": 1, "engine": "InnoDB", + "field_order": [ + "disable", + "apply_multiple_pricing_rules", + "column_break_2", + "rule_description", + "section_break_2", + "min_qty", + "max_qty", + "column_break_3", + "min_amount", + "max_amount", + "section_break_6", + "rate_or_discount", + "column_break_10", + "rate", + "discount_amount", + "discount_percentage", + "section_break_11", + "warehouse", + "threshold_percentage", + "validate_applied_rule", + "column_break_14", + "priority", + "apply_discount_on_rate" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "disable", "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": "Disable", - "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 + "label": "Disable" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "rule_description", "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Rule Description", - "length": 0, "no_copy": 1, - "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 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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 + "fieldtype": "Section Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, "columns": 1, "default": "0", "fieldname": "min_qty", "fieldtype": "Float", - "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": "Min Qty", - "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 + "label": "Min Qty" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, "columns": 1, "default": "0", "fieldname": "max_qty", "fieldtype": "Float", - "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": "Max Qty", - "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 + "label": "Max Qty" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_3", - "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 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "0", "fieldname": "min_amount", "fieldtype": "Currency", - "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": "Min Amount", - "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 + "label": "Min Amount" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "0", - "depends_on": "", "fieldname": "max_amount", "fieldtype": "Currency", - "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": "Max Amount", - "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 + "label": "Max Amount" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", "fieldname": "section_break_6", - "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, - "label": "", - "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 + "fieldtype": "Section Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "Discount Percentage", - "depends_on": "", "fieldname": "rate_or_discount", "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": "Discount Type", - "length": 0, - "no_copy": 0, - "options": "\nRate\nDiscount Percentage\nDiscount Amount", - "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 + "options": "\nRate\nDiscount Percentage\nDiscount Amount" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", "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 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, "columns": 2, "depends_on": "eval:doc.rate_or_discount==\"Rate\"", "fieldname": "rate", "fieldtype": "Currency", - "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": "Rate", - "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 + "label": "Rate" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.rate_or_discount==\"Discount Amount\"", "fieldname": "discount_amount", "fieldtype": "Currency", - "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": "Discount Amount", - "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 + "label": "Discount Amount" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:doc.rate_or_discount==\"Discount Percentage\"", "fieldname": "discount_percentage", "fieldtype": "Float", - "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": "Discount Percentage", - "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 + "label": "Discount Percentage" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "section_break_11", - "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 + "fieldtype": "Section Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "warehouse", "fieldtype": "Link", - "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": "Warehouse", - "length": 0, - "no_copy": 0, - "options": "Warehouse", - "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 + "options": "Warehouse" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "threshold_percentage", "fieldtype": "Percent", - "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": "Threshold for Suggestion", - "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 + "label": "Threshold for Suggestion" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "validate_applied_rule", "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": "Validate Applied Rule", - "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 + "label": "Validate Applied Rule" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "priority", "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": "Priority", - "length": 0, - "no_copy": 0, - "options": "\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20", - "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 + "options": "\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "depends_on": "priority", "fieldname": "apply_multiple_pricing_rules", "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": "Apply Multiple Pricing Rules", - "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 + "label": "Apply Multiple Pricing Rules" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "0", "depends_on": "eval:in_list(['Discount Percentage', 'Discount Amount'], doc.rate_or_discount) && doc.apply_multiple_pricing_rules", "fieldname": "apply_discount_on_rate", "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": "Apply Discount on Rate", - "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 + "label": "Apply Discount on Rate" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, + "index_web_pages_for_search": 1, "istable": 1, - "max_attachments": 0, - "modified": "2019-03-24 14:48:59.649168", + "links": [], + "modified": "2021-03-07 11:56:23.424137", "modified_by": "Administrator", "module": "Accounts", "name": "Promotional Scheme Price Discount", - "name_case": "", "owner": "Administrator", "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/promotional_scheme_product_discount/promotional_scheme_product_discount.json b/erpnext/accounts/doctype/promotional_scheme_product_discount/promotional_scheme_product_discount.json index 72d53bfa016..3eab51510db 100644 --- a/erpnext/accounts/doctype/promotional_scheme_product_discount/promotional_scheme_product_discount.json +++ b/erpnext/accounts/doctype/promotional_scheme_product_discount/promotional_scheme_product_discount.json @@ -1,10 +1,12 @@ { + "actions": [], "creation": "2019-03-24 14:48:59.649168", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "disable", + "apply_multiple_pricing_rules", "column_break_2", "rule_description", "section_break_1", @@ -25,7 +27,7 @@ "threshold_percentage", "column_break_15", "priority", - "apply_multiple_pricing_rules" + "is_recursive" ], "fields": [ { @@ -152,10 +154,19 @@ "fieldname": "apply_multiple_pricing_rules", "fieldtype": "Check", "label": "Apply Multiple Pricing Rules" + }, + { + "default": "0", + "description": "Discounts to be applied in sequential ranges like buy 1 get 1, buy 2 get 2, buy 3 get 3 and so on", + "fieldname": "is_recursive", + "fieldtype": "Check", + "label": "Is Recursive" } ], + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2019-07-21 00:00:56.674284", + "links": [], + "modified": "2021-03-06 21:58:18.162346", "modified_by": "Administrator", "module": "Accounts", "name": "Promotional Scheme Product Discount", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index fe5301d5c83..66a8e206a81 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -15,7 +15,22 @@ erpnext.accounts.PurchaseInvoice = erpnext.buying.BuyingController.extend({ return (doc.qty<=doc.received_qty) ? "green" : "orange"; }); } + + this.frm.set_query("unrealized_profit_loss_account", function() { + return { + filters: { + company: doc.company, + is_group: 0, + root_type: "Liability", + } + }; + }); }, + + company: function() { + erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); + }, + onload: function() { this._super(); @@ -31,6 +46,8 @@ erpnext.accounts.PurchaseInvoice = erpnext.buying.BuyingController.extend({ if (this.frm.doc.supplier && this.frm.doc.__islocal) { this.frm.trigger('supplier'); } + + erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype); }, refresh: function(doc) { @@ -99,6 +116,7 @@ erpnext.accounts.PurchaseInvoice = erpnext.buying.BuyingController.extend({ target: me.frm, setters: { supplier: me.frm.doc.supplier || undefined, + schedule_date: undefined }, get_query_filters: { docstatus: 1, @@ -107,16 +125,16 @@ erpnext.accounts.PurchaseInvoice = erpnext.buying.BuyingController.extend({ company: me.frm.doc.company } }) - }, __("Get items from")); + }, __("Get Items From")); this.frm.add_custom_button(__('Purchase Receipt'), function() { erpnext.utils.map_current_doc({ method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice", source_doctype: "Purchase Receipt", target: me.frm, - date_field: "posting_date", setters: { supplier: me.frm.doc.supplier || undefined, + posting_date: undefined }, get_query_filters: { docstatus: 1, @@ -125,7 +143,7 @@ erpnext.accounts.PurchaseInvoice = erpnext.buying.BuyingController.extend({ is_return: 0 } }) - }, __("Get items from")); + }, __("Get Items From")); } this.frm.toggle_reqd("supplier_warehouse", this.frm.doc.is_subcontracted==="Yes"); @@ -257,8 +275,11 @@ erpnext.accounts.PurchaseInvoice = erpnext.buying.BuyingController.extend({ supplier: function() { var me = this; - if(this.frm.updating_party_details) + + // Do not update if inter company reference is there as the details will already be updated + if(this.frm.updating_party_details || this.frm.doc.inter_company_invoice_reference) return; + erpnext.utils.get_party_details(this.frm, "erpnext.accounts.party.get_party_details", { posting_date: this.frm.doc.posting_date, @@ -487,7 +508,7 @@ cur_frm.cscript.select_print_heading = function(doc,cdt,cdn){ frappe.ui.form.on("Purchase Invoice", { setup: function(frm) { frm.custom_make_buttons = { - 'Purchase Invoice': 'Debit Note', + 'Purchase Invoice': 'Return / Debit Note', 'Payment Entry': 'Payment' } @@ -500,19 +521,10 @@ frappe.ui.form.on("Purchase Invoice", { } } } - - frm.set_query("cost_center", function() { - return { - filters: { - company: frm.doc.company, - is_group: 0 - } - }; - }); }, onload: function(frm) { - if(frm.doc.__onload) { + if(frm.doc.__onload && frm.is_new()) { if(frm.doc.supplier) { frm.doc.apply_tds = frm.doc.__onload.supplier_tds ? 1 : 0; } diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index d62e73b6ac6..18b66375e99 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -57,8 +57,9 @@ "set_warehouse", "rejected_warehouse", "col_break_warehouse", - "is_subcontracted", + "set_from_warehouse", "supplier_warehouse", + "is_subcontracted", "items_section", "update_stock", "scan_barcode", @@ -126,6 +127,7 @@ "write_off_cost_center", "advances_section", "allocate_advances_automatically", + "adjust_advance_taxes", "get_advances", "advances", "payment_schedule_section", @@ -151,9 +153,11 @@ "is_opening", "against_expense_account", "column_break_63", + "unrealized_profit_loss_account", "status", "inter_company_invoice_reference", "is_internal_supplier", + "represents_company", "remarks", "subscription_section", "from_date", @@ -361,6 +365,7 @@ "fieldname": "bill_date", "fieldtype": "Date", "label": "Supplier Invoice Date", + "no_copy": 1, "oldfieldname": "bill_date", "oldfieldtype": "Date", "print_hide": 1 @@ -511,6 +516,7 @@ }, { "depends_on": "update_stock", + "description": "Sets 'Accepted Warehouse' in each row of the items table.", "fieldname": "set_warehouse", "fieldtype": "Link", "label": "Set Accepted Warehouse", @@ -539,17 +545,6 @@ "options": "No\nYes", "print_hide": 1 }, - { - "depends_on": "eval:doc.is_subcontracted==\"Yes\"", - "fieldname": "supplier_warehouse", - "fieldtype": "Link", - "label": "Supplier Warehouse", - "no_copy": 1, - "options": "Warehouse", - "print_hide": 1, - "print_width": "50px", - "width": "50px" - }, { "fieldname": "items_section", "fieldtype": "Section Break", @@ -1221,14 +1216,16 @@ "fieldtype": "Select", "in_standard_filter": 1, "label": "Status", - "options": "\nDraft\nReturn\nDebit Note Issued\nSubmitted\nPaid\nUnpaid\nOverdue\nCancelled", + "options": "\nDraft\nReturn\nDebit Note Issued\nSubmitted\nPaid\nUnpaid\nOverdue\nCancelled\nInternal Transfer", "print_hide": 1 }, { "fieldname": "inter_company_invoice_reference", "fieldtype": "Link", "label": "Inter Company Invoice Reference", + "no_copy": 1, "options": "Sales Invoice", + "print_hide": 1, "read_only": 1 }, { @@ -1328,13 +1325,60 @@ "fieldtype": "Link", "label": "Project", "options": "Project" + }, + { + "default": "0", + "description": "Taxes paid while advance payment will be adjusted against this invoice", + "fieldname": "adjust_advance_taxes", + "fieldtype": "Check", + "label": "Adjust Advance Taxes" + }, + { + "depends_on": "eval:doc.is_internal_supplier", + "description": "Unrealized Profit / Loss account for intra-company transfers", + "fieldname": "unrealized_profit_loss_account", + "fieldtype": "Link", + "label": "Unrealized Profit / Loss Account", + "options": "Account" + }, + { + "depends_on": "eval:doc.is_internal_supplier", + "description": "Company which internal supplier represents", + "fetch_from": "supplier.represents_company", + "fieldname": "represents_company", + "fieldtype": "Link", + "label": "Represents Company", + "options": "Company" + }, + { + "depends_on": "eval:doc.update_stock && doc.is_internal_supplier", + "description": "Sets 'From Warehouse' in each row of the items table.", + "fieldname": "set_from_warehouse", + "fieldtype": "Link", + "label": "Set From Warehouse", + "no_copy": 1, + "options": "Warehouse", + "print_hide": 1, + "print_width": "50px", + "width": "50px" + }, + { + "depends_on": "eval:doc.update_stock && doc.is_subcontracted==\"Yes\"", + "fieldname": "supplier_warehouse", + "fieldtype": "Link", + "label": "Supplier Warehouse", + "no_copy": 1, + "options": "Warehouse", + "print_hide": 1, + "print_width": "50px", + "width": "50px" } ], "icon": "fa fa-file-text", "idx": 204, "is_submittable": 1, "links": [], - "modified": "2020-08-03 23:20:04.466153", + "modified": "2021-03-09 21:12:30.422084", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", @@ -1396,4 +1440,4 @@ "timeline_field": "supplier", "title_field": "title", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 079f5997067..5c4e32e493e 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -147,18 +147,25 @@ class PurchaseInvoice(BuyingController): throw(_("Conversion rate cannot be 0 or 1")) def validate_credit_to_acc(self): + if not self.credit_to: + self.credit_to = get_party_account("Supplier", self.supplier, self.company) + if not self.credit_to: + self.raise_missing_debit_credit_account_error("Supplier", self.supplier) + account = frappe.db.get_value("Account", self.credit_to, ["account_type", "report_type", "account_currency"], as_dict=True) if account.report_type != "Balance Sheet": - frappe.throw(_("Please ensure {} account is a Balance Sheet account. \ - You can change the parent account to a Balance Sheet account or select a different account.") - .format(frappe.bold("Credit To")), title=_("Invalid Account")) + frappe.throw( + _("Please ensure {} account is a Balance Sheet account. You can change the parent account to a Balance Sheet account or select a different account.") + .format(frappe.bold("Credit To")), title=_("Invalid Account") + ) if self.supplier and account.account_type != "Payable": - frappe.throw(_("Please ensure {} account is a Payable account. \ - Change the account type to Payable or select a different account.") - .format(frappe.bold("Credit To")), title=_("Invalid Account")) + frappe.throw( + _("Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.") + .format(frappe.bold("Credit To")), title=_("Invalid Account") + ) self.party_account_currency = account.account_currency @@ -199,8 +206,8 @@ class PurchaseInvoice(BuyingController): ["Purchase Receipt", "purchase_receipt", "pr_detail"] ]) - def validate_warehouse(self): - if self.update_stock: + def validate_warehouse(self, for_validate=True): + if self.update_stock and for_validate: for d in self.get('items'): if not d.warehouse: frappe.throw(_("Warehouse required at Row No {0}, please set default warehouse for the item {1} for the company {2}"). @@ -226,7 +233,7 @@ class PurchaseInvoice(BuyingController): if self.update_stock: self.validate_item_code() - self.validate_warehouse() + self.validate_warehouse(for_validate) if auto_accounting_for_stock: warehouse_account = get_warehouse_account_map(self.company) @@ -244,10 +251,10 @@ class PurchaseInvoice(BuyingController): if self.update_stock and (not item.from_warehouse): if for_validate and item.expense_account and item.expense_account != warehouse_account[item.warehouse]["account"]: - frappe.msgprint(_('''Row {0}: Expense Head changed to {1} because account {2} - is not linked to warehouse {3} or it is not the default inventory account'''.format( - item.idx, frappe.bold(warehouse_account[item.warehouse]["account"]), - frappe.bold(item.expense_account), frappe.bold(item.warehouse)))) + msg = _("Row {}: Expense Head changed to {} ").format(item.idx, frappe.bold(warehouse_account[item.warehouse]["account"])) + msg += _("because account {} is not linked to warehouse {} ").format(frappe.bold(item.expense_account), frappe.bold(item.warehouse)) + msg += _("or it is not the default inventory account") + frappe.msgprint(msg, title=_("Expense Head Changed")) item.expense_account = warehouse_account[item.warehouse]["account"] else: @@ -259,19 +266,19 @@ class PurchaseInvoice(BuyingController): if negative_expense_booked_in_pr: if for_validate and item.expense_account and item.expense_account != stock_not_billed_account: - frappe.msgprint(_('''Row {0}: Expense Head changed to {1} because - expense is booked against this account in Purchase Receipt {2}'''.format( - item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.purchase_receipt)))) + msg = _("Row {}: Expense Head changed to {} ").format(item.idx, frappe.bold(stock_not_billed_account)) + msg += _("because expense is booked against this account in Purchase Receipt {}").format(frappe.bold(item.purchase_receipt)) + frappe.msgprint(msg, title=_("Expense Head Changed")) item.expense_account = stock_not_billed_account else: # If no purchase receipt present then book expense in 'Stock Received But Not Billed' # This is done in cases when Purchase Invoice is created before Purchase Receipt if for_validate and item.expense_account and item.expense_account != stock_not_billed_account: - frappe.msgprint(_('''Row {0}: Expense Head changed to {1} as no Purchase - Receipt is created against Item {2}. This is done to handle accounting for cases - when Purchase Receipt is created after Purchase Invoice'''.format( - item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.item_code)))) + msg = _("Row {}: Expense Head changed to {} ").format(item.idx, frappe.bold(stock_not_billed_account)) + msg += _("as no Purchase Receipt is created against Item {}. ").format(frappe.bold(item.item_code)) + msg += _("This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice") + frappe.msgprint(msg, title=_("Expense Head Changed")) item.expense_account = stock_not_billed_account @@ -299,10 +306,11 @@ class PurchaseInvoice(BuyingController): for d in self.get('items'): if not d.purchase_order: - throw(_("""Purchase Order Required for item {0} - To submit the invoice without purchase order please set - {1} as {2} in {3}""").format(frappe.bold(d.item_code), frappe.bold(_('Purchase Order Required')), - frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings'))) + msg = _("Purchase Order Required for item {}").format(frappe.bold(d.item_code)) + msg += "

    " + msg += _("To submit the invoice without purchase order please set {} ").format(frappe.bold(_('Purchase Order Required'))) + msg += _("as {} in {}").format(frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings')) + throw(msg, title=_("Mandatory Purchase Order")) def pr_required(self): stock_items = self.get_stock_items() @@ -313,10 +321,11 @@ class PurchaseInvoice(BuyingController): for d in self.get('items'): if not d.purchase_receipt and d.item_code in stock_items: - throw(_("""Purchase Receipt Required for item {0} - To submit the invoice without purchase receipt please set - {1} as {2} in {3}""").format(frappe.bold(d.item_code), frappe.bold(_('Purchase Receipt Required')), - frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings'))) + msg = _("Purchase Receipt Required for item {}").format(frappe.bold(d.item_code)) + msg += "

    " + msg += _("To submit the invoice without purchase receipt please set {} ").format(frappe.bold(_('Purchase Receipt Required'))) + msg += _("as {} in {}").format(frappe.bold('No'), get_link_to_form('Buying Settings', 'Buying Settings', 'Buying Settings')) + throw(msg, title=_("Mandatory Purchase Receipt")) def validate_write_off_account(self): if self.write_off_amount and not self.write_off_account: @@ -401,10 +410,13 @@ class PurchaseInvoice(BuyingController): # this sequence because outstanding may get -negative self.make_gl_entries() + if self.update_stock == 1: + self.repost_future_sle_and_gle() + self.update_project() update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference) - def make_gl_entries(self, gl_entries=None): + def make_gl_entries(self, gl_entries=None, from_repost=False): if not gl_entries: gl_entries = self.get_gl_entries() @@ -412,7 +424,7 @@ class PurchaseInvoice(BuyingController): update_outstanding = "No" if (cint(self.is_paid) or self.write_off_account) else "Yes" if self.docstatus == 1: - make_gl_entries(gl_entries, update_outstanding=update_outstanding, merge_entries=False) + make_gl_entries(gl_entries, update_outstanding=update_outstanding, merge_entries=False, from_repost=from_repost) elif self.docstatus == 2: make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) @@ -427,9 +439,11 @@ class PurchaseInvoice(BuyingController): self.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(self.company) if self.auto_accounting_for_stock: self.stock_received_but_not_billed = self.get_company_default("stock_received_but_not_billed") + self.expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") else: self.stock_received_but_not_billed = None - self.expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") + self.expenses_included_in_valuation = None + self.negative_expense_to_be_booked = 0.0 gl_entries = [] @@ -440,6 +454,7 @@ class PurchaseInvoice(BuyingController): self.get_asset_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) + self.make_internal_transfer_gl_entries(gl_entries) gl_entries = make_regional_gl_entries(gl_entries, self) @@ -448,7 +463,6 @@ class PurchaseInvoice(BuyingController): self.make_payment_gl_entries(gl_entries) self.make_write_off_gl_entry(gl_entries) self.make_gle_for_rounding_adjustment(gl_entries) - return gl_entries def check_asset_cwip_enabled(self): @@ -465,31 +479,30 @@ class PurchaseInvoice(BuyingController): # because rounded_total had value even before introcution of posting GLE based on rounded total grand_total = self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total - if grand_total: - # Didnot use base_grand_total to book rounding loss gle - grand_total_in_company_currency = flt(grand_total * self.conversion_rate, - self.precision("grand_total")) - gl_entries.append( - self.get_gl_dict({ - "account": self.credit_to, - "party_type": "Supplier", - "party": self.supplier, - "due_date": self.due_date, - "against": self.against_expense_account, - "credit": grand_total_in_company_currency, - "credit_in_account_currency": grand_total_in_company_currency \ - if self.party_account_currency==self.company_currency else grand_total, - "against_voucher": self.return_against if cint(self.is_return) and self.return_against else self.name, - "against_voucher_type": self.doctype, - "project": self.project, - "cost_center": self.cost_center - }, self.party_account_currency, item=self) - ) + if grand_total and not self.is_internal_transfer(): + # Did not use base_grand_total to book rounding loss gle + grand_total_in_company_currency = flt(grand_total * self.conversion_rate, + self.precision("grand_total")) + gl_entries.append( + self.get_gl_dict({ + "account": self.credit_to, + "party_type": "Supplier", + "party": self.supplier, + "due_date": self.due_date, + "against": self.against_expense_account, + "credit": grand_total_in_company_currency, + "credit_in_account_currency": grand_total_in_company_currency \ + if self.party_account_currency==self.company_currency else grand_total, + "against_voucher": self.return_against if cint(self.is_return) and self.return_against else self.name, + "against_voucher_type": self.doctype, + "project": self.project, + "cost_center": self.cost_center + }, self.party_account_currency, item=self) + ) def make_item_gl_entries(self, gl_entries): # item gl entries stock_items = self.get_stock_items() - expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation") if self.update_stock and self.auto_accounting_for_stock: warehouse_account = get_warehouse_account_map(self.company) @@ -498,8 +511,8 @@ class PurchaseInvoice(BuyingController): voucher_wise_stock_value = {} if self.update_stock: for d in frappe.get_all('Stock Ledger Entry', - fields = ["voucher_detail_no", "stock_value_difference"], filters={'voucher_no': self.name}): - voucher_wise_stock_value.setdefault(d.voucher_detail_no, d.stock_value_difference) + fields = ["voucher_detail_no", "stock_value_difference", "warehouse"], filters={'voucher_no': self.name}): + voucher_wise_stock_value.setdefault((d.voucher_detail_no, d.warehouse), d.stock_value_difference) valuation_tax_accounts = [d.account_head for d in self.get("taxes") if d.category in ('Valuation', 'Total and Valuation') @@ -517,7 +530,6 @@ class PurchaseInvoice(BuyingController): item, voucher_wise_stock_value, account_currency) if item.from_warehouse: - gl_entries.append(self.get_gl_dict({ "account": warehouse_account[item.warehouse]['account'], "against": warehouse_account[item.from_warehouse]["account"], @@ -537,28 +549,31 @@ class PurchaseInvoice(BuyingController): "debit": -1 * flt(item.base_net_amount, item.precision("base_net_amount")), }, warehouse_account[item.from_warehouse]["account_currency"], item=item)) - gl_entries.append( - self.get_gl_dict({ - "account": item.expense_account, - "against": self.supplier, - "debit": flt(item.base_net_amount, item.precision("base_net_amount")), - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "cost_center": item.cost_center, - "project": item.project - }, account_currency, item=item) - ) + # Do not book expense for transfer within same company transfer + if not self.is_internal_transfer(): + gl_entries.append( + self.get_gl_dict({ + "account": item.expense_account, + "against": self.supplier, + "debit": flt(item.base_net_amount, item.precision("base_net_amount")), + "remarks": self.get("remarks") or _("Accounting Entry for Stock"), + "cost_center": item.cost_center, + "project": item.project + }, account_currency, item=item) + ) else: - gl_entries.append( - self.get_gl_dict({ - "account": item.expense_account, - "against": self.supplier, - "debit": warehouse_debit_amount, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "cost_center": item.cost_center, - "project": item.project or self.project - }, account_currency, item=item) - ) + if not self.is_internal_transfer(): + gl_entries.append( + self.get_gl_dict({ + "account": item.expense_account, + "against": self.supplier, + "debit": warehouse_debit_amount, + "remarks": self.get("remarks") or _("Accounting Entry for Stock"), + "cost_center": item.cost_center, + "project": item.project or self.project + }, account_currency, item=item) + ) # Amount added through landed-cost-voucher if landed_cost_entries: @@ -568,7 +583,8 @@ class PurchaseInvoice(BuyingController): "against": item.expense_account, "cost_center": item.cost_center, "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": flt(amount), + "credit": flt(amount["base_amount"]), + "credit_in_account_currency": flt(amount["amount"]), "project": item.project or self.project }, item=item)) @@ -610,13 +626,14 @@ class PurchaseInvoice(BuyingController): if expense_booked_in_pr: expense_account = service_received_but_not_billed_account - gl_entries.append(self.get_gl_dict({ - "account": expense_account, - "against": self.supplier, - "debit": amount, - "cost_center": item.cost_center, - "project": item.project or self.project - }, account_currency, item=item)) + if not self.is_internal_transfer(): + gl_entries.append(self.get_gl_dict({ + "account": expense_account, + "against": self.supplier, + "debit": amount, + "cost_center": item.cost_center, + "project": item.project or self.project + }, account_currency, item=item)) # If asset is bought through this document and not linked to PR if self.update_stock and item.landed_cost_voucher_amount: @@ -711,7 +728,8 @@ class PurchaseInvoice(BuyingController): item.item_tax_amount / self.conversion_rate) }, item=item)) else: - cwip_account = get_asset_account("capital_work_in_progress_account", company = self.company) + cwip_account = get_asset_account("capital_work_in_progress_account", + asset_category=item.asset_category,company=self.company) cwip_account_currency = get_account_currency(cwip_account) gl_entries.append(self.get_gl_dict({ @@ -780,10 +798,10 @@ class PurchaseInvoice(BuyingController): # Stock ledger value is not matching with the warehouse amount if (self.update_stock and voucher_wise_stock_value.get(item.name) and - warehouse_debit_amount != flt(voucher_wise_stock_value.get(item.name), net_amt_precision)): + warehouse_debit_amount != flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)): cost_of_goods_sold_account = self.get_company_default("default_expense_account") - stock_amount = flt(voucher_wise_stock_value.get(item.name), net_amt_precision) + stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision) stock_adjustment_amt = warehouse_debit_amount - stock_amount gl_entries.append( @@ -822,7 +840,8 @@ class PurchaseInvoice(BuyingController): }, account_currency, item=tax) ) # accumulate valuation tax - if self.is_opening == "No" and tax.category in ("Valuation", "Valuation and Total") and flt(tax.base_tax_amount_after_discount_amount): + if self.is_opening == "No" and tax.category in ("Valuation", "Valuation and Total") and flt(tax.base_tax_amount_after_discount_amount) \ + and not self.is_internal_transfer(): if self.auto_accounting_for_stock and not tax.cost_center: frappe.throw(_("Cost Center is required in row {0} in Taxes table for type {1}").format(tax.idx, _(tax.category))) valuation_tax.setdefault(tax.name, 0) @@ -866,8 +885,19 @@ class PurchaseInvoice(BuyingController): "against": self.supplier, "credit": valuation_tax[tax.name], "remarks": self.remarks or "Accounting Entry for Stock" - }, item=tax) - ) + }, item=tax)) + + def make_internal_transfer_gl_entries(self, gl_entries): + if self.is_internal_transfer() and flt(self.base_total_taxes_and_charges): + account_currency = get_account_currency(self.unrealized_profit_loss_account) + gl_entries.append( + self.get_gl_dict({ + "account": self.unrealized_profit_loss_account, + "against": self.supplier, + "credit": flt(self.total_taxes_and_charges), + "credit_in_account_currency": flt(self.base_total_taxes_and_charges), + "cost_center": self.cost_center + }, account_currency, item=self)) def make_payment_gl_entries(self, gl_entries): # Make Cash GL Entries @@ -938,7 +968,7 @@ class PurchaseInvoice(BuyingController): # base_rounding_adjustment may become zero due to small precision # eg: rounding_adjustment = 0.01 and exchange rate = 0.05 and precision of base_rounding_adjustment is 2 # then base_rounding_adjustment becomes zero and error is thrown in GL Entry - if self.rounding_adjustment and self.base_rounding_adjustment: + if not self.is_internal_transfer() and self.rounding_adjustment and self.base_rounding_adjustment: round_off_account, round_off_cost_center = \ get_round_off_account_and_cost_center(self.company) @@ -972,11 +1002,15 @@ class PurchaseInvoice(BuyingController): self.delete_auto_created_batches() self.make_gl_entries_on_cancel() + + if self.update_stock == 1: + self.repost_future_sle_and_gle() + self.update_project() frappe.db.set(self, 'status', 'Cancelled') unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference) - self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry') + self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry', 'Repost Item Valuation') def update_project(self): project_list = [] @@ -1027,7 +1061,9 @@ class PurchaseInvoice(BuyingController): updated_pr += update_billed_amount_based_on_po(d.po_detail, update_modified) for pr in set(updated_pr): - frappe.get_doc("Purchase Receipt", pr).update_billing_percentage(update_modified=update_modified) + 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) def on_recurring(self, reference_doc, auto_repeat_doc): self.due_date = None @@ -1083,7 +1119,9 @@ class PurchaseInvoice(BuyingController): if self.docstatus == 2: status = "Cancelled" elif self.docstatus == 1: - if outstanding_amount > 0 and due_date < nowdate: + if self.is_internal_transfer(): + self.status = 'Internal Transfer' + elif outstanding_amount > 0 and due_date < nowdate: self.status = "Overdue" elif outstanding_amount > 0 and due_date >= nowdate: self.status = "Unpaid" diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js index 86c2e408c0b..914a2457d45 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js @@ -4,23 +4,25 @@ // render frappe.listview_settings['Purchase Invoice'] = { add_fields: ["supplier", "supplier_name", "base_grand_total", "outstanding_amount", "due_date", "company", - "currency", "is_return", "release_date", "on_hold"], + "currency", "is_return", "release_date", "on_hold", "represents_company", "is_internal_supplier"], get_indicator: function(doc) { - if( (flt(doc.outstanding_amount) <= 0) && doc.docstatus == 1 && doc.status == 'Debit Note Issued') { + if ((flt(doc.outstanding_amount) <= 0) && doc.docstatus == 1 && doc.status == 'Debit Note Issued') { return [__("Debit Note Issued"), "darkgrey", "outstanding_amount,<=,0"]; - } else if(flt(doc.outstanding_amount) > 0 && doc.docstatus==1) { + } else if (flt(doc.outstanding_amount) > 0 && doc.docstatus==1) { if(cint(doc.on_hold) && !doc.release_date) { return [__("On Hold"), "darkgrey"]; - } else if(cint(doc.on_hold) && doc.release_date && frappe.datetime.get_diff(doc.release_date, frappe.datetime.nowdate()) > 0) { + } else if (cint(doc.on_hold) && doc.release_date && frappe.datetime.get_diff(doc.release_date, frappe.datetime.nowdate()) > 0) { return [__("Temporarily on Hold"), "darkgrey"]; - } else if(frappe.datetime.get_diff(doc.due_date) < 0) { + } else if (frappe.datetime.get_diff(doc.due_date) < 0) { return [__("Overdue"), "red", "outstanding_amount,>,0|due_date,<,Today"]; } else { return [__("Unpaid"), "orange", "outstanding_amount,>,0|due_date,>=,Today"]; } - } else if(cint(doc.is_return)) { - return [__("Return"), "darkgrey", "is_return,=,Yes"]; - } else if(flt(doc.outstanding_amount)==0 && doc.docstatus==1) { + } else if (cint(doc.is_return)) { + return [__("Return"), "gray", "is_return,=,Yes"]; + } else if (doc.company == doc.represents_company && doc.is_internal_supplier) { + return [__("Internal Transfer"), "darkgrey", "outstanding_amount,=,0"]; + } else if (flt(doc.outstanding_amount)==0 && doc.docstatus==1) { return [__("Paid"), "green", "outstanding_amount,=,0"]; } } diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 9a666bf9f8d..50492f50b51 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -9,8 +9,7 @@ import frappe.model from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from frappe.utils import cint, flt, today, nowdate, add_days, getdate import frappe.defaults -from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_perpetual_inventory, \ - test_records as pr_test_records, make_purchase_receipt, get_taxes +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt, get_taxes from erpnext.controllers.accounts_controller import get_payment_terms from erpnext.exceptions import InvalidCurrency from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction @@ -33,13 +32,10 @@ class TestPurchaseInvoice(unittest.TestCase): def test_gl_entries_without_perpetual_inventory(self): frappe.db.set_value("Company", "_Test Company", "round_off_account", "Round Off - _TC") - wrapper = frappe.copy_doc(test_records[0]) - set_perpetual_inventory(0, wrapper.company) - self.assertTrue(not cint(erpnext.is_perpetual_inventory_enabled(wrapper.company))) - wrapper.insert() - wrapper.submit() - wrapper.load_from_db() - dl = wrapper + pi = frappe.copy_doc(test_records[0]) + self.assertTrue(not cint(erpnext.is_perpetual_inventory_enabled(pi.company))) + pi.insert() + pi.submit() expected_gl_entries = { "_Test Payable - _TC": [0, 1512.0], @@ -54,12 +50,16 @@ class TestPurchaseInvoice(unittest.TestCase): "Round Off - _TC": [0, 0.3] } gl_entries = frappe.db.sql("""select account, debit, credit from `tabGL Entry` - where voucher_type = 'Purchase Invoice' and voucher_no = %s""", dl.name, as_dict=1) + where voucher_type = 'Purchase Invoice' and voucher_no = %s""", pi.name, as_dict=1) for d in gl_entries: self.assertEqual([d.debit, d.credit], expected_gl_entries.get(d.account)) def test_gl_entries_with_perpetual_inventory(self): - pi = make_purchase_invoice(company="_Test Company with perpetual inventory", supplier_warehouse="Work In Progress - TCP1", warehouse= "Stores - TCP1", cost_center = "Main - TCP1", expense_account ="_Test Account Cost for Goods Sold - TCP1", get_taxes_and_charges=True, qty=10) + pi = make_purchase_invoice(company="_Test Company with perpetual inventory", + warehouse= "Stores - TCP1", cost_center = "Main - TCP1", + expense_account ="_Test Account Cost for Goods Sold - TCP1", + get_taxes_and_charges=True, qty=10) + self.assertTrue(cint(erpnext.is_perpetual_inventory_enabled(pi.company)), 1) self.check_gle_for_pi(pi.name) @@ -198,8 +198,6 @@ class TestPurchaseInvoice(unittest.TestCase): pr = make_purchase_receipt(company="_Test Company with perpetual inventory", supplier_warehouse="Work In Progress - TCP1", warehouse= "Stores - TCP1", cost_center = "Main - TCP1", get_taxes_and_charges=True,) - self.assertTrue(cint(erpnext.is_perpetual_inventory_enabled(pr.company)), 1) - pi = make_purchase_invoice(company="_Test Company with perpetual inventory", supplier_warehouse="Work In Progress - TCP1", warehouse= "Stores - TCP1", cost_center = "Main - TCP1", expense_account ="_Test Account Cost for Goods Sold - TCP1", get_taxes_and_charges=True, qty=10,do_not_save= "True") for d in pi.items: @@ -247,17 +245,11 @@ class TestPurchaseInvoice(unittest.TestCase): self.assertRaises(frappe.CannotChangeConstantError, pi.save) - def test_gl_entries_with_aia_for_non_stock_items(self): - pi = frappe.copy_doc(test_records[1]) - set_perpetual_inventory(1, pi.company) - self.assertTrue(cint(erpnext.is_perpetual_inventory_enabled(pi.company)), 1) - pi.get("items")[0].item_code = "_Test Non Stock Item" - pi.get("items")[0].expense_account = "_Test Account Cost for Goods Sold - _TC" - pi.get("taxes").pop(0) - pi.get("taxes").pop(1) - pi.insert() - pi.submit() - pi.load_from_db() + def test_gl_entries_for_non_stock_items_with_perpetual_inventory(self): + pi = make_purchase_invoice(item_code = "_Test Non Stock Item", + company = "_Test Company with perpetual inventory", warehouse= "Stores - TCP1", + cost_center = "Main - TCP1", expense_account ="_Test Account Cost for Goods Sold - TCP1") + self.assertTrue(pi.status, "Unpaid") gl_entries = frappe.db.sql("""select account, debit, credit @@ -265,17 +257,15 @@ class TestPurchaseInvoice(unittest.TestCase): order by account asc""", pi.name, as_dict=1) self.assertTrue(gl_entries) - expected_values = sorted([ - ["_Test Payable - _TC", 0, 620], - ["_Test Account Cost for Goods Sold - _TC", 500.0, 0], - ["_Test Account VAT - _TC", 120.0, 0], - ]) + expected_values = [ + ["_Test Account Cost for Goods Sold - TCP1", 250.0, 0], + ["Creditors - TCP1", 0, 250] + ] for i, gle in enumerate(gl_entries): self.assertEqual(expected_values[i][0], gle.account) self.assertEqual(expected_values[i][1], gle.debit) self.assertEqual(expected_values[i][2], gle.credit) - set_perpetual_inventory(0, pi.company) def test_purchase_invoice_calculation(self): pi = frappe.copy_doc(test_records[0]) @@ -436,33 +426,39 @@ class TestPurchaseInvoice(unittest.TestCase): ) def test_total_purchase_cost_for_project(self): - make_project({'project_name':'_Test Project'}) + if not frappe.db.exists("Project", {"project_name": "_Test Project for Purchase"}): + project = make_project({'project_name':'_Test Project for Purchase'}) + else: + project = frappe.get_doc("Project", {"project_name": "_Test Project for Purchase"}) existing_purchase_cost = frappe.db.sql("""select sum(base_net_amount) - from `tabPurchase Invoice Item` where project = '_Test Project' and docstatus=1""") + from `tabPurchase Invoice Item` + where project = '{0}' + and docstatus=1""".format(project.name)) existing_purchase_cost = existing_purchase_cost and existing_purchase_cost[0][0] or 0 - pi = make_purchase_invoice(currency="USD", conversion_rate=60, project="_Test Project") - self.assertEqual(frappe.db.get_value("Project", "_Test Project", "total_purchase_cost"), + pi = make_purchase_invoice(currency="USD", conversion_rate=60, project=project.name) + self.assertEqual(frappe.db.get_value("Project", project.name, "total_purchase_cost"), existing_purchase_cost + 15000) - pi1 = make_purchase_invoice(qty=10, project="_Test Project") - self.assertEqual(frappe.db.get_value("Project", "_Test Project", "total_purchase_cost"), + pi1 = make_purchase_invoice(qty=10, project=project.name) + self.assertEqual(frappe.db.get_value("Project", project.name, "total_purchase_cost"), existing_purchase_cost + 15500) pi1.cancel() - self.assertEqual(frappe.db.get_value("Project", "_Test Project", "total_purchase_cost"), + self.assertEqual(frappe.db.get_value("Project", project.name, "total_purchase_cost"), existing_purchase_cost + 15000) pi.cancel() - self.assertEqual(frappe.db.get_value("Project", "_Test Project", "total_purchase_cost"), existing_purchase_cost) + self.assertEqual(frappe.db.get_value("Project", project.name, "total_purchase_cost"), existing_purchase_cost) - def test_return_purchase_invoice(self): - set_perpetual_inventory() + def test_return_purchase_invoice_with_perpetual_inventory(self): + pi = make_purchase_invoice(company = "_Test Company with perpetual inventory", warehouse= "Stores - TCP1", + cost_center = "Main - TCP1", expense_account ="_Test Account Cost for Goods Sold - TCP1") - pi = make_purchase_invoice() - - return_pi = make_purchase_invoice(is_return=1, return_against=pi.name, qty=-2) + return_pi = make_purchase_invoice(is_return=1, return_against=pi.name, qty=-2, + company = "_Test Company with perpetual inventory", warehouse= "Stores - TCP1", + cost_center = "Main - TCP1", expense_account ="_Test Account Cost for Goods Sold - TCP1") # check gl entries for return @@ -473,19 +469,15 @@ class TestPurchaseInvoice(unittest.TestCase): self.assertTrue(gl_entries) expected_values = { - "Creditors - _TC": [100.0, 0.0], - "Stock Received But Not Billed - _TC": [0.0, 100.0], + "Creditors - TCP1": [100.0, 0.0], + "Stock Received But Not Billed - TCP1": [0.0, 100.0], } for gle in gl_entries: self.assertEqual(expected_values[gle.account][0], gle.debit) self.assertEqual(expected_values[gle.account][1], gle.credit) - set_perpetual_inventory(0) - def test_multi_currency_gle(self): - set_perpetual_inventory(0) - pi = make_purchase_invoice(supplier="_Test Supplier USD", credit_to="_Test Payable USD - _TC", currency="USD", conversion_rate=50) @@ -640,10 +632,9 @@ class TestPurchaseInvoice(unittest.TestCase): self.assertEqual(len(pi.get("supplied_items")), 2) rm_supp_cost = sum([d.amount for d in pi.get("supplied_items")]) - self.assertEqual(pi.get("items")[0].rm_supp_cost, flt(rm_supp_cost, 2)) + self.assertEqual(flt(pi.get("items")[0].rm_supp_cost, 2), flt(rm_supp_cost, 2)) def test_rejected_serial_no(self): - set_perpetual_inventory(0) pi = make_purchase_invoice(item_code="_Test Serialized Item With Series", received_qty=2, qty=1, rejected_qty=1, rate=500, update_stock=1, rejected_warehouse = "_Test Rejected Warehouse - _TC") @@ -874,17 +865,17 @@ class TestPurchaseInvoice(unittest.TestCase): }) pi = make_purchase_invoice(credit_to="Creditors - _TC" ,do_not_save=1) - pi.items[0].project = item_project.project_name - pi.project = project.project_name + pi.items[0].project = item_project.name + pi.project = project.name pi.submit() expected_values = { "Creditors - _TC": { - "project": project.project_name + "project": project.name }, "_Test Account Cost for Goods Sold - _TC": { - "project": item_project.project_name + "project": item_project.name } } @@ -907,7 +898,7 @@ class TestPurchaseInvoice(unittest.TestCase): acc_settings.submit_journal_entries = 1 acc_settings.save() - item = create_item("_Test Item for Deferred Accounting") + item = create_item("_Test Item for Deferred Accounting", is_purchase_item=True) item.enable_deferred_expense = 1 item.deferred_expense_account = deferred_account item.save() @@ -998,11 +989,12 @@ def make_purchase_invoice(**args): 'expense_account': args.expense_account or '_Test Account Cost for Goods Sold - _TC', "conversion_factor": 1.0, "serial_no": args.serial_no, - "stock_uom": "_Test UOM", + "stock_uom": args.uom or "_Test UOM", "cost_center": args.cost_center or "_Test Cost Center - _TC", "project": args.project, "rejected_warehouse": args.rejected_warehouse or "", - "rejected_serial_no": args.rejected_serial_no or "" + "rejected_serial_no": args.rejected_serial_no or "", + "asset_location": args.location or "" }) if args.get_taxes_and_charges: @@ -1039,7 +1031,8 @@ def make_purchase_invoice_against_cost_center(**args): pi.is_return = args.is_return pi.credit_to = args.return_against or "Creditors - _TC" pi.is_subcontracted = args.is_subcontracted or "No" - pi.supplier_warehouse = "_Test Warehouse 1 - _TC" + if args.supplier_warehouse: + pi.supplier_warehouse = "_Test Warehouse 1 - _TC" pi.append("items", { "item_code": args.item or args.item_code or "_Test Item", diff --git a/erpnext/accounts/doctype/purchase_invoice/test_records.json b/erpnext/accounts/doctype/purchase_invoice/test_records.json index 7030faf2b73..e7166c5a12d 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_records.json +++ b/erpnext/accounts/doctype/purchase_invoice/test_records.json @@ -18,7 +18,7 @@ "expense_account": "_Test Account Cost for Goods Sold - _TC", "item_code": "_Test Item Home Desktop 100", "item_name": "_Test Item Home Desktop 100", - "item_tax_template": "_Test Account Excise Duty @ 10", + "item_tax_template": "_Test Account Excise Duty @ 10 - _TC", "parentfield": "items", "qty": 10, "rate": 50, diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index f6d76e50502..96ad0fd7852 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "hash", "creation": "2013-05-22 12:43:10", "doctype": "DocType", @@ -27,10 +28,16 @@ "stock_qty", "sec_break1", "price_list_rate", - "discount_percentage", - "discount_amount", "col_break3", "base_price_list_rate", + "section_break_26", + "margin_type", + "margin_rate_or_amount", + "rate_with_margin", + "column_break_30", + "discount_percentage", + "discount_amount", + "base_rate_with_margin", "sec_break2", "rate", "amount", @@ -39,6 +46,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_22", "net_rate", @@ -87,6 +95,7 @@ "po_detail", "purchase_receipt", "pr_detail", + "sales_invoice_item", "item_weight_details", "weight_per_unit", "total_weight", @@ -553,8 +562,8 @@ "fieldtype": "Link", "hidden": 1, "label": "Brand", - "print_hide": 1, - "options": "Brand" + "options": "Brand", + "print_hide": 1 }, { "fetch_from": "item_code.item_group", @@ -562,9 +571,9 @@ "fieldname": "item_group", "fieldtype": "Link", "label": "Item Group", + "options": "Item Group", "print_hide": 1, - "read_only": 1, - "options": "Item Group" + "read_only": 1 }, { "description": "Tax detail table fetched from item master as a string and stored in this field.\nUsed for Taxes and Charges", @@ -759,10 +768,11 @@ "read_only": 1 }, { + "depends_on": "eval:parent.is_internal_supplier && parent.update_stock", "fieldname": "from_warehouse", "fieldtype": "Link", "ignore_user_permissions": 1, - "label": "Supplier Warehouse", + "label": "From Warehouse", "options": "Warehouse" }, { @@ -779,11 +789,71 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "no_copy": 1, + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "sales_invoice_item", + "fieldtype": "Data", + "label": "Sales Invoice Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_26", + "fieldtype": "Section Break", + "label": "Discount and Margin" + }, + { + "depends_on": "price_list_rate", + "fieldname": "margin_type", + "fieldtype": "Select", + "label": "Margin Type", + "options": "\nPercentage\nAmount", + "print_hide": 1 + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate", + "fieldname": "margin_rate_or_amount", + "fieldtype": "Float", + "label": "Margin Rate or Amount", + "print_hide": 1 + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount", + "fieldname": "rate_with_margin", + "fieldtype": "Currency", + "label": "Rate With Margin", + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "column_break_30", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount", + "fieldname": "base_rate_with_margin", + "fieldtype": "Currency", + "label": "Rate With Margin (Company Currency)", + "options": "Company:company:default_currency", + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, - "modified": "2020-08-20 11:48:01.398356", + "links": [], + "modified": "2021-02-23 00:59:52.614805", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Item", @@ -791,4 +861,4 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC" -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py index 56576df0791..50ec7d8b4d8 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py @@ -6,8 +6,5 @@ import frappe from frappe.model.document import Document -from erpnext.controllers.print_settings import print_settings_for_item_table - class PurchaseInvoiceItem(Document): - def __setup__(self): - print_settings_for_item_table(self) + pass diff --git a/erpnext/accounts/doctype/salary_component_account/salary_component_account.json b/erpnext/accounts/doctype/salary_component_account/salary_component_account.json index 23dc6c47e8d..f1ed8efa319 100644 --- a/erpnext/accounts/doctype/salary_component_account/salary_component_account.json +++ b/erpnext/accounts/doctype/salary_component_account/salary_component_account.json @@ -1,92 +1,38 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-07-27 17:24:24.956896", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, + "actions": [], + "creation": "2016-07-27 17:24:24.956896", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "account" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Company", - "length": 0, - "no_copy": 0, - "options": "Company", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "Default Bank / Cash account will be automatically updated in Salary Journal Entry when this mode is selected.", - "fieldname": "default_account", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Default Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "description": "Default Bank / Cash account will be automatically updated in Salary Journal Entry when this mode is selected.", + "fieldname": "account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Account", + "options": "Account" } - ], - "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": "2016-09-02 07:49:06.567389", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Salary Component Account", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_seen": 0 + ], + "istable": 1, + "links": [], + "modified": "2020-10-18 17:57:57.110257", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Salary Component Account", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/sales_invoice/regional/india.js b/erpnext/accounts/doctype/sales_invoice/regional/india.js index 6336db16ebc..f54bce8aac7 100644 --- a/erpnext/accounts/doctype/sales_invoice/regional/india.js +++ b/erpnext/accounts/doctype/sales_invoice/regional/india.js @@ -1,6 +1,8 @@ {% include "erpnext/regional/india/taxes.js" %} +{% include "erpnext/regional/india/e_invoice/einvoice.js" %} erpnext.setup_auto_gst_taxation('Sales Invoice'); +erpnext.setup_einvoice_actions('Sales Invoice') frappe.ui.form.on("Sales Invoice", { setup: function(frm) { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 9af584e0b17..b361c0c3457 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -5,18 +5,22 @@ cur_frm.pformat.print_heading = 'Invoice'; {% include 'erpnext/selling/sales_common.js' %}; - - frappe.provide("erpnext.accounts"); + + erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.extend({ setup: function(doc) { this.setup_posting_date_time_check(); this._super(doc); }, + company: function() { + erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); + }, onload: function() { var me = this; this._super(); + this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice']; if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { // show debit_to in print format this.frm.set_df_property("debit_to", "print_hide", 0); @@ -33,6 +37,7 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte me.frm.refresh_fields(); } erpnext.queries.setup_warehouse_query(this.frm); + erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype); }, refresh: function(doc, dt, dn) { @@ -126,16 +131,15 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte this.set_default_print_format(); if (doc.docstatus == 1 && !doc.inter_company_invoice_reference) { - frappe.model.with_doc("Customer", me.frm.doc.customer, function() { - var customer = frappe.model.get_doc("Customer", me.frm.doc.customer); - var internal = customer.is_internal_customer; - var disabled = customer.disabled; - if (internal == 1 && disabled == 0) { - me.frm.add_custom_button("Inter Company Invoice", function() { - me.make_inter_company_invoice(); - }, __('Create')); - } - }); + let internal = me.frm.doc.is_internal_customer; + if (internal) { + let button_label = (me.frm.doc.company === me.frm.doc.represents_company) ? "Internal Purchase Invoice" : + "Inter Company Purchase Invoice"; + + me.frm.add_custom_button(button_label, function() { + me.make_inter_company_invoice(); + }, __('Create')); + } } }, @@ -199,7 +203,7 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte company: me.frm.doc.company } }) - }, __("Get items from")); + }, __("Get Items From")); }, quotation_btn: function() { @@ -223,7 +227,7 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte company: me.frm.doc.company } }) - }, __("Get items from")); + }, __("Get Items From")); }, delivery_note_btn: function() { @@ -251,7 +255,7 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte }; } }); - }, __("Get items from")); + }, __("Get Items From")); }, tc_name: function() { @@ -571,18 +575,19 @@ frappe.ui.form.on('Sales Invoice', { }; }); - frm.set_query("cost_center", function() { + frm.set_query("unrealized_profit_loss_account", function() { return { filters: { company: frm.doc.company, - is_group: 0 + is_group: 0, + root_type: "Liability", } }; }); frm.custom_make_buttons = { 'Delivery Note': 'Delivery', - 'Sales Invoice': 'Sales Return', + 'Sales Invoice': 'Return / Credit Note', 'Payment Request': 'Payment Request', 'Payment Entry': 'Payment' }, @@ -664,12 +669,12 @@ frappe.ui.form.on('Sales Invoice', { }; }, // When multiple companies are set up. in case company name is changed set default company address - company:function(frm){ - if (frm.doc.company) - { + company: function(frm){ + if (frm.doc.company) { frappe.call({ - method:"erpnext.setup.doctype.company.company.get_default_company_address", - args:{name:frm.doc.company, existing_address: frm.doc.company_address}, + method: "erpnext.setup.doctype.company.company.get_default_company_address", + args: {name:frm.doc.company, existing_address: frm.doc.company_address || ""}, + debounce: 2000, callback: function(r){ if (r.message){ frm.set_value("company_address",r.message) @@ -690,6 +695,7 @@ frappe.ui.form.on('Sales Invoice', { refresh_field(['timesheets']) } }) + frm.refresh(); }, onload: function(frm) { @@ -805,6 +811,65 @@ frappe.ui.form.on('Sales Invoice', { }, refresh: function(frm) { + if (frm.doc.project) { + frm.add_custom_button(__('Fetch Timesheet'), function() { + let d = new frappe.ui.Dialog({ + title: __('Fetch Timesheet'), + fields: [ + { + "label" : "From", + "fieldname": "from_time", + "fieldtype": "Date", + "reqd": 1, + }, + { + fieldtype: 'Column Break', + fieldname: 'col_break_1', + }, + { + "label" : "To", + "fieldname": "to_time", + "fieldtype": "Date", + "reqd": 1, + } + ], + primary_action: function() { + let data = d.get_values(); + frappe.call({ + method: "erpnext.projects.doctype.timesheet.timesheet.get_projectwise_timesheet_data", + args: { + from_time: data.from_time, + to_time: data.to_time, + project: frm.doc.project + }, + callback: function(r) { + if(!r.exc) { + if(r.message.length > 0) { + frm.clear_table('timesheets') + r.message.forEach((d) => { + frm.add_child('timesheets',{ + 'time_sheet': d.parent, + 'billing_hours': d.billing_hours, + 'billing_amount': d.billing_amt, + 'timesheet_detail': d.name + }); + }); + frm.refresh_field('timesheets') + } + else { + frappe.msgprint(__('No Timesheet Found.')) + } + d.hide(); + } + } + }); + }, + primary_action_label: __('Get Timesheets') + }); + d.show(); + }) + } + if (frappe.boot.active_domains.includes("Healthcare")) { frm.set_df_property("patient", "hidden", 0); frm.set_df_property("patient_name", "hidden", 0); @@ -812,10 +877,10 @@ frappe.ui.form.on('Sales Invoice', { if (cint(frm.doc.docstatus==0) && cur_frm.page.current_view_name!=="pos" && !frm.doc.is_return) { frm.add_custom_button(__('Healthcare Services'), function() { get_healthcare_services_to_invoice(frm); - },"Get items from"); + },"Get Items From"); frm.add_custom_button(__('Prescriptions'), function() { get_drugs_to_invoice(frm); - },"Get items from"); + },"Get Items From"); } } else { @@ -1080,7 +1145,7 @@ var get_drugs_to_invoice = function(frm) { description:'Quantity will be calculated only for items which has "Nos" as UoM. You may change as required for each invoice item.', get_query: function(doc) { return { - filters: { + filters: { patient: dialog.get_value("patient"), company: frm.doc.company, docstatus: 1 diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index 2397b7d0cb4..720a9175e66 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -12,22 +12,20 @@ "customer", "customer_name", "tax_id", + "pos_profile", "is_pos", "is_consolidated", - "pos_profile", - "offline_pos_name", "is_return", + "update_billed_amount_in_sales_order", "column_break1", "company", + "company_tax_id", "posting_date", "posting_time", "set_posting_time", "due_date", - "amended_from", - "returns", "return_against", - "column_break_21", - "update_billed_amount_in_sales_order", + "amended_from", "accounting_dimensions_section", "project", "dimension_col_break", @@ -59,6 +57,8 @@ "ignore_pricing_rule", "sec_warehouse", "set_warehouse", + "column_break_55", + "set_target_warehouse", "items_section", "update_stock", "scan_barcode", @@ -156,6 +156,7 @@ "more_information", "inter_company_invoice_reference", "is_internal_customer", + "represents_company", "customer_group", "campaign", "is_discounted", @@ -169,6 +170,7 @@ "c_form_applicable", "c_form_no", "column_break8", + "unrealized_profit_loss_account", "remarks", "sales_team_section_break", "sales_partner", @@ -183,8 +185,7 @@ "column_break_140", "auto_repeat", "update_auto_repeat_reference", - "against_income_account", - "pos_total_qty" + "against_income_account" ], "fields": [ { @@ -291,16 +292,6 @@ "options": "POS Profile", "print_hide": 1 }, - { - "fieldname": "offline_pos_name", - "fieldtype": "Data", - "hidden": 1, - "hide_days": 1, - "hide_seconds": 1, - "label": "Offline POS Name", - "print_hide": 1, - "read_only": 1 - }, { "default": "0", "fieldname": "is_return", @@ -399,33 +390,19 @@ "print_hide": 1, "read_only": 1 }, - { - "depends_on": "return_against", - "fieldname": "returns", - "fieldtype": "Section Break", - "hide_days": 1, - "hide_seconds": 1, - "label": "Returns" - }, { "depends_on": "return_against", "fieldname": "return_against", "fieldtype": "Link", "hide_days": 1, "hide_seconds": 1, - "label": "Return Against Sales Invoice", + "label": "Return Against", "no_copy": 1, "options": "Sales Invoice", "print_hide": 1, "read_only": 1, "search_index": 1 }, - { - "fieldname": "column_break_21", - "fieldtype": "Column Break", - "hide_days": 1, - "hide_seconds": 1 - }, { "default": "0", "depends_on": "eval: doc.is_return && doc.return_against", @@ -673,7 +650,8 @@ "fieldname": "sec_warehouse", "fieldtype": "Section Break", "hide_days": 1, - "hide_seconds": 1 + "hide_seconds": 1, + "label": "Warehouse" }, { "depends_on": "update_stock", @@ -681,7 +659,7 @@ "fieldtype": "Link", "hide_days": 1, "hide_seconds": 1, - "label": "Set Source Warehouse", + "label": "Source Warehouse", "options": "Warehouse", "print_hide": 1 }, @@ -690,6 +668,7 @@ "fieldtype": "Section Break", "hide_days": 1, "hide_seconds": 1, + "label": "Items", "oldfieldtype": "Section Break", "options": "fa fa-shopping-cart" }, @@ -1653,7 +1632,7 @@ "in_standard_filter": 1, "label": "Status", "no_copy": 1, - "options": "\nDraft\nReturn\nCredit Note Issued\nSubmitted\nPaid\nUnpaid\nUnpaid and Discounted\nOverdue and Discounted\nOverdue\nCancelled", + "options": "\nDraft\nReturn\nCredit Note Issued\nSubmitted\nPaid\nUnpaid\nUnpaid and Discounted\nOverdue and Discounted\nOverdue\nCancelled\nInternal Transfer", "print_hide": 1, "read_only": 1 }, @@ -1825,7 +1804,7 @@ "fieldtype": "Table", "hide_days": 1, "hide_seconds": 1, - "label": "Sales Team1", + "label": "Sales Contributions and Incentives", "oldfieldname": "sales_team", "oldfieldtype": "Table", "options": "Sales Team", @@ -1899,17 +1878,6 @@ "print_hide": 1, "report_hide": 1 }, - { - "fieldname": "pos_total_qty", - "fieldtype": "Float", - "hidden": 1, - "hide_days": 1, - "hide_seconds": 1, - "label": "Total Qty", - "print_hide": 1, - "print_hide_if_no_value": 1, - "read_only": 1 - }, { "collapsible": 1, "fieldname": "accounting_dimensions_section", @@ -1926,6 +1894,7 @@ }, { "default": "0", + "depends_on": "eval:(doc.is_pos && doc.is_consolidated)", "fieldname": "is_consolidated", "fieldtype": "Check", "label": "Is Consolidated", @@ -1940,13 +1909,56 @@ "hide_seconds": 1, "label": "Is Internal Customer", "read_only": 1 + }, + { + "fetch_from": "company.tax_id", + "fieldname": "company_tax_id", + "fieldtype": "Data", + "label": "Company Tax ID", + "read_only": 1 + }, + { + "depends_on": "eval:doc.is_internal_customer", + "description": "Unrealized Profit / Loss account for intra-company transfers", + "fieldname": "unrealized_profit_loss_account", + "fieldtype": "Link", + "label": "Unrealized Profit / Loss Account", + "options": "Account" + }, + { + "depends_on": "eval:doc.is_internal_customer", + "description": "Company which internal customer represents", + "fetch_from": "customer.represents_company", + "fieldname": "represents_company", + "fieldtype": "Link", + "label": "Represents Company", + "options": "Company", + "read_only": 1 + }, + { + "fieldname": "column_break_55", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.is_internal_customer && doc.update_stock", + "fieldname": "set_target_warehouse", + "fieldtype": "Link", + "label": "Set Target Warehouse", + "options": "Warehouse" } ], "icon": "fa fa-file-text", "idx": 181, "is_submittable": 1, - "links": [], - "modified": "2020-08-27 01:56:28.532140", + "links": [ + { + "custom": 1, + "group": "Reference", + "link_doctype": "POS Invoice", + "link_fieldname": "consolidated_invoice" + } + ], + "modified": "2021-02-01 15:42:26.261540", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 92e49d59da7..a1bf66b03e5 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -4,9 +4,9 @@ from __future__ import unicode_literals import frappe, erpnext import frappe.defaults -from frappe.utils import cint, flt, add_months, today, date_diff, getdate, add_days, cstr, nowdate, get_link_to_form +from frappe.utils import cint, flt, getdate, add_days, cstr, nowdate, get_link_to_form, formatdate from frappe import _, msgprint, throw -from erpnext.accounts.party import get_party_account, get_due_date +from erpnext.accounts.party import get_party_account, get_due_date, get_party_details from frappe.model.mapper import get_mapped_doc from erpnext.controllers.selling_controller import SellingController from erpnext.accounts.utils import get_account_currency @@ -21,6 +21,9 @@ from erpnext.accounts.general_ledger import get_round_off_account_and_cost_cente from erpnext.accounts.doctype.loyalty_program.loyalty_program import \ get_loyalty_program_details_with_points, get_loyalty_details, validate_loyalty_points from erpnext.accounts.deferred_revenue import validate_service_stop_date +from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import get_party_tax_withholding_details +from frappe.model.utils import get_fetch_values +from frappe.contacts.doctype.address.address import get_address_display from erpnext.healthcare.utils import manage_invoice_submit_cancel @@ -53,7 +56,7 @@ class SalesInvoice(SellingController): """Set indicator for portal""" if self.outstanding_amount < 0: self.indicator_title = _("Credit Note Issued") - self.indicator_color = "darkgrey" + self.indicator_color = "gray" elif self.outstanding_amount > 0 and getdate(self.due_date) >= getdate(nowdate()): self.indicator_color = "orange" self.indicator_title = _("Unpaid") @@ -62,7 +65,7 @@ class SalesInvoice(SellingController): self.indicator_title = _("Overdue") elif cint(self.is_return) == 1: self.indicator_title = _("Return") - self.indicator_color = "darkgrey" + self.indicator_color = "gray" else: self.indicator_color = "green" self.indicator_title = _("Paid") @@ -74,6 +77,8 @@ class SalesInvoice(SellingController): if not self.is_pos: self.so_dn_required() + self.set_tax_withholding() + self.validate_proj_cust() self.validate_pos_return() self.validate_with_previous_doc() @@ -151,6 +156,32 @@ class SalesInvoice(SellingController): if cost_center_company != self.company: frappe.throw(_("Row #{0}: Cost Center {1} does not belong to company {2}").format(frappe.bold(item.idx), frappe.bold(item.cost_center), frappe.bold(self.company))) + def set_tax_withholding(self): + tax_withholding_details = get_party_tax_withholding_details(self) + + if not tax_withholding_details: + return + + accounts = [] + tax_withholding_account = tax_withholding_details.get("account_head") + + for d in self.taxes: + if d.account_head == tax_withholding_account: + d.update(tax_withholding_details) + accounts.append(d.account_head) + + if not accounts or tax_withholding_account not in accounts: + self.append("taxes", tax_withholding_details) + + to_remove = [d for d in self.taxes + if not d.tax_amount and d.charge_type == "Actual" and d.account_head == tax_withholding_account] + + for d in to_remove: + self.remove(d) + + # calculate totals again after applying TDS + self.calculate_taxes_and_totals() + def before_save(self): set_account_for_mode_of_payment(self) @@ -180,6 +211,9 @@ class SalesInvoice(SellingController): # this sequence because outstanding may get -ve self.make_gl_entries() + if self.update_stock == 1: + self.repost_future_sle_and_gle() + if not self.is_return: self.update_billing_status_for_zero_amount_refdoc("Delivery Note") self.update_billing_status_for_zero_amount_refdoc("Sales Order") @@ -228,9 +262,27 @@ class SalesInvoice(SellingController): if len(self.payments) == 0 and self.is_pos: frappe.throw(_("At least one mode of payment is required for POS invoice.")) - def before_cancel(self): - self.update_time_sheet(None) + def check_if_consolidated_invoice(self): + # since POS Invoice extends Sales Invoice, we explicitly check if doctype is Sales Invoice + if self.doctype == "Sales Invoice" and self.is_consolidated: + invoice_or_credit_note = "consolidated_credit_note" if self.is_return else "consolidated_invoice" + pos_closing_entry = frappe.get_all( + "POS Invoice Merge Log", + filters={ invoice_or_credit_note: self.name }, + pluck="pos_closing_entry" + ) + if pos_closing_entry: + msg = _("To cancel a {} you need to cancel the POS Closing Entry {}. ").format( + frappe.bold("Consolidated Sales Invoice"), + get_link_to_form("POS Closing Entry", pos_closing_entry[0]) + ) + frappe.throw(msg, title=_("Not Allowed")) + def before_cancel(self): + self.check_if_consolidated_invoice() + + super(SalesInvoice, self).before_cancel() + self.update_time_sheet(None) def on_cancel(self): super(SalesInvoice, self).on_cancel() @@ -258,6 +310,10 @@ class SalesInvoice(SellingController): self.update_stock_ledger() self.make_gl_entries_on_cancel() + + if self.update_stock == 1: + self.repost_future_sle_and_gle() + frappe.db.set(self, 'status', 'Cancelled') if frappe.db.get_single_value('Selling Settings', 'sales_update_frequency') == "Each Transaction": @@ -279,7 +335,7 @@ class SalesInvoice(SellingController): if "Healthcare" in active_domains: manage_invoice_submit_cancel(self, "on_cancel") - self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry') + self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry', 'Repost Item Valuation') def update_status_updater_args(self): if cint(self.update_stock): @@ -405,6 +461,8 @@ class SalesInvoice(SellingController): from erpnext.stock.get_item_details import get_pos_profile_item_details, get_pos_profile if not self.pos_profile: pos_profile = get_pos_profile(self.company) or {} + if not pos_profile: + frappe.throw(_("No POS Profile found. Please create a New POS Profile first")) self.pos_profile = pos_profile.get('name') pos = {} @@ -424,11 +482,13 @@ class SalesInvoice(SellingController): if not for_validate and not self.customer: self.customer = pos.customer - self.ignore_pricing_rule = pos.ignore_pricing_rule + if not for_validate: + self.ignore_pricing_rule = pos.ignore_pricing_rule + if pos.get('account_for_change_amount'): self.account_for_change_amount = pos.get('account_for_change_amount') - for fieldname in ('naming_series', 'currency', 'letter_head', 'tc_name', + for fieldname in ('currency', 'letter_head', 'tc_name', 'company', 'select_print_heading', 'write_off_account', 'taxes_and_charges', 'write_off_cost_center', 'apply_discount_on', 'cost_center'): if (not for_validate) or (for_validate and not self.get(fieldname)): @@ -472,6 +532,11 @@ class SalesInvoice(SellingController): return frappe.db.sql("select abbr from tabCompany where name=%s", self.company)[0][0] def validate_debit_to_acc(self): + if not self.debit_to: + self.debit_to = get_party_account("Customer", self.customer, self.company) + if not self.debit_to: + self.raise_missing_debit_credit_account_error("Customer", self.customer) + account = frappe.get_cached_value("Account", self.debit_to, ["account_type", "report_type", "account_currency"], as_dict=True) @@ -479,14 +544,14 @@ class SalesInvoice(SellingController): frappe.throw(_("Debit To is required"), title=_("Account Missing")) if account.report_type != "Balance Sheet": - frappe.throw(_("Please ensure {} account is a Balance Sheet account. \ - You can change the parent account to a Balance Sheet account or select a different account.") - .format(frappe.bold("Debit To")), title=_("Invalid Account")) + msg = _("Please ensure {} account is a Balance Sheet account. ").format(frappe.bold("Debit To")) + msg += _("You can change the parent account to a Balance Sheet account or select a different account.") + frappe.throw(msg, title=_("Invalid Account")) if self.customer and account.account_type != "Receivable": - frappe.throw(_("Please ensure {} account is a Receivable account. \ - Change the account type to Receivable or select a different account.") - .format(frappe.bold("Debit To")), title=_("Invalid Account")) + msg = _("Please ensure {} account is a Receivable account. ").format(frappe.bold("Debit To")) + msg += _("Change the account type to Receivable or select a different account.") + frappe.throw(msg, title=_("Invalid Account")) self.party_account_currency = account.account_currency @@ -535,7 +600,12 @@ class SalesInvoice(SellingController): self.against_income_account = ','.join(against_acc) def add_remarks(self): - if not self.remarks: self.remarks = 'No Remarks' + if not self.remarks: + if self.po_no and self.po_date: + self.remarks = _("Against Customer Order {0} dated {1}").format(self.po_no, + formatdate(self.po_date)) + else: + self.remarks = _("No Remarks") def validate_auto_set_posting_time(self): # Don't auto set the posting date and time if invoice is amended @@ -572,7 +642,8 @@ class SalesInvoice(SellingController): def validate_pos(self): if self.is_return: - if flt(self.paid_amount) + flt(self.write_off_amount) - flt(self.grand_total) > \ + invoice_total = self.rounded_total or self.grand_total + if flt(self.paid_amount) + flt(self.write_off_amount) - flt(invoice_total) > \ 1.0/(10.0**(self.precision("grand_total") + 1.0)): frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total")) @@ -714,22 +785,20 @@ class SalesInvoice(SellingController): if d.delivery_note and frappe.db.get_value("Delivery Note", d.delivery_note, "docstatus") != 1: throw(_("Delivery Note {0} is not submitted").format(d.delivery_note)) - def make_gl_entries(self, gl_entries=None): - from erpnext.accounts.general_ledger import make_reverse_gl_entries + def make_gl_entries(self, gl_entries=None, from_repost=False): + from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(self.company) if not gl_entries: gl_entries = self.get_gl_entries() if gl_entries: - from erpnext.accounts.general_ledger import make_gl_entries - # if POS and amount is written off, updating outstanding amt after posting all gl entries update_outstanding = "No" if (cint(self.is_pos) or self.write_off_account or cint(self.redeem_loyalty_points)) else "Yes" if self.docstatus == 1: - make_gl_entries(gl_entries, update_outstanding=update_outstanding, merge_entries=False) + make_gl_entries(gl_entries, update_outstanding=update_outstanding, merge_entries=False, from_repost=from_repost) elif self.docstatus == 2: make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) @@ -750,6 +819,7 @@ class SalesInvoice(SellingController): self.make_customer_gl_entry(gl_entries) self.make_tax_gl_entries(gl_entries) + self.make_internal_transfer_gl_entries(gl_entries) self.make_item_gl_entries(gl_entries) @@ -769,7 +839,7 @@ class SalesInvoice(SellingController): # Checked both rounding_adjustment and rounded_total # because rounded_total had value even before introcution of posting GLE based on rounded total grand_total = self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total - if grand_total: + if grand_total and not self.is_internal_transfer(): # Didnot use base_grand_total to book rounding loss gle grand_total_in_company_currency = flt(grand_total * self.conversion_rate, self.precision("grand_total")) @@ -808,6 +878,18 @@ class SalesInvoice(SellingController): }, account_currency, item=tax) ) + def make_internal_transfer_gl_entries(self, gl_entries): + if self.is_internal_transfer() and flt(self.base_total_taxes_and_charges): + account_currency = get_account_currency(self.unrealized_profit_loss_account) + gl_entries.append( + self.get_gl_dict({ + "account": self.unrealized_profit_loss_account, + "against": self.customer, + "debit": flt(self.total_taxes_and_charges), + "debit_in_account_currency": flt(self.base_total_taxes_and_charges), + "cost_center": self.cost_center + }, account_currency, item=self)) + def make_item_gl_entries(self, gl_entries): # income account gl entries for item in self.get("items"): @@ -830,22 +912,24 @@ class SalesInvoice(SellingController): asset.db_set("disposal_date", self.posting_date) asset.set_status("Sold" if self.docstatus==1 else None) else: - income_account = (item.income_account - if (not item.enable_deferred_revenue or self.is_return) else item.deferred_revenue_account) + # Do not book income for transfer within same company + if not self.is_internal_transfer(): + income_account = (item.income_account + if (not item.enable_deferred_revenue or self.is_return) else item.deferred_revenue_account) - account_currency = get_account_currency(income_account) - gl_entries.append( - self.get_gl_dict({ - "account": income_account, - "against": self.customer, - "credit": flt(item.base_net_amount, item.precision("base_net_amount")), - "credit_in_account_currency": (flt(item.base_net_amount, item.precision("base_net_amount")) - if account_currency==self.company_currency - else flt(item.net_amount, item.precision("net_amount"))), - "cost_center": item.cost_center, - "project": item.project or self.project - }, account_currency, item=item) - ) + account_currency = get_account_currency(income_account) + gl_entries.append( + self.get_gl_dict({ + "account": income_account, + "against": self.customer, + "credit": flt(item.base_net_amount, item.precision("base_net_amount")), + "credit_in_account_currency": (flt(item.base_net_amount, item.precision("base_net_amount")) + if account_currency==self.company_currency + else flt(item.net_amount, item.precision("net_amount"))), + "cost_center": item.cost_center, + "project": item.project or self.project + }, account_currency, item=item) + ) # expense account gl entries if cint(self.update_stock) and \ @@ -975,7 +1059,8 @@ class SalesInvoice(SellingController): ) def make_gle_for_rounding_adjustment(self, gl_entries): - if flt(self.rounding_adjustment, self.precision("rounding_adjustment")) and self.base_rounding_adjustment: + if flt(self.rounding_adjustment, self.precision("rounding_adjustment")) and self.base_rounding_adjustment \ + and not self.is_internal_transfer(): round_off_account, round_off_cost_center = \ get_round_off_account_and_cost_center(self.company) @@ -1140,8 +1225,10 @@ class SalesInvoice(SellingController): where redeem_against=%s''', (lp_entry[0].name), as_dict=1) if against_lp_entry: invoice_list = ", ".join([d.invoice for d in against_lp_entry]) - frappe.throw(_('''{} can't be cancelled since the Loyalty Points earned has been redeemed. - First cancel the {} No {}''').format(self.doctype, self.doctype, invoice_list)) + frappe.throw( + _('''{} can't be cancelled since the Loyalty Points earned has been redeemed. First cancel the {} No {}''') + .format(self.doctype, self.doctype, invoice_list) + ) else: frappe.db.sql('''delete from `tabLoyalty Point Entry` where invoice=%s''', (self.name)) # Set loyalty program @@ -1255,7 +1342,9 @@ class SalesInvoice(SellingController): if self.docstatus == 2: status = "Cancelled" elif self.docstatus == 1: - if outstanding_amount > 0 and due_date < nowdate and self.is_discounted and discountng_status=='Disbursed': + if self.is_internal_transfer(): + self.status = 'Internal Transfer' + elif outstanding_amount > 0 and due_date < nowdate and self.is_discounted and discountng_status=='Disbursed': self.status = "Overdue and Discounted" elif outstanding_amount > 0 and due_date < nowdate: self.status = "Overdue" @@ -1398,6 +1487,7 @@ def make_delivery_note(source_name, target_doc=None): def set_missing_values(source, target): target.ignore_pricing_rule = 1 target.run_method("set_missing_values") + target.run_method("set_po_nos") target.run_method("calculate_taxes_and_totals") def update_item(source_doc, target_doc, source_parent): @@ -1496,7 +1586,7 @@ def validate_inter_company_transaction(doc, doctype): details = get_inter_company_details(doc, doctype) price_list = doc.selling_price_list if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"] else doc.buying_price_list valid_price_list = frappe.db.get_value("Price List", {"name": price_list, "buying": 1, "selling": 1}) - if not valid_price_list: + if not valid_price_list and not doc.is_internal_transfer(): frappe.throw(_("Selected Price List should have buying and selling fields checked.")) party = details.get("party") @@ -1519,15 +1609,21 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): if doctype in ["Sales Invoice", "Sales Order"]: source_doc = frappe.get_doc(doctype, source_name) target_doctype = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order" + target_detail_field = "sales_invoice_item" if doctype == "Sales Invoice" else "sales_order_item" + source_document_warehouse_field = 'target_warehouse' + target_document_warehouse_field = 'from_warehouse' else: source_doc = frappe.get_doc(doctype, source_name) target_doctype = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order" + source_document_warehouse_field = 'from_warehouse' + target_document_warehouse_field = 'target_warehouse' validate_inter_company_transaction(source_doc, doctype) details = get_inter_company_details(source_doc, doctype) def set_missing_values(source, target): target.run_method("set_missing_values") + set_purchase_references(target) def update_details(source_doc, target_doc, source_parent): target_doc.inter_company_invoice_reference = source_doc.name @@ -1535,41 +1631,184 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): currency = frappe.db.get_value('Supplier', details.get('party'), 'default_currency') target_doc.company = details.get("company") target_doc.supplier = details.get("party") + target_doc.is_internal_supplier = 1 + target_doc.ignore_pricing_rule = 1 target_doc.buying_price_list = source_doc.selling_price_list + # Invert Addresses + update_address(target_doc, 'supplier_address', 'address_display', source_doc.company_address) + update_address(target_doc, 'shipping_address', 'shipping_address_display', source_doc.customer_address) + if currency: target_doc.currency = currency + + update_taxes(target_doc, party=target_doc.supplier, party_type='Supplier', company=target_doc.company, + doctype=target_doc.doctype, party_address=target_doc.supplier_address, + company_address=target_doc.shipping_address) + else: currency = frappe.db.get_value('Customer', details.get('party'), 'default_currency') target_doc.company = details.get("company") target_doc.customer = details.get("party") target_doc.selling_price_list = source_doc.buying_price_list + update_address(target_doc, 'company_address', 'company_address_display', source_doc.supplier_address) + update_address(target_doc, 'shipping_address_name', 'shipping_address', source_doc.shipping_address) + update_address(target_doc, 'customer_address', 'address_display', source_doc.shipping_address) + if currency: target_doc.currency = currency + update_taxes(target_doc, party=target_doc.customer, party_type='Customer', company=target_doc.company, + doctype=target_doc.doctype, party_address=target_doc.customer_address, + company_address=target_doc.company_address, shipping_address_name=target_doc.shipping_address_name) + + item_field_map = { + "doctype": target_doctype + " Item", + "field_no_map": [ + "income_account", + "expense_account", + "cost_center", + "warehouse" + ], + "field_map": { + 'rate': 'rate', + } + } + + if doctype in ["Sales Invoice", "Sales Order"]: + item_field_map["field_map"].update({ + "name": target_detail_field, + }) + + if source_doc.get('update_stock'): + item_field_map["field_map"].update({ + source_document_warehouse_field: target_document_warehouse_field, + 'batch_no': 'batch_no', + 'serial_no': 'serial_no' + }) + doclist = get_mapped_doc(doctype, source_name, { doctype: { "doctype": target_doctype, "postprocess": update_details, + "set_target_warehouse": "set_from_warehouse", "field_no_map": [ - "taxes_and_charges" + "taxes_and_charges", + "set_warehouse", + "shipping_address" ] }, - doctype +" Item": { - "doctype": target_doctype + " Item", - "field_no_map": [ - "income_account", - "expense_account", - "cost_center", - "warehouse" - ] - } + doctype +" Item": item_field_map }, target_doc, set_missing_values) return doclist +def set_purchase_references(doc): + # add internal PO or PR links if any + if doc.is_internal_transfer(): + if doc.doctype == 'Purchase Receipt': + so_item_map = get_delivery_note_details(doc.inter_company_invoice_reference) + + if so_item_map: + pd_item_map, parent_child_map, warehouse_map = \ + get_pd_details('Purchase Order Item', so_item_map, 'sales_order_item') + + update_pr_items(doc, so_item_map, pd_item_map, parent_child_map, warehouse_map) + + elif doc.doctype == 'Purchase Invoice': + dn_item_map, so_item_map = get_sales_invoice_details(doc.inter_company_invoice_reference) + # First check for Purchase receipt + if list(dn_item_map.values()): + pd_item_map, parent_child_map, warehouse_map = \ + get_pd_details('Purchase Receipt Item', dn_item_map, 'delivery_note_item') + + update_pi_items(doc, 'pr_detail', 'purchase_receipt', + dn_item_map, pd_item_map, parent_child_map, warehouse_map) + + if list(so_item_map.values()): + pd_item_map, parent_child_map, warehouse_map = \ + get_pd_details('Purchase Order Item', so_item_map, 'sales_order_item') + + update_pi_items(doc, 'po_detail', 'purchase_order', + so_item_map, pd_item_map, parent_child_map, warehouse_map) + +def update_pi_items(doc, detail_field, parent_field, sales_item_map, + purchase_item_map, parent_child_map, warehouse_map): + for item in doc.get('items'): + item.set(detail_field, purchase_item_map.get(sales_item_map.get(item.sales_invoice_item))) + item.set(parent_field, parent_child_map.get(sales_item_map.get(item.sales_invoice_item))) + if doc.update_stock: + item.warehouse = warehouse_map.get(sales_item_map.get(item.sales_invoice_item)) + +def update_pr_items(doc, sales_item_map, purchase_item_map, parent_child_map, warehouse_map): + for item in doc.get('items'): + item.purchase_order_item = purchase_item_map.get(sales_item_map.get(item.delivery_note_item)) + item.warehouse = warehouse_map.get(sales_item_map.get(item.delivery_note_item)) + item.purchase_order = parent_child_map.get(sales_item_map.get(item.delivery_note_item)) + +def get_delivery_note_details(internal_reference): + so_item_map = {} + + si_item_details = frappe.get_all('Delivery Note Item', fields=['name', 'so_detail'], + filters={'parent': internal_reference}) + + for d in si_item_details: + so_item_map.setdefault(d.name, d.so_detail) + + return so_item_map + +def get_sales_invoice_details(internal_reference): + dn_item_map = {} + so_item_map = {} + + si_item_details = frappe.get_all('Sales Invoice Item', fields=['name', 'so_detail', + 'dn_detail'], filters={'parent': internal_reference}) + + for d in si_item_details: + if d.dn_detail: + dn_item_map.setdefault(d.name, d.dn_detail) + if d.so_detail: + so_item_map.setdefault(d.name, d.so_detail) + + return dn_item_map, so_item_map + +def get_pd_details(doctype, sd_detail_map, sd_detail_field): + pd_item_map = {} + accepted_warehouse_map = {} + parent_child_map = {} + + pd_item_details = frappe.get_all(doctype, + fields=[sd_detail_field, 'name', 'warehouse', 'parent'], filters={sd_detail_field: ('in', list(sd_detail_map.values()))}) + + for d in pd_item_details: + pd_item_map.setdefault(d.get(sd_detail_field), d.name) + parent_child_map.setdefault(d.get(sd_detail_field), d.parent) + accepted_warehouse_map.setdefault(d.get(sd_detail_field), d.warehouse) + + return pd_item_map, parent_child_map, accepted_warehouse_map + +def update_taxes(doc, party=None, party_type=None, company=None, doctype=None, party_address=None, + company_address=None, shipping_address_name=None, master_doctype=None): + # Update Party Details + party_details = get_party_details(party=party, party_type=party_type, company=company, + doctype=doctype, party_address=party_address, company_address=company_address, + shipping_address=shipping_address_name) + + # Update taxes and charges if any + doc.taxes_and_charges = party_details.get('taxes_and_charges') + doc.set('taxes', party_details.get('taxes')) + +def update_address(doc, address_field, address_display_field, address_name): + doc.set(address_field, address_name) + fetch_values = get_fetch_values(doc.doctype, address_field, address_name) + + for key, value in fetch_values.items(): + doc.set(key, value) + + doc.set(address_display_field, get_address_display(doc.get(address_field))) + @frappe.whitelist() def get_loyalty_programs(customer): ''' sets applicable loyalty program to the customer or returns a list of applicable programs ''' @@ -1612,17 +1851,25 @@ def update_multi_mode_option(doc, pos_profile): payment.type = payment_mode.type doc.set('payments', []) + invalid_modes = [] for pos_payment_method in pos_profile.get('payments'): pos_payment_method = pos_payment_method.as_dict() payment_mode = get_mode_of_payment_info(pos_payment_method.mode_of_payment, doc.company) if not payment_mode: - frappe.throw(_("Please set default Cash or Bank account in Mode of Payment {0}") - .format(get_link_to_form("Mode of Payment", pos_payment_method.mode_of_payment)), title=_("Missing Account")) + invalid_modes.append(get_link_to_form("Mode of Payment", pos_payment_method.mode_of_payment)) + continue payment_mode[0].default = pos_payment_method.default append_payment(payment_mode[0]) + if invalid_modes: + if invalid_modes == 1: + msg = _("Please set default Cash or Bank account in Mode of Payment {}") + else: + msg = _("Please set default Cash or Bank account in Mode of Payments {}") + frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account")) + def get_all_mode_of_payments(doc): return frappe.db.sql(""" select mpa.default_account, mpa.parent, mp.type as type @@ -1637,6 +1884,7 @@ def get_mode_of_payment_info(mode_of_payment, company): where mpa.parent = mp.name and mpa.company = %s and mp.enabled = 1 and mp.name = %s""", (company, mode_of_payment), as_dict=1) +@frappe.whitelist() def create_dunning(source_name, target_doc=None): from frappe.model.mapper import get_mapped_doc from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text, calculate_interest_and_amount diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py index 2980213f3b0..f1069282edc 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_dashboard.py @@ -13,8 +13,7 @@ def get_data(): 'Auto Repeat': 'reference_document', }, 'internal_links': { - 'Sales Order': ['items', 'sales_order'], - 'Delivery Note': ['items', 'delivery_note'] + 'Sales Order': ['items', 'sales_order'] }, 'transactions': [ { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js index 05d49df711a..1a01cb58f2a 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js @@ -10,12 +10,12 @@ frappe.listview_settings['Sales Invoice'] = { "Draft": "grey", "Unpaid": "orange", "Paid": "green", - "Return": "darkgrey", - "Credit Note Issued": "darkgrey", + "Return": "gray", + "Credit Note Issued": "gray", "Unpaid and Discounted": "orange", "Overdue and Discounted": "red", - "Overdue": "red" - + "Overdue": "red", + "Internal Transfer": "darkgrey" }; return [__(doc.status), status_color[doc.status], "status,=,"+doc.status]; }, diff --git a/erpnext/accounts/doctype/sales_invoice/test_records.json b/erpnext/accounts/doctype/sales_invoice/test_records.json index 11ebe6a573a..e00a58f8641 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_records.json +++ b/erpnext/accounts/doctype/sales_invoice/test_records.json @@ -17,7 +17,8 @@ "description": "138-CMS Shoe", "doctype": "Sales Invoice Item", "income_account": "Sales - _TC", - "expense_account": "_Test Account Cost for Goods Sold - _TC", + "expense_account": "_Test Account Cost for Goods Sold - _TC", + "item_code": "138-CMS Shoe", "item_name": "138-CMS Shoe", "parentfield": "items", "qty": 1.0, @@ -147,7 +148,7 @@ "expense_account": "_Test Account Cost for Goods Sold - _TC", "item_code": "_Test Item Home Desktop 100", "item_name": "_Test Item Home Desktop 100", - "item_tax_template": "_Test Account Excise Duty @ 10", + "item_tax_template": "_Test Account Excise Duty @ 10 - _TC", "parentfield": "items", "price_list_rate": 50, "qty": 10, @@ -275,7 +276,7 @@ "expense_account": "_Test Account Cost for Goods Sold - _TC", "item_code": "_Test Item Home Desktop 100", "item_name": "_Test Item Home Desktop 100", - "item_tax_template": "_Test Account Excise Duty @ 10", + "item_tax_template": "_Test Account Excise Duty @ 10 - _TC", "parentfield": "items", "price_list_rate": 62.5, "qty": 10, diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 9660c9570e2..90e21444f54 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -10,7 +10,6 @@ from frappe.model.dynamic_links import get_dynamic_link_map from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry, get_qty_after_transaction from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import unlink_payment_on_cancel_of_invoice from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile -from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_perpetual_inventory from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency from erpnext.stock.doctype.serial_no.serial_no import SerialNoWarehouseError from frappe.model.naming import make_autoname @@ -23,6 +22,7 @@ from erpnext.regional.india.utils import get_ewb_data from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice +from erpnext.stock.utils import get_incoming_rate class TestSalesInvoice(unittest.TestCase): def make(self): @@ -405,10 +405,10 @@ class TestSalesInvoice(unittest.TestCase): item_row = si.get("items")[0] add_items = [ - (54, '_Test Account Excise Duty @ 12'), - (288, '_Test Account Excise Duty @ 15'), - (144, '_Test Account Excise Duty @ 20'), - (430, '_Test Item Tax Template 1') + (54, '_Test Account Excise Duty @ 12 - _TC'), + (288, '_Test Account Excise Duty @ 15 - _TC'), + (144, '_Test Account Excise Duty @ 20 - _TC'), + (430, '_Test Item Tax Template 1 - _TC') ] for qty, item_tax_template in add_items: item_row_copy = copy.deepcopy(item_row) @@ -659,7 +659,6 @@ class TestSalesInvoice(unittest.TestCase): def test_sales_invoice_gl_entry_without_perpetual_inventory(self): si = frappe.copy_doc(test_records[1]) - set_perpetual_inventory(0, si.company) si.insert() si.submit() @@ -690,7 +689,8 @@ class TestSalesInvoice(unittest.TestCase): self.assertTrue(gle) def test_pos_gl_entry_with_perpetual_inventory(self): - make_pos_profile() + make_pos_profile(company="_Test Company with perpetual inventory", income_account = "Sales - TCP1", + expense_account = "Cost of Goods Sold - TCP1", warehouse="Stores - TCP1", cost_center = "Main - TCP1", write_off_account="_Test Write Off - TCP1") pr = make_purchase_receipt(company= "_Test Company with perpetual inventory", item_code= "_Test FG Item",warehouse= "Stores - TCP1",cost_center= "Main - TCP1") @@ -746,7 +746,8 @@ class TestSalesInvoice(unittest.TestCase): self.assertEqual(pos_return.get('payments')[0].amount, -1000) def test_pos_change_amount(self): - make_pos_profile() + make_pos_profile(company="_Test Company with perpetual inventory", income_account = "Sales - TCP1", + expense_account = "Cost of Goods Sold - TCP1", warehouse="Stores - TCP1", cost_center = "Main - TCP1", write_off_account="_Test Write Off - TCP1") pr = make_purchase_receipt(company= "_Test Company with perpetual inventory", item_code= "_Test FG Item",warehouse= "Stores - TCP1", cost_center= "Main - TCP1") @@ -813,7 +814,6 @@ class TestSalesInvoice(unittest.TestCase): frappe.db.sql("delete from `tabPOS Profile`") def test_pos_si_without_payment(self): - set_perpetual_inventory() make_pos_profile() pos = copy.deepcopy(test_records[1]) @@ -827,9 +827,8 @@ class TestSalesInvoice(unittest.TestCase): self.assertRaises(frappe.ValidationError, si.submit) def test_sales_invoice_gl_entry_with_perpetual_inventory_no_item_code(self): - set_perpetual_inventory() - - si = frappe.get_doc(test_records[1]) + si = create_sales_invoice(company="_Test Company with perpetual inventory", debit_to = "Debtors - TCP1", + income_account="Sales - TCP1", cost_center = "Main - TCP1", do_not_save=True) si.get("items")[0].item_code = None si.insert() si.submit() @@ -840,24 +839,16 @@ class TestSalesInvoice(unittest.TestCase): self.assertTrue(gl_entries) expected_values = dict((d[0], d) for d in [ - [si.debit_to, 630.0, 0.0], - [test_records[1]["items"][0]["income_account"], 0.0, 500.0], - [test_records[1]["taxes"][0]["account_head"], 0.0, 80.0], - [test_records[1]["taxes"][1]["account_head"], 0.0, 50.0], + ["Debtors - TCP1", 100.0, 0.0], + ["Sales - TCP1", 0.0, 100.0] ]) for i, gle in enumerate(gl_entries): self.assertEqual(expected_values[gle.account][0], gle.account) self.assertEqual(expected_values[gle.account][1], gle.debit) self.assertEqual(expected_values[gle.account][2], gle.credit) - set_perpetual_inventory(0) - def test_sales_invoice_gl_entry_with_perpetual_inventory_non_stock_item(self): - set_perpetual_inventory() - si = frappe.get_doc(test_records[1]) - si.get("items")[0].item_code = "_Test Non Stock Item" - si.insert() - si.submit() + si = create_sales_invoice(item="_Test Non Stock Item") gl_entries = frappe.db.sql("""select account, debit, credit from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s @@ -865,17 +856,14 @@ class TestSalesInvoice(unittest.TestCase): self.assertTrue(gl_entries) expected_values = dict((d[0], d) for d in [ - [si.debit_to, 630.0, 0.0], - [test_records[1]["items"][0]["income_account"], 0.0, 500.0], - [test_records[1]["taxes"][0]["account_head"], 0.0, 80.0], - [test_records[1]["taxes"][1]["account_head"], 0.0, 50.0], + [si.debit_to, 100.0, 0.0], + [test_records[1]["items"][0]["income_account"], 0.0, 100.0] ]) for i, gle in enumerate(gl_entries): self.assertEqual(expected_values[gle.account][0], gle.account) self.assertEqual(expected_values[gle.account][1], gle.debit) self.assertEqual(expected_values[gle.account][2], gle.credit) - set_perpetual_inventory(0) def _insert_purchase_receipt(self): from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import test_records \ @@ -1104,7 +1092,6 @@ class TestSalesInvoice(unittest.TestCase): self.assertEqual(si.grand_total, 859.43) def test_multi_currency_gle(self): - set_perpetual_inventory(0) si = create_sales_invoice(customer="_Test Customer USD", debit_to="_Test Receivable USD - _TC", currency="USD", conversion_rate=50) @@ -1571,7 +1558,7 @@ class TestSalesInvoice(unittest.TestCase): for gle in gl_entries: self.assertEqual(expected_values[gle.account]["cost_center"], gle.cost_center) - + def test_sales_invoice_with_project_link(self): from erpnext.projects.doctype.project.test_project import make_project @@ -1587,17 +1574,17 @@ class TestSalesInvoice(unittest.TestCase): }) sales_invoice = create_sales_invoice(do_not_save=1) - sales_invoice.items[0].project = item_project.project_name - sales_invoice.project = project.project_name + sales_invoice.items[0].project = item_project.name + sales_invoice.project = project.name sales_invoice.submit() expected_values = { "Debtors - _TC": { - "project": project.project_name + "project": project.name }, "Sales - _TC": { - "project": item_project.project_name + "project": item_project.name } } @@ -1605,9 +1592,9 @@ class TestSalesInvoice(unittest.TestCase): debit_in_account_currency, credit_in_account_currency from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s order by account asc""", sales_invoice.name, as_dict=1) - + self.assertTrue(gl_entries) - + for gle in gl_entries: self.assertEqual(expected_values[gle.account]["project"], gle.project) @@ -1774,99 +1761,104 @@ class TestSalesInvoice(unittest.TestCase): si.submit() target_doc = make_inter_company_transaction("Sales Invoice", si.name) + target_doc.items[0].update({ + "expense_account": "Cost of Goods Sold - _TC1", + "cost_center": "Main - _TC1", + "warehouse": "Stores - _TC1" + }) target_doc.submit() self.assertEqual(target_doc.company, "_Test Company 1") self.assertEqual(target_doc.supplier, "_Test Internal Supplier") + def test_internal_transfer_gl_entry(self): + ## Create internal transfer account + account = create_account(account_name="Unrealized Profit", + parent_account="Current Liabilities - TCP1", company="_Test Company with perpetual inventory") + + frappe.db.set_value('Company', '_Test Company with perpetual inventory', + 'unrealized_profit_loss_account', account) + + customer = create_internal_customer("_Test Internal Customer 2", "_Test Company with perpetual inventory", + "_Test Company with perpetual inventory") + + create_internal_supplier("_Test Internal Supplier 2", "_Test Company with perpetual inventory", + "_Test Company with perpetual inventory") + + si = create_sales_invoice( + company = "_Test Company with perpetual inventory", + customer = customer, + debit_to = "Debtors - TCP1", + warehouse = "Stores - TCP1", + income_account = "Sales - TCP1", + expense_account = "Cost of Goods Sold - TCP1", + cost_center = "Main - TCP1", + currency = "INR", + do_not_save = 1 + ) + + si.selling_price_list = "_Test Price List Rest of the World" + si.update_stock = 1 + si.items[0].target_warehouse = 'Work In Progress - TCP1' + + # Add stock to stores for succesful stock transfer + make_stock_entry( + target="Stores - TCP1", + company = "_Test Company with perpetual inventory", + qty=1, + basic_rate=100 + ) + + add_taxes(si) + si.save() + + rate = 0.0 + for d in si.get('items'): + rate = get_incoming_rate({ + "item_code": d.item_code, + "warehouse": d.warehouse, + "posting_date": si.posting_date, + "posting_time": si.posting_time, + "qty": -1 * flt(d.get('stock_qty')), + "serial_no": d.serial_no, + "company": si.company, + "voucher_type": 'Sales Invoice', + "voucher_no": si.name, + "allow_zero_valuation": d.get("allow_zero_valuation") + }, raise_error_if_no_rate=False) + + rate = flt(rate, 2) + + si.submit() + + target_doc = make_inter_company_transaction("Sales Invoice", si.name) + target_doc.company = '_Test Company with perpetual inventory' + target_doc.items[0].warehouse = 'Finished Goods - TCP1' + add_taxes(target_doc) + target_doc.save() + target_doc.submit() + + tax_amount = flt(rate * (12/100), 2) + si_gl_entries = [ + ["_Test Account Excise Duty - TCP1", 0.0, tax_amount, nowdate()], + ["Unrealized Profit - TCP1", tax_amount, 0.0, nowdate()] + ] + + check_gl_entries(self, si.name, si_gl_entries, add_days(nowdate(), -1)) + + pi_gl_entries = [ + ["_Test Account Excise Duty - TCP1", tax_amount , 0.0, nowdate()], + ["Unrealized Profit - TCP1", 0.0, tax_amount, nowdate()] + ] + + # Sale and Purchase both should be at valuation rate + self.assertEqual(si.items[0].rate, rate) + self.assertEqual(target_doc.items[0].rate, rate) + + check_gl_entries(self, target_doc.name, pi_gl_entries, add_days(nowdate(), -1)) + def test_eway_bill_json(self): - if not frappe.db.exists('Address', '_Test Address for Eway bill-Billing'): - address = frappe.get_doc({ - "address_line1": "_Test Address Line 1", - "address_title": "_Test Address for Eway bill", - "address_type": "Billing", - "city": "_Test City", - "state": "Test State", - "country": "India", - "doctype": "Address", - "is_primary_address": 1, - "phone": "+91 0000000000", - "gstin": "27AAECE4835E1ZR", - "gst_state": "Maharashtra", - "gst_state_number": "27", - "pincode": "401108" - }).insert() - - address.append("links", { - "link_doctype": "Company", - "link_name": "_Test Company" - }) - - address.save() - - if not frappe.db.exists('Address', '_Test Customer-Address for Eway bill-Shipping'): - address = frappe.get_doc({ - "address_line1": "_Test Address Line 1", - "address_title": "_Test Customer-Address for Eway bill", - "address_type": "Shipping", - "city": "_Test City", - "state": "Test State", - "country": "India", - "doctype": "Address", - "is_primary_address": 1, - "phone": "+91 0000000000", - "gst_state": "Maharashtra", - "gst_state_number": "27", - "pincode": "410038" - }).insert() - - address.append("links", { - "link_doctype": "Customer", - "link_name": "_Test Customer" - }) - - address.save() - - gst_settings = frappe.get_doc("GST Settings") - - gst_account = frappe.get_all( - "GST Account", - fields=["cgst_account", "sgst_account", "igst_account"], - filters = {"company": "_Test Company"}) - - if not gst_account: - gst_settings.append("gst_accounts", { - "company": "_Test Company", - "cgst_account": "CGST - _TC", - "sgst_account": "SGST - _TC", - "igst_account": "IGST - _TC", - }) - - gst_settings.save() - - si = create_sales_invoice(do_not_save =1, rate = '60000') - - si.distance = 2000 - si.company_address = "_Test Address for Eway bill-Billing" - si.customer_address = "_Test Customer-Address for Eway bill-Shipping" - si.vehicle_no = "KA12KA1234" - si.gst_category = "Registered Regular" - - si.append("taxes", { - "charge_type": "On Net Total", - "account_head": "CGST - _TC", - "cost_center": "Main - _TC", - "description": "CGST @ 9.0", - "rate": 9 - }) - - si.append("taxes", { - "charge_type": "On Net Total", - "account_head": "SGST - _TC", - "cost_center": "Main - _TC", - "description": "SGST @ 9.0", - "rate": 9 - }) + si = make_sales_invoice_for_ewaybill() si.submit() @@ -1883,6 +1875,197 @@ class TestSalesInvoice(unittest.TestCase): self.assertEqual(data['billLists'][0]['vehicleNo'], 'KA12KA1234') self.assertEqual(data['billLists'][0]['itemList'][0]['taxableAmount'], 60000) + def test_einvoice_submission_without_irn(self): + # init + frappe.db.set_value('E Invoice Settings', 'E Invoice Settings', 'enable', 1) + country = frappe.flags.country + frappe.flags.country = 'India' + + si = make_sales_invoice_for_ewaybill() + self.assertRaises(frappe.ValidationError, si.submit) + + si.irn = 'test_irn' + si.submit() + + # reset + frappe.db.set_value('E Invoice Settings', 'E Invoice Settings', 'enable', 0) + frappe.flags.country = country + + def test_einvoice_json(self): + from erpnext.regional.india.e_invoice.utils import make_einvoice + + si = make_sales_invoice_for_ewaybill() + si.naming_series = 'INV-2020-.#####' + si.items = [] + si.append("items", { + "item_code": "_Test Item", + "uom": "Nos", + "warehouse": "_Test Warehouse - _TC", + "qty": 2000, + "rate": 12, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + }) + si.append("items", { + "item_code": "_Test Item 2", + "uom": "Nos", + "warehouse": "_Test Warehouse - _TC", + "qty": 420, + "rate": 15, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": "_Test Cost Center - _TC", + }) + si.discount_amount = 100 + si.save() + + einvoice = make_einvoice(si) + + total_item_ass_value = 0 + total_item_cgst_value = 0 + total_item_sgst_value = 0 + total_item_igst_value = 0 + total_item_value = 0 + + for item in einvoice['ItemList']: + total_item_ass_value += item['AssAmt'] + total_item_cgst_value += item['CgstAmt'] + total_item_sgst_value += item['SgstAmt'] + total_item_igst_value += item['IgstAmt'] + total_item_value += item['TotItemVal'] + + self.assertTrue(item['AssAmt'], item['TotAmt'] - item['Discount']) + self.assertTrue(item['TotItemVal'], item['AssAmt'] + item['CgstAmt'] + item['SgstAmt'] + item['IgstAmt']) + + value_details = einvoice['ValDtls'] + + self.assertEqual(einvoice['Version'], '1.1') + self.assertEqual(value_details['AssVal'], total_item_ass_value) + self.assertEqual(value_details['CgstVal'], total_item_cgst_value) + self.assertEqual(value_details['SgstVal'], total_item_sgst_value) + self.assertEqual(value_details['IgstVal'], total_item_igst_value) + + calculated_invoice_value = \ + value_details['AssVal'] + value_details['CgstVal'] \ + + value_details['SgstVal'] + value_details['IgstVal'] \ + + value_details['OthChrg'] - value_details['Discount'] + + self.assertTrue(value_details['TotInvVal'] - calculated_invoice_value < 0.1) + + self.assertEqual(value_details['TotInvVal'], si.base_grand_total) + self.assertTrue(einvoice['EwbDtls']) + +def make_test_address_for_ewaybill(): + if not frappe.db.exists('Address', '_Test Address for Eway bill-Billing'): + address = frappe.get_doc({ + "address_line1": "_Test Address Line 1", + "address_title": "_Test Address for Eway bill", + "address_type": "Billing", + "city": "_Test City", + "state": "Test State", + "country": "India", + "doctype": "Address", + "is_primary_address": 1, + "phone": "+910000000000", + "gstin": "27AAECE4835E1ZR", + "gst_state": "Maharashtra", + "gst_state_number": "27", + "pincode": "401108" + }).insert() + + address.append("links", { + "link_doctype": "Company", + "link_name": "_Test Company" + }) + + address.save() + + if not frappe.db.exists('Address', '_Test Customer-Address for Eway bill-Shipping'): + address = frappe.get_doc({ + "address_line1": "_Test Address Line 1", + "address_title": "_Test Customer-Address for Eway bill", + "address_type": "Shipping", + "city": "_Test City", + "state": "Test State", + "country": "India", + "doctype": "Address", + "is_primary_address": 1, + "phone": "+910000000000", + "gstin": "27AACCM7806M1Z3", + "gst_state": "Maharashtra", + "gst_state_number": "27", + "pincode": "410038" + }).insert() + + address.append("links", { + "link_doctype": "Customer", + "link_name": "_Test Customer" + }) + + address.save() + +def make_test_transporter_for_ewaybill(): + if not frappe.db.exists('Supplier', '_Test Transporter'): + frappe.get_doc({ + "doctype": "Supplier", + "supplier_name": "_Test Transporter", + "country": "India", + "supplier_group": "_Test Supplier Group", + "supplier_type": "Company", + "is_transporter": 1 + }).insert() + +def make_sales_invoice_for_ewaybill(): + make_test_address_for_ewaybill() + make_test_transporter_for_ewaybill() + + gst_settings = frappe.get_doc("GST Settings") + + gst_account = frappe.get_all( + "GST Account", + fields=["cgst_account", "sgst_account", "igst_account"], + filters = {"company": "_Test Company"} + ) + + if not gst_account: + gst_settings.append("gst_accounts", { + "company": "_Test Company", + "cgst_account": "CGST - _TC", + "sgst_account": "SGST - _TC", + "igst_account": "IGST - _TC", + }) + + gst_settings.save() + + si = create_sales_invoice(do_not_save=1, rate='60000') + + si.distance = 2000 + si.company_address = "_Test Address for Eway bill-Billing" + si.customer_address = "_Test Customer-Address for Eway bill-Shipping" + si.vehicle_no = "KA12KA1234" + si.gst_category = "Registered Regular" + si.mode_of_transport = 'Road' + si.transporter = '_Test Transporter' + + si.append("taxes", { + "charge_type": "On Net Total", + "account_head": "CGST - _TC", + "cost_center": "Main - _TC", + "description": "CGST @ 9.0", + "rate": 9 + }) + + si.append("taxes", { + "charge_type": "On Net Total", + "account_head": "SGST - _TC", + "cost_center": "Main - _TC", + "description": "SGST @ 9.0", + "rate": 9 + }) + + return si + def check_gl_entries(doc, voucher_no, expected_gle, posting_date): gl_entries = frappe.db.sql("""select account, debit, credit, posting_date from `tabGL Entry` @@ -1903,14 +2086,14 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date): item.save() item.append("taxes", { - "item_tax_template": "_Test Item Tax Template 1", + "item_tax_template": "_Test Item Tax Template 1 - _TC", "valid_from": add_days(nowdate(), 1) }) item.save() sales_invoice = create_sales_invoice(item = "_Test Item 2", do_not_save=1) - sales_invoice.items[0].item_tax_template = "_Test Item Tax Template 1" + sales_invoice.items[0].item_tax_template = "_Test Item Tax Template 1 - _TC" self.assertRaises(frappe.ValidationError, sales_invoice.save) item.taxes = [] @@ -1935,14 +2118,19 @@ def create_sales_invoice(**args): si.append("items", { "item_code": args.item or args.item_code or "_Test Item", + "item_name": args.item_name or "_Test Item", + "description": args.description or "_Test Item", "gst_hsn_code": "999800", "warehouse": args.warehouse or "_Test Warehouse - _TC", "qty": args.qty or 1, + "uom": args.uom or "Nos", + "stock_uom": args.uom or "Nos", "rate": args.rate if args.get("rate") is not None else 100, "income_account": args.income_account or "Sales - _TC", "expense_account": args.expense_account or "Cost of Goods Sold - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC", - "serial_no": args.serial_no + "serial_no": args.serial_no, + "conversion_factor": 1 }) if not args.do_not_save: @@ -2037,4 +2225,57 @@ def get_taxes_and_charges(): "parentfield": "taxes", "rate": 2, "row_id": 1 - }] \ No newline at end of file + }] + +def create_internal_customer(customer_name, represents_company, allowed_to_interact_with): + if not frappe.db.exists("Customer", customer_name): + customer = frappe.get_doc({ + "customer_group": "_Test Customer Group", + "customer_name": customer_name, + "customer_type": "Individual", + "doctype": "Customer", + "territory": "_Test Territory", + "is_internal_customer": 1, + "represents_company": represents_company + }) + + customer.append("companies", { + "company": allowed_to_interact_with + }) + + customer.insert() + customer_name = customer.name + else: + customer_name = frappe.db.get_value("Customer", customer_name) + + return customer_name + +def create_internal_supplier(supplier_name, represents_company, allowed_to_interact_with): + if not frappe.db.exists("Supplier", supplier_name): + supplier = frappe.get_doc({ + "supplier_group": "_Test Supplier Group", + "supplier_name": supplier_name, + "doctype": "Supplier", + "is_internal_supplier": 1, + "represents_company": represents_company + }) + + supplier.append("companies", { + "company": allowed_to_interact_with + }) + + supplier.insert() + supplier_name = supplier.name + else: + supplier_name = frappe.db.exists("Supplier", supplier_name) + + return supplier_name + +def add_taxes(doc): + doc.append('taxes', { + 'account_head': '_Test Account Excise Duty - TCP1', + "charge_type": "On Net Total", + "cost_center": "Main - TCP1", + "description": "Excise Duty", + "rate": 12 + }) 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 fb3dd6a92a1..8e6952a93c4 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "hash", "creation": "2013-06-04 11:02:19", "doctype": "DocType", @@ -44,6 +45,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_21", "net_rate", @@ -51,6 +53,7 @@ "column_break_24", "base_net_rate", "base_net_amount", + "incoming_rate", "drop_ship", "delivered_by_supplier", "accounting", @@ -563,11 +566,12 @@ "print_hide": 1 }, { + "depends_on": "eval: parent.is_internal_customer && parent.update_stock", "fieldname": "target_warehouse", "fieldtype": "Link", "hidden": 1, "ignore_user_permissions": 1, - "label": "Customer Warehouse (Optional)", + "label": "Target Warehouse", "no_copy": 1, "options": "Warehouse", "print_hide": 1 @@ -792,20 +796,37 @@ "options": "Project" }, { - "depends_on": "eval:parent.update_stock == 1", - "fieldname": "sales_invoice_item", - "fieldtype": "Data", - "ignore_user_permissions": 1, - "label": "Sales Invoice Item", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - } + "depends_on": "eval:parent.update_stock == 1", + "fieldname": "sales_invoice_item", + "fieldtype": "Data", + "ignore_user_permissions": 1, + "label": "Sales Invoice Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "incoming_rate", + "fieldtype": "Currency", + "label": "Incoming Rate", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "no_copy": 1, + "options": "currency", + "read_only": 1 + } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-08-20 11:24:41.749986", + "modified": "2021-02-23 01:05:22.123527", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py index 7a62f8e2815..a73b03acc84 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.py @@ -5,8 +5,6 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document -from erpnext.controllers.print_settings import print_settings_for_item_table class SalesInvoiceItem(Document): - def __setup__(self): - print_settings_for_item_table(self) + pass diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.js b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.js index 97a6fdd3366..0e011883b15 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.js +++ b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.js @@ -5,3 +5,25 @@ cur_frm.cscript.tax_table = "Sales Taxes and Charges"; {% include "erpnext/public/js/controllers/accounts.js" %} +frappe.tour['Sales Taxes and Charges Template'] = [ + { + fieldname: "title", + title: __("Title"), + description: __("A name by which you will identify this template. You can change this later."), + }, + { + fieldname: "company", + title: __("Company"), + description: __("Company for which this tax template will be applicable"), + }, + { + fieldname: "is_default", + title: __("Is this Default?"), + description: __("Set this template as the default for all sales transactions"), + }, + { + fieldname: "taxes", + title: __("Taxes Table"), + description: __("You can add a row for a tax rule here. These rules can be applied on the net total, or can be a flat amount."), + } +]; diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py index b46de6c85bb..52d19d54a8b 100644 --- a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py +++ b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py @@ -34,6 +34,9 @@ def valdiate_taxes_and_charges_template(doc): validate_disabled(doc) + # Validate with existing taxes and charges template for unique tax category + validate_for_tax_category(doc) + for tax in doc.get("taxes"): validate_taxes_and_charges(tax) validate_inclusive_tax(tax, doc) @@ -41,3 +44,7 @@ def valdiate_taxes_and_charges_template(doc): def validate_disabled(doc): if doc.is_default and doc.disabled: frappe.throw(_("Disabled template must not be default template")) + +def validate_for_tax_category(doc): + if frappe.db.exists(doc.doctype, {"company": doc.company, "tax_category": doc.tax_category, "disabled": 0, "name": ["!=", doc.name]}): + frappe.throw(_("A template with tax category {0} already exists. Only one template is allowed with each tax category").format(frappe.bold(doc.tax_category))) diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.js b/erpnext/accounts/doctype/shipping_rule/shipping_rule.js index d0904eec3e3..8e4b806f02d 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.js +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.js @@ -1,16 +1,18 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -frappe.ui.form.on('Shipping Rule', { - refresh: function(frm) { - frm.set_query("cost_center", function() { - return { - filters: { - company: frm.doc.company - } - } - }) +frappe.provide('erpnext.accounts.dimensions'); +frappe.ui.form.on('Shipping Rule', { + onload: function(frm) { + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); + }, + + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, + + refresh: function(frm) { frm.set_query("account", function() { return { filters: { diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js index ba98eb9b2a2..1a9066470a5 100644 --- a/erpnext/accounts/doctype/subscription/subscription.js +++ b/erpnext/accounts/doctype/subscription/subscription.js @@ -10,6 +10,14 @@ frappe.ui.form.on('Subscription', { } } }); + + frm.set_query('cost_center', function() { + return { + filters: { + company: frm.doc.company + } + }; + }); }, refresh: function(frm) { diff --git a/erpnext/accounts/doctype/subscription/subscription.json b/erpnext/accounts/doctype/subscription/subscription.json index afb94fe9c95..e80df2ab886 100644 --- a/erpnext/accounts/doctype/subscription/subscription.json +++ b/erpnext/accounts/doctype/subscription/subscription.json @@ -7,9 +7,10 @@ "engine": "InnoDB", "field_order": [ "party_type", - "status", - "cb_1", "party", + "cb_1", + "company", + "status", "subscription_period", "start_date", "end_date", @@ -44,80 +45,107 @@ { "allow_on_submit": 1, "fieldname": "cb_1", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "status", "fieldtype": "Select", "label": "Status", + "no_copy": 1, "options": "\nTrialling\nActive\nPast Due Date\nCancelled\nUnpaid\nCompleted", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "subscription_period", "fieldtype": "Section Break", - "label": "Subscription Period" + "label": "Subscription Period", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "cancelation_date", "fieldtype": "Date", "label": "Cancelation Date", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "fieldname": "trial_period_start", "fieldtype": "Date", "label": "Trial Period Start Date", - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.trial_period_start", "fieldname": "trial_period_end", "fieldtype": "Date", "label": "Trial Period End Date", - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_11", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "current_invoice_start", "fieldtype": "Date", "label": "Current Invoice Start Date", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "current_invoice_end", "fieldtype": "Date", "label": "Current Invoice End Date", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "default": "0", "description": "Number of days that the subscriber has to pay invoices generated by this subscription", "fieldname": "days_until_due", "fieldtype": "Int", - "label": "Days Until Due" + "label": "Days Until Due", + "show_days": 1, + "show_seconds": 1 }, { "default": "0", "fieldname": "cancel_at_period_end", "fieldtype": "Check", - "label": "Cancel At End Of Period" + "label": "Cancel At End Of Period", + "show_days": 1, + "show_seconds": 1 }, { "default": "0", "fieldname": "generate_invoice_at_period_start", "fieldtype": "Check", - "label": "Generate Invoice At Beginning Of Period" + "label": "Generate Invoice At Beginning Of Period", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "fieldname": "sb_4", "fieldtype": "Section Break", - "label": "Plans" + "label": "Plans", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, @@ -125,62 +153,84 @@ "fieldtype": "Table", "label": "Plans", "options": "Subscription Plan Detail", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:['Customer', 'Supplier'].includes(doc.party_type)", "fieldname": "sb_1", "fieldtype": "Section Break", - "label": "Taxes" + "label": "Taxes", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "sb_2", "fieldtype": "Section Break", - "label": "Discounts" + "label": "Discounts", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "apply_additional_discount", "fieldtype": "Select", "label": "Apply Additional Discount On", - "options": "\nGrand Total\nNet Total" + "options": "\nGrand Total\nNet Total", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "cb_2", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "additional_discount_percentage", "fieldtype": "Percent", - "label": "Additional DIscount Percentage" + "label": "Additional DIscount Percentage", + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "additional_discount_amount", "fieldtype": "Currency", - "label": "Additional DIscount Amount" + "label": "Additional DIscount Amount", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.invoices", "fieldname": "sb_3", "fieldtype": "Section Break", - "label": "Invoices" + "label": "Invoices", + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "invoices", "fieldtype": "Table", "label": "Invoices", - "options": "Subscription Invoice" + "options": "Subscription Invoice", + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "accounting_dimensions_section", "fieldtype": "Section Break", - "label": "Accounting Dimensions" + "label": "Accounting Dimensions", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "dimension_col_break", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "party_type", @@ -188,7 +238,9 @@ "label": "Party Type", "options": "DocType", "reqd": 1, - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "party", @@ -197,21 +249,27 @@ "label": "Party", "options": "party_type", "reqd": 1, - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.party_type === 'Customer'", "fieldname": "sales_tax_template", "fieldtype": "Link", "label": "Sales Taxes and Charges Template", - "options": "Sales Taxes and Charges Template" + "options": "Sales Taxes and Charges Template", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.party_type === 'Supplier'", "fieldname": "purchase_tax_template", "fieldtype": "Link", "label": "Purchase Taxes and Charges Template", - "options": "Purchase Taxes and Charges Template" + "options": "Purchase Taxes and Charges Template", + "show_days": 1, + "show_seconds": 1 }, { "default": "0", @@ -219,36 +277,55 @@ "fieldname": "follow_calendar_months", "fieldtype": "Check", "label": "Follow Calendar Months", - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "default": "0", "description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date", "fieldname": "generate_new_invoices_past_due_date", "fieldtype": "Check", - "label": "Generate New Invoices Past Due Date" + "label": "Generate New Invoices Past Due Date", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "end_date", "fieldtype": "Date", "label": "Subscription End Date", - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "start_date", "fieldtype": "Date", "label": "Subscription Start Date", - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "cost_center", "fieldtype": "Link", "label": "Cost Center", - "options": "Cost Center" + "options": "Cost Center", + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "show_days": 1, + "show_seconds": 1 } ], + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-06-25 10:52:52.265105", + "modified": "2021-02-09 15:44:20.024789", "modified_by": "Administrator", "module": "Accounts", "name": "Subscription", diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index 07525317aab..826044a4075 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -1,3 +1,4 @@ + # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt @@ -5,12 +6,13 @@ from __future__ import unicode_literals import frappe +import erpnext from frappe import _ from frappe.model.document import Document from frappe.utils.data import nowdate, getdate, cstr, cint, add_days, date_diff, get_last_day, add_to_date, flt from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions - +from erpnext import get_default_company class Subscription(Document): def before_insert(self): @@ -243,6 +245,7 @@ class Subscription(Document): self.validate_plans_billing_cycle(self.get_billing_cycle_and_interval()) self.validate_end_date() self.validate_to_follow_calendar_months() + self.cost_center = erpnext.get_default_cost_center(self.get('company')) def validate_trial_period(self): """ @@ -304,6 +307,14 @@ class Subscription(Document): doctype = 'Sales Invoice' if self.party_type == 'Customer' else 'Purchase Invoice' invoice = frappe.new_doc(doctype) + + # For backward compatibility + # Earlier subscription didn't had any company field + company = self.get('company') or get_default_company() + if not company: + frappe.throw(_("Company is mandatory was generating invoice. Please set default company in Global Defaults")) + + invoice.company = company invoice.set_posting_time = 1 invoice.posting_date = self.current_invoice_start if self.generate_invoice_at_period_start \ else self.current_invoice_end @@ -330,6 +341,7 @@ class Subscription(Document): # for that reason items_list = self.get_items_from_plans(self.plans, prorate) for item in items_list: + item['cost_center'] = self.cost_center invoice.append('items', item) # Taxes @@ -345,13 +357,14 @@ class Subscription(Document): invoice.set_taxes() # Due date - invoice.append( - 'payment_schedule', - { - 'due_date': add_days(invoice.posting_date, cint(self.days_until_due)), - 'invoice_portion': 100 - } - ) + if self.days_until_due: + invoice.append( + 'payment_schedule', + { + 'due_date': add_days(invoice.posting_date, cint(self.days_until_due)), + 'invoice_portion': 100 + } + ) # Discounts if self.additional_discount_percentage: @@ -379,7 +392,8 @@ class Subscription(Document): Returns the `Item`s linked to `Subscription Plan` """ if prorate: - prorate_factor = get_prorata_factor(self.current_invoice_end, self.current_invoice_start) + prorate_factor = get_prorata_factor(self.current_invoice_end, self.current_invoice_start, + self.generate_invoice_at_period_start) items = [] party = self.party @@ -445,7 +459,7 @@ class Subscription(Document): if not self.generate_invoice_at_period_start: return False - if self.is_new_subscription(): + if self.is_new_subscription() and getdate() >= getdate(self.current_invoice_start): return True # Check invoice dates and make sure it doesn't have outstanding invoices @@ -582,10 +596,13 @@ def get_calendar_months(billing_interval): return calendar_months -def get_prorata_factor(period_end, period_start): - diff = flt(date_diff(nowdate(), period_start) + 1) - plan_days = flt(date_diff(period_end, period_start) + 1) - prorate_factor = diff / plan_days +def get_prorata_factor(period_end, period_start, is_prepaid): + if is_prepaid: + prorate_factor = 1 + else: + diff = flt(date_diff(nowdate(), period_start) + 1) + plan_days = flt(date_diff(period_end, period_start) + 1) + prorate_factor = diff / plan_days return prorate_factor diff --git a/erpnext/accounts/doctype/subscription/subscription_list.js b/erpnext/accounts/doctype/subscription/subscription_list.js index a4edb77dc9e..c7325fb9f74 100644 --- a/erpnext/accounts/doctype/subscription/subscription_list.js +++ b/erpnext/accounts/doctype/subscription/subscription_list.js @@ -11,7 +11,7 @@ frappe.listview_settings['Subscription'] = { } else if(doc.status === 'Unpaid') { return [__("Unpaid"), "red"]; } else if(doc.status === 'Cancelled') { - return [__("Cancelled"), "darkgrey"]; + return [__("Cancelled"), "gray"]; } } }; \ No newline at end of file diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index 811fc356cf4..7c58e9865fd 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -237,7 +237,7 @@ class TestSubscription(unittest.TestCase): subscription.party_type = 'Customer' subscription.party = '_Test Customer' subscription.append('plans', {'plan': '_Test Plan Name', 'qty': 1}) - subscription.start_date = '2018-01-01' + subscription.start_date = add_days(nowdate(), -1000) subscription.insert() subscription.process() # generate first invoice @@ -321,7 +321,8 @@ class TestSubscription(unittest.TestCase): self.assertEqual( flt( - get_prorata_factor(subscription.current_invoice_end, subscription.current_invoice_start), + get_prorata_factor(subscription.current_invoice_end, subscription.current_invoice_start, + subscription.generate_invoice_at_period_start), 2), flt(prorate_factor, 2) ) @@ -561,9 +562,7 @@ class TestSubscription(unittest.TestCase): current_inv = subscription.get_current_invoice() self.assertEqual(current_inv.status, "Unpaid") - diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1) - plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1) - prorate_factor = flt(diff / plan_days) + prorate_factor = 1 self.assertEqual(flt(current_inv.grand_total, 2), flt(prorate_factor * 900, 2)) diff --git a/erpnext/accounts/doctype/subscription_invoice/subscription_invoice.json b/erpnext/accounts/doctype/subscription_invoice/subscription_invoice.json index f54e887f263..8a0d1de94c1 100644 --- a/erpnext/accounts/doctype/subscription_invoice/subscription_invoice.json +++ b/erpnext/accounts/doctype/subscription_invoice/subscription_invoice.json @@ -13,21 +13,28 @@ "fieldname": "document_type", "fieldtype": "Link", "label": "Document Type ", + "no_copy": 1, "options": "DocType", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "invoice", "fieldtype": "Dynamic Link", "in_list_view": 1, "label": "Invoice", + "no_copy": 1, "options": "document_type", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-06-01 22:23:54.462718", + "modified": "2021-02-09 15:43:32.026233", "modified_by": "Administrator", "module": "Accounts", "name": "Subscription Invoice", diff --git a/erpnext/accounts/doctype/tax_category/tax_category.json b/erpnext/accounts/doctype/tax_category/tax_category.json index 6f682a0466d..f7145af44c3 100644 --- a/erpnext/accounts/doctype/tax_category/tax_category.json +++ b/erpnext/accounts/doctype/tax_category/tax_category.json @@ -11,15 +11,18 @@ ], "fields": [ { + "allow_in_quick_entry": 1, "fieldname": "title", "fieldtype": "Data", + "in_list_view": 1, "label": "Title", + "reqd": 1, "unique": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-08-30 19:41:25.783852", + "modified": "2021-03-03 11:50:38.748872", "modified_by": "Administrator", "module": "Accounts", "name": "Tax Category", diff --git a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py index bbbcc7f3a69..632e30db45d 100644 --- a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py @@ -6,6 +6,8 @@ from __future__ import unicode_literals import frappe import unittest from erpnext.accounts.doctype.tax_rule.tax_rule import IncorrectCustomerGroup, IncorrectSupplierType, ConflictingTaxRule, get_tax_template +from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity +from erpnext.crm.doctype.opportunity.opportunity import make_quotation test_records = frappe.get_test_records('Tax Rule') @@ -144,6 +146,23 @@ class TestTaxRule(unittest.TestCase): self.assertEqual(get_tax_template("2015-01-01", {"customer":"_Test Customer", "billing_city": "Test City 1"}), "_Test Sales Taxes and Charges Template 1 - _TC") + def test_taxes_fetch_via_tax_rule(self): + make_tax_rule(customer= "_Test Customer", billing_city = "_Test City", + sales_tax_template = "_Test Sales Taxes and Charges Template - _TC", save=1) + + # create opportunity for customer + opportunity = make_opportunity(with_items=1) + + # make quotation from opportunity + quotation = make_quotation(opportunity.name) + quotation.save() + + self.assertEqual(quotation.taxes_and_charges, "_Test Sales Taxes and Charges Template - _TC") + + # Check if accounts heads and rate fetched are also fetched from tax template or not + self.assertTrue(len(quotation.taxes) > 0) + + def make_tax_rule(**args): args = frappe._dict(args) 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 83d7967f793..961bdb147f2 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -12,37 +12,62 @@ from erpnext.accounts.utils import get_fiscal_year class TaxWithholdingCategory(Document): pass -def get_party_tax_withholding_details(ref_doc, tax_withholding_category=None): +def get_party_details(inv): + party_type, party = '', '' + if inv.doctype == 'Sales Invoice': + party_type = 'Customer' + party = inv.customer + else: + party_type = 'Supplier' + party = inv.supplier + + return party_type, party + +def get_party_tax_withholding_details(inv, tax_withholding_category=None): pan_no = '' - suppliers = [] + parties = [] + party_type, party = get_party_details(inv) if not tax_withholding_category: - tax_withholding_category, pan_no = frappe.db.get_value('Supplier', ref_doc.supplier, ['tax_withholding_category', 'pan']) + tax_withholding_category, pan_no = frappe.db.get_value(party_type, party, ['tax_withholding_category', 'pan']) if not tax_withholding_category: return + # if tax_withholding_category passed as an argument but not pan_no if not pan_no: - pan_no = frappe.db.get_value('Supplier', ref_doc.supplier, 'pan') + pan_no = frappe.db.get_value(party_type, party, 'pan') # Get others suppliers with the same PAN No if pan_no: - suppliers = [d.name for d in frappe.get_all('Supplier', fields=['name'], filters={'pan': pan_no})] + parties = frappe.get_all(party_type, filters={ 'pan': pan_no }, pluck='name') - if not suppliers: - suppliers.append(ref_doc.supplier) + if not parties: + parties.append(party) + + fiscal_year = get_fiscal_year(inv.posting_date, company=inv.company) + tax_details = get_tax_withholding_details(tax_withholding_category, fiscal_year[0], inv.company) - fy = get_fiscal_year(ref_doc.posting_date, company=ref_doc.company) - tax_details = get_tax_withholding_details(tax_withholding_category, fy[0], ref_doc.company) if not tax_details: frappe.throw(_('Please set associated account in Tax Withholding Category {0} against Company {1}') - .format(tax_withholding_category, ref_doc.company)) + .format(tax_withholding_category, inv.company)) - tds_amount = get_tds_amount(suppliers, ref_doc.net_total, ref_doc.company, - tax_details, fy, ref_doc.posting_date, pan_no) + if party_type == 'Customer' and not tax_details.cumulative_threshold: + # TCS is only chargeable on sum of invoiced value + frappe.throw(_('Tax Withholding Category {} against Company {} for Customer {} should have Cumulative Threshold value.') + .format(tax_withholding_category, inv.company, party)) - tax_row = get_tax_row(tax_details, tds_amount) + tax_amount, tax_deducted = get_tax_amount( + party_type, parties, + inv, tax_details, + fiscal_year, pan_no + ) + + if party_type == 'Supplier': + tax_row = get_tax_row_for_tds(tax_details, tax_amount) + else: + tax_row = get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted) return tax_row @@ -69,145 +94,254 @@ def get_tax_withholding_rates(tax_withholding, fiscal_year): frappe.throw(_("No Tax Withholding data found for the current Fiscal Year.")) -def get_tax_row(tax_details, tds_amount): - - return { +def get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted): + row = { "category": "Total", - "add_deduct_tax": "Deduct", "charge_type": "Actual", - "account_head": tax_details.account_head, + "tax_amount": tax_amount, "description": tax_details.description, - "tax_amount": tds_amount + "account_head": tax_details.account_head } -def get_tds_amount(suppliers, net_total, company, tax_details, fiscal_year_details, posting_date, pan_no=None): - fiscal_year, year_start_date, year_end_date = fiscal_year_details - tds_amount = 0 - tds_deducted = 0 + if tax_deducted: + # TCS already deducted on previous invoices + # So, TCS will be calculated by 'Previous Row Total' - def _get_tds(amount, rate): - if amount <= 0: - return 0 - - return amount * rate / 100 - - ldc_name = frappe.db.get_value('Lower Deduction Certificate', - { - 'pan_no': pan_no, - 'fiscal_year': fiscal_year - }, 'name') - ldc = '' - - if ldc_name: - ldc = frappe.get_doc('Lower Deduction Certificate', ldc_name) - - entries = frappe.db.sql(""" - select voucher_no, credit - from `tabGL Entry` - where company = %s and - party in %s and fiscal_year=%s and credit > 0 - """, (company, tuple(suppliers), fiscal_year), as_dict=1) - - vouchers = [d.voucher_no for d in entries] - advance_vouchers = get_advance_vouchers(suppliers, fiscal_year=fiscal_year, company=company) - - tds_vouchers = vouchers + advance_vouchers - - if tds_vouchers: - tds_deducted = frappe.db.sql(""" - SELECT sum(credit) FROM `tabGL Entry` - WHERE - account=%s and fiscal_year=%s and credit > 0 - and voucher_no in ({0})""". format(','.join(['%s'] * len(tds_vouchers))), - ((tax_details.account_head, fiscal_year) + tuple(tds_vouchers))) - - tds_deducted = tds_deducted[0][0] if tds_deducted and tds_deducted[0][0] else 0 - - if tds_deducted: - if ldc: - limit_consumed = frappe.db.get_value('Purchase Invoice', - { - 'supplier': ('in', suppliers), - 'apply_tds': 1, - 'docstatus': 1 - }, 'sum(net_total)') - - if ldc and is_valid_certificate(ldc.valid_from, ldc.valid_upto, posting_date, limit_consumed, net_total, - ldc.certificate_limit): - - tds_amount = get_ltds_amount(net_total, limit_consumed, ldc.certificate_limit, ldc.rate, tax_details) + taxes_excluding_tcs = [d for d in inv.taxes if d.account_head != tax_details.account_head] + if taxes_excluding_tcs: + # chargeable amount is the total amount after other charges are applied + row.update({ + "charge_type": "On Previous Row Total", + "row_id": len(taxes_excluding_tcs), + "rate": tax_details.rate + }) else: - tds_amount = _get_tds(net_total, tax_details.rate) - else: - supplier_credit_amount = frappe.get_all('Purchase Invoice Item', - fields = ['sum(net_amount)'], - filters = {'parent': ('in', vouchers), 'docstatus': 1}, as_list=1) + # if only TCS is to be charged, then net total is chargeable amount + row.update({ + "charge_type": "On Net Total", + "rate": tax_details.rate + }) - supplier_credit_amount = (supplier_credit_amount[0][0] - if supplier_credit_amount and supplier_credit_amount[0][0] else 0) + return row - jv_supplier_credit_amt = frappe.get_all('Journal Entry Account', - fields = ['sum(credit_in_account_currency)'], - filters = { - 'parent': ('in', vouchers), 'docstatus': 1, - 'party': ('in', suppliers), - 'reference_type': ('not in', ['Purchase Invoice']) - }, as_list=1) +def get_tax_row_for_tds(tax_details, tax_amount): + return { + "category": "Total", + "charge_type": "Actual", + "tax_amount": tax_amount, + "add_deduct_tax": "Deduct", + "description": tax_details.description, + "account_head": tax_details.account_head + } - supplier_credit_amount += (jv_supplier_credit_amt[0][0] - if jv_supplier_credit_amt and jv_supplier_credit_amt[0][0] else 0) +def get_lower_deduction_certificate(fiscal_year, pan_no): + ldc_name = frappe.db.get_value('Lower Deduction Certificate', { 'pan_no': pan_no, 'fiscal_year': fiscal_year }, 'name') + if ldc_name: + return frappe.get_doc('Lower Deduction Certificate', ldc_name) - supplier_credit_amount += net_total +def get_tax_amount(party_type, parties, inv, tax_details, fiscal_year_details, pan_no=None): + fiscal_year = fiscal_year_details[0] - debit_note_amount = get_debit_note_amount(suppliers, year_start_date, year_end_date) - supplier_credit_amount -= debit_note_amount + vouchers = get_invoice_vouchers(parties, fiscal_year, inv.company, party_type=party_type) + advance_vouchers = get_advance_vouchers(parties, fiscal_year, inv.company, party_type=party_type) + taxable_vouchers = vouchers + advance_vouchers - if ((tax_details.get('threshold', 0) and supplier_credit_amount >= tax_details.threshold) - or (tax_details.get('cumulative_threshold', 0) and supplier_credit_amount >= tax_details.cumulative_threshold)): + tax_deducted = 0 + if taxable_vouchers: + tax_deducted = get_deducted_tax(taxable_vouchers, fiscal_year, tax_details) - if ldc and is_valid_certificate(ldc.valid_from, ldc.valid_upto, posting_date, tds_deducted, net_total, - ldc.certificate_limit): - tds_amount = get_ltds_amount(supplier_credit_amount, 0, ldc.certificate_limit, ldc.rate, - tax_details) + tax_amount = 0 + posting_date = inv.posting_date + if party_type == 'Supplier': + ldc = get_lower_deduction_certificate(fiscal_year, pan_no) + if tax_deducted: + net_total = inv.net_total + if ldc: + tax_amount = get_tds_amount_from_ldc(ldc, parties, fiscal_year, pan_no, tax_details, posting_date, net_total) else: - tds_amount = _get_tds(supplier_credit_amount, tax_details.rate) + tax_amount = net_total * tax_details.rate / 100 if net_total > 0 else 0 + else: + tax_amount = get_tds_amount( + ldc, parties, inv, tax_details, + fiscal_year_details, tax_deducted, vouchers + ) + + elif party_type == 'Customer': + if tax_deducted: + # if already TCS is charged, then amount will be calculated based on 'Previous Row Total' + tax_amount = 0 + else: + # if no TCS has been charged in FY, + # then chargeable value is "prev invoices + advances" value which cross the threshold + tax_amount = get_tcs_amount( + parties, inv, tax_details, + fiscal_year_details, vouchers, advance_vouchers + ) + + return tax_amount, tax_deducted + +def get_invoice_vouchers(parties, fiscal_year, company, party_type='Supplier'): + dr_or_cr = 'credit' if party_type == 'Supplier' else 'debit' + + filters = { + dr_or_cr: ['>', 0], + 'company': company, + 'party_type': party_type, + 'party': ['in', parties], + 'fiscal_year': fiscal_year, + 'is_opening': 'No', + 'is_cancelled': 0 + } + + return frappe.get_all('GL Entry', filters=filters, distinct=1, pluck="voucher_no") or [""] + +def get_advance_vouchers(parties, fiscal_year=None, company=None, from_date=None, to_date=None, party_type='Supplier'): + # for advance vouchers, debit and credit is reversed + dr_or_cr = 'debit' if party_type == 'Supplier' else 'credit' + + filters = { + dr_or_cr: ['>', 0], + 'is_opening': 'No', + 'is_cancelled': 0, + 'party_type': party_type, + 'party': ['in', parties], + 'against_voucher': ['is', 'not set'] + } + + if fiscal_year: + filters['fiscal_year'] = fiscal_year + if company: + filters['company'] = company + if from_date and to_date: + filters['posting_date'] = ['between', (from_date, to_date)] + + return frappe.get_all('GL Entry', filters=filters, distinct=1, pluck='voucher_no') or [""] + +def get_deducted_tax(taxable_vouchers, fiscal_year, tax_details): + # check if TDS / TCS account is already charged on taxable vouchers + filters = { + 'is_cancelled': 0, + 'credit': ['>', 0], + 'fiscal_year': fiscal_year, + 'account': tax_details.account_head, + 'voucher_no': ['in', taxable_vouchers], + } + field = "sum(credit)" + + return frappe.db.get_value('GL Entry', filters, field) or 0.0 + +def get_tds_amount(ldc, parties, inv, tax_details, fiscal_year_details, tax_deducted, vouchers): + tds_amount = 0 + + supp_credit_amt = frappe.db.get_value('Purchase Invoice', { + 'name': ('in', vouchers), 'docstatus': 1, 'apply_tds': 1 + }, 'sum(net_total)') or 0.0 + + supp_jv_credit_amt = frappe.db.get_value('Journal Entry Account', { + 'parent': ('in', vouchers), 'docstatus': 1, + 'party': ('in', parties), 'reference_type': ('!=', 'Purchase Invoice') + }, 'sum(credit_in_account_currency)') or 0.0 + + supp_credit_amt += supp_jv_credit_amt + supp_credit_amt += inv.net_total + + debit_note_amount = get_debit_note_amount(parties, fiscal_year_details, inv.company) + supp_credit_amt -= debit_note_amount + + threshold = tax_details.get('threshold', 0) + cumulative_threshold = tax_details.get('cumulative_threshold', 0) + + if ((threshold and supp_credit_amt >= threshold) or (cumulative_threshold and supp_credit_amt >= cumulative_threshold)): + if ldc and is_valid_certificate( + ldc.valid_from, ldc.valid_upto, + inv.posting_date, tax_deducted, + inv.net_total, ldc.certificate_limit + ): + tds_amount = get_ltds_amount(supp_credit_amt, 0, ldc.certificate_limit, ldc.rate, tax_details) + else: + tds_amount = supp_credit_amt * tax_details.rate / 100 if supp_credit_amt > 0 else 0 return tds_amount -def get_advance_vouchers(suppliers, fiscal_year=None, company=None, from_date=None, to_date=None): - condition = "fiscal_year=%s" % fiscal_year +def get_tcs_amount(parties, inv, tax_details, fiscal_year_details, vouchers, adv_vouchers): + tcs_amount = 0 + fiscal_year, _, _ = fiscal_year_details + + # sum of debit entries made from sales invoices + invoiced_amt = frappe.db.get_value('GL Entry', { + 'is_cancelled': 0, + 'party': ['in', parties], + 'company': inv.company, + 'voucher_no': ['in', vouchers], + }, 'sum(debit)') or 0.0 + + # sum of credit entries made from PE / JV with unset 'against voucher' + advance_amt = frappe.db.get_value('GL Entry', { + 'is_cancelled': 0, + 'party': ['in', parties], + 'company': inv.company, + 'voucher_no': ['in', adv_vouchers], + }, 'sum(credit)') or 0.0 + + # sum of credit entries made from sales invoice + credit_note_amt = frappe.db.get_value('GL Entry', { + 'is_cancelled': 0, + 'credit': ['>', 0], + 'party': ['in', parties], + 'fiscal_year': fiscal_year, + 'company': inv.company, + 'voucher_type': 'Sales Invoice', + }, 'sum(credit)') or 0.0 + + cumulative_threshold = tax_details.get('cumulative_threshold', 0) + + current_invoice_total = get_invoice_total_without_tcs(inv, tax_details) + total_invoiced_amt = current_invoice_total + invoiced_amt + advance_amt - credit_note_amt + + if ((cumulative_threshold and total_invoiced_amt >= cumulative_threshold)): + chargeable_amt = total_invoiced_amt - cumulative_threshold + tcs_amount = chargeable_amt * tax_details.rate / 100 if chargeable_amt > 0 else 0 + + return tcs_amount + +def get_invoice_total_without_tcs(inv, tax_details): + tcs_tax_row = [d for d in inv.taxes if d.account_head == tax_details.account_head] + tcs_tax_row_amount = tcs_tax_row[0].base_tax_amount if tcs_tax_row else 0 + + return inv.grand_total - tcs_tax_row_amount + +def get_tds_amount_from_ldc(ldc, parties, fiscal_year, pan_no, tax_details, posting_date, net_total): + tds_amount = 0 + limit_consumed = frappe.db.get_value('Purchase Invoice', { + 'supplier': ('in', parties), + 'apply_tds': 1, + 'docstatus': 1 + }, 'sum(net_total)') + + if is_valid_certificate( + ldc.valid_from, ldc.valid_upto, + posting_date, limit_consumed, + net_total, ldc.certificate_limit + ): + tds_amount = get_ltds_amount(net_total, limit_consumed, ldc.certificate_limit, ldc.rate, tax_details) + + return tds_amount + +def get_debit_note_amount(suppliers, fiscal_year_details, company=None): + _, year_start_date, year_end_date = fiscal_year_details + + filters = { + 'supplier': ['in', suppliers], + 'is_return': 1, + 'docstatus': 1, + 'posting_date': ['between', (year_start_date, year_end_date)] + } + fields = ['abs(sum(net_total)) as net_total'] if company: - condition += "and company =%s" % (company) - if from_date and to_date: - condition += "and posting_date between %s and %s" % (from_date, to_date) + filters['company'] = company - ## Appending the same supplier again if length of suppliers list is 1 - ## since tuple of single element list contains None, For example ('Test Supplier 1', ) - ## and the below query fails - if len(suppliers) == 1: - suppliers.append(suppliers[0]) - - return frappe.db.sql_list(""" - select distinct voucher_no - from `tabGL Entry` - where party in %s and %s and debit > 0 - """, (tuple(suppliers), condition)) or [] - -def get_debit_note_amount(suppliers, year_start_date, year_end_date, company=None): - condition = "and 1=1" - if company: - condition = " and company=%s " % company - - if len(suppliers) == 1: - suppliers.append(suppliers[0]) - - return flt(frappe.db.sql(""" - select abs(sum(net_total)) - from `tabPurchase Invoice` - where supplier in %s and is_return=1 and docstatus=1 - and posting_date between %s and %s %s - """, (tuple(suppliers), year_start_date, year_end_date, condition))) + return frappe.get_all('Purchase Invoice', filters, fields)[0].get('net_total') or 0.0 def get_ltds_amount(current_amount, deducted_amount, certificate_limit, rate, tax_details): if current_amount < (certificate_limit - deducted_amount): @@ -225,4 +359,4 @@ def is_valid_certificate(valid_from, valid_upto, posting_date, deducted_amount, certificate_limit > deducted_amount): valid = True - return valid \ No newline at end of file + return valid 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 b1468999fc1..dd3b49aa04b 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 @@ -7,8 +7,9 @@ import frappe import unittest from frappe.utils import today from erpnext.accounts.utils import get_fiscal_year +from erpnext.buying.doctype.supplier.test_supplier import create_supplier -test_dependencies = ["Supplier Group"] +test_dependencies = ["Supplier Group", "Customer Group"] class TestTaxWithholdingCategory(unittest.TestCase): @classmethod @@ -17,6 +18,9 @@ class TestTaxWithholdingCategory(unittest.TestCase): create_records() create_tax_with_holding_category() + def tearDown(self): + cancel_invoices() + def test_cumulative_threshold_tds(self): frappe.db.set_value("Supplier", "Test TDS Supplier", "tax_withholding_category", "Cumulative Threshold TDS") invoices = [] @@ -101,15 +105,91 @@ class TestTaxWithholdingCategory(unittest.TestCase): for d in invoices: d.cancel() + def test_single_threshold_tds_with_previous_vouchers_and_no_tds(self): + invoices = [] + doc = create_supplier(supplier_name = "Test TDS Supplier ABC", + tax_withholding_category="Single Threshold TDS") + supplier = doc.name + + pi = create_purchase_invoice(supplier=supplier) + pi.submit() + invoices.append(pi) + + # TDS not applied + pi = create_purchase_invoice(supplier=supplier, do_not_apply_tds=True) + pi.submit() + invoices.append(pi) + + pi = create_purchase_invoice(supplier=supplier) + pi.submit() + invoices.append(pi) + + self.assertEqual(pi.taxes_and_charges_deducted, 2000) + self.assertEqual(pi.grand_total, 8000) + + # delete invoices to avoid clashing + for d in invoices: + d.cancel() + + def test_cumulative_threshold_tcs(self): + frappe.db.set_value("Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS") + invoices = [] + + # create invoices for lower than single threshold tax rate + for _ in range(2): + si = create_sales_invoice(customer = "Test TCS Customer") + si.submit() + invoices.append(si) + + # create another invoice whose total when added to previously created invoice, + # surpasses cumulative threshhold + si = create_sales_invoice(customer = "Test TCS Customer", rate=12000) + si.submit() + + # assert tax collection on total invoice amount created until now + tcs_charged = sum([d.base_tax_amount for d in si.taxes if d.account_head == 'TCS - _TC']) + self.assertEqual(tcs_charged, 200) + self.assertEqual(si.grand_total, 12200) + invoices.append(si) + + # TCS is already collected once, so going forward system will collect TCS on every invoice + si = create_sales_invoice(customer = "Test TCS Customer", rate=5000) + si.submit() + + tcs_charged = sum([d.base_tax_amount for d in si.taxes if d.account_head == 'TCS - _TC']) + self.assertEqual(tcs_charged, 500) + invoices.append(si) + + #delete invoices to avoid clashing + for d in invoices: + d.cancel() + +def cancel_invoices(): + purchase_invoices = frappe.get_all("Purchase Invoice", { + 'supplier': ['in', ['Test TDS Supplier', 'Test TDS Supplier1', 'Test TDS Supplier2']], + 'docstatus': 1 + }, pluck="name") + + sales_invoices = frappe.get_all("Sales Invoice", { + 'customer': 'Test TCS Customer', + 'docstatus': 1 + }, pluck="name") + + for d in purchase_invoices: + frappe.get_doc('Purchase Invoice', d).cancel() + + for d in sales_invoices: + frappe.get_doc('Sales Invoice', d).cancel() + def create_purchase_invoice(**args): # return sales invoice doc object - item = frappe.get_doc('Item', {'item_name': 'TDS Item'}) + item = frappe.db.get_value('Item', {'item_name': 'TDS Item'}, "name") args = frappe._dict(args) pi = frappe.get_doc({ "doctype": "Purchase Invoice", "posting_date": today(), - "apply_tds": 1, + "apply_tds": 0 if args.do_not_apply_tds else 1, "supplier": args.supplier, "company": '_Test Company', "taxes_and_charges": "", @@ -118,7 +198,7 @@ def create_purchase_invoice(**args): "taxes": [], "items": [{ 'doctype': 'Purchase Invoice Item', - 'item_code': item.name, + 'item_code': item, 'qty': args.qty or 1, 'rate': args.rate or 10000, 'cost_center': 'Main - _TC', @@ -129,6 +209,34 @@ def create_purchase_invoice(**args): pi.save() return pi +def create_sales_invoice(**args): + # return sales invoice doc object + item = frappe.db.get_value('Item', {'item_name': 'TCS Item'}, "name") + + args = frappe._dict(args) + si = frappe.get_doc({ + "doctype": "Sales Invoice", + "posting_date": today(), + "customer": args.customer, + "company": '_Test Company', + "taxes_and_charges": "", + "currency": "INR", + "debit_to": "Debtors - _TC", + "taxes": [], + "items": [{ + 'doctype': 'Sales Invoice Item', + 'item_code': item, + 'qty': args.qty or 1, + 'rate': args.rate or 10000, + 'cost_center': 'Main - _TC', + 'expense_account': 'Cost of Goods Sold - _TC', + 'warehouse': args.warehouse or '_Test Warehouse - _TC' + }] + }) + + si.save() + return si + def create_records(): # create a new suppliers for name in ['Test TDS Supplier', 'Test TDS Supplier1', 'Test TDS Supplier2']: @@ -141,7 +249,17 @@ def create_records(): "doctype": "Supplier", }).insert() - # create an item + for name in ['Test TCS Customer']: + if frappe.db.exists('Customer', name): + continue + + frappe.get_doc({ + "customer_group": "_Test Customer Group", + "customer_name": name, + "doctype": "Customer" + }).insert() + + # create item if not frappe.db.exists('Item', "TDS Item"): frappe.get_doc({ "doctype": "Item", @@ -151,7 +269,16 @@ def create_records(): "is_stock_item": 0, }).insert() - # create an account + if not frappe.db.exists('Item', "TCS Item"): + frappe.get_doc({ + "doctype": "Item", + "item_code": "TCS Item", + "item_name": "TCS Item", + "item_group": "All Item Groups", + "is_stock_item": 1 + }).insert() + + # create tds account if not frappe.db.exists("Account", "TDS - _TC"): frappe.get_doc({ 'doctype': 'Account', @@ -162,6 +289,17 @@ def create_records(): 'root_type': 'Asset' }).insert() + # create tcs account + if not frappe.db.exists("Account", "TCS - _TC"): + frappe.get_doc({ + 'doctype': 'Account', + 'company': '_Test Company', + 'account_name': 'TCS', + 'parent_account': 'Duties and Taxes - _TC', + 'report_type': 'Balance Sheet', + 'root_type': 'Liability' + }).insert() + def create_tax_with_holding_category(): fiscal_year = get_fiscal_year(today(), company="_Test Company")[0] @@ -183,6 +321,23 @@ def create_tax_with_holding_category(): }] }).insert() + 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": [{ + 'fiscal_year': fiscal_year, + 'tax_withholding_rate': 10, + 'single_threshold': 0, + 'cumulative_threshold': 30000.00 + }], + "accounts": [{ + 'company': '_Test Company', + 'account': 'TCS - _TC' + }] + }).insert() + # Single thresold if not frappe.db.exists("Tax Withholding Category", "Single Threshold TDS"): frappe.get_doc({ @@ -199,4 +354,4 @@ def create_tax_with_holding_category(): 'company': '_Test Company', 'account': 'TDS - _TC' }] - }).insert() \ No newline at end of file + }).insert() diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index c12e006d2b2..dac0c216c8a 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -5,23 +5,19 @@ from __future__ import unicode_literals import frappe, erpnext from frappe.utils import flt, cstr, cint, comma_and, today, getdate, formatdate, now from frappe import _ -from erpnext.accounts.utils import get_stock_and_account_balance from frappe.model.meta import get_field_precision from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions - class ClosedAccountingPeriod(frappe.ValidationError): pass -class StockAccountInvalidTransaction(frappe.ValidationError): pass -class StockValueAndAccountBalanceOutOfSync(frappe.ValidationError): pass -def make_gl_entries(gl_map, cancel=False, adv_adj=False, merge_entries=True, update_outstanding='Yes'): +def make_gl_entries(gl_map, cancel=False, adv_adj=False, merge_entries=True, update_outstanding='Yes', from_repost=False): if gl_map: if not cancel: validate_accounting_period(gl_map) gl_map = process_gl_map(gl_map, merge_entries) if gl_map and len(gl_map) > 1: - save_entries(gl_map, adv_adj, update_outstanding) + save_entries(gl_map, adv_adj, update_outstanding, from_repost) else: frappe.throw(_("Incorrect number of General Ledger Entries found. You might have selected a wrong Account in the transaction.")) else: @@ -48,9 +44,9 @@ def validate_accounting_period(gl_map): frappe.throw(_("You cannot create or cancel any accounting entries with in the closed Accounting Period {0}") .format(frappe.bold(accounting_periods[0].name)), ClosedAccountingPeriod) -def process_gl_map(gl_map, merge_entries=True): +def process_gl_map(gl_map, merge_entries=True, precision=None): if merge_entries: - gl_map = merge_similar_entries(gl_map) + gl_map = merge_similar_entries(gl_map, precision) for entry in gl_map: # toggle debit, credit if negative entry if flt(entry.debit) < 0: @@ -73,7 +69,7 @@ def process_gl_map(gl_map, merge_entries=True): return gl_map -def merge_similar_entries(gl_map): +def merge_similar_entries(gl_map, precision=None): merged_gl_map = [] accounting_dimensions = get_accounting_dimensions() for entry in gl_map: @@ -92,7 +88,9 @@ def merge_similar_entries(gl_map): company = gl_map[0].company if gl_map else erpnext.get_default_company() company_currency = erpnext.get_company_currency(company) - precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), company_currency) + + if not precision: + precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), company_currency) # filter zero debit and credit entries merged_gl_map = filter(lambda x: flt(x.debit, precision)!=0 or flt(x.credit, precision)!=0, merged_gl_map) @@ -119,8 +117,9 @@ def check_if_in_list(gle, gl_map, dimensions=None): if same_head: return e -def save_entries(gl_map, adv_adj, update_outstanding): - validate_cwip_accounts(gl_map) +def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False): + if not from_repost: + validate_cwip_accounts(gl_map) round_off_debit_credit(gl_map) @@ -128,76 +127,19 @@ def save_entries(gl_map, adv_adj, update_outstanding): check_freezing_date(gl_map[0]["posting_date"], adv_adj) for entry in gl_map: - make_entry(entry, adv_adj, update_outstanding) + make_entry(entry, adv_adj, update_outstanding, from_repost) - # check against budget - validate_expense_against_budget(entry) - - validate_account_for_perpetual_inventory(gl_map) - - -def make_entry(args, adv_adj, update_outstanding): +def make_entry(args, adv_adj, update_outstanding, from_repost=False): gle = frappe.new_doc("GL Entry") gle.update(args) gle.flags.ignore_permissions = 1 - gle.insert() - gle.run_method("on_update_with_args", adv_adj, update_outstanding) + gle.flags.from_repost = from_repost + gle.flags.adv_adj = adv_adj + gle.flags.update_outstanding = update_outstanding or 'Yes' gle.submit() - # check against budget - validate_expense_against_budget(args) - -def validate_account_for_perpetual_inventory(gl_map): - if cint(erpnext.is_perpetual_inventory_enabled(gl_map[0].company)): - account_list = [gl_entries.account for gl_entries in gl_map] - - aii_accounts = [d.name for d in frappe.get_all("Account", - filters={'account_type': 'Stock', 'is_group': 0, 'company': gl_map[0].company})] - - for account in account_list: - if account not in aii_accounts: - continue - - # Always use current date to get stock and account balance as there can future entries for - # other items - account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(account, - getdate(), gl_map[0].company) - - if gl_map[0].voucher_type=="Journal Entry": - # In case of Journal Entry, there are no corresponding SL entries, - # hence deducting currency amount - account_bal -= flt(gl_map[0].debit) - flt(gl_map[0].credit) - if account_bal == stock_bal: - frappe.throw(_("Account: {0} can only be updated via Stock Transactions") - .format(account), StockAccountInvalidTransaction) - - elif account_bal != stock_bal: - precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), - currency=frappe.get_cached_value('Company', gl_map[0].company, "default_currency")) - - diff = flt(stock_bal - account_bal, precision) - error_reason = _("Stock Value ({0}) and Account Balance ({1}) are out of sync for account {2} and it's linked warehouses.").format( - stock_bal, account_bal, frappe.bold(account)) - error_resolution = _("Please create adjustment Journal Entry for amount {0} ").format(frappe.bold(diff)) - stock_adjustment_account = frappe.db.get_value("Company",gl_map[0].company,"stock_adjustment_account") - - db_or_cr_warehouse_account =('credit_in_account_currency' if diff < 0 else 'debit_in_account_currency') - db_or_cr_stock_adjustment_account = ('debit_in_account_currency' if diff < 0 else 'credit_in_account_currency') - - journal_entry_args = { - 'accounts':[ - {'account': account, db_or_cr_warehouse_account : abs(diff)}, - {'account': stock_adjustment_account, db_or_cr_stock_adjustment_account : abs(diff) }] - } - - frappe.msgprint(msg="""{0}

    {1}

    """.format(error_reason, error_resolution), - raise_exception=StockValueAndAccountBalanceOutOfSync, - title=_('Values Out Of Sync'), - primary_action={ - 'label': _('Make Journal Entry'), - 'client_action': 'erpnext.route_to_adjustment_jv', - 'args': journal_entry_args - }) + if not from_repost: + validate_expense_against_budget(args) def validate_cwip_accounts(gl_map): cwip_enabled = any([cint(ac.enable_cwip_accounting) for ac in frappe.db.get_all("Asset Category","enable_cwip_accounting")]) @@ -254,7 +196,7 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision): if not round_off_gle: for k in ["voucher_type", "voucher_no", "company", - "posting_date", "remarks", "is_opening"]: + "posting_date", "remarks"]: round_off_gle[k] = gl_map[0][k] round_off_gle.update({ @@ -266,6 +208,7 @@ def make_round_off_gle(gl_map, debit_credit_diff, precision): "cost_center": round_off_cost_center, "party_type": None, "party": None, + "is_opening": "No", "against_voucher_type": None, "against_voucher": None }) diff --git a/erpnext/accounts/module_onboarding/accounts/accounts.json b/erpnext/accounts/module_onboarding/accounts/accounts.json index ba1a779b4c1..6b5c5a1db88 100644 --- a/erpnext/accounts/module_onboarding/accounts/accounts.json +++ b/erpnext/accounts/module_onboarding/accounts/accounts.json @@ -13,14 +13,14 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/accounts", "idx": 0, "is_complete": 0, - "modified": "2020-07-08 14:06:09.033880", + "modified": "2020-10-30 15:41:15.547225", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts", "owner": "Administrator", "steps": [ { - "step": "Chart Of Accounts" + "step": "Chart of Accounts" }, { "step": "Setup Taxes" diff --git a/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json b/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json index cbd022bfdbe..fc49bd652b3 100644 --- a/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json +++ b/erpnext/accounts/onboarding_step/chart_of_accounts/chart_of_accounts.json @@ -1,20 +1,25 @@ { "action": "Go to Page", + "action_label": "View Chart of Accounts", + "callback_message": "You can continue with the onboarding after exploring this page", + "callback_title": "Awesome Work", "creation": "2020-05-13 19:58:20.928127", + "description": "# Chart Of Accounts\n\nThe Chart of Accounts is the blueprint of the accounts in your organization.\nIt is a tree view of the names of the Accounts (Ledgers and Groups) that a Company requires to manage its books of accounts. ERPNext sets up a simple chart of accounts for each Company you create, but you can modify it according to your needs and legal requirements.\n\nFor each company, Chart of Accounts signifies the way to classify the accounting entries, mostly\nbased on statutory (tax, compliance to government regulations) requirements.\n\nThere's a brief video tutorial about chart of accounts in the next step.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, + "intro_video_url": "https://www.youtube.com/embed/AcfMCT7wLLo", "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 17:40:28.410447", + "modified": "2020-10-30 14:35:59.474920", "modified_by": "Administrator", - "name": "Chart Of Accounts", + "name": "Chart of Accounts", "owner": "Administrator", "path": "Tree/Account", "reference_document": "Account", + "show_form_tour": 0, "show_full_form": 0, - "title": "Review Chart Of Accounts", + "title": "Review Chart of Accounts", "validate_action": 0 } \ No newline at end of file diff --git a/erpnext/accounts/onboarding_step/configure_account_settings/configure_account_settings.json b/erpnext/accounts/onboarding_step/configure_account_settings/configure_account_settings.json index c8be357de0a..c84430a0c64 100644 --- a/erpnext/accounts/onboarding_step/configure_account_settings/configure_account_settings.json +++ b/erpnext/accounts/onboarding_step/configure_account_settings/configure_account_settings.json @@ -1,18 +1,19 @@ { - "action": "Create Entry", + "action": "Show Form Tour", "creation": "2020-05-14 17:53:00.876946", + "description": "# Account Settings\n\nThis is a crucial piece of configuration. There are various account settings in ERPNext to restrict and configure actions in the Accounting module.\n\nThe following settings are avaialble for you to configure\n\n1. Account Freezing \n2. Credit and Overbilling\n3. Invoicing and Tax Automations\n4. Balance Sheet configurations\n\nThere's much more, you can check it all out in this step", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 1, "is_skipped": 0, - "modified": "2020-05-14 18:06:25.212923", + "modified": "2020-10-19 14:40:55.584484", "modified_by": "Administrator", "name": "Configure Account Settings", "owner": "Administrator", "reference_document": "Accounts Settings", + "show_form_tour": 0, "show_full_form": 1, "title": "Configure Account Settings", "validate_action": 1 diff --git a/erpnext/accounts/onboarding_step/create_a_customer/create_a_customer.json b/erpnext/accounts/onboarding_step/create_a_customer/create_a_customer.json index 5a403b06cf0..0b6750c5f8e 100644 --- a/erpnext/accounts/onboarding_step/create_a_customer/create_a_customer.json +++ b/erpnext/accounts/onboarding_step/create_a_customer/create_a_customer.json @@ -1,18 +1,19 @@ { "action": "Create Entry", "creation": "2020-05-14 17:46:41.831517", + "description": "## Who is a Customer?\n\nA customer, who is sometimes known as a client, buyer, or purchaser is the one who receives goods, services, products, or ideas, from a seller for a monetary consideration.\n\nEvery customer needs to be assigned a unique id. Customer name itself can be the id or you can set a naming series for ids to be generated in Selling Settings.\n\nJust like the supplier, let's quickly create a customer.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-06-01 13:16:19.731719", + "modified": "2020-10-30 15:28:46.659660", "modified_by": "Administrator", "name": "Create a Customer", "owner": "Administrator", "reference_document": "Customer", + "show_form_tour": 0, "show_full_form": 0, "title": "Create a Customer", "validate_action": 1 diff --git a/erpnext/accounts/onboarding_step/create_a_product/create_a_product.json b/erpnext/accounts/onboarding_step/create_a_product/create_a_product.json index d2068e167b7..d76f645d96f 100644 --- a/erpnext/accounts/onboarding_step/create_a_product/create_a_product.json +++ b/erpnext/accounts/onboarding_step/create_a_product/create_a_product.json @@ -1,19 +1,21 @@ { "action": "Create Entry", "creation": "2020-05-12 18:16:06.624554", + "description": "## Products and Services\n\nDepending on the nature of your business, you might be selling products or services to your clients or even both. \nERPNext is optimized for itemized management of your sales and purchase.\n\nThe **Item Master** is where you can add all your sales items. If you are in services, you can create an Item for each service that you offer. If you run a manufacturing business, the same master is used for keeping a record of raw materials, sub-assemblies etc.\n\nCompleting the Item Master is very essential for the successful implementation of ERPNext. We have a brief video introducing the item master for you, you can watch it in the next step.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, + "intro_video_url": "https://www.youtube.com/watch?v=Sl5UFA5H5EQ", "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-12 18:30:02.489949", + "modified": "2020-10-30 15:20:30.133495", "modified_by": "Administrator", "name": "Create a Product", "owner": "Administrator", "reference_document": "Item", + "show_form_tour": 0, "show_full_form": 0, - "title": "Create a Product", + "title": "Create a Sales Item", "validate_action": 1 } \ No newline at end of file diff --git a/erpnext/accounts/onboarding_step/create_a_supplier/create_a_supplier.json b/erpnext/accounts/onboarding_step/create_a_supplier/create_a_supplier.json index 7a64224bd43..64bc7bbfef9 100644 --- a/erpnext/accounts/onboarding_step/create_a_supplier/create_a_supplier.json +++ b/erpnext/accounts/onboarding_step/create_a_supplier/create_a_supplier.json @@ -1,18 +1,19 @@ { "action": "Create Entry", "creation": "2020-05-14 22:09:10.043554", + "description": "## Who is a Supplier?\n\nSuppliers are companies or individuals who provide you with products or services. ERPNext has comprehensive features for purchase cycles. \n\nLet's quickly create a supplier with the minimal details required. You need the name of the supplier, assign the supplier to a group, and select the type of the supplier, viz. Company or Individual.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 22:09:10.043554", + "modified": "2020-10-30 15:26:48.315772", "modified_by": "Administrator", "name": "Create a Supplier", "owner": "Administrator", "reference_document": "Supplier", + "show_form_tour": 0, "show_full_form": 0, "title": "Create a Supplier", "validate_action": 1 diff --git a/erpnext/accounts/onboarding_step/create_your_first_purchase_invoice/create_your_first_purchase_invoice.json b/erpnext/accounts/onboarding_step/create_your_first_purchase_invoice/create_your_first_purchase_invoice.json index 3a2b8d39253..ddbc89ec0a7 100644 --- a/erpnext/accounts/onboarding_step/create_your_first_purchase_invoice/create_your_first_purchase_invoice.json +++ b/erpnext/accounts/onboarding_step/create_your_first_purchase_invoice/create_your_first_purchase_invoice.json @@ -1,18 +1,19 @@ { "action": "Create Entry", "creation": "2020-05-14 22:10:07.049704", + "description": "# What's a Purchase Invoice?\n\nA Purchase Invoice is a bill you receive from your Suppliers against which you need to make the payment.\nPurchase Invoice is the exact opposite of your Sales Invoice. Here you accrue expenses to your Supplier. \n\nThe following is what a typical purchase cycle looks like, however you can create a purchase invoice directly as well.\n\n![Purchase Flow](https://docs.erpnext.com/docs/assets/img/accounts/pi-flow.png)\n\n", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 22:10:07.049704", + "modified": "2020-10-30 15:30:26.337773", "modified_by": "Administrator", "name": "Create Your First Purchase Invoice", "owner": "Administrator", "reference_document": "Purchase Invoice", + "show_form_tour": 0, "show_full_form": 1, "title": "Create Your First Purchase Invoice ", "validate_action": 1 diff --git a/erpnext/accounts/onboarding_step/create_your_first_sales_invoice/create_your_first_sales_invoice.json b/erpnext/accounts/onboarding_step/create_your_first_sales_invoice/create_your_first_sales_invoice.json index 473de5079f5..9e7dd679001 100644 --- a/erpnext/accounts/onboarding_step/create_your_first_sales_invoice/create_your_first_sales_invoice.json +++ b/erpnext/accounts/onboarding_step/create_your_first_sales_invoice/create_your_first_sales_invoice.json @@ -1,18 +1,19 @@ { "action": "Create Entry", "creation": "2020-05-14 17:48:21.019019", + "description": "# All about sales invoice\n\nA Sales Invoice is a bill that you send to your Customers against which the Customer makes the payment. Sales Invoice is an accounting transaction. On submission of Sales Invoice, the system updates the receivable and books income against a Customer Account.\n\nHere's the flow of how a sales invoice is generally created\n\n\n![Sales Flow](https://docs.erpnext.com/docs/assets/img/accounts/so-flow.png)", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 17:48:21.019019", + "modified": "2020-10-16 12:59:16.987507", "modified_by": "Administrator", "name": "Create Your First Sales Invoice", "owner": "Administrator", "reference_document": "Sales Invoice", + "show_form_tour": 0, "show_full_form": 1, "title": "Create Your First Sales Invoice ", "validate_action": 1 diff --git a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json index 8e0006762d1..a4922013dab 100644 --- a/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json +++ b/erpnext/accounts/onboarding_step/setup_taxes/setup_taxes.json @@ -1,18 +1,20 @@ { "action": "Create Entry", + "action_label": "Make a Sales Tax Template", "creation": "2020-05-13 19:29:43.844463", + "description": "# Setting up Taxes\n\nAny sophisticated accounting system, including ERPNext will have automatic tax calculations for your transactions. These calculations are based on user defined rules in compliance to local rules and regulations.\n\nERPNext allows this via *Tax Templates*. These templates can be used in Sales Orders and Sales Invoices. Other types of charges that may apply to your invoices (like shipping, insurance etc.) can also be configured as taxes.\n\nFor Tax Accounts that you want to use in the tax templates, go to:\n\n`> Accounting > Taxes > Sales Taxes and Charges Template`\n\nYou can read more about these templates in our documentation [here](https://docs.erpnext.com/docs/user/manual/en/selling/sales-taxes-and-charges-template)\n", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 17:40:16.014413", + "modified": "2020-10-30 14:54:18.087383", "modified_by": "Administrator", "name": "Setup Taxes", "owner": "Administrator", "reference_document": "Sales Taxes and Charges Template", + "show_form_tour": 1, "show_full_form": 1, "title": "Lets create a Tax Template for Sales ", "validate_action": 0 diff --git a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.js b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.js deleted file mode 100644 index 97035278754..00000000000 --- a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.js +++ /dev/null @@ -1,585 +0,0 @@ -frappe.provide("erpnext.accounts"); - -frappe.pages['bank-reconciliation'].on_page_load = function(wrapper) { - new erpnext.accounts.bankReconciliation(wrapper); -} - -erpnext.accounts.bankReconciliation = class BankReconciliation { - constructor(wrapper) { - this.page = frappe.ui.make_app_page({ - parent: wrapper, - title: __("Bank Reconciliation"), - single_column: true - }); - this.parent = wrapper; - this.page = this.parent.page; - - this.check_plaid_status(); - this.make(); - } - - make() { - const me = this; - - me.$main_section = $(`
    `).appendTo(me.page.main); - const empty_state = __("Upload a bank statement, link or reconcile a bank account") - me.$main_section.append(`
    ${empty_state}
    `) - - me.page.add_field({ - fieldtype: 'Link', - label: __('Company'), - fieldname: 'company', - options: "Company", - onchange: function() { - if (this.value) { - me.company = this.value; - } else { - me.company = null; - me.bank_account = null; - } - } - }) - me.page.add_field({ - fieldtype: 'Link', - label: __('Bank Account'), - fieldname: 'bank_account', - options: "Bank Account", - get_query: function() { - if(!me.company) { - frappe.throw(__("Please select company first")); - return - } - - return { - filters: { - "company": me.company - } - } - }, - onchange: function() { - if (this.value) { - me.bank_account = this.value; - me.add_actions(); - } else { - me.bank_account = null; - me.page.hide_actions_menu(); - } - } - }) - } - - check_plaid_status() { - const me = this; - frappe.db.get_value("Plaid Settings", "Plaid Settings", "enabled", (r) => { - if (r && r.enabled === "1") { - me.plaid_status = "active" - } else { - me.plaid_status = "inactive" - } - }) - } - - add_actions() { - const me = this; - - me.page.show_menu() - - me.page.add_menu_item(__("Upload a statement"), function() { - me.clear_page_content(); - new erpnext.accounts.bankTransactionUpload(me); - }, true) - - if (me.plaid_status==="active") { - me.page.add_menu_item(__("Synchronize this account"), function() { - me.clear_page_content(); - new erpnext.accounts.bankTransactionSync(me); - }, true) - } - - me.page.add_menu_item(__("Reconcile this account"), function() { - me.clear_page_content(); - me.make_reconciliation_tool(); - }, true) - } - - clear_page_content() { - const me = this; - $(me.page.body).find('.frappe-list').remove(); - me.$main_section.empty(); - } - - make_reconciliation_tool() { - const me = this; - frappe.model.with_doctype("Bank Transaction", () => { - erpnext.accounts.ReconciliationList = new erpnext.accounts.ReconciliationTool({ - parent: me.parent, - doctype: "Bank Transaction" - }); - }) - } -} - - -erpnext.accounts.bankTransactionUpload = class bankTransactionUpload { - constructor(parent) { - this.parent = parent; - this.data = []; - - const assets = [ - "/assets/frappe/css/frappe-datatable.css", - "/assets/frappe/js/lib/clusterize.min.js", - "/assets/frappe/js/lib/Sortable.min.js", - "/assets/frappe/js/lib/frappe-datatable.js" - ]; - - frappe.require(assets, () => { - this.make(); - }); - } - - make() { - const me = this; - new frappe.ui.FileUploader({ - method: 'erpnext.accounts.doctype.bank_transaction.bank_transaction_upload.upload_bank_statement', - allow_multiple: 0, - on_success: function(attachment, r) { - if (!r.exc && r.message) { - me.data = r.message; - me.setup_transactions_dom(); - me.create_datatable(); - me.add_primary_action(); - } - } - }) - } - - setup_transactions_dom() { - const me = this; - me.parent.$main_section.append(`
    `) - } - - create_datatable() { - try { - this.datatable = new DataTable('.transactions-table', { - columns: this.data.columns, - data: this.data.data - }) - } - catch(err) { - let msg = __(`Your file could not be processed by ERPNext. -
    It should be a standard CSV or XLSX file. -
    The headers should be in the first row.`) - frappe.throw(msg) - } - - } - - add_primary_action() { - const me = this; - me.parent.page.set_primary_action(__("Submit"), function() { - me.add_bank_entries() - }, null, __("Creating bank entries...")) - } - - add_bank_entries() { - const me = this; - frappe.xcall('erpnext.accounts.doctype.bank_transaction.bank_transaction_upload.create_bank_entries', - {columns: this.datatable.datamanager.columns, data: this.datatable.datamanager.data, bank_account: me.parent.bank_account} - ).then((result) => { - let result_title = result.errors == 0 ? __("{0} bank transaction(s) created", [result.success]) : __("{0} bank transaction(s) created and {1} errors", [result.success, result.errors]) - let result_msg = ` -
    -
    ${result_title}
    -
    ` - me.parent.page.clear_primary_action(); - me.parent.$main_section.empty(); - me.parent.$main_section.append(result_msg); - if (result.errors == 0) { - frappe.show_alert({message:__("All bank transactions have been created"), indicator:'green'}); - } else { - frappe.show_alert({message:__("Please check the error log for details about the import errors"), indicator:'red'}); - } - }) - } -} - -erpnext.accounts.bankTransactionSync = class bankTransactionSync { - constructor(parent) { - this.parent = parent; - this.data = []; - - this.init_config() - } - - init_config() { - const me = this; - frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.get_plaid_configuration') - .then(result => { - me.plaid_env = result.plaid_env; - me.client_name = result.client_name; - me.link_token = result.link_token; - me.sync_transactions(); - }) - } - - sync_transactions() { - const me = this; - frappe.db.get_value("Bank Account", me.parent.bank_account, "bank", (r) => { - frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.sync_transactions', { - bank: r.bank, - bank_account: me.parent.bank_account, - freeze: true - }) - .then((result) => { - let result_title = (result && result.length > 0) - ? __("{0} bank transaction(s) created", [result.length]) - : __("This bank account is already synchronized"); - - let result_msg = ` -
    -
    ${result_title}
    -
    ` - - this.parent.$main_section.append(result_msg) - frappe.show_alert({ message: __("Bank account '{0}' has been synchronized", [me.parent.bank_account]), indicator: 'green' }); - }) - }) - } -} - - -erpnext.accounts.ReconciliationTool = class ReconciliationTool extends frappe.views.BaseList { - constructor(opts) { - super(opts); - this.show(); - } - - setup_defaults() { - super.setup_defaults(); - - this.page_title = __("Bank Reconciliation"); - this.doctype = 'Bank Transaction'; - this.fields = ['date', 'description', 'debit', 'credit', 'currency'] - - } - - setup_view() { - this.render_header(); - } - - setup_side_bar() { - // - } - - make_standard_filters() { - // - } - - freeze() { - this.$result.find('.list-count').html(`${__('Refreshing')}...`); - } - - get_args() { - const args = super.get_args(); - - return Object.assign({}, args, { - ...args.filters.push(["Bank Transaction", "docstatus", "=", 1], - ["Bank Transaction", "unallocated_amount", ">", 0]) - }); - - } - - update_data(r) { - let data = r.message || []; - - if (this.start === 0) { - this.data = data; - } else { - this.data = this.data.concat(data); - } - } - - render() { - const me = this; - this.$result.find('.list-row-container').remove(); - $('[data-fieldname="name"]').remove(); - me.data.map((value) => { - const row = $('
    ').data("data", value).appendTo(me.$result).get(0); - new erpnext.accounts.ReconciliationRow(row, value); - }) - } - - render_header() { - const me = this; - if ($(this.wrapper).find('.transaction-header').length === 0) { - me.$result.append(frappe.render_template("bank_transaction_header")); - } - } -} - -erpnext.accounts.ReconciliationRow = class ReconciliationRow { - constructor(row, data) { - this.data = data; - this.row = row; - this.make(); - this.bind_events(); - } - - make() { - $(this.row).append(frappe.render_template("bank_transaction_row", this.data)) - } - - bind_events() { - const me = this; - $(me.row).on('click', '.clickable-section', function() { - me.bank_entry = $(this).attr("data-name"); - me.show_dialog($(this).attr("data-name")); - }) - - $(me.row).on('click', '.new-reconciliation', function() { - me.bank_entry = $(this).attr("data-name"); - me.show_dialog($(this).attr("data-name")); - }) - - $(me.row).on('click', '.new-payment', function() { - me.bank_entry = $(this).attr("data-name"); - me.new_payment(); - }) - - $(me.row).on('click', '.new-invoice', function() { - me.bank_entry = $(this).attr("data-name"); - me.new_invoice(); - }) - - $(me.row).on('click', '.new-expense', function() { - me.bank_entry = $(this).attr("data-name"); - me.new_expense(); - }) - } - - new_payment() { - const me = this; - const paid_amount = me.data.credit > 0 ? me.data.credit : me.data.debit; - const payment_type = me.data.credit > 0 ? "Receive": "Pay"; - const party_type = me.data.credit > 0 ? "Customer": "Supplier"; - - frappe.new_doc("Payment Entry", {"payment_type": payment_type, "paid_amount": paid_amount, - "party_type": party_type, "paid_from": me.data.bank_account}) - } - - new_invoice() { - const me = this; - const invoice_type = me.data.credit > 0 ? "Sales Invoice" : "Purchase Invoice"; - - frappe.new_doc(invoice_type) - } - - new_expense() { - frappe.new_doc("Expense Claim") - } - - - show_dialog(data) { - const me = this; - - frappe.db.get_value("Bank Account", me.data.bank_account, "account", (r) => { - me.gl_account = r.account; - }) - - frappe.xcall('erpnext.accounts.page.bank_reconciliation.bank_reconciliation.get_linked_payments', - { bank_transaction: data, freeze: true, freeze_message: __("Finding linked payments") } - ).then((result) => { - me.make_dialog(result) - }) - } - - make_dialog(data) { - const me = this; - me.selected_payment = null; - - const fields = [ - { - fieldtype: 'Section Break', - fieldname: 'section_break_1', - label: __('Automatic Reconciliation') - }, - { - fieldtype: 'HTML', - fieldname: 'payment_proposals' - }, - { - fieldtype: 'Section Break', - fieldname: 'section_break_2', - label: __('Search for a payment') - }, - { - fieldtype: 'Link', - fieldname: 'payment_doctype', - options: 'DocType', - label: 'Payment DocType', - get_query: () => { - return { - filters : { - "name": ["in", ["Payment Entry", "Journal Entry", "Sales Invoice", "Purchase Invoice", "Expense Claim"]] - } - } - }, - }, - { - fieldtype: 'Column Break', - fieldname: 'column_break_1', - }, - { - fieldtype: 'Dynamic Link', - fieldname: 'payment_entry', - options: 'payment_doctype', - label: 'Payment Document', - get_query: () => { - let dt = this.dialog.fields_dict.payment_doctype.value; - if (dt === "Payment Entry") { - return { - query: "erpnext.accounts.page.bank_reconciliation.bank_reconciliation.payment_entry_query", - filters : { - "bank_account": this.data.bank_account, - "company": this.data.company - } - } - } else if (dt === "Journal Entry") { - return { - query: "erpnext.accounts.page.bank_reconciliation.bank_reconciliation.journal_entry_query", - filters : { - "bank_account": this.data.bank_account, - "company": this.data.company - } - } - } else if (dt === "Sales Invoice") { - return { - query: "erpnext.accounts.page.bank_reconciliation.bank_reconciliation.sales_invoices_query" - } - } else if (dt === "Purchase Invoice") { - return { - filters : [ - ["Purchase Invoice", "ifnull(clearance_date, '')", "=", ""], - ["Purchase Invoice", "docstatus", "=", 1], - ["Purchase Invoice", "company", "=", this.data.company] - ] - } - } else if (dt === "Expense Claim") { - return { - filters : [ - ["Expense Claim", "ifnull(clearance_date, '')", "=", ""], - ["Expense Claim", "docstatus", "=", 1], - ["Expense Claim", "company", "=", this.data.company] - ] - } - } - }, - onchange: function() { - if (me.selected_payment !== this.value) { - me.selected_payment = this.value; - me.display_payment_details(this); - } - } - }, - { - fieldtype: 'Section Break', - fieldname: 'section_break_3' - }, - { - fieldtype: 'HTML', - fieldname: 'payment_details' - }, - ]; - - me.dialog = new frappe.ui.Dialog({ - title: __("Choose a corresponding payment"), - fields: fields, - size: "large" - }); - - const proposals_wrapper = me.dialog.fields_dict.payment_proposals.$wrapper; - if (data && data.length > 0) { - proposals_wrapper.append(frappe.render_template("linked_payment_header")); - data.map(value => { - proposals_wrapper.append(frappe.render_template("linked_payment_row", value)) - }) - } else { - const empty_data_msg = __("ERPNext could not find any matching payment entry") - proposals_wrapper.append(`
    ${empty_data_msg}
    `) - } - - $(me.dialog.body).on('click', '.reconciliation-btn', (e) => { - const payment_entry = $(e.target).attr('data-name'); - const payment_doctype = $(e.target).attr('data-doctype'); - frappe.xcall('erpnext.accounts.page.bank_reconciliation.bank_reconciliation.reconcile', - {bank_transaction: me.bank_entry, payment_doctype: payment_doctype, payment_name: payment_entry}) - .then((result) => { - setTimeout(function(){ - erpnext.accounts.ReconciliationList.refresh(); - }, 2000); - me.dialog.hide(); - }) - }) - - me.dialog.show(); - } - - display_payment_details(event) { - const me = this; - if (event.value) { - let dt = me.dialog.fields_dict.payment_doctype.value; - me.dialog.fields_dict['payment_details'].$wrapper.empty(); - frappe.db.get_doc(dt, event.value) - .then(doc => { - let displayed_docs = [] - let payment = [] - if (dt === "Payment Entry") { - payment.currency = doc.payment_type == "Receive" ? doc.paid_to_account_currency : doc.paid_from_account_currency; - payment.doctype = dt - payment.posting_date = doc.posting_date; - payment.party = doc.party; - payment.reference_no = doc.reference_no; - payment.reference_date = doc.reference_date; - payment.paid_amount = doc.paid_amount; - payment.name = doc.name; - displayed_docs.push(payment); - } else if (dt === "Journal Entry") { - doc.accounts.forEach(payment => { - if (payment.account === me.gl_account) { - payment.doctype = dt; - payment.posting_date = doc.posting_date; - payment.party = doc.pay_to_recd_from; - payment.reference_no = doc.cheque_no; - payment.reference_date = doc.cheque_date; - payment.currency = payment.account_currency; - payment.paid_amount = payment.credit > 0 ? payment.credit : payment.debit; - payment.name = doc.name; - displayed_docs.push(payment); - } - }) - } else if (dt === "Sales Invoice") { - doc.payments.forEach(payment => { - if (payment.clearance_date === null || payment.clearance_date === "") { - payment.doctype = dt; - payment.posting_date = doc.posting_date; - payment.party = doc.customer; - payment.reference_no = doc.remarks; - payment.currency = doc.currency; - payment.paid_amount = payment.amount; - payment.name = doc.name; - displayed_docs.push(payment); - } - }) - } - - const details_wrapper = me.dialog.fields_dict.payment_details.$wrapper; - details_wrapper.append(frappe.render_template("linked_payment_header")); - displayed_docs.forEach(payment => { - details_wrapper.append(frappe.render_template("linked_payment_row", payment)); - }) - }) - } - - } -} diff --git a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.json b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.json deleted file mode 100644 index feea36860b1..00000000000 --- a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "content": null, - "creation": "2018-11-24 12:03:14.646669", - "docstatus": 0, - "doctype": "Page", - "idx": 0, - "modified": "2018-11-24 12:03:14.646669", - "modified_by": "Administrator", - "module": "Accounts", - "name": "bank-reconciliation", - "owner": "Administrator", - "page_name": "bank-reconciliation", - "roles": [ - { - "role": "System Manager" - }, - { - "role": "Accounts Manager" - }, - { - "role": "Accounts User" - } - ], - "script": null, - "standard": "Yes", - "style": null, - "system_page": 0, - "title": "Bank Reconciliation" -} \ No newline at end of file diff --git a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py b/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py deleted file mode 100644 index ce6baa68467..00000000000 --- a/erpnext/accounts/page/bank_reconciliation/bank_reconciliation.py +++ /dev/null @@ -1,386 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe import _ -import difflib -from frappe.utils import flt -from six import iteritems -from erpnext import get_company_currency - -@frappe.whitelist() -def reconcile(bank_transaction, payment_doctype, payment_name): - transaction = frappe.get_doc("Bank Transaction", bank_transaction) - payment_entry = frappe.get_doc(payment_doctype, payment_name) - - account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") - gl_entry = frappe.get_doc("GL Entry", dict(account=account, voucher_type=payment_doctype, voucher_no=payment_name)) - - if payment_doctype == "Payment Entry" and payment_entry.unallocated_amount > transaction.unallocated_amount: - frappe.throw(_("The unallocated amount of Payment Entry {0} \ - is greater than the Bank Transaction's unallocated amount").format(payment_name)) - - if transaction.unallocated_amount == 0: - frappe.throw(_("This bank transaction is already fully reconciled")) - - if transaction.credit > 0 and gl_entry.credit > 0: - frappe.throw(_("The selected payment entry should be linked with a debtor bank transaction")) - - if transaction.debit > 0 and gl_entry.debit > 0: - frappe.throw(_("The selected payment entry should be linked with a creditor bank transaction")) - - add_payment_to_transaction(transaction, payment_entry, gl_entry) - - return 'reconciled' - -def add_payment_to_transaction(transaction, payment_entry, gl_entry): - gl_amount, transaction_amount = (gl_entry.credit, transaction.debit) if gl_entry.credit > 0 else (gl_entry.debit, transaction.credit) - allocated_amount = gl_amount if gl_amount <= transaction_amount else transaction_amount - transaction.append("payment_entries", { - "payment_document": payment_entry.doctype, - "payment_entry": payment_entry.name, - "allocated_amount": allocated_amount - }) - - transaction.save() - transaction.update_allocations() - -@frappe.whitelist() -def get_linked_payments(bank_transaction): - transaction = frappe.get_doc("Bank Transaction", bank_transaction) - bank_account = frappe.db.get_values("Bank Account", transaction.bank_account, ["account", "company"], as_dict=True) - - # Get all payment entries with a matching amount - amount_matching = check_matching_amount(bank_account[0].account, bank_account[0].company, transaction) - - # Get some data from payment entries linked to a corresponding bank transaction - description_matching = get_matching_descriptions_data(bank_account[0].company, transaction) - - if amount_matching: - return check_amount_vs_description(amount_matching, description_matching) - - elif description_matching: - description_matching = filter(lambda x: not x.get('clearance_date'), description_matching) - if not description_matching: - return [] - - return sorted(list(description_matching), key = lambda x: x["posting_date"], reverse=True) - - else: - return [] - -def check_matching_amount(bank_account, company, transaction): - payments = [] - amount = transaction.credit if transaction.credit > 0 else transaction.debit - - payment_type = "Receive" if transaction.credit > 0 else "Pay" - account_from_to = "paid_to" if transaction.credit > 0 else "paid_from" - currency_field = "paid_to_account_currency as currency" if transaction.credit > 0 else "paid_from_account_currency as currency" - - payment_entries = frappe.get_all("Payment Entry", fields=["'Payment Entry' as doctype", "name", "paid_amount", "payment_type", "reference_no", "reference_date", - "party", "party_type", "posting_date", "{0}".format(currency_field)], filters=[["paid_amount", "like", "{0}%".format(amount)], - ["docstatus", "=", "1"], ["payment_type", "=", [payment_type, "Internal Transfer"]], ["ifnull(clearance_date, '')", "=", ""], ["{0}".format(account_from_to), "=", "{0}".format(bank_account)]]) - - if transaction.credit > 0: - journal_entries = frappe.db.sql(""" - SELECT - 'Journal Entry' as doctype, je.name, je.posting_date, je.cheque_no as reference_no, - je.pay_to_recd_from as party, je.cheque_date as reference_date, jea.debit_in_account_currency as paid_amount - FROM - `tabJournal Entry Account` as jea - JOIN - `tabJournal Entry` as je - ON - jea.parent = je.name - WHERE - (je.clearance_date is null or je.clearance_date='0000-00-00') - AND - jea.account = %s - AND - jea.debit_in_account_currency like %s - AND - je.docstatus = 1 - """, (bank_account, amount), as_dict=True) - else: - journal_entries = frappe.db.sql(""" - SELECT - 'Journal Entry' as doctype, je.name, je.posting_date, je.cheque_no as reference_no, - jea.account_currency as currency, je.pay_to_recd_from as party, je.cheque_date as reference_date, - jea.credit_in_account_currency as paid_amount - FROM - `tabJournal Entry Account` as jea - JOIN - `tabJournal Entry` as je - ON - jea.parent = je.name - WHERE - (je.clearance_date is null or je.clearance_date='0000-00-00') - AND - jea.account = %(bank_account)s - AND - jea.credit_in_account_currency like %(txt)s - AND - je.docstatus = 1 - """, { - 'bank_account': bank_account, - 'txt': '%%%s%%' % amount - }, as_dict=True) - - if transaction.credit > 0: - sales_invoices = frappe.db.sql(""" - SELECT - 'Sales Invoice' as doctype, si.name, si.customer as party, - si.posting_date, sip.amount as paid_amount - FROM - `tabSales Invoice Payment` as sip - JOIN - `tabSales Invoice` as si - ON - sip.parent = si.name - WHERE - (sip.clearance_date is null or sip.clearance_date='0000-00-00') - AND - sip.account = %s - AND - sip.amount like %s - AND - si.docstatus = 1 - """, (bank_account, amount), as_dict=True) - else: - sales_invoices = [] - - if transaction.debit > 0: - purchase_invoices = frappe.get_all("Purchase Invoice", - fields = ["'Purchase Invoice' as doctype", "name", "paid_amount", "supplier as party", "posting_date", "currency"], - filters=[ - ["paid_amount", "like", "{0}%".format(amount)], - ["docstatus", "=", "1"], - ["is_paid", "=", "1"], - ["ifnull(clearance_date, '')", "=", ""], - ["cash_bank_account", "=", "{0}".format(bank_account)] - ] - ) - - mode_of_payments = [x["parent"] for x in frappe.db.get_list("Mode of Payment Account", - filters={"default_account": bank_account}, fields=["parent"])] - - company_currency = get_company_currency(company) - - expense_claims = frappe.get_all("Expense Claim", - fields=["'Expense Claim' as doctype", "name", "total_sanctioned_amount as paid_amount", - "employee as party", "posting_date", "'{0}' as currency".format(company_currency)], - filters=[ - ["total_sanctioned_amount", "like", "{0}%".format(amount)], - ["docstatus", "=", "1"], - ["is_paid", "=", "1"], - ["ifnull(clearance_date, '')", "=", ""], - ["mode_of_payment", "in", "{0}".format(tuple(mode_of_payments))] - ] - ) - else: - purchase_invoices = expense_claims = [] - - for data in [payment_entries, journal_entries, sales_invoices, purchase_invoices, expense_claims]: - if data: - payments.extend(data) - - return payments - -def get_matching_descriptions_data(company, transaction): - if not transaction.description : - return [] - - bank_transactions = frappe.db.sql(""" - SELECT - bt.name, bt.description, bt.date, btp.payment_document, btp.payment_entry - FROM - `tabBank Transaction` as bt - LEFT JOIN - `tabBank Transaction Payments` as btp - ON - bt.name = btp.parent - WHERE - bt.allocated_amount > 0 - AND - bt.docstatus = 1 - """, as_dict=True) - - selection = [] - for bank_transaction in bank_transactions: - if bank_transaction.description: - seq=difflib.SequenceMatcher(lambda x: x == " ", transaction.description, bank_transaction.description) - - if seq.ratio() > 0.6: - bank_transaction["ratio"] = seq.ratio() - selection.append(bank_transaction) - - document_types = set([x["payment_document"] for x in selection]) - - links = {} - for document_type in document_types: - links[document_type] = [x["payment_entry"] for x in selection if x["payment_document"]==document_type] - - - data = [] - company_currency = get_company_currency(company) - for key, value in iteritems(links): - if key == "Payment Entry": - data.extend(frappe.get_all("Payment Entry", filters=[["name", "in", value]], - fields=["'Payment Entry' as doctype", "posting_date", "party", "reference_no", - "reference_date", "paid_amount", "paid_to_account_currency as currency", "clearance_date"])) - if key == "Journal Entry": - journal_entries = frappe.get_all("Journal Entry", filters=[["name", "in", value]], - fields=["name", "'Journal Entry' as doctype", "posting_date", - "pay_to_recd_from as party", "cheque_no as reference_no", "cheque_date as reference_date", - "total_credit as paid_amount", "clearance_date"]) - for journal_entry in journal_entries: - journal_entry_accounts = frappe.get_all("Journal Entry Account", filters={"parenttype": journal_entry["doctype"], "parent": journal_entry["name"]}, fields=["account_currency"]) - journal_entry["currency"] = journal_entry_accounts[0]["account_currency"] if journal_entry_accounts else company_currency - data.extend(journal_entries) - if key == "Sales Invoice": - data.extend(frappe.get_all("Sales Invoice", filters=[["name", "in", value]], fields=["'Sales Invoice' as doctype", "posting_date", "customer_name as party", "paid_amount", "currency"])) - if key == "Purchase Invoice": - data.extend(frappe.get_all("Purchase Invoice", filters=[["name", "in", value]], fields=["'Purchase Invoice' as doctype", "posting_date", "supplier_name as party", "paid_amount", "currency"])) - if key == "Expense Claim": - expense_claims = frappe.get_all("Expense Claim", filters=[["name", "in", value]], fields=["'Expense Claim' as doctype", "posting_date", "employee_name as party", "total_amount_reimbursed as paid_amount"]) - data.extend([dict(x,**{"currency": company_currency}) for x in expense_claims]) - - return data - -def check_amount_vs_description(amount_matching, description_matching): - result = [] - - if description_matching: - for am_match in amount_matching: - for des_match in description_matching: - if des_match.get("clearance_date"): - continue - - if am_match["party"] == des_match["party"]: - if am_match not in result: - result.append(am_match) - continue - - if "reference_no" in am_match and "reference_no" in des_match: - if difflib.SequenceMatcher(lambda x: x == " ", am_match["reference_no"], des_match["reference_no"]).ratio() > 70: - if am_match not in result: - result.append(am_match) - if result: - return sorted(result, key = lambda x: x["posting_date"], reverse=True) - else: - return sorted(amount_matching, key = lambda x: x["posting_date"], reverse=True) - - else: - return sorted(amount_matching, key = lambda x: x["posting_date"], reverse=True) - -def get_matching_transactions_payments(description_matching): - payments = [x["payment_entry"] for x in description_matching] - - payment_by_ratio = {x["payment_entry"]: x["ratio"] for x in description_matching} - - if payments: - reference_payment_list = frappe.get_all("Payment Entry", fields=["name", "paid_amount", "payment_type", "reference_no", "reference_date", - "party", "party_type", "posting_date", "paid_to_account_currency"], filters=[["name", "in", payments]]) - - return sorted(reference_payment_list, key=lambda x: payment_by_ratio[x["name"]]) - - else: - return [] - -@frappe.whitelist() -@frappe.validate_and_sanitize_search_inputs -def payment_entry_query(doctype, txt, searchfield, start, page_len, filters): - account = frappe.db.get_value("Bank Account", filters.get("bank_account"), "account") - if not account: - return - - return frappe.db.sql(""" - SELECT - name, party, paid_amount, received_amount, reference_no - FROM - `tabPayment Entry` - WHERE - (clearance_date is null or clearance_date='0000-00-00') - AND (paid_from = %(account)s or paid_to = %(account)s) - AND (name like %(txt)s or party like %(txt)s) - AND docstatus = 1 - ORDER BY - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), name - LIMIT - %(start)s, %(page_len)s""", - { - 'txt': "%%%s%%" % txt, - '_txt': txt.replace("%", ""), - 'start': start, - 'page_len': page_len, - 'account': account - } - ) - -@frappe.whitelist() -@frappe.validate_and_sanitize_search_inputs -def journal_entry_query(doctype, txt, searchfield, start, page_len, filters): - account = frappe.db.get_value("Bank Account", filters.get("bank_account"), "account") - - return frappe.db.sql(""" - SELECT - jea.parent, je.pay_to_recd_from, - if(jea.debit_in_account_currency > 0, jea.debit_in_account_currency, jea.credit_in_account_currency) - FROM - `tabJournal Entry Account` as jea - LEFT JOIN - `tabJournal Entry` as je - ON - jea.parent = je.name - WHERE - (je.clearance_date is null or je.clearance_date='0000-00-00') - AND - jea.account = %(account)s - AND - (jea.parent like %(txt)s or je.pay_to_recd_from like %(txt)s) - AND - je.docstatus = 1 - ORDER BY - if(locate(%(_txt)s, jea.parent), locate(%(_txt)s, jea.parent), 99999), - jea.parent - LIMIT - %(start)s, %(page_len)s""", - { - 'txt': "%%%s%%" % txt, - '_txt': txt.replace("%", ""), - 'start': start, - 'page_len': page_len, - 'account': account - } - ) - -@frappe.whitelist() -@frappe.validate_and_sanitize_search_inputs -def sales_invoices_query(doctype, txt, searchfield, start, page_len, filters): - return frappe.db.sql(""" - SELECT - sip.parent, si.customer, sip.amount, sip.mode_of_payment - FROM - `tabSales Invoice Payment` as sip - LEFT JOIN - `tabSales Invoice` as si - ON - sip.parent = si.name - WHERE - (sip.clearance_date is null or sip.clearance_date='0000-00-00') - AND - (sip.parent like %(txt)s or si.customer like %(txt)s) - ORDER BY - if(locate(%(_txt)s, sip.parent), locate(%(_txt)s, sip.parent), 99999), - sip.parent - LIMIT - %(start)s, %(page_len)s""", - { - 'txt': "%%%s%%" % txt, - '_txt': txt.replace("%", ""), - 'start': start, - 'page_len': page_len - } - ) diff --git a/erpnext/accounts/page/bank_reconciliation/bank_transaction_header.html b/erpnext/accounts/page/bank_reconciliation/bank_transaction_header.html deleted file mode 100644 index 94f183b793b..00000000000 --- a/erpnext/accounts/page/bank_reconciliation/bank_transaction_header.html +++ /dev/null @@ -1,21 +0,0 @@ -
    -
    - -
    - {{ __("Description") }} -
    - - - -
    -
    -
    -
    diff --git a/erpnext/accounts/page/bank_reconciliation/bank_transaction_row.html b/erpnext/accounts/page/bank_reconciliation/bank_transaction_row.html deleted file mode 100644 index 742b84c63f5..00000000000 --- a/erpnext/accounts/page/bank_reconciliation/bank_transaction_row.html +++ /dev/null @@ -1,36 +0,0 @@ -
    -
    -
    - -
    - {{ description }} -
    - - - -
    - -
    -
    diff --git a/erpnext/accounts/page/bank_reconciliation/linked_payment_header.html b/erpnext/accounts/page/bank_reconciliation/linked_payment_header.html deleted file mode 100644 index 4542c36e0dc..00000000000 --- a/erpnext/accounts/page/bank_reconciliation/linked_payment_header.html +++ /dev/null @@ -1,21 +0,0 @@ -
    -
    -
    - {{ __("Payment Name") }} -
    -
    - {{ __("Reference Date") }} -
    - - -
    - {{ __("Reference Number") }} -
    -
    -
    -
    -
    diff --git a/erpnext/accounts/page/bank_reconciliation/linked_payment_row.html b/erpnext/accounts/page/bank_reconciliation/linked_payment_row.html deleted file mode 100644 index bdbc9fce033..00000000000 --- a/erpnext/accounts/page/bank_reconciliation/linked_payment_row.html +++ /dev/null @@ -1,36 +0,0 @@ -
    -
    -
    - {{ name }} -
    -
    - {% if (typeof reference_date !== "undefined") %} - {%= frappe.datetime.str_to_user(reference_date) %} - {% else %} - {% if (typeof posting_date !== "undefined") %} - {%= frappe.datetime.str_to_user(posting_date) %} - {% endif %} - {% endif %} -
    - - -
    - {% if (typeof reference_no !== "undefined") %} - {{ reference_no }} - {% else %} - {{ "" }} - {% endif %} -
    -
    -
    - -
    -
    -
    -
    \ No newline at end of file diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 2f800bb2abd..e01cb6e151e 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -39,7 +39,7 @@ def _get_party_details(party=None, account=None, party_type="Customer", company= party_details = frappe._dict(set_account_and_due_date(party, account, party_type, company, posting_date, bill_date, doctype)) party = party_details[party_type.lower()] - if not ignore_permissions and not frappe.has_permission(party_type, "read", party): + if not ignore_permissions and not (frappe.has_permission(party_type, "read", party) or frappe.has_permission(party_type, "select", party)): frappe.throw(_("Not permitted for {0}").format(party), frappe.PermissionError) party = frappe.get_doc(party_type, party) @@ -59,7 +59,7 @@ def _get_party_details(party=None, account=None, party_type="Customer", company= billing_address=party_address, shipping_address=shipping_address) if fetch_payment_terms_template: - party_details["payment_terms_template"] = get_pyt_term_template(party.name, party_type, company) + party_details["payment_terms_template"] = get_payment_terms_template(party.name, party_type, company) if not party_details.get("currency"): party_details["currency"] = currency @@ -203,7 +203,7 @@ def set_account_and_due_date(party, account, party_type, company, posting_date, return out @frappe.whitelist() -def get_party_account(party_type, party, company): +def get_party_account(party_type, party, company=None): """Returns the account for the given `party`. Will first search in party (Customer / Supplier) record, if not found, will search in group (Customer Group / Supplier Group), @@ -315,7 +315,7 @@ def get_due_date(posting_date, party_type, party, company=None, bill_date=None): due_date = None if (bill_date or posting_date) and party: due_date = bill_date or posting_date - template_name = get_pyt_term_template(party, party_type, company) + template_name = get_payment_terms_template(party, party_type, company) if template_name: due_date = get_due_date_from_template(template_name, posting_date, bill_date).strftime("%Y-%m-%d") @@ -422,7 +422,7 @@ def set_taxes(party, party_type, posting_date, company, customer_group=None, sup @frappe.whitelist() -def get_pyt_term_template(party_name, party_type, company=None): +def get_payment_terms_template(party_name, party_type, company=None): if party_type not in ("Customer", "Supplier"): return template = None @@ -617,6 +617,7 @@ def get_partywise_advanced_payment_amount(party_type, posting_date = None, futur FROM `tabGL Entry` WHERE party_type = %s and against_voucher is null + and is_cancelled = 0 and {1} GROUP BY party""" .format(("credit") if party_type == "Customer" else "debit", cond) , party_type) diff --git a/erpnext/accounts/print_format/bank_and_cash_payment_voucher/bank_and_cash_payment_voucher.html b/erpnext/accounts/print_format/bank_and_cash_payment_voucher/bank_and_cash_payment_voucher.html index 6fe69990513..e588ed6609e 100644 --- a/erpnext/accounts/print_format/bank_and_cash_payment_voucher/bank_and_cash_payment_voucher.html +++ b/erpnext/accounts/print_format/bank_and_cash_payment_voucher/bank_and_cash_payment_voucher.html @@ -19,7 +19,7 @@
    - +
    Date: {{ frappe.utils.formatdate(doc.creation) }}
    Date: {{ frappe.utils.format_date(doc.creation) }}
    diff --git a/erpnext/accounts/doctype/bank_statement_transaction_settings/__init__.py b/erpnext/accounts/print_format/gst_e_invoice/__init__.py similarity index 100% rename from erpnext/accounts/doctype/bank_statement_transaction_settings/__init__.py rename to erpnext/accounts/print_format/gst_e_invoice/__init__.py diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html new file mode 100644 index 00000000000..71c26e8c55a --- /dev/null +++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html @@ -0,0 +1,162 @@ +{%- from "templates/print_formats/standard_macros.html" import add_header, render_field, print_value -%} +{%- set einvoice = json.loads(doc.signed_einvoice) -%} + +
    +
    + {% if letter_head and not no_letterhead %} +
    {{ letter_head }}
    + {% endif %} + +
    + {% if print_settings.repeat_header_footer %} + + {% endif %} +
    1. Transaction Details
    +
    +
    +
    +
    +
    {{ einvoice.Irn }}
    +
    +
    +
    +
    {{ einvoice.AckNo }}
    +
    +
    +
    +
    {{ frappe.utils.format_datetime(einvoice.AckDt, "dd/MM/yyyy hh:mm:ss") }}
    +
    +
    +
    +
    {{ einvoice.TranDtls.SupTyp }}
    +
    +
    +
    +
    {{ einvoice.DocDtls.Typ }}
    +
    +
    +
    +
    {{ einvoice.DocDtls.No }}
    +
    +
    +
    + +
    +
    +
    2. Party Details
    +
    + {%- set seller = einvoice.SellerDtls -%} +
    +
    Seller
    +

    {{ seller.Gstin }}

    +

    {{ seller.LglNm }}

    +

    {{ seller.Addr1 }}

    + {%- if seller.Addr2 -%}

    {{ seller.Addr2 }}

    {% endif %} +

    {{ seller.Loc }}

    +

    {{ frappe.db.get_value("Address", doc.company_address, "gst_state") }} - {{ seller.Pin }}

    + + {%- if einvoice.ShipDtls -%} + {%- set shipping = einvoice.ShipDtls -%} +
    Shipping
    +

    {{ shipping.Gstin }}

    +

    {{ shipping.LglNm }}

    +

    {{ shipping.Addr1 }}

    + {%- if shipping.Addr2 -%}

    {{ shipping.Addr2 }}

    {% endif %} +

    {{ shipping.Loc }}

    +

    {{ frappe.db.get_value("Address", doc.shipping_address_name, "gst_state") }} - {{ shipping.Pin }}

    + {% endif %} +
    + {%- set buyer = einvoice.BuyerDtls -%} +
    +
    Buyer
    +

    {{ buyer.Gstin }}

    +

    {{ buyer.LglNm }}

    +

    {{ buyer.Addr1 }}

    + {%- if buyer.Addr2 -%}

    {{ buyer.Addr2 }}

    {% endif %} +

    {{ buyer.Loc }}

    +

    {{ frappe.db.get_value("Address", doc.customer_address, "gst_state") }} - {{ buyer.Pin }}

    +
    +
    +
    +
    3. Item Details
    + + + + + + + + + + + + + + + + + + {% for item in einvoice.ItemList %} + + + + + + + + + + + + + + {% endfor %} + +
    Sr. No.ItemHSN CodeQtyUOMRateDiscountTaxable AmountTax RateOther ChargesTotal
    {{ item.SlNo }}{{ item.PrdDesc }}{{ item.HsnCd }}{{ item.Qty }}{{ item.Unit }}{{ frappe.utils.fmt_money(item.UnitPrice, None, "INR") }}{{ frappe.utils.fmt_money(item.Discount, None, "INR") }}{{ frappe.utils.fmt_money(item.AssAmt, None, "INR") }}{{ item.GstRt + item.CesRt }} %{{ frappe.utils.fmt_money(0, None, "INR") }}{{ frappe.utils.fmt_money(item.TotItemVal, None, "INR") }}
    +
    +
    +
    4. Value Details
    + + + + + + + + + + + + + + + + + {%- set value_details = einvoice.ValDtls -%} + + + + + + + + + + + + + +
    Taxable AmountCGSTSGSTIGSTCESSState CESSDiscountOther ChargesRound OffTotal Value
    {{ frappe.utils.fmt_money(value_details.AssVal, None, "INR") }}{{ frappe.utils.fmt_money(value_details.CgstVal, None, "INR") }}{{ frappe.utils.fmt_money(value_details.SgstVal, None, "INR") }}{{ frappe.utils.fmt_money(value_details.IgstVal, None, "INR") }}{{ frappe.utils.fmt_money(value_details.CesVal, None, "INR") }}{{ frappe.utils.fmt_money(0, None, "INR") }}{{ frappe.utils.fmt_money(value_details.Discount, None, "INR") }}{{ frappe.utils.fmt_money(value_details.OthChrg, None, "INR") }}{{ frappe.utils.fmt_money(value_details.RndOffAmt, None, "INR") }}{{ frappe.utils.fmt_money(value_details.TotInvVal, None, "INR") }}
    +
    +
    \ No newline at end of file diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json new file mode 100644 index 00000000000..1001199a092 --- /dev/null +++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.json @@ -0,0 +1,24 @@ +{ + "align_labels_right": 1, + "creation": "2020-10-10 18:01:21.032914", + "custom_format": 0, + "default_print_language": "en-US", + "disabled": 1, + "doc_type": "Sales Invoice", + "docstatus": 0, + "doctype": "Print Format", + "font": "Default", + "html": "", + "idx": 0, + "line_breaks": 1, + "modified": "2020-10-23 19:54:40.634936", + "modified_by": "Administrator", + "module": "Accounts", + "name": "GST E-Invoice", + "owner": "Administrator", + "print_format_builder": 0, + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 1, + "standard": "Yes" +} \ No newline at end of file diff --git a/erpnext/accounts/print_format/journal_auditing_voucher/journal_auditing_voucher.html b/erpnext/accounts/print_format/journal_auditing_voucher/journal_auditing_voucher.html index 13515179816..0ca940f8bd5 100644 --- a/erpnext/accounts/print_format/journal_auditing_voucher/journal_auditing_voucher.html +++ b/erpnext/accounts/print_format/journal_auditing_voucher/journal_auditing_voucher.html @@ -17,7 +17,7 @@
    - +
    Date: {{ frappe.utils.formatdate(doc.creation) }}
    Date: {{ frappe.utils.format_date(doc.creation) }}
    diff --git a/erpnext/accounts/print_format/payment_receipt_voucher/payment_receipt_voucher.html b/erpnext/accounts/print_format/payment_receipt_voucher/payment_receipt_voucher.html index f2e65d33345..283d505e3be 100644 --- a/erpnext/accounts/print_format/payment_receipt_voucher/payment_receipt_voucher.html +++ b/erpnext/accounts/print_format/payment_receipt_voucher/payment_receipt_voucher.html @@ -5,7 +5,7 @@ {{ add_header(0, 1, doc, letter_head, no_letterhead, print_settings) }} {%- for label, value in ( - (_("Received On"), frappe.utils.formatdate(doc.voucher_date)), + (_("Received On"), frappe.utils.format_date(doc.voucher_date)), (_("Received From"), doc.pay_to_recd_from), (_("Amount"), "" + doc.get_formatted("total_amount") + "
    " + (doc.total_amount_in_words or "") + "
    "), (_("Remarks"), doc.remark) diff --git a/erpnext/accounts/print_format/purchase_auditing_voucher/purchase_auditing_voucher.html b/erpnext/accounts/print_format/purchase_auditing_voucher/purchase_auditing_voucher.html index a7c3bce0b4f..043ac254ed3 100644 --- a/erpnext/accounts/print_format/purchase_auditing_voucher/purchase_auditing_voucher.html +++ b/erpnext/accounts/print_format/purchase_auditing_voucher/purchase_auditing_voucher.html @@ -8,7 +8,7 @@
    - + @@ -17,7 +17,7 @@
    Supplier Name: {{ doc.supplier }}
    Due Date: {{ frappe.utils.formatdate(doc.due_date) }}
    Due Date: {{ frappe.utils.format_date(doc.due_date) }}
    Address: {{doc.address_display}}
    Contact: {{doc.contact_display}}
    Mobile no: {{doc.contact_mobile}}
    - +
    Voucher No: {{ doc.name }}
    Date: {{ frappe.utils.formatdate(doc.creation) }}
    Date: {{ frappe.utils.format_date(doc.creation) }}
    diff --git a/erpnext/accounts/print_format/sales_auditing_voucher/sales_auditing_voucher.html b/erpnext/accounts/print_format/sales_auditing_voucher/sales_auditing_voucher.html index ef4ada14a3e..a53b593a72a 100644 --- a/erpnext/accounts/print_format/sales_auditing_voucher/sales_auditing_voucher.html +++ b/erpnext/accounts/print_format/sales_auditing_voucher/sales_auditing_voucher.html @@ -8,7 +8,7 @@
    - + @@ -17,7 +17,7 @@
    Customer Name: {{ doc.customer }}
    Due Date: {{ frappe.utils.formatdate(doc.due_date) }}
    Due Date: {{ frappe.utils.format_date(doc.due_date) }}
    Address: {{doc.address_display}}
    Contact: {{doc.contact_display}}
    Mobile no: {{doc.contact_mobile}}
    - +
    Voucher No: {{ doc.name }}
    Date: {{ frappe.utils.formatdate(doc.creation) }}
    Date: {{ frappe.utils.format_date(doc.creation) }}
    diff --git a/erpnext/accounts/print_format/sales_invoice_return/sales_invoice_return.html b/erpnext/accounts/print_format/sales_invoice_return/sales_invoice_return.html index 1d758e89355..3d5a9b1da94 100644 --- a/erpnext/accounts/print_format/sales_invoice_return/sales_invoice_return.html +++ b/erpnext/accounts/print_format/sales_invoice_return/sales_invoice_return.html @@ -1,5 +1,5 @@ {%- from "templates/print_formats/standard_macros.html" import add_header, render_field, print_value, fieldmeta, - get_width, get_align_class -%} + get_width, get_align_class with context -%} {%- macro render_currency(df, doc) -%}
    @@ -63,14 +63,19 @@ {{ d.idx }} {% for tdf in visible_columns %} - {% if not d.flags.compact_item_print or tdf.fieldname in doc.get(df.fieldname)[0].flags.compact_item_fields %} + {% if not print_settings.compact_item_print or tdf.fieldname in doc.flags.compact_item_fields %} {% if tdf.fieldname == 'qty' %}
    {{ (d[tdf.fieldname])|abs }}
    {% elif tdf.fieldtype == 'Currency' %}
    {{ frappe.utils.fmt_money((d[tdf.fieldname])|abs, currency=doc.currency) }}
    {% else %} -
    {{ print_value(tdf, d, doc, visible_columns) }}
    + {% if doc.child_print_templates %} + {%- set child_templates = doc.child_print_templates.get(df.fieldname) -%} +
    {{ print_value(tdf, d, doc, visible_columns, child_templates) }}
    + {% else %} +
    {{ print_value(tdf, d, doc, visible_columns) }}
    + {% endif %} {% endif %} {% endif %} {% endfor %} diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html index bb0d0a132a5..f4fd06ba037 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.html +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.html @@ -42,11 +42,13 @@ {% if(filters.show_future_payments) { %} {% var balance_row = data.slice(-1).pop(); - var range1 = report.columns[11].label; - var range2 = report.columns[12].label; - var range3 = report.columns[13].label; - var range4 = report.columns[14].label; - var range5 = report.columns[15].label; + var start = filters.based_on_payment_terms ? 13 : 11; + var range1 = report.columns[start].label; + var range2 = report.columns[start+1].label; + var range3 = report.columns[start+2].label; + var range4 = report.columns[start+3].label; + var range5 = report.columns[start+4].label; + var range6 = report.columns[start+5].label; %} {% if(balance_row) { %} @@ -70,20 +72,34 @@ + - - - - - + + + + + + + @@ -91,6 +107,7 @@ + @@ -101,6 +118,7 @@ + @@ -218,15 +236,15 @@ + {%= format_currency(data[i]["invoiced"], data[i]["currency"] ) %} {% if(!filters.show_future_payments) { %} - + {%= format_currency(data[i]["paid"], data[i]["currency"]) %} + {% } %} + {%= format_currency(data[i]["outstanding"], data[i]["currency"]) %} {% if(filters.show_future_payments) { %} {% if(report.report_name === "Accounts Receivable") { %} @@ -234,13 +252,13 @@ {%= data[i]["po_no"] %} {% } %} - - + + {% } %} {% } %} {% } else { %} {% if(data[i]["party"]|| " ") { %} - {% if((data[i]["party"]) != __("'Total'")) { %} + {% if(!data[i]["is_total_row"]) { %} {% } %} - - - - + + + + {% } %} {% } %} diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 044fc1d3abd..51fc7ec49aa 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -160,6 +160,8 @@ class ReceivablePayableReport(object): else: # advance / unlinked payment or other adjustment row.paid -= gle_balance + if gle.cost_center: + row.cost_center = str(gle.cost_center) def update_sub_total_row(self, row, party): total_row = self.total_row_map.get(party) @@ -210,7 +212,6 @@ class ReceivablePayableReport(object): for key, row in self.voucher_balance.items(): row.outstanding = flt(row.invoiced - row.paid - row.credit_note, self.currency_precision) row.invoice_grand_total = row.invoiced - if abs(row.outstanding) > 1.0/10 ** self.currency_precision: # non-zero oustanding, we must consider this row @@ -577,7 +578,7 @@ class ReceivablePayableReport(object): self.gl_entries = frappe.db.sql(""" select - name, posting_date, account, party_type, party, voucher_type, voucher_no, + name, posting_date, account, party_type, party, voucher_type, voucher_no, cost_center, against_voucher_type, against_voucher, account_currency, remarks, {0} from `tabGL Entry` @@ -741,6 +742,7 @@ class ReceivablePayableReport(object): self.add_column(_("Customer Contact"), fieldname='customer_primary_contact', fieldtype='Link', options='Contact') + self.add_column(label=_('Cost Center'), fieldname='cost_center', fieldtype='Data') self.add_column(label=_('Voucher Type'), fieldname='voucher_type', fieldtype='Data') self.add_column(label=_('Voucher No'), fieldname='voucher_no', fieldtype='Dynamic Link', options='voucher_type', width=180) 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 16bef565252..2162a02eff9 100644 --- a/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py +++ b/erpnext/accounts/report/asset_depreciation_ledger/asset_depreciation_ledger.py @@ -47,21 +47,22 @@ def get_data(filters): for d in gl_entries: asset_data = assets_details.get(d.against_voucher) - if not asset_data.get("accumulated_depreciation_amount"): - asset_data.accumulated_depreciation_amount = d.debit - else: - asset_data.accumulated_depreciation_amount += d.debit + if asset_data: + if not asset_data.get("accumulated_depreciation_amount"): + asset_data.accumulated_depreciation_amount = d.debit + else: + asset_data.accumulated_depreciation_amount += d.debit - row = frappe._dict(asset_data) - row.update({ - "depreciation_amount": d.debit, - "depreciation_date": d.posting_date, - "amount_after_depreciation": (flt(row.gross_purchase_amount) - - flt(row.accumulated_depreciation_amount)), - "depreciation_entry": d.voucher_no - }) + row = frappe._dict(asset_data) + row.update({ + "depreciation_amount": d.debit, + "depreciation_date": d.posting_date, + "amount_after_depreciation": (flt(row.gross_purchase_amount) - + flt(row.accumulated_depreciation_amount)), + "depreciation_entry": d.voucher_no + }) - data.append(row) + data.append(row) return data diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py index a858c1998f5..1729abce9ef 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.py +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py @@ -147,7 +147,6 @@ def get_report_summary(period_list, asset, liability, equity, provisional_profit { "value": net_asset, "label": "Total Asset", - "indicator": "Green", "datatype": "Currency", "currency": currency }, @@ -155,14 +154,12 @@ def get_report_summary(period_list, asset, liability, equity, provisional_profit "value": net_liability, "label": "Total Liability", "datatype": "Currency", - "indicator": "Red", "currency": currency }, { "value": net_equity, "label": "Total Equity", "datatype": "Currency", - "indicator": "Blue", "currency": currency }, { 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 0861b20f14a..79b0a6f30ec 100644 --- a/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py +++ b/erpnext/accounts/report/bank_clearance_summary/bank_clearance_summary.py @@ -15,15 +15,51 @@ def execute(filters=None): return columns, data def get_columns(): - return [ - _("Payment Document") + "::130", - _("Payment Entry") + ":Dynamic Link/"+_("Payment Document")+":110", - _("Posting Date") + ":Date:100", - _("Cheque/Reference No") + "::120", - _("Clearance Date") + ":Date:100", - _("Against Account") + ":Link/Account:170", - _("Amount") + ":Currency:120" - ] + columns = [{ + "label": _("Payment Document Type"), + "fieldname": "payment_document_type", + "fieldtype": "Link", + "options": "Doctype", + "width": 130 + }, + { + "label": _("Payment Entry"), + "fieldname": "payment_entry", + "fieldtype": "Dynamic Link", + "options": "payment_document_type", + "width": 140 + }, + { + "label": _("Posting Date"), + "fieldname": "posting_date", + "fieldtype": "Date", + "width": 100 + }, + { + "label": _("Cheque/Reference No"), + "fieldname": "cheque_no", + "width": 120 + }, + { + "label": _("Clearance Date"), + "fieldname": "clearance_date", + "fieldtype": "Date", + "width": 100 + }, + { + "label": _("Against Account"), + "fieldname": "against", + "fieldtype": "Link", + "options": "Account", + "width": 170 + }, + { + "label": _("Amount"), + "fieldname": "amount", + "width": 120 + }] + + return columns def get_conditions(filters): conditions = "" diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.js b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.js index 57fe4b05be4..8f028496cd5 100644 --- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.js +++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.js @@ -3,6 +3,14 @@ frappe.query_reports["Bank Reconciliation Statement"] = { "filters": [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "reqd": 1, + "default": frappe.defaults.get_user_default("Company") + }, { "fieldname":"account", "label": __("Bank Account"), @@ -12,11 +20,14 @@ frappe.query_reports["Bank Reconciliation Statement"] = { locals[":Company"][frappe.defaults.get_user_default("Company")]["default_bank_account"]: "", "reqd": 1, "get_query": function() { + var company = frappe.query_report.get_filter_value('company') return { "query": "erpnext.controllers.queries.get_account_list", "filters": [ ['Account', 'account_type', 'in', 'Bank, Cash'], ['Account', 'is_group', '=', 0], + ['Account', 'disabled', '=', 0], + ['Account', 'company', '=', company], ] } } @@ -34,4 +45,4 @@ frappe.query_reports["Bank Reconciliation Statement"] = { "fieldtype": "Check" }, ] -} \ No newline at end of file +} 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 d0116890b65..0c4a4224407 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -222,7 +222,7 @@ def get_data(companies, root_type, balance_must_be, fiscal_year, filters=None, i set_gl_entries_by_account(start_date, end_date, root.lft, root.rgt, filters, - gl_entries_by_account, accounts_by_name, ignore_closing_entries=False) + gl_entries_by_account, accounts_by_name, accounts, ignore_closing_entries=False) calculate_values(accounts_by_name, gl_entries_by_account, companies, start_date, filters) accumulate_values_into_parents(accounts, accounts_by_name, companies) @@ -240,8 +240,7 @@ def get_company_currency(filters=None): def calculate_values(accounts_by_name, gl_entries_by_account, companies, start_date, filters): for entries in gl_entries_by_account.values(): for entry in entries: - key = entry.account_number or entry.account_name - d = accounts_by_name.get(key) + d = accounts_by_name.get(entry.account_name) if d: for company in companies: # check if posting date is within the period @@ -256,7 +255,8 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies): """accumulate children's values in parent accounts""" for d in reversed(accounts): if d.parent_account: - account = d.parent_account.split(' - ')[0].strip() + account = d.parent_account_name + if not accounts_by_name.get(account): continue @@ -267,16 +267,34 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies): accounts_by_name[account]["opening_balance"] = \ accounts_by_name[account].get("opening_balance", 0.0) + d.get("opening_balance", 0.0) + def get_account_heads(root_type, companies, filters): accounts = get_accounts(root_type, filters) if not accounts: return None, None + accounts = update_parent_account_names(accounts) + accounts, accounts_by_name, parent_children_map = filter_accounts(accounts) return accounts, accounts_by_name +def update_parent_account_names(accounts): + """Update parent_account_name in accounts list. + + parent_name is `name` of parent account which could have other prefix + of account_number and suffix of company abbr. This function adds key called + `parent_account_name` which does not have such prefix/suffix. + """ + name_to_account_map = { d.name : d.account_name for d in accounts } + + for account in accounts: + if account.parent_account: + account["parent_account_name"] = name_to_account_map[account.parent_account] + + return accounts + def get_companies(filters): companies = {} all_companies = get_subsidiary_companies(filters.get('company')) @@ -339,7 +357,7 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com return data def set_gl_entries_by_account(from_date, to_date, root_lft, root_rgt, filters, gl_entries_by_account, - accounts_by_name, ignore_closing_entries=False): + accounts_by_name, accounts, ignore_closing_entries=False): """Returns a dict like { "account": [gl entries], ... }""" company_lft, company_rgt = frappe.get_cached_value('Company', @@ -381,16 +399,32 @@ def set_gl_entries_by_account(from_date, to_date, root_lft, root_rgt, filters, g convert_to_presentation_currency(gl_entries, currency_info, filters.get('company')) for entry in gl_entries: - key = entry.account_number or entry.account_name - validate_entries(key, entry, accounts_by_name) - gl_entries_by_account.setdefault(key, []).append(entry) + account_name = entry.account_name + validate_entries(account_name, entry, accounts_by_name, accounts) + gl_entries_by_account.setdefault(account_name, []).append(entry) return gl_entries_by_account -def validate_entries(key, entry, accounts_by_name): +def get_account_details(account): + return frappe.get_cached_value('Account', account, ['name', 'report_type', 'root_type', 'company', + 'is_group', 'account_name', 'account_number', 'parent_account', 'lft', 'rgt'], as_dict=1) + +def validate_entries(key, entry, accounts_by_name, accounts): if key not in accounts_by_name: - field = "Account number" if entry.account_number else "Account name" - frappe.throw(_("{0} {1} is not present in the parent company").format(field, key)) + args = get_account_details(entry.account) + + if args.parent_account: + parent_args = get_account_details(args.parent_account) + + args.update({ + 'lft': parent_args.lft + 1, + 'rgt': parent_args.rgt - 1, + 'root_type': parent_args.root_type, + 'report_type': parent_args.report_type + }) + + accounts_by_name.setdefault(key, args) + accounts.append(args) def get_additional_conditions(from_date, ignore_closing_entries, filters): additional_conditions = [] @@ -436,8 +470,7 @@ def filter_accounts(accounts, depth=10): parent_children_map = {} accounts_by_name = {} for d in accounts: - key = d.account_number or d.account_name - accounts_by_name[key] = d + accounts_by_name[d.account_name] = d parent_children_map.setdefault(d.parent_account or None, []).append(d) filtered_accounts = [] diff --git a/erpnext/accounts/report/delivered_items_to_be_billed/delivered_items_to_be_billed.py b/erpnext/accounts/report/delivered_items_to_be_billed/delivered_items_to_be_billed.py index 3ffb3ac1df4..515fd995e66 100644 --- a/erpnext/accounts/report/delivered_items_to_be_billed/delivered_items_to_be_billed.py +++ b/erpnext/accounts/report/delivered_items_to_be_billed/delivered_items_to_be_billed.py @@ -14,11 +14,93 @@ def execute(filters=None): def get_column(): return [ - _("Delivery Note") + ":Link/Delivery Note:120", _("Status") + "::120", _("Date") + ":Date:100", - _("Suplier") + ":Link/Customer:120", _("Customer Name") + "::120", - _("Project") + ":Link/Project:120", _("Item Code") + ":Link/Item:120", - _("Amount") + ":Currency:100", _("Billed Amount") + ":Currency:100", _("Pending Amount") + ":Currency:100", - _("Item Name") + "::120", _("Description") + "::120", _("Company") + ":Link/Company:120", + { + "label": _("Delivery Note"), + "fieldname": "name", + "fieldtype": "Link", + "options": "Delivery Note", + "width": 160 + }, + { + "label": _("Date"), + "fieldname": "date", + "fieldtype": "Date", + "width": 100 + }, + { + "label": _("Customer"), + "fieldname": "customer", + "fieldtype": "Link", + "options": "Customer", + "width": 120 + }, + { + "label": _("Customer Name"), + "fieldname": "customer_name", + "fieldtype": "Data", + "width": 120 + }, + { + "label": _("Item Code"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": 120 + }, + { + "label": _("Amount"), + "fieldname": "amount", + "fieldtype": "Currency", + "width": 100, + "options": "Company:company:default_currency" + }, + { + "label": _("Billed Amount"), + "fieldname": "billed_amount", + "fieldtype": "Currency", + "width": 100, + "options": "Company:company:default_currency" + }, + { + "label": _("Returned Amount"), + "fieldname": "returned_amount", + "fieldtype": "Currency", + "width": 120, + "options": "Company:company:default_currency" + }, + { + "label": _("Pending Amount"), + "fieldname": "pending_amount", + "fieldtype": "Currency", + "width": 120, + "options": "Company:company:default_currency" + }, + { + "label": _("Item Name"), + "fieldname": "item_name", + "fieldtype": "Data", + "width": 120 + }, + { + "label": _("Description"), + "fieldname": "description", + "fieldtype": "Data", + "width": 120 + }, + { + "label": _("Project"), + "fieldname": "project", + "fieldtype": "Link", + "options": "Project", + "width": 120 + }, + { + "label": _("Company"), + "fieldname": "company", + "fieldtype": "Link", + "options": "Company", + "width": 120 + } ] def get_args(): diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py index 1b65a318b6f..14efa1f8fc7 100644 --- a/erpnext/accounts/report/financial_statements.py +++ b/erpnext/accounts/report/financial_statements.py @@ -51,7 +51,11 @@ def get_period_list(from_fiscal_year, to_fiscal_year, period_start_date, period_ "from_date": start_date }) - to_date = add_months(start_date, months_to_add) + if i==0 and filter_based_on == 'Date Range': + to_date = add_months(get_first_day(start_date), months_to_add) + else: + to_date = add_months(start_date, months_to_add) + start_date = to_date # Subtract one day from to_date, as it may be first day in next fiscal year or month @@ -307,7 +311,7 @@ def get_accounts(company, root_type): where company=%s and root_type=%s order by lft""", (company, root_type), as_dict=True) -def filter_accounts(accounts, depth=10): +def filter_accounts(accounts, depth=20): parent_children_map = {} accounts_by_name = {} for d in accounts: diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index f735d87a764..b5d7992604f 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -129,6 +129,9 @@ def get_gl_entries(filters, accounting_dimensions): order_by_statement = "order by posting_date, account, creation" + if filters.get("include_dimensions"): + order_by_statement = "order by posting_date, creation" + if filters.get("group_by") == _("Group by Voucher"): order_by_statement = "order by posting_date, voucher_type, voucher_no" @@ -142,7 +145,9 @@ def get_gl_entries(filters, accounting_dimensions): distributed_cost_center_query = "" if filters and filters.get('cost_center'): - select_fields_with_percentage = """, debit*(DCC_allocation.percentage_allocation/100) as debit, credit*(DCC_allocation.percentage_allocation/100) as credit, debit_in_account_currency*(DCC_allocation.percentage_allocation/100) as debit_in_account_currency, + select_fields_with_percentage = """, debit*(DCC_allocation.percentage_allocation/100) as debit, + credit*(DCC_allocation.percentage_allocation/100) as credit, + debit_in_account_currency*(DCC_allocation.percentage_allocation/100) as debit_in_account_currency, credit_in_account_currency*(DCC_allocation.percentage_allocation/100) as credit_in_account_currency """ distributed_cost_center_query = """ @@ -200,7 +205,7 @@ def get_gl_entries(filters, accounting_dimensions): def get_conditions(filters): conditions = [] - if filters.get("account"): + if filters.get("account") and not filters.get("include_dimensions"): lft, rgt = frappe.db.get_value("Account", filters["account"], ["lft", "rgt"]) conditions.append("""account in (select name from tabAccount where lft>=%s and rgt<=%s and docstatus<2)""" % (lft, rgt)) @@ -245,17 +250,19 @@ def get_conditions(filters): if match_conditions: conditions.append(match_conditions) - accounting_dimensions = get_accounting_dimensions(as_list=False) + if filters.get("include_dimensions"): + accounting_dimensions = get_accounting_dimensions(as_list=False) - if accounting_dimensions: - for dimension in accounting_dimensions: - if filters.get(dimension.fieldname): - if frappe.get_cached_value('DocType', dimension.document_type, 'is_tree'): - filters[dimension.fieldname] = get_dimension_with_children(dimension.document_type, - filters.get(dimension.fieldname)) - conditions.append("{0} in %({0})s".format(dimension.fieldname)) - else: - conditions.append("{0} in (%({0})s)".format(dimension.fieldname)) + if accounting_dimensions: + for dimension in accounting_dimensions: + if not dimension.disabled: + if filters.get(dimension.fieldname): + if frappe.get_cached_value('DocType', dimension.document_type, 'is_tree'): + filters[dimension.fieldname] = get_dimension_with_children(dimension.document_type, + filters.get(dimension.fieldname)) + conditions.append("{0} in %({0})s".format(dimension.fieldname)) + else: + conditions.append("{0} in (%({0})s)".format(dimension.fieldname)) return "and {}".format(" and ".join(conditions)) if conditions else "" diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py index 3445df7206f..cb4d9b43dbd 100644 --- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py +++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py @@ -8,6 +8,7 @@ from frappe.utils import flt from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import (get_tax_accounts, get_grand_total, add_total_row, get_display_value, get_group_by_and_display_fields, add_sub_total_row, get_group_by_conditions) +from erpnext.selling.report.item_wise_sales_history.item_wise_sales_history import get_item_details def execute(filters=None): return _execute(filters) @@ -22,7 +23,7 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum aii_account_map = get_aii_accounts() if item_list: itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency, - doctype="Purchase Invoice", tax_doctype="Purchase Taxes and Charges") + doctype='Purchase Invoice', tax_doctype='Purchase Taxes and Charges') po_pr_map = get_purchase_receipts_against_purchase_order(item_list) @@ -34,22 +35,27 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum if filters.get('group_by'): grand_total = get_grand_total(filters, 'Purchase Invoice') + item_details = get_item_details() + for d in item_list: if not d.stock_qty: continue + item_record = item_details.get(d.item_code) + purchase_receipt = None if d.purchase_receipt: purchase_receipt = d.purchase_receipt elif d.po_detail: purchase_receipt = ", ".join(po_pr_map.get(d.po_detail, [])) - expense_account = d.expense_account or aii_account_map.get(d.company) + expense_account = d.unrealized_profit_loss_account or d.expense_account \ + or aii_account_map.get(d.company) row = { 'item_code': d.item_code, - 'item_name': d.item_name, - 'item_group': d.item_group, + 'item_name': item_record.item_name if item_record else d.item_name, + 'item_group': item_record.item_group if item_record else d.item_group, 'description': d.description, 'invoice': d.parent, 'posting_date': d.posting_date, @@ -81,10 +87,10 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum for tax in tax_columns: item_tax = itemised_tax.get(d.name, {}).get(tax, {}) row.update({ - frappe.scrub(tax + ' Rate'): item_tax.get("tax_rate", 0), - frappe.scrub(tax + ' Amount'): item_tax.get("tax_amount", 0), + frappe.scrub(tax + ' Rate'): item_tax.get('tax_rate', 0), + frappe.scrub(tax + ' Amount'): item_tax.get('tax_amount', 0), }) - total_tax += flt(item_tax.get("tax_amount")) + total_tax += flt(item_tax.get('tax_amount')) row.update({ 'total_tax': total_tax, @@ -309,8 +315,10 @@ def get_items(filters, additional_query_columns): select `tabPurchase Invoice Item`.`name`, `tabPurchase Invoice Item`.`parent`, `tabPurchase Invoice`.posting_date, `tabPurchase Invoice`.credit_to, `tabPurchase Invoice`.company, - `tabPurchase Invoice`.supplier, `tabPurchase Invoice`.remarks, `tabPurchase Invoice`.base_net_total, `tabPurchase Invoice Item`.`item_code`, - `tabPurchase Invoice Item`.`item_name`, `tabPurchase Invoice Item`.`item_group`, `tabPurchase Invoice Item`.description, + `tabPurchase Invoice`.supplier, `tabPurchase Invoice`.remarks, `tabPurchase Invoice`.base_net_total, + `tabPurchase Invoice`.unrealized_profit_loss_account, + `tabPurchase Invoice Item`.`item_code`, `tabPurchase Invoice Item`.description, + `tabPurchase Invoice Item`.`item_name`, `tabPurchase Invoice Item`.`item_group`, `tabPurchase Invoice Item`.`project`, `tabPurchase Invoice Item`.`purchase_order`, `tabPurchase Invoice Item`.`purchase_receipt`, `tabPurchase Invoice Item`.`po_detail`, `tabPurchase Invoice Item`.`expense_account`, `tabPurchase Invoice Item`.`stock_qty`, diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index a05dcd75ce5..928b373effe 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -8,6 +8,7 @@ from frappe.utils import flt, cstr from frappe.model.meta import get_field_precision from frappe.utils.xlsxutils import handle_html from erpnext.accounts.report.sales_register.sales_register import get_mode_of_payments +from erpnext.selling.report.item_wise_sales_history.item_wise_sales_history import get_item_details, get_customer_details def execute(filters=None): return _execute(filters) @@ -16,7 +17,7 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum if not filters: filters = {} columns = get_columns(additional_table_columns, filters) - company_currency = frappe.get_cached_value('Company', filters.get("company"), "default_currency") + company_currency = frappe.get_cached_value('Company', filters.get('company'), 'default_currency') item_list = get_items(filters, additional_query_columns) if item_list: @@ -33,7 +34,13 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum if filters.get('group_by'): grand_total = get_grand_total(filters, 'Sales Invoice') + customer_details = get_customer_details() + item_details = get_item_details() + for d in item_list: + customer_record = customer_details.get(d.customer) + item_record = item_details.get(d.item_code) + delivery_note = None if d.delivery_note: delivery_note = d.delivery_note @@ -45,14 +52,14 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum row = { 'item_code': d.item_code, - 'item_name': d.item_name, - 'item_group': d.item_group, + 'item_name': item_record.item_name if item_record else d.item_name, + 'item_group': item_record.item_group if item_record else d.item_group, 'description': d.description, 'invoice': d.parent, 'posting_date': d.posting_date, 'customer': d.customer, - 'customer_name': d.customer_name, - 'customer_group': d.customer_group, + 'customer_name': customer_record.customer_name, + 'customer_group': customer_record.customer_group, } if additional_query_columns: @@ -69,7 +76,7 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum 'company': d.company, 'sales_order': d.sales_order, 'delivery_note': d.delivery_note, - 'income_account': d.income_account, + 'income_account': d.unrealized_profit_loss_account or d.income_account, 'cost_center': d.cost_center, 'stock_qty': d.stock_qty, 'stock_uom': d.stock_uom @@ -90,10 +97,10 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum for tax in tax_columns: item_tax = itemised_tax.get(d.name, {}).get(tax, {}) row.update({ - frappe.scrub(tax + ' Rate'): item_tax.get("tax_rate", 0), - frappe.scrub(tax + ' Amount'): item_tax.get("tax_amount", 0), + frappe.scrub(tax + ' Rate'): item_tax.get('tax_rate', 0), + frappe.scrub(tax + ' Amount'): item_tax.get('tax_amount', 0), }) - total_tax += flt(item_tax.get("tax_amount")) + total_tax += flt(item_tax.get('tax_amount')) row.update({ 'total_tax': total_tax, @@ -226,7 +233,7 @@ def get_columns(additional_table_columns, filters): if filters.get('group_by') != 'Territory': columns.extend([ { - 'label': _("Territory"), + 'label': _('Territory'), 'fieldname': 'territory', 'fieldtype': 'Link', 'options': 'Territory', @@ -372,15 +379,16 @@ def get_items(filters, additional_query_columns): select `tabSales Invoice Item`.name, `tabSales Invoice Item`.parent, `tabSales Invoice`.posting_date, `tabSales Invoice`.debit_to, + `tabSales Invoice`.unrealized_profit_loss_account, `tabSales Invoice`.project, `tabSales Invoice`.customer, `tabSales Invoice`.remarks, `tabSales Invoice`.territory, `tabSales Invoice`.company, `tabSales Invoice`.base_net_total, - `tabSales Invoice Item`.item_code, `tabSales Invoice Item`.item_name, - `tabSales Invoice Item`.item_group, `tabSales Invoice Item`.description, `tabSales Invoice Item`.sales_order, - `tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.income_account, - `tabSales Invoice Item`.cost_center, `tabSales Invoice Item`.stock_qty, - `tabSales Invoice Item`.stock_uom, `tabSales Invoice Item`.base_net_rate, - `tabSales Invoice Item`.base_net_amount, `tabSales Invoice`.customer_name, - `tabSales Invoice`.customer_group, `tabSales Invoice Item`.so_detail, + `tabSales Invoice Item`.item_code, `tabSales Invoice Item`.description, + `tabSales Invoice Item`.`item_name`, `tabSales Invoice Item`.`item_group`, + `tabSales Invoice Item`.sales_order, `tabSales Invoice Item`.delivery_note, + `tabSales Invoice Item`.income_account, `tabSales Invoice Item`.cost_center, + `tabSales Invoice Item`.stock_qty, `tabSales Invoice Item`.stock_uom, + `tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount, + `tabSales Invoice`.customer_name, `tabSales Invoice`.customer_group, `tabSales Invoice Item`.so_detail, `tabSales Invoice`.update_stock, `tabSales Invoice Item`.uom, `tabSales Invoice Item`.qty {0} from `tabSales Invoice`, `tabSales Invoice Item` where `tabSales Invoice`.name = `tabSales Invoice Item`.parent @@ -417,14 +425,14 @@ def get_deducted_taxes(): return frappe.db.sql_list("select name from `tabPurchase Taxes and Charges` where add_deduct_tax = 'Deduct'") def get_tax_accounts(item_list, columns, company_currency, - doctype="Sales Invoice", tax_doctype="Sales Taxes and Charges"): + doctype='Sales Invoice', tax_doctype='Sales Taxes and Charges'): import json item_row_map = {} tax_columns = [] invoice_item_row = {} itemised_tax = {} - tax_amount_precision = get_field_precision(frappe.get_meta(tax_doctype).get_field("tax_amount"), + tax_amount_precision = get_field_precision(frappe.get_meta(tax_doctype).get_field('tax_amount'), currency=company_currency) or 2 for d in item_list: @@ -469,8 +477,8 @@ def get_tax_accounts(item_list, columns, company_currency, tax_rate = tax_data tax_amount = 0 - if charge_type == "Actual" and not tax_rate: - tax_rate = "NA" + if charge_type == 'Actual' and not tax_rate: + tax_rate = 'NA' item_net_amount = sum([flt(d.base_net_amount) for d in item_row_map.get(parent, {}).get(item_code, [])]) @@ -484,17 +492,17 @@ def get_tax_accounts(item_list, columns, company_currency, if (doctype == 'Purchase Invoice' and name in deducted_tax) else tax_value) itemised_tax.setdefault(d.name, {})[description] = frappe._dict({ - "tax_rate": tax_rate, - "tax_amount": tax_value + 'tax_rate': tax_rate, + 'tax_amount': tax_value }) except ValueError: continue - elif charge_type == "Actual" and tax_amount: + elif charge_type == 'Actual' and tax_amount: for d in invoice_item_row.get(parent, []): itemised_tax.setdefault(d.name, {})[description] = frappe._dict({ - "tax_rate": "NA", - "tax_amount": flt((tax_amount * d.base_net_amount) / d.base_net_total, + 'tax_rate': 'NA', + 'tax_amount': flt((tax_amount * d.base_net_amount) / d.base_net_total, tax_amount_precision) }) @@ -563,7 +571,7 @@ def add_total_row(data, filters, prev_group_by_value, item, total_row_map, }) total_row_map.setdefault('total_row', { - subtotal_display_field: "Total", + subtotal_display_field: 'Total', 'stock_qty': 0.0, 'amount': 0.0, 'bold': 1, diff --git a/erpnext/accounts/report/non_billed_report.py b/erpnext/accounts/report/non_billed_report.py index a9e25bc25bf..2e18ce11ddc 100644 --- a/erpnext/accounts/report/non_billed_report.py +++ b/erpnext/accounts/report/non_billed_report.py @@ -17,18 +17,26 @@ def get_ordered_to_be_billed_data(args): return frappe.db.sql(""" Select - `{parent_tab}`.name, `{parent_tab}`.status, `{parent_tab}`.{date_field}, `{parent_tab}`.{party}, `{parent_tab}`.{party}_name, - {project_field}, `{child_tab}`.item_code, `{child_tab}`.base_amount, + `{parent_tab}`.name, `{parent_tab}`.{date_field}, + `{parent_tab}`.{party}, `{parent_tab}`.{party}_name, + `{child_tab}`.item_code, + `{child_tab}`.base_amount, (`{child_tab}`.billed_amt * ifnull(`{parent_tab}`.conversion_rate, 1)), - (`{child_tab}`.base_amount - (`{child_tab}`.billed_amt * ifnull(`{parent_tab}`.conversion_rate, 1))), - `{child_tab}`.item_name, `{child_tab}`.description, `{parent_tab}`.company + (`{child_tab}`.base_rate * ifnull(`{child_tab}`.returned_qty, 0)), + (`{child_tab}`.base_amount - + (`{child_tab}`.billed_amt * ifnull(`{parent_tab}`.conversion_rate, 1)) - + (`{child_tab}`.base_rate * ifnull(`{child_tab}`.returned_qty, 0))), + `{child_tab}`.item_name, `{child_tab}`.description, + {project_field}, `{parent_tab}`.company from `{parent_tab}`, `{child_tab}` where `{parent_tab}`.name = `{child_tab}`.parent and `{parent_tab}`.docstatus = 1 and `{parent_tab}`.status not in ('Closed', 'Completed') - and `{child_tab}`.amount > 0 and round(`{child_tab}`.billed_amt * - ifnull(`{parent_tab}`.conversion_rate, 1), {precision}) < `{child_tab}`.base_amount + and `{child_tab}`.amount > 0 + and (`{child_tab}`.base_amount - + round(`{child_tab}`.billed_amt * ifnull(`{parent_tab}`.conversion_rate, 1), {precision}) - + (`{child_tab}`.base_rate * ifnull(`{child_tab}`.returned_qty, 0))) > 0 order by `{parent_tab}`.{order} {order_by} """.format(parent_tab = 'tab' + doctype, child_tab = 'tab' + child_tab, precision= precision, party = party, diff --git a/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py b/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py index 57a1231f5a9..7195c7e0b8b 100644 --- a/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py +++ b/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py @@ -59,23 +59,111 @@ def validate_filters(filters): def get_columns(filters): return [ - _("Payment Document") + ":: 100", - _("Payment Entry") + ":Dynamic Link/"+_("Payment Document")+":140", - _("Party Type") + "::100", - _("Party") + ":Dynamic Link/Party Type:140", - _("Posting Date") + ":Date:100", - _("Invoice") + (":Link/Purchase Invoice:130" if filters.get("payment_type") == _("Outgoing") else ":Link/Sales Invoice:130"), - _("Invoice Posting Date") + ":Date:130", - _("Payment Due Date") + ":Date:130", - _("Debit") + ":Currency:120", - _("Credit") + ":Currency:120", - _("Remarks") + "::150", - _("Age") +":Int:40", - "0-30:Currency:100", - "30-60:Currency:100", - "60-90:Currency:100", - _("90-Above") + ":Currency:100", - _("Delay in payment (Days)") + "::150" + { + "fieldname": "payment_document", + "label": _("Payment Document Type"), + "fieldtype": "Data", + "width": 100 + }, + { + "fieldname": "payment_entry", + "label": _("Payment Document"), + "fieldtype": "Dynamic Link", + "options": "payment_document", + "width": 160 + }, + { + "fieldname": "party_type", + "label": _("Party Type"), + "fieldtype": "Data", + "width": 100 + }, + { + "fieldname": "party", + "label": _("Party"), + "fieldtype": "Dynamic Link", + "options": "party_type", + "width": 160 + }, + { + "fieldname": "posting_date", + "label": _("Posting Date"), + "fieldtype": "Date", + "width": 100 + }, + { + "fieldname": "invoice", + "label": _("Invoice"), + "fieldtype": "Link", + "options": "Purchase Invoice" if filters.get("payment_type") == _("Outgoing") else "Sales Invoice", + "width": 160 + }, + { + "fieldname": "invoice_posting_date", + "label": _("Invoice Posting Date"), + "fieldtype": "Date", + "width": 100 + }, + { + "fieldname": "due_date", + "label": _("Payment Due Date"), + "fieldtype": "Date", + "width": 100 + }, + { + "fieldname": "debit", + "label": _("Debit"), + "fieldtype": "Currency", + "width": 140 + }, + { + "fieldname": "credit", + "label": _("Credit"), + "fieldtype": "Currency", + "width": 140 + }, + { + "fieldname": "remarks", + "label": _("Remarks"), + "fieldtype": "Data", + "width": 200 + }, + { + "fieldname": "age", + "label": _("Age"), + "fieldtype": "Int", + "width": 50 + }, + { + "fieldname": "range1", + "label": "0-30", + "fieldtype": "Currency", + "width": 140 + }, + { + "fieldname": "range2", + "label": "30-60", + "fieldtype": "Currency", + "width": 140 + }, + { + "fieldname": "range3", + "label": "60-90", + "fieldtype": "Currency", + "width": 140 + }, + { + "fieldname": "range4", + "label": _("90 Above"), + "fieldtype": "Currency", + "width": 140 + }, + { + "fieldname": "delay_in_payment", + "label": _("Delay in payment (Days)"), + "fieldtype": "Int", + "width": 100 + } ] def get_conditions(filters): diff --git a/erpnext/accounts/doctype/bank_statement_transaction_settings_item/__init__.py b/erpnext/accounts/report/pos_register/__init__.py similarity index 100% rename from erpnext/accounts/doctype/bank_statement_transaction_settings_item/__init__.py rename to erpnext/accounts/report/pos_register/__init__.py diff --git a/erpnext/accounts/report/pos_register/pos_register.js b/erpnext/accounts/report/pos_register/pos_register.js new file mode 100644 index 00000000000..b8d48d92de0 --- /dev/null +++ b/erpnext/accounts/report/pos_register/pos_register.js @@ -0,0 +1,76 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["POS Register"] = { + "filters": [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + }, + { + "fieldname":"from_date", + "label": __("From Date"), + "fieldtype": "Date", + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1), + "reqd": 1, + "width": "60px" + }, + { + "fieldname":"to_date", + "label": __("To Date"), + "fieldtype": "Date", + "default": frappe.datetime.get_today(), + "reqd": 1, + "width": "60px" + }, + { + "fieldname":"pos_profile", + "label": __("POS Profile"), + "fieldtype": "Link", + "options": "POS Profile" + }, + { + "fieldname":"cashier", + "label": __("Cashier"), + "fieldtype": "Link", + "options": "User" + }, + { + "fieldname":"customer", + "label": __("Customer"), + "fieldtype": "Link", + "options": "Customer" + }, + { + "fieldname":"mode_of_payment", + "label": __("Payment Method"), + "fieldtype": "Link", + "options": "Mode of Payment" + }, + { + "fieldname":"group_by", + "label": __("Group by"), + "fieldtype": "Select", + "options": ["", "POS Profile", "Cashier", "Payment Method", "Customer"], + "default": "POS Profile" + }, + { + "fieldname":"is_return", + "label": __("Is Return"), + "fieldtype": "Check" + }, + ], + "formatter": function(value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + if (data && data.bold) { + value = value.bold(); + + } + return value; + } +}; diff --git a/erpnext/accounts/report/pos_register/pos_register.json b/erpnext/accounts/report/pos_register/pos_register.json new file mode 100644 index 00000000000..2398b104755 --- /dev/null +++ b/erpnext/accounts/report/pos_register/pos_register.json @@ -0,0 +1,30 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2020-09-10 19:25:03.766871", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "modified": "2020-09-10 19:25:15.851331", + "modified_by": "Administrator", + "module": "Accounts", + "name": "POS Register", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "POS Invoice", + "report_name": "POS Register", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts Manager" + }, + { + "role": "Accounts User" + } + ] +} \ No newline at end of file diff --git a/erpnext/accounts/report/pos_register/pos_register.py b/erpnext/accounts/report/pos_register/pos_register.py new file mode 100644 index 00000000000..52f7fe238e8 --- /dev/null +++ b/erpnext/accounts/report/pos_register/pos_register.py @@ -0,0 +1,223 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _, _dict +from erpnext import get_company_currency, get_default_company +from erpnext.accounts.report.sales_register.sales_register import get_mode_of_payments + +def execute(filters=None): + if not filters: + return [], [] + + validate_filters(filters) + + columns = get_columns(filters) + + group_by_field = get_group_by_field(filters.get("group_by")) + + pos_entries = get_pos_entries(filters, group_by_field) + if group_by_field != "mode_of_payment": + concat_mode_of_payments(pos_entries) + + # return only entries if group by is unselected + if not group_by_field: + return columns, pos_entries + + # handle grouping + invoice_map, grouped_data = {}, [] + for d in pos_entries: + invoice_map.setdefault(d[group_by_field], []).append(d) + + for key in invoice_map: + invoices = invoice_map[key] + grouped_data += invoices + add_subtotal_row(grouped_data, invoices, group_by_field, key) + + # move group by column to first position + column_index = next((index for (index, d) in enumerate(columns) if d["fieldname"] == group_by_field), None) + columns.insert(0, columns.pop(column_index)) + + return columns, grouped_data + +def get_pos_entries(filters, group_by_field): + conditions = get_conditions(filters) + order_by = "p.posting_date" + select_mop_field, from_sales_invoice_payment, group_by_mop_condition = "", "", "" + if group_by_field == "mode_of_payment": + select_mop_field = ", sip.mode_of_payment" + from_sales_invoice_payment = ", `tabSales Invoice Payment` sip" + group_by_mop_condition = "sip.parent = p.name AND ifnull(sip.base_amount, 0) != 0 AND" + order_by += ", sip.mode_of_payment" + + elif group_by_field: + order_by += ", p.{}".format(group_by_field) + + return frappe.db.sql( + """ + SELECT + p.posting_date, p.name as pos_invoice, p.pos_profile, + p.owner, p.base_grand_total as grand_total, p.base_paid_amount as paid_amount, + p.customer, p.is_return {select_mop_field} + FROM + `tabPOS Invoice` p {from_sales_invoice_payment} + WHERE + p.docstatus = 1 and + {group_by_mop_condition} + {conditions} + ORDER BY + {order_by} + """.format( + select_mop_field=select_mop_field, + from_sales_invoice_payment=from_sales_invoice_payment, + group_by_mop_condition=group_by_mop_condition, + conditions=conditions, + order_by=order_by + ), filters, as_dict=1) + +def concat_mode_of_payments(pos_entries): + mode_of_payments = get_mode_of_payments(set([d.pos_invoice for d in pos_entries])) + for entry in pos_entries: + if mode_of_payments.get(entry.pos_invoice): + entry.mode_of_payment = ", ".join(mode_of_payments.get(entry.pos_invoice, [])) + +def add_subtotal_row(data, group_invoices, group_by_field, group_by_value): + grand_total = sum([d.grand_total for d in group_invoices]) + paid_amount = sum([d.paid_amount for d in group_invoices]) + data.append({ + group_by_field: group_by_value, + "grand_total": grand_total, + "paid_amount": paid_amount, + "bold": 1 + }) + data.append({}) + +def validate_filters(filters): + if not filters.get("company"): + frappe.throw(_("{0} is mandatory").format(_("Company"))) + + if not filters.get("from_date") and not filters.get("to_date"): + frappe.throw(_("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date")))) + + if filters.from_date > filters.to_date: + frappe.throw(_("From Date must be before To Date")) + + if (filters.get("pos_profile") and filters.get("group_by") == _('POS Profile')): + frappe.throw(_("Can not filter based on POS Profile, if grouped by POS Profile")) + + if (filters.get("customer") and filters.get("group_by") == _('Customer')): + frappe.throw(_("Can not filter based on Customer, if grouped by Customer")) + + if (filters.get("owner") and filters.get("group_by") == _('Cashier')): + frappe.throw(_("Can not filter based on Cashier, if grouped by Cashier")) + + if (filters.get("mode_of_payment") and filters.get("group_by") == _('Payment Method')): + frappe.throw(_("Can not filter based on Payment Method, if grouped by Payment Method")) + +def get_conditions(filters): + conditions = "company = %(company)s AND posting_date >= %(from_date)s AND posting_date <= %(to_date)s".format( + company=filters.get("company"), + from_date=filters.get("from_date"), + to_date=filters.get("to_date")) + + if filters.get("pos_profile"): + conditions += " AND pos_profile = %(pos_profile)s".format(pos_profile=filters.get("pos_profile")) + + if filters.get("owner"): + conditions += " AND owner = %(owner)s".format(owner=filters.get("owner")) + + if filters.get("customer"): + conditions += " AND customer = %(customer)s".format(customer=filters.get("customer")) + + if filters.get("is_return"): + conditions += " AND is_return = %(is_return)s".format(is_return=filters.get("is_return")) + + if filters.get("mode_of_payment"): + conditions += """ + AND EXISTS( + SELECT name FROM `tabSales Invoice Payment` sip + WHERE parent=p.name AND ifnull(sip.mode_of_payment, '') = %(mode_of_payment)s + )""" + + return conditions + +def get_group_by_field(group_by): + group_by_field = "" + + if group_by == "POS Profile": + group_by_field = "pos_profile" + elif group_by == "Cashier": + group_by_field = "owner" + elif group_by == "Customer": + group_by_field = "customer" + elif group_by == "Payment Method": + group_by_field = "mode_of_payment" + + return group_by_field + +def get_columns(filters): + columns = [ + { + "label": _("Posting Date"), + "fieldname": "posting_date", + "fieldtype": "Date", + "width": 90 + }, + { + "label": _("POS Invoice"), + "fieldname": "pos_invoice", + "fieldtype": "Link", + "options": "POS Invoice", + "width": 120 + }, + { + "label": _("Customer"), + "fieldname": "customer", + "fieldtype": "Link", + "options": "Customer", + "width": 120 + }, + { + "label": _("POS Profile"), + "fieldname": "pos_profile", + "fieldtype": "Link", + "options": "POS Profile", + "width": 160 + }, + { + "label": _("Cashier"), + "fieldname": "owner", + "fieldtype": "Link", + "options": "User", + "width": 140 + }, + { + "label": _("Grand Total"), + "fieldname": "grand_total", + "fieldtype": "Currency", + "options": "company:currency", + "width": 120 + }, + { + "label": _("Paid Amount"), + "fieldname": "paid_amount", + "fieldtype": "Currency", + "options": "company:currency", + "width": 120 + }, + { + "label": _("Payment Method"), + "fieldname": "mode_of_payment", + "fieldtype": "Data", + "width": 150 + }, + { + "label": _("Is Return"), + "fieldname": "is_return", + "fieldtype": "Data", + "width": 80 + }, + ] + + return columns \ No newline at end of file diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index b34d037f04d..fe261b30b45 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -59,24 +59,26 @@ def get_report_summary(period_list, periodicity, income, expense, net_profit_los expense_label = _("Total Expense") return [ - { - "value": net_profit, - "indicator": "Green" if net_profit > 0 else "Red", - "label": profit_label, - "datatype": "Currency", - "currency": currency - }, { "value": net_income, "label": income_label, "datatype": "Currency", "currency": currency }, + { "type": "separator", "value": "-"}, { "value": net_expense, "label": expense_label, "datatype": "Currency", "currency": currency + }, + { "type": "separator", "value": "=", "color": "blue"}, + { + "value": net_profit, + "indicator": "Green" if net_profit > 0 else "Red", + "label": profit_label, + "datatype": "Currency", + "currency": currency } ] diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index 9399e707390..8ac749d6290 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -14,13 +14,15 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum if not filters: filters = {} invoice_list = get_invoices(filters, additional_query_columns) - columns, expense_accounts, tax_accounts = get_columns(invoice_list, additional_table_columns) + columns, expense_accounts, tax_accounts, unrealized_profit_loss_accounts \ + = get_columns(invoice_list, additional_table_columns) if not invoice_list: msgprint(_("No record found")) return columns, invoice_list invoice_expense_map = get_invoice_expense_map(invoice_list) + internal_invoice_map = get_internal_invoice_map(invoice_list) invoice_expense_map, invoice_tax_map = get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts) invoice_po_pr_map = get_invoice_po_pr_map(invoice_list) @@ -52,10 +54,17 @@ def _execute(filters=None, additional_table_columns=None, additional_query_colum # map expense values base_net_total = 0 for expense_acc in expense_accounts: - expense_amount = flt(invoice_expense_map.get(inv.name, {}).get(expense_acc)) + if inv.is_internal_supplier and inv.company == inv.represents_company: + expense_amount = 0 + else: + expense_amount = flt(invoice_expense_map.get(inv.name, {}).get(expense_acc)) base_net_total += expense_amount row.append(expense_amount) + # Add amount in unrealized account + for account in unrealized_profit_loss_accounts: + row.append(flt(internal_invoice_map.get((inv.name, account)))) + # net total row.append(base_net_total or inv.base_net_total) @@ -96,7 +105,8 @@ def get_columns(invoice_list, additional_table_columns): "width": 80 } ] - expense_accounts = tax_accounts = expense_columns = tax_columns = [] + expense_accounts = tax_accounts = expense_columns = tax_columns = unrealized_profit_loss_accounts = \ + unrealized_profit_loss_account_columns = [] if invoice_list: expense_accounts = frappe.db.sql_list("""select distinct expense_account @@ -112,17 +122,25 @@ def get_columns(invoice_list, additional_table_columns): and parent in (%s) order by account_head""" % ', '.join(['%s']*len(invoice_list)), tuple([inv.name for inv in invoice_list])) + unrealized_profit_loss_accounts = frappe.db.sql_list("""SELECT distinct unrealized_profit_loss_account + from `tabPurchase Invoice` where docstatus = 1 and name in (%s) + and ifnull(unrealized_profit_loss_account, '') != '' + order by unrealized_profit_loss_account""" % + ', '.join(['%s']*len(invoice_list)), tuple([inv.name for inv in invoice_list])) expense_columns = [(account + ":Currency/currency:120") for account in expense_accounts] + unrealized_profit_loss_account_columns = [(account + ":Currency/currency:120") for account in unrealized_profit_loss_accounts] + for account in tax_accounts: if account not in expense_accounts: tax_columns.append(account + ":Currency/currency:120") - columns = columns + expense_columns + [_("Net Total") + ":Currency/currency:120"] + tax_columns + \ + columns = columns + expense_columns + unrealized_profit_loss_account_columns + \ + [_("Net Total") + ":Currency/currency:120"] + tax_columns + \ [_("Total Tax") + ":Currency/currency:120", _("Grand Total") + ":Currency/currency:120", _("Rounded Total") + ":Currency/currency:120", _("Outstanding Amount") + ":Currency/currency:120"] - return columns, expense_accounts, tax_accounts + return columns, expense_accounts, tax_accounts, unrealized_profit_loss_accounts def get_conditions(filters): conditions = "" @@ -199,6 +217,19 @@ def get_invoice_expense_map(invoice_list): return invoice_expense_map +def get_internal_invoice_map(invoice_list): + unrealized_amount_details = frappe.db.sql("""SELECT name, unrealized_profit_loss_account, + base_net_total as amount from `tabPurchase Invoice` where name in (%s) + and is_internal_supplier = 1 and company = represents_company""" % + ', '.join(['%s']*len(invoice_list)), tuple([inv.name for inv in invoice_list]), as_dict=1) + + internal_invoice_map = {} + for d in unrealized_amount_details: + if d.unrealized_profit_loss_account: + internal_invoice_map.setdefault((d.name, d.unrealized_profit_loss_account), d.amount) + + return internal_invoice_map + def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts): tax_details = frappe.db.sql(""" select parent, account_head, case add_deduct_tax when "Add" then sum(base_tax_amount_after_discount_amount) diff --git a/erpnext/accounts/report/received_items_to_be_billed/received_items_to_be_billed.py b/erpnext/accounts/report/received_items_to_be_billed/received_items_to_be_billed.py index 5e8d7730b76..e9e9c9c4e69 100644 --- a/erpnext/accounts/report/received_items_to_be_billed/received_items_to_be_billed.py +++ b/erpnext/accounts/report/received_items_to_be_billed/received_items_to_be_billed.py @@ -14,11 +14,93 @@ def execute(filters=None): def get_column(): return [ - _("Purchase Receipt") + ":Link/Purchase Receipt:120", _("Status") + "::120", _("Date") + ":Date:100", - _("Supplier") + ":Link/Supplier:120", _("Supplier Name") + "::120", - _("Project") + ":Link/Project:120", _("Item Code") + ":Link/Item:120", - _("Amount") + ":Currency:100", _("Billed Amount") + ":Currency:100", _("Amount to Bill") + ":Currency:100", - _("Item Name") + "::120", _("Description") + "::120", _("Company") + ":Link/Company:120", + { + "label": _("Purchase Receipt"), + "fieldname": "name", + "fieldtype": "Link", + "options": "Purchase Receipt", + "width": 160 + }, + { + "label": _("Date"), + "fieldname": "date", + "fieldtype": "Date", + "width": 100 + }, + { + "label": _("Supplier"), + "fieldname": "supplier", + "fieldtype": "Link", + "options": "Supplier", + "width": 120 + }, + { + "label": _("Supplier Name"), + "fieldname": "supplier_name", + "fieldtype": "Data", + "width": 120 + }, + { + "label": _("Item Code"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": 120 + }, + { + "label": _("Amount"), + "fieldname": "amount", + "fieldtype": "Currency", + "width": 100, + "options": "Company:company:default_currency" + }, + { + "label": _("Billed Amount"), + "fieldname": "billed_amount", + "fieldtype": "Currency", + "width": 100, + "options": "Company:company:default_currency" + }, + { + "label": _("Returned Amount"), + "fieldname": "returned_amount", + "fieldtype": "Currency", + "width": 120, + "options": "Company:company:default_currency" + }, + { + "label": _("Pending Amount"), + "fieldname": "pending_amount", + "fieldtype": "Currency", + "width": 120, + "options": "Company:company:default_currency" + }, + { + "label": _("Item Name"), + "fieldname": "item_name", + "fieldtype": "Data", + "width": 120 + }, + { + "label": _("Description"), + "fieldname": "description", + "fieldtype": "Data", + "width": 120 + }, + { + "label": _("Project"), + "fieldname": "project", + "fieldtype": "Link", + "options": "Project", + "width": 120 + }, + { + "label": _("Company"), + "fieldname": "company", + "fieldtype": "Link", + "options": "Company", + "width": 120 + } ] def get_args(): diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py index b6e61b13069..cb2c98b64ae 100644 --- a/erpnext/accounts/report/sales_register/sales_register.py +++ b/erpnext/accounts/report/sales_register/sales_register.py @@ -15,13 +15,14 @@ def _execute(filters, additional_table_columns=None, additional_query_columns=No if not filters: filters = frappe._dict({}) invoice_list = get_invoices(filters, additional_query_columns) - columns, income_accounts, tax_accounts = get_columns(invoice_list, additional_table_columns) + columns, income_accounts, tax_accounts, unrealized_profit_loss_accounts = get_columns(invoice_list, additional_table_columns) if not invoice_list: msgprint(_("No record found")) return columns, invoice_list invoice_income_map = get_invoice_income_map(invoice_list) + internal_invoice_map = get_internal_invoice_map(invoice_list) invoice_income_map, invoice_tax_map = get_invoice_tax_map(invoice_list, invoice_income_map, income_accounts) #Cost Center & Warehouse Map @@ -70,12 +71,22 @@ def _execute(filters, additional_table_columns=None, additional_query_columns=No # map income values base_net_total = 0 for income_acc in income_accounts: - income_amount = flt(invoice_income_map.get(inv.name, {}).get(income_acc)) + if inv.is_internal_customer and inv.company == inv.represents_company: + income_amount = 0 + else: + income_amount = flt(invoice_income_map.get(inv.name, {}).get(income_acc)) + base_net_total += income_amount row.update({ frappe.scrub(income_acc): income_amount }) + # Add amount in unrealized account + for account in unrealized_profit_loss_accounts: + row.update({ + frappe.scrub(account): flt(internal_invoice_map.get((inv.name, account))) + }) + # net total row.update({'net_total': base_net_total or inv.base_net_total}) @@ -230,6 +241,8 @@ def get_columns(invoice_list, additional_table_columns): tax_accounts = [] income_columns = [] tax_columns = [] + unrealized_profit_loss_accounts = [] + unrealized_profit_loss_account_columns = [] if invoice_list: income_accounts = frappe.db.sql_list("""select distinct income_account @@ -243,12 +256,18 @@ def get_columns(invoice_list, additional_table_columns): and parent in (%s) order by account_head""" % ', '.join(['%s']*len(invoice_list)), tuple([inv.name for inv in invoice_list])) + unrealized_profit_loss_accounts = frappe.db.sql_list("""SELECT distinct unrealized_profit_loss_account + from `tabSales Invoice` where docstatus = 1 and name in (%s) + and ifnull(unrealized_profit_loss_account, '') != '' + order by unrealized_profit_loss_account""" % + ', '.join(['%s']*len(invoice_list)), tuple([inv.name for inv in invoice_list])) + for account in income_accounts: income_columns.append({ "label": account, "fieldname": frappe.scrub(account), "fieldtype": "Currency", - "options": 'currency', + "options": "currency", "width": 120 }) @@ -258,15 +277,24 @@ def get_columns(invoice_list, additional_table_columns): "label": account, "fieldname": frappe.scrub(account), "fieldtype": "Currency", - "options": 'currency', + "options": "currency", "width": 120 }) + for account in unrealized_profit_loss_accounts: + unrealized_profit_loss_account_columns.append({ + "label": account, + "fieldname": frappe.scrub(account), + "fieldtype": "Currency", + "options": "currency", + "width": 120 + }) + net_total_column = [{ "label": _("Net Total"), "fieldname": "net_total", "fieldtype": "Currency", - "options": 'currency', + "options": "currency", "width": 120 }] @@ -301,9 +329,10 @@ def get_columns(invoice_list, additional_table_columns): } ] - columns = columns + income_columns + net_total_column + tax_columns + total_columns + columns = columns + income_columns + unrealized_profit_loss_account_columns + \ + net_total_column + tax_columns + total_columns - return columns, income_accounts, tax_accounts + return columns, income_accounts, tax_accounts, unrealized_profit_loss_accounts def get_conditions(filters): conditions = "" @@ -368,7 +397,8 @@ def get_invoices(filters, additional_query_columns): return frappe.db.sql(""" select name, posting_date, debit_to, project, customer, customer_name, owner, remarks, territory, tax_id, customer_group, - base_net_total, base_grand_total, base_rounded_total, outstanding_amount {0} + base_net_total, base_grand_total, base_rounded_total, outstanding_amount, + is_internal_customer, represents_company, company {0} from `tabSales Invoice` where docstatus = 1 %s order by posting_date desc, name desc""".format(additional_query_columns or '') % conditions, filters, as_dict=1) @@ -385,6 +415,19 @@ def get_invoice_income_map(invoice_list): return invoice_income_map +def get_internal_invoice_map(invoice_list): + unrealized_amount_details = frappe.db.sql("""SELECT name, unrealized_profit_loss_account, + base_net_total as amount from `tabSales Invoice` where name in (%s) + and is_internal_customer = 1 and company = represents_company""" % + ', '.join(['%s']*len(invoice_list)), tuple([inv.name for inv in invoice_list]), as_dict=1) + + internal_invoice_map = {} + for d in unrealized_amount_details: + if d.unrealized_profit_loss_account: + internal_invoice_map.setdefault((d.name, d.unrealized_profit_loss_account), d.amount) + + return internal_invoice_map + def get_invoice_tax_map(invoice_list, invoice_income_map, income_accounts): tax_details = frappe.db.sql("""select parent, account_head, sum(base_tax_amount_after_discount_amount) as tax_amount diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index c7cfee74cb0..a8280c1b18e 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -55,7 +55,7 @@ def get_result(filters): except IndexError: account = [] total_invoiced_amount, tds_deducted = get_invoice_and_tds_amount(supplier.name, account, - filters.company, filters.from_date, filters.to_date) + filters.company, filters.from_date, filters.to_date, filters.fiscal_year) if total_invoiced_amount or tds_deducted: row = [supplier.pan, supplier.name] @@ -68,7 +68,7 @@ def get_result(filters): return out -def get_invoice_and_tds_amount(supplier, account, company, from_date, to_date): +def get_invoice_and_tds_amount(supplier, account, company, from_date, to_date, fiscal_year): ''' calculate total invoice amount and total tds deducted for given supplier ''' entries = frappe.db.sql(""" @@ -94,7 +94,9 @@ def get_invoice_and_tds_amount(supplier, account, company, from_date, to_date): """.format(', '.join(["'%s'" % d for d in vouchers])), (account, from_date, to_date, company))[0][0]) - debit_note_amount = get_debit_note_amount([supplier], from_date, to_date, company=company) + date_range_filter = [fiscal_year, from_date, to_date] + + debit_note_amount = get_debit_note_amount([supplier], date_range_filter, company=company) total_invoiced_amount = supplier_credit_amount + tds_deducted - debit_note_amount diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 008f6e82369..89a05b187d1 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -12,11 +12,12 @@ from frappe.utils import formatdate, get_number_format_info from six import iteritems # imported to enable erpnext.accounts.utils.get_account_currency from erpnext.accounts.doctype.account.account import get_account_currency +from frappe.model.meta import get_field_precision from erpnext.stock.utils import get_stock_value_on from erpnext.stock import get_warehouse_account_map - +class StockValueAndAccountBalanceOutOfSync(frappe.ValidationError): pass class FiscalYearError(frappe.ValidationError): pass @frappe.whitelist() @@ -78,7 +79,10 @@ def get_fiscal_years(transaction_date=None, fiscal_year=None, label="Date", verb else: return ((fy.name, fy.year_start_date, fy.year_end_date),) - error_msg = _("""{0} {1} not in any active Fiscal Year.""").format(label, formatdate(transaction_date)) + error_msg = _("""{0} {1} is not in any active Fiscal Year""").format(label, formatdate(transaction_date)) + if company: + error_msg = _("""{0} for {1}""").format(error_msg, frappe.bold(company)) + if verbose==1: frappe.msgprint(error_msg) raise FiscalYearError(error_msg) @@ -582,24 +586,6 @@ def fix_total_debit_credit(): (dr_or_cr, dr_or_cr, '%s', '%s', '%s', dr_or_cr), (d.diff, d.voucher_type, d.voucher_no)) -def get_stock_and_account_balance(account=None, posting_date=None, company=None): - if not posting_date: posting_date = nowdate() - - warehouse_account = get_warehouse_account_map(company) - - account_balance = get_balance_on(account, posting_date, in_account_currency=False, ignore_account_permission=True) - - related_warehouses = [wh for wh, wh_details in warehouse_account.items() - if wh_details.account == account and not wh_details.is_group] - - total_stock_value = 0.0 - for warehouse in related_warehouses: - value = get_stock_value_on(warehouse, posting_date) - total_stock_value += value - - precision = frappe.get_precision("Journal Entry Account", "debit_in_account_currency") - return flt(account_balance, precision), flt(total_stock_value, precision), related_warehouses - def get_currency_precision(): precision = cint(frappe.db.get_default("currency_precision")) if not precision: @@ -796,7 +782,7 @@ def get_children(doctype, parent, company, is_root=False): return acc -def create_payment_gateway_account(gateway): +def create_payment_gateway_account(gateway, payment_channel="Email"): from erpnext.setup.setup_wizard.operations.company_setup import create_bank_account company = frappe.db.get_value("Global Defaults", None, "default_company") @@ -831,7 +817,8 @@ def create_payment_gateway_account(gateway): "is_default": 1, "payment_gateway": gateway, "payment_account": bank_account.name, - "currency": bank_account.account_currency + "currency": bank_account.account_currency, + "payment_channel": payment_channel }).insert(ignore_permissions=True) except frappe.DuplicateEntryError: @@ -899,14 +886,13 @@ def get_coa(doctype, parent, is_root, chart=None): return accounts -def get_stock_accounts(company): - return frappe.get_all("Account", filters = { - "account_type": "Stock", - "company": company - }) - def update_gl_entries_after(posting_date, posting_time, for_warehouses=None, for_items=None, warehouse_account=None, company=None): + stock_vouchers = get_future_stock_vouchers(posting_date, posting_time, for_warehouses, for_items, company) + repost_gle_for_stock_vouchers(stock_vouchers, posting_date, company, warehouse_account) + + +def repost_gle_for_stock_vouchers(stock_vouchers, posting_date, company=None, warehouse_account=None): def _delete_gl_entries(voucher_type, voucher_no): frappe.db.sql("""delete from `tabGL Entry` where voucher_type=%s and voucher_no=%s""", (voucher_type, voucher_no)) @@ -914,21 +900,21 @@ def update_gl_entries_after(posting_date, posting_time, for_warehouses=None, for if not warehouse_account: warehouse_account = get_warehouse_account_map(company) - future_stock_vouchers = get_future_stock_vouchers(posting_date, posting_time, for_warehouses, for_items) - gle = get_voucherwise_gl_entries(future_stock_vouchers, posting_date) + precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit")) or 2 - for voucher_type, voucher_no in future_stock_vouchers: + gle = get_voucherwise_gl_entries(stock_vouchers, posting_date) + for voucher_type, voucher_no in stock_vouchers: existing_gle = gle.get((voucher_type, voucher_no), []) - voucher_obj = frappe.get_doc(voucher_type, voucher_no) + voucher_obj = frappe.get_cached_doc(voucher_type, voucher_no) expected_gle = voucher_obj.get_gl_entries(warehouse_account) if expected_gle: - if not existing_gle or not compare_existing_and_expected_gle(existing_gle, expected_gle): + if not existing_gle or not compare_existing_and_expected_gle(existing_gle, expected_gle, precision): _delete_gl_entries(voucher_type, voucher_no) - voucher_obj.make_gl_entries(gl_entries=expected_gle, repost_future_gle=False, from_repost=True) + voucher_obj.make_gl_entries(gl_entries=expected_gle, from_repost=True) else: _delete_gl_entries(voucher_type, voucher_no) -def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, for_items=None): +def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, for_items=None, company=None): future_stock_vouchers = [] values = [] @@ -941,9 +927,16 @@ def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, f condition += " and warehouse in ({})".format(", ".join(["%s"] * len(for_warehouses))) values += for_warehouses + if company: + condition += " and company = %s" + values.append(company) + for d in frappe.db.sql("""select distinct sle.voucher_type, sle.voucher_no from `tabStock Ledger Entry` sle - where timestamp(sle.posting_date, sle.posting_time) >= timestamp(%s, %s) {condition} + where + timestamp(sle.posting_date, sle.posting_time) >= timestamp(%s, %s) + and is_cancelled = 0 + {condition} order by timestamp(sle.posting_date, sle.posting_time) asc, creation asc for update""".format(condition=condition), tuple([posting_date, posting_time] + values), as_dict=True): future_stock_vouchers.append([d.voucher_type, d.voucher_no]) @@ -960,3 +953,107 @@ def get_voucherwise_gl_entries(future_stock_vouchers, posting_date): gl_entries.setdefault((d.voucher_type, d.voucher_no), []).append(d) return gl_entries + +def compare_existing_and_expected_gle(existing_gle, expected_gle, precision): + matched = True + for entry in expected_gle: + account_existed = False + for e in existing_gle: + if entry.account == e.account: + account_existed = True + if (entry.account == e.account and entry.against_account == e.against_account + and (not entry.cost_center or not e.cost_center or entry.cost_center == e.cost_center) + and ( flt(entry.debit, precision) != flt(e.debit, precision) or + flt(entry.credit, precision) != flt(e.credit, precision))): + matched = False + break + if not account_existed: + matched = False + break + return matched + +def check_if_stock_and_account_balance_synced(posting_date, company, voucher_type=None, voucher_no=None): + if not cint(erpnext.is_perpetual_inventory_enabled(company)): + return + + accounts = get_stock_accounts(company, voucher_type, voucher_no) + stock_adjustment_account = frappe.db.get_value("Company", company, "stock_adjustment_account") + + for account in accounts: + account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(account, + posting_date, company) + + if abs(account_bal - stock_bal) > 0.1: + precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), + currency=frappe.get_cached_value('Company', company, "default_currency")) + + diff = flt(stock_bal - account_bal, precision) + + error_reason = _("Stock Value ({0}) and Account Balance ({1}) are out of sync for account {2} and it's linked warehouses as on {3}.").format( + stock_bal, account_bal, frappe.bold(account), posting_date) + error_resolution = _("Please create an adjustment Journal Entry for amount {0} on {1}")\ + .format(frappe.bold(diff), frappe.bold(posting_date)) + + frappe.msgprint( + msg="""{0}

    {1}

    """.format(error_reason, error_resolution), + raise_exception=StockValueAndAccountBalanceOutOfSync, + title=_('Values Out Of Sync'), + primary_action={ + 'label': _('Make Journal Entry'), + 'client_action': 'erpnext.route_to_adjustment_jv', + 'args': get_journal_entry(account, stock_adjustment_account, diff) + }) + +def get_stock_accounts(company, voucher_type=None, voucher_no=None): + stock_accounts = [d.name for d in frappe.db.get_all("Account", { + "account_type": "Stock", + "company": company, + "is_group": 0 + })] + if voucher_type and voucher_no: + if voucher_type == "Journal Entry": + stock_accounts = [d.account for d in frappe.db.get_all("Journal Entry Account", { + "parent": voucher_no, + "account": ["in", stock_accounts] + }, "account")] + + else: + stock_accounts = [d.account for d in frappe.db.get_all("GL Entry", { + "voucher_type": voucher_type, + "voucher_no": voucher_no, + "account": ["in", stock_accounts] + }, "account")] + + return stock_accounts + +def get_stock_and_account_balance(account=None, posting_date=None, company=None): + if not posting_date: posting_date = nowdate() + + warehouse_account = get_warehouse_account_map(company) + + account_balance = get_balance_on(account, posting_date, in_account_currency=False, ignore_account_permission=True) + + related_warehouses = [wh for wh, wh_details in warehouse_account.items() + if wh_details.account == account and not wh_details.is_group] + + total_stock_value = 0.0 + for warehouse in related_warehouses: + value = get_stock_value_on(warehouse, posting_date) + total_stock_value += value + + precision = frappe.get_precision("Journal Entry Account", "debit_in_account_currency") + return flt(account_balance, precision), flt(total_stock_value, precision), related_warehouses + +def get_journal_entry(account, stock_adjustment_account, amount): + db_or_cr_warehouse_account =('credit_in_account_currency' if amount < 0 else 'debit_in_account_currency') + db_or_cr_stock_adjustment_account = ('debit_in_account_currency' if amount < 0 else 'credit_in_account_currency') + + return { + 'accounts':[{ + 'account': account, + db_or_cr_warehouse_account: abs(amount) + }, { + 'account': stock_adjustment_account, + db_or_cr_stock_adjustment_account : abs(amount) + }] + } diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json new file mode 100644 index 00000000000..fadb66535f5 --- /dev/null +++ b/erpnext/accounts/workspace/accounting/accounting.json @@ -0,0 +1,1119 @@ +{ + "category": "Modules", + "charts": [ + { + "chart_name": "Profit and Loss", + "label": "Profit and Loss" + } + ], + "creation": "2020-03-02 15:41:59.515192", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "accounting", + "idx": 0, + "is_standard": 1, + "label": "Accounting", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Accounting Masters", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Company", + "link_to": "Company", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Chart of Accounts", + "link_to": "Account", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Accounts Settings", + "link_to": "Accounts Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Fiscal Year", + "link_to": "Fiscal Year", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Accounting Dimension", + "link_to": "Accounting Dimension", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Finance Book", + "link_to": "Finance Book", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Accounting Period", + "link_to": "Accounting Period", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Payment Term", + "link_to": "Payment Term", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "General Ledger", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Journal Entry", + "link_to": "Journal Entry", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Journal Entry Template", + "link_to": "Journal Entry Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "General Ledger", + "link_to": "General Ledger", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Customer Ledger Summary", + "link_to": "Customer Ledger Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Supplier Ledger Summary", + "link_to": "Supplier Ledger Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Accounts Receivable", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Invoice", + "link_to": "Sales Invoice", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customer", + "link_to": "Customer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Payment Entry", + "link_to": "Payment Entry", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Payment Request", + "link_to": "Payment Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Accounts Receivable", + "link_to": "Accounts Receivable", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Accounts Receivable Summary", + "link_to": "Accounts Receivable Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Register", + "link_to": "Sales Register", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Item-wise Sales Register", + "link_to": "Item-wise Sales Register", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Order Analysis", + "link_to": "Sales Order Analysis", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Delivered Items To Be Billed", + "link_to": "Delivered Items To Be Billed", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Accounts Payable", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Purchase Invoice", + "link_to": "Purchase Invoice", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier", + "link_to": "Supplier", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Payment Entry", + "link_to": "Payment Entry", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Accounts Payable", + "link_to": "Accounts Payable", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Accounts Payable Summary", + "link_to": "Accounts Payable Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Register", + "link_to": "Purchase Register", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Item-wise Purchase Register", + "link_to": "Item-wise Purchase Register", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Order", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Order Analysis", + "link_to": "Purchase Order Analysis", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Received Items To Be Billed", + "link_to": "Received Items To Be Billed", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Trial Balance for Party", + "link_to": "Trial Balance for Party", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Journal Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Payment Period Based On Invoice Date", + "link_to": "Payment Period Based On Invoice Date", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Partners Commission", + "link_to": "Sales Partners Commission", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Customer", + "hidden": 0, + "is_query_report": 1, + "label": "Customer Credit Balance", + "link_to": "Customer Credit Balance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Payment Summary", + "link_to": "Sales Payment Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Address", + "hidden": 0, + "is_query_report": 1, + "label": "Address And Contacts", + "link_to": "Address And Contacts", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "DATEV Export", + "link_to": "DATEV", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Financial Statements", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Trial Balance", + "link_to": "Trial Balance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Profit and Loss Statement", + "link_to": "Profit and Loss Statement", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Balance Sheet", + "link_to": "Balance Sheet", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Cash Flow", + "link_to": "Cash Flow", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Consolidated Financial Statement", + "link_to": "Consolidated Financial Statement", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Multi Currency", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Currency", + "link_to": "Currency", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Currency Exchange", + "link_to": "Currency Exchange", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Exchange Rate Revaluation", + "link_to": "Exchange Rate Revaluation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Payment Gateway Account", + "link_to": "Payment Gateway Account", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Terms and Conditions Template", + "link_to": "Terms and Conditions", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Mode of Payment", + "link_to": "Mode of Payment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Bank Statement", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Bank", + "link_to": "Bank", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Bank Account", + "link_to": "Bank Account", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Bank Clearance", + "link_to": "Bank Clearance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Bank Reconciliation", + "link_to": "bank-reconciliation", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Bank Reconciliation Statement", + "link_to": "Bank Reconciliation Statement", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Bank Statement Transaction Entry", + "link_to": "Bank Statement Transaction Entry", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Bank Statement Settings", + "link_to": "Bank Statement Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Subscription Management", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Subscription Plan", + "link_to": "Subscription Plan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Subscription", + "link_to": "Subscription", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Subscription Settings", + "link_to": "Subscription Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Goods and Services Tax (GST India)", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "GST Settings", + "link_to": "GST Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "GST HSN Code", + "link_to": "GST HSN Code", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "GSTR-1", + "link_to": "GSTR-1", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "GSTR-2", + "link_to": "GSTR-2", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "GSTR 3B Report", + "link_to": "GSTR 3B Report", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "GST Sales Register", + "link_to": "GST Sales Register", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "GST Purchase Register", + "link_to": "GST Purchase Register", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "GST Itemised Sales Register", + "link_to": "GST Itemised Sales Register", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "GST Itemised Purchase Register", + "link_to": "GST Itemised Purchase Register", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "C-Form", + "link_to": "C-Form", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lower Deduction Certificate", + "link_to": "Lower Deduction Certificate", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Share Management", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shareholder", + "link_to": "Shareholder", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Share Transfer", + "link_to": "Share Transfer", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Share Transfer", + "hidden": 0, + "is_query_report": 1, + "label": "Share Ledger", + "link_to": "Share Ledger", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Share Transfer", + "hidden": 0, + "is_query_report": 1, + "label": "Share Balance", + "link_to": "Share Balance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Cost Center and Budgeting", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Chart of Cost Centers", + "link_to": "Cost Center", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Budget", + "link_to": "Budget", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Accounting Dimension", + "link_to": "Accounting Dimension", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Cost Center", + "hidden": 0, + "is_query_report": 1, + "label": "Budget Variance Report", + "link_to": "Budget Variance Report", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Monthly Distribution", + "link_to": "Monthly Distribution", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Opening and Closing", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Opening Invoice Creation Tool", + "link_to": "Opening Invoice Creation Tool", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Chart of Accounts Importer", + "link_to": "Chart of Accounts Importer", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Period Closing Voucher", + "link_to": "Period Closing Voucher", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Taxes", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Taxes and Charges Template", + "link_to": "Sales Taxes and Charges Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Purchase Taxes and Charges Template", + "link_to": "Purchase Taxes and Charges Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Tax Template", + "link_to": "Item Tax Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Tax Category", + "link_to": "Tax Category", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Tax Rule", + "link_to": "Tax Rule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Tax Withholding Category", + "link_to": "Tax Withholding Category", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Profitability", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Gross Profit", + "link_to": "Gross Profit", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Profitability Analysis", + "link_to": "Profitability Analysis", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Invoice Trends", + "link_to": "Sales Invoice Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Invoice Trends", + "link_to": "Purchase Invoice Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2021-03-04 00:38:35.349024", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Accounting", + "onboarding": "Accounts", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "label": "Chart of Accounts", + "link_to": "Account", + "type": "DocType" + }, + { + "label": "Sales Invoice", + "link_to": "Sales Invoice", + "type": "DocType" + }, + { + "label": "Purchase Invoice", + "link_to": "Purchase Invoice", + "type": "DocType" + }, + { + "label": "Journal Entry", + "link_to": "Journal Entry", + "type": "DocType" + }, + { + "label": "Payment Entry", + "link_to": "Payment Entry", + "type": "DocType" + }, + { + "label": "Accounts Receivable", + "link_to": "Accounts Receivable", + "type": "Report" + }, + { + "label": "General Ledger", + "link_to": "General Ledger", + "type": "Report" + }, + { + "label": "Trial Balance", + "link_to": "Trial Balance", + "type": "Report" + }, + { + "label": "Dashboard", + "link_to": "Accounts", + "type": "Dashboard" + } + ] +} diff --git a/erpnext/agriculture/desk_page/agriculture/agriculture.json b/erpnext/agriculture/desk_page/agriculture/agriculture.json deleted file mode 100644 index e0d2c9ca25a..00000000000 --- a/erpnext/agriculture/desk_page/agriculture/agriculture.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Crops & Lands", - "links": "[\n {\n \"label\": \"Crop\",\n \"name\": \"Crop\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Crop Cycle\",\n \"name\": \"Crop Cycle\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Location\",\n \"name\": \"Location\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Analytics", - "links": "[\n {\n \"label\": \"Plant Analysis\",\n \"name\": \"Plant Analysis\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Soil Analysis\",\n \"name\": \"Soil Analysis\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Water Analysis\",\n \"name\": \"Water Analysis\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Soil Texture\",\n \"name\": \"Soil Texture\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Weather\",\n \"name\": \"Weather\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Agriculture Analysis Criteria\",\n \"name\": \"Agriculture Analysis Criteria\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Diseases & Fertilizers", - "links": "[\n {\n \"label\": \"Disease\",\n \"name\": \"Disease\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Fertilizer\",\n \"name\": \"Fertilizer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Domains", - "charts": [], - "creation": "2020-03-02 17:23:34.339274", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "idx": 0, - "is_standard": 1, - "label": "Agriculture", - "modified": "2020-04-01 11:28:51.032822", - "modified_by": "Administrator", - "module": "Agriculture", - "name": "Agriculture", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "restrict_to_domain": "Agriculture", - "shortcuts": [] -} \ No newline at end of file diff --git a/erpnext/agriculture/doctype/crop_cycle/crop_cycle.py b/erpnext/agriculture/doctype/crop_cycle/crop_cycle.py index cae150c428a..afbd9b4e6e0 100644 --- a/erpnext/agriculture/doctype/crop_cycle/crop_cycle.py +++ b/erpnext/agriculture/doctype/crop_cycle/crop_cycle.py @@ -48,7 +48,7 @@ class CropCycle(Document): def import_disease_tasks(self, disease, start_date): disease_doc = frappe.get_doc('Disease', disease) - self.create_task(disease_doc.treatment_task, self.name, start_date) + self.create_task(disease_doc.treatment_task, self.project, start_date) def create_project(self, period, crop_tasks): project = frappe.get_doc({ diff --git a/erpnext/agriculture/doctype/crop_cycle/test_crop_cycle.py b/erpnext/agriculture/doctype/crop_cycle/test_crop_cycle.py index 5510d5ac020..763b4036c3a 100644 --- a/erpnext/agriculture/doctype/crop_cycle/test_crop_cycle.py +++ b/erpnext/agriculture/doctype/crop_cycle/test_crop_cycle.py @@ -71,4 +71,4 @@ def check_task_creation(): def check_project_creation(): - return True if frappe.db.exists('Project', 'Basil from seed 2017') else False + return True if frappe.db.exists('Project', {'project_name': 'Basil from seed 2017'}) else False diff --git a/erpnext/agriculture/workspace/agriculture/agriculture.json b/erpnext/agriculture/workspace/agriculture/agriculture.json new file mode 100644 index 00000000000..2cc252491d3 --- /dev/null +++ b/erpnext/agriculture/workspace/agriculture/agriculture.json @@ -0,0 +1,157 @@ +{ + "category": "Domains", + "charts": [], + "creation": "2020-03-02 17:23:34.339274", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "agriculture", + "idx": 0, + "is_standard": 1, + "label": "Agriculture", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Crops & Lands", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Crop", + "link_to": "Crop", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Crop Cycle", + "link_to": "Crop Cycle", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Location", + "link_to": "Location", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Analytics", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Plant Analysis", + "link_to": "Plant Analysis", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Soil Analysis", + "link_to": "Soil Analysis", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Water Analysis", + "link_to": "Water Analysis", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Soil Texture", + "link_to": "Soil Texture", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Weather", + "link_to": "Weather", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Agriculture Analysis Criteria", + "link_to": "Agriculture Analysis Criteria", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Diseases & Fertilizers", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Disease", + "link_to": "Disease", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Fertilizer", + "link_to": "Fertilizer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:38.477493", + "modified_by": "Administrator", + "module": "Agriculture", + "name": "Agriculture", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "restrict_to_domain": "Agriculture", + "shortcuts": [] +} \ No newline at end of file diff --git a/erpnext/assets/dashboard_chart/asset_value_analytics/asset_value_analytics.json b/erpnext/assets/dashboard_chart/asset_value_analytics/asset_value_analytics.json index bc2edc9d7d6..94debf12431 100644 --- a/erpnext/assets/dashboard_chart/asset_value_analytics/asset_value_analytics.json +++ b/erpnext/assets/dashboard_chart/asset_value_analytics/asset_value_analytics.json @@ -9,9 +9,9 @@ "filters_json": "{\"status\":\"In Location\",\"filter_based_on\":\"Fiscal Year\",\"period_start_date\":\"2020-04-01\",\"period_end_date\":\"2021-03-31\",\"date_based_on\":\"Purchase Date\",\"group_by\":\"--Select a group--\"}", "group_by_type": "Count", "idx": 0, - "is_public": 0, + "is_public": 1, "is_standard": 1, - "modified": "2020-07-23 13:53:33.211371", + "modified": "2020-10-28 23:15:58.432189", "modified_by": "Administrator", "module": "Assets", "name": "Asset Value Analytics", diff --git a/erpnext/assets/dashboard_chart/category_wise_asset_value/category_wise_asset_value.json b/erpnext/assets/dashboard_chart/category_wise_asset_value/category_wise_asset_value.json index e79d2d73722..78611da0036 100644 --- a/erpnext/assets/dashboard_chart/category_wise_asset_value/category_wise_asset_value.json +++ b/erpnext/assets/dashboard_chart/category_wise_asset_value/category_wise_asset_value.json @@ -8,9 +8,9 @@ "dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"from_date\":\"frappe.datetime.add_months(frappe.datetime.nowdate(), -12)\",\"to_date\":\"frappe.datetime.nowdate()\"}", "filters_json": "{\"status\":\"In Location\",\"group_by\":\"Asset Category\",\"is_existing_asset\":0}", "idx": 0, - "is_public": 0, + "is_public": 1, "is_standard": 1, - "modified": "2020-07-23 13:39:32.429240", + "modified": "2020-10-28 23:16:16.939070", "modified_by": "Administrator", "module": "Assets", "name": "Category-wise Asset Value", diff --git a/erpnext/assets/dashboard_chart/location_wise_asset_value/location_wise_asset_value.json b/erpnext/assets/dashboard_chart/location_wise_asset_value/location_wise_asset_value.json index 481586e7ca9..848184cc148 100644 --- a/erpnext/assets/dashboard_chart/location_wise_asset_value/location_wise_asset_value.json +++ b/erpnext/assets/dashboard_chart/location_wise_asset_value/location_wise_asset_value.json @@ -8,9 +8,9 @@ "dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"from_date\":\"frappe.datetime.add_months(frappe.datetime.nowdate(), -12)\",\"to_date\":\"frappe.datetime.nowdate()\"}", "filters_json": "{\"status\":\"In Location\",\"group_by\":\"Location\",\"is_existing_asset\":0}", "idx": 0, - "is_public": 0, + "is_public": 1, "is_standard": 1, - "modified": "2020-07-23 13:42:44.912551", + "modified": "2020-10-28 23:16:07.883312", "modified_by": "Administrator", "module": "Assets", "name": "Location-wise Asset Value", diff --git a/erpnext/assets/desk_page/assets/assets.json b/erpnext/assets/desk_page/assets/assets.json deleted file mode 100644 index 449a5facb08..00000000000 --- a/erpnext/assets/desk_page/assets/assets.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Assets", - "links": "[\n {\n \"label\": \"Asset\",\n \"name\": \"Asset\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Location\",\n \"name\": \"Location\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Asset Category\",\n \"name\": \"Asset Category\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Transfer an asset from one warehouse to another\",\n \"label\": \"Asset Movement\",\n \"name\": \"Asset Movement\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Maintenance", - "links": "[\n {\n \"label\": \"Asset Maintenance Team\",\n \"name\": \"Asset Maintenance Team\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Asset Maintenance Team\"\n ],\n \"label\": \"Asset Maintenance\",\n \"name\": \"Asset Maintenance\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Asset Maintenance\"\n ],\n \"label\": \"Asset Maintenance Log\",\n \"name\": \"Asset Maintenance Log\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Asset\"\n ],\n \"label\": \"Asset Value Adjustment\",\n \"name\": \"Asset Value Adjustment\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Asset\"\n ],\n \"label\": \"Asset Repair\",\n \"name\": \"Asset Repair\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Reports", - "links": "[\n {\n \"dependencies\": [\n \"Asset\"\n ],\n \"doctype\": \"Asset\",\n \"is_query_report\": true,\n \"label\": \"Asset Depreciation Ledger\",\n \"name\": \"Asset Depreciation Ledger\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Asset\"\n ],\n \"doctype\": \"Asset\",\n \"is_query_report\": true,\n \"label\": \"Asset Depreciations and Balances\",\n \"name\": \"Asset Depreciations and Balances\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Asset Maintenance\"\n ],\n \"doctype\": \"Asset Maintenance\",\n \"label\": \"Asset Maintenance\",\n \"name\": \"Asset Maintenance\",\n \"type\": \"report\"\n }\n]" - } - ], - "category": "Modules", - "charts": [ - { - "chart_name": "Asset Value Analytics", - "label": "Asset Value Analytics" - } - ], - "creation": "2020-03-02 15:43:27.634865", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Assets", - "modified": "2020-05-20 18:05:23.994795", - "modified_by": "Administrator", - "module": "Assets", - "name": "Assets", - "onboarding": "Assets", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "label": "Asset", - "link_to": "Asset", - "type": "DocType" - }, - { - "label": "Asset Category", - "link_to": "Asset Category", - "type": "DocType" - }, - { - "label": "Fixed Asset Register", - "link_to": "Fixed Asset Register", - "type": "Report" - }, - { - "label": "Dashboard", - "link_to": "Asset", - "type": "Dashboard" - } - ] -} \ No newline at end of file diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 7ad164a8b9b..6f1bb28f370 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -2,6 +2,7 @@ // For license information, please see license.txt frappe.provide("erpnext.asset"); +frappe.provide("erpnext.accounts.dimensions"); frappe.ui.form.on('Asset', { onload: function(frm) { @@ -32,13 +33,11 @@ frappe.ui.form.on('Asset', { }; }); - frm.set_query("cost_center", function() { - return { - "filters": { - "company": frm.doc.company, - } - }; - }); + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); + }, + + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); }, setup: function(frm) { @@ -373,8 +372,8 @@ frappe.ui.form.on('Asset', { doctype_field = frappe.scrub(doctype) frm.set_value(doctype_field, ''); frappe.msgprint({ - title: __(`Invalid ${doctype}`), - message: __(`The selected ${doctype} doesn't contains selected Asset Item.`), + title: __('Invalid {0}', [__(doctype)]), + message: __('The selected {0} does not contain the selected Asset Item.', [__(doctype)]), indicator: 'red' }); } @@ -436,7 +435,7 @@ frappe.ui.form.on('Asset Finance Book', { depreciation_start_date: function(frm, cdt, cdn) { const book = locals[cdt][cdn]; if (frm.doc.available_for_use_date && book.depreciation_start_date == frm.doc.available_for_use_date) { - frappe.msgprint(__(`Depreciation Posting Date should not be equal to Available for Use Date.`)); + frappe.msgprint(__("Depreciation Posting Date should not be equal to Available for Use Date.")); book.depreciation_start_date = ""; frm.refresh_field("finance_books"); } diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index a3152abf205..421b9a6c378 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -8,21 +8,20 @@ "document_type": "Document", "engine": "InnoDB", "field_order": [ - "is_existing_asset", - "section_break_2", - "naming_series", + "company", "item_code", "item_name", - "asset_category", "asset_owner", "asset_owner_company", + "is_existing_asset", "supplier", "customer", "image", "journal_entry_for_scrap", "column_break_3", - "company", + "naming_series", "asset_name", + "asset_category", "location", "custodian", "department", @@ -95,12 +94,14 @@ "reqd": 1 }, { + "depends_on": "item_code", "fetch_from": "item_code.item_name", "fieldname": "item_name", "fieldtype": "Read Only", "label": "Item Name" }, { + "depends_on": "item_code", "fetch_from": "item_code.asset_category", "fieldname": "asset_category", "fieldtype": "Link", @@ -307,12 +308,13 @@ { "depends_on": "calculate_depreciation", "fieldname": "section_break_14", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Depreciation Schedule" }, { "fieldname": "schedules", "fieldtype": "Table", - "label": "Depreciation Schedules", + "label": "Depreciation Schedule", "no_copy": 1, "options": "Depreciation Schedule" }, @@ -458,10 +460,6 @@ "fieldtype": "Check", "label": "Allow Monthly Depreciation" }, - { - "fieldname": "section_break_2", - "fieldtype": "Section Break" - }, { "collapsible": 1, "collapsible_depends_on": "is_existing_asset", @@ -480,14 +478,31 @@ { "depends_on": "calculate_depreciation", "fieldname": "section_break_36", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Finance Books" } ], "idx": 72, "image_field": "image", "is_submittable": 1, - "links": [], - "modified": "2020-07-28 15:04:44.452224", + "links": [ + { + "group": "Maintenance", + "link_doctype": "Asset Maintenance", + "link_fieldname": "asset_name" + }, + { + "group": "Repair", + "link_doctype": "Asset Repair", + "link_fieldname": "asset_name" + }, + { + "group": "Value", + "link_doctype": "Asset Value Adjustment", + "link_fieldname": "asset" + } + ], + "modified": "2021-01-22 12:38:59.091510", "modified_by": "Administrator", "module": "Assets", "name": "Asset", @@ -527,5 +542,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", - "title_field": "asset_name" + "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 efdbdb1fbfc..e8e8ec6cc0b 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -131,11 +131,12 @@ class Asset(AccountsController): def validate_gross_and_purchase_amount(self): if self.is_existing_asset: return - + if self.gross_purchase_amount and self.gross_purchase_amount != self.purchase_receipt_amount: - frappe.throw(_("Gross Purchase Amount should be {} to purchase amount of one single Asset. {}\ - Please do not book expense of multiple assets against one single Asset.") - .format(frappe.bold("equal"), "
    "), title=_("Invalid Gross Purchase Amount")) + error_message = _("Gross Purchase Amount should be equal to purchase amount of one single Asset.") + error_message += "
    " + error_message += _("Please do not book expense of multiple assets against one single Asset.") + frappe.throw(error_message, title=_("Invalid Gross Purchase Amount")) def make_asset_movement(self): reference_doctype = 'Purchase Receipt' if self.purchase_receipt else 'Purchase Invoice' @@ -466,29 +467,37 @@ class Asset(AccountsController): def validate_make_gl_entry(self): purchase_document = self.get_purchase_document() - asset_bought_with_invoice = purchase_document == self.purchase_invoice - fixed_asset_account, cwip_account = self.get_asset_accounts() - cwip_enabled = is_cwip_accounting_enabled(self.asset_category) - # check if expense already has been booked in case of cwip was enabled after purchasing asset - expense_booked = False - cwip_booked = False - - if asset_bought_with_invoice: - expense_booked = frappe.db.sql("""SELECT name FROM `tabGL Entry` WHERE voucher_no = %s and account = %s""", - (purchase_document, fixed_asset_account), as_dict=1) - else: - cwip_booked = frappe.db.sql("""SELECT name FROM `tabGL Entry` WHERE voucher_no = %s and account = %s""", - (purchase_document, cwip_account), as_dict=1) - - if cwip_enabled and (expense_booked or not cwip_booked): - # if expense has already booked from invoice or cwip is booked from receipt + if not purchase_document: return False - elif not cwip_enabled and (not expense_booked or cwip_booked): - # if cwip is disabled but expense hasn't been booked yet - return True - elif cwip_enabled: - # default condition - return True + + asset_bought_with_invoice = (purchase_document == self.purchase_invoice) + fixed_asset_account = self.get_fixed_asset_account() + + cwip_enabled = is_cwip_accounting_enabled(self.asset_category) + cwip_account = self.get_cwip_account(cwip_enabled=cwip_enabled) + + query = """SELECT name FROM `tabGL Entry` WHERE voucher_no = %s and account = %s""" + if asset_bought_with_invoice: + # with invoice purchase either expense or cwip has been booked + expense_booked = frappe.db.sql(query, (purchase_document, fixed_asset_account), as_dict=1) + if expense_booked: + # if expense is already booked from invoice then do not make gl entries regardless of cwip enabled/disabled + return False + + cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1) + if cwip_booked: + # if cwip is booked from invoice then make gl entries regardless of cwip enabled/disabled + return True + else: + # with receipt purchase either cwip has been booked or no entries have been made + if not cwip_account: + # if cwip account isn't available do not make gl entries + return False + + cwip_booked = frappe.db.sql(query, (purchase_document, cwip_account), as_dict=1) + # if cwip is not booked from receipt then do not make gl entries + # if cwip is booked from receipt then make gl entries + return cwip_booked def get_purchase_document(self): asset_bought_with_invoice = self.purchase_invoice and frappe.db.get_value('Purchase Invoice', self.purchase_invoice, 'update_stock') @@ -496,20 +505,25 @@ class Asset(AccountsController): return purchase_document - def get_asset_accounts(self): - fixed_asset_account = get_asset_category_account('fixed_asset_account', asset=self.name, - asset_category = self.asset_category, company = self.company) + def get_fixed_asset_account(self): + return get_asset_category_account('fixed_asset_account', None, self.name, None, self.asset_category, self.company) - cwip_account = get_asset_account("capital_work_in_progress_account", - self.name, self.asset_category, self.company) + def get_cwip_account(self, cwip_enabled=False): + cwip_account = None + try: + cwip_account = get_asset_account("capital_work_in_progress_account", self.name, self.asset_category, self.company) + except: + # if no cwip account found in category or company and "cwip is enabled" then raise else silently pass + if cwip_enabled: + raise - return fixed_asset_account, cwip_account + return cwip_account def make_gl_entries(self): gl_entries = [] purchase_document = self.get_purchase_document() - fixed_asset_account, cwip_account = self.get_asset_accounts() + fixed_asset_account, cwip_account = self.get_fixed_asset_account(), self.get_cwip_account() if (purchase_document and self.purchase_receipt_amount and self.available_for_use_date <= nowdate()): @@ -561,14 +575,18 @@ class Asset(AccountsController): return 100 * (1 - flt(depreciation_rate, float_precision)) def update_maintenance_status(): - assets = frappe.get_all('Asset', filters = {'docstatus': 1, 'maintenance_required': 1}) + assets = frappe.get_all( + "Asset", filters={"docstatus": 1, "maintenance_required": 1} + ) for asset in assets: asset = frappe.get_doc("Asset", asset.name) - if frappe.db.exists('Asset Maintenance Task', {'parent': asset.name, 'next_due_date': today()}): - asset.set_status('In Maintenance') - if frappe.db.exists('Asset Repair', {'asset_name': asset.name, 'repair_status': 'Pending'}): - asset.set_status('Out of Order') + if frappe.db.exists("Asset Repair", {"asset_name": asset.name, "repair_status": "Pending"}): + asset.set_status("Out of Order") + elif frappe.db.exists("Asset Maintenance Task", {"parent": asset.name, "next_due_date": today()}): + asset.set_status("In Maintenance") + else: + asset.set_status() def make_post_gl_entry(): @@ -642,7 +660,7 @@ def transfer_asset(args): frappe.db.commit() - frappe.msgprint(_("Asset Movement record {0} created").format("{0}").format(movement_entry.name)) + frappe.msgprint(_("Asset Movement record {0} created").format("{0}").format(movement_entry.name)) @frappe.whitelist() def get_item_details(item_code, asset_category): diff --git a/erpnext/assets/doctype/asset/asset_dashboard.py b/erpnext/assets/doctype/asset/asset_dashboard.py index b48989923e2..a5cf23803d2 100644 --- a/erpnext/assets/doctype/asset/asset_dashboard.py +++ b/erpnext/assets/doctype/asset/asset_dashboard.py @@ -2,19 +2,10 @@ from __future__ import unicode_literals def get_data(): return { - 'fieldname': 'asset_name', 'non_standard_fieldnames': { 'Asset Movement': 'asset' }, 'transactions': [ - { - 'label': ['Maintenance'], - 'items': ['Asset Maintenance', 'Asset Maintenance Log'] - }, - { - 'label': ['Repair'], - 'items': ['Asset Repair'] - }, { 'label': ['Movement'], 'items': ['Asset Movement'] diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 52039c183ba..a0d76031fc4 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -9,6 +9,7 @@ from frappe.utils import cstr, nowdate, getdate, flt, get_last_day, add_days, ad from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries, scrap_asset, restore_asset from erpnext.assets.doctype.asset.asset import make_sales_invoice from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice as make_invoice class TestAsset(unittest.TestCase): @@ -558,81 +559,6 @@ class TestAsset(unittest.TestCase): self.assertEqual(gle, expected_gle) - def test_gle_with_cwip_toggling(self): - # TEST: purchase an asset with cwip enabled and then disable cwip and try submitting the asset - frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", 1) - - pr = make_purchase_receipt(item_code="Macbook Pro", - qty=1, rate=5000, do_not_submit=True, location="Test Location") - pr.set('taxes', [{ - 'category': 'Total', - 'add_deduct_tax': 'Add', - 'charge_type': 'On Net Total', - 'account_head': '_Test Account Service Tax - _TC', - 'description': '_Test Account Service Tax', - 'cost_center': 'Main - _TC', - 'rate': 5.0 - }, { - 'category': 'Valuation and Total', - 'add_deduct_tax': 'Add', - 'charge_type': 'On Net Total', - 'account_head': '_Test Account Shipping Charges - _TC', - 'description': '_Test Account Shipping Charges', - 'cost_center': 'Main - _TC', - 'rate': 5.0 - }]) - pr.submit() - expected_gle = ( - ("Asset Received But Not Billed - _TC", 0.0, 5250.0), - ("CWIP Account - _TC", 5250.0, 0.0) - ) - pr_gle = frappe.db.sql("""select account, debit, credit from `tabGL Entry` - where voucher_type='Purchase Receipt' and voucher_no = %s - order by account""", pr.name) - self.assertEqual(pr_gle, expected_gle) - - pi = make_invoice(pr.name) - pi.submit() - expected_gle = ( - ("_Test Account Service Tax - _TC", 250.0, 0.0), - ("_Test Account Shipping Charges - _TC", 250.0, 0.0), - ("Asset Received But Not Billed - _TC", 5250.0, 0.0), - ("Creditors - _TC", 0.0, 5500.0), - ("Expenses Included In Asset Valuation - _TC", 0.0, 250.0), - ) - pi_gle = frappe.db.sql("""select account, debit, credit from `tabGL Entry` - where voucher_type='Purchase Invoice' and voucher_no = %s - order by account""", pi.name) - self.assertEqual(pi_gle, expected_gle) - - asset = frappe.db.get_value('Asset', {'purchase_receipt': pr.name, 'docstatus': 0}, 'name') - asset_doc = frappe.get_doc('Asset', asset) - month_end_date = get_last_day(nowdate()) - asset_doc.available_for_use_date = nowdate() if nowdate() != month_end_date else add_days(nowdate(), -15) - self.assertEqual(asset_doc.gross_purchase_amount, 5250.0) - asset_doc.append("finance_books", { - "expected_value_after_useful_life": 200, - "depreciation_method": "Straight Line", - "total_number_of_depreciations": 3, - "frequency_of_depreciation": 10, - "depreciation_start_date": month_end_date - }) - - # disable cwip and try submitting - frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", 0) - asset_doc.submit() - # asset should have gl entries even if cwip is disabled - expected_gle = ( - ("_Test Fixed Asset - _TC", 5250.0, 0.0), - ("CWIP Account - _TC", 0.0, 5250.0) - ) - gle = frappe.db.sql("""select account, debit, credit from `tabGL Entry` - where voucher_type='Asset' and voucher_no = %s - order by account""", asset_doc.name) - self.assertEqual(gle, expected_gle) - - frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", 1) - def test_expense_head(self): pr = make_purchase_receipt(item_code="Macbook Pro", qty=2, rate=200000.0, location="Test Location") @@ -640,6 +566,74 @@ class TestAsset(unittest.TestCase): doc = make_invoice(pr.name) self.assertEquals('Asset Received But Not Billed - _TC', doc.items[0].expense_account) + + def test_asset_cwip_toggling_cases(self): + cwip = frappe.db.get_value("Asset Category", "Computers", "enable_cwip_accounting") + name = frappe.db.get_value("Asset Category Account", filters={"parent": "Computers"}, fieldname=["name"]) + cwip_acc = "CWIP Account - _TC" + + frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", 0) + frappe.db.set_value("Asset Category Account", name, "capital_work_in_progress_account", "") + frappe.db.get_value("Company", "_Test Company", "capital_work_in_progress_account", "") + + # case 0 -- PI with cwip disable, Asset with cwip disabled, No cwip account set + pi = make_purchase_invoice(item_code="Macbook Pro", qty=1, rate=200000.0, location="Test Location", update_stock=1) + asset = frappe.db.get_value('Asset', {'purchase_invoice': pi.name, 'docstatus': 0}, 'name') + asset_doc = frappe.get_doc('Asset', asset) + asset_doc.available_for_use_date = nowdate() + asset_doc.calculate_depreciation = 0 + asset_doc.submit() + gle = frappe.db.sql("""select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", asset_doc.name) + self.assertFalse(gle) + + # case 1 -- PR with cwip disabled, Asset with cwip enabled + pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=200000.0, location="Test Location") + frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", 1) + frappe.db.set_value("Asset Category Account", name, "capital_work_in_progress_account", cwip_acc) + asset = frappe.db.get_value('Asset', {'purchase_receipt': pr.name, 'docstatus': 0}, 'name') + asset_doc = frappe.get_doc('Asset', asset) + asset_doc.available_for_use_date = nowdate() + asset_doc.calculate_depreciation = 0 + asset_doc.submit() + gle = frappe.db.sql("""select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", asset_doc.name) + self.assertFalse(gle) + + # case 2 -- PR with cwip enabled, Asset with cwip disabled + pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=200000.0, location="Test Location") + frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", 0) + asset = frappe.db.get_value('Asset', {'purchase_receipt': pr.name, 'docstatus': 0}, 'name') + asset_doc = frappe.get_doc('Asset', asset) + asset_doc.available_for_use_date = nowdate() + asset_doc.calculate_depreciation = 0 + asset_doc.submit() + gle = frappe.db.sql("""select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", asset_doc.name) + self.assertTrue(gle) + + # case 3 -- PI with cwip disabled, Asset with cwip enabled + pi = make_purchase_invoice(item_code="Macbook Pro", qty=1, rate=200000.0, location="Test Location", update_stock=1) + frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", 1) + asset = frappe.db.get_value('Asset', {'purchase_invoice': pi.name, 'docstatus': 0}, 'name') + asset_doc = frappe.get_doc('Asset', asset) + asset_doc.available_for_use_date = nowdate() + asset_doc.calculate_depreciation = 0 + asset_doc.submit() + gle = frappe.db.sql("""select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", asset_doc.name) + self.assertFalse(gle) + + # case 4 -- PI with cwip enabled, Asset with cwip disabled + pi = make_purchase_invoice(item_code="Macbook Pro", qty=1, rate=200000.0, location="Test Location", update_stock=1) + frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", 0) + asset = frappe.db.get_value('Asset', {'purchase_invoice': pi.name, 'docstatus': 0}, 'name') + asset_doc = frappe.get_doc('Asset', asset) + asset_doc.available_for_use_date = nowdate() + asset_doc.calculate_depreciation = 0 + asset_doc.submit() + gle = frappe.db.sql("""select name from `tabGL Entry` where voucher_type='Asset' and voucher_no = %s""", asset_doc.name) + self.assertTrue(gle) + + frappe.db.set_value("Asset Category", "Computers", "enable_cwip_accounting", cwip) + frappe.db.set_value("Asset Category Account", name, "capital_work_in_progress_account", cwip_acc) + frappe.db.get_value("Company", "_Test Company", "capital_work_in_progress_account", cwip_acc) def create_asset_data(): if not frappe.db.exists("Asset Category", "Computers"): diff --git a/erpnext/assets/doctype/asset_category/asset_category.json b/erpnext/assets/doctype/asset_category/asset_category.json index 7483b41d4de..a25f5469039 100644 --- a/erpnext/assets/doctype/asset_category/asset_category.json +++ b/erpnext/assets/doctype/asset_category/asset_category.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "field:asset_category_name", @@ -64,7 +65,8 @@ "label": "Enable Capital Work in Progress Accounting" } ], - "modified": "2019-10-11 12:19:59.759136", + "links": [], + "modified": "2021-02-24 15:05:38.621803", "modified_by": "Administrator", "module": "Assets", "name": "Asset Category", @@ -111,5 +113,6 @@ ], "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_category/asset_category.py b/erpnext/assets/doctype/asset_category/asset_category.py index 9a33fc14ac0..46620d56e98 100644 --- a/erpnext/assets/doctype/asset_category/asset_category.py +++ b/erpnext/assets/doctype/asset_category/asset_category.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import cint +from frappe.utils import cint, get_link_to_form from frappe.model.document import Document class AssetCategory(Document): @@ -13,6 +13,7 @@ class AssetCategory(Document): self.validate_finance_books() self.validate_account_types() self.validate_account_currency() + self.valide_cwip_account() def validate_finance_books(self): for d in self.finance_books: @@ -58,6 +59,21 @@ class AssetCategory(Document): frappe.throw(_("Row #{}: {} of {} should be {}. Please modify the account or select a different account.") .format(d.idx, frappe.unscrub(key_to_match), frappe.bold(selected_account), frappe.bold(expected_key_type)), title=_("Invalid Account")) + + def valide_cwip_account(self): + if self.enable_cwip_accounting: + missing_cwip_accounts_for_company = [] + for d in self.accounts: + if (not d.capital_work_in_progress_account and + not frappe.db.get_value("Company", d.company_name, "capital_work_in_progress_account")): + missing_cwip_accounts_for_company.append(get_link_to_form("Company", d.company_name)) + + if missing_cwip_accounts_for_company: + msg = _("""To enable Capital Work in Progress Accounting, """) + msg += _("""you must select Capital Work in Progress Account in accounts table""") + msg += "

    " + msg += _("You can also set default CWIP account in Company {}").format(", ".join(missing_cwip_accounts_for_company)) + frappe.throw(msg, title=_("Missing Account")) @frappe.whitelist() diff --git a/erpnext/assets/doctype/asset_category/test_asset_category.py b/erpnext/assets/doctype/asset_category/test_asset_category.py index b32f9b50202..39b79d6c507 100644 --- a/erpnext/assets/doctype/asset_category/test_asset_category.py +++ b/erpnext/assets/doctype/asset_category/test_asset_category.py @@ -26,4 +26,22 @@ class TestAssetCategory(unittest.TestCase): asset_category.insert() except frappe.DuplicateEntryError: pass - \ No newline at end of file + + def test_cwip_accounting(self): + company_cwip_acc = frappe.db.get_value("Company", "_Test Company", "capital_work_in_progress_account") + frappe.db.set_value("Company", "_Test Company", "capital_work_in_progress_account", "") + + asset_category = frappe.new_doc("Asset Category") + asset_category.asset_category_name = "Computers" + asset_category.enable_cwip_accounting = 1 + + asset_category.total_number_of_depreciations = 3 + asset_category.frequency_of_depreciation = 3 + asset_category.append("accounts", { + "company_name": "_Test Company", + "fixed_asset_account": "_Test Fixed Asset - _TC", + "accumulated_depreciation_account": "_Test Accumulated Depreciations - _TC", + "depreciation_expense_account": "_Test Depreciations - _TC" + }) + + self.assertRaises(frappe.ValidationError, asset_category.insert) \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json index 79fcb957d4d..d9b7b695f7f 100644 --- a/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json +++ b/erpnext/assets/doctype/asset_finance_book/asset_finance_book.json @@ -50,12 +50,11 @@ "reqd": 1 }, { - "depends_on": "eval:parent.doctype == 'Asset'", "fieldname": "depreciation_start_date", "fieldtype": "Date", "in_list_view": 1, "label": "Depreciation Posting Date", - "reqd": 1 + "mandatory_depends_on": "eval:parent.doctype == 'Asset'" }, { "default": "0", @@ -86,7 +85,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-09-16 12:11:30.631788", + "modified": "2020-11-05 16:30:09.213479", "modified_by": "Administrator", "module": "Assets", "name": "Asset Finance Book", diff --git a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.js b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.js index 001fc26ffe7..70b8654509f 100644 --- a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.js +++ b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.js @@ -40,14 +40,13 @@ frappe.ui.form.on('Asset Maintenance', { if(!r.message) { return; } - var section = frm.dashboard.add_section(`
    - ${ __("Maintenance Log") }
    `); + const section = frm.dashboard.add_section('', __("Maintenance Log")); var rows = $('
    ').appendTo(section); // show (r.message || []).forEach(function(d) { $(`
    - ${d.maintenance_status} ${d.count} diff --git a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py index 60c528bcc4b..a506deec93e 100644 --- a/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py +++ b/erpnext/assets/doctype/asset_maintenance/asset_maintenance.py @@ -108,7 +108,7 @@ def update_maintenance_log(asset_maintenance, item_code, item_name, task): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_team_members(doctype, txt, searchfield, start, page_len, filters): - return frappe.db.get_values('Maintenance Team Member', { 'parent': filters.get("maintenance_team") }) + return frappe.db.get_values('Maintenance Team Member', { 'parent': filters.get("maintenance_team") }, "team_member") @frappe.whitelist() def get_maintenance_log(asset_name): diff --git a/erpnext/assets/doctype/asset_maintenance_log/asset_maintenance_log.json b/erpnext/assets/doctype/asset_maintenance_log/asset_maintenance_log.json index 7395bec1e62..7d33176e2f3 100644 --- a/erpnext/assets/doctype/asset_maintenance_log/asset_maintenance_log.json +++ b/erpnext/assets/doctype/asset_maintenance_log/asset_maintenance_log.json @@ -18,15 +18,13 @@ "task_name", "maintenance_type", "periodicity", - "assign_to_name", - "column_break_6", - "due_date", - "completion_date", - "maintenance_status", - "section_break_12", "has_certificate", "certificate_attachement", - "section_break_6", + "column_break_6", + "maintenance_status", + "assign_to_name", + "due_date", + "completion_date", "description", "column_break_9", "actions_performed", @@ -70,7 +68,8 @@ }, { "fieldname": "section_break_5", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Maintenance Details" }, { "fieldname": "task", @@ -123,10 +122,6 @@ "options": "Planned\nCompleted\nCancelled\nOverdue", "reqd": 1 }, - { - "fieldname": "section_break_12", - "fieldtype": "Section Break" - }, { "default": "0", "fetch_from": "task.certificate_required", @@ -140,10 +135,6 @@ "fieldtype": "Attach", "label": "Certificate" }, - { - "fieldname": "section_break_6", - "fieldtype": "Column Break" - }, { "fetch_from": "task.description", "fieldname": "description", @@ -179,9 +170,10 @@ "read_only": 1 } ], + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-05-28 20:51:48.238397", + "modified": "2021-01-22 12:33:45.888124", "modified_by": "Administrator", "module": "Assets", "name": "Asset Maintenance Log", diff --git a/erpnext/assets/doctype/asset_maintenance_team/asset_maintenance_team.json b/erpnext/assets/doctype/asset_maintenance_team/asset_maintenance_team.json index e2aa548e269..ffa04e58f02 100644 --- a/erpnext/assets/doctype/asset_maintenance_team/asset_maintenance_team.json +++ b/erpnext/assets/doctype/asset_maintenance_team/asset_maintenance_team.json @@ -1,282 +1,87 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:maintenance_team_name", - "beta": 0, - "creation": "2017-10-20 11:43:47.712616", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "field:maintenance_team_name", + "creation": "2017-10-20 11:43:47.712616", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "maintenance_team_name", + "maintenance_manager", + "maintenance_manager_name", + "column_break_2", + "company", + "section_break_2", + "maintenance_team_members" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "maintenance_team_name", - "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": "Maintenance Team Name", - "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": "maintenance_team_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Maintenance Team Name", + "reqd": 1, + "unique": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "maintenance_manager", - "fieldtype": "Link", - "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 Manager", - "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 - }, + "fieldname": "maintenance_manager", + "fieldtype": "Link", + "label": "Maintenance Manager", + "options": "User" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fetch_from": "maintenance_manager.full_name", - "fieldname": "maintenance_manager_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": "Maintenance Manager 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 - }, + "fieldname": "maintenance_manager_name", + "fieldtype": "Read Only", + "label": "Maintenance Manager Name" + }, { - "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 - }, + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "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": "Company", - "length": 0, - "no_copy": 0, - "options": "Company", - "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": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, { - "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 - }, + "fieldname": "section_break_2", + "fieldtype": "Section Break", + "label": "Team" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "maintenance_team_members", - "fieldtype": "Table", - "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 Team Members", - "length": 0, - "no_copy": 0, - "options": "Maintenance Team Member", - "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_team_members", + "fieldtype": "Table", + "label": "Maintenance Team Members", + "options": "Maintenance Team Member", + "reqd": 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-05-16 22:43:24.195349", - "modified_by": "Administrator", - "module": "Assets", - "name": "Asset Maintenance Team", - "name_case": "", - "owner": "Administrator", + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-01-22 15:09:03.347345", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Maintenance Team", + "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": "Manufacturing User", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing User", + "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": 1, - "track_seen": 0 + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.json b/erpnext/assets/doctype/asset_movement/asset_movement.json index 3472ab5d7da..bdce639b039 100644 --- a/erpnext/assets/doctype/asset_movement/asset_movement.json +++ b/erpnext/assets/doctype/asset_movement/asset_movement.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "autoname": "format:ACC-ASM-{YYYY}-{#####}", "creation": "2016-04-25 18:00:23.559973", @@ -91,8 +92,10 @@ "fieldtype": "Column Break" } ], + "index_web_pages_for_search": 1, "is_submittable": 1, - "modified": "2019-11-23 13:28:47.256935", + "links": [], + "modified": "2021-01-22 12:30:55.295670", "modified_by": "Administrator", "module": "Assets", "name": "Asset Movement", diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.json b/erpnext/assets/doctype/asset_repair/asset_repair.json index 6df6e27bd0c..d338fc0fb79 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.json +++ b/erpnext/assets/doctype/asset_repair/asset_repair.json @@ -1,763 +1,208 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "naming_series:", - "beta": 0, - "creation": "2017-10-23 11:38:54.004355", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "naming_series:", + "creation": "2017-10-23 11:38:54.004355", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "asset_name", + "column_break_2", + "item_code", + "item_name", + "section_break_5", + "failure_date", + "assign_to", + "assign_to_name", + "column_break_6", + "completion_date", + "repair_status", + "repair_cost", + "section_break_9", + "description", + "column_break_9", + "actions_performed", + "section_break_17", + "downtime", + "column_break_19", + "amended_from" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 1, - "fieldname": "asset_name", - "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": "Asset Name", - "length": 0, - "no_copy": 0, - "options": "Asset", - "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 - }, + "columns": 1, + "fieldname": "asset_name", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Asset", + "options": "Asset", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "", - "fieldname": "naming_series", - "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": "Series", - "length": 0, - "no_copy": 0, - "options": "ACC-ASR-.YYYY.-", - "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": "naming_series", + "fieldtype": "Select", + "label": "Series", + "options": "ACC-ASR-.YYYY.-", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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 - }, + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_from": "asset_name.item_code", - "fieldname": "item_code", - "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": "Item Code", - "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 - }, + "fetch_from": "asset_name.item_code", + "fieldname": "item_code", + "fieldtype": "Read Only", + "label": "Item Code" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_from": "asset_name.item_name", - "fieldname": "item_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": "Item 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 - }, + "fetch_from": "asset_name.item_name", + "fieldname": "item_name", + "fieldtype": "Read Only", + "label": "Item Name" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_5", - "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 - }, + "fieldname": "section_break_5", + "fieldtype": "Section Break", + "label": "Repair Details" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 1, - "fieldname": "failure_date", - "fieldtype": "Datetime", - "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": "Failure Date", - "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 - }, + "columns": 1, + "fieldname": "failure_date", + "fieldtype": "Datetime", + "label": "Failure Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "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": 0, - "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_on_submit": 1, + "fieldname": "assign_to", + "fieldtype": "Link", + "label": "Assign To", + "options": "User" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "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_on_submit": 1, + "fetch_from": "assign_to.full_name", + "fieldname": "assign_to_name", + "fieldtype": "Read Only", + "label": "Assign To Name" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_6", - "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 - }, + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "completion_date", - "fieldtype": "Datetime", - "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": "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_on_submit": 1, + "fieldname": "completion_date", + "fieldtype": "Datetime", + "label": "Completion Date" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Pending", - "fieldname": "repair_status", - "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": "Repair Status", - "length": 0, - "no_copy": 1, - "options": "Pending\nCompleted\nCancelled", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "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, - "width": "" - }, + "allow_on_submit": 1, + "default": "Pending", + "fieldname": "repair_status", + "fieldtype": "Select", + "label": "Repair Status", + "no_copy": 1, + "options": "Pending\nCompleted\nCancelled", + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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 - }, + "fieldname": "section_break_9", + "fieldtype": "Section Break", + "label": "Description" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "description", - "fieldtype": "Long Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Error 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": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "description", + "fieldtype": "Long Text", + "label": "Error Description", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_9", - "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 - }, + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "actions_performed", - "fieldtype": "Long Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Actions performed", - "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_on_submit": 1, + "fieldname": "actions_performed", + "fieldtype": "Long Text", + "label": "Actions performed" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_17", - "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 - }, + "fieldname": "section_break_17", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "downtime", - "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": "Downtime", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "fieldname": "downtime", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Downtime", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_19", - "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 - }, + "fieldname": "column_break_19", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "repair_cost", - "fieldtype": "Currency", - "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": "Repair Cost", - "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_on_submit": 1, + "fieldname": "repair_cost", + "fieldtype": "Currency", + "label": "Repair Cost" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amended_from", - "fieldtype": "Link", - "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": "Amended From", - "length": 0, - "no_copy": 1, - "options": "Asset Repair", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Asset Repair", + "print_hide": 1, + "read_only": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-08-21 14:44:27.181876", - "modified_by": "Administrator", - "module": "Assets", - "name": "Asset Repair", - "name_case": "", - "owner": "Administrator", + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2021-01-22 15:08:12.495850", + "modified_by": "Administrator", + "module": "Assets", + "name": "Asset Repair", + "owner": "Administrator", "permissions": [ { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Manufacturing Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing Manager", + "share": 1, + "submit": 1, "write": 1 - }, + }, { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Quality Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Quality Manager", + "share": 1, + "submit": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "", - "track_changes": 1, - "track_seen": 1, - "track_views": 0 + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "track_seen": 1 } \ No newline at end of file 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 a6e6974c48d..79c8861bcdc 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.js @@ -1,6 +1,8 @@ // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +frappe.provide("erpnext.accounts.dimensions"); + frappe.ui.form.on('Asset Value Adjustment', { setup: function(frm) { frm.add_fetch('company', 'cost_center', 'cost_center'); @@ -13,11 +15,19 @@ frappe.ui.form.on('Asset Value Adjustment', { } }); }, + onload: function(frm) { if(frm.is_new() && frm.doc.asset) { frm.trigger("set_current_asset_value"); } + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, + + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, + asset: function(frm) { frm.trigger("set_current_asset_value"); }, diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.json b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.json index 3236e726ded..57e04e2567f 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.json +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2018-05-11 00:22:43.695151", "doctype": "DocType", "editable_grid": 1, @@ -7,14 +8,16 @@ "company", "asset", "asset_category", - "finance_book", - "journal_entry", "column_break_4", "date", + "finance_book", + "amended_from", + "value_details_section", "current_asset_value", "new_asset_value", + "column_break_11", "difference_amount", - "amended_from", + "journal_entry", "accounting_dimensions_section", "cost_center", "dimension_col_break" @@ -108,10 +111,21 @@ { "fieldname": "dimension_col_break", "fieldtype": "Column Break" + }, + { + "fieldname": "value_details_section", + "fieldtype": "Section Break", + "label": "Value Details" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" } ], + "index_web_pages_for_search": 1, "is_submittable": 1, - "modified": "2019-11-22 14:09:25.800375", + "links": [], + "modified": "2021-01-22 14:10:23.085181", "modified_by": "Administrator", "module": "Assets", "name": "Asset Value Adjustment", 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 fd702c74c73..14308277c14 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -13,19 +13,16 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g class AssetValueAdjustment(Document): def validate(self): self.validate_date() - self.set_difference_amount() self.set_current_asset_value() + self.set_difference_amount() def on_submit(self): self.make_depreciation_entry() self.reschedule_depreciations(self.new_asset_value) def on_cancel(self): - if self.journal_entry: - frappe.throw(_("Cancel the journal entry {0} first").format(self.journal_entry)) - self.reschedule_depreciations(self.current_asset_value) - + def validate_date(self): asset_purchase_date = frappe.db.get_value('Asset', self.asset, 'purchase_date') if getdate(self.date) < getdate(asset_purchase_date): @@ -53,6 +50,7 @@ class AssetValueAdjustment(Document): je.posting_date = self.date je.company = self.company je.remark = "Depreciation Entry against {0} worth {1}".format(self.asset, self.difference_amount) + je.finance_book = self.finance_book credit_entry = { "account": accumulated_depreciation_account, @@ -78,7 +76,7 @@ class AssetValueAdjustment(Document): debit_entry.update({ dimension['fieldname']: self.get(dimension['fieldname']) or dimension.get('default_dimension') }) - + je.append("accounts", credit_entry) je.append("accounts", debit_entry) 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 af08a2a601e..d1457b9b85a 100644 --- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py +++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py @@ -75,24 +75,23 @@ def get_data(filters): for asset in assets_record: asset_value = asset.gross_purchase_amount - flt(asset.opening_accumulated_depreciation) \ - flt(depreciation_amount_map.get(asset.name)) - if asset_value: - row = { - "asset_id": asset.asset_id, - "asset_name": asset.asset_name, - "status": asset.status, - "department": asset.department, - "cost_center": asset.cost_center, - "vendor_name": pr_supplier_map.get(asset.purchase_receipt) 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, - "available_for_use_date": asset.available_for_use_date, - "location": asset.location, - "asset_category": asset.asset_category, - "purchase_date": asset.purchase_date, - "asset_value": asset_value - } - data.append(row) + row = { + "asset_id": asset.asset_id, + "asset_name": asset.asset_name, + "status": asset.status, + "department": asset.department, + "cost_center": asset.cost_center, + "vendor_name": pr_supplier_map.get(asset.purchase_receipt) 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, + "available_for_use_date": asset.available_for_use_date, + "location": asset.location, + "asset_category": asset.asset_category, + "purchase_date": asset.purchase_date, + "asset_value": asset_value + } + data.append(row) return data diff --git a/erpnext/assets/workspace/assets/assets.json b/erpnext/assets/workspace/assets/assets.json new file mode 100644 index 00000000000..c4015817583 --- /dev/null +++ b/erpnext/assets/workspace/assets/assets.json @@ -0,0 +1,193 @@ +{ + "category": "Modules", + "charts": [ + { + "chart_name": "Asset Value Analytics", + "label": "Asset Value Analytics" + } + ], + "creation": "2020-03-02 15:43:27.634865", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "assets", + "idx": 0, + "is_standard": 1, + "label": "Assets", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Assets", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Asset", + "link_to": "Asset", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Location", + "link_to": "Location", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Asset Category", + "link_to": "Asset Category", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Asset Movement", + "link_to": "Asset Movement", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Maintenance", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Asset Maintenance Team", + "link_to": "Asset Maintenance Team", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Asset Maintenance Team", + "hidden": 0, + "is_query_report": 0, + "label": "Asset Maintenance", + "link_to": "Asset Maintenance", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Asset Maintenance", + "hidden": 0, + "is_query_report": 0, + "label": "Asset Maintenance Log", + "link_to": "Asset Maintenance Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Asset", + "hidden": 0, + "is_query_report": 0, + "label": "Asset Value Adjustment", + "link_to": "Asset Value Adjustment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Asset", + "hidden": 0, + "is_query_report": 0, + "label": "Asset Repair", + "link_to": "Asset Repair", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Asset", + "hidden": 0, + "is_query_report": 1, + "label": "Asset Depreciation Ledger", + "link_to": "Asset Depreciation Ledger", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Asset", + "hidden": 0, + "is_query_report": 1, + "label": "Asset Depreciations and Balances", + "link_to": "Asset Depreciations and Balances", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Asset Maintenance", + "hidden": 0, + "is_query_report": 0, + "label": "Asset Maintenance", + "link_to": "Asset Maintenance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:37.977119", + "modified_by": "Administrator", + "module": "Assets", + "name": "Assets", + "onboarding": "Assets", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "label": "Asset", + "link_to": "Asset", + "type": "DocType" + }, + { + "label": "Asset Category", + "link_to": "Asset Category", + "type": "DocType" + }, + { + "label": "Fixed Asset Register", + "link_to": "Fixed Asset Register", + "type": "Report" + }, + { + "label": "Dashboard", + "link_to": "Asset", + "type": "Dashboard" + } + ] +} \ No newline at end of file diff --git a/erpnext/buying/desk_page/buying/buying.json b/erpnext/buying/desk_page/buying/buying.json deleted file mode 100644 index 565d39c3c83..00000000000 --- a/erpnext/buying/desk_page/buying/buying.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Buying", - "links": "[ \n {\n \"dependencies\": [\n \"Item\"\n ],\n \"description\": \"Request for purchase.\",\n \"label\": \"Material Request\",\n \"name\": \"Material Request\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Supplier\"\n ],\n \"description\": \"Purchase Orders given to Suppliers.\",\n \"label\": \"Purchase Order\",\n \"name\": \"Purchase Order\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Supplier\"\n ],\n \"label\": \"Purchase Invoice\",\n \"name\": \"Purchase Invoice\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Supplier\"\n ],\n \"description\": \"Request for quotation.\",\n \"label\": \"Request for Quotation\",\n \"name\": \"Request for Quotation\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Supplier\"\n ],\n \"description\": \"Quotations received from Suppliers.\",\n \"label\": \"Supplier Quotation\",\n \"name\": \"Supplier Quotation\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Items & Pricing", - "links": "[\n {\n \"description\": \"All Products or Services.\",\n \"label\": \"Item\",\n \"name\": \"Item\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Multiple Item prices.\",\n \"label\": \"Item Price\",\n \"name\": \"Item Price\",\n \"onboard\": 1,\n \"route\": \"#Report/Item Price\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Price List master.\",\n \"label\": \"Price List\",\n \"name\": \"Price List\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Bundle items at time of sale.\",\n \"label\": \"Product Bundle\",\n \"name\": \"Product Bundle\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tree of Item Groups.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Item Group\",\n \"link\": \"Tree/Item Group\",\n \"name\": \"Item Group\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Rules for applying different promotional schemes.\",\n \"label\": \"Promotional Scheme\",\n \"name\": \"Promotional Scheme\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Rules for applying pricing and discount.\",\n \"label\": \"Pricing Rule\",\n \"name\": \"Pricing Rule\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"description\": \"Default settings for buying transactions.\",\n \"label\": \"Buying Settings\",\n \"name\": \"Buying Settings\",\n \"settings\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tax template for buying transactions.\",\n \"label\": \"Purchase Taxes and Charges Template\",\n \"name\": \"Purchase Taxes and Charges Template\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Template of terms or contract.\",\n \"label\": \"Terms and Conditions Template\",\n \"name\": \"Terms and Conditions\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Supplier", - "links": "[\n {\n \"description\": \"Supplier database.\",\n \"label\": \"Supplier\",\n \"name\": \"Supplier\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Supplier Group master.\",\n \"label\": \"Supplier Group\",\n \"name\": \"Supplier Group\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"All Contacts.\",\n \"label\": \"Contact\",\n \"name\": \"Contact\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"All Addresses.\",\n \"label\": \"Address\",\n \"name\": \"Address\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Supplier Scorecard", - "links": "[\n {\n \"description\": \"All Supplier scorecards.\",\n \"label\": \"Supplier Scorecard\",\n \"name\": \"Supplier Scorecard\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Templates of supplier scorecard variables.\",\n \"label\": \"Supplier Scorecard Variable\",\n \"name\": \"Supplier Scorecard Variable\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Templates of supplier scorecard criteria.\",\n \"label\": \"Supplier Scorecard Criteria\",\n \"name\": \"Supplier Scorecard Criteria\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Templates of supplier standings.\",\n \"label\": \"Supplier Scorecard Standing\",\n \"name\": \"Supplier Scorecard Standing\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Key Reports", - "links": "[\n {\n \"is_query_report\": true,\n \"label\": \"Purchase Analytics\",\n \"name\": \"Purchase Analytics\",\n \"onboard\": 1,\n \"reference_doctype\": \"Purchase Order\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Purchase Order Analysis\",\n \"name\": \"Purchase Order Analysis\",\n \"onboard\": 1,\n \"reference_doctype\": \"Purchase Order\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Supplier-Wise Sales Analytics\",\n \"name\": \"Supplier-Wise Sales Analytics\",\n \"onboard\": 1,\n \"reference_doctype\": \"Stock Ledger Entry\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Requested Items to Order\",\n \"name\": \"Requested Items to Order\",\n \"onboard\": 1,\n \"reference_doctype\": \"Material Request\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Purchase Order Trends\",\n \"name\": \"Purchase Order Trends\",\n \"onboard\": 1,\n \"reference_doctype\": \"Purchase Order\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Procurement Tracker\",\n \"name\": \"Procurement Tracker\",\n \"onboard\": 1,\n \"reference_doctype\": \"Purchase Order\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Other Reports", - "links": "[\n {\n \"is_query_report\": true,\n \"label\": \"Items To Be Requested\",\n \"name\": \"Items To Be Requested\",\n \"onboard\": 1,\n \"reference_doctype\": \"Item\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Item-wise Purchase History\",\n \"name\": \"Item-wise Purchase History\",\n \"onboard\": 1,\n \"reference_doctype\": \"Item\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Purchase Receipt Trends\",\n \"name\": \"Purchase Receipt Trends\",\n \"reference_doctype\": \"Purchase Receipt\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Purchase Invoice Trends\",\n \"name\": \"Purchase Invoice Trends\",\n \"reference_doctype\": \"Purchase Invoice\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Subcontracted Raw Materials To Be Transferred\",\n \"name\": \"Subcontracted Raw Materials To Be Transferred\",\n \"reference_doctype\": \"Purchase Order\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Subcontracted Item To Be Received\",\n \"name\": \"Subcontracted Item To Be Received\",\n \"reference_doctype\": \"Purchase Order\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Quoted Item Comparison\",\n \"name\": \"Quoted Item Comparison\",\n \"onboard\": 1,\n \"reference_doctype\": \"Supplier Quotation\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Material Requests for which Supplier Quotations are not created\",\n \"name\": \"Material Requests for which Supplier Quotations are not created\",\n \"reference_doctype\": \"Material Request\",\n \"type\": \"report\"\n },\n {\n \"is_query_report\": true,\n \"label\": \"Supplier Addresses And Contacts\",\n \"name\": \"Address And Contacts\",\n \"reference_doctype\": \"Address\",\n \"route_options\": {\n \"party_type\": \"Supplier\"\n },\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Regional", - "links": "[\n {\n \"description\": \"Import Italian Purchase Invoices\",\n \"label\": \"Import Supplier Invoice\",\n \"name\": \"Import Supplier Invoice\",\n \"type\": \"doctype\"\n } \n]" - } - ], - "cards_label": "", - "category": "Modules", - "charts": [ - { - "chart_name": "Purchase Order Trends", - "label": "Purchase Order Trends" - } - ], - "charts_label": "", - "creation": "2020-01-28 11:50:26.195467", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Buying", - "modified": "2020-06-29 19:30:24.983050", - "modified_by": "Administrator", - "module": "Buying", - "name": "Buying", - "onboarding": "Buying", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "color": "#cef6d1", - "format": "{} Available", - "label": "Item", - "link_to": "Item", - "stats_filter": "{\n \"disabled\": 0\n}", - "type": "DocType" - }, - { - "color": "#ffe8cd", - "format": "{} Pending", - "label": "Material Request", - "link_to": "Material Request", - "stats_filter": "{\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%'],\n \"status\": \"Pending\"\n}", - "type": "DocType" - }, - { - "color": "#ffe8cd", - "format": "{} To Receive", - "label": "Purchase Order", - "link_to": "Purchase Order", - "stats_filter": "{\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%'],\n \"status\":[\"in\", [\"To Receive\", \"To Receive and Bill\"]]\n}", - "type": "DocType" - }, - { - "label": "Purchase Analytics", - "link_to": "Purchase Analytics", - "type": "Report" - }, - { - "label": "Purchase Order Analysis", - "link_to": "Purchase Order Analysis", - "type": "Report" - }, - { - "label": "Dashboard", - "link_to": "Buying", - "type": "Dashboard" - } - ], - "shortcuts_label": "" -} \ No newline at end of file diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index a0ab2a00f99..248cb9a8a0e 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -46,26 +46,26 @@ { "fieldname": "po_required", "fieldtype": "Select", - "label": "Purchase Order Required for Purchase Invoice & Receipt Creation", + "label": "Is Purchase Order Required for Purchase Invoice & Receipt Creation?", "options": "No\nYes" }, { "fieldname": "pr_required", "fieldtype": "Select", - "label": "Purchase Receipt Required for Purchase Invoice Creation", + "label": "Is Purchase Receipt Required for Purchase Invoice Creation?", "options": "No\nYes" }, { "default": "0", "fieldname": "maintain_same_rate", "fieldtype": "Check", - "label": "Maintain same rate throughout purchase cycle" + "label": "Maintain Same Rate Throughout the Purchase Cycle" }, { "default": "0", "fieldname": "allow_multiple_items", "fieldtype": "Check", - "label": "Allow Item to be added multiple times in a transaction" + "label": "Allow Item To Be Added Multiple Times in a Transaction" }, { "fieldname": "subcontract", @@ -93,9 +93,10 @@ ], "icon": "fa fa-cog", "idx": 1, + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-05-15 14:49:32.513611", + "modified": "2021-03-02 17:34:04.190677", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", @@ -112,5 +113,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 9f2b9714f74..dd0f0658485 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -2,7 +2,7 @@ // License: GNU General Public License v3. See license.txt frappe.provide("erpnext.buying"); - +frappe.provide("erpnext.accounts.dimensions"); {% include 'erpnext/public/js/controllers/buying.js' %}; frappe.ui.form.on("Purchase Order", { @@ -30,6 +30,10 @@ frappe.ui.form.on("Purchase Order", { }, + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, + onload: function(frm) { set_schedule_date(frm); if (!frm.doc.transaction_date){ @@ -39,6 +43,8 @@ frappe.ui.form.on("Purchase Order", { erpnext.queries.setup_queries(frm, "Warehouse", function() { return erpnext.queries.warehouse(frm.doc); }); + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); } }); @@ -58,8 +64,8 @@ frappe.ui.form.on("Purchase Order Item", { erpnext.buying.PurchaseOrderController = erpnext.buying.BuyingController.extend({ setup: function() { this.frm.custom_make_buttons = { - 'Purchase Receipt': 'Receipt', - 'Purchase Invoice': 'Invoice', + 'Purchase Receipt': 'Purchase Receipt', + 'Purchase Invoice': 'Purchase Invoice', 'Stock Entry': 'Material to Supplier', 'Payment Entry': 'Payment', } @@ -90,6 +96,11 @@ erpnext.buying.PurchaseOrderController = erpnext.buying.BuyingController.extend( this.frm.set_df_property("drop_ship", "hidden", !is_drop_ship); if(doc.docstatus == 1) { + this.frm.fields_dict.items_section.wrapper.addClass("hide-border"); + if(!this.frm.doc.set_warehouse) { + this.frm.fields_dict.items_section.wrapper.removeClass("hide-border"); + } + if(!in_list(["Closed", "Delivered"], doc.status)) { if(this.frm.doc.status !== 'Closed' && flt(this.frm.doc.per_received) < 100 && flt(this.frm.doc.per_billed) < 100) { this.frm.add_custom_button(__('Update Items'), () => { @@ -126,16 +137,25 @@ erpnext.buying.PurchaseOrderController = erpnext.buying.BuyingController.extend( if(doc.status != "Closed") { if (doc.status != "On Hold") { if(flt(doc.per_received) < 100 && allow_receipt) { - cur_frm.add_custom_button(__('Receipt'), this.make_purchase_receipt, __('Create')); + cur_frm.add_custom_button(__('Purchase Receipt'), this.make_purchase_receipt, __('Create')); if(doc.is_subcontracted==="Yes" && me.has_unsupplied_items()) { cur_frm.add_custom_button(__('Material to Supplier'), function() { me.make_stock_entry(); }, __("Transfer")); } } if(flt(doc.per_billed) < 100) - cur_frm.add_custom_button(__('Invoice'), + cur_frm.add_custom_button(__('Purchase Invoice'), this.make_purchase_invoice, __('Create')); + if(flt(doc.per_billed)==0 && doc.status != "Delivered") { + cur_frm.add_custom_button(__('Payment'), cur_frm.cscript.make_payment_entry, __('Create')); + } + + if(flt(doc.per_billed)==0) { + this.frm.add_custom_button(__('Payment Request'), + function() { me.make_payment_request() }, __('Create')); + } + if(!doc.auto_repeat) { cur_frm.add_custom_button(__('Subscription'), function() { erpnext.utils.make_subscription(doc.doctype, doc.name) @@ -144,25 +164,19 @@ erpnext.buying.PurchaseOrderController = erpnext.buying.BuyingController.extend( if (doc.docstatus === 1 && !doc.inter_company_order_reference) { let me = this; - frappe.model.with_doc("Supplier", me.frm.doc.supplier, () => { - let supplier = frappe.model.get_doc("Supplier", me.frm.doc.supplier); - let internal = supplier.is_internal_supplier; - let disabled = supplier.disabled; - if (internal === 1 && disabled === 0) { - me.frm.add_custom_button("Inter Company Order", function() { - me.make_inter_company_order(me.frm); - }, __('Create')); - } - }); + let internal = me.frm.doc.is_internal_supplier; + if (internal) { + let button_label = (me.frm.doc.company === me.frm.doc.represents_company) ? "Internal Sales Order" : + "Inter Company Sales Order"; + + me.frm.add_custom_button(button_label, function() { + me.make_inter_company_order(me.frm); + }, __('Create')); + } + } } - if(flt(doc.per_billed)==0) { - this.frm.add_custom_button(__('Payment Request'), - function() { me.make_payment_request() }, __('Create')); - } - if(flt(doc.per_billed)==0 && doc.status != "Delivered") { - cur_frm.add_custom_button(__('Payment'), cur_frm.cscript.make_payment_entry, __('Create')); - } + cur_frm.page.set_inner_btn_group_as_primary(__('Create')); } } else if(doc.docstatus===0) { @@ -299,7 +313,7 @@ erpnext.buying.PurchaseOrderController = erpnext.buying.BuyingController.extend( if(me.values) { me.values.sub_con_rm_items.map((row,i) => { if (!row.item_code || !row.rm_item_code || !row.warehouse || !row.qty || row.qty === 0) { - frappe.throw(__("Item Code, warehouse, quantity are required on row" + (i+1))); + frappe.throw(__("Item Code, warehouse, quantity are required on row {0}", [i+1])); } }) me._make_rm_stock_entry(me.dialog.fields_dict.sub_con_rm_items.grid.get_selected_children()) @@ -339,7 +353,8 @@ erpnext.buying.PurchaseOrderController = erpnext.buying.BuyingController.extend( make_purchase_receipt: function() { frappe.model.open_mapped_doc({ method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt", - frm: cur_frm + frm: cur_frm, + freeze_message: __("Creating Purchase Receipt ...") }) }, @@ -358,15 +373,19 @@ erpnext.buying.PurchaseOrderController = erpnext.buying.BuyingController.extend( method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order", source_doctype: "Material Request", target: me.frm, - setters: {}, + setters: { + schedule_date: undefined, + status: undefined + }, get_query_filters: { material_request_type: "Purchase", docstatus: 1, status: ["!=", "Stopped"], - per_ordered: ["<", 99.99], + per_ordered: ["<", 100], + company: me.frm.doc.company } }) - }, __("Get items from")); + }, __("Get Items From")); this.frm.add_custom_button(__('Supplier Quotation'), function() { @@ -375,16 +394,17 @@ erpnext.buying.PurchaseOrderController = erpnext.buying.BuyingController.extend( source_doctype: "Supplier Quotation", target: me.frm, setters: { - supplier: me.frm.doc.supplier + supplier: me.frm.doc.supplier, + valid_till: undefined }, get_query_filters: { docstatus: 1, - status: ["!=", "Stopped"], + status: ["not in", ["Stopped", "Expired"]], } }) - }, __("Get items from")); + }, __("Get Items From")); - this.frm.add_custom_button(__('Update rate as per last purchase'), + this.frm.add_custom_button(__('Update Rate as per Last Purchase'), function() { frappe.call({ "method": "get_last_purchase_rate", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index d1063b1503d..ee2beea67f9 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_auto_repeat": 1, "allow_import": 1, "autoname": "naming_series:", "creation": "2013-05-21 16:16:39", @@ -30,8 +31,8 @@ "customer_contact_email", "section_addresses", "supplier_address", - "contact_person", "address_display", + "contact_person", "contact_display", "contact_mobile", "contact_email", @@ -49,12 +50,14 @@ "plc_conversion_rate", "ignore_pricing_rule", "sec_warehouse", - "set_warehouse", - "col_break_warehouse", "is_subcontracted", + "col_break_warehouse", "supplier_warehouse", - "items_section", + "before_items_section", "scan_barcode", + "items_col_break", + "set_warehouse", + "items_section", "items", "sb_last_purchase", "total_qty", @@ -108,18 +111,13 @@ "payment_terms_template", "payment_schedule", "tracking_section", - "per_billed", + "status", "column_break_75", + "per_billed", "per_received", "terms_section_break", "tc_name", "terms", - "more_info", - "status", - "ref_sq", - "column_break_74", - "party_account_currency", - "inter_company_order_reference", "column_break5", "letter_head", "select_print_heading", @@ -131,7 +129,14 @@ "to_date", "column_break_97", "auto_repeat", - "update_auto_repeat_reference" + "update_auto_repeat_reference", + "more_info", + "ref_sq", + "column_break_74", + "party_account_currency", + "is_internal_supplier", + "represents_company", + "inter_company_order_reference" ], "fields": [ { @@ -165,6 +170,7 @@ "bold": 1, "fieldname": "supplier", "fieldtype": "Link", + "in_global_search": 1, "in_standard_filter": 1, "label": "Supplier", "oldfieldname": "supplier", @@ -313,34 +319,34 @@ { "fieldname": "supplier_address", "fieldtype": "Link", - "label": "Select Supplier Address", + "label": "Supplier Address", "options": "Address", "print_hide": 1 }, { "fieldname": "contact_person", "fieldtype": "Link", - "label": "Contact Person", + "label": "Supplier Contact", "options": "Contact", "print_hide": 1 }, { "fieldname": "address_display", "fieldtype": "Small Text", - "label": "Address", + "label": "Supplier Address Details", "read_only": 1 }, { "fieldname": "contact_display", "fieldtype": "Small Text", "in_global_search": 1, - "label": "Contact", + "label": "Contact Name", "read_only": 1 }, { "fieldname": "contact_mobile", "fieldtype": "Small Text", - "label": "Mobile No", + "label": "Contact Mobile No", "read_only": 1 }, { @@ -358,14 +364,14 @@ { "fieldname": "shipping_address", "fieldtype": "Link", - "label": "Select Shipping Address", + "label": "Company Shipping Address", "options": "Address", "print_hide": 1 }, { "fieldname": "shipping_address_display", "fieldtype": "Small Text", - "label": "Shipping Address", + "label": "Shipping Address Details", "print_hide": 1, "read_only": 1 }, @@ -433,7 +439,8 @@ }, { "fieldname": "sec_warehouse", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Subcontracting" }, { "description": "Sets 'Warehouse' in each row of the Items table.", @@ -466,6 +473,7 @@ { "fieldname": "items_section", "fieldtype": "Section Break", + "hide_border": 1, "oldfieldtype": "Section Break", "options": "fa fa-shopping-cart" }, @@ -598,7 +606,8 @@ }, { "fieldname": "section_break_52", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "fieldname": "taxes", @@ -626,10 +635,12 @@ { "fieldname": "totals", "fieldtype": "Section Break", + "label": "Taxes and Charges", "oldfieldtype": "Section Break", "options": "fa fa-money" }, { + "depends_on": "base_taxes_and_charges_added", "fieldname": "base_taxes_and_charges_added", "fieldtype": "Currency", "label": "Taxes and Charges Added (Company Currency)", @@ -640,6 +651,7 @@ "read_only": 1 }, { + "depends_on": "base_taxes_and_charges_deducted", "fieldname": "base_taxes_and_charges_deducted", "fieldtype": "Currency", "label": "Taxes and Charges Deducted (Company Currency)", @@ -650,6 +662,7 @@ "read_only": 1 }, { + "depends_on": "base_total_taxes_and_charges", "fieldname": "base_total_taxes_and_charges", "fieldtype": "Currency", "label": "Total Taxes and Charges (Company Currency)", @@ -665,6 +678,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "taxes_and_charges_added", "fieldname": "taxes_and_charges_added", "fieldtype": "Currency", "label": "Taxes and Charges Added", @@ -675,6 +689,7 @@ "read_only": 1 }, { + "depends_on": "taxes_and_charges_deducted", "fieldname": "taxes_and_charges_deducted", "fieldtype": "Currency", "label": "Taxes and Charges Deducted", @@ -685,6 +700,7 @@ "read_only": 1 }, { + "depends_on": "total_taxes_and_charges", "fieldname": "total_taxes_and_charges", "fieldtype": "Currency", "label": "Total Taxes and Charges", @@ -694,7 +710,7 @@ }, { "collapsible": 1, - "collapsible_depends_on": "discount_amount", + "collapsible_depends_on": "apply_discount_on", "fieldname": "discount_section", "fieldtype": "Section Break", "label": "Additional Discount" @@ -734,7 +750,8 @@ }, { "fieldname": "totals_section", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Totals" }, { "fieldname": "base_grand_total", @@ -902,12 +919,12 @@ }, { "fieldname": "ref_sq", - "fieldtype": "Data", - "hidden": 1, - "label": "Ref SQ", + "fieldtype": "Link", + "label": "Supplier Quotation", "no_copy": 1, "oldfieldname": "ref_sq", "oldfieldtype": "Data", + "options": "Supplier Quotation", "print_hide": 1, "read_only": 1 }, @@ -1061,7 +1078,7 @@ "collapsible": 1, "fieldname": "tracking_section", "fieldtype": "Section Break", - "label": "Tracking" + "label": "Order Status" }, { "fieldname": "column_break_75", @@ -1070,13 +1087,36 @@ { "fieldname": "billing_address", "fieldtype": "Link", - "label": "Select Billing Address", + "label": "Company Billing Address", "options": "Address" }, { "fieldname": "billing_address_display", "fieldtype": "Small Text", - "label": "Billing Address", + "label": "Billing Address Details", + "read_only": 1 + }, + { + "fieldname": "before_items_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "items_col_break", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fetch_from": "supplier.is_internal_supplier", + "fieldname": "is_internal_supplier", + "fieldtype": "Check", + "label": "Is Internal Supplier" + }, + { + "fetch_from": "supplier.represents_company", + "fieldname": "represents_company", + "fieldtype": "Link", + "label": "Represents Company", + "options": "Company", "read_only": 1 } ], @@ -1084,7 +1124,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2020-09-14 14:36:12.418690", + "modified": "2021-01-20 22:07:23.487138", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", @@ -1130,11 +1170,11 @@ "write": 1 } ], - "search_fields": "status, transaction_date, supplier,grand_total", + "search_fields": "status, transaction_date, supplier, grand_total", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", "timeline_field": "supplier", - "title_field": "supplier", + "title_field": "supplier_name", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index c7efb8a1a17..d32e98e8d94 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -123,8 +123,8 @@ class PurchaseOrder(BuyingController): if self.is_subcontracted == "Yes": for item in self.items: if not item.bom: - frappe.throw(_("BOM is not specified for subcontracting item {0} at row {1}"\ - .format(item.item_code, item.idx))) + frappe.throw(_("BOM is not specified for subcontracting item {0} at row {1}") + .format(item.item_code, item.idx)) def get_schedule_dates(self): for d in self.get('items'): diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 158799ce631..604c88682f7 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -19,6 +19,8 @@ from erpnext.controllers.accounts_controller import update_child_qty_rate from erpnext.controllers.status_updater import OverAllowanceError from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order +from erpnext.stock.doctype.batch.test_batch import make_new_batch +from erpnext.controllers.buying_controller import get_backflushed_subcontracted_raw_materials class TestPurchaseOrder(unittest.TestCase): def test_make_purchase_receipt(self): @@ -203,9 +205,39 @@ class TestPurchaseOrder(unittest.TestCase): frappe.set_user("Administrator") def test_update_child_with_tax_template(self): - tax_template = "_Test Account Excise Duty @ 10" - item = "_Test Item Home Desktop 100" + """ + Test Action: Create a PO with one item having its tax account head already in the PO. + Add the same item + new item with tax template via Update Items. + Expected result: First Item's tax row is updated. New tax row is added for second Item. + """ + if not frappe.db.exists("Item", "Test Item with Tax"): + make_item("Test Item with Tax", { + 'is_stock_item': 1, + }) + if not frappe.db.exists("Item Tax Template", {"title": 'Test Update Items Template'}): + frappe.get_doc({ + 'doctype': 'Item Tax Template', + 'title': 'Test Update Items Template', + 'company': '_Test Company', + 'taxes': [ + { + 'tax_type': "_Test Account Service Tax - _TC", + 'tax_rate': 10, + } + ] + }).insert() + + new_item_with_tax = frappe.get_doc("Item", "Test Item with Tax") + + new_item_with_tax.append("taxes", { + "item_tax_template": "Test Update Items Template - _TC", + "valid_from": nowdate() + }) + new_item_with_tax.save() + + tax_template = "_Test Account Excise Duty @ 10 - _TC" + item = "_Test Item Home Desktop 100" if not frappe.db.exists("Item Tax", {"parent":item, "item_tax_template":tax_template}): item_doc = frappe.get_doc("Item", item) item_doc.append("taxes", { @@ -237,17 +269,25 @@ class TestPurchaseOrder(unittest.TestCase): items = json.dumps([ {'item_code' : item, 'rate' : 500, 'qty' : 1, 'docname': po.items[0].name}, - {'item_code' : item, 'rate' : 100, 'qty' : 1} # added item + {'item_code' : item, 'rate' : 100, 'qty' : 1}, # added item whose tax account head already exists in PO + {'item_code' : new_item_with_tax.name, 'rate' : 100, 'qty' : 1} # added item whose tax account head is missing in PO ]) update_child_qty_rate('Purchase Order', items, po.name) po.reload() - self.assertEqual(po.taxes[0].tax_amount, 60) - self.assertEqual(po.taxes[0].total, 660) + self.assertEqual(po.taxes[0].tax_amount, 70) + self.assertEqual(po.taxes[0].total, 770) + self.assertEqual(po.taxes[1].account_head, "_Test Account Service Tax - _TC") + self.assertEqual(po.taxes[1].tax_amount, 70) + self.assertEqual(po.taxes[1].total, 840) + # teardown frappe.db.sql("""UPDATE `tabItem Tax` set valid_from = NULL - where parent = %(item)s and item_tax_template = %(tax)s""", - {"item": item, "tax": tax_template}) + where parent = %(item)s and item_tax_template = %(tax)s""", {"item": item, "tax": tax_template}) + po.cancel() + po.delete() + new_item_with_tax.delete() + frappe.get_doc("Item Tax Template", "Test Update Items Template - _TC").delete() def test_update_child_uom_conv_factor_change(self): po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes") @@ -648,15 +688,15 @@ class TestPurchaseOrder(unittest.TestCase): def test_exploded_items_in_subcontracted(self): item_code = "_Test Subcontracted FG Item 1" - make_subcontracted_item(item_code) + make_subcontracted_item(item_code=item_code) po = create_purchase_order(item_code=item_code, qty=1, - is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC") + is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC", include_exploded_items=1) name = frappe.db.get_value('BOM', {'item': item_code}, 'name') bom = frappe.get_doc('BOM', name) - exploded_items = sorted([d.item_code for d in bom.exploded_items]) + exploded_items = sorted([d.item_code for d in bom.exploded_items if not d.get('sourced_by_supplier')]) supplied_items = sorted([d.rm_item_code for d in po.supplied_items]) self.assertEquals(exploded_items, supplied_items) @@ -664,13 +704,13 @@ class TestPurchaseOrder(unittest.TestCase): is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC", include_exploded_items=0) supplied_items1 = sorted([d.rm_item_code for d in po1.supplied_items]) - bom_items = sorted([d.item_code for d in bom.items]) + bom_items = sorted([d.item_code for d in bom.items if not d.get('sourced_by_supplier')]) self.assertEquals(supplied_items1, bom_items) def test_backflush_based_on_stock_entry(self): item_code = "_Test Subcontracted FG Item 1" - make_subcontracted_item(item_code) + make_subcontracted_item(item_code=item_code) make_item('Sub Contracted Raw Material 1', { 'is_stock_item': 1, 'is_sub_contracted_item': 1 @@ -683,7 +723,7 @@ class TestPurchaseOrder(unittest.TestCase): is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC") make_stock_entry(target="_Test Warehouse - _TC", - item_code="_Test Item Home Desktop 100", qty=10, basic_rate=100) + item_code="_Test Item Home Desktop 100", qty=20, basic_rate=100) make_stock_entry(target="_Test Warehouse - _TC", item_code = "Test Extra Item 1", qty=100, basic_rate=100) make_stock_entry(target="_Test Warehouse - _TC", @@ -729,6 +769,133 @@ class TestPurchaseOrder(unittest.TestCase): update_backflush_based_on("BOM") + def test_backflushed_based_on_for_multiple_batches(self): + item_code = "_Test Subcontracted FG Item 2" + make_item('Sub Contracted Raw Material 2', { + 'is_stock_item': 1, + 'is_sub_contracted_item': 1 + }) + + make_subcontracted_item(item_code=item_code, has_batch_no=1, create_new_batch=1, + raw_materials=["Sub Contracted Raw Material 2"]) + + update_backflush_based_on("Material Transferred for Subcontract") + + order_qty = 500 + po = create_purchase_order(item_code=item_code, qty=order_qty, + is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC") + + make_stock_entry(target="_Test Warehouse - _TC", + item_code = "Sub Contracted Raw Material 2", qty=552, basic_rate=100) + + rm_items = [ + {"item_code":item_code,"rm_item_code":"Sub Contracted Raw Material 2","item_name":"_Test Item", + "qty":552,"warehouse":"_Test Warehouse - _TC", "stock_uom":"Nos"}] + + rm_item_string = json.dumps(rm_items) + se = frappe.get_doc(make_subcontract_transfer_entry(po.name, rm_item_string)) + se.submit() + + for batch in ["ABCD1", "ABCD2", "ABCD3", "ABCD4"]: + make_new_batch(batch_id=batch, item_code=item_code) + + pr = make_purchase_receipt(po.name) + + # partial receipt + pr.get('items')[0].qty = 30 + pr.get('items')[0].batch_no = "ABCD1" + + purchase_order = po.name + purchase_order_item = po.items[0].name + + for batch_no, qty in {"ABCD2": 60, "ABCD3": 70, "ABCD4":40}.items(): + pr.append("items", { + "item_code": pr.get('items')[0].item_code, + "item_name": pr.get('items')[0].item_name, + "uom": pr.get('items')[0].uom, + "stock_uom": pr.get('items')[0].stock_uom, + "warehouse": pr.get('items')[0].warehouse, + "conversion_factor": pr.get('items')[0].conversion_factor, + "cost_center": pr.get('items')[0].cost_center, + "rate": pr.get('items')[0].rate, + "qty": qty, + "batch_no": batch_no, + "purchase_order": purchase_order, + "purchase_order_item": purchase_order_item + }) + + pr.submit() + + pr1 = make_purchase_receipt(po.name) + pr1.get('items')[0].qty = 300 + pr1.get('items')[0].batch_no = "ABCD1" + pr1.save() + + pr_key = ("Sub Contracted Raw Material 2", po.name) + consumed_qty = get_backflushed_subcontracted_raw_materials([po.name]).get(pr_key) + + self.assertTrue(pr1.supplied_items[0].consumed_qty > 0) + self.assertTrue(pr1.supplied_items[0].consumed_qty, flt(552.0) - flt(consumed_qty)) + + update_backflush_based_on("BOM") + + def test_supplied_qty_against_subcontracted_po(self): + item_code = "_Test Subcontracted FG Item 5" + make_item('Sub Contracted Raw Material 4', { + 'is_stock_item': 1, + 'is_sub_contracted_item': 1 + }) + + make_subcontracted_item(item_code=item_code, raw_materials=["Sub Contracted Raw Material 4"]) + + update_backflush_based_on("Material Transferred for Subcontract") + + order_qty = 250 + po = create_purchase_order(item_code=item_code, qty=order_qty, + is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC", do_not_save=True) + + # Add same subcontracted items multiple times + po.append("items", { + "item_code": item_code, + "qty": order_qty, + "schedule_date": add_days(nowdate(), 1), + "warehouse": "_Test Warehouse - _TC" + }) + + po.set_missing_values() + po.submit() + + # Material receipt entry for the raw materials which will be send to supplier + make_stock_entry(target="_Test Warehouse - _TC", + item_code = "Sub Contracted Raw Material 4", qty=500, basic_rate=100) + + rm_items = [ + { + "item_code":item_code,"rm_item_code":"Sub Contracted Raw Material 4","item_name":"_Test Item", + "qty":250,"warehouse":"_Test Warehouse - _TC", "stock_uom":"Nos", "name": po.supplied_items[0].name + }, + { + "item_code":item_code,"rm_item_code":"Sub Contracted Raw Material 4","item_name":"_Test Item", + "qty":250,"warehouse":"_Test Warehouse - _TC", "stock_uom":"Nos" + }, + ] + + # Raw Materials transfer entry from stores to supplier's warehouse + rm_item_string = json.dumps(rm_items) + se = frappe.get_doc(make_subcontract_transfer_entry(po.name, rm_item_string)) + se.submit() + + # Test po_detail field has value or not + for item_row in se.items: + self.assertEqual(item_row.po_detail, po.supplied_items[item_row.idx - 1].name) + + po_doc = frappe.get_doc("Purchase Order", po.name) + for row in po_doc.supplied_items: + # Valid that whether transferred quantity is matching with supplied qty or not in the purchase order + self.assertEqual(row.supplied_qty, 250.0) + + update_backflush_based_on("BOM") + def test_advance_payment_entry_unlink_against_purchase_order(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry frappe.db.set_value("Accounts Settings", "Accounts Settings", @@ -801,27 +968,33 @@ def make_pr_against_po(po, received_qty=0): pr.submit() return pr -def make_subcontracted_item(item_code): +def make_subcontracted_item(**args): from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom - if not frappe.db.exists('Item', item_code): - make_item(item_code, { + args = frappe._dict(args) + + if not frappe.db.exists('Item', args.item_code): + make_item(args.item_code, { 'is_stock_item': 1, - 'is_sub_contracted_item': 1 + 'is_sub_contracted_item': 1, + 'has_batch_no': args.get("has_batch_no") or 0 }) - if not frappe.db.exists('Item', "Test Extra Item 1"): - make_item("Test Extra Item 1", { - 'is_stock_item': 1, - }) + if not args.raw_materials: + if not frappe.db.exists('Item', "Test Extra Item 1"): + make_item("Test Extra Item 1", { + 'is_stock_item': 1, + }) - if not frappe.db.exists('Item', "Test Extra Item 2"): - make_item("Test Extra Item 2", { - 'is_stock_item': 1, - }) + if not frappe.db.exists('Item', "Test Extra Item 2"): + make_item("Test Extra Item 2", { + 'is_stock_item': 1, + }) - if not frappe.db.get_value('BOM', {'item': item_code}, 'name'): - make_bom(item = item_code, raw_materials = ['_Test FG Item', 'Test Extra Item 1']) + args.raw_materials = ['_Test FG Item', 'Test Extra Item 1'] + + if not frappe.db.get_value('BOM', {'item': args.item_code}, 'name'): + make_bom(item = args.item_code, raw_materials = args.get("raw_materials")) def update_backflush_based_on(based_on): doc = frappe.get_doc('Buying Settings') diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 7a52c28a0ee..5baf6939cd3 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -24,13 +24,20 @@ "col_break2", "uom", "conversion_factor", + "stock_qty", "sec_break1", "price_list_rate", + "last_purchase_rate", + "col_break3", + "base_price_list_rate", + "discount_and_margin_section", + "margin_type", + "margin_rate_or_amount", + "rate_with_margin", + "column_break_28", "discount_percentage", "discount_amount", - "col_break3", - "last_purchase_rate", - "base_price_list_rate", + "base_rate_with_margin", "sec_break2", "rate", "amount", @@ -39,6 +46,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_29", "net_rate", @@ -46,11 +54,8 @@ "column_break_32", "base_net_rate", "base_net_amount", - "billed_amt", "warehouse_and_reference", "warehouse", - "delivered_by_supplier", - "project", "material_request", "material_request_item", "sales_order", @@ -58,36 +63,37 @@ "supplier_quotation", "supplier_quotation_item", "col_break5", + "delivered_by_supplier", "against_blanket_order", "blanket_order", "blanket_order_rate", "item_group", "brand", - "bom", - "include_exploded_items", "section_break_56", - "stock_qty", - "column_break_60", "received_qty", "returned_qty", - "manufacture_details", - "manufacturer", - "column_break_14", - "manufacturer_part_no", - "more_info_section_break", - "is_fixed_asset", - "item_tax_rate", + "column_break_60", + "billed_amt", "accounting_details", "expense_account", - "column_break_68", + "manufacture_details", + "manufacturer", + "manufacturer_part_no", + "column_break_14", + "bom", + "include_exploded_items", "item_weight_details", "weight_per_unit", "total_weight", "column_break_40", "weight_uom", "accounting_dimensions_section", - "cost_center", + "project", "dimension_col_break", + "cost_center", + "more_info_section_break", + "is_fixed_asset", + "item_tax_rate", "section_break_72", "page_break" ], @@ -346,6 +352,7 @@ }, { "default": "0", + "depends_on": "is_free_item", "fieldname": "is_free_item", "fieldtype": "Check", "label": "Is Free Item", @@ -508,9 +515,10 @@ }, { "default": "0", + "depends_on": "delivered_by_supplier", "fieldname": "delivered_by_supplier", "fieldtype": "Check", - "label": "To be delivered to customer", + "label": "To be Delivered to Customer", "print_hide": 1, "read_only": 1 }, @@ -558,6 +566,7 @@ "read_only": 1 }, { + "depends_on": "eval:parent.is_subcontracted == 'Yes'", "fieldname": "bom", "fieldtype": "Link", "label": "BOM", @@ -574,21 +583,21 @@ }, { "fieldname": "section_break_56", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Billed, Received & Returned" }, { "fieldname": "stock_qty", "fieldtype": "Float", - "label": "Qty as per Stock UOM", + "label": "Qty in Stock UOM", "no_copy": 1, - "oldfieldname": "stock_qty", - "oldfieldtype": "Currency", "print_hide": 1, "print_width": "100px", "read_only": 1, "width": "100px" }, { + "depends_on": "received_qty", "fieldname": "received_qty", "fieldtype": "Float", "label": "Received Qty", @@ -612,9 +621,10 @@ "fieldtype": "Column Break" }, { + "depends_on": "billed_amt", "fieldname": "billed_amt", "fieldtype": "Currency", - "label": "Billed Amt", + "label": "Billed Amount", "no_copy": 1, "options": "currency", "print_hide": 1, @@ -633,6 +643,7 @@ "report_hide": 1 }, { + "collapsible": 1, "fieldname": "accounting_details", "fieldtype": "Section Break", "label": "Accounting Details" @@ -644,10 +655,6 @@ "options": "Account", "print_hide": 1 }, - { - "fieldname": "column_break_68", - "fieldtype": "Column Break" - }, { "fieldname": "cost_center", "fieldtype": "Link", @@ -715,6 +722,7 @@ }, { "default": "0", + "depends_on": "is_fixed_asset", "fetch_from": "item_code.is_fixed_asset", "fieldname": "is_fixed_asset", "fieldtype": "Check", @@ -725,12 +733,65 @@ "fieldname": "more_info_section_break", "fieldtype": "Section Break", "label": "More Information" + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "no_copy": 1, + "options": "currency", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "discount_and_margin_section", + "fieldtype": "Section Break", + "label": "Discount and Margin" + }, + { + "depends_on": "price_list_rate", + "fieldname": "margin_type", + "fieldtype": "Select", + "label": "Margin Type", + "options": "\nPercentage\nAmount", + "print_hide": 1 + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate", + "fieldname": "margin_rate_or_amount", + "fieldtype": "Float", + "label": "Margin Rate or Amount", + "print_hide": 1 + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount", + "fieldname": "rate_with_margin", + "fieldtype": "Currency", + "label": "Rate With Margin", + "options": "currency", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_28", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount", + "fieldname": "base_rate_with_margin", + "fieldtype": "Currency", + "label": "Rate With Margin (Company Currency)", + "options": "Company:company:default_currency", + "print_hide": 1, + "read_only": 1 } ], "idx": 1, + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-04-21 11:55:58.643393", + "modified": "2021-02-23 01:00:27.132705", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py index b711e36bf99..8bdcd47e028 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.py @@ -6,11 +6,8 @@ import frappe from frappe.model.document import Document -from erpnext.controllers.print_settings import print_settings_for_item_table - class PurchaseOrderItem(Document): - def __setup__(self): - print_settings_for_item_table(self) + pass def on_doctype_update(): frappe.db.add_index("Purchase Order Item", ["item_code", "warehouse"]) \ No newline at end of file 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 4a937f7f0d3..b76c3784a47 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -22,8 +22,6 @@ frappe.ui.form.on("Request for Quotation",{ }, onload: function(frm) { - frm.add_fetch('email_template', 'response', 'message_for_supplier'); - if(!frm.doc.message_for_supplier) { frm.set_value("message_for_supplier", __("Please supply the specified items at the best possible rates")) } @@ -31,14 +29,12 @@ frappe.ui.form.on("Request for Quotation",{ refresh: function(frm, cdt, cdn) { if (frm.doc.docstatus === 1) { - frm.add_custom_button(__('Create'), - function(){ frm.trigger("make_suppplier_quotation") }, __("Supplier Quotation")); - frm.add_custom_button(__("View"), - function(){ frappe.set_route('List', 'Supplier Quotation', - {'request_for_quotation': frm.doc.name}) }, __("Supplier Quotation")); + frm.add_custom_button(__('Supplier Quotation'), + function(){ frm.trigger("make_suppplier_quotation") }, __("Create")); - frm.add_custom_button(__("Send Supplier Emails"), function() { + + frm.add_custom_button(__("Send Emails to Suppliers"), function() { frappe.call({ method: 'erpnext.buying.doctype.request_for_quotation.request_for_quotation.send_supplier_emails', freeze: true, @@ -49,11 +45,273 @@ frappe.ui.form.on("Request for Quotation",{ frm.reload_doc(); } }); - }); + }, __("Tools")); + + frm.add_custom_button(__('Download PDF'), () => { + var suppliers = []; + const fields = [{ + fieldtype: 'Link', + label: __('Select a Supplier'), + fieldname: 'supplier', + options: 'Supplier', + reqd: 1, + get_query: () => { + return { + filters: [ + ["Supplier", "name", "in", frm.doc.suppliers.map((row) => {return row.supplier;})] + ] + } + } + }]; + + frappe.prompt(fields, data => { + var child = locals[cdt][cdn] + + var w = window.open( + frappe.urllib.get_full_url("/api/method/erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_pdf?" + +"doctype="+encodeURIComponent(frm.doc.doctype) + +"&name="+encodeURIComponent(frm.doc.name) + +"&supplier="+encodeURIComponent(data.supplier) + +"&no_letterhead=0")); + if(!w) { + frappe.msgprint(__("Please enable pop-ups")); return; + } + }, + 'Download PDF for Supplier', + 'Download'); + }, + __("Tools")); + + frm.page.set_inner_btn_group_as_primary(__('Create')); } }, + make_suppplier_quotation: function(frm) { + var doc = frm.doc; + var dialog = new frappe.ui.Dialog({ + title: __("Create Supplier Quotation"), + fields: [ + { "fieldtype": "Select", "label": __("Supplier"), + "fieldname": "supplier", + "options": doc.suppliers.map(d => d.supplier), + "reqd": 1, + "default": doc.suppliers.length === 1 ? doc.suppliers[0].supplier_name : "" }, + ], + primary_action_label: __("Create"), + primary_action: (args) => { + if(!args) return; + dialog.hide(); + + return frappe.call({ + type: "GET", + method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq", + args: { + "source_name": doc.name, + "for_supplier": args.supplier + }, + freeze: true, + callback: function(r) { + if(!r.exc) { + var doc = frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + } + }); + } + }); + + dialog.show() + }, + + preview: (frm) => { + let dialog = new frappe.ui.Dialog({ + title: __('Preview Email'), + fields: [ + { + label: __('Supplier'), + fieldtype: 'Select', + fieldname: 'supplier', + options: frm.doc.suppliers.map(row => row.supplier), + reqd: 1 + }, + { + fieldtype: 'Column Break', + fieldname: 'col_break_1', + }, + { + label: __('Subject'), + fieldtype: 'Data', + fieldname: 'subject', + read_only: 1, + depends_on: 'subject' + }, + { + fieldtype: 'Section Break', + fieldname: 'sec_break_1', + hide_border: 1 + }, + { + label: __('Email'), + fieldtype: 'HTML', + fieldname: 'email_preview' + }, + { + fieldtype: 'Section Break', + fieldname: 'sec_break_2' + }, + { + label: __('Note'), + fieldtype: 'HTML', + fieldname: 'note' + } + ] + }); + + dialog.fields_dict['supplier'].df.onchange = () => { + var supplier = dialog.get_value('supplier'); + frm.call('get_supplier_email_preview', {supplier: supplier}).then(result => { + dialog.fields_dict.email_preview.$wrapper.empty(); + dialog.fields_dict.email_preview.$wrapper.append(result.message); + }); + + } + + dialog.fields_dict.note.$wrapper.append(`

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

    `); + + dialog.set_value("subject", frm.doc.subject); + dialog.show(); + } +}) + +frappe.ui.form.on("Request for Quotation Supplier",{ + supplier: function(frm, cdt, cdn) { + var d = locals[cdt][cdn] + frappe.call({ + method:"erpnext.accounts.party.get_party_details", + args:{ + party: d.supplier, + party_type: 'Supplier' + }, + callback: function(r){ + if(r.message){ + frappe.model.set_value(cdt, cdn, 'contact', r.message.contact_person) + frappe.model.set_value(cdt, cdn, 'email_id', r.message.contact_email) + } + } + }) + }, + +}) + +erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.extend({ + refresh: function() { + var me = this; + this._super(); + if (this.frm.doc.docstatus===0) { + this.frm.add_custom_button(__('Material Request'), + function() { + erpnext.utils.map_current_doc({ + method: "erpnext.stock.doctype.material_request.material_request.make_request_for_quotation", + source_doctype: "Material Request", + target: me.frm, + setters: { + schedule_date: undefined, + status: undefined + }, + get_query_filters: { + material_request_type: "Purchase", + docstatus: 1, + status: ["!=", "Stopped"], + per_ordered: ["<", 100], + company: me.frm.doc.company + } + }) + }, __("Get Items From")); + + // Get items from Opportunity + this.frm.add_custom_button(__('Opportunity'), + function() { + erpnext.utils.map_current_doc({ + method: "erpnext.crm.doctype.opportunity.opportunity.make_request_for_quotation", + source_doctype: "Opportunity", + target: me.frm, + setters: { + party_name: undefined, + opportunity_from: undefined, + status: undefined + }, + get_query_filters: { + status: ["not in", ["Closed", "Lost"]], + company: me.frm.doc.company + } + }) + }, __("Get Items From")); + + // Get items from open Material Requests based on supplier + this.frm.add_custom_button(__('Possible Supplier'), function() { + // Create a dialog window for the user to pick their supplier + var dialog = new frappe.ui.Dialog({ + title: __('Select Possible Supplier'), + fields: [ + { + fieldname: 'supplier', + fieldtype:'Link', + options:'Supplier', + label:'Supplier', + reqd:1, + description: __("Get Items from Material Requests against this Supplier") + } + ], + primary_action_label: __("Get Items"), + primary_action: (args) => { + if(!args) return; + dialog.hide(); + + erpnext.utils.map_current_doc({ + method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_item_from_material_requests_based_on_supplier", + source_name: args.supplier, + target: me.frm, + setters: { + company: me.frm.doc.company + }, + get_query_filters: { + material_request_type: "Purchase", + docstatus: 1, + status: ["!=", "Stopped"], + per_ordered: ["<", 100] + } + }); + dialog.hide(); + } + }); + + dialog.show(); + }, __("Get Items From")); + + // Link Material Requests + this.frm.add_custom_button(__('Link to Material Requests'), + function() { + erpnext.buying.link_to_mrs(me.frm); + }, __("Tools")); + + // Get Suppliers + this.frm.add_custom_button(__('Get Suppliers'), + function() { + me.get_suppliers_button(me.frm); + }, __("Tools")); + } + }, + + calculate_taxes_and_totals: function() { + return; + }, + + tc_name: function() { + this.get_terms(); + }, + get_suppliers_button: function (frm) { var doc = frm.doc; var dialog = new frappe.ui.Dialog({ @@ -62,7 +320,7 @@ frappe.ui.form.on("Request for Quotation",{ { "fieldtype": "Select", "label": __("Get Suppliers By"), "fieldname": "search_type", - "options": ["Tag","Supplier Group"], + "options": ["Supplier Group", "Tag"], "reqd": 1, onchange() { if(dialog.get_value('search_type') == 'Tag'){ @@ -86,258 +344,74 @@ frappe.ui.form.on("Request for Quotation",{ "fieldname": "tag", "reqd": 0, "depends_on": "eval:doc.search_type == 'Tag'", - }, - { - "fieldtype": "Button", "label": __("Add All Suppliers"), - "fieldname": "add_suppliers" - }, - ] - }); - - dialog.fields_dict.add_suppliers.$input.click(function() { - var args = dialog.get_values(); - if(!args) return; - dialog.hide(); - - //Remove blanks - for (var j = 0; j < frm.doc.suppliers.length; j++) { - if(!frm.doc.suppliers[j].hasOwnProperty("supplier")) { - frm.get_field("suppliers").grid.grid_rows[j].remove(); } - } + ], + primary_action_label: __("Add Suppliers"), + primary_action : (args) => { + if(!args) return; + dialog.hide(); - function load_suppliers(r) { - if(r.message) { - for (var i = 0; i < r.message.length; i++) { - var exists = false; - if (r.message[i].constructor === Array){ - var supplier = r.message[i][0]; - } else { - var supplier = r.message[i].name; - } + //Remove blanks + for (var j = 0; j < frm.doc.suppliers.length; j++) { + if(!frm.doc.suppliers[j].hasOwnProperty("supplier")) { + frm.get_field("suppliers").grid.grid_rows[j].remove(); + } + } - for (var j = 0; j < doc.suppliers.length;j++) { - if (supplier === doc.suppliers[j].supplier) { - exists = true; + function load_suppliers(r) { + if(r.message) { + for (var i = 0; i < r.message.length; i++) { + var exists = false; + if (r.message[i].constructor === Array){ + var supplier = r.message[i][0]; + } else { + var supplier = r.message[i].name; + } + + for (var j = 0; j < doc.suppliers.length;j++) { + if (supplier === doc.suppliers[j].supplier) { + exists = true; + } + } + if(!exists) { + var d = frm.add_child('suppliers'); + d.supplier = supplier; + frm.script_manager.trigger("supplier", d.doctype, d.name); } } - if(!exists) { - var d = frm.add_child('suppliers'); - d.supplier = supplier; - frm.script_manager.trigger("supplier", d.doctype, d.name); - } } - } - frm.refresh_field("suppliers"); - } - - if (args.search_type === "Tag" && args.tag) { - return frappe.call({ - type: "GET", - method: "frappe.desk.doctype.tag.tag.get_tagged_docs", - args: { - "doctype": "Supplier", - "tag": args.tag - }, - callback: load_suppliers - }); - } else if (args.supplier_group) { - return frappe.call({ - method: "frappe.client.get_list", - args: { - doctype: "Supplier", - order_by: "name", - fields: ["name"], - filters: [["Supplier", "supplier_group", "=", args.supplier_group]] - - }, - callback: load_suppliers - }); - } - }); - dialog.show(); - - }, - make_suppplier_quotation: function(frm) { - var doc = frm.doc; - var dialog = new frappe.ui.Dialog({ - title: __("For Supplier"), - fields: [ - { "fieldtype": "Select", "label": __("Supplier"), - "fieldname": "supplier", - "options": doc.suppliers.map(d => d.supplier), - "reqd": 1, - "default": doc.suppliers.length === 1 ? doc.suppliers[0].supplier_name : "" }, - { "fieldtype": "Button", "label": __('Create Supplier Quotation'), - "fieldname": "make_supplier_quotation", "cssClass": "btn-primary" }, - ] - }); - - dialog.fields_dict.make_supplier_quotation.$input.click(function() { - var args = dialog.get_values(); - if(!args) return; - dialog.hide(); - return frappe.call({ - type: "GET", - method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation", - args: { - "source_name": doc.name, - "for_supplier": args.supplier - }, - freeze: true, - callback: function(r) { - if(!r.exc) { - var doc = frappe.model.sync(r.message); - frappe.set_route("Form", r.message.doctype, r.message.name); - } - } - }); - }); - dialog.show() - } -}) - -frappe.ui.form.on("Request for Quotation Supplier",{ - supplier: function(frm, cdt, cdn) { - var d = locals[cdt][cdn] - frappe.call({ - method:"erpnext.accounts.party.get_party_details", - args:{ - party: d.supplier, - party_type: 'Supplier' - }, - callback: function(r){ - if(r.message){ - frappe.model.set_value(cdt, cdn, 'contact', r.message.contact_person) - frappe.model.set_value(cdt, cdn, 'email_id', r.message.contact_email) - } - } - }) - }, - - download_pdf: function(frm, cdt, cdn) { - var child = locals[cdt][cdn] - - var w = window.open( - frappe.urllib.get_full_url("/api/method/erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_pdf?" - +"doctype="+encodeURIComponent(frm.doc.doctype) - +"&name="+encodeURIComponent(frm.doc.name) - +"&supplier_idx="+encodeURIComponent(child.idx) - +"&no_letterhead=0")); - if(!w) { - frappe.msgprint(__("Please enable pop-ups")); return; - } - }, - no_quote: function(frm, cdt, cdn) { - var d = locals[cdt][cdn]; - if (d.no_quote) { - if (d.quote_status != __('Received')) { - frappe.model.set_value(cdt, cdn, 'quote_status', 'No Quote'); - } else { - frappe.msgprint(__("Cannot set a received RFQ to No Quote")); - frappe.model.set_value(cdt, cdn, 'no_quote', 0); - } - } else { - d.quote_status = __('Pending'); - frm.call({ - method:"update_rfq_supplier_status", - doc: frm.doc, - args: { - sup_name: d.supplier - }, - callback: function(r) { frm.refresh_field("suppliers"); } - }); - } - } -}) -erpnext.buying.RequestforQuotationController = erpnext.buying.BuyingController.extend({ - refresh: function() { - var me = this; - this._super(); - if (this.frm.doc.docstatus===0) { - this.frm.add_custom_button(__('Material Request'), - function() { - erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_request_for_quotation", - source_doctype: "Material Request", - target: me.frm, - setters: { - company: me.frm.doc.company + if (args.search_type === "Tag" && args.tag) { + return frappe.call({ + type: "GET", + method: "frappe.desk.doctype.tag.tag.get_tagged_docs", + args: { + "doctype": "Supplier", + "tag": args.tag }, - get_query_filters: { - material_request_type: "Purchase", - docstatus: 1, - status: ["!=", "Stopped"], - per_ordered: ["<", 99.99] - } - }) - }, __("Get items from")); - // Get items from Opportunity - this.frm.add_custom_button(__('Opportunity'), - function() { - erpnext.utils.map_current_doc({ - method: "erpnext.crm.doctype.opportunity.opportunity.make_request_for_quotation", - source_doctype: "Opportunity", - target: me.frm, - setters: { - company: me.frm.doc.company + callback: load_suppliers + }); + } else if (args.supplier_group) { + return frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Supplier", + order_by: "name", + fields: ["name"], + filters: [["Supplier", "supplier_group", "=", args.supplier_group]] + }, - }) - }, __("Get items from")); - // Get items from open Material Requests based on supplier - this.frm.add_custom_button(__('Possible Supplier'), function() { - // Create a dialog window for the user to pick their supplier - var d = new frappe.ui.Dialog({ - title: __('Select Possible Supplier'), - fields: [ - {fieldname: 'supplier', fieldtype:'Link', options:'Supplier', label:'Supplier', reqd:1}, - {fieldname: 'ok_button', fieldtype:'Button', label:'Get Items from Material Requests'}, - ] - }); - - // On the user clicking the ok button - d.fields_dict.ok_button.input.onclick = function() { - var btn = d.fields_dict.ok_button.input; - var v = d.get_values(); - if(v) { - $(btn).set_working(); - - erpnext.utils.map_current_doc({ - method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_item_from_material_requests_based_on_supplier", - source_name: v.supplier, - target: me.frm, - setters: { - company: me.frm.doc.company - }, - get_query_filters: { - material_request_type: "Purchase", - docstatus: 1, - status: ["!=", "Stopped"], - per_ordered: ["<", 99.99] - } - }); - $(btn).done_working(); - d.hide(); - } + callback: load_suppliers + }); } - d.show(); - }, __("Get items from")); + } + }); - } + dialog.show(); }, - - calculate_taxes_and_totals: function() { - return; - }, - - tc_name: function() { - this.get_terms(); - } }); - // for backward compatibility: combine new and previous states $.extend(cur_frm.cscript, new erpnext.buying.RequestforQuotationController({frm: cur_frm})); 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 5cd8e6f4fa8..4ce4100a7fc 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -1,5 +1,5 @@ { - "actions": "", + "actions": [], "allow_import": 1, "autoname": "naming_series:", "creation": "2016-02-25 01:24:07.224790", @@ -12,25 +12,26 @@ "vendor", "column_break1", "transaction_date", + "status", + "amended_from", "suppliers_section", "suppliers", - "get_suppliers_button", "items_section", "items", - "link_to_mrs", "supplier_response_section", + "salutation", + "subject", + "col_break_email_1", "email_template", + "preview", + "sec_break_email_2", "message_for_supplier", "terms_section_break", "tc_name", "terms", "printing_settings", "select_print_heading", - "letter_head", - "more_info", - "status", - "column_break3", - "amended_from" + "letter_head" ], "fields": [ { @@ -78,6 +79,7 @@ "width": "50%" }, { + "default": "Today", "fieldname": "transaction_date", "fieldtype": "Date", "in_list_view": 1, @@ -94,16 +96,11 @@ { "fieldname": "suppliers", "fieldtype": "Table", - "label": "Supplier Detail", + "label": "Suppliers", "options": "Request for Quotation Supplier", "print_hide": 1, "reqd": 1 }, - { - "fieldname": "get_suppliers_button", - "fieldtype": "Button", - "label": "Get Suppliers" - }, { "fieldname": "items_section", "fieldtype": "Section Break", @@ -119,15 +116,10 @@ "options": "Request for Quotation Item", "reqd": 1 }, - { - "depends_on": "eval:doc.docstatus===0 && (doc.items && doc.items.length)", - "fieldname": "link_to_mrs", - "fieldtype": "Button", - "label": "Link to Material Requests" - }, { "fieldname": "supplier_response_section", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Email Details" }, { "fieldname": "email_template", @@ -137,6 +129,9 @@ "print_hide": 1 }, { + "allow_on_submit": 1, + "fetch_from": "email_template.response", + "fetch_if_empty": 1, "fieldname": "message_for_supplier", "fieldtype": "Text Editor", "in_list_view": 1, @@ -197,14 +192,6 @@ "options": "Letter Head", "print_hide": 1 }, - { - "collapsible": 1, - "fieldname": "more_info", - "fieldtype": "Section Break", - "label": "More Information", - "oldfieldtype": "Section Break", - "options": "fa fa-file-text" - }, { "fieldname": "status", "fieldtype": "Select", @@ -218,10 +205,6 @@ "reqd": 1, "search_index": 1 }, - { - "fieldname": "column_break3", - "fieldtype": "Column Break" - }, { "fieldname": "amended_from", "fieldtype": "Link", @@ -230,12 +213,46 @@ "options": "Request for Quotation", "print_hide": 1, "read_only": 1 + }, + { + "fetch_from": "email_template.subject", + "fetch_if_empty": 1, + "fieldname": "subject", + "fieldtype": "Data", + "label": "Subject", + "print_hide": 1 + }, + { + "description": "Select a greeting for the receiver. E.g. Mr., Ms., etc.", + "fieldname": "salutation", + "fieldtype": "Link", + "label": "Salutation", + "no_copy": 1, + "options": "Salutation", + "print_hide": 1 + }, + { + "fieldname": "col_break_email_1", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:!doc.docstatus==1", + "fieldname": "preview", + "fieldtype": "Button", + "label": "Preview Email" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "sec_break_email_2", + "fieldtype": "Section Break", + "hide_border": 1 } ], "icon": "fa fa-shopping-cart", + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-06-25 14:37:21.140194", + "modified": "2020-11-05 22:04:29.017134", "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 b54a585b97f..7cf22f87e4f 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -28,6 +28,10 @@ class RequestforQuotation(BuyingController): super(RequestforQuotation, self).set_qty_as_per_stock_uom() self.update_email_id() + if self.docstatus < 1: + # after amend and save, status still shows as cancelled, until submit + frappe.db.set(self, 'status', 'Draft') + def validate_duplicate_supplier(self): supplier_list = [d.supplier for d in self.suppliers] if len(supplier_list) != len(set(supplier_list)): @@ -51,7 +55,7 @@ class RequestforQuotation(BuyingController): def validate_email_id(self, args): if not args.email_id: - frappe.throw(_("Row {0}: For Supplier {0}, Email Address is Required to Send Email").format(args.idx, args.supplier)) + frappe.throw(_("Row {0}: For Supplier {1}, Email Address is Required to send an email").format(args.idx, frappe.bold(args.supplier))) def on_submit(self): frappe.db.set(self, 'status', 'Submitted') @@ -62,43 +66,58 @@ class RequestforQuotation(BuyingController): def on_cancel(self): frappe.db.set(self, 'status', 'Cancelled') + def get_supplier_email_preview(self, supplier): + """Returns formatted email preview as string.""" + rfq_suppliers = list(filter(lambda row: row.supplier == supplier, self.suppliers)) + rfq_supplier = rfq_suppliers[0] + + self.validate_email_id(rfq_supplier) + + message = self.supplier_rfq_mail(rfq_supplier, '', self.get_link(), True) + + return message + def send_to_supplier(self): + """Sends RFQ mail to involved suppliers.""" for rfq_supplier in self.suppliers: if rfq_supplier.send_email: self.validate_email_id(rfq_supplier) # make new user if required - update_password_link = self.update_supplier_contact(rfq_supplier, self.get_link()) + update_password_link, contact = self.update_supplier_contact(rfq_supplier, self.get_link()) - self.update_supplier_part_no(rfq_supplier) + self.update_supplier_part_no(rfq_supplier.supplier) self.supplier_rfq_mail(rfq_supplier, update_password_link, self.get_link()) rfq_supplier.email_sent = 1 + if not rfq_supplier.contact: + rfq_supplier.contact = contact rfq_supplier.save() def get_link(self): # RFQ link for supplier portal return get_url("/rfq/" + self.name) - def update_supplier_part_no(self, args): - self.vendor = args.supplier + def update_supplier_part_no(self, supplier): + self.vendor = supplier for item in self.items: item.supplier_part_no = frappe.db.get_value('Item Supplier', - {'parent': item.item_code, 'supplier': args.supplier}, 'supplier_part_no') + {'parent': item.item_code, 'supplier': supplier}, 'supplier_part_no') def update_supplier_contact(self, rfq_supplier, link): '''Create a new user for the supplier if not set in contact''' - update_password_link = '' + update_password_link, contact = '', '' if frappe.db.exists("User", rfq_supplier.email_id): user = frappe.get_doc("User", rfq_supplier.email_id) else: user, update_password_link = self.create_user(rfq_supplier, link) - self.update_contact_of_supplier(rfq_supplier, user) + contact = self.link_supplier_contact(rfq_supplier, user) - return update_password_link + return update_password_link, contact - def update_contact_of_supplier(self, rfq_supplier, user): + def link_supplier_contact(self, rfq_supplier, user): + """If no Contact, create a new contact against Supplier. If Contact exists, check if email and user id set.""" if rfq_supplier.contact: contact = frappe.get_doc("Contact", rfq_supplier.contact) else: @@ -108,6 +127,10 @@ class RequestforQuotation(BuyingController): 'link_doctype': 'Supplier', 'link_name': rfq_supplier.supplier }) + contact.append('email_ids', { + 'email_id': user.name, + 'is_primary': 1 + }) if not contact.email_id and not contact.user: contact.email_id = user.name @@ -115,6 +138,10 @@ class RequestforQuotation(BuyingController): contact.save(ignore_permissions=True) + if not rfq_supplier.contact: + # return contact to later update, RFQ supplier row's contact + return contact.name + def create_user(self, rfq_supplier, link): user = frappe.get_doc({ 'doctype': 'User', @@ -129,22 +156,36 @@ class RequestforQuotation(BuyingController): return user, update_password_link - def supplier_rfq_mail(self, data, update_password_link, rfq_link): + def supplier_rfq_mail(self, data, update_password_link, rfq_link, preview=False): full_name = get_user_fullname(frappe.session['user']) if full_name == "Guest": full_name = "Administrator" + # send document dict and some important data from suppliers row + # to render message_for_supplier from any template + doc_args = self.as_dict() + doc_args.update({ + 'supplier': data.get('supplier'), + 'supplier_name': data.get('supplier_name') + }) + args = { 'update_password_link': update_password_link, - 'message': frappe.render_template(self.message_for_supplier, data.as_dict()), + 'message': frappe.render_template(self.message_for_supplier, doc_args), 'rfq_link': rfq_link, - 'user_fullname': full_name + 'user_fullname': full_name, + 'supplier_name' : data.get('supplier_name'), + 'supplier_salutation' : self.salutation or 'Dear Mx.', } - subject = _("Request for Quotation") + subject = self.subject or _("Request for Quotation") template = "templates/emails/request_for_quotation.html" sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None message = frappe.get_template(template).render(args) + + if preview: + return message + attachments = self.get_attachments() self.send_email(data, sender, subject, message, attachments) @@ -164,23 +205,22 @@ class RequestforQuotation(BuyingController): def update_rfq_supplier_status(self, sup_name=None): for supplier in self.suppliers: if sup_name == None or supplier.supplier == sup_name: - if supplier.quote_status != _('No Quote'): - quote_status = _('Received') - for item in self.items: - sqi_count = frappe.db.sql(""" - SELECT - COUNT(sqi.name) as count - FROM - `tabSupplier Quotation Item` as sqi, - `tabSupplier Quotation` as sq - WHERE sq.supplier = %(supplier)s - AND sqi.docstatus = 1 - AND sqi.request_for_quotation_item = %(rqi)s - AND sqi.parent = sq.name""", - {"supplier": supplier.supplier, "rqi": item.name}, as_dict=1)[0] - if (sqi_count.count) == 0: - quote_status = _('Pending') - supplier.quote_status = quote_status + quote_status = _('Received') + for item in self.items: + sqi_count = frappe.db.sql(""" + SELECT + COUNT(sqi.name) as count + FROM + `tabSupplier Quotation Item` as sqi, + `tabSupplier Quotation` as sq + WHERE sq.supplier = %(supplier)s + AND sqi.docstatus = 1 + AND sqi.request_for_quotation_item = %(rqi)s + AND sqi.parent = sq.name""", + {"supplier": supplier.supplier, "rqi": item.name}, as_dict=1)[0] + if (sqi_count.count) == 0: + quote_status = _('Pending') + supplier.quote_status = quote_status @frappe.whitelist() @@ -214,14 +254,14 @@ def get_supplier_contacts(doctype, txt, searchfield, start, page_len, filters): and `tabDynamic Link`.link_name like %(txt)s) and `tabContact`.name = `tabDynamic Link`.parent limit %(start)s, %(page_len)s""", {"start": start, "page_len":page_len, "txt": "%%%s%%" % txt, "name": filters.get('supplier')}) -# This method is used to make supplier quotation from material request form. @frappe.whitelist() -def make_supplier_quotation(source_name, for_supplier, target_doc=None): +def make_supplier_quotation_from_rfq(source_name, target_doc=None, for_supplier=None): def postprocess(source, target_doc): - target_doc.supplier = for_supplier - args = get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True) - target_doc.currency = args.currency or get_party_account_currency('Supplier', for_supplier, source.company) - target_doc.buying_price_list = args.buying_price_list or frappe.db.get_value('Buying Settings', None, 'buying_price_list') + if for_supplier: + target_doc.supplier = for_supplier + args = get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True) + target_doc.currency = args.currency or get_party_account_currency('Supplier', for_supplier, source.company) + target_doc.buying_price_list = args.buying_price_list or frappe.db.get_value('Buying Settings', None, 'buying_price_list') set_missing_values(source, target_doc) doclist = get_mapped_doc("Request for Quotation", source_name, { @@ -289,16 +329,15 @@ def create_rfq_items(sq_doc, supplier, data): }) @frappe.whitelist() -def get_pdf(doctype, name, supplier_idx): - doc = get_rfq_doc(doctype, name, supplier_idx) +def get_pdf(doctype, name, supplier): + doc = get_rfq_doc(doctype, name, supplier) if doc: download_pdf(doctype, name, doc=doc) -def get_rfq_doc(doctype, name, supplier_idx): - if cint(supplier_idx): +def get_rfq_doc(doctype, name, supplier): + if supplier: doc = frappe.get_doc(doctype, name) - args = doc.get('suppliers')[cint(supplier_idx) - 1] - doc.update_supplier_part_no(args) + doc.update_supplier_part_no(supplier) return doc @frappe.whitelist() @@ -354,3 +393,32 @@ def get_supplier_tag(): frappe.cache().hset("Supplier", "Tags", tags) return frappe.cache().hget("Supplier", "Tags") + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_rfq_containing_supplier(doctype, txt, searchfield, start, page_len, filters): + conditions = "" + if txt: + conditions += "and rfq.name like '%%"+txt+"%%' " + + if filters.get("transaction_date"): + conditions += "and rfq.transaction_date = '{0}'".format(filters.get("transaction_date")) + + rfq_data = frappe.db.sql(""" + select + distinct rfq.name, rfq.transaction_date, + rfq.company + from + `tabRequest for Quotation` rfq, `tabRequest for Quotation Supplier` rfq_supplier + where + rfq.name = rfq_supplier.parent + and rfq_supplier.supplier = '{0}' + and rfq.docstatus = 1 + and rfq.company = '{1}' + {2} + order by rfq.transaction_date ASC + limit %(page_len)s offset %(start)s """ \ + .format(filters.get("supplier"), filters.get("company"), conditions), + {"page_len": page_len, "start": start}, as_dict=1) + + return rfq_data \ No newline at end of file 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 019cefc0bd1..36f87b0b841 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 @@ -9,7 +9,7 @@ import frappe from frappe.utils import nowdate from erpnext.stock.doctype.item.test_item import make_item from erpnext.templates.pages.rfq import check_supplier_has_docname_access -from erpnext.buying.doctype.request_for_quotation.request_for_quotation import make_supplier_quotation +from erpnext.buying.doctype.request_for_quotation.request_for_quotation import make_supplier_quotation_from_rfq from erpnext.buying.doctype.request_for_quotation.request_for_quotation import create_supplier_quotation from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq @@ -22,25 +22,21 @@ class TestRequestforQuotation(unittest.TestCase): self.assertEqual(rfq.get('suppliers')[1].quote_status, 'Pending') # Submit the first supplier quotation - sq = make_supplier_quotation(rfq.name, rfq.get('suppliers')[0].supplier) + sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get('suppliers')[0].supplier) sq.submit() - # No Quote first supplier quotation - rfq.get('suppliers')[1].no_quote = 1 - rfq.get('suppliers')[1].quote_status = 'No Quote' - rfq.update_rfq_supplier_status() #rfq.get('suppliers')[1].supplier) self.assertEqual(rfq.get('suppliers')[0].quote_status, 'Received') - self.assertEqual(rfq.get('suppliers')[1].quote_status, 'No Quote') + self.assertEqual(rfq.get('suppliers')[1].quote_status, 'Pending') def test_make_supplier_quotation(self): rfq = make_request_for_quotation() - sq = make_supplier_quotation(rfq.name, rfq.get('suppliers')[0].supplier) + sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get('suppliers')[0].supplier) sq.submit() - sq1 = make_supplier_quotation(rfq.name, rfq.get('suppliers')[1].supplier) + sq1 = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get('suppliers')[1].supplier) sq1.submit() self.assertEqual(sq.supplier, rfq.get('suppliers')[0].supplier) @@ -62,7 +58,7 @@ class TestRequestforQuotation(unittest.TestCase): rfq = make_request_for_quotation(supplier_data=supplier_wt_appos) - sq = make_supplier_quotation(rfq.name, supplier_wt_appos[0].get("supplier")) + sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=supplier_wt_appos[0].get("supplier")) sq.submit() frappe.form_dict = frappe.local("form_dict") diff --git a/erpnext/buying/doctype/request_for_quotation/tests/test_request_for_quotation_for_status.js b/erpnext/buying/doctype/request_for_quotation/tests/test_request_for_quotation_for_status.js index 1a9cd351dc7..2e1652de733 100644 --- a/erpnext/buying/doctype/request_for_quotation/tests/test_request_for_quotation_for_status.js +++ b/erpnext/buying/doctype/request_for_quotation/tests/test_request_for_quotation_for_status.js @@ -84,9 +84,6 @@ QUnit.test("Test: Request for Quotation", function (assert) { cur_frm.fields_dict.suppliers.grid.grid_rows[0].toggle_view(); }, () => frappe.timeout(1), - () => { - frappe.click_check('No Quote'); - }, () => frappe.timeout(1), () => { cur_frm.cur_grid.toggle_view(); @@ -125,7 +122,6 @@ QUnit.test("Test: Request for Quotation", function (assert) { () => frappe.timeout(1), () => { assert.ok(cur_frm.fields_dict.suppliers.grid.grid_rows[1].doc.quote_status == "Received"); - assert.ok(cur_frm.fields_dict.suppliers.grid.grid_rows[0].doc.no_quote == 1); }, () => done() ]); diff --git a/erpnext/buying/doctype/request_for_quotation_item/request_for_quotation_item.json b/erpnext/buying/doctype/request_for_quotation_item/request_for_quotation_item.json index 408f49f5233..e07f4626b8f 100644 --- a/erpnext/buying/doctype/request_for_quotation_item/request_for_quotation_item.json +++ b/erpnext/buying/doctype/request_for_quotation_item/request_for_quotation_item.json @@ -27,10 +27,11 @@ "stock_qty", "warehouse_and_reference", "warehouse", - "project_name", "col_break4", "material_request", "material_request_item", + "section_break_24", + "project_name", "section_break_23", "page_break" ], @@ -161,7 +162,7 @@ { "fieldname": "project_name", "fieldtype": "Link", - "label": "Project Name", + "label": "Project", "options": "Project", "print_hide": 1 }, @@ -249,11 +250,18 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_24", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-06-12 19:10:36.333441", + "modified": "2020-09-24 17:26:46.276934", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation Item", diff --git a/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json b/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json index 61ad336574d..534cd90cc65 100644 --- a/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json +++ b/erpnext/buying/doctype/request_for_quotation_supplier/request_for_quotation_supplier.json @@ -1,362 +1,99 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-03-29 05:59:11.896885", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2016-03-29 05:59:11.896885", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "supplier", + "contact", + "quote_status", + "column_break_3", + "supplier_name", + "email_id", + "send_email", + "email_sent" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "1", - "fieldname": "send_email", - "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": "Send Email", - "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_on_submit": 1, + "columns": 2, + "default": "1", + "fieldname": "send_email", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Send Email" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "0", - "depends_on": "eval:doc.docstatus >= 1", - "fieldname": "email_sent", - "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": "Email Sent", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "default": "0", + "depends_on": "eval:doc.docstatus >= 1", + "fieldname": "email_sent", + "fieldtype": "Check", + "label": "Email Sent", + "no_copy": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 4, - "fieldname": "supplier", - "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": "Supplier", - "length": 0, - "no_copy": 0, - "options": "Supplier", - "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 - }, + "columns": 2, + "fieldname": "supplier", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Supplier", + "options": "Supplier", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 3, - "fieldname": "contact", - "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": "Contact", - "length": 0, - "no_copy": 1, - "options": "Contact", - "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_on_submit": 1, + "columns": 2, + "fieldname": "contact", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Contact", + "no_copy": 1, + "options": "Contact" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.docstatus >= 1 && doc.quote_status != 'Received'", - "fieldname": "no_quote", - "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": "No Quote", - "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_on_submit": 1, + "depends_on": "eval:doc.docstatus >= 1", + "fieldname": "quote_status", + "fieldtype": "Select", + "label": "Quote Status", + "options": "Pending\nReceived", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.docstatus >= 1 && !doc.no_quote", - "fieldname": "quote_status", - "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": "Quote Status", - "length": 0, - "no_copy": 0, - "options": "Pending\nReceived\nNo Quote", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_3", - "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": 1, - "collapsible": 0, - "columns": 0, + "bold": 1, "fetch_from": "supplier.supplier_name", - "fieldname": "supplier_name", - "fieldtype": "Read Only", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Supplier 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 - }, + "fieldname": "supplier_name", + "fieldtype": "Read Only", + "in_global_search": 1, + "label": "Supplier Name" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 3, + "columns": 3, "fetch_from": "contact.email_id", - "fieldname": "email_id", - "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": "Email Id", - "length": 0, - "no_copy": 1, - "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": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "download_pdf", - "fieldtype": "Button", - "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": "Download PDF", - "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 + "fieldname": "email_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Email Id", + "no_copy": 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": 1, - "max_attachments": 0, - "modified": "2018-05-16 22:43:30.212408", - "modified_by": "Administrator", - "module": "Buying", - "name": "Request for Quotation Supplier", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-11-04 22:01:43.832942", + "modified_by": "Administrator", + "module": "Buying", + "name": "Request for Quotation Supplier", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json index 40362b1d404..4cc5753cbd0 100644 --- a/erpnext/buying/doctype/supplier/supplier.json +++ b/erpnext/buying/doctype/supplier/supplier.json @@ -26,7 +26,6 @@ "supplier_group", "supplier_type", "pan", - "language", "allow_purchase_invoice_creation_without_purchase_order", "allow_purchase_invoice_creation_without_purchase_receipt", "disabled", @@ -57,6 +56,7 @@ "website", "supplier_details", "column_break_30", + "language", "is_frozen" ], "fields": [ @@ -384,7 +384,7 @@ "idx": 370, "image_field": "image", "links": [], - "modified": "2020-06-17 23:18:20", + "modified": "2021-01-06 19:51:40.939087", "modified_by": "Administrator", "module": "Buying", "name": "Supplier", diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index df143eefa0d..edeb135d951 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -49,6 +49,15 @@ class Supplier(TransactionBase): msgprint(_("Series is mandatory"), raise_exception=1) validate_party_accounts(self) + self.validate_internal_supplier() + + def validate_internal_supplier(self): + internal_supplier = frappe.db.get_value("Supplier", + {"is_internal_supplier": 1, "represents_company": self.represents_company, "name": ("!=", self.name)}, "name") + + if internal_supplier: + frappe.throw(_("Internal Supplier for company {0} already exists").format( + frappe.bold(self.represents_company))) def on_trash(self): delete_contact_and_address('Supplier', self.name) diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index a377ec90f8b..f9c8d35518d 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -120,3 +120,20 @@ class TestSupplier(unittest.TestCase): # Rollback address.delete() + +def create_supplier(**args): + args = frappe._dict(args) + + try: + doc = frappe.get_doc({ + "doctype": "Supplier", + "supplier_name": args.supplier_name, + "supplier_group": args.supplier_group or "Services", + "supplier_type": args.supplier_type or "Company", + "tax_withholding_category": args.tax_withholding_category + }).insert() + + return doc + + except frappe.DuplicateEntryError: + return frappe.get_doc("Supplier", args.supplier_name) \ No newline at end of file diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index 1b8b40459f6..a0187b0a824 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -8,8 +8,7 @@ erpnext.buying.SupplierQuotationController = erpnext.buying.BuyingController.ext setup: function() { this.frm.custom_make_buttons = { 'Purchase Order': 'Purchase Order', - 'Quotation': 'Quotation', - 'Subscription': 'Subscription' + 'Quotation': 'Quotation' } this._super(); @@ -28,12 +27,6 @@ erpnext.buying.SupplierQuotationController = erpnext.buying.BuyingController.ext cur_frm.page.set_inner_btn_group_as_primary(__('Create')); cur_frm.add_custom_button(__("Quotation"), this.make_quotation, __('Create')); - - if(!this.frm.doc.auto_repeat) { - cur_frm.add_custom_button(__('Subscription'), function() { - erpnext.utils.make_subscription(me.frm.doc.doctype, me.frm.doc.name) - }, __('Create')) - } } else if (this.frm.doc.docstatus===0) { @@ -44,16 +37,45 @@ erpnext.buying.SupplierQuotationController = erpnext.buying.BuyingController.ext source_doctype: "Material Request", target: me.frm, setters: { - company: me.frm.doc.company + schedule_date: undefined, + status: undefined }, get_query_filters: { material_request_type: "Purchase", docstatus: 1, status: ["!=", "Stopped"], - per_ordered: ["<", 99.99] + per_ordered: ["<", 100], + company: me.frm.doc.company } }) - }, __("Get items from")); + }, __("Get Items From")); + + // Link Material Requests + this.frm.add_custom_button(__('Link to Material Requests'), + function() { + erpnext.buying.link_to_mrs(me.frm); + }, __("Tools")); + + this.frm.add_custom_button(__("Request for Quotation"), + function() { + if (!me.frm.doc.supplier) { + frappe.throw({message:__("Please select a Supplier"), title:__("Mandatory")}) + } + erpnext.utils.map_current_doc({ + method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq", + source_doctype: "Request for Quotation", + target: me.frm, + setters: { + transaction_date: null + }, + get_query_filters: { + supplier: me.frm.doc.supplier, + company: me.frm.doc.company + }, + get_query_method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_rfq_containing_supplier" + + }) + }, __("Get Items From")); } }, diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index 660dcff34bc..40fbe2c26e5 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_auto_repeat": 1, "allow_import": 1, "autoname": "naming_series:", "creation": "2013-05-21 16:16:45", @@ -34,7 +35,6 @@ "ignore_pricing_rule", "items_section", "items", - "link_to_mrs", "pricing_rule_details", "pricing_rules", "section_break_22", @@ -159,6 +159,7 @@ "default": "Today", "fieldname": "transaction_date", "fieldtype": "Date", + "in_list_view": 1, "label": "Date", "oldfieldname": "transaction_date", "oldfieldtype": "Date", @@ -320,12 +321,6 @@ "options": "Supplier Quotation Item", "reqd": 1 }, - { - "depends_on": "eval:doc.docstatus===0 && (doc.items && doc.items.length)", - "fieldname": "link_to_mrs", - "fieldtype": "Button", - "label": "Link to material requests" - }, { "fieldname": "pricing_rule_details", "fieldtype": "Section Break", @@ -798,14 +793,16 @@ { "fieldname": "valid_till", "fieldtype": "Date", + "in_list_view": 1, "label": "Valid Till" } ], "icon": "fa fa-shopping-cart", "idx": 29, + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-07-18 05:10:45.556792", + "modified": "2020-12-03 15:18:29.073368", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index baf245735a4..6a4c02c075c 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -71,7 +71,7 @@ class SupplierQuotation(BuyingController): doc_sup = doc_sup[0] if doc_sup else None if not doc_sup: frappe.throw(_("Supplier {0} not found in {1}").format(self.supplier, - " Request for Quotation {0} ".format(doc.name))) + " Request for Quotation {0} ".format(doc.name))) quote_status = _('Received') for item in doc.items: @@ -91,12 +91,7 @@ class SupplierQuotation(BuyingController): for my_item in self.items) if include_me else 0 if (sqi_count.count + self_count) == 0: quote_status = _('Pending') - if quote_status == _('Received') and doc_sup.quote_status == _('No Quote'): - frappe.msgprint(_("{0} indicates that {1} will not provide a quotation, but all items \ - have been quoted. Updating the RFQ quote status.").format(doc.name, self.supplier)) - frappe.db.set_value('Request for Quotation Supplier', doc_sup.name, 'quote_status', quote_status) - frappe.db.set_value('Request for Quotation Supplier', doc_sup.name, 'no_quote', 0) - elif doc_sup.quote_status != _('No Quote'): + frappe.db.set_value('Request for Quotation Supplier', doc_sup.name, 'quote_status', quote_status) def get_list_context(context=None): diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js index 9f4fecea86b..5ab6c980d00 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js @@ -4,9 +4,9 @@ frappe.listview_settings['Supplier Quotation'] = { if(doc.status==="Ordered") { return [__("Ordered"), "green", "status,=,Ordered"]; } else if(doc.status==="Rejected") { - return [__("Lost"), "darkgrey", "status,=,Lost"]; + return [__("Lost"), "gray", "status,=,Lost"]; } else if(doc.status==="Expired") { - return [__("Expired"), "darkgrey", "status,=,Expired"]; + return [__("Expired"), "gray", "status,=,Expired"]; } } }; diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json index b50e834ec73..638cde01be5 100644 --- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json +++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json @@ -12,6 +12,8 @@ "item_name", "column_break_3", "lead_time_days", + "expected_delivery_date", + "is_free_item", "section_break_5", "description", "item_group", @@ -19,20 +21,18 @@ "col_break1", "image", "image_view", - "manufacture_details", - "manufacturer", - "column_break_15", - "manufacturer_part_no", "quantity_and_rate", "qty", "stock_uom", - "price_list_rate", - "discount_percentage", - "discount_amount", "col_break2", "uom", "conversion_factor", "stock_qty", + "sec_break_price_list", + "price_list_rate", + "discount_percentage", + "discount_amount", + "col_break_price_list", "base_price_list_rate", "sec_break1", "rate", @@ -42,7 +42,6 @@ "base_rate", "base_amount", "pricing_rules", - "is_free_item", "section_break_24", "net_rate", "net_amount", @@ -56,7 +55,6 @@ "weight_uom", "warehouse_and_reference", "warehouse", - "project", "prevdoc_doctype", "material_request", "sales_order", @@ -65,13 +63,19 @@ "material_request_item", "request_for_quotation_item", "item_tax_rate", + "manufacture_details", + "manufacturer", + "column_break_15", + "manufacturer_part_no", + "ad_sec_break", + "project", "section_break_44", "page_break" ], "fields": [ { "bold": 1, - "columns": 4, + "columns": 2, "fieldname": "item_code", "fieldtype": "Link", "in_list_view": 1, @@ -107,7 +111,7 @@ { "fieldname": "lead_time_days", "fieldtype": "Int", - "label": "Lead Time in days" + "label": "Supplier Lead Time (days)" }, { "collapsible": 1, @@ -162,7 +166,6 @@ { "fieldname": "stock_uom", "fieldtype": "Link", - "in_list_view": 1, "label": "Stock UOM", "options": "UOM", "print_hide": 1, @@ -196,6 +199,7 @@ { "fieldname": "uom", "fieldtype": "Link", + "in_list_view": 1, "label": "UOM", "options": "UOM", "print_hide": 1, @@ -237,7 +241,7 @@ "fieldname": "rate", "fieldtype": "Currency", "in_list_view": 1, - "label": "Rate ", + "label": "Rate", "oldfieldname": "import_rate", "oldfieldtype": "Currency", "options": "currency" @@ -289,14 +293,6 @@ "print_hide": 1, "read_only": 1 }, - { - "default": "0", - "fieldname": "is_free_item", - "fieldtype": "Check", - "label": "Is Free Item", - "print_hide": 1, - "read_only": 1 - }, { "fieldname": "section_break_24", "fieldtype": "Section Break" @@ -528,12 +524,43 @@ { "fieldname": "column_break_15", "fieldtype": "Column Break" + }, + { + "fieldname": "sec_break_price_list", + "fieldtype": "Section Break" + }, + { + "fieldname": "col_break_price_list", + "fieldtype": "Column Break" + }, + { + "collapsible": 1, + "fieldname": "ad_sec_break", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "default": "0", + "depends_on": "is_free_item", + "fieldname": "is_free_item", + "fieldtype": "Check", + "label": "Is Free Item", + "print_hide": 1, + "read_only": 1 + }, + { + "allow_on_submit": 1, + "bold": 1, + "fieldname": "expected_delivery_date", + "fieldtype": "Date", + "label": "Expected Delivery Date" } ], "idx": 1, + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-04-07 18:35:51.175947", + "modified": "2020-10-19 12:36:26.913211", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation Item", diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py index f24e5be0768..64dda879450 100644 --- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py +++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py @@ -6,8 +6,5 @@ import frappe from frappe.model.document import Document -from erpnext.controllers.print_settings import print_settings_for_item_table - class SupplierQuotationItem(Document): - def __setup__(self): - print_settings_for_item_table(self) + pass diff --git a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard_list.js b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard_list.js index c50916e4fae..dc5474e3b43 100644 --- a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard_list.js +++ b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard_list.js @@ -10,7 +10,7 @@ frappe.listview_settings["Supplier Scorecard"] = { if (doc.indicator_color) { return [__(doc.status), doc.indicator_color.toLowerCase(), "status,=," + doc.status]; } else { - return [__("Unknown"), "darkgrey", "status,=,''"]; + return [__("Unknown"), "gray", "status,=,''"]; } }, diff --git a/erpnext/buying/report/procurement_tracker/procurement_tracker.py b/erpnext/buying/report/procurement_tracker/procurement_tracker.py index 88a865f0f85..beeca091c8a 100644 --- a/erpnext/buying/report/procurement_tracker/procurement_tracker.py +++ b/erpnext/buying/report/procurement_tracker/procurement_tracker.py @@ -143,7 +143,7 @@ def get_conditions(filters): conditions = "" if filters.get("company"): - conditions += " AND par.company=%s" % frappe.db.escape(filters.get('company')) + conditions += " AND parent.company=%s" % frappe.db.escape(filters.get('company')) if filters.get("cost_center") or filters.get("project"): conditions += """ @@ -151,10 +151,10 @@ def get_conditions(filters): """ % (frappe.db.escape(filters.get('cost_center')), frappe.db.escape(filters.get('project'))) if filters.get("from_date"): - conditions += " AND par.transaction_date>='%s'" % filters.get('from_date') + conditions += " AND parent.transaction_date>='%s'" % filters.get('from_date') if filters.get("to_date"): - conditions += " AND par.transaction_date<='%s'" % filters.get('to_date') + conditions += " AND parent.transaction_date<='%s'" % filters.get('to_date') return conditions def get_data(filters): @@ -198,21 +198,23 @@ def get_mapped_mr_details(conditions): mr_records = {} mr_details = frappe.db.sql(""" SELECT - par.transaction_date, - par.per_ordered, - par.owner, + parent.transaction_date, + parent.per_ordered, + parent.owner, child.name, child.parent, child.amount, child.qty, child.item_code, child.uom, - par.status - FROM `tabMaterial Request` par, `tabMaterial Request Item` child + parent.status, + child.project, + child.cost_center + FROM `tabMaterial Request` parent, `tabMaterial Request Item` child WHERE - par.per_ordered>=0 - AND par.name=child.parent - AND par.docstatus=1 + parent.per_ordered>=0 + AND parent.name=child.parent + AND parent.docstatus=1 {conditions} """.format(conditions=conditions), as_dict=1) #nosec @@ -232,7 +234,9 @@ def get_mapped_mr_details(conditions): status=record.status, actual_cost=0, purchase_order_amt=0, - purchase_order_amt_in_company_currency=0 + purchase_order_amt_in_company_currency=0, + project = record.project, + cost_center = record.cost_center ) procurement_record_against_mr.append(procurement_record_details) return mr_records, procurement_record_against_mr @@ -280,16 +284,16 @@ def get_po_entries(conditions): child.amount, child.base_amount, child.schedule_date, - par.transaction_date, - par.supplier, - par.status, - par.owner - FROM `tabPurchase Order` par, `tabPurchase Order Item` child + parent.transaction_date, + parent.supplier, + parent.status, + parent.owner + FROM `tabPurchase Order` parent, `tabPurchase Order Item` child WHERE - par.docstatus = 1 - AND par.name = child.parent - AND par.status not in ("Closed","Completed","Cancelled") + parent.docstatus = 1 + AND parent.name = child.parent + AND parent.status not in ("Closed","Completed","Cancelled") {conditions} GROUP BY - par.name, child.item_code + parent.name, child.item_code """.format(conditions=conditions), as_dict=1) #nosec \ No newline at end of file diff --git a/erpnext/buying/report/purchase_analytics/purchase_analytics.js b/erpnext/buying/report/purchase_analytics/purchase_analytics.js index e17973c337b..ba8535a3ae4 100644 --- a/erpnext/buying/report/purchase_analytics/purchase_analytics.js +++ b/erpnext/buying/report/purchase_analytics/purchase_analytics.js @@ -75,62 +75,70 @@ frappe.query_reports["Purchase Analytics"] = { return Object.assign(options, { checkboxColumn: true, events: { - onCheckRow: function(data) { + onCheckRow: function (data) { + if (!data) return; + + const data_doctype = $( + data[2].html + )[0].attributes.getNamedItem("data-doctype").value; + const tree_type = frappe.query_report.filters[0].value; + if (data_doctype != tree_type) return; + row_name = data[2].content; length = data.length; - var tree_type = frappe.query_report.filters[0].value; - - if(tree_type == "Supplier" || tree_type == "Item") { - row_values = data.slice(4,length-1).map(function (column) { - return column.content; - }) - } - else { - row_values = data.slice(3,length-1).map(function (column) { - return column.content; - }) + if (tree_type == "Supplier") { + row_values = data + .slice(4, length - 1) + .map(function (column) { + return column.content; + }); + } else if (tree_type == "Item") { + row_values = data + .slice(5, length - 1) + .map(function (column) { + return column.content; + }); + } else { + row_values = data + .slice(3, length - 1) + .map(function (column) { + return column.content; + }); } - entry = { - 'name':row_name, - 'values':row_values - } + entry = { + name: row_name, + values: row_values, + }; let raw_data = frappe.query_report.chart.data; let new_datasets = raw_data.datasets; - var found = false; - - for(var i=0; i < new_datasets.length;i++){ - if(new_datasets[i].name == row_name){ - found = true; - new_datasets.splice(i,1); - break; + let element_found = new_datasets.some((element, index, array)=>{ + if(element.name == row_name){ + array.splice(index, 1) + return true } - } + return false + }) - if(!found){ + if (!element_found) { new_datasets.push(entry); } - let new_data = { labels: raw_data.labels, - datasets: new_datasets - } - - setTimeout(() => { - frappe.query_report.chart.update(new_data) - },500) - - - setTimeout(() => { - frappe.query_report.chart.draw(true); - }, 1000) + datasets: new_datasets, + }; + chart_options = { + data: new_data, + type: "line", + }; + frappe.query_report.render_chart(chart_options); frappe.query_report.raw_chart_data = new_data; }, - } + }, }); } } diff --git a/erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.json b/erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.json deleted file mode 100644 index 23b3ace49c8..00000000000 --- a/erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "add_total_row": 0, - "apply_user_permissions": 1, - "creation": "2016-07-21 08:31:05.890362", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 2, - "is_standard": "Yes", - "modified": "2017-02-24 20:04:58.784351", - "modified_by": "Administrator", - "module": "Buying", - "name": "Quoted Item Comparison", - "owner": "Administrator", - "ref_doctype": "Supplier Quotation", - "report_name": "Quoted Item Comparison", - "report_type": "Script Report", - "roles": [ - { - "role": "Manufacturing Manager" - }, - { - "role": "Purchase Manager" - }, - { - "role": "Purchase User" - }, - { - "role": "Stock User" - } - ] -} \ No newline at end of file diff --git a/erpnext/accounts/page/bank_reconciliation/__init__.py b/erpnext/buying/report/supplier_quotation_comparison/__init__.py similarity index 100% rename from erpnext/accounts/page/bank_reconciliation/__init__.py rename to erpnext/buying/report/supplier_quotation_comparison/__init__.py diff --git a/erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.html b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.html similarity index 100% rename from erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.html rename to erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.html diff --git a/erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.js b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js similarity index 91% rename from erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.js rename to erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js index 518d665e7ef..80e521a8bfa 100644 --- a/erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.js +++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.js @@ -1,7 +1,7 @@ // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -frappe.query_reports["Quoted Item Comparison"] = { +frappe.query_reports["Supplier Quotation Comparison"] = { filters: [ { fieldtype: "Link", @@ -78,6 +78,13 @@ frappe.query_reports["Quoted Item Comparison"] = { return { filters: { "docstatus": ["<", 2] } } } }, + { + "fieldname":"group_by", + "label": __("Group by"), + "fieldtype": "Select", + "options": [__("Group by Supplier"), __("Group by Item")], + "default": __("Group by Supplier") + }, { fieldtype: "Check", label: __("Include Expired"), @@ -98,6 +105,9 @@ frappe.query_reports["Quoted Item Comparison"] = { } } + if(column.fieldname === "price_per_unit" && data.price_per_unit && data.min && data.min === 1){ + value = `
    ${value}
    `; + } return value; }, diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.json b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.json new file mode 100644 index 00000000000..886e5b8757b --- /dev/null +++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.json @@ -0,0 +1,32 @@ +{ + "add_total_row": 0, + "apply_user_permissions": 1, + "creation": "2016-07-21 08:31:05.890362", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "idx": 2, + "is_standard": "Yes", + "modified": "2017-02-24 20:04:58.784351", + "modified_by": "Administrator", + "module": "Buying", + "name": "Supplier Quotation Comparison", + "owner": "Administrator", + "ref_doctype": "Supplier Quotation", + "report_name": "Supplier Quotation Comparison", + "report_type": "Script Report", + "roles": [ + { + "role": "Manufacturing Manager" + }, + { + "role": "Purchase Manager" + }, + { + "role": "Purchase User" + }, + { + "role": "Stock User" + } + ] +} \ No newline at end of file diff --git a/erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.py b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py similarity index 68% rename from erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.py rename to erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py index 4426560c162..2b371915f32 100644 --- a/erpnext/buying/report/quoted_item_comparison/quoted_item_comparison.py +++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py @@ -12,9 +12,9 @@ def execute(filters=None): if not filters: return [], [] + columns = get_columns(filters) conditions = get_conditions(filters) supplier_quotation_data = get_data(filters, conditions) - columns = get_columns() data, chart_data = prepare_data(supplier_quotation_data, filters) message = get_message() @@ -41,9 +41,13 @@ def get_conditions(filters): return conditions def get_data(filters, conditions): - supplier_quotation_data = frappe.db.sql("""SELECT - sqi.parent, sqi.item_code, sqi.qty, sqi.rate, sqi.uom, sqi.request_for_quotation, - sqi.lead_time_days, sq.supplier, sq.valid_till + supplier_quotation_data = frappe.db.sql(""" + SELECT + sqi.parent, sqi.item_code, + sqi.qty, sqi.stock_qty, sqi.amount, + sqi.uom, sqi.stock_uom, + sqi.request_for_quotation, + sqi.lead_time_days, sq.supplier as supplier_name, sq.valid_till FROM `tabSupplier Quotation Item` sqi, `tabSupplier Quotation` sq @@ -58,16 +62,18 @@ def get_data(filters, conditions): return supplier_quotation_data def prepare_data(supplier_quotation_data, filters): - out, suppliers, qty_list, chart_data = [], [], [], [] - supplier_wise_map = defaultdict(list) + out, groups, qty_list, suppliers, chart_data = [], [], [], [], [] + group_wise_map = defaultdict(list) supplier_qty_price_map = {} + group_by_field = "supplier_name" if filters.get("group_by") == "Group by Supplier" else "item_code" company_currency = frappe.db.get_default("currency") float_precision = cint(frappe.db.get_default("float_precision")) or 2 for data in supplier_quotation_data: - supplier = data.get("supplier") - supplier_currency = frappe.db.get_value("Supplier", data.get("supplier"), "default_currency") + group = data.get(group_by_field) # get item or supplier value for this row + + supplier_currency = frappe.db.get_value("Supplier", data.get("supplier_name"), "default_currency") if supplier_currency: exchange_rate = get_exchange_rate(supplier_currency, company_currency) @@ -75,38 +81,55 @@ def prepare_data(supplier_quotation_data, filters): exchange_rate = 1 row = { - "item_code": data.get('item_code'), + "item_code": "" if group_by_field=="item_code" else data.get("item_code"), # leave blank if group by field + "supplier_name": "" if group_by_field=="supplier_name" else data.get("supplier_name"), "quotation": data.get("parent"), "qty": data.get("qty"), - "price": flt(data.get("rate") * exchange_rate, float_precision), + "price": flt(data.get("amount") * exchange_rate, float_precision), "uom": data.get("uom"), + "stock_uom": data.get('stock_uom'), "request_for_quotation": data.get("request_for_quotation"), "valid_till": data.get('valid_till'), "lead_time_days": data.get('lead_time_days') } + row["price_per_unit"] = flt(row["price"]) / (flt(data.get("stock_qty")) or 1) - # map for report view of form {'supplier1':[{},{},...]} - supplier_wise_map[supplier].append(row) + # map for report view of form {'supplier1'/'item1':[{},{},...]} + group_wise_map[group].append(row) # map for chart preparation of the form {'supplier1': {'qty': 'price'}} + supplier = data.get("supplier_name") if filters.get("item_code"): if not supplier in supplier_qty_price_map: supplier_qty_price_map[supplier] = {} supplier_qty_price_map[supplier][row["qty"]] = row["price"] + groups.append(group) suppliers.append(supplier) qty_list.append(data.get("qty")) + groups = list(set(groups)) suppliers = list(set(suppliers)) qty_list = list(set(qty_list)) + highlight_min_price = group_by_field == "item_code" or filters.get("item_code") + # final data format for report view - for supplier in suppliers: - supplier_wise_map[supplier][0].update({"supplier_name": supplier}) - for entry in supplier_wise_map[supplier]: + for group in groups: + group_entries = group_wise_map[group] # all entries pertaining to item/supplier + group_entries[0].update({group_by_field : group}) # Add item/supplier name in first group row + + if highlight_min_price: + prices = [group_entry["price_per_unit"] for group_entry in group_entries] + min_price = min(prices) + + for entry in group_entries: + if highlight_min_price and entry["price_per_unit"] == min_price: + entry["min"] = 1 out.append(entry) if filters.get("item_code"): + # render chart only for one item comparison chart_data = prepare_chart_data(suppliers, qty_list, supplier_qty_price_map) return out, chart_data @@ -145,8 +168,9 @@ def prepare_chart_data(suppliers, qty_list, supplier_qty_price_map): return chart_data -def get_columns(): - columns = [{ +def get_columns(filters): + group_by_columns = [ + { "fieldname": "supplier_name", "label": _("Supplier"), "fieldtype": "Link", @@ -158,8 +182,10 @@ def get_columns(): "label": _("Item"), "fieldtype": "Link", "options": "Item", - "width": 200 - }, + "width": 150 + }] + + columns = [ { "fieldname": "uom", "label": _("UOM"), @@ -180,6 +206,20 @@ def get_columns(): "options": "Company:company:default_currency", "width": 110 }, + { + "fieldname": "stock_uom", + "label": _("Stock UOM"), + "fieldtype": "Link", + "options": "UOM", + "width": 90 + }, + { + "fieldname": "price_per_unit", + "label": _("Price per Unit (Stock UOM)"), + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "width": 120 + }, { "fieldname": "quotation", "label": _("Supplier Quotation"), @@ -205,9 +245,12 @@ def get_columns(): "fieldtype": "Link", "options": "Request for Quotation", "width": 150 - } - ] + }] + if filters.get("group_by") == "Group by Item": + group_by_columns.reverse() + + columns[0:0] = group_by_columns # add positioned group by columns to the report return columns def get_message(): diff --git a/erpnext/buying/utils.py b/erpnext/buying/utils.py index 47b48665b60..a73cb0d62ec 100644 --- a/erpnext/buying/utils.py +++ b/erpnext/buying/utils.py @@ -35,9 +35,10 @@ def update_last_purchase_rate(doc, is_submit): frappe.throw(_("UOM Conversion factor is required in row {0}").format(d.idx)) # update last purchsae rate - if last_purchase_rate: - frappe.db.sql("""update `tabItem` set last_purchase_rate = %s where name = %s""", - (flt(last_purchase_rate), d.item_code)) + frappe.db.set_value('Item', d.item_code, 'last_purchase_rate', flt(last_purchase_rate)) + + + def validate_for_items(doc): items = [] diff --git a/erpnext/buying/workspace/buying/buying.json b/erpnext/buying/workspace/buying/buying.json new file mode 100644 index 00000000000..6c9c0f3011b --- /dev/null +++ b/erpnext/buying/workspace/buying/buying.json @@ -0,0 +1,520 @@ +{ + "cards_label": "", + "category": "Modules", + "charts": [ + { + "chart_name": "Purchase Order Trends", + "label": "Purchase Order Trends" + } + ], + "charts_label": "", + "creation": "2020-01-28 11:50:26.195467", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "buying", + "idx": 0, + "is_standard": 1, + "label": "Buying", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Buying", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Material Request", + "link_to": "Material Request", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Supplier", + "hidden": 0, + "is_query_report": 0, + "label": "Purchase Order", + "link_to": "Purchase Order", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Supplier", + "hidden": 0, + "is_query_report": 0, + "label": "Purchase Invoice", + "link_to": "Purchase Invoice", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Supplier", + "hidden": 0, + "is_query_report": 0, + "label": "Request for Quotation", + "link_to": "Request for Quotation", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Supplier", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier Quotation", + "link_to": "Supplier Quotation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Items & Pricing", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item", + "link_to": "Item", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Price", + "link_to": "Item Price", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Price List", + "link_to": "Price List", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Product Bundle", + "link_to": "Product Bundle", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Group", + "link_to": "Item Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Promotional Scheme", + "link_to": "Promotional Scheme", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Pricing Rule", + "link_to": "Pricing Rule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Buying Settings", + "link_to": "Buying Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Purchase Taxes and Charges Template", + "link_to": "Purchase Taxes and Charges Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Terms and Conditions Template", + "link_to": "Terms and Conditions", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Supplier", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier", + "link_to": "Supplier", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier Group", + "link_to": "Supplier Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Contact", + "link_to": "Contact", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Address", + "link_to": "Address", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Supplier Scorecard", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier Scorecard", + "link_to": "Supplier Scorecard", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier Scorecard Variable", + "link_to": "Supplier Scorecard Variable", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier Scorecard Criteria", + "link_to": "Supplier Scorecard Criteria", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier Scorecard Standing", + "link_to": "Supplier Scorecard Standing", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Key Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Analytics", + "link_to": "Purchase Analytics", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Order Analysis", + "link_to": "Purchase Order Analysis", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Supplier-Wise Sales Analytics", + "link_to": "Supplier-Wise Sales Analytics", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Items to Order and Receive", + "link_to": "Requested Items to Order and Receive", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Order Trends", + "link_to": "Purchase Order Trends", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Procurement Tracker", + "link_to": "Procurement Tracker", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Other Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Items To Be Requested", + "link_to": "Items To Be Requested", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Item-wise Purchase History", + "link_to": "Item-wise Purchase History", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Receipt Trends", + "link_to": "Purchase Receipt Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Invoice Trends", + "link_to": "Purchase Invoice Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Subcontracted Raw Materials To Be Transferred", + "link_to": "Subcontracted Raw Materials To Be Transferred", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Subcontracted Item To Be Received", + "link_to": "Subcontracted Item To Be Received", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Supplier Quotation Comparison", + "link_to": "Supplier Quotation Comparison", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Material Requests for which Supplier Quotations are not created", + "link_to": "Material Requests for which Supplier Quotations are not created", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Supplier Addresses And Contacts", + "link_to": "Address And Contacts", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Regional", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Import Supplier Invoice", + "link_to": "Import Supplier Invoice", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:38.615167", + "modified_by": "Administrator", + "module": "Buying", + "name": "Buying", + "onboarding": "Buying", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "color": "Green", + "format": "{} Available", + "label": "Item", + "link_to": "Item", + "stats_filter": "{\n \"disabled\": 0\n}", + "type": "DocType" + }, + { + "color": "Yellow", + "format": "{} Pending", + "label": "Material Request", + "link_to": "Material Request", + "stats_filter": "{\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%'],\n \"status\": \"Pending\"\n}", + "type": "DocType" + }, + { + "color": "Yellow", + "format": "{} To Receive", + "label": "Purchase Order", + "link_to": "Purchase Order", + "stats_filter": "{\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%'],\n \"status\":[\"in\", [\"To Receive\", \"To Receive and Bill\"]]\n}", + "type": "DocType" + }, + { + "label": "Purchase Analytics", + "link_to": "Purchase Analytics", + "type": "Report" + }, + { + "label": "Purchase Order Analysis", + "link_to": "Purchase Order Analysis", + "type": "Report" + }, + { + "label": "Dashboard", + "link_to": "Buying", + "type": "Dashboard" + } + ], + "shortcuts_label": "" +} \ No newline at end of file diff --git a/erpnext/communication/doctype/call_log/call_log.py b/erpnext/communication/doctype/call_log/call_log.py deleted file mode 100644 index b31b757a376..00000000000 --- a/erpnext/communication/doctype/call_log/call_log.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe import _ -from frappe.model.document import Document -from erpnext.crm.doctype.utils import get_scheduled_employees_for_popup, strip_number -from frappe.contacts.doctype.contact.contact import get_contact_with_phone_number -from erpnext.crm.doctype.lead.lead import get_lead_with_phone_number - -class CallLog(Document): - def before_insert(self): - number = strip_number(self.get('from')) - self.contact = get_contact_with_phone_number(number) - self.lead = get_lead_with_phone_number(number) - - contact = frappe.get_doc("Contact", self.contact) - self.customer = contact.get_link_for("Customer") - - def after_insert(self): - self.trigger_call_popup() - - def on_update(self): - doc_before_save = self.get_doc_before_save() - if not doc_before_save: return - if doc_before_save.status in ['Ringing'] and self.status in ['Missed', 'Completed']: - frappe.publish_realtime('call_{id}_disconnected'.format(id=self.id), self) - elif doc_before_save.to != self.to: - self.trigger_call_popup() - - def trigger_call_popup(self): - scheduled_employees = get_scheduled_employees_for_popup(self.medium) - employee_emails = get_employees_with_number(self.to) - - # check if employees with matched number are scheduled to receive popup - emails = set(scheduled_employees).intersection(employee_emails) - - # # if no employee found with matching phone number then show popup to scheduled employees - # emails = emails or scheduled_employees if employee_emails - - for email in emails: - frappe.publish_realtime('show_call_popup', self, user=email) - -@frappe.whitelist() -def add_call_summary(call_log, summary): - doc = frappe.get_doc('Call Log', call_log) - doc.add_comment('Comment', frappe.bold(_('Call Summary')) + '

    ' + summary) - -def get_employees_with_number(number): - number = strip_number(number) - if not number: return [] - - employee_emails = frappe.cache().hget('employees_with_number', number) - if employee_emails: return employee_emails - - employees = frappe.get_all('Employee', filters={ - 'cell_number': ['like', '%{}%'.format(number)], - 'user_id': ['!=', ''] - }, fields=['user_id']) - - employee_emails = [employee.user_id for employee in employees] - frappe.cache().hset('employees_with_number', number, employee_emails) - - return employee_emails - -def set_caller_information(doc, state): - '''Called from hooks on creation of Lead or Contact''' - if doc.doctype not in ['Lead', 'Contact']: return - - numbers = [doc.get('phone'), doc.get('mobile_no')] - # contact for Contact and lead for Lead - fieldname = doc.doctype.lower() - - # contact_name or lead_name - display_name_field = '{}_name'.format(fieldname) - - # Contact now has all the nos saved in child table - if doc.doctype == 'Contact': - numbers = [d.phone for d in doc.phone_nos] - - for number in numbers: - number = strip_number(number) - if not number: continue - - filters = frappe._dict({ - 'from': ['like', '%{}'.format(number)], - fieldname: '' - }) - - logs = frappe.get_all('Call Log', filters=filters) - - for log in logs: - frappe.db.set_value('Call Log', log.name, { - fieldname: doc.name, - display_name_field: doc.get_title() - }, update_modified=False) diff --git a/erpnext/communication/doctype/communication_medium/communication_medium.json b/erpnext/communication/doctype/communication_medium/communication_medium.json index f009b388771..1e1fe3bf499 100644 --- a/erpnext/communication/doctype/communication_medium/communication_medium.json +++ b/erpnext/communication/doctype/communication_medium/communication_medium.json @@ -1,12 +1,14 @@ { + "actions": [], "autoname": "Prompt", "creation": "2019-06-05 11:48:30.572795", "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "communication_channel", "communication_medium_type", - "catch_all", "column_break_3", + "catch_all", "provider", "disabled", "timeslots_section", @@ -54,9 +56,16 @@ "fieldtype": "Table", "label": "Timeslots", "options": "Communication Medium Timeslot" + }, + { + "fieldname": "communication_channel", + "fieldtype": "Select", + "label": "Communication Channel", + "options": "\nExotel" } ], - "modified": "2019-06-05 11:49:30.769006", + "links": [], + "modified": "2020-10-27 16:22:08.068542", "modified_by": "Administrator", "module": "Communication", "name": "Communication Medium", diff --git a/erpnext/config/accounts.py b/erpnext/config/accounts.py deleted file mode 100644 index 839c4ad84a1..00000000000 --- a/erpnext/config/accounts.py +++ /dev/null @@ -1,626 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ -import frappe - - -def get_data(): - config = [ - { - "label": _("Accounts Receivable"), - "items": [ - { - "type": "doctype", - "name": "Sales Invoice", - "description": _("Bills raised to Customers."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Customer", - "description": _("Customer database."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Payment Entry", - "description": _("Bank/Cash transactions against party or for internal transfer") - }, - { - "type": "doctype", - "name": "Payment Request", - "description": _("Payment Request"), - }, - { - "type": "report", - "name": "Accounts Receivable", - "doctype": "Sales Invoice", - "is_query_report": True - }, - { - "type": "report", - "name": "Accounts Receivable Summary", - "doctype": "Sales Invoice", - "is_query_report": True - }, - { - "type": "report", - "name": "Sales Register", - "doctype": "Sales Invoice", - "is_query_report": True - }, - { - "type": "report", - "name": "Item-wise Sales Register", - "is_query_report": True, - "doctype": "Sales Invoice" - }, - { - "type": "report", - "name": "Ordered Items To Be Billed", - "is_query_report": True, - "doctype": "Sales Invoice" - }, - { - "type": "report", - "name": "Delivered Items To Be Billed", - "is_query_report": True, - "doctype": "Sales Invoice" - }, - ] - }, - { - "label": _("Accounts Payable"), - "items": [ - { - "type": "doctype", - "name": "Purchase Invoice", - "description": _("Bills raised by Suppliers."), - "onboard": 1 - }, - { - "type": "doctype", - "name": "Supplier", - "description": _("Supplier database."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Payment Entry", - "description": _("Bank/Cash transactions against party or for internal transfer") - }, - { - "type": "report", - "name": "Accounts Payable", - "doctype": "Purchase Invoice", - "is_query_report": True - }, - { - "type": "report", - "name": "Accounts Payable Summary", - "doctype": "Purchase Invoice", - "is_query_report": True - }, - { - "type": "report", - "name": "Purchase Register", - "doctype": "Purchase Invoice", - "is_query_report": True - }, - { - "type": "report", - "name": "Item-wise Purchase Register", - "is_query_report": True, - "doctype": "Purchase Invoice" - }, - { - "type": "report", - "name": "Purchase Order Items To Be Billed", - "is_query_report": True, - "doctype": "Purchase Invoice" - }, - { - "type": "report", - "name": "Received Items To Be Billed", - "is_query_report": True, - "doctype": "Purchase Invoice" - }, - ] - }, - { - "label": _("Accounting Masters"), - "items": [ - { - "type": "doctype", - "name": "Company", - "description": _("Company (not Customer or Supplier) master."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Account", - "icon": "fa fa-sitemap", - "label": _("Chart of Accounts"), - "route": "#Tree/Account", - "description": _("Tree of financial accounts."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Accounts Settings", - }, - { - "type": "doctype", - "name": "Fiscal Year", - "description": _("Financial / accounting year.") - }, - { - "type": "doctype", - "name": "Accounting Dimension", - }, - { - "type": "doctype", - "name": "Finance Book", - }, - { - "type": "doctype", - "name": "Accounting Period", - }, - { - "type": "doctype", - "name": "Payment Term", - "description": _("Payment Terms based on conditions") - }, - ] - }, - { - "label": _("Banking and Payments"), - "items": [ - { - "type": "doctype", - "label": _("Match Payments with Invoices"), - "name": "Payment Reconciliation", - "description": _("Match non-linked Invoices and Payments.") - }, - { - "type": "doctype", - "label": _("Update Bank Clearance Dates"), - "name": "Bank Clearance", - "description": _("Update bank payment dates with journals.") - }, - { - "type": "doctype", - "label": _("Invoice Discounting"), - "name": "Invoice Discounting", - }, - { - "type": "report", - "name": "Bank Reconciliation Statement", - "is_query_report": True, - "doctype": "Journal Entry" - },{ - "type": "page", - "name": "bank-reconciliation", - "label": _("Bank Reconciliation"), - "icon": "fa fa-bar-chart" - }, - { - "type": "report", - "name": "Bank Clearance Summary", - "is_query_report": True, - "doctype": "Journal Entry" - }, - { - "type": "doctype", - "name": "Bank Guarantee" - }, - { - "type": "doctype", - "name": "Cheque Print Template", - "description": _("Setup cheque dimensions for printing") - }, - ] - }, - { - "label": _("General Ledger"), - "items": [ - { - "type": "doctype", - "name": "Journal Entry", - "description": _("Accounting journal entries.") - }, - { - "type": "report", - "name": "General Ledger", - "doctype": "GL Entry", - "is_query_report": True, - }, - { - "type": "report", - "name": "Customer Ledger Summary", - "doctype": "Sales Invoice", - "is_query_report": True, - }, - { - "type": "report", - "name": "Supplier Ledger Summary", - "doctype": "Sales Invoice", - "is_query_report": True, - }, - { - "type": "doctype", - "name": "Process Deferred Accounting" - } - ] - }, - { - "label": _("Taxes"), - "items": [ - { - "type": "doctype", - "name": "Sales Taxes and Charges Template", - "description": _("Tax template for selling transactions.") - }, - { - "type": "doctype", - "name": "Purchase Taxes and Charges Template", - "description": _("Tax template for buying transactions.") - }, - { - "type": "doctype", - "name": "Item Tax Template", - "description": _("Tax template for item tax rates.") - }, - { - "type": "doctype", - "name": "Tax Category", - "description": _("Tax Category for overriding tax rates.") - }, - { - "type": "doctype", - "name": "Tax Rule", - "description": _("Tax Rule for transactions.") - }, - { - "type": "doctype", - "name": "Tax Withholding Category", - "description": _("Tax Withholding rates to be applied on transactions.") - }, - ] - }, - { - "label": _("Cost Center and Budgeting"), - "items": [ - { - "type": "doctype", - "name": "Cost Center", - "icon": "fa fa-sitemap", - "label": _("Chart of Cost Centers"), - "route": "#Tree/Cost Center", - "description": _("Tree of financial Cost Centers."), - }, - { - "type": "doctype", - "name": "Budget", - "description": _("Define budget for a financial year.") - }, - { - "type": "doctype", - "name": "Accounting Dimension", - }, - { - "type": "report", - "name": "Budget Variance Report", - "is_query_report": True, - "doctype": "Cost Center" - }, - { - "type": "doctype", - "name": "Monthly Distribution", - "description": _("Seasonality for setting budgets, targets etc.") - }, - ] - }, - { - "label": _("Financial Statements"), - "items": [ - { - "type": "report", - "name": "Trial Balance", - "doctype": "GL Entry", - "is_query_report": True, - }, - { - "type": "report", - "name": "Profit and Loss Statement", - "doctype": "GL Entry", - "is_query_report": True - }, - { - "type": "report", - "name": "Balance Sheet", - "doctype": "GL Entry", - "is_query_report": True - }, - { - "type": "report", - "name": "Cash Flow", - "doctype": "GL Entry", - "is_query_report": True - }, - { - "type": "report", - "name": "Consolidated Financial Statement", - "doctype": "GL Entry", - "is_query_report": True - }, - ] - }, - { - "label": _("Opening and Closing"), - "items": [ - { - "type": "doctype", - "name": "Opening Invoice Creation Tool", - }, - { - "type": "doctype", - "name": "Chart of Accounts Importer", - }, - { - "type": "doctype", - "name": "Period Closing Voucher", - "description": _("Close Balance Sheet and book Profit or Loss.") - }, - ] - - }, - { - "label": _("Multi Currency"), - "items": [ - { - "type": "doctype", - "name": "Currency", - "description": _("Enable / disable currencies.") - }, - { - "type": "doctype", - "name": "Currency Exchange", - "description": _("Currency exchange rate master.") - }, - { - "type": "doctype", - "name": "Exchange Rate Revaluation", - "description": _("Exchange Rate Revaluation master.") - }, - ] - }, - { - "label": _("Settings"), - "icon": "fa fa-cog", - "items": [ - { - "type": "doctype", - "name": "Payment Gateway Account", - "description": _("Setup Gateway accounts.") - }, - { - "type": "doctype", - "name": "Terms and Conditions", - "label": _("Terms and Conditions Template"), - "description": _("Template of terms or contract.") - }, - { - "type": "doctype", - "name": "Mode of Payment", - "description": _("e.g. Bank, Cash, Credit Card") - }, - ] - }, - { - "label": _("Subscription Management"), - "items": [ - { - "type": "doctype", - "name": "Subscriber", - }, - { - "type": "doctype", - "name": "Subscription Plan", - }, - { - "type": "doctype", - "name": "Subscription" - }, - { - "type": "doctype", - "name": "Subscription Settings" - } - ] - }, - { - "label": _("Bank Statement"), - "items": [ - { - "type": "doctype", - "label": _("Bank"), - "name": "Bank", - }, - { - "type": "doctype", - "label": _("Bank Account"), - "name": "Bank Account", - }, - { - "type": "doctype", - "name": "Bank Statement Transaction Entry", - }, - { - "type": "doctype", - "label": _("Bank Statement Settings"), - "name": "Bank Statement Settings", - }, - ] - }, - { - "label": _("Profitability"), - "items": [ - { - "type": "report", - "name": "Gross Profit", - "doctype": "Sales Invoice", - "is_query_report": True - }, - { - "type": "report", - "name": "Profitability Analysis", - "doctype": "GL Entry", - "is_query_report": True, - }, - { - "type": "report", - "name": "Sales Invoice Trends", - "is_query_report": True, - "doctype": "Sales Invoice" - }, - { - "type": "report", - "name": "Purchase Invoice Trends", - "is_query_report": True, - "doctype": "Purchase Invoice" - }, - ] - }, - { - "label": _("Reports"), - "icon": "fa fa-table", - "items": [ - { - "type": "report", - "name": "Trial Balance for Party", - "doctype": "GL Entry", - "is_query_report": True, - }, - { - "type": "report", - "name": "Payment Period Based On Invoice Date", - "is_query_report": True, - "doctype": "Journal Entry" - }, - { - "type": "report", - "name": "Sales Partners Commission", - "is_query_report": True, - "doctype": "Sales Invoice" - }, - { - "type": "report", - "is_query_report": True, - "name": "Customer Credit Balance", - "doctype": "Customer" - }, - { - "type": "report", - "is_query_report": True, - "name": "Sales Payment Summary", - "doctype": "Sales Invoice" - }, - { - "type": "report", - "is_query_report": True, - "name": "Address And Contacts", - "doctype": "Address" - } - ] - }, - { - "label": _("Share Management"), - "icon": "fa fa-microchip ", - "items": [ - { - "type": "doctype", - "name": "Shareholder", - "description": _("List of available Shareholders with folio numbers") - }, - { - "type": "doctype", - "name": "Share Transfer", - "description": _("List of all share transactions"), - }, - { - "type": "report", - "name": "Share Ledger", - "doctype": "Share Transfer", - "is_query_report": True - }, - { - "type": "report", - "name": "Share Balance", - "doctype": "Share Transfer", - "is_query_report": True - } - ] - }, - - ] - - gst = { - "label": _("Goods and Services Tax (GST India)"), - "items": [ - { - "type": "doctype", - "name": "GST Settings", - }, - { - "type": "doctype", - "name": "GST HSN Code", - }, - { - "type": "report", - "name": "GSTR-1", - "is_query_report": True - }, - { - "type": "report", - "name": "GSTR-2", - "is_query_report": True - }, - { - "type": "doctype", - "name": "GSTR 3B Report", - }, - { - "type": "report", - "name": "GST Sales Register", - "is_query_report": True - }, - { - "type": "report", - "name": "GST Purchase Register", - "is_query_report": True - }, - { - "type": "report", - "name": "GST Itemised Sales Register", - "is_query_report": True - }, - { - "type": "report", - "name": "GST Itemised Purchase Register", - "is_query_report": True - }, - { - "type": "doctype", - "name": "C-Form", - "description": _("C-Form records"), - "country": "India" - }, - ] - } - - - countries = frappe.get_all("Company", fields="country") - countries = [country["country"] for country in countries] - if "India" in countries: - config.insert(9, gst) - domains = frappe.get_active_domains() - return config diff --git a/erpnext/config/agriculture.py b/erpnext/config/agriculture.py deleted file mode 100644 index 937d76ef7b3..00000000000 --- a/erpnext/config/agriculture.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Crops & Lands"), - "items": [ - { - "type": "doctype", - "name": "Crop", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Crop Cycle", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Location", - "onboard": 1, - } - ] - }, - { - "label": _("Diseases & Fertilizers"), - "items": [ - { - "type": "doctype", - "name": "Disease", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Fertilizer", - "onboard": 1, - } - ] - }, - { - "label": _("Analytics"), - "items": [ - { - "type": "doctype", - "name": "Plant Analysis", - }, - { - "type": "doctype", - "name": "Soil Analysis", - }, - { - "type": "doctype", - "name": "Water Analysis", - }, - { - "type": "doctype", - "name": "Soil Texture", - }, - { - "type": "doctype", - "name": "Weather", - }, - { - "type": "doctype", - "name": "Agriculture Analysis Criteria", - } - ] - }, - ] \ No newline at end of file diff --git a/erpnext/config/assets.py b/erpnext/config/assets.py deleted file mode 100644 index 4cf7cf08067..00000000000 --- a/erpnext/config/assets.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Assets"), - "items": [ - { - "type": "doctype", - "name": "Asset", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Location", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Asset Category", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Asset Movement", - "description": _("Transfer an asset from one warehouse to another") - }, - ] - }, - { - "label": _("Maintenance"), - "items": [ - { - "type": "doctype", - "name": "Asset Maintenance Team", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Asset Maintenance", - "onboard": 1, - "dependencies": ["Asset Maintenance Team"], - }, - { - "type": "doctype", - "name": "Asset Maintenance Tasks", - "onboard": 1, - "dependencies": ["Asset Maintenance"], - }, - { - "type": "doctype", - "name": "Asset Maintenance Log", - "dependencies": ["Asset Maintenance"], - }, - { - "type": "doctype", - "name": "Asset Value Adjustment", - "dependencies": ["Asset"], - }, - { - "type": "doctype", - "name": "Asset Repair", - "dependencies": ["Asset"], - }, - ] - }, - { - "label": _("Reports"), - "icon": "fa fa-table", - "items": [ - { - "type": "report", - "name": "Asset Depreciation Ledger", - "doctype": "Asset", - "is_query_report": True, - "dependencies": ["Asset"], - }, - { - "type": "report", - "name": "Asset Depreciations and Balances", - "doctype": "Asset", - "is_query_report": True, - "dependencies": ["Asset"], - }, - { - "type": "report", - "name": "Asset Maintenance", - "doctype": "Asset Maintenance", - "dependencies": ["Asset Maintenance"] - }, - ] - } - ] diff --git a/erpnext/config/buying.py b/erpnext/config/buying.py deleted file mode 100644 index b06bb76ca82..00000000000 --- a/erpnext/config/buying.py +++ /dev/null @@ -1,264 +0,0 @@ -from __future__ import unicode_literals -import frappe -from frappe import _ - -def get_data(): - config = [ - { - "label": _("Purchasing"), - "icon": "fa fa-star", - "items": [ - { - "type": "doctype", - "name": "Material Request", - "onboard": 1, - "dependencies": ["Item"], - "description": _("Request for purchase."), - }, - { - "type": "doctype", - "name": "Purchase Order", - "onboard": 1, - "dependencies": ["Item", "Supplier"], - "description": _("Purchase Orders given to Suppliers."), - }, - { - "type": "doctype", - "name": "Purchase Invoice", - "onboard": 1, - "dependencies": ["Item", "Supplier"] - }, - { - "type": "doctype", - "name": "Request for Quotation", - "onboard": 1, - "dependencies": ["Item", "Supplier"], - "description": _("Request for quotation."), - }, - { - "type": "doctype", - "name": "Supplier Quotation", - "dependencies": ["Item", "Supplier"], - "description": _("Quotations received from Suppliers."), - }, - ] - }, - { - "label": _("Items and Pricing"), - "items": [ - { - "type": "doctype", - "name": "Item", - "onboard": 1, - "description": _("All Products or Services."), - }, - { - "type": "doctype", - "name": "Item Price", - "description": _("Multiple Item prices."), - "onboard": 1, - "route": "#Report/Item Price" - }, - { - "type": "doctype", - "name": "Price List", - "description": _("Price List master.") - }, - { - "type": "doctype", - "name": "Pricing Rule", - "description": _("Rules for applying pricing and discount.") - }, - { - "type": "doctype", - "name": "Product Bundle", - "description": _("Bundle items at time of sale."), - }, - { - "type": "doctype", - "name": "Item Group", - "icon": "fa fa-sitemap", - "label": _("Item Group"), - "link": "Tree/Item Group", - "description": _("Tree of Item Groups."), - }, - { - "type": "doctype", - "name": "Promotional Scheme", - "description": _("Rules for applying different promotional schemes.") - } - ] - }, - { - "label": _("Settings"), - "icon": "fa fa-cog", - "items": [ - { - "type": "doctype", - "name": "Buying Settings", - "settings": 1, - "description": _("Default settings for buying transactions.") - }, - { - "type": "doctype", - "name": "Purchase Taxes and Charges Template", - "description": _("Tax template for buying transactions.") - }, - { - "type": "doctype", - "name":"Terms and Conditions", - "label": _("Terms and Conditions Template"), - "description": _("Template of terms or contract.") - }, - ] - }, - { - "label": _("Supplier"), - "items": [ - { - "type": "doctype", - "name": "Supplier", - "onboard": 1, - "description": _("Supplier database."), - }, - { - "type": "doctype", - "name": "Supplier Group", - "description": _("Supplier Group master.") - }, - { - "type": "doctype", - "name": "Contact", - "description": _("All Contacts."), - }, - { - "type": "doctype", - "name": "Address", - "description": _("All Addresses."), - }, - - ] - }, - { - "label": _("Key Reports"), - "icon": "fa fa-table", - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Purchase Analytics", - "reference_doctype": "Purchase Order", - "onboard": 1 - }, - { - "type": "report", - "is_query_report": True, - "name": "Purchase Order Trends", - "reference_doctype": "Purchase Order", - "onboard": 1, - }, - { - "type": "report", - "is_query_report": True, - "name": "Procurement Tracker", - "reference_doctype": "Purchase Order", - "onboard": 1, - }, - { - "type": "report", - "is_query_report": True, - "name": "Requested Items To Order", - "reference_doctype": "Material Request", - "onboard": 1, - }, - { - "type": "report", - "is_query_report": True, - "name": "Address And Contacts", - "label": _("Supplier Addresses And Contacts"), - "reference_doctype": "Address", - "route_options": { - "party_type": "Supplier" - } - } - ] - }, - { - "label": _("Supplier Scorecard"), - "items": [ - { - "type": "doctype", - "name": "Supplier Scorecard", - "description": _("All Supplier scorecards."), - }, - { - "type": "doctype", - "name": "Supplier Scorecard Variable", - "description": _("Templates of supplier scorecard variables.") - }, - { - "type": "doctype", - "name": "Supplier Scorecard Criteria", - "description": _("Templates of supplier scorecard criteria."), - }, - { - "type": "doctype", - "name": "Supplier Scorecard Standing", - "description": _("Templates of supplier standings."), - }, - - ] - }, - { - "label": _("Other Reports"), - "icon": "fa fa-list", - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Items To Be Requested", - "reference_doctype": "Item", - "onboard": 1, - }, - { - "type": "report", - "is_query_report": True, - "name": "Item-wise Purchase History", - "reference_doctype": "Item", - "onboard": 1, - }, - { - "type": "report", - "is_query_report": True, - "name": "Supplier-Wise Sales Analytics", - "reference_doctype": "Stock Ledger Entry", - "onboard": 1 - }, - { - "type": "report", - "is_query_report": True, - "name": "Material Requests for which Supplier Quotations are not created", - "reference_doctype": "Material Request" - } - ] - }, - - ] - - regional = { - "label": _("Regional"), - "items": [ - { - "type": "doctype", - "name": "Import Supplier Invoice", - "description": _("Import Italian Supplier Invoice."), - "onboard": 1, - } - ] - } - - countries = frappe.get_all("Company", fields="country") - countries = [country["country"] for country in countries] - if "Italy" in countries: - config.append(regional) - return config \ No newline at end of file diff --git a/erpnext/config/crm.py b/erpnext/config/crm.py deleted file mode 100644 index 09c2a65633b..00000000000 --- a/erpnext/config/crm.py +++ /dev/null @@ -1,236 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Sales Pipeline"), - "icon": "fa fa-star", - "items": [ - { - "type": "doctype", - "name": "Lead", - "description": _("Database of potential customers."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Opportunity", - "description": _("Potential opportunities for selling."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Customer", - "description": _("Customer database."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Contact", - "description": _("All Contacts."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Communication", - "description": _("Record of all communications of type email, phone, chat, visit, etc."), - }, - { - "type": "doctype", - "name": "Lead Source", - "description": _("Track Leads by Lead Source.") - }, - { - "type": "doctype", - "name": "Contract", - "description": _("Helps you keep tracks of Contracts based on Supplier, Customer and Employee"), - }, - { - "type": "doctype", - "name": "Appointment", - "description" : _("Helps you manage appointments with your leads"), - }, - { - "type": "doctype", - "name": "Newsletter", - "label": _("Newsletter"), - } - ] - }, - { - "label": _("Reports"), - "icon": "fa fa-list", - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Lead Details", - "doctype": "Lead", - "onboard": 1, - }, - { - "type": "page", - "name": "sales-funnel", - "label": _("Sales Funnel"), - "icon": "fa fa-bar-chart", - "onboard": 1, - }, - { - "type": "report", - "name": "Prospects Engaged But Not Converted", - "doctype": "Lead", - "is_query_report": True, - "onboard": 1, - }, - { - "type": "report", - "name": "Minutes to First Response for Opportunity", - "doctype": "Opportunity", - "is_query_report": True, - "dependencies": ["Opportunity"] - }, - { - "type": "report", - "is_query_report": True, - "name": "Customer Addresses And Contacts", - "doctype": "Contact", - "dependencies": ["Customer"] - }, - { - "type": "report", - "is_query_report": True, - "name": "Inactive Customers", - "doctype": "Sales Order", - "dependencies": ["Sales Order"] - }, - { - "type": "report", - "is_query_report": True, - "name": "Campaign Efficiency", - "doctype": "Lead", - "dependencies": ["Lead"] - }, - { - "type": "report", - "is_query_report": True, - "name": "Lead Owner Efficiency", - "doctype": "Lead", - "dependencies": ["Lead"] - }, - { - "type": "report", - "is_query_report": True, - "name": "Territory-wise Sales", - "doctype": "Opportunity", - "dependencies": ["Opportunity"] - } - ] - }, - { - "label": _("Settings"), - "icon": "fa fa-cog", - "items": [ - { - "type": "doctype", - "label": _("Customer Group"), - "name": "Customer Group", - "icon": "fa fa-sitemap", - "link": "Tree/Customer Group", - "description": _("Manage Customer Group Tree."), - "onboard": 1, - }, - { - "type": "doctype", - "label": _("Territory"), - "name": "Territory", - "icon": "fa fa-sitemap", - "link": "Tree/Territory", - "description": _("Manage Territory Tree."), - "onboard": 1, - }, - { - "type": "doctype", - "label": _("Sales Person"), - "name": "Sales Person", - "icon": "fa fa-sitemap", - "link": "Tree/Sales Person", - "description": _("Manage Sales Person Tree."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Campaign", - "description": _("Sales campaigns."), - }, - { - "type": "doctype", - "name": "Email Campaign", - "description": _("Sends Mails to lead or contact based on a Campaign schedule"), - }, - { - "type": "doctype", - "name": "SMS Center", - "description":_("Send mass SMS to your contacts"), - }, - { - "type": "doctype", - "name": "SMS Log", - "description":_("Logs for maintaining sms delivery status"), - }, - { - "type": "doctype", - "name": "SMS Settings", - "description": _("Setup SMS gateway settings") - }, - { - "type": "doctype", - "label": _("Email Group"), - "name": "Email Group", - } - ] - }, - { - "label": _("Maintenance"), - "icon": "fa fa-star", - "items": [ - { - "type": "doctype", - "name": "Maintenance Schedule", - "description": _("Plan for maintenance visits."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Maintenance Visit", - "description": _("Visit report for maintenance call."), - }, - { - "type": "report", - "name": "Maintenance Schedules", - "is_query_report": True, - "doctype": "Maintenance Schedule" - }, - { - "type": "doctype", - "name": "Warranty Claim", - "description": _("Warranty Claim against Serial No."), - }, - ] - }, - # { - # "label": _("Help"), - # "items": [ - # { - # "type": "help", - # "label": _("Lead to Quotation"), - # "youtube_id": "TxYX4r4JAKA" - # }, - # { - # "type": "help", - # "label": _("Newsletters"), - # "youtube_id": "muLKsCrrDRo" - # }, - # ] - # }, - ] diff --git a/erpnext/config/desktop.py b/erpnext/config/desktop.py deleted file mode 100644 index ce7c245a631..00000000000 --- a/erpnext/config/desktop.py +++ /dev/null @@ -1,220 +0,0 @@ -# coding=utf-8 - -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - # Modules - { - "module_name": "Getting Started", - "category": "Modules", - "label": _("Getting Started"), - "color": "#1abc9c", - "icon": "fa fa-check-square-o", - "type": "module", - "disable_after_onboard": 1, - "description": "Dive into the basics for your organisation's needs.", - "onboard_present": 1 - }, - { - "module_name": "Accounts", - "category": "Modules", - "label": _("Accounting"), - "color": "#3498db", - "icon": "octicon octicon-repo", - "type": "module", - "description": "Accounts, billing, payments, cost center and budgeting." - }, - { - "module_name": "Selling", - "category": "Modules", - "label": _("Selling"), - "color": "#1abc9c", - "icon": "octicon octicon-tag", - "type": "module", - "description": "Sales orders, quotations, customers and items." - }, - { - "module_name": "Buying", - "category": "Modules", - "label": _("Buying"), - "color": "#c0392b", - "icon": "octicon octicon-briefcase", - "type": "module", - "description": "Purchasing, suppliers, material requests, and items." - }, - { - "module_name": "Stock", - "category": "Modules", - "label": _("Stock"), - "color": "#f39c12", - "icon": "octicon octicon-package", - "type": "module", - "description": "Stock transactions, reports, serial numbers and batches." - }, - { - "module_name": "Assets", - "category": "Modules", - "label": _("Assets"), - "color": "#4286f4", - "icon": "octicon octicon-database", - "type": "module", - "description": "Asset movement, maintainance and tools." - }, - { - "module_name": "Projects", - "category": "Modules", - "label": _("Projects"), - "color": "#8e44ad", - "icon": "octicon octicon-rocket", - "type": "module", - "description": "Updates, Timesheets and Activities." - }, - { - "module_name": "CRM", - "category": "Modules", - "label": _("CRM"), - "color": "#EF4DB6", - "icon": "octicon octicon-broadcast", - "type": "module", - "description": "Sales pipeline, leads, opportunities and customers." - }, - { - "module_name": "Loan Management", - "category": "Modules", - "label": _("Loan Management"), - "color": "#EF4DB6", - "icon": "octicon octicon-repo", - "type": "module", - "description": "Loan Management for Customer and Employees" - }, - { - "module_name": "Support", - "category": "Modules", - "label": _("Support"), - "color": "#1abc9c", - "icon": "fa fa-check-square-o", - "type": "module", - "description": "User interactions, support issues and knowledge base." - }, - { - "module_name": "HR", - "category": "Modules", - "label": _("Human Resources"), - "color": "#2ecc71", - "icon": "octicon octicon-organization", - "type": "module", - "description": "Employees, attendance, payroll, leaves and shifts." - }, - { - "module_name": "Quality Management", - "category": "Modules", - "label": _("Quality"), - "color": "#1abc9c", - "icon": "fa fa-check-square-o", - "type": "module", - "description": "Quality goals, procedures, reviews and action." - }, - - - # Category: "Domains" - { - "module_name": "Manufacturing", - "category": "Domains", - "label": _("Manufacturing"), - "color": "#7f8c8d", - "icon": "octicon octicon-tools", - "type": "module", - "description": "BOMS, work orders, operations, and timesheets." - }, - { - "module_name": "Retail", - "category": "Domains", - "label": _("Retail"), - "color": "#7f8c8d", - "icon": "octicon octicon-credit-card", - "type": "module", - "description": "Point of Sale and cashier closing." - }, - { - "module_name": "Education", - "category": "Domains", - "label": _("Education"), - "color": "#428B46", - "icon": "octicon octicon-mortar-board", - "type": "module", - "description": "Student admissions, fees, courses and scores." - }, - - { - "module_name": "Healthcare", - "category": "Domains", - "label": _("Healthcare"), - "color": "#FF888B", - "icon": "fa fa-heartbeat", - "type": "module", - "description": "Patient appointments, procedures and tests." - }, - { - "module_name": "Agriculture", - "category": "Domains", - "label": _("Agriculture"), - "color": "#8BC34A", - "icon": "octicon octicon-globe", - "type": "module", - "description": "Crop cycles, land areas, soil and plant analysis." - }, - { - "module_name": "Hotels", - "category": "Domains", - "label": _("Hotels"), - "color": "#EA81E8", - "icon": "fa fa-bed", - "type": "module", - "description": "Hotel rooms, pricing, reservation and amenities." - }, - - { - "module_name": "Non Profit", - "category": "Domains", - "label": _("Non Profit"), - "color": "#DE2B37", - "icon": "octicon octicon-heart", - "type": "module", - "description": "Volunteers, memberships, grants and chapters." - }, - { - "module_name": "Restaurant", - "category": "Domains", - "label": _("Restaurant"), - "color": "#EA81E8", - "icon": "fa fa-cutlery", - "_doctype": "Restaurant", - "type": "module", - "link": "List/Restaurant", - "description": "Menu, Orders and Table Reservations." - }, - - { - "module_name": "Help", - "category": "Administration", - "label": _("Learn"), - "color": "#FF888B", - "icon": "octicon octicon-device-camera-video", - "type": "module", - "is_help": True, - "description": "Explore Help Articles and Videos." - }, - { - "module_name": 'Marketplace', - "category": "Places", - "label": _('Marketplace'), - "icon": "octicon octicon-star", - "type": 'link', - "link": '#marketplace/home', - "color": '#FF4136', - 'standard': 1, - "description": "Publish items to other ERPNext users." - }, - ] diff --git a/erpnext/config/docs.py b/erpnext/config/docs.py deleted file mode 100644 index 85e600687f2..00000000000 --- a/erpnext/config/docs.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import unicode_literals - -source_link = "https://github.com/erpnext/foundation" diff --git a/erpnext/config/education.py b/erpnext/config/education.py index 4efaaa65cdc..1c8ab10f537 100644 --- a/erpnext/config/education.py +++ b/erpnext/config/education.py @@ -173,7 +173,7 @@ def get_data(): { "type": "doctype", "name": "Course Schedule", - "route": "#List/Course Schedule/Calendar" + "route": "/app/List/Course Schedule/Calendar" }, { "type": "doctype", diff --git a/erpnext/config/getting_started.py b/erpnext/config/getting_started.py deleted file mode 100644 index fa84b1ce944..00000000000 --- a/erpnext/config/getting_started.py +++ /dev/null @@ -1,268 +0,0 @@ -from __future__ import unicode_literals -import frappe -from frappe import _ - -active_domains = frappe.get_active_domains() - -def get_data(): - return [ - { - "label": _("Accounting"), - "items": [ - { - "type": "doctype", - "name": "Item", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Customer", - "description": _("Customer database."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Supplier", - "description": _("Supplier database."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Company", - "description": _("Company (not Customer or Supplier) master."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Account", - "icon": "fa fa-sitemap", - "label": _("Chart of Accounts"), - "route": "#Tree/Account", - "description": _("Tree of financial accounts."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Opening Invoice Creation Tool", - "description": _("Create Opening Sales and Purchase Invoices"), - "onboard": 1, - }, - ] - }, - { - "label": _("Data Import and Settings"), - "items": [ - { - "type": "doctype", - "name": "Data Import", - "label": _("Import Data"), - "icon": "octicon octicon-cloud-upload", - "description": _("Import Data from CSV / Excel files."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Chart of Accounts Importer", - "labe": _("Chart Of Accounts Importer"), - "description": _("Import Chart Of Accounts from CSV / Excel files"), - "onboard": 1 - }, - { - "type": "doctype", - "name": "Letter Head", - "description": _("Letter Heads for print templates."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Email Account", - "description": _("Add / Manage Email Accounts."), - "onboard": 1, - }, - - ] - }, - { - "label": _("Stock"), - "items": [ - { - "type": "doctype", - "name": "Warehouse", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Brand", - "onboard": 1, - }, - { - "type": "doctype", - "name": "UOM", - "label": _("Unit of Measure") + " (UOM)", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Stock Reconciliation", - "onboard": 1, - }, - ] - }, - { - "label": _("CRM"), - "items": [ - { - "type": "doctype", - "name": "Lead", - "description": _("Database of potential customers."), - "onboard": 1, - }, - { - "type": "doctype", - "label": _("Customer Group"), - "name": "Customer Group", - "icon": "fa fa-sitemap", - "link": "Tree/Customer Group", - "description": _("Manage Customer Group Tree."), - "onboard": 1, - }, - { - "type": "doctype", - "label": _("Territory"), - "name": "Territory", - "icon": "fa fa-sitemap", - "link": "Tree/Territory", - "description": _("Manage Territory Tree."), - "onboard": 1, - }, - ] - }, - { - "label": _("Human Resources"), - "items": [ - { - "type": "doctype", - "name": "Employee", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Employee Attendance Tool", - "hide_count": True, - "onboard": 1, - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Salary Structure", - "onboard": 1, - }, - ] - }, - { - "label": _("Education"), - "condition": "Education" in active_domains, - "items": [ - { - "type": "doctype", - "name": "Student", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Course", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Instructor", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Room", - "onboard": 1, - }, - ] - }, - { - "label": _("Healthcare"), - "condition": "Healthcare" in active_domains, - "items": [ - { - "type": "doctype", - "name": "Patient", - "label": _("Patient"), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Physician", - "label": _("Physician"), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Diagnosis", - "label": _("Diagnosis"), - "onboard": 1, - } - ] - }, - { - "label": _("Agriculture"), - "condition": "Agriculture" in active_domains, - "items": [ - { - "type": "doctype", - "name": "Crop", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Crop Cycle", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Location", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Fertilizer", - "onboard": 1, - } - ] - }, - { - "label": _("Non Profit"), - "condition": "Non Profit" in active_domains, - "items": [ - { - "type": "doctype", - "name": "Member", - "description": _("Member information."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Volunteer", - "description": _("Volunteer information."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Chapter", - "description": _("Chapter information."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Donor", - "description": _("Donor information."), - "onboard": 1, - }, - ] - } - ] \ No newline at end of file diff --git a/erpnext/config/healthcare.py b/erpnext/config/healthcare.py deleted file mode 100644 index da24d11538d..00000000000 --- a/erpnext/config/healthcare.py +++ /dev/null @@ -1,254 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Masters"), - "items": [ - { - "type": "doctype", - "name": "Patient", - "label": _("Patient"), - "onboard": 1 - }, - { - "type": "doctype", - "name": "Healthcare Practitioner", - "label": _("Healthcare Practitioner"), - "onboard": 1 - }, - { - "type": "doctype", - "name": "Practitioner Schedule", - "label": _("Practitioner Schedule"), - "onboard": 1 - }, - { - "type": "doctype", - "name": "Medical Department", - "label": _("Medical Department"), - }, - { - "type": "doctype", - "name": "Healthcare Service Unit Type", - "label": _("Healthcare Service Unit Type") - }, - { - "type": "doctype", - "name": "Healthcare Service Unit", - "label": _("Healthcare Service Unit") - }, - { - "type": "doctype", - "name": "Medical Code Standard", - "label": _("Medical Code Standard") - }, - { - "type": "doctype", - "name": "Medical Code", - "label": _("Medical Code") - } - ] - }, - { - "label": _("Consultation Setup"), - "items": [ - { - "type": "doctype", - "name": "Appointment Type", - "label": _("Appointment Type"), - }, - { - "type": "doctype", - "name": "Clinical Procedure Template", - "label": _("Clinical Procedure Template") - }, - { - "type": "doctype", - "name": "Prescription Dosage", - "label": _("Prescription Dosage") - }, - { - "type": "doctype", - "name": "Prescription Duration", - "label": _("Prescription Duration") - }, - { - "type": "doctype", - "name": "Antibiotic", - "label": _("Antibiotic") - } - ] - }, - { - "label": _("Consultation"), - "items": [ - { - "type": "doctype", - "name": "Patient Appointment", - "label": _("Patient Appointment") - }, - { - "type": "doctype", - "name": "Clinical Procedure", - "label": _("Clinical Procedure") - }, - { - "type": "doctype", - "name": "Patient Encounter", - "label": _("Patient Encounter") - }, - { - "type": "doctype", - "name": "Vital Signs", - "label": _("Vital Signs") - }, - { - "type": "doctype", - "name": "Complaint", - "label": _("Complaint") - }, - { - "type": "doctype", - "name": "Diagnosis", - "label": _("Diagnosis") - }, - { - "type": "doctype", - "name": "Fee Validity", - "label": _("Fee Validity") - } - ] - }, - { - "label": _("Settings"), - "items": [ - { - "type": "doctype", - "name": "Healthcare Settings", - "label": _("Healthcare Settings"), - "onboard": 1 - } - ] - }, - { - "label": _("Laboratory Setup"), - "items": [ - { - "type": "doctype", - "name": "Lab Test Template", - "label": _("Lab Test Template") - }, - { - "type": "doctype", - "name": "Lab Test Sample", - "label": _("Lab Test Sample") - }, - { - "type": "doctype", - "name": "Lab Test UOM", - "label": _("Lab Test UOM") - }, - { - "type": "doctype", - "name": "Sensitivity", - "label": _("Sensitivity") - } - ] - }, - { - "label": _("Laboratory"), - "items": [ - { - "type": "doctype", - "name": "Lab Test", - "label": _("Lab Test") - }, - { - "type": "doctype", - "name": "Sample Collection", - "label": _("Sample Collection") - }, - { - "type": "doctype", - "name": "Dosage Form", - "label": _("Dosage Form") - } - ] - }, - { - "label": _("Records and History"), - "items": [ - { - "type": "page", - "name": "patient_history", - "label": _("Patient History"), - }, - { - "type": "doctype", - "name": "Patient Medical Record", - "label": _("Patient Medical Record") - }, - { - "type": "doctype", - "name": "Inpatient Record", - "label": _("Inpatient Record") - } - ] - }, - { - "label": _("Reports"), - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Patient Appointment Analytics", - "doctype": "Patient Appointment" - }, - { - "type": "report", - "is_query_report": True, - "name": "Lab Test Report", - "doctype": "Lab Test", - "label": _("Lab Test Report") - } - ] - }, - { - "label": _("Rehabilitation"), - "icon": "icon-cog", - "items": [ - { - "type": "doctype", - "name": "Exercise Type", - "label": _("Exercise Type") - }, - { - "type": "doctype", - "name": "Exercise Difficulty Level", - "label": _("Exercise Difficulty Level") - }, - { - "type": "doctype", - "name": "Therapy Type", - "label": _("Therapy Type") - }, - { - "type": "doctype", - "name": "Therapy Plan", - "label": _("Therapy Plan") - }, - { - "type": "doctype", - "name": "Therapy Session", - "label": _("Therapy Session") - }, - { - "type": "doctype", - "name": "Motor Assessment Scale", - "label": _("Motor Assessment Scale") - } - ] - } - ] diff --git a/erpnext/config/help.py b/erpnext/config/help.py deleted file mode 100644 index 922afb4c495..00000000000 --- a/erpnext/config/help.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("General"), - "items": [ - { - "type": "help", - "label": _("Navigating"), - "youtube_id": "YDoI2DF4Lmc" - }, - { - "type": "help", - "label": _("Setup Wizard"), - "youtube_id": "oIOf_zCFWKQ" - }, - { - "type": "help", - "label": _("Customizing Forms"), - "youtube_id": "pJhL9mmxV_U" - }, - { - "type": "help", - "label": _("Report Builder"), - "youtube_id": "TxJGUNarcQs" - }, - ] - - }, - { - "label": _("Settings"), - "items": [ - { - "type": "help", - "label": _("Data Import and Export"), - "youtube_id": "6wiriRKPhmg" - }, - { - "type": "help", - "label": _("Opening Stock Balance"), - "youtube_id": "nlHX0ZZ84Lw" - }, - { - "type": "help", - "label": _("Setting up Email Account"), - "youtube_id": "YFYe0DrB95o" - }, - { - "type": "help", - "label": _("Printing and Branding"), - "youtube_id": "cKZHcx1znMc" - }, - { - "type": "help", - "label": _("Users and Permissions"), - "youtube_id": "8Slw1hsTmUI" - }, - { - "type": "help", - "label": _("Workflow"), - "youtube_id": "yObJUg9FxFs" - }, - { - "type": "help", - "label": _("File Manager"), - "youtube_id": "4-osLW3E_Rk" - }, - ] - }, - { - "label": _("Accounting"), - "items": [ - { - "type": "help", - "label": _("Chart of Accounts"), - "youtube_id": "DyR-DST-PyA" - }, - { - "type": "help", - "label": _("Setting up Taxes"), - "youtube_id": "nQ1zZdPgdaQ" - }, - { - "type": "help", - "label": _("Opening Accounting Balance"), - "youtube_id": "kdgM20Q-q68" - }, - { - "type": "help", - "label": _("Advance Payments"), - "youtube_id": "J46-6qtyZ9U" - }, - ] - }, - { - "label": _("CRM"), - "items": [ - { - "type": "help", - "label": _("Lead to Quotation"), - "youtube_id": "TxYX4r4JAKA" - }, - { - "type": "help", - "label": _("Newsletters"), - "youtube_id": "muLKsCrrDRo" - }, - ] - }, - { - "label": _("Selling"), - "items": [ - { - "type": "help", - "label": _("Customer and Supplier"), - "youtube_id": "anoGi_RpQ20" - }, - { - "type": "help", - "label": _("Sales Order to Payment"), - "youtube_id": "1eP90MWoDQM" - }, - { - "type": "help", - "label": _("Point-of-Sale"), - "youtube_id": "4WkelWkbP_c" - }, - { - "type": "help", - "label": _("Product Bundle"), - "youtube_id": "yk3kPrRyRRc" - }, - { - "type": "help", - "label": _("Drop Ship"), - "youtube_id": "hUc0hu_XLdo" - }, - ] - }, - { - "label": _("Stock"), - "items": [ - { - "type": "help", - "label": _("Items and Pricing"), - "youtube_id": "qXaEwld4_Ps" - }, - { - "type": "help", - "label": _("Item Variants"), - "youtube_id": "OGBETlCzU5o" - }, - { - "type": "help", - "label": _("Opening Stock Balance"), - "youtube_id": "0yPgrtfeCTs" - }, - { - "type": "help", - "label": _("Making Stock Entries"), - "youtube_id": "Njt107hlY3I" - }, - { - "type": "help", - "label": _("Serialized Inventory"), - "youtube_id": "gvOVlEwFDAk" - }, - { - "type": "help", - "label": _("Batch Inventory"), - "youtube_id": "J0QKl7ABPKM" - }, - { - "type": "help", - "label": _("Managing Subcontracting"), - "youtube_id": "ThiMCC2DtKo" - }, - { - "type": "help", - "label": _("Quality Inspection"), - "youtube_id": "WmtcF3Y40Fs" - }, - ] - }, - { - "label": _("Buying"), - "items": [ - { - "type": "help", - "label": _("Customer and Supplier"), - "youtube_id": "anoGi_RpQ20" - }, - { - "type": "help", - "label": _("Material Request to Purchase Order"), - "youtube_id": "55Gk2j7Q8Zw" - }, - { - "type": "help", - "label": _("Purchase Order to Payment"), - "youtube_id": "efFajTTQBa8" - }, - { - "type": "help", - "label": _("Managing Subcontracting"), - "youtube_id": "ThiMCC2DtKo" - }, - ] - }, - { - "label": _("Manufacturing"), - "items": [ - { - "type": "help", - "label": _("Bill of Materials"), - "youtube_id": "hDV0c1OeWLo" - }, - { - "type": "help", - "label": _("Work Order"), - "youtube_id": "ZotgLyp2YFY" - }, - - ] - }, - { - "label": _("Human Resource"), - "items": [ - { - "type": "help", - "label": _("Setting up Employees"), - "youtube_id": "USfIUdZlUhw" - }, - { - "type": "help", - "label": _("Leave Management"), - "youtube_id": "fc0p_AXebc8" - }, - { - "type": "help", - "label": _("Expense Claims"), - "youtube_id": "5SZHJF--ZFY" - } - ] - }, - { - "label": _("Projects"), - "items": [ - { - "type": "help", - "label": _("Managing Projects"), - "youtube_id": "gCzShu9Niu4" - }, - ] - }, - { - "label": _("Website"), - "items": [ - { - "type": "help", - "label": _("Publish Items on Website"), - "youtube_id": "W31LBBNzbgc" - }, - { - "type": "help", - "label": _("Shopping Cart"), - "youtube_id": "xkrYO-KFukM" - }, - ] - }, - ] diff --git a/erpnext/config/hr.py b/erpnext/config/hr.py deleted file mode 100644 index 9855a115a60..00000000000 --- a/erpnext/config/hr.py +++ /dev/null @@ -1,470 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Employee"), - "items": [ - { - "type": "doctype", - "name": "Employee", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Employment Type", - }, - { - "type": "doctype", - "name": "Branch", - }, - { - "type": "doctype", - "name": "Department", - }, - { - "type": "doctype", - "name": "Designation", - }, - { - "type": "doctype", - "name": "Employee Grade", - }, - { - "type": "doctype", - "name": "Employee Group", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Health Insurance" - }, - ] - }, - { - "label": _("Attendance"), - "items": [ - { - "type": "doctype", - "name": "Employee Attendance Tool", - "hide_count": True, - "onboard": 1, - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Attendance", - "onboard": 1, - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Attendance Request", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Upload Attendance", - "hide_count": True, - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Checkin", - "hide_count": True, - "dependencies": ["Employee"] - }, - { - "type": "report", - "is_query_report": True, - "name": "Monthly Attendance Sheet", - "doctype": "Attendance" - }, - ] - }, - { - "label": _("Leaves"), - "items": [ - { - "type": "doctype", - "name": "Leave Application", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Leave Allocation", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Leave Policy", - "dependencies": ["Leave Type"] - }, - { - "type": "doctype", - "name": "Leave Period", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name":"Leave Type", - }, - { - "type": "doctype", - "name": "Holiday List", - }, - { - "type": "doctype", - "name": "Compensatory Leave Request", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Leave Encashment", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Leave Block List", - }, - { - "type": "report", - "is_query_report": True, - "name": "Employee Leave Balance", - "doctype": "Leave Application" - }, - { - "type": "report", - "is_query_report": True, - "name": "Leave Ledger Entry", - "doctype": "Leave Ledger Entry" - }, - ] - }, - { - "label": _("Payroll"), - "items": [ - { - "type": "doctype", - "name": "Salary Structure", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Salary Structure Assignment", - "onboard": 1, - "dependencies": ["Salary Structure", "Employee"], - }, - { - "type": "doctype", - "name": "Payroll Entry", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Salary Slip", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Payroll Period", - }, - { - "type": "doctype", - "name": "Income Tax Slab", - }, - { - "type": "doctype", - "name": "Salary Component", - }, - { - "type": "doctype", - "name": "Additional Salary", - }, - { - "type": "doctype", - "name": "Retention Bonus", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Incentive", - "dependencies": ["Employee"] - }, - { - "type": "report", - "is_query_report": True, - "name": "Salary Register", - "doctype": "Salary Slip" - }, - ] - }, - { - "label": _("Employee Tax and Benefits"), - "items": [ - { - "type": "doctype", - "name": "Employee Tax Exemption Declaration", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Tax Exemption Proof Submission", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Other Income", - }, - { - "type": "doctype", - "name": "Employee Benefit Application", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Benefit Claim", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Tax Exemption Category", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Tax Exemption Sub Category", - "dependencies": ["Employee"] - }, - ] - }, - { - "label": _("Employee Lifecycle"), - "items": [ - { - "type": "doctype", - "name": "Employee Onboarding", - "dependencies": ["Job Applicant"], - }, - { - "type": "doctype", - "name": "Employee Skill Map", - "dependencies": ["Employee"], - }, - { - "type": "doctype", - "name": "Employee Promotion", - "dependencies": ["Employee"], - }, - { - "type": "doctype", - "name": "Employee Transfer", - "dependencies": ["Employee"], - }, - { - "type": "doctype", - "name": "Employee Separation", - "dependencies": ["Employee"], - }, - { - "type": "doctype", - "name": "Employee Onboarding Template", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Separation Template", - "dependencies": ["Employee"] - }, - ] - }, - { - "label": _("Recruitment"), - "items": [ - { - "type": "doctype", - "name": "Job Opening", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Job Applicant", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Job Offer", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Appointment Letter", - }, - { - "type": "doctype", - "name": "Staffing Plan", - }, - ] - }, - { - "label": _("Training"), - "items": [ - { - "type": "doctype", - "name": "Training Program" - }, - { - "type": "doctype", - "name": "Training Event" - }, - { - "type": "doctype", - "name": "Training Result" - }, - { - "type": "doctype", - "name": "Training Feedback" - }, - ] - }, - { - "label": _("Performance"), - "items": [ - { - "type": "doctype", - "name": "Appraisal", - }, - { - "type": "doctype", - "name": "Appraisal Template", - }, - { - "type": "doctype", - "name": "Energy Point Rule", - }, - { - "type": "doctype", - "name": "Energy Point Log", - }, - { - "type": "link", - "doctype": "Energy Point Log", - "label": _("Energy Point Leaderboard"), - "route": "#social/users" - }, - ] - }, - { - "label": _("Expense Claims"), - "items": [ - { - "type": "doctype", - "name": "Expense Claim", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Employee Advance", - "dependencies": ["Employee"] - }, - ] - }, - { - "label": _("Loans"), - "items": [ - { - "type": "doctype", - "name": "Loan Application", - "dependencies": ["Employee"] - }, - { - "type": "doctype", - "name": "Loan" - }, - { - "type": "doctype", - "name": "Loan Type", - }, - ] - }, - { - "label": _("Shift Management"), - "items": [ - { - "type": "doctype", - "name": "Shift Type", - }, - { - "type": "doctype", - "name": "Shift Request", - }, - { - "type": "doctype", - "name": "Shift Assignment", - }, - ] - }, - { - "label": _("Fleet Management"), - "items": [ - { - "type": "doctype", - "name": "Vehicle" - }, - { - "type": "doctype", - "name": "Vehicle Log" - }, - { - "type": "report", - "is_query_report": True, - "name": "Vehicle Expenses", - "doctype": "Vehicle" - }, - ] - }, - { - "label": _("Settings"), - "icon": "fa fa-cog", - "items": [ - { - "type": "doctype", - "name": "HR Settings", - }, - { - "type": "doctype", - "name": "Daily Work Summary Group" - }, - { - "type": "page", - "name": "team-updates", - "label": _("Team Updates") - }, - ] - }, - { - "label": _("Reports"), - "icon": "fa fa-list", - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Employee Birthday", - "doctype": "Employee" - }, - { - "type": "report", - "is_query_report": True, - "name": "Employees working on a holiday", - "doctype": "Employee" - }, - { - "type": "report", - "is_query_report": True, - "name": "Department Analytics", - "doctype": "Employee" - }, - ] - }, - ] diff --git a/erpnext/config/hub_node.py b/erpnext/config/hub_node.py deleted file mode 100644 index 0afdeb52b16..00000000000 --- a/erpnext/config/hub_node.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Settings"), - "items": [ - { - "type": "doctype", - "name": "Marketplace Settings" - }, - ] - }, - { - "label": _("Marketplace"), - "items": [ - { - "type": "page", - "name": "marketplace/home" - }, - ] - }, - ] \ No newline at end of file diff --git a/erpnext/config/integrations.py b/erpnext/config/integrations.py deleted file mode 100644 index f8b3257b5c2..00000000000 --- a/erpnext/config/integrations.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Payments"), - "icon": "fa fa-star", - "items": [ - { - "type": "doctype", - "name": "GoCardless Settings", - "description": _("GoCardless payment gateway settings"), - }, - { - "type": "doctype", - "name": "GoCardless Mandate", - "description": _("GoCardless SEPA Mandate"), - } - ] - }, - { - "label": _("Settings"), - "items": [ - { - "type": "doctype", - "name": "Woocommerce Settings" - }, - { - "type": "doctype", - "name": "Shopify Settings", - "description": _("Connect Shopify with ERPNext"), - }, - { - "type": "doctype", - "name": "Amazon MWS Settings", - "description": _("Connect Amazon with ERPNext"), - }, - { - "type": "doctype", - "name": "Plaid Settings", - "description": _("Connect your bank accounts to ERPNext"), - }, - { - "type": "doctype", - "name": "Exotel Settings", - "description": _("Connect your Exotel Account to ERPNext and track call logs"), - } - ] - } - ] diff --git a/erpnext/config/loan_management.py b/erpnext/config/loan_management.py deleted file mode 100644 index a84f13ababc..00000000000 --- a/erpnext/config/loan_management.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ -import frappe - - -def get_data(): - return [ - { - "label": _("Loan"), - "items": [ - { - "type": "doctype", - "name": "Loan Type", - "description": _("Loan Type for interest and penalty rates"), - }, - { - "type": "doctype", - "name": "Loan Application", - "description": _("Loan Applications from customers and employees."), - }, - { - "type": "doctype", - "name": "Loan", - "description": _("Loans provided to customers and employees."), - }, - - ] - }, - { - "label": _("Loan Security"), - "items": [ - { - "type": "doctype", - "name": "Loan Security Type", - }, - { - "type": "doctype", - "name": "Loan Security Price", - }, - { - "type": "doctype", - "name": "Loan Security", - }, - { - "type": "doctype", - "name": "Loan Security Pledge", - }, - { - "type": "doctype", - "name": "Loan Security Unpledge", - }, - { - "type": "doctype", - "name": "Loan Security Shortfall", - }, - ] - }, - { - "label": _("Disbursement and Repayment"), - "items": [ - { - "type": "doctype", - "name": "Loan Disbursement", - }, - { - "type": "doctype", - "name": "Loan Repayment", - }, - { - "type": "doctype", - "name": "Loan Interest Accrual" - } - ] - }, - { - "label": _("Loan Processes"), - "items": [ - { - "type": "doctype", - "name": "Process Loan Security Shortfall", - }, - { - "type": "doctype", - "name": "Process Loan Interest Accrual", - } - ] - }, - { - "label": _("Reports"), - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Loan Repayment and Closure", - "route": "#query-report/Loan Repayment and Closure", - "doctype": "Loan Repayment", - }, - { - "type": "report", - "is_query_report": True, - "name": "Loan Security Status", - "route": "#query-report/Loan Security Status", - "doctype": "Loan Security Pledge", - } - ] - } - ] \ No newline at end of file diff --git a/erpnext/config/manufacturing.py b/erpnext/config/manufacturing.py deleted file mode 100644 index 012f1cad0ad..00000000000 --- a/erpnext/config/manufacturing.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Bill of Materials"), - "items": [ - { - "type": "doctype", - "name": "Item", - "description": _("All Products or Services."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "BOM", - "description": _("Bill of Materials (BOM)"), - "label": _("Bill of Materials"), - "onboard": 1, - "dependencies": ["Item"] - }, - { - "type": "doctype", - "name": "BOM Browser", - "icon": "fa fa-sitemap", - "label": _("BOM Browser"), - "description": _("Tree of Bill of Materials"), - "link": "Tree/BOM", - "onboard": 1, - "dependencies": ["Item"] - }, - - { - "type": "doctype", - "name": "Workstation", - "description": _("Where manufacturing operations are carried."), - }, - { - "type": "doctype", - "name": "Operation", - "description": _("Details of the operations carried out."), - }, - { - "type": "doctype", - "name": "Routing" - } - - ] - }, - { - "label": _("Production"), - "icon": "fa fa-star", - "items": [ - { - "type": "doctype", - "name": "Work Order", - "description": _("Orders released for production."), - "onboard": 1, - "dependencies": ["Item", "BOM"] - }, - { - "type": "doctype", - "name": "Production Plan", - "description": _("Generate Material Requests (MRP) and Work Orders."), - "onboard": 1, - "dependencies": ["Item", "BOM"] - }, - { - "type": "doctype", - "name": "Stock Entry", - "onboard": 1, - "dependencies": ["Item"] - }, - { - "type": "doctype", - "name": "Timesheet", - "description": _("Time Sheet for manufacturing."), - "onboard": 1, - "dependencies": ["Activity Type"] - }, - { - "type": "doctype", - "name": "Job Card" - } - ] - }, - { - "label": _("Tools"), - "icon": "fa fa-wrench", - "items": [ - { - "type": "doctype", - "name": "BOM Update Tool", - "description": _("Replace BOM and update latest price in all BOMs"), - }, - { - "type": "page", - "label": _("BOM Comparison Tool"), - "name": "bom-comparison-tool", - "description": _("Compare BOMs for changes in Raw Materials and Operations"), - "data_doctype": "BOM" - }, - ] - }, - { - "label": _("Settings"), - "items": [ - { - "type": "doctype", - "name": "Manufacturing Settings", - "description": _("Global settings for all manufacturing processes."), - } - ] - }, - { - "label": _("Reports"), - "icon": "fa fa-list", - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Work Order Summary", - "doctype": "Work Order" - }, - { - "type": "report", - "is_query_report": True, - "name": "Issued Items Against Work Order", - "doctype": "Work Order" - }, - { - "type": "report", - "is_query_report": True, - "name": "Production Analytics", - "doctype": "Work Order" - }, - { - "type": "report", - "is_query_report": True, - "name": "BOM Search", - "doctype": "BOM" - }, - { - "type": "report", - "is_query_report": True, - "name": "BOM Stock Report", - "doctype": "BOM" - } - ] - }, - { - "label": _("Help"), - "icon": "fa fa-facetime-video", - "items": [ - { - "type": "help", - "label": _("Bill of Materials"), - "youtube_id": "hDV0c1OeWLo" - }, - { - "type": "help", - "label": _("Work Order"), - "youtube_id": "ZotgLyp2YFY" - }, - ] - } - ] diff --git a/erpnext/config/non_profit.py b/erpnext/config/non_profit.py deleted file mode 100644 index 42ec9d3db35..00000000000 --- a/erpnext/config/non_profit.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Chapter"), - "icon": "fa fa-star", - "items": [ - { - "type": "doctype", - "name": "Chapter", - "description": _("Chapter information."), - "onboard": 1, - } - ] - }, - { - "label": _("Membership"), - "items": [ - { - "type": "doctype", - "name": "Member", - "description": _("Member information."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Membership", - "description": _("Memebership Details"), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Membership Type", - "description": _("Memebership Type Details"), - }, - ] - }, - { - "label": _("Volunteer"), - "items": [ - { - "type": "doctype", - "name": "Volunteer", - "description": _("Volunteer information."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Volunteer Type", - "description": _("Volunteer Type information."), - } - ] - }, - { - "label": _("Donor"), - "items": [ - { - "type": "doctype", - "name": "Donor", - "description": _("Donor information."), - }, - { - "type": "doctype", - "name": "Donor Type", - "description": _("Donor Type information."), - } - ] - }, - { - "label": _("Loan Management"), - "icon": "icon-list", - "items": [ - { - "type": "doctype", - "name": "Loan Type", - "description": _("Define various loan types") - }, - { - "type": "doctype", - "name": "Loan Application", - "description": _("Loan Application") - }, - { - "type": "doctype", - "name": "Loan" - }, - ] - }, - { - "label": _("Grant Application"), - "items": [ - { - "type": "doctype", - "name": "Grant Application", - "description": _("Grant information."), - } - ] - } - ] diff --git a/erpnext/config/projects.py b/erpnext/config/projects.py index 47700d10b2a..ab4db964772 100644 --- a/erpnext/config/projects.py +++ b/erpnext/config/projects.py @@ -16,13 +16,13 @@ def get_data(): { "type": "doctype", "name": "Task", - "route": "#List/Task", + "route": "/app/List/Task", "description": _("Project activity / task."), "onboard": 1, }, { "type": "report", - "route": "#List/Task/Gantt", + "route": "/app/List/Task/Gantt", "doctype": "Task", "name": "Gantt Chart", "description": _("Gantt chart of all tasks."), @@ -97,5 +97,5 @@ def get_data(): }, ] }, - + ] diff --git a/erpnext/config/quality_management.py b/erpnext/config/quality_management.py deleted file mode 100644 index 35acdfab24f..00000000000 --- a/erpnext/config/quality_management.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Goal and Procedure"), - "items": [ - { - "type": "doctype", - "name": "Quality Goal", - "description":_("Quality Goal."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Quality Procedure", - "description":_("Quality Procedure."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Quality Procedure", - "icon": "fa fa-sitemap", - "label": _("Tree of Procedures"), - "route": "#Tree/Quality Procedure", - "description": _("Tree of Quality Procedures."), - }, - ] - }, - { - "label": _("Review and Action"), - "items": [ - { - "type": "doctype", - "name": "Quality Review", - "description":_("Quality Review"), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Quality Action", - "description":_("Quality Action"), - } - ] - }, - { - "label": _("Meeting"), - "items": [ - { - "type": "doctype", - "name": "Quality Meeting", - "description":_("Quality Meeting"), - } - ] - }, - { - "label": _("Feedback"), - "items": [ - { - "type": "doctype", - "name": "Quality Feedback", - "description":_("Quality Feedback"), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Quality Feedback Template", - "description":_("Quality Feedback Template"), - } - ] - }, - ] \ No newline at end of file diff --git a/erpnext/config/retail.py b/erpnext/config/retail.py deleted file mode 100644 index 738be7eb173..00000000000 --- a/erpnext/config/retail.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Retail Operations"), - "items": [ - { - "type": "doctype", - "name": "POS Profile", - "label": _("Point-of-Sale Profile"), - "description": _("Setup default values for POS Invoices"), - "onboard": 1, - }, - { - "type": "page", - "name": "pos", - "label": _("POS"), - "description": _("Point of Sale"), - "onboard": 1, - "dependencies": ["POS Profile"] - }, - { - "type": "doctype", - "name": "Cashier Closing", - "description": _("Cashier Closing"), - }, - { - "type": "doctype", - "name": "POS Settings", - "description": _("Setup mode of POS (Online / Offline)") - }, - { - "type": "doctype", - "name": "Loyalty Program", - "label": _("Loyalty Program"), - "description": _("To make Customer based incentive schemes.") - }, - { - "type": "doctype", - "name": "Loyalty Point Entry", - "label": _("Loyalty Point Entry"), - "description": _("To view logs of Loyalty Points assigned to a Customer.") - } - ] - } - ] \ No newline at end of file diff --git a/erpnext/config/selling.py b/erpnext/config/selling.py deleted file mode 100644 index 5db4cc27021..00000000000 --- a/erpnext/config/selling.py +++ /dev/null @@ -1,320 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Sales"), - "icon": "fa fa-star", - "items": [ - { - "type": "doctype", - "name": "Customer", - "description": _("Customer Database."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Quotation", - "description": _("Quotes to Leads or Customers."), - "onboard": 1, - "dependencies": ["Item", "Customer"], - }, - { - "type": "doctype", - "name": "Sales Order", - "description": _("Confirmed orders from Customers."), - "onboard": 1, - "dependencies": ["Item", "Customer"], - }, - { - "type": "doctype", - "name": "Sales Invoice", - "description": _("Invoices for Costumers."), - "onboard": 1, - "dependencies": ["Item", "Customer"], - }, - { - "type": "doctype", - "name": "Blanket Order", - "description": _("Blanket Orders from Costumers."), - "onboard": 1, - "dependencies": ["Item", "Customer"], - }, - { - "type": "doctype", - "name": "Sales Partner", - "description": _("Manage Sales Partners."), - "dependencies": ["Item"], - }, - { - "type": "doctype", - "label": _("Sales Person"), - "name": "Sales Person", - "icon": "fa fa-sitemap", - "link": "Tree/Sales Person", - "description": _("Manage Sales Person Tree."), - "dependencies": ["Item", "Customer"], - }, - { - "type": "report", - "is_query_report": True, - "name": "Territory Target Variance (Item Group-Wise)", - "route": "#query-report/Territory Target Variance Item Group-Wise", - "doctype": "Territory", - }, - { - "type": "report", - "is_query_report": True, - "name": "Sales Person Target Variance (Item Group-Wise)", - "route": "#query-report/Sales Person Target Variance Item Group-Wise", - "doctype": "Sales Person", - "dependencies": ["Sales Person"], - }, - ] - }, - { - "label": _("Items and Pricing"), - "items": [ - { - "type": "doctype", - "name": "Item", - "description": _("All Products or Services."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Item Price", - "description": _("Multiple Item prices."), - "route": "#Report/Item Price", - "dependencies": ["Item", "Price List"], - "onboard": 1, - }, - { - "type": "doctype", - "name": "Price List", - "description": _("Price List master."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Item Group", - "icon": "fa fa-sitemap", - "label": _("Item Group"), - "link": "Tree/Item Group", - "description": _("Tree of Item Groups."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Product Bundle", - "description": _("Bundle items at time of sale."), - "dependencies": ["Item"], - }, - { - "type": "doctype", - "name": "Promotional Scheme", - "description": _("Rules for applying different promotional schemes.") - }, - { - "type": "doctype", - "name": "Pricing Rule", - "description": _("Rules for applying pricing and discount."), - "dependencies": ["Item"], - }, - { - "type": "doctype", - "name": "Shipping Rule", - "description": _("Rules for adding shipping costs."), - }, - { - "type": "doctype", - "name": "Coupon Code", - "description": _("Define coupon codes."), - } - ] - }, - { - "label": _("Settings"), - "icon": "fa fa-cog", - "items": [ - { - "type": "doctype", - "name": "Selling Settings", - "description": _("Default settings for selling transactions."), - "settings": 1, - }, - { - "type": "doctype", - "name":"Terms and Conditions", - "label": _("Terms and Conditions Template"), - "description": _("Template of terms or contract."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Sales Taxes and Charges Template", - "description": _("Tax template for selling transactions."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Lead Source", - "description": _("Track Leads by Lead Source.") - }, - { - "type": "doctype", - "label": _("Customer Group"), - "name": "Customer Group", - "icon": "fa fa-sitemap", - "link": "Tree/Customer Group", - "description": _("Manage Customer Group Tree."), - }, - { - "type": "doctype", - "name": "Contact", - "description": _("All Contacts."), - }, - { - "type": "doctype", - "name": "Address", - "description": _("All Addresses."), - }, - { - "type": "doctype", - "label": _("Territory"), - "name": "Territory", - "icon": "fa fa-sitemap", - "link": "Tree/Territory", - "description": _("Manage Territory Tree."), - }, - { - "type": "doctype", - "name": "Campaign", - "description": _("Sales campaigns."), - }, - ] - }, - { - "label": _("Key Reports"), - "icon": "fa fa-table", - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Sales Analytics", - "doctype": "Sales Order", - "onboard": 1, - }, - { - "type": "page", - "name": "sales-funnel", - "label": _("Sales Funnel"), - "icon": "fa fa-bar-chart", - "onboard": 1, - }, - { - "type": "report", - "is_query_report": True, - "name": "Customer Acquisition and Loyalty", - "doctype": "Customer", - "icon": "fa fa-bar-chart", - }, - { - "type": "report", - "is_query_report": True, - "name": "Inactive Customers", - "doctype": "Sales Order" - }, - { - "type": "report", - "is_query_report": True, - "name": "Ordered Items To Be Delivered", - "doctype": "Sales Order" - }, - { - "type": "report", - "is_query_report": True, - "name": "Sales Person-wise Transaction Summary", - "doctype": "Sales Order" - }, - { - "type": "report", - "is_query_report": True, - "name": "Item-wise Sales History", - "doctype": "Item" - }, - { - "type": "report", - "is_query_report": True, - "name": "Quotation Trends", - "doctype": "Quotation" - }, - { - "type": "report", - "is_query_report": True, - "name": "Sales Order Trends", - "doctype": "Sales Order" - }, - ] - }, - { - "label": _("Other Reports"), - "icon": "fa fa-list", - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Lead Details", - "doctype": "Lead" - }, - { - "type": "report", - "is_query_report": True, - "name": "Address And Contacts", - "label": _("Customer Addresses And Contacts"), - "doctype": "Address", - "route_options": { - "party_type": "Customer" - } - }, - { - "type": "report", - "is_query_report": True, - "name": "BOM Search", - "doctype": "BOM" - }, - { - "type": "report", - "is_query_report": True, - "name": "Available Stock for Packing Items", - "doctype": "Item", - }, - { - "type": "report", - "is_query_report": True, - "name": "Pending SO Items For Purchase Request", - "doctype": "Sales Order" - }, - { - "type": "report", - "is_query_report": True, - "name": "Customer Credit Balance", - "doctype": "Customer" - }, - { - "type": "report", - "is_query_report": True, - "name": "Customers Without Any Sales Transactions", - "doctype": "Customer" - }, - { - "type": "report", - "is_query_report": True, - "name": "Sales Partners Commission", - "doctype": "Customer" - } - ] - }, - - ] diff --git a/erpnext/config/settings.py b/erpnext/config/settings.py deleted file mode 100644 index 323683a3e6d..00000000000 --- a/erpnext/config/settings.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ -from frappe.desk.moduleview import add_setup_section - -def get_data(): - data = [ - { - "label": _("Settings"), - "icon": "fa fa-wrench", - "items": [ - { - "type": "doctype", - "name": "Global Defaults", - "label": _("ERPNext Settings"), - "description": _("Set Default Values like Company, Currency, Current Fiscal Year, etc."), - "hide_count": True, - "settings": 1, - } - ] - }, - { - "label": _("Printing"), - "icon": "fa fa-print", - "items": [ - { - "type": "doctype", - "name": "Letter Head", - "description": _("Letter Heads for print templates."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Print Heading", - "description": _("Titles for print templates e.g. Proforma Invoice.") - }, - { - "type": "doctype", - "name": "Address Template", - "description": _("Country wise default Address Templates") - }, - { - "type": "doctype", - "name": "Terms and Conditions", - "description": _("Standard contract terms for Sales or Purchase.") - }, - ] - }, - { - "label": _("Help"), - "items": [ - { - "type": "help", - "name": _("Data Import and Export"), - "youtube_id": "6wiriRKPhmg" - }, - { - "type": "help", - "label": _("Setting up Email"), - "youtube_id": "YFYe0DrB95o" - }, - { - "type": "help", - "label": _("Printing and Branding"), - "youtube_id": "cKZHcx1znMc" - }, - { - "type": "help", - "label": _("Users and Permissions"), - "youtube_id": "8Slw1hsTmUI" - }, - { - "type": "help", - "label": _("Workflow"), - "youtube_id": "yObJUg9FxFs" - }, - ] - }, - { - "label": _("Customize"), - "icon": "fa fa-glass", - "items": [ - { - "type": "doctype", - "name": "Authorization Rule", - "description": _("Create rules to restrict transactions based on values.") - } - ] - }, - { - "label": _("Email"), - "icon": "fa fa-envelope", - "items": [ - { - "type": "doctype", - "name": "Email Digest", - "description": _("Create and manage daily, weekly and monthly email digests.") - }, - { - "type": "doctype", - "name": "SMS Settings", - "description": _("Setup SMS gateway settings") - }, - ] - } - ] - - for module, label, icon in ( - ("accounts", _("Accounting"), "fa fa-money"), - ("stock", _("Stock"), "fa fa-truck"), - ("selling", _("Selling"), "fa fa-tag"), - ("buying", _("Buying"), "fa fa-shopping-cart"), - ("hr", _("Human Resources"), "fa fa-group"), - ("support", _("Support"), "fa fa-phone")): - - add_setup_section(data, "erpnext", module, label, icon) - - return data diff --git a/erpnext/config/stock.py b/erpnext/config/stock.py deleted file mode 100644 index dd35f5ab368..00000000000 --- a/erpnext/config/stock.py +++ /dev/null @@ -1,361 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Stock Transactions"), - "items": [ - { - "type": "doctype", - "name": "Stock Entry", - "onboard": 1, - "dependencies": ["Item"], - }, - { - "type": "doctype", - "name": "Delivery Note", - "onboard": 1, - "dependencies": ["Item", "Customer"], - }, - { - "type": "doctype", - "name": "Purchase Receipt", - "onboard": 1, - "dependencies": ["Item", "Supplier"], - }, - { - "type": "doctype", - "name": "Material Request", - "onboard": 1, - "dependencies": ["Item"], - }, - { - "type": "doctype", - "name": "Pick List", - "onboard": 1, - "dependencies": ["Item"], - }, - { - "type": "doctype", - "name": "Delivery Trip" - }, - ] - }, - { - "label": _("Stock Reports"), - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Stock Ledger", - "doctype": "Stock Ledger Entry", - "onboard": 1, - "dependencies": ["Item"], - }, - { - "type": "report", - "is_query_report": True, - "name": "Stock Balance", - "doctype": "Stock Ledger Entry", - "onboard": 1, - "dependencies": ["Item"], - }, - { - "type": "report", - "is_query_report": True, - "name": "Stock Projected Qty", - "doctype": "Item", - "onboard": 1, - "dependencies": ["Item"], - }, - { - "type": "page", - "name": "stock-balance", - "label": _("Stock Summary"), - "dependencies": ["Item"], - }, - { - "type": "report", - "is_query_report": True, - "name": "Stock Ageing", - "doctype": "Item", - "dependencies": ["Item"], - }, - { - "type": "report", - "is_query_report": True, - "name": "Item Price Stock", - "doctype": "Item", - "dependencies": ["Item"], - } - ] - }, - { - "label": _("Settings"), - "icon": "fa fa-cog", - "items": [ - { - "type": "doctype", - "name": "Stock Settings", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Warehouse", - "onboard": 1, - }, - { - "type": "doctype", - "name": "UOM", - "label": _("Unit of Measure") + " (UOM)", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Brand", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Item Attribute", - }, - { - "type": "doctype", - "name": "Item Variant Settings", - }, - ] - }, - { - "label": _("Items and Pricing"), - "items": [ - { - "type": "doctype", - "name": "Item", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Product Bundle", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Item Group", - "icon": "fa fa-sitemap", - "label": _("Item Group"), - "link": "Tree/Item Group", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Price List", - }, - { - "type": "doctype", - "name": "Item Price", - }, - { - "type": "doctype", - "name": "Shipping Rule", - }, - { - "type": "doctype", - "name": "Pricing Rule", - }, - { - "type": "doctype", - "name": "Item Alternative", - }, - { - "type": "doctype", - "name": "Item Manufacturer", - }, - { - "type": "doctype", - "name": "Item Variant Settings", - }, - ] - }, - { - "label": _("Serial No and Batch"), - "items": [ - { - "type": "doctype", - "name": "Serial No", - "onboard": 1, - "dependencies": ["Item"], - }, - { - "type": "doctype", - "name": "Batch", - "onboard": 1, - "dependencies": ["Item"], - }, - { - "type": "doctype", - "name": "Installation Note", - "dependencies": ["Item"], - }, - { - "type": "report", - "name": "Serial No Service Contract Expiry", - "doctype": "Serial No" - }, - { - "type": "report", - "name": "Serial No Status", - "doctype": "Serial No" - }, - { - "type": "report", - "name": "Serial No Warranty Expiry", - "doctype": "Serial No" - }, - ] - }, - { - "label": _("Tools"), - "icon": "fa fa-wrench", - "items": [ - { - "type": "doctype", - "name": "Stock Reconciliation", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Landed Cost Voucher", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Packing Slip", - "onboard": 1, - }, - { - "type": "doctype", - "name": "Quality Inspection", - }, - { - "type": "doctype", - "name": "Quality Inspection Template", - }, - { - "type": "doctype", - "name": "Quick Stock Balance", - }, - ] - }, - { - "label": _("Key Reports"), - "icon": "fa fa-table", - "items": [ - { - "type": "report", - "is_query_report": False, - "name": "Item-wise Price List Rate", - "doctype": "Item Price", - "onboard": 1, - }, - { - "type": "report", - "is_query_report": True, - "name": "Stock Analytics", - "doctype": "Stock Entry", - "onboard": 1, - }, - { - "type": "report", - "is_query_report": True, - "name": "Delivery Note Trends", - "doctype": "Delivery Note" - }, - { - "type": "report", - "is_query_report": True, - "name": "Purchase Receipt Trends", - "doctype": "Purchase Receipt" - }, - { - "type": "report", - "is_query_report": True, - "name": "Ordered Items To Be Delivered", - "doctype": "Delivery Note" - }, - { - "type": "report", - "is_query_report": True, - "name": "Purchase Order Items To Be Received", - "doctype": "Purchase Receipt" - }, - { - "type": "report", - "is_query_report": True, - "name": "Item Shortage Report", - "doctype": "Bin" - }, - { - "type": "report", - "is_query_report": True, - "name": "Batch-Wise Balance History", - "doctype": "Batch" - }, - ] - }, - { - "label": _("Other Reports"), - "icon": "fa fa-list", - "items": [ - { - "type": "report", - "is_query_report": True, - "name": "Requested Items To Be Transferred", - "doctype": "Material Request" - }, - { - "type": "report", - "is_query_report": True, - "name": "Batch Item Expiry Status", - "doctype": "Stock Ledger Entry" - }, - { - "type": "report", - "is_query_report": True, - "name": "Item Prices", - "doctype": "Price List" - }, - { - "type": "report", - "is_query_report": True, - "name": "Itemwise Recommended Reorder Level", - "doctype": "Item" - }, - { - "type": "report", - "is_query_report": True, - "name": "Item Variant Details", - "doctype": "Item" - }, - { - "type": "report", - "is_query_report": True, - "name": "Subcontracted Raw Materials To Be Transferred", - "doctype": "Purchase Order" - }, - { - "type": "report", - "is_query_report": True, - "name": "Subcontracted Item To Be Received", - "doctype": "Purchase Order" - }, - { - "type": "report", - "is_query_report": True, - "name": "Stock and Account Value Comparison", - "doctype": "Stock Ledger Entry" - } - ] - }, - - ] diff --git a/erpnext/config/support.py b/erpnext/config/support.py deleted file mode 100644 index 151c4f743e1..00000000000 --- a/erpnext/config/support.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Issues"), - "items": [ - { - "type": "doctype", - "name": "Issue", - "description": _("Support queries from customers."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Issue Type", - "description": _("Issue Type."), - }, - { - "type": "doctype", - "name": "Issue Priority", - "description": _("Issue Priority."), - } - ] - }, - { - "label": _("Warranty"), - "items": [ - { - "type": "doctype", - "name": "Warranty Claim", - "description": _("Warranty Claim against Serial No."), - }, - { - "type": "doctype", - "name": "Serial No", - "description": _("Single unit of an Item."), - }, - ] - }, - { - "label": _("Service Level Agreement"), - "items": [ - { - "type": "doctype", - "name": "Service Level", - "description": _("Service Level."), - }, - { - "type": "doctype", - "name": "Service Level Agreement", - "description": _("Service Level Agreement."), - } - ] - }, - { - "label": _("Maintenance"), - "items": [ - { - "type": "doctype", - "name": "Maintenance Schedule", - }, - { - "type": "doctype", - "name": "Maintenance Visit", - }, - ] - }, - { - "label": _("Reports"), - "icon": "fa fa-list", - "items": [ - { - "type": "page", - "name": "support-analytics", - "label": _("Support Analytics"), - "icon": "fa fa-bar-chart" - }, - { - "type": "report", - "name": "Minutes to First Response for Issues", - "doctype": "Issue", - "is_query_report": True - }, - { - "type": "report", - "name": "Support Hours", - "doctype": "Issue", - "is_query_report": True - }, - ] - }, - { - "label": _("Settings"), - "icon": "fa fa-list", - "items": [ - { - "type": "doctype", - "name": "Support Settings", - "label": _("Support Settings"), - }, - ] - }, - ] \ No newline at end of file diff --git a/erpnext/config/website.py b/erpnext/config/website.py deleted file mode 100644 index d31b0578812..00000000000 --- a/erpnext/config/website.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Portal"), - "items": [ - { - "type": "doctype", - "name": "Homepage", - "description": _("Settings for website homepage"), - }, - { - "type": "doctype", - "name": "Homepage Section", - "description": _("Add cards or custom sections on homepage"), - }, - { - "type": "doctype", - "name": "Products Settings", - "description": _("Settings for website product listing"), - }, - { - "type": "doctype", - "name": "Shopping Cart Settings", - "label": _("Shopping Cart Settings"), - "description": _("Settings for online shopping cart such as shipping rules, price list etc."), - "hide_count": True - } - ] - } - ] diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index f1c96ded0a5..477ad6a79f0 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -22,13 +22,30 @@ from six import text_type from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions from erpnext.stock.get_item_details import get_item_warehouse, _get_item_tax_template, get_item_tax_map from erpnext.stock.doctype.packed_item.packed_item import make_packing_list +from erpnext.controllers.print_settings import set_print_templates_for_item_table, set_print_templates_for_taxes -force_item_fields = ("item_group", "brand", "stock_uom", "is_fixed_asset", "item_tax_rate", "pricing_rules") +class AccountMissingError(frappe.ValidationError): pass + +force_item_fields = ("item_group", "brand", "stock_uom", "is_fixed_asset", "item_tax_rate", + "pricing_rules", "weight_per_unit", "weight_uom", "total_weight") class AccountsController(TransactionBase): def __init__(self, *args, **kwargs): super(AccountsController, self).__init__(*args, **kwargs) + def get_print_settings(self): + print_setting_fields = [] + items_field = self.meta.get_field('items') + + if items_field and items_field.fieldtype == 'Table': + print_setting_fields += ['compact_item_print', 'print_uom_after_quantity'] + + taxes_field = self.meta.get_field('taxes') + if taxes_field and taxes_field.fieldtype == 'Table': + print_setting_fields += ['print_taxes_with_zero_amount'] + + return print_setting_fields + @property def company_currency(self): if not hasattr(self, "__company_currency"): @@ -73,6 +90,9 @@ class AccountsController(TransactionBase): self.ensure_supplier_is_not_blocked() self.validate_date_with_fiscal_year() + self.validate_inter_company_reference() + + self.set_incoming_rate() if self.meta.get_field("currency"): self.calculate_taxes_and_totals() @@ -105,10 +125,24 @@ class AccountsController(TransactionBase): else: self.validate_deferred_start_and_end_date() + self.set_inter_company_account() + validate_regional(self) + + validate_einvoice_fields(self) + if self.doctype != 'Material Request': apply_pricing_rule_on_transaction(self) + def before_cancel(self): + validate_einvoice_fields(self) + + def on_trash(self): + # delete sl and gl entries on deletion of transaction + if frappe.db.get_single_value('Accounts Settings', 'delete_linked_ledger_entries'): + frappe.db.sql("delete from `tabGL Entry` where voucher_type=%s and voucher_no=%s", (self.doctype, self.name)) + frappe.db.sql("delete from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s", (self.doctype, self.name)) + def validate_deferred_start_and_end_date(self): for d in self.items: if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"): @@ -138,7 +172,7 @@ class AccountsController(TransactionBase): elif self.doctype in ("Quotation", "Purchase Order", "Sales Order"): self.validate_non_invoice_documents_schedule() - def before_print(self): + def before_print(self, settings=None): if self.doctype in ['Purchase Order', 'Sales Order', 'Sales Invoice', 'Purchase Invoice', 'Supplier Quotation', 'Purchase Receipt', 'Delivery Note', 'Quotation']: if self.get("group_same_items"): @@ -151,6 +185,9 @@ class AccountsController(TransactionBase): else: df.set("print_hide", 1) + set_print_templates_for_item_table(self, settings) + set_print_templates_for_taxes(self, settings) + def calculate_paid_amount(self): if hasattr(self, "is_pos") or hasattr(self, "is_paid"): is_paid = self.get("is_pos") or self.get("is_paid") @@ -196,6 +233,17 @@ class AccountsController(TransactionBase): validate_fiscal_year(self.get(date_field), self.fiscal_year, self.company, self.meta.get_label(date_field), self) + def validate_inter_company_reference(self): + if self.doctype not in ('Purchase Invoice', 'Purchase Receipt', 'Purchase Order'): + return + + if self.is_internal_transfer(): + if not (self.get('inter_company_reference') or self.get('inter_company_invoice_reference') + or self.get('inter_company_order_reference')): + 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")) + def validate_due_date(self): if self.get('is_pos'): return @@ -263,6 +311,7 @@ class AccountsController(TransactionBase): if self.doctype == "Quotation" and self.quotation_to == "Customer" and parent_dict.get("party_name"): parent_dict.update({"customer": parent_dict.get("party_name")}) + self.pricing_rules = [] for item in self.get("items"): if item.get("item_code"): args = parent_dict.copy() @@ -271,6 +320,7 @@ class AccountsController(TransactionBase): args["doctype"] = self.doctype args["name"] = self.name args["child_docname"] = item.name + args["ignore_pricing_rule"] = self.ignore_pricing_rule if hasattr(self, 'ignore_pricing_rule') else 0 if not args.get("transaction_date"): args["transaction_date"] = args.get("posting_date") @@ -301,6 +351,7 @@ class AccountsController(TransactionBase): if ret.get("pricing_rules"): self.apply_pricing_rule_on_items(item, ret) + self.set_pricing_rule_details(item, ret) if self.doctype == "Purchase Invoice": self.set_expense_account(for_validate) @@ -322,6 +373,9 @@ class AccountsController(TransactionBase): if item.get('discount_amount'): item.rate = item.price_list_rate - item.discount_amount + if item.get("apply_discount_on_discounted_rate") and pricing_rule_args.get("rate"): + item.rate = pricing_rule_args.get("rate") + elif pricing_rule_args.get('free_item_data'): apply_pricing_rule_for_free_items(self, pricing_rule_args.get('free_item_data')) @@ -335,6 +389,18 @@ class AccountsController(TransactionBase): frappe.msgprint(_("Row {0}: user has not applied the rule {1} on the item {2}") .format(item.idx, frappe.bold(title), frappe.bold(item.item_code))) + def set_pricing_rule_details(self, item_row, args): + pricing_rules = get_applied_pricing_rules(args.get("pricing_rules")) + if not pricing_rules: return + + for pricing_rule in pricing_rules: + self.append("pricing_rules", { + "pricing_rule": pricing_rule, + "item_code": item_row.item_code, + "child_docname": item_row.name, + "rule_applied": True + }) + def set_taxes(self): if not self.meta.get_field("taxes"): return @@ -421,8 +487,10 @@ class AccountsController(TransactionBase): account_currency = get_account_currency(gl_dict.account) if gl_dict.account and self.doctype not in ["Journal Entry", - "Period Closing Voucher", "Payment Entry"]: + "Period Closing Voucher", "Payment Entry", "Purchase Receipt", "Purchase Invoice", "Stock Entry"]: self.validate_account_currency(gl_dict.account, account_currency) + + if gl_dict.account and self.doctype not in ["Journal Entry", "Period Closing Voucher", "Payment Entry"]: set_balance_in_account_currency(gl_dict, account_currency, self.get("conversion_rate"), self.company_currency) @@ -605,8 +673,6 @@ class AccountsController(TransactionBase): from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries if self.doctype in ["Sales Invoice", "Purchase Invoice"]: - if self.is_return: return - if frappe.db.get_single_value('Accounts Settings', 'unlink_payment_on_cancellation_of_invoice'): unlink_ref_doc_from_payment_entries(self) @@ -720,6 +786,21 @@ class AccountsController(TransactionBase): return self._abbr + def raise_missing_debit_credit_account_error(self, party_type, party): + """Raise an error if debit to/credit to account does not exist.""" + db_or_cr = frappe.bold("Debit To") if self.doctype == "Sales Invoice" else frappe.bold("Credit To") + rec_or_pay = "Receivable" if self.doctype == "Sales Invoice" else "Payable" + + link_to_party = frappe.utils.get_link_to_form(party_type, party) + link_to_company = frappe.utils.get_link_to_form("Company", self.company) + + message = _("{0} Account not found against Customer {1}.").format(db_or_cr, frappe.bold(party) or '') + message += "
    " + _("Please set one of the following:") + "
    " + message += "
    • " + _("'Account' in the Accounting section of Customer {0}").format(link_to_party) + "
    • " + message += "
    • " + _("'Default {0} Account' in Company {1}").format(rec_or_pay, link_to_company) + "
    " + + frappe.throw(message, title=_("Account Missing"), exc=AccountMissingError) + def validate_party(self): party_type, party = self.get_party() validate_party_frozen_disabled(party_type, party) @@ -900,6 +981,38 @@ class AccountsController(TransactionBase): else: return frappe.db.get_single_value("Global Defaults", "disable_rounded_total") + def set_inter_company_account(self): + """ + Set intercompany account for inter warehouse transactions + This account will be used in case billing company and internal customer's + representation company is same + """ + + if self.is_internal_transfer() and not self.unrealized_profit_loss_account: + unrealized_profit_loss_account = frappe.db.get_value('Company', self.company, 'unrealized_profit_loss_account') + + if not unrealized_profit_loss_account: + msg = _("Please select Unrealized Profit / Loss account or add default Unrealized Profit / Loss account account for company {0}").format( + frappe.bold(self.company)) + frappe.throw(msg) + + self.unrealized_profit_loss_account = unrealized_profit_loss_account + + def is_internal_transfer(self): + """ + It will an internal transfer if its an internal customer and representation + company is same as billing company + """ + if self.doctype in ('Sales Invoice', 'Delivery Note', 'Sales Order'): + internal_party_field = 'is_internal_customer' + elif self.doctype in ('Purchase Invoice', 'Purchase Receipt', 'Purchase Order'): + internal_party_field = 'is_internal_supplier' + + if self.get(internal_party_field) and (self.represents_company == self.company): + return True + + return False + @frappe.whitelist() def get_tax_rate(account_head): return frappe.db.get_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True) @@ -948,8 +1061,10 @@ def validate_conversion_rate(currency, conversion_rate, conversion_rate_label, c company_currency = frappe.get_cached_value('Company', company, "default_currency") if not conversion_rate: - throw(_("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.").format( - conversion_rate_label, currency, company_currency)) + throw( + _("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.") + .format(conversion_rate_label, currency, company_currency) + ) def validate_taxes_and_charges(tax): @@ -1170,46 +1285,56 @@ def set_child_tax_template_and_map(item, child_item, parent_doc): if child_item.get("item_tax_template"): child_item.item_tax_rate = get_item_tax_map(parent_doc.get('company'), child_item.item_tax_template, as_json=True) -def set_sales_order_defaults(parent_doctype, parent_doctype_name, child_docname, trans_item): +def add_taxes_from_tax_template(child_item, parent_doc): + add_taxes_from_item_tax_template = frappe.db.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template") + + if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template: + tax_map = json.loads(child_item.get("item_tax_rate")) + for tax_type in tax_map: + tax_rate = flt(tax_map[tax_type]) + taxes = parent_doc.get('taxes') or [] + # add new row for tax head only if missing + found = any(tax.account_head == tax_type for tax in taxes) + if not found: + tax_row = parent_doc.append("taxes", {}) + tax_row.update({ + "description" : str(tax_type).split(' - ')[0], + "charge_type" : "On Net Total", + "account_head" : tax_type, + "rate" : tax_rate + }) + if parent_doc.doctype == "Purchase Order": + tax_row.update({ + "category" : "Total", + "add_deduct_tax" : "Add" + }) + tax_row.db_insert() + +def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, trans_item): """ - Returns a Sales Order Item child item containing the default values + Returns a Sales/Purchase Order Item child item containing the default values """ p_doc = frappe.get_doc(parent_doctype, parent_doctype_name) - child_item = frappe.new_doc('Sales Order Item', p_doc, child_docname) + child_item = frappe.new_doc(child_doctype, p_doc, child_docname) item = frappe.get_doc("Item", trans_item.get('item_code')) - child_item.item_code = item.item_code - child_item.item_name = item.item_name - child_item.description = item.description - child_item.delivery_date = trans_item.get('delivery_date') or p_doc.delivery_date - child_item.uom = trans_item.get("uom") or item.stock_uom - conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) - child_item.conversion_factor = flt(trans_item.get('conversion_factor')) or conversion_factor - set_child_tax_template_and_map(item, child_item, p_doc) - child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True) - if not child_item.warehouse: - frappe.throw(_("Cannot find {} for item {}. Please set the same in Item Master or Stock Settings.") - .format(frappe.bold("default warehouse"), frappe.bold(item.item_code))) - return child_item - - -def set_purchase_order_defaults(parent_doctype, parent_doctype_name, child_docname, trans_item): - """ - Returns a Purchase Order Item child item containing the default values - """ - p_doc = frappe.get_doc(parent_doctype, parent_doctype_name) - child_item = frappe.new_doc('Purchase Order Item', p_doc, child_docname) - item = frappe.get_doc("Item", trans_item.get('item_code')) - child_item.item_code = item.item_code - child_item.item_name = item.item_name - child_item.description = item.description - child_item.schedule_date = trans_item.get('schedule_date') or p_doc.schedule_date + for field in ("item_code", "item_name", "description", "item_group"): + child_item.update({field: item.get(field)}) + date_fieldname = "delivery_date" if child_doctype == "Sales Order Item" else "schedule_date" + child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)}) child_item.uom = trans_item.get("uom") or item.stock_uom child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True) conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) child_item.conversion_factor = flt(trans_item.get('conversion_factor')) or conversion_factor - child_item.base_rate = 1 # Initiallize value will update in parent validation - child_item.base_amount = 1 # Initiallize value will update in parent validation + if child_doctype == "Purchase Order Item": + child_item.base_rate = 1 # Initiallize value will update in parent validation + child_item.base_amount = 1 # Initiallize value will update in parent validation + if child_doctype == "Sales Order Item": + child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True) + if not child_item.warehouse: + frappe.throw(_("Cannot find {} for item {}. Please set the same in Item Master or Stock Settings.") + .format(frappe.bold("default warehouse"), frappe.bold(item.item_code))) set_child_tax_template_and_map(item, child_item, p_doc) + add_taxes_from_tax_template(child_item, p_doc) return child_item def validate_and_delete_children(parent, data): @@ -1282,8 +1407,8 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) def get_new_child_item(item_row): - new_child_function = set_sales_order_defaults if parent_doctype == "Sales Order" else set_purchase_order_defaults - return new_child_function(parent_doctype, parent_doctype_name, child_docname, item_row) + child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item" + return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row) def validate_quantity(child_item, d): if parent_doctype == "Sales Order" and flt(d.get("qty")) < flt(child_item.delivered_qty): @@ -1331,24 +1456,26 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil validate_quantity(child_item, d) child_item.qty = flt(d.get("qty")) - precision = child_item.precision("rate") or 2 + rate_precision = child_item.precision("rate") or 2 + conv_fac_precision = child_item.precision("conversion_factor") or 2 + qty_precision = child_item.precision("qty") or 2 - if flt(child_item.billed_amt, precision) > flt(flt(d.get("rate")) * flt(d.get("qty")), precision): + if flt(child_item.billed_amt, rate_precision) > flt(flt(d.get("rate"), rate_precision) * flt(d.get("qty"), qty_precision), rate_precision): frappe.throw(_("Row #{0}: Cannot set Rate if amount is greater than billed amount for Item {1}.") .format(child_item.idx, child_item.item_code)) else: - child_item.rate = flt(d.get("rate")) + child_item.rate = flt(d.get("rate"), rate_precision) if d.get("conversion_factor"): if child_item.stock_uom == child_item.uom: child_item.conversion_factor = 1 else: - child_item.conversion_factor = flt(d.get('conversion_factor')) + child_item.conversion_factor = flt(d.get('conversion_factor'), conv_fac_precision) if d.get("uom"): child_item.uom = d.get("uom") conversion_factor = flt(get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor")) - child_item.conversion_factor = flt(d.get('conversion_factor')) or conversion_factor + child_item.conversion_factor = flt(d.get('conversion_factor'), conv_fac_precision) or conversion_factor if d.get("delivery_date") and parent_doctype == 'Sales Order': child_item.delivery_date = d.get('delivery_date') @@ -1390,6 +1517,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.flags.ignore_validate_update_after_submit = True parent.set_qty_as_per_stock_uom() parent.calculate_taxes_and_totals() + parent.set_total_in_words() if parent_doctype == "Sales Order": make_packing_list(parent) parent.set_gross_profit() @@ -1433,3 +1561,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil @erpnext.allow_regional def validate_regional(doc): pass + +@erpnext.allow_regional +def validate_einvoice_fields(doc): + pass diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index ac567b7deae..219d5295c38 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _, msgprint from frappe.utils import flt,cint, cstr, getdate - +from six import iteritems from erpnext.accounts.party import get_party_details from erpnext.stock.get_item_details import get_conversion_factor from erpnext.buying.utils import validate_for_items, update_last_purchase_rate @@ -16,18 +16,10 @@ from frappe.contacts.doctype.address.address import get_address_display from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget from erpnext.controllers.stock_controller import StockController +from erpnext.controllers.sales_and_purchase_return import get_rate_for_return +from erpnext.stock.utils import get_incoming_rate class BuyingController(StockController): - def __setup__(self): - if hasattr(self, "taxes"): - self.flags.print_taxes_with_zero_amount = cint(frappe.db.get_single_value("Print Settings", - "print_taxes_with_zero_amount")) - self.flags.show_inclusive_tax_in_print = self.is_inclusive_tax() - - self.print_templates = { - "total": "templates/print_formats/includes/total.html", - "taxes": "templates/print_formats/includes/taxes.html" - } def get_feed(self): if self.get("supplier_name"): @@ -62,7 +54,7 @@ class BuyingController(StockController): self.set_landed_cost_voucher_amount() if self.doctype in ("Purchase Receipt", "Purchase Invoice"): - self.update_valuation_rate("items") + self.update_valuation_rate() def set_missing_values(self, for_validate=False): super(BuyingController, self).set_missing_values(for_validate) @@ -94,13 +86,18 @@ class BuyingController(StockController): def validate_stock_or_nonstock_items(self): if self.meta.get_field("taxes") and not self.get_stock_items() and not self.get_asset_items(): - tax_for_valuation = [d for d in self.get("taxes") + msg = _('Tax Category has been changed to "Total" because all the Items are non-stock items') + self.update_tax_category(msg) + + def update_tax_category(self, msg): + tax_for_valuation = [d for d in self.get("taxes") if d.category in ["Valuation", "Valuation and Total"]] - if tax_for_valuation: - for d in tax_for_valuation: - d.category = 'Total' - msgprint(_('Tax Category has been changed to "Total" because all the Items are non-stock items')) + if tax_for_valuation: + for d in tax_for_valuation: + d.category = 'Total' + + msgprint(msg) def validate_asset_return(self): if self.doctype not in ['Purchase Receipt', 'Purchase Invoice'] or not self.is_return: @@ -112,8 +109,8 @@ class BuyingController(StockController): "docstatus": 1 })] if self.is_return and len(not_cancelled_asset): - frappe.throw(_("{} has submitted assets linked to it. You need to cancel the assets to create purchase return.".format(self.return_against)), - title=_("Not Allowed")) + frappe.throw(_("{} has submitted assets linked to it. You need to cancel the assets to create purchase return.") + .format(self.return_against), title=_("Not Allowed")) def get_asset_items(self): if self.doctype not in ['Purchase Order', 'Purchase Invoice', 'Purchase Receipt']: @@ -166,7 +163,7 @@ class BuyingController(StockController): self.in_words = money_in_words(amount, self.currency) # update valuation rate - def update_valuation_rate(self, parentfield): + def update_valuation_rate(self, reset_outgoing_rate=True): """ item_tax_amount is the total tax amount applied on that item stored for valuation @@ -177,7 +174,7 @@ class BuyingController(StockController): stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0 last_item_idx = 1 - for d in self.get(parentfield): + for d in self.get("items"): if d.item_code and d.item_code in stock_and_asset_items: stock_and_asset_items_qty += flt(d.qty) stock_and_asset_items_amount += flt(d.base_net_amount) @@ -187,7 +184,7 @@ class BuyingController(StockController): if d.category in ["Valuation", "Valuation and Total"]]) valuation_amount_adjustment = total_valuation_amount - for i, item in enumerate(self.get(parentfield)): + for i, item in enumerate(self.get("items")): if item.item_code and item.qty and item.item_code in stock_and_asset_items: item_proportion = flt(item.base_net_amount) / stock_and_asset_items_amount if stock_and_asset_items_amount \ else flt(item.qty) / stock_and_asset_items_qty @@ -205,23 +202,83 @@ class BuyingController(StockController): item.conversion_factor = get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 qty_in_stock_uom = flt(item.qty * item.conversion_factor) - rm_supp_cost = flt(item.rm_supp_cost) if self.doctype in ["Purchase Receipt", "Purchase Invoice"] else 0.0 - - landed_cost_voucher_amount = flt(item.landed_cost_voucher_amount) \ - if self.doctype in ["Purchase Receipt", "Purchase Invoice"] else 0.0 - - item.valuation_rate = ((item.base_net_amount + item.item_tax_amount + rm_supp_cost - + landed_cost_voucher_amount) / qty_in_stock_uom) + item.rm_supp_cost = self.get_supplied_items_cost(item.name, reset_outgoing_rate) + item.valuation_rate = ((item.base_net_amount + item.item_tax_amount + item.rm_supp_cost + + flt(item.landed_cost_voucher_amount)) / qty_in_stock_uom) else: item.valuation_rate = 0.0 + def set_incoming_rate(self): + if self.doctype not in ("Purchase Receipt", "Purchase Invoice", "Purchase Order"): + return + + ref_doctype_map = { + "Purchase Order": "Sales Order Item", + "Purchase Receipt": "Delivery Note Item", + "Purchase Invoice": "Sales Invoice Item", + } + + ref_doctype = ref_doctype_map.get(self.doctype) + items = self.get("items") + for d in items: + if not cint(self.get("is_return")): + # Get outgoing rate based on original item cost based on valuation method + + if not d.get(frappe.scrub(ref_doctype)): + outgoing_rate = get_incoming_rate({ + "item_code": d.item_code, + "warehouse": d.get('from_warehouse'), + "posting_date": self.get('posting_date') or self.get('transation_date'), + "posting_time": self.get('posting_time'), + "qty": -1 * flt(d.get('stock_qty')), + "serial_no": d.get('serial_no'), + "company": self.company, + "voucher_type": self.doctype, + "voucher_no": self.name, + "allow_zero_valuation": d.get("allow_zero_valuation") + }, raise_error_if_no_rate=False) + + rate = flt(outgoing_rate * d.conversion_factor, d.precision('rate')) + else: + rate = frappe.db.get_value(ref_doctype, d.get(frappe.scrub(ref_doctype)), 'rate') + + if self.is_internal_transfer(): + if rate != d.rate: + d.rate = rate + d.discount_percentage = 0 + d.discount_amount = 0 + frappe.msgprint(_("Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer") + .format(d.idx), alert=1) + + def get_supplied_items_cost(self, item_row_id, reset_outgoing_rate=True): + supplied_items_cost = 0.0 + for d in self.get("supplied_items"): + if d.reference_name == item_row_id: + if reset_outgoing_rate and frappe.db.get_value('Item', d.rm_item_code, 'is_stock_item'): + rate = get_incoming_rate({ + "item_code": d.rm_item_code, + "warehouse": self.supplier_warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "qty": -1 * d.consumed_qty, + "serial_no": d.serial_no + }) + + if rate > 0: + d.rate = rate + + d.amount = flt(flt(d.consumed_qty) * flt(d.rate), d.precision("amount")) + supplied_items_cost += flt(d.amount) + + return supplied_items_cost + def validate_for_subcontracting(self): if not self.is_subcontracted and self.sub_contracted_items: frappe.throw(_("Please enter 'Is Subcontracted' as Yes or No")) if self.is_subcontracted == "Yes": if self.doctype in ["Purchase Receipt", "Purchase Invoice"] and not self.supplier_warehouse: - frappe.throw(_("Supplier Warehouse mandatory for sub-contracted Purchase Receipt")) + frappe.throw(_("Supplier Warehouse mandatory for sub-contracted {0}").format(self.doctype)) for item in self.get("items"): if item in self.sub_contracted_items and not item.bom: @@ -298,14 +355,14 @@ class BuyingController(StockController): title=_("Limit Crossed")) transferred_batch_qty_map = get_transferred_batch_qty_map(item.purchase_order, item.item_code) - backflushed_batch_qty_map = get_backflushed_batch_qty_map(item.purchase_order, item.item_code) + # backflushed_batch_qty_map = get_backflushed_batch_qty_map(item.purchase_order, item.item_code) for raw_material in transferred_raw_materials + non_stock_items: - rm_item_key = '{}{}'.format(raw_material.rm_item_code, item.purchase_order) + rm_item_key = (raw_material.rm_item_code, item.item_code, item.purchase_order) raw_material_data = backflushed_raw_materials_map.get(rm_item_key, {}) consumed_qty = raw_material_data.get('qty', 0) - consumed_serial_nos = raw_material_data.get('serial_nos', '') + consumed_serial_nos = raw_material_data.get('serial_no', '') consumed_batch_nos = raw_material_data.get('batch_nos', '') transferred_qty = raw_material.qty @@ -330,8 +387,10 @@ class BuyingController(StockController): set_serial_nos(raw_material, consumed_serial_nos, qty) if raw_material.batch_nos: + backflushed_batch_qty_map = raw_material_data.get('consumed_batch', {}) + batches_qty = get_batches_with_qty(raw_material.rm_item_code, raw_material.main_item_code, - qty, transferred_batch_qty_map, backflushed_batch_qty_map) + qty, transferred_batch_qty_map, backflushed_batch_qty_map, item.purchase_order) for batch_data in batches_qty: qty = batch_data['qty'] raw_material.batch_no = batch_data['batch'] @@ -339,31 +398,17 @@ class BuyingController(StockController): else: self.append_raw_material_to_be_backflushed(item, raw_material, qty) - def append_raw_material_to_be_backflushed(self, fg_item_doc, raw_material_data, qty): + def append_raw_material_to_be_backflushed(self, fg_item_row, raw_material_data, qty): rm = self.append('supplied_items', {}) rm.update(raw_material_data) + if not rm.main_item_code: + rm.main_item_code = fg_item_row.item_code + + rm.reference_name = fg_item_row.name rm.required_qty = qty rm.consumed_qty = qty - if not raw_material_data.get('non_stock_item'): - from erpnext.stock.utils import get_incoming_rate - rm.rate = get_incoming_rate({ - "item_code": raw_material_data.rm_item_code, - "warehouse": self.supplier_warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - "qty": -1 * qty, - "serial_no": rm.serial_no - }) - - if not rm.rate: - rm.rate = get_valuation_rate(raw_material_data.rm_item_code, self.supplier_warehouse, - self.doctype, self.name, currency=self.company_currency, company=self.company) - - rm.amount = qty * flt(rm.rate) - fg_item_doc.rm_supp_cost += rm.amount - def update_raw_materials_supplied_based_on_bom(self, item, raw_material_table): exploded_item = 1 if hasattr(item, 'include_exploded_items'): @@ -372,7 +417,7 @@ class BuyingController(StockController): bom_items = get_items_from_bom(item.item_code, item.bom, exploded_item) used_alternative_items = [] - if self.doctype == 'Purchase Receipt' and item.purchase_order: + if self.doctype in ["Purchase Receipt", "Purchase Invoice"] and item.purchase_order: used_alternative_items = get_used_alternative_items(purchase_order = item.purchase_order) raw_materials_cost = 0 @@ -389,7 +434,7 @@ class BuyingController(StockController): reserve_warehouse = None conversion_factor = item.conversion_factor - if (self.doctype == 'Purchase Receipt' and item.purchase_order and + if (self.doctype in ["Purchase Receipt", "Purchase Invoice"] and item.purchase_order and bom_item.item_code in used_alternative_items): alternative_item_data = used_alternative_items.get(bom_item.item_code) bom_item.item_code = alternative_item_data.item_code @@ -417,9 +462,7 @@ class BuyingController(StockController): rm.rm_item_code = bom_item.item_code rm.stock_uom = bom_item.stock_uom rm.required_qty = required_qty - if self.doctype == "Purchase Order" and not rm.reserve_warehouse: - rm.reserve_warehouse = reserve_warehouse - + rm.rate = bom_item.rate rm.conversion_factor = conversion_factor if self.doctype in ["Purchase Receipt", "Purchase Invoice"]: @@ -427,29 +470,8 @@ class BuyingController(StockController): rm.description = bom_item.description if item.batch_no and frappe.db.get_value("Item", rm.rm_item_code, "has_batch_no") and not rm.batch_no: rm.batch_no = item.batch_no - - # get raw materials rate - if self.doctype == "Purchase Receipt": - from erpnext.stock.utils import get_incoming_rate - rm.rate = get_incoming_rate({ - "item_code": bom_item.item_code, - "warehouse": self.supplier_warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - "qty": -1 * required_qty, - "serial_no": rm.serial_no - }) - if not rm.rate: - rm.rate = get_valuation_rate(bom_item.item_code, self.supplier_warehouse, - self.doctype, self.name, currency=self.company_currency, company = self.company) - else: - rm.rate = bom_item.rate - - rm.amount = required_qty * flt(rm.rate) - raw_materials_cost += flt(rm.amount) - - if self.doctype in ("Purchase Receipt", "Purchase Invoice"): - item.rm_supp_cost = raw_materials_cost + elif not rm.reserve_warehouse: + rm.reserve_warehouse = reserve_warehouse def cleanup_raw_materials_supplied(self, parent_items, raw_material_table): """Remove all those child items which are no longer present in main item table""" @@ -491,6 +513,10 @@ class BuyingController(StockController): frappe.throw(_("Row {0}: Conversion Factor is mandatory").format(d.idx)) d.stock_qty = flt(d.qty) * flt(d.conversion_factor) + if self.doctype=="Purchase Receipt" and d.meta.get_field("received_stock_qty"): + # Set Received Qty in Stock UOM + d.received_stock_qty = flt(d.received_qty) * flt(d.conversion_factor, d.precision("conversion_factor")) + def validate_purchase_return(self): for d in self.get("items"): if self.is_return and flt(d.rejected_qty) != 0: @@ -558,7 +584,10 @@ class BuyingController(StockController): or (cint(self.is_return) and self.docstatus==2)): from_warehouse_sle = self.get_sl_entries(d, { "actual_qty": -1 * pr_qty, - "warehouse": d.from_warehouse + "warehouse": d.from_warehouse, + "outgoing_rate": d.rate, + "recalculate_rate": 1, + "dependant_sle_voucher_detail_no": d.name }) sl_entries.append(from_warehouse_sle) @@ -568,28 +597,20 @@ class BuyingController(StockController): "serial_no": cstr(d.serial_no).strip() }) if self.is_return: - filters = { - "voucher_type": self.doctype, - "voucher_no": self.return_against, - "item_code": d.item_code - } - - if (self.doctype == "Purchase Invoice" and self.update_stock - and d.get("purchase_invoice_item")): - filters["voucher_detail_no"] = d.purchase_invoice_item - elif self.doctype == "Purchase Receipt" and d.get("purchase_receipt_item"): - filters["voucher_detail_no"] = d.purchase_receipt_item - - original_incoming_rate = frappe.db.get_value("Stock Ledger Entry", filters, "incoming_rate") + outgoing_rate = get_rate_for_return(self.doctype, self.name, d.item_code, self.return_against, item_row=d) sle.update({ - "outgoing_rate": original_incoming_rate + "outgoing_rate": outgoing_rate, + "recalculate_rate": 1 }) + if d.from_warehouse: + sle.dependant_sle_voucher_detail_no = d.name else: val_rate_db_precision = 6 if cint(self.precision("valuation_rate", d)) <= 6 else 9 incoming_rate = flt(d.valuation_rate, val_rate_db_precision) sle.update({ - "incoming_rate": incoming_rate + "incoming_rate": incoming_rate, + "recalculate_rate": 1 if (self.is_subcontracted and d.bom) or d.from_warehouse else 0 }) sl_entries.append(sle) @@ -597,7 +618,8 @@ class BuyingController(StockController): or (cint(self.is_return) and self.docstatus==1)): from_warehouse_sle = self.get_sl_entries(d, { "actual_qty": -1 * pr_qty, - "warehouse": d.from_warehouse + "warehouse": d.from_warehouse, + "recalculate_rate": 1 }) sl_entries.append(from_warehouse_sle) @@ -645,6 +667,7 @@ class BuyingController(StockController): "item_code": d.rm_item_code, "warehouse": self.supplier_warehouse, "actual_qty": -1*flt(d.consumed_qty), + "dependant_sle_voucher_detail_no": d.reference_name })) def on_submit(self): @@ -792,8 +815,8 @@ class BuyingController(StockController): asset.set(field, None) asset.supplier = None if asset.docstatus == 1 and delete_asset: - frappe.throw(_('Cannot cancel this document as it is linked with submitted asset {0}.\ - Please cancel the it to continue.').format(frappe.utils.get_link_to_form('Asset', asset.name))) + frappe.throw(_('Cannot cancel this document as it is linked with submitted asset {0}. Please cancel it to continue.') + .format(frappe.utils.get_link_to_form('Asset', asset.name))) asset.flags.ignore_validate_update_after_submit = True asset.flags.ignore_mandatory = True @@ -836,6 +859,7 @@ class BuyingController(StockController): else: validate_item_type(self, "is_purchase_item", "purchase") + def get_items_from_bom(item_code, bom, exploded_item=1): doctype = "BOM Item" if not exploded_item else "BOM Explosion Item" @@ -847,6 +871,7 @@ def get_items_from_bom(item_code, bom, exploded_item=1): where t2.parent = t1.name and t1.item = %s and t1.docstatus = 1 and t1.is_active = 1 and t1.name = %s + and t2.sourced_by_supplier = 0 and t2.item_code = t3.name""".format(doctype), (item_code, bom), as_dict=1) @@ -872,7 +897,7 @@ def get_subcontracted_raw_materials_from_se(purchase_order, fg_item): AND se.purpose='Send to Subcontractor' AND se.purchase_order = %s AND IFNULL(sed.t_warehouse, '') != '' - AND sed.subcontracted_item = %s + AND IFNULL(sed.subcontracted_item, '') in ('', %s) GROUP BY sed.item_code, sed.subcontracted_item """ raw_materials = frappe.db.multisql({ @@ -889,39 +914,49 @@ def get_subcontracted_raw_materials_from_se(purchase_order, fg_item): return raw_materials def get_backflushed_subcontracted_raw_materials(purchase_orders): - common_query = """ - SELECT - CONCAT(prsi.rm_item_code, pri.purchase_order) AS item_key, - SUM(prsi.consumed_qty) AS qty, - {serial_no_concat_syntax} AS serial_nos, - {batch_no_concat_syntax} AS batch_nos - FROM `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pri, `tabPurchase Receipt Item Supplied` prsi - WHERE - pr.name = pri.parent - AND pr.name = prsi.parent - AND pri.purchase_order IN %s - AND pri.item_code = prsi.main_item_code - AND pr.docstatus = 1 - GROUP BY prsi.rm_item_code, pri.purchase_order - """ + purchase_receipts = frappe.get_all("Purchase Receipt Item", + fields = ["purchase_order", "item_code", "name", "parent"], + filters={"docstatus": 1, "purchase_order": ("in", list(purchase_orders))}) - backflushed_raw_materials = frappe.db.multisql({ - 'mariadb': common_query.format( - serial_no_concat_syntax="GROUP_CONCAT(prsi.serial_no)", - batch_no_concat_syntax="GROUP_CONCAT(prsi.batch_no)" - ), - 'postgres': common_query.format( - serial_no_concat_syntax="STRING_AGG(prsi.serial_no, ',')", - batch_no_concat_syntax="STRING_AGG(prsi.batch_no, ',')" - ) - }, (purchase_orders, ), as_dict=1) + distinct_purchase_receipts = {} + for pr in purchase_receipts: + key = (pr.purchase_order, pr.item_code, pr.parent) + distinct_purchase_receipts.setdefault(key, []).append(pr.name) backflushed_raw_materials_map = frappe._dict() - for item in backflushed_raw_materials: - backflushed_raw_materials_map.setdefault(item.item_key, item) + for args, references in iteritems(distinct_purchase_receipts): + purchase_receipt_supplied_items = get_supplied_items(args[1], args[2], references) + + for data in purchase_receipt_supplied_items: + pr_key = (data.rm_item_code, data.main_item_code, args[0]) + if pr_key not in backflushed_raw_materials_map: + backflushed_raw_materials_map.setdefault(pr_key, frappe._dict({ + "qty": 0.0, + "serial_no": [], + "batch_no": [], + "consumed_batch": {} + })) + + row = backflushed_raw_materials_map.get(pr_key) + row.qty += data.consumed_qty + + for field in ["serial_no", "batch_no"]: + if data.get(field): + row[field].append(data.get(field)) + + if data.get("batch_no"): + if data.get("batch_no") in row.consumed_batch: + row.consumed_batch[data.get("batch_no")] += data.consumed_qty + else: + row.consumed_batch[data.get("batch_no")] = data.consumed_qty return backflushed_raw_materials_map +def get_supplied_items(item_code, purchase_receipt, references): + return frappe.get_all("Purchase Receipt Item Supplied", + fields=["rm_item_code", "main_item_code", "consumed_qty", "serial_no", "batch_no"], + filters={"main_item_code": item_code, "parent": purchase_receipt, "reference_name": ("in", references)}) + def get_asset_item_details(asset_items): asset_items_data = {} for d in frappe.get_all('Item', fields = ["name", "auto_create_assets", "asset_naming_series"], @@ -1003,14 +1038,15 @@ def get_transferred_batch_qty_map(purchase_order, fg_item): SELECT sed.batch_no, SUM(sed.qty) AS qty, - sed.item_code + sed.item_code, + sed.subcontracted_item FROM `tabStock Entry` se,`tabStock Entry Detail` sed WHERE se.name = sed.parent AND se.docstatus=1 AND se.purpose='Send to Subcontractor' AND se.purchase_order = %s - AND sed.subcontracted_item = %s + AND ifnull(sed.subcontracted_item, '') in ('', %s) AND sed.batch_no IS NOT NULL GROUP BY sed.batch_no, @@ -1018,8 +1054,10 @@ def get_transferred_batch_qty_map(purchase_order, fg_item): """, (purchase_order, fg_item), as_dict=1) for batch_data in transferred_batches: - transferred_batch_qty_map.setdefault((batch_data.item_code, fg_item), {}) - transferred_batch_qty_map[(batch_data.item_code, fg_item)][batch_data.batch_no] = batch_data.qty + key = ((batch_data.item_code, fg_item) + if batch_data.subcontracted_item else (batch_data.item_code, purchase_order)) + transferred_batch_qty_map.setdefault(key, {}) + transferred_batch_qty_map[key][batch_data.batch_no] = batch_data.qty return transferred_batch_qty_map @@ -1056,10 +1094,11 @@ def get_backflushed_batch_qty_map(purchase_order, fg_item): return backflushed_batch_qty_map -def get_batches_with_qty(item_code, fg_item, required_qty, transferred_batch_qty_map, backflushed_batch_qty_map): +def get_batches_with_qty(item_code, fg_item, required_qty, transferred_batch_qty_map, backflushed_batches, po): # Returns available batches to be backflushed based on requirements transferred_batches = transferred_batch_qty_map.get((item_code, fg_item), {}) - backflushed_batches = backflushed_batch_qty_map.get((item_code, fg_item), {}) + if not transferred_batches: + transferred_batches = transferred_batch_qty_map.get((item_code, po), {}) available_batches = [] diff --git a/erpnext/controllers/print_settings.py b/erpnext/controllers/print_settings.py index c41db25253f..e08c400068b 100644 --- a/erpnext/controllers/print_settings.py +++ b/erpnext/controllers/print_settings.py @@ -5,20 +5,34 @@ from __future__ import unicode_literals import frappe from frappe.utils import cint -def print_settings_for_item_table(doc): - +def set_print_templates_for_item_table(doc, settings): doc.print_templates = { - "qty": "templates/print_formats/includes/item_table_qty.html" + "items": "templates/print_formats/includes/items.html", } - doc.hide_in_print_layout = ["uom", "stock_uom"] - doc.flags.compact_item_print = cint(frappe.db.get_single_value("Print Settings", "compact_item_print")) + doc.child_print_templates = { + "items": { + "qty": "templates/print_formats/includes/item_table_qty.html", + } + } - if doc.flags.compact_item_print: - doc.print_templates["description"] = "templates/print_formats/includes/item_table_description.html" - doc.flags.compact_item_fields = ["description", "qty", "rate", "amount"] + if doc.meta.get_field("items"): + doc.meta.get_field("items").hide_in_print_layout = ["uom", "stock_uom"] + + doc.flags.compact_item_fields = ["description", "qty", "rate", "amount"] + + if settings.compact_item_print: + doc.child_print_templates["items"]["description"] =\ + "templates/print_formats/includes/item_table_description.html" doc.flags.format_columns = format_columns +def set_print_templates_for_taxes(doc, settings): + doc.flags.show_inclusive_tax_in_print = doc.is_inclusive_tax() + doc.print_templates.update({ + "total": "templates/print_formats/includes/total.html", + "taxes": "templates/print_formats/includes/taxes.html" + }) + def format_columns(display_columns, compact_fields): compact_fields = compact_fields + ["image", "item_code", "item_name"] final_columns = [] diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index efd4944c342..81f0ad3fed1 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -368,13 +368,17 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): searchfields = meta.get_search_fields() search_columns = '' + search_cond = '' + if searchfields: search_columns = ", " + ", ".join(searchfields) + search_cond = " or " + " or ".join([field + " like %(txt)s" for field in searchfields]) if args.get('warehouse'): searchfields = ['batch.' + field for field in searchfields] if searchfields: search_columns = ", " + ", ".join(searchfields) + search_cond = " or " + " or ".join([field + " like %(txt)s" for field in searchfields]) batch_nos = frappe.db.sql("""select sle.batch_no, round(sum(sle.actual_qty),2), sle.stock_uom, concat('MFG-',batch.manufacturing_date), concat('EXP-',batch.expiry_date) @@ -387,7 +391,8 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): and sle.warehouse = %(warehouse)s and (sle.batch_no like %(txt)s or batch.expiry_date like %(txt)s - or batch.manufacturing_date like %(txt)s) + or batch.manufacturing_date like %(txt)s + {search_cond}) and batch.docstatus < 2 {cond} {match_conditions} @@ -397,7 +402,8 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): search_columns = search_columns, cond=cond, match_conditions=get_match_cond(doctype), - having_clause = having_clause + having_clause = having_clause, + search_cond = search_cond ), args) return batch_nos @@ -409,12 +415,15 @@ def get_batch_no(doctype, txt, searchfield, start, page_len, filters): and item = %(item_code)s and (name like %(txt)s or expiry_date like %(txt)s - or manufacturing_date like %(txt)s) + or manufacturing_date like %(txt)s + {search_cond}) and docstatus < 2 {0} {match_conditions} + order by expiry_date, name desc - limit %(start)s, %(page_len)s""".format(cond, search_columns = search_columns, match_conditions=get_match_cond(doctype)), args) + limit %(start)s, %(page_len)s""".format(cond, search_columns = search_columns, + search_cond = search_cond, match_conditions=get_match_cond(doctype)), args) @frappe.whitelist() @@ -484,6 +493,41 @@ def get_income_account(doctype, txt, searchfield, start, page_len, filters): 'company': filters.get("company", "") }) +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_filtered_dimensions(doctype, txt, searchfield, start, page_len, filters): + from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import get_dimension_filter_map + dimension_filters = get_dimension_filter_map() + dimension_filters = dimension_filters.get((filters.get('dimension'),filters.get('account'))) + query_filters = [] + + meta = frappe.get_meta(doctype) + if meta.is_tree: + query_filters.append(['is_group', '=', 0]) + + if meta.has_field('company'): + query_filters.append(['company', '=', filters.get('company')]) + + if txt: + query_filters.append([searchfield, 'LIKE', "%%%s%%" % txt]) + + if dimension_filters: + if dimension_filters['allow_or_restrict'] == 'Allow': + query_selector = 'in' + else: + query_selector = 'not in' + + if len(dimension_filters['allowed_dimensions']) == 1: + dimensions = tuple(dimension_filters['allowed_dimensions'] * 2) + else: + dimensions = tuple(dimension_filters['allowed_dimensions']) + + query_filters.append(['name', query_selector, dimensions]) + + output = frappe.get_all(doctype, filters=query_filters) + result = [d.name for d in output] + + return [(d,) for d in set(result)] @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs @@ -611,6 +655,34 @@ def get_purchase_invoices(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql(query, filters) +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_healthcare_service_units(doctype, txt, searchfield, start, page_len, filters): + query = """ + select name + from `tabHealthcare Service Unit` + where + is_group = 0 + and company = {company} + and name like {txt}""".format( + company = frappe.db.escape(filters.get('company')), txt = frappe.db.escape('%{0}%'.format(txt))) + + if filters and filters.get('inpatient_record'): + from erpnext.healthcare.doctype.inpatient_medication_entry.inpatient_medication_entry import get_current_healthcare_service_unit + service_unit = get_current_healthcare_service_unit(filters.get('inpatient_record')) + + # if the patient is admitted, then appointments should be allowed against the admission service unit, + # inspite of it being an Inpatient Occupancy service unit + if service_unit: + query += " and (allow_appointments = 1 or name = {service_unit})".format(service_unit = frappe.db.escape(service_unit)) + else: + query += " and allow_appointments = 1" + else: + query += " and allow_appointments = 1" + + return frappe.db.sql(query, filters) + + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_tax_template(doctype, txt, searchfield, start, page_len, filters): diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index afc5f8179f5..de61b35316e 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -203,10 +203,40 @@ def get_already_returned_items(doc): return items +def get_returned_qty_map_for_row(row_name, doctype): + child_doctype = doctype + " Item" + reference_field = "dn_detail" if doctype == "Delivery Note" else frappe.scrub(child_doctype) + + fields = [ + "sum(abs(`tab{0}`.qty)) as qty".format(child_doctype), + "sum(abs(`tab{0}`.stock_qty)) as stock_qty".format(child_doctype) + ] + + if doctype in ("Purchase Receipt", "Purchase Invoice"): + fields += [ + "sum(abs(`tab{0}`.rejected_qty)) as rejected_qty".format(child_doctype), + "sum(abs(`tab{0}`.received_qty)) as received_qty".format(child_doctype) + ] + + if doctype == "Purchase Receipt": + fields += ["sum(abs(`tab{0}`.received_stock_qty)) as received_stock_qty".format(child_doctype)] + + data = frappe.db.get_list(doctype, + fields = fields, + filters = [ + [doctype, "docstatus", "=", 1], + [doctype, "is_return", "=", 1], + [child_doctype, reference_field, "=", row_name] + ]) + + return data[0] + def make_return_doc(doctype, source_name, target_doc=None): from frappe.model.mapper import get_mapped_doc + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos company = frappe.db.get_value("Delivery Note", source_name, "company") default_warehouse_for_sales_return = frappe.db.get_value("Company", company, "default_warehouse_for_sales_return") + def set_missing_values(source, target): doc = frappe.get_doc(target) doc.is_return = 1 @@ -230,6 +260,7 @@ def make_return_doc(doctype, source_name, target_doc=None): if doc.get("is_return"): if doc.doctype == 'Sales Invoice' or doc.doctype == 'POS Invoice': + doc.consolidated_invoice = "" doc.set('payments', []) for data in source.payments: paid_amount = 0.00 @@ -261,30 +292,48 @@ def make_return_doc(doctype, source_name, target_doc=None): doc.run_method("calculate_taxes_and_totals") def update_item(source_doc, target_doc, source_parent): - target_doc.qty = -1* source_doc.qty + target_doc.qty = -1 * source_doc.qty + + if source_doc.serial_no: + returned_serial_nos = get_returned_serial_nos(source_doc, source_parent) + serial_nos = list(set(get_serial_nos(source_doc.serial_no)) - set(returned_serial_nos)) + if serial_nos: + target_doc.serial_no = '\n'.join(serial_nos) + if doctype == "Purchase Receipt": - target_doc.received_qty = -1* source_doc.received_qty - target_doc.rejected_qty = -1* source_doc.rejected_qty - target_doc.qty = -1* source_doc.qty - target_doc.stock_qty = -1 * source_doc.stock_qty + returned_qty_map = get_returned_qty_map_for_row(source_doc.name, doctype) + target_doc.received_qty = -1 * flt(source_doc.received_qty - (returned_qty_map.get('received_qty') or 0)) + target_doc.rejected_qty = -1 * flt(source_doc.rejected_qty - (returned_qty_map.get('rejected_qty') or 0)) + target_doc.qty = -1 * flt(source_doc.qty - (returned_qty_map.get('qty') or 0)) + + target_doc.stock_qty = -1 * flt(source_doc.stock_qty - (returned_qty_map.get('stock_qty') or 0)) + target_doc.received_stock_qty = -1 * flt(source_doc.received_stock_qty - (returned_qty_map.get('received_stock_qty') or 0)) + target_doc.purchase_order = source_doc.purchase_order target_doc.purchase_order_item = source_doc.purchase_order_item target_doc.rejected_warehouse = source_doc.rejected_warehouse target_doc.purchase_receipt_item = source_doc.name elif doctype == "Purchase Invoice": - target_doc.received_qty = -1* source_doc.received_qty - target_doc.rejected_qty = -1* source_doc.rejected_qty - target_doc.qty = -1* source_doc.qty - target_doc.stock_qty = -1 * source_doc.stock_qty + returned_qty_map = get_returned_qty_map_for_row(source_doc.name, doctype) + target_doc.received_qty = -1 * flt(source_doc.received_qty - (returned_qty_map.get('received_qty') or 0)) + target_doc.rejected_qty = -1 * flt(source_doc.rejected_qty - (returned_qty_map.get('rejected_qty') or 0)) + target_doc.qty = -1 * flt(source_doc.qty - (returned_qty_map.get('qty') or 0)) + + target_doc.stock_qty = -1 * flt(source_doc.stock_qty - (returned_qty_map.get('stock_qty') or 0)) target_doc.purchase_order = source_doc.purchase_order target_doc.purchase_receipt = source_doc.purchase_receipt target_doc.rejected_warehouse = source_doc.rejected_warehouse target_doc.po_detail = source_doc.po_detail target_doc.pr_detail = source_doc.pr_detail target_doc.purchase_invoice_item = source_doc.name + target_doc.price_list_rate = 0 elif doctype == "Delivery Note": + returned_qty_map = get_returned_qty_map_for_row(source_doc.name, doctype) + target_doc.qty = -1 * flt(source_doc.qty - (returned_qty_map.get('qty') or 0)) + target_doc.stock_qty = -1 * flt(source_doc.stock_qty - (returned_qty_map.get('stock_qty') or 0)) + target_doc.against_sales_order = source_doc.against_sales_order target_doc.against_sales_invoice = source_doc.against_sales_invoice target_doc.so_detail = source_doc.so_detail @@ -294,12 +343,22 @@ def make_return_doc(doctype, source_name, target_doc=None): if default_warehouse_for_sales_return: target_doc.warehouse = default_warehouse_for_sales_return elif doctype == "Sales Invoice" or doctype == "POS Invoice": + returned_qty_map = get_returned_qty_map_for_row(source_doc.name, doctype) + target_doc.qty = -1 * flt(source_doc.qty - (returned_qty_map.get('qty') or 0)) + target_doc.stock_qty = -1 * flt(source_doc.stock_qty - (returned_qty_map.get('stock_qty') or 0)) + target_doc.sales_order = source_doc.sales_order target_doc.delivery_note = source_doc.delivery_note target_doc.so_detail = source_doc.so_detail target_doc.dn_detail = source_doc.dn_detail target_doc.expense_account = source_doc.expense_account - target_doc.sales_invoice_item = source_doc.name + + if doctype == "Sales Invoice": + target_doc.sales_invoice_item = source_doc.name + else: + target_doc.pos_invoice_item = source_doc.name + + target_doc.price_list_rate = 0 if default_warehouse_for_sales_return: target_doc.warehouse = default_warehouse_for_sales_return @@ -329,3 +388,63 @@ def make_return_doc(doctype, source_name, target_doc=None): }, target_doc, set_missing_values) return doclist + +def get_rate_for_return(voucher_type, voucher_no, item_code, return_against=None, item_row=None, voucher_detail_no=None): + if not return_against: + return_against = frappe.get_cached_value(voucher_type, voucher_no, "return_against") + + return_against_item_field = get_return_against_item_fields(voucher_type) + + filters = get_filters(voucher_type, voucher_no, voucher_detail_no, + return_against, item_code, return_against_item_field, item_row) + + if voucher_type in ("Purchase Receipt", "Purchase Invoice"): + select_field = "incoming_rate" + else: + select_field = "abs(stock_value_difference / actual_qty)" + + return flt(frappe.db.get_value("Stock Ledger Entry", filters, select_field)) + +def get_return_against_item_fields(voucher_type): + return_against_item_fields = { + "Purchase Receipt": "purchase_receipt_item", + "Purchase Invoice": "purchase_invoice_item", + "Delivery Note": "dn_detail", + "Sales Invoice": "sales_invoice_item" + } + return return_against_item_fields[voucher_type] + +def get_filters(voucher_type, voucher_no, voucher_detail_no, return_against, item_code, return_against_item_field, item_row): + filters = { + "voucher_type": voucher_type, + "voucher_no": return_against, + "item_code": item_code + } + + if item_row: + reference_voucher_detail_no = item_row.get(return_against_item_field) + else: + reference_voucher_detail_no = frappe.db.get_value(voucher_type + " Item", voucher_detail_no, return_against_item_field) + + if reference_voucher_detail_no: + filters["voucher_detail_no"] = reference_voucher_detail_no + + return filters + +def get_returned_serial_nos(child_doc, parent_doc): + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + return_ref_field = frappe.scrub(child_doc.doctype) + if child_doc.doctype == "Delivery Note Item": + return_ref_field = "dn_detail" + + serial_nos = [] + + fields = ["`{0}`.`serial_no`".format("tab" + child_doc.doctype)] + + filters = [[parent_doc.doctype, "return_against", "=", parent_doc.name], [parent_doc.doctype, "is_return", "=", 1], + [child_doc.doctype, return_ref_field, "=", child_doc.name], [parent_doc.doctype, "docstatus", "=", 1]] + + for row in frappe.get_all(parent_doc.doctype, fields = fields, filters=filters): + serial_nos.extend(get_serial_nos(row.serial_no)) + + return serial_nos \ No newline at end of file diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 7f7aae31b13..edc40c430ac 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -3,27 +3,19 @@ from __future__ import unicode_literals import frappe -from frappe.utils import cint, flt, cstr, comma_or +from frappe.utils import cint, flt, cstr, get_link_to_form, nowtime from frappe import _, throw from erpnext.stock.get_item_details import get_bin_details from erpnext.stock.utils import get_incoming_rate from erpnext.stock.get_item_details import get_conversion_factor from erpnext.stock.doctype.item.item import set_item_default from frappe.contacts.doctype.address.address import get_address_display +from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.stock_controller import StockController +from erpnext.controllers.sales_and_purchase_return import get_rate_for_return class SellingController(StockController): - def __setup__(self): - if hasattr(self, "taxes"): - self.flags.print_taxes_with_zero_amount = cint(frappe.db.get_single_value("Print Settings", - "print_taxes_with_zero_amount")) - self.flags.show_inclusive_tax_in_print = self.is_inclusive_tax() - - self.print_templates = { - "total": "templates/print_formats/includes/total.html", - "taxes": "templates/print_formats/includes/taxes.html" - } def get_feed(self): return _("To {0} | {1} {2}").format(self.customer_name, self.currency, @@ -41,7 +33,7 @@ class SellingController(StockController): self.validate_max_discount() self.validate_selling_price() self.set_qty_as_per_stock_uom() - self.set_po_nos() + self.set_po_nos(for_validate=True) self.set_gross_profit() set_default_income_account_for_item(self) self.set_customer_address() @@ -53,10 +45,10 @@ class SellingController(StockController): super(SellingController, self).set_missing_values(for_validate) # set contact and address details for customer, if they are not mentioned - self.set_missing_lead_customer_details() + self.set_missing_lead_customer_details(for_validate=for_validate) self.set_price_list_and_item_details(for_validate=for_validate) - def set_missing_lead_customer_details(self): + def set_missing_lead_customer_details(self, for_validate=False): customer, lead = None, None if getattr(self, "customer", None): customer = self.customer @@ -94,6 +86,11 @@ class SellingController(StockController): posting_date=self.get('transaction_date') or self.get('posting_date'), company=self.company)) + if self.get('taxes_and_charges') and not self.get('taxes') and not for_validate: + taxes = get_taxes_and_charges('Sales Taxes and Charges Template', self.taxes_and_charges) + for tax in taxes: + self.append('taxes', tax) + def set_price_list_and_item_details(self, for_validate=False): self.set_price_list_currency("Selling") self.set_missing_item_details(for_validate=for_validate) @@ -145,6 +142,11 @@ class SellingController(StockController): self.base_net_total * sales_person.allocated_percentage / 100.0, self.precision("allocated_amount", sales_person)) + if sales_person.commission_rate: + sales_person.incentives = flt( + sales_person.allocated_amount * flt(sales_person.commission_rate) / 100.0, + self.precision("incentives", sales_person)) + total += sales_person.allocated_percentage if sales_team and total != 100.0: @@ -167,12 +169,16 @@ class SellingController(StockController): def validate_selling_price(self): def throw_message(idx, item_name, rate, ref_rate_field): - frappe.throw(_("""Row #{}: Selling rate for item {} is lower than its {}. Selling rate should be atleast {}""") - .format(idx, item_name, ref_rate_field, rate)) + bold_net_rate = frappe.bold("net rate") + msg = (_("""Row #{}: Selling rate for item {} is lower than its {}. Selling {} should be atleast {}""") + .format(idx, frappe.bold(item_name), frappe.bold(ref_rate_field), bold_net_rate, frappe.bold(rate))) + msg += "

    " + msg += (_("""You can alternatively disable selling price validation in {} to bypass this validation.""") + .format(get_link_to_form("Selling Settings", "Selling Settings"))) + frappe.throw(msg, title=_("Invalid Selling Price")) if not frappe.db.get_single_value("Selling Settings", "validate_selling_price"): return - if hasattr(self, "is_return") and self.is_return: return @@ -181,8 +187,8 @@ class SellingController(StockController): continue last_purchase_rate, is_stock_item = frappe.get_cached_value("Item", it.item_code, ["last_purchase_rate", "is_stock_item"]) - last_purchase_rate_in_sales_uom = last_purchase_rate / (it.conversion_factor or 1) - if flt(it.base_rate) < flt(last_purchase_rate_in_sales_uom): + last_purchase_rate_in_sales_uom = last_purchase_rate * (it.conversion_factor or 1) + if flt(it.base_net_rate) < flt(last_purchase_rate_in_sales_uom): throw_message(it.idx, frappe.bold(it.item_name), last_purchase_rate_in_sales_uom, "last purchase rate") last_valuation_rate = frappe.db.sql(""" @@ -191,8 +197,8 @@ class SellingController(StockController): ORDER BY posting_date DESC, posting_time DESC, creation DESC LIMIT 1 """, (it.item_code, it.warehouse)) if last_valuation_rate: - last_valuation_rate_in_sales_uom = last_valuation_rate[0][0] / (it.conversion_factor or 1) - if is_stock_item and flt(it.base_rate) < flt(last_valuation_rate_in_sales_uom) \ + last_valuation_rate_in_sales_uom = last_valuation_rate[0][0] * (it.conversion_factor or 1) + if is_stock_item and flt(it.base_net_rate) < flt(last_valuation_rate_in_sales_uom) \ and not self.get('is_internal_customer'): throw_message(it.idx, frappe.bold(it.item_name), last_valuation_rate_in_sales_uom, "valuation rate") @@ -220,7 +226,8 @@ class SellingController(StockController): 'voucher_type': self.doctype, 'allow_zero_valuation': d.allow_zero_valuation_rate, 'sales_invoice_item': d.get("sales_invoice_item"), - 'delivery_note_item': d.get("dn_detail") + 'dn_detail': d.get("dn_detail"), + 'incoming_rate': p.get("incoming_rate") })) else: il.append(frappe._dict({ @@ -238,7 +245,8 @@ class SellingController(StockController): 'voucher_type': self.doctype, 'allow_zero_valuation': d.allow_zero_valuation_rate, 'sales_invoice_item': d.get("sales_invoice_item"), - 'delivery_note_item': d.get("dn_detail") + 'dn_detail': d.get("dn_detail"), + 'incoming_rate': d.get("incoming_rate") })) return il @@ -297,77 +305,130 @@ class SellingController(StockController): sales_order.update_reserved_qty(so_item_rows) + def set_incoming_rate(self): + if self.doctype not in ("Delivery Note", "Sales Invoice", "Sales Order"): + return + + items = self.get("items") + (self.get("packed_items") or []) + for d in items: + if not cint(self.get("is_return")): + # Get incoming rate based on original item cost based on valuation method + d.incoming_rate = get_incoming_rate({ + "item_code": d.item_code, + "warehouse": d.warehouse, + "posting_date": self.get('posting_date') or self.get('transaction_date'), + "posting_time": self.get('posting_time') or nowtime(), + "qty": -1 * flt(d.get('stock_qty') or d.get('actual_qty')), + "serial_no": d.get('serial_no'), + "company": self.company, + "voucher_type": self.doctype, + "voucher_no": self.name, + "allow_zero_valuation": d.get("allow_zero_valuation") + }, raise_error_if_no_rate=False) + + # For internal transfers use incoming rate as the valuation rate + if self.is_internal_transfer(): + rate = flt(d.incoming_rate * d.conversion_factor, d.precision('rate')) + if d.rate != rate: + d.rate = rate + d.discount_percentage = 0 + d.discount_amount = 0 + frappe.msgprint(_("Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer") + .format(d.idx), alert=1) + + elif self.get("return_against"): + # Get incoming rate of return entry from reference document + # based on original item cost as per valuation method + d.incoming_rate = get_rate_for_return(self.doctype, self.name, d.item_code, self.return_against, item_row=d) + def update_stock_ledger(self): self.update_reserved_qty() sl_entries = [] + # Loop over items and packed items table for d in self.get_item_list(): if frappe.get_cached_value("Item", d.item_code, "is_stock_item") == 1 and flt(d.qty): if flt(d.conversion_factor)==0.0: d.conversion_factor = get_conversion_factor(d.item_code, d.uom).get("conversion_factor") or 1.0 - return_rate = 0 - if cint(self.is_return) and self.return_against and self.docstatus==1: - against_document_no = (d.get("sales_invoice_item") - if self.doctype == "Sales Invoice" else d.get("delivery_note_item")) - return_rate = self.get_incoming_rate_for_return(d.item_code, - self.return_against, against_document_no) - - # On cancellation or if return entry submission, make stock ledger entry for + # On cancellation or return entry submission, make stock ledger entry for # target warehouse first, to update serial no values properly if d.warehouse and ((not cint(self.is_return) and self.docstatus==1) or (cint(self.is_return) and self.docstatus==2)): - sl_entries.append(self.get_sl_entries(d, { - "actual_qty": -1*flt(d.qty), - "incoming_rate": return_rate - })) + sl_entries.append(self.get_sle_for_source_warehouse(d)) if d.target_warehouse: - target_warehouse_sle = self.get_sl_entries(d, { - "actual_qty": flt(d.qty), - "warehouse": d.target_warehouse - }) - - if self.docstatus == 1: - if not cint(self.is_return): - args = frappe._dict({ - "item_code": d.item_code, - "warehouse": d.warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - "qty": -1*flt(d.qty), - "serial_no": d.serial_no, - "company": d.company, - "voucher_type": d.voucher_type, - "voucher_no": d.name, - "allow_zero_valuation": d.allow_zero_valuation - }) - target_warehouse_sle.update({ - "incoming_rate": get_incoming_rate(args) - }) - else: - target_warehouse_sle.update({ - "outgoing_rate": return_rate - }) - sl_entries.append(target_warehouse_sle) + sl_entries.append(self.get_sle_for_target_warehouse(d)) if d.warehouse and ((not cint(self.is_return) and self.docstatus==2) or (cint(self.is_return) and self.docstatus==1)): - sl_entries.append(self.get_sl_entries(d, { - "actual_qty": -1*flt(d.qty), - "incoming_rate": return_rate - })) + sl_entries.append(self.get_sle_for_source_warehouse(d)) + self.make_sl_entries(sl_entries) - def set_po_nos(self): - if self.doctype in ("Delivery Note", "Sales Invoice") and hasattr(self, "items"): - ref_fieldname = "against_sales_order" if self.doctype == "Delivery Note" else "sales_order" - sales_orders = list(set([d.get(ref_fieldname) for d in self.items if d.get(ref_fieldname)])) - if sales_orders: - po_nos = frappe.get_all('Sales Order', 'po_no', filters = {'name': ('in', sales_orders)}) - if po_nos and po_nos[0].get('po_no'): - self.po_no = ', '.join(list(set([d.po_no for d in po_nos if d.po_no]))) + def get_sle_for_source_warehouse(self, item_row): + sle = self.get_sl_entries(item_row, { + "actual_qty": -1*flt(item_row.qty), + "incoming_rate": item_row.incoming_rate, + "recalculate_rate": cint(self.is_return) + }) + if item_row.target_warehouse and not cint(self.is_return): + sle.dependant_sle_voucher_detail_no = item_row.name + + return sle + + def get_sle_for_target_warehouse(self, item_row): + sle = self.get_sl_entries(item_row, { + "actual_qty": flt(item_row.qty), + "warehouse": item_row.target_warehouse + }) + + if self.docstatus == 1: + if not cint(self.is_return): + sle.update({ + "incoming_rate": item_row.incoming_rate, + "recalculate_rate": 1 + }) + else: + sle.update({ + "outgoing_rate": item_row.incoming_rate + }) + if item_row.warehouse: + sle.dependant_sle_voucher_detail_no = item_row.name + + return sle + + def set_po_nos(self, for_validate=False): + if self.doctype == 'Sales Invoice' and hasattr(self, "items"): + if for_validate and self.po_no: + return + self.set_pos_for_sales_invoice() + if self.doctype == 'Delivery Note' and hasattr(self, "items"): + if for_validate and self.po_no: + return + self.set_pos_for_delivery_note() + + def set_pos_for_sales_invoice(self): + po_nos = [] + if self.po_no: + po_nos.append(self.po_no) + self.get_po_nos('Sales Order', 'sales_order', po_nos) + self.get_po_nos('Delivery Note', 'delivery_note', po_nos) + self.po_no = ', '.join(list(set(x.strip() for x in ','.join(po_nos).split(',')))) + + def set_pos_for_delivery_note(self): + po_nos = [] + if self.po_no: + po_nos.append(self.po_no) + self.get_po_nos('Sales Order', 'against_sales_order', po_nos) + self.get_po_nos('Sales Invoice', 'against_sales_invoice', po_nos) + self.po_no = ', '.join(list(set(x.strip() for x in ','.join(po_nos).split(',')))) + + def get_po_nos(self, ref_doctype, ref_fieldname, po_nos): + doc_list = list(set([d.get(ref_fieldname) for d in self.items if d.get(ref_fieldname)])) + if doc_list: + po_nos += [d.po_no for d in frappe.get_all(ref_doctype, 'po_no', filters = {'name': ('in', doc_list)}) if d.get('po_no')] def set_gross_profit(self): if self.doctype in ["Sales Order", "Quotation"]: @@ -390,28 +451,38 @@ class SellingController(StockController): check_list, chk_dupl_itm = [], [] if cint(frappe.db.get_single_value("Selling Settings", "allow_multiple_items")): return + if self.doctype == "Sales Invoice" and self.is_consolidated: + return + if self.doctype == "POS Invoice": + return for d in self.get('items'): if self.doctype == "Sales Invoice": - e = [d.item_code, d.description, d.warehouse, d.sales_order or d.delivery_note, d.batch_no or ''] - f = [d.item_code, d.description, d.sales_order or d.delivery_note] + stock_items = [d.item_code, d.description, d.warehouse, d.sales_order or d.delivery_note, d.batch_no or ''] + non_stock_items = [d.item_code, d.description, d.sales_order or d.delivery_note] elif self.doctype == "Delivery Note": - e = [d.item_code, d.description, d.warehouse, d.against_sales_order or d.against_sales_invoice, d.batch_no or ''] - f = [d.item_code, d.description, d.against_sales_order or d.against_sales_invoice] + stock_items = [d.item_code, d.description, d.warehouse, d.against_sales_order or d.against_sales_invoice, d.batch_no or ''] + non_stock_items = [d.item_code, d.description, d.against_sales_order or d.against_sales_invoice] elif self.doctype in ["Sales Order", "Quotation"]: - e = [d.item_code, d.description, d.warehouse, ''] - f = [d.item_code, d.description] + stock_items = [d.item_code, d.description, d.warehouse, ''] + non_stock_items = [d.item_code, d.description] if frappe.db.get_value("Item", d.item_code, "is_stock_item") == 1: - if e in check_list: - frappe.throw(_("Note: Item {0} entered multiple times").format(d.item_code)) + duplicate_items_msg = _("Item {0} entered multiple times.").format(frappe.bold(d.item_code)) + duplicate_items_msg += "

    " + duplicate_items_msg += _("Please enable {} in {} to allow same item in multiple rows").format( + frappe.bold("Allow Item to Be Added Multiple Times in a Transaction"), + get_link_to_form("Selling Settings", "Selling Settings") + ) + if stock_items in check_list: + frappe.throw(duplicate_items_msg) else: - check_list.append(e) + check_list.append(stock_items) else: - if f in chk_dupl_itm: - frappe.throw(_("Note: Item {0} entered multiple times").format(d.item_code)) + if non_stock_items in chk_dupl_itm: + frappe.throw(duplicate_items_msg) else: - chk_dupl_itm.append(f) + chk_dupl_itm.append(non_stock_items) def validate_target_warehouse(self): items = self.get("items") + (self.get("packed_items") or []) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 9feac787709..0987d0985ea 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -58,6 +58,7 @@ status_map = { "Delivery Note": [ ["Draft", None], ["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"], + ["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'"], @@ -65,6 +66,7 @@ status_map = { "Purchase Receipt": [ ["Draft", None], ["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"], + ["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'"], @@ -91,6 +93,12 @@ status_map = { ["Open", "eval:self.docstatus == 1 and not self.pos_closing_entry"], ["Closed", "eval:self.docstatus == 1 and self.pos_closing_entry"], ["Cancelled", "eval:self.docstatus == 2"], + ], + "POS Closing Entry": [ + ["Draft", None], + ["Submitted", "eval:self.docstatus == 1"], + ["Queued", "eval:self.status == 'Queued'"], + ["Cancelled", "eval:self.docstatus == 2"], ] } @@ -232,7 +240,7 @@ class StatusUpdater(Document): self._update_children(args, update_modified) - if "percent_join_field" in args: + if "percent_join_field" in args or "percent_join_field_parent" in args: self._update_percent_field_in_targets(args, update_modified) def _update_children(self, args, update_modified): @@ -252,33 +260,43 @@ class StatusUpdater(Document): if not args.get("second_source_extra_cond"): args["second_source_extra_cond"] = "" - args['second_source_condition'] = """ + ifnull((select sum(%(second_source_field)s) + args['second_source_condition'] = frappe.db.sql(""" select ifnull((select sum(%(second_source_field)s) from `tab%(second_source_dt)s` where `%(second_join_field)s`="%(detail_id)s" - and (`tab%(second_source_dt)s`.docstatus=1) %(second_source_extra_cond)s FOR UPDATE), 0)""" % args + and (`tab%(second_source_dt)s`.docstatus=1) + %(second_source_extra_cond)s), 0) """ % args)[0][0] if args['detail_id']: if not args.get("extra_cond"): args["extra_cond"] = "" - frappe.db.sql("""update `tab%(target_dt)s` - set %(target_field)s = ( + args["source_dt_value"] = frappe.db.sql(""" (select ifnull(sum(%(source_field)s), 0) from `tab%(source_dt)s` where `%(join_field)s`="%(detail_id)s" and (docstatus=1 %(cond)s) %(extra_cond)s) - %(second_source_condition)s - ) - %(update_modified)s + """ % args)[0][0] or 0.0 + + if args['second_source_condition']: + args["source_dt_value"] += flt(args['second_source_condition']) + + frappe.db.sql("""update `tab%(target_dt)s` + set %(target_field)s = %(source_dt_value)s %(update_modified)s where name='%(detail_id)s'""" % args) def _update_percent_field_in_targets(self, args, update_modified=True): """Update percent field in parent transaction""" - distinct_transactions = set([d.get(args['percent_join_field']) - for d in self.get_all_children(args['source_dt'])]) + if args.get('percent_join_field_parent'): + # if reference to target doc where % is to be updated, is + # in source doc's parent form, consider percent_join_field_parent + args['name'] = self.get(args['percent_join_field_parent']) + self._update_percent_field(args, update_modified) + else: + distinct_transactions = set([d.get(args['percent_join_field']) + for d in self.get_all_children(args['source_dt'])]) - for name in distinct_transactions: - if name: - args['name'] = name - self._update_percent_field(args, update_modified) + for name in distinct_transactions: + if name: + args['name'] = name + self._update_percent_field(args, update_modified) def _update_percent_field(self, args, update_modified=True): """Update percent field in parent transaction""" diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 394883d2397..f352bae30e4 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -6,7 +6,8 @@ import frappe, erpnext from frappe.utils import cint, flt, cstr, get_link_to_form, today, getdate from frappe import _ import frappe.defaults -from erpnext.accounts.utils import get_fiscal_year +from collections import defaultdict +from erpnext.accounts.utils import get_fiscal_year, check_if_stock_and_account_balance_synced from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries, process_gl_map from erpnext.controllers.accounts_controller import AccountsController from erpnext.stock.stock_ledger import get_valuation_rate @@ -23,8 +24,11 @@ class StockController(AccountsController): self.validate_inspection() self.validate_serialized_batch() self.validate_customer_provided_item() + self.set_rate_of_stock_uom() + self.validate_internal_transfer() + self.validate_putaway_capacity() - def make_gl_entries(self, gl_entries=None): + def make_gl_entries(self, gl_entries=None, from_repost=False): if self.docstatus == 2: make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) @@ -34,12 +38,12 @@ class StockController(AccountsController): if self.docstatus==1: if not gl_entries: gl_entries = self.get_gl_entries(warehouse_account) - make_gl_entries(gl_entries) + make_gl_entries(gl_entries, from_repost=from_repost) elif self.doctype in ['Purchase Receipt', 'Purchase Invoice'] and self.docstatus == 1: gl_entries = [] gl_entries = self.get_asset_gl_entry(gl_entries) - make_gl_entries(gl_entries) + make_gl_entries(gl_entries, from_repost=from_repost) def validate_serialized_batch(self): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -52,7 +56,7 @@ class StockController(AccountsController): frappe.throw(_("Row #{0}: Serial No {1} does not belong to Batch {2}") .format(d.idx, serial_no_data.name, d.batch_no)) - if d.qty > 0 and d.get("batch_no") and self.get("posting_date") and self.docstatus < 2: + if flt(d.qty) > 0.0 and d.get("batch_no") and self.get("posting_date") and self.docstatus < 2: expiry_date = frappe.get_cached_value("Batch", d.get("batch_no"), "expiry_date") if expiry_date and getdate(expiry_date) < getdate(self.posting_date): @@ -70,14 +74,14 @@ class StockController(AccountsController): gl_list = [] warehouse_with_no_account = [] - - precision = frappe.get_precision("GL Entry", "debit_in_account_currency") + precision = self.get_debit_field_precision() for item_row in voucher_details: + sle_list = sle_map.get(item_row.name) if sle_list: for sle in sle_list: if warehouse_account.get(sle.warehouse): - # from warehouse account/ target warehouse account + # from warehouse account self.check_expense_account(item_row) @@ -92,9 +96,16 @@ class StockController(AccountsController): sle = self.update_stock_ledger_entries(sle) + # expense account/ target_warehouse / source_warehouse + if item_row.get('target_warehouse'): + warehouse = item_row.get('target_warehouse') + expense_account = warehouse_account[warehouse]["account"] + else: + expense_account = item_row.expense_account + gl_list.append(self.get_gl_dict({ "account": warehouse_account[sle.warehouse]["account"], - "against": item_row.expense_account, + "against": expense_account, "cost_center": item_row.cost_center, "project": item_row.project or self.get('project'), "remarks": self.get("remarks") or "Accounting Entry for Stock", @@ -102,9 +113,8 @@ class StockController(AccountsController): "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", }, warehouse_account[sle.warehouse]["account_currency"], item=item_row)) - # expense account gl_list.append(self.get_gl_dict({ - "account": item_row.expense_account, + "account": expense_account, "against": warehouse_account[sle.warehouse]["account"], "cost_center": item_row.cost_center, "project": item_row.project or self.get('project'), @@ -119,9 +129,15 @@ class StockController(AccountsController): if warehouse_with_no_account: for wh in warehouse_with_no_account: if frappe.db.get_value("Warehouse", wh, "company"): - frappe.throw(_("Warehouse {0} is not linked to any account, please mention the account in the warehouse record or set default inventory account in company {1}.").format(wh, self.company)) + frappe.throw(_("Warehouse {0} is not linked to any account, please mention the account in the warehouse record or set default inventory account in company {1}.").format(wh, self.company)) - return process_gl_map(gl_list) + return process_gl_map(gl_list, precision=precision) + + def get_debit_field_precision(self): + if not frappe.flags.debit_field_precision: + frappe.flags.debit_field_precision = frappe.get_precision("GL Entry", "debit_in_account_currency") + + return frappe.flags.debit_field_precision def update_stock_ledger_entries(self, sle): sle.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, @@ -211,7 +227,7 @@ class StockController(AccountsController): """, (self.doctype, self.name), as_dict=True) for sle in stock_ledger_entries: - stock_ledger.setdefault(sle.voucher_detail_no, []).append(sle) + stock_ledger.setdefault(sle.voucher_detail_no, []).append(sle) return stock_ledger def make_batches(self, warehouse_field): @@ -229,12 +245,12 @@ class StockController(AccountsController): def check_expense_account(self, item): if not item.get("expense_account"): - frappe.throw(_("Row #{0}: Expense Account not set for Item {1}. Please set an Expense \ - Account in the Items table").format(item.idx, frappe.bold(item.item_code)), - title=_("Expense Account Missing")) + msg = _("Please set an Expense Account in the Items table") + frappe.throw(_("Row #{0}: Expense Account not set for the Item {1}. {2}") + .format(item.idx, frappe.bold(item.item_code), msg), title=_("Expense Account Missing")) else: - is_expense_account = frappe.db.get_value("Account", + is_expense_account = frappe.get_cached_value("Account", item.get("expense_account"), "report_type")=="Profit and Loss" if self.doctype not in ("Purchase Receipt", "Purchase Invoice", "Stock Reconciliation", "Stock Entry") and not is_expense_account: frappe.throw(_("Expense / Difference account ({0}) must be a 'Profit or Loss' account") @@ -247,7 +263,9 @@ class StockController(AccountsController): for d in self.items: if not d.batch_no: continue - serial_nos = [sr.name for sr in frappe.get_all("Serial No", {'batch_no': d.batch_no})] + serial_nos = [sr.name for sr in frappe.get_all("Serial No", + {'batch_no': d.batch_no, 'status': 'Inactive'})] + if serial_nos: frappe.db.set_value("Serial No", { 'name': ['in', serial_nos] }, "batch_no", None) @@ -301,25 +319,8 @@ class StockController(AccountsController): return serialized_items - def get_incoming_rate_for_return(self, item_code, against_document, against_document_no=None): - incoming_rate = 0.0 - cond = '' - if against_document and item_code: - if against_document_no: - cond = " and voucher_detail_no = %s" %(frappe.db.escape(against_document_no)) - - incoming_rate = frappe.db.sql("""select abs(stock_value_difference / actual_qty) - from `tabStock Ledger Entry` - where voucher_type = %s and voucher_no = %s - and item_code = %s {0} limit 1""".format(cond), - (self.doctype, against_document, item_code)) - - incoming_rate = incoming_rate[0][0] if incoming_rate else 0.0 - - return incoming_rate - def validate_warehouse(self): - from erpnext.stock.utils import validate_warehouse_company + from erpnext.stock.utils import validate_warehouse_company, validate_disabled_warehouse warehouses = list(set([d.warehouse for d in self.get("items") if getattr(d, "warehouse", None)])) @@ -335,14 +336,19 @@ class StockController(AccountsController): warehouses.extend(from_warehouse) for w in warehouses: + validate_disabled_warehouse(w) validate_warehouse_company(w, self.company) def update_billing_percentage(self, update_modified=True): + target_ref_field = "amount" + if self.doctype == "Delivery Note": + target_ref_field = "amount - (returned_qty * rate)" + self._update_percent_field({ "target_dt": self.doctype + " Item", "target_parent_dt": self.doctype, "target_parent_field": "per_billed", - "target_ref_field": "amount", + "target_ref_field": target_ref_field, "target_field": "billed_amt", "name": self.name, }, update_modified) @@ -397,19 +403,160 @@ class StockController(AccountsController): if frappe.db.get_value('Item', d.item_code, 'is_customer_provided_item'): d.allow_zero_valuation_rate = 1 -def compare_existing_and_expected_gle(existing_gle, expected_gle): - matched = True - for entry in expected_gle: - account_existed = False - for e in existing_gle: - if entry.account == e.account: - account_existed = True - if entry.account == e.account and entry.against_account == e.against_account \ - and (not entry.cost_center or not e.cost_center or entry.cost_center == e.cost_center) \ - and (entry.debit != e.debit or entry.credit != e.credit): - matched = False - break - if not account_existed: - matched = False - break - return matched + def set_rate_of_stock_uom(self): + if self.doctype in ["Purchase Receipt", "Purchase Invoice", "Purchase Order", "Sales Invoice", "Sales Order", "Delivery Note", "Quotation"]: + for d in self.get("items"): + if d.conversion_factor: + d.stock_uom_rate = d.rate / d.conversion_factor + + def validate_internal_transfer(self): + if self.doctype in ('Sales Invoice', 'Delivery Note', 'Purchase Invoice', 'Purchase Receipt') \ + and self.is_internal_transfer(): + self.validate_in_transit_warehouses() + self.validate_multi_currency() + self.validate_packed_items() + + def validate_in_transit_warehouses(self): + if (self.doctype == 'Sales Invoice' and self.get('update_stock')) or self.doctype == 'Delivery Note': + for item in self.get('items'): + if not item.target_warehouse: + frappe.throw(_("Row {0}: Target Warehouse is mandatory for internal transfers").format(item.idx)) + + if (self.doctype == 'Purchase Invoice' and self.get('update_stock')) or self.doctype == 'Purchase Receipt': + for item in self.get('items'): + if not item.from_warehouse: + frappe.throw(_("Row {0}: From Warehouse is mandatory for internal transfers").format(item.idx)) + + def validate_multi_currency(self): + if self.currency != self.company_currency: + frappe.throw(_("Internal transfers can only be done in company's default currency")) + + def validate_packed_items(self): + if self.doctype in ('Sales Invoice', 'Delivery Note Item') and self.get('packed_items'): + frappe.throw(_("Packed Items cannot be transferred internally")) + + def validate_putaway_capacity(self): + # if over receipt is attempted while 'apply putaway rule' is disabled + # and if rule was applied on the transaction, validate it. + from erpnext.stock.doctype.putaway_rule.putaway_rule import get_available_putaway_capacity + valid_doctype = self.doctype in ("Purchase Receipt", "Stock Entry", "Purchase Invoice", + "Stock Reconciliation") + + if self.doctype == "Purchase Invoice" and self.get("update_stock") == 0: + valid_doctype = False + + if valid_doctype: + rule_map = defaultdict(dict) + for item in self.get("items"): + warehouse_field = "t_warehouse" if self.doctype == "Stock Entry" else "warehouse" + rule = frappe.db.get_value("Putaway Rule", + { + "item_code": item.get("item_code"), + "warehouse": item.get(warehouse_field) + }, + ["name", "disable"], as_dict=True) + if rule: + if rule.get("disabled"): continue # dont validate for disabled rule + + if self.doctype == "Stock Reconciliation": + stock_qty = flt(item.qty) + else: + stock_qty = flt(item.transfer_qty) if self.doctype == "Stock Entry" else flt(item.stock_qty) + + rule_name = rule.get("name") + if not rule_map[rule_name]: + rule_map[rule_name]["warehouse"] = item.get(warehouse_field) + rule_map[rule_name]["item"] = item.get("item_code") + rule_map[rule_name]["qty_put"] = 0 + rule_map[rule_name]["capacity"] = get_available_putaway_capacity(rule_name) + rule_map[rule_name]["qty_put"] += flt(stock_qty) + + for rule, values in rule_map.items(): + if flt(values["qty_put"]) > flt(values["capacity"]): + message = self.prepare_over_receipt_message(rule, values) + frappe.throw(msg=message, title=_("Over Receipt")) + + def prepare_over_receipt_message(self, rule, values): + message = _("{0} qty of Item {1} is being received into Warehouse {2} with capacity {3}.") \ + .format( + frappe.bold(values["qty_put"]), frappe.bold(values["item"]), + frappe.bold(values["warehouse"]), frappe.bold(values["capacity"]) + ) + message += "

    " + rule_link = frappe.utils.get_link_to_form("Putaway Rule", rule) + message += _(" Please adjust the qty or edit {0} to proceed.").format(rule_link) + return message + + def repost_future_sle_and_gle(self): + args = frappe._dict({ + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "voucher_type": self.doctype, + "voucher_no": self.name, + "company": self.company + }) + if future_sle_exists(args): + create_repost_item_valuation_entry(args) + elif not is_reposting_pending(): + check_if_stock_and_account_balance_synced(self.posting_date, + self.company, self.doctype, self.name) + +def is_reposting_pending(): + return frappe.db.exists("Repost Item Valuation", + {'docstatus': 1, 'status': ['in', ['Queued','In Progress']]}) + + +def future_sle_exists(args): + sl_entries = frappe.get_all("Stock Ledger Entry", + filters={"voucher_type": args.voucher_type, "voucher_no": args.voucher_no}, + fields=["item_code", "warehouse"], + order_by="creation asc") + + if not sl_entries: + return + + warehouse_items_map = {} + for entry in sl_entries: + if entry.warehouse not in warehouse_items_map: + warehouse_items_map[entry.warehouse] = set() + + warehouse_items_map[entry.warehouse].add(entry.item_code) + + or_conditions = [] + for warehouse, items in warehouse_items_map.items(): + or_conditions.append( + "warehouse = '{}' and item_code in ({})".format( + warehouse, + ", ".join(frappe.db.escape(item) for item in items) + ) + ) + + return frappe.db.sql(""" + select name + from `tabStock Ledger Entry` + where + ({}) + and timestamp(posting_date, posting_time) + >= timestamp(%(posting_date)s, %(posting_time)s) + and voucher_no != %(voucher_no)s + and is_cancelled = 0 + limit 1 + """.format(" or ".join(or_conditions)), args) + +def create_repost_item_valuation_entry(args): + args = frappe._dict(args) + repost_entry = frappe.new_doc("Repost Item Valuation") + repost_entry.based_on = args.based_on + if not args.based_on: + repost_entry.based_on = 'Transaction' if args.voucher_no else "Item and Warehouse" + repost_entry.voucher_type = args.voucher_type + repost_entry.voucher_no = args.voucher_no + repost_entry.item_code = args.item_code + repost_entry.warehouse = args.warehouse + repost_entry.posting_date = args.posting_date + repost_entry.posting_time = args.posting_time + repost_entry.company = args.company + repost_entry.allow_zero_rate = args.allow_zero_rate + repost_entry.flags.ignore_links = True + repost_entry.save() + repost_entry.submit() diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 92cfdb7f1a1..e329b325b31 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -10,10 +10,13 @@ from erpnext.controllers.accounts_controller import validate_conversion_rate, \ validate_taxes_and_charges, validate_inclusive_tax from erpnext.stock.get_item_details import _get_item_tax_template from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules +from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate class calculate_taxes_and_totals(object): def __init__(self, doc): self.doc = doc + frappe.flags.round_off_applicable_accounts = [] + get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts) self.calculate() def calculate(self): @@ -106,11 +109,14 @@ class calculate_taxes_and_totals(object): elif item.discount_amount and item.pricing_rules: item.rate = item.price_list_rate - item.discount_amount - if item.doctype in ['Quotation Item', 'Sales Order Item', 'Delivery Note Item', 'Sales Invoice Item']: + if item.doctype in ['Quotation Item', 'Sales Order Item', 'Delivery Note Item', 'Sales Invoice Item', 'POS Invoice Item', 'Purchase Invoice Item', 'Purchase Order Item', 'Purchase Receipt Item']: item.rate_with_margin, item.base_rate_with_margin = self.calculate_margin(item) if flt(item.rate_with_margin) > 0: item.rate = flt(item.rate_with_margin * (1.0 - (item.discount_percentage / 100.0)), item.precision("rate")) - item.discount_amount = item.rate_with_margin - item.rate + if not item.discount_amount: + item.discount_amount = item.rate_with_margin - item.rate + elif not item.discount_percentage: + item.rate -= item.discount_amount elif flt(item.price_list_rate) > 0: item.discount_amount = item.price_list_rate - item.rate elif flt(item.price_list_rate) > 0 and not item.discount_amount: @@ -238,9 +244,6 @@ class calculate_taxes_and_totals(object): self.doc.round_floats_in(self.doc, ["total", "base_total", "net_total", "base_net_total"]) - if self.doc.doctype == 'Sales Invoice' and self.doc.is_pos: - self.doc.pos_total_qty = self.doc.total_qty - def calculate_taxes(self): self.doc.rounding_adjustment = 0 # maintain actual tax rate based on idx @@ -334,10 +337,18 @@ class calculate_taxes_and_totals(object): elif tax.charge_type == "On Item Quantity": current_tax_amount = tax_rate * item.qty + current_tax_amount = self.get_final_current_tax_amount(tax, current_tax_amount) self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount) return current_tax_amount + def get_final_current_tax_amount(self, tax, current_tax_amount): + # Some countries need individual tax components to be rounded + # Handeled via regional doctypess + if tax.account_head in frappe.flags.round_off_applicable_accounts: + current_tax_amount = round(current_tax_amount, 0) + return current_tax_amount + def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount): # store tax breakup for each item key = item.item_code or item.item_name @@ -519,6 +530,17 @@ class calculate_taxes_and_totals(object): if self.doc.docstatus == 0: self.calculate_outstanding_amount() + def is_internal_invoice(self): + """ + Checks if its an internal transfer invoice + and decides if to calculate any out standing amount or not + """ + + if self.doc.doctype in ('Sales Invoice', 'Purchase Invoice') and self.doc.is_internal_transfer(): + return True + + return False + def calculate_outstanding_amount(self): # NOTE: # write_off_amount is only for POS Invoice @@ -526,7 +548,8 @@ class calculate_taxes_and_totals(object): if self.doc.doctype == "Sales Invoice": self.calculate_paid_amount() - if self.doc.is_return and self.doc.return_against and not self.doc.get('is_pos'): return + if self.doc.is_return and self.doc.return_against and not self.doc.get('is_pos') or \ + self.is_internal_invoice(): return self.doc.round_floats_in(self.doc, ["grand_total", "total_advance", "write_off_amount"]) self._set_in_company_currency(self.doc, ['write_off_amount']) @@ -603,21 +626,23 @@ class calculate_taxes_and_totals(object): self.doc.precision("base_write_off_amount")) def calculate_margin(self, item): - rate_with_margin = 0.0 base_rate_with_margin = 0.0 if item.price_list_rate: if item.pricing_rules and not self.doc.ignore_pricing_rule: + has_margin = False for d in get_applied_pricing_rules(item.pricing_rules): pricing_rule = frappe.get_cached_doc('Pricing Rule', d) - if (pricing_rule.margin_type == 'Amount' and pricing_rule.currency == self.doc.currency)\ - or (pricing_rule.margin_type == 'Percentage'): + if pricing_rule.margin_rate_or_amount and ((pricing_rule.currency == self.doc.currency and + pricing_rule.margin_type in ['Amount', 'Percentage']) or pricing_rule.margin_type == 'Percentage'): item.margin_type = pricing_rule.margin_type item.margin_rate_or_amount = pricing_rule.margin_rate_or_amount - else: - item.margin_type = None - item.margin_rate_or_amount = 0.0 + has_margin = True + + if not has_margin: + item.margin_type = None + item.margin_rate_or_amount = 0.0 if item.margin_type and item.margin_rate_or_amount: margin_value = item.margin_rate_or_amount if item.margin_type == 'Amount' else flt(item.price_list_rate) * flt(item.margin_rate_or_amount) / 100 @@ -638,7 +663,8 @@ class calculate_taxes_and_totals(object): if default_mode_of_payment: self.doc.append('payments', { 'mode_of_payment': default_mode_of_payment.mode_of_payment, - 'amount': total_amount_to_pay + 'amount': total_amount_to_pay, + 'default': 1 }) else: self.doc.is_pos = 0 @@ -680,6 +706,15 @@ def get_itemised_tax_breakup_html(doc): ) ) +@frappe.whitelist() +def get_round_off_applicable_accounts(company, account_list): + account_list = get_regional_round_off_accounts(company, account_list) + + return account_list + +@erpnext.allow_regional +def get_regional_round_off_accounts(company, account_list): + pass @erpnext.allow_regional def update_itemised_tax_data(doc): @@ -742,3 +777,35 @@ def get_rounded_tax_amount(itemised_tax, precision): for taxes in itemised_tax.values(): for tax_account in taxes: taxes[tax_account]["tax_amount"] = flt(taxes[tax_account]["tax_amount"], precision) + +class init_landed_taxes_and_totals(object): + def __init__(self, doc): + self.doc = doc + self.tax_field = 'taxes' if self.doc.doctype == 'Landed Cost Voucher' else 'additional_costs' + self.set_account_currency() + self.set_exchange_rate() + self.set_amounts_in_company_currency() + + def set_account_currency(self): + company_currency = erpnext.get_company_currency(self.doc.company) + for d in self.doc.get(self.tax_field): + if not d.account_currency: + account_currency = frappe.db.get_value('Account', d.expense_account, 'account_currency') + d.account_currency = account_currency or company_currency + + def set_exchange_rate(self): + company_currency = erpnext.get_company_currency(self.doc.company) + for d in self.doc.get(self.tax_field): + if d.account_currency == company_currency: + d.exchange_rate = 1 + elif not d.exchange_rate: + d.exchange_rate = get_exchange_rate(self.doc.posting_date, account=d.expense_account, + account_currency=d.account_currency, company=self.doc.company) + + if not d.exchange_rate: + frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx)) + + def set_amounts_in_company_currency(self): + for d in self.doc.get(self.tax_field): + d.amount = flt(d.amount, d.precision("amount")) + d.base_amount = flt(d.amount * flt(d.exchange_rate), d.precision("base_amount")) \ No newline at end of file diff --git a/erpnext/controllers/tests/test_item_variant.py b/erpnext/controllers/tests/test_item_variant.py index c257215e718..813f0a00758 100644 --- a/erpnext/controllers/tests/test_item_variant.py +++ b/erpnext/controllers/tests/test_item_variant.py @@ -6,6 +6,7 @@ import unittest from erpnext.stock.doctype.item.test_item import set_item_variant_settings from erpnext.controllers.item_variant import copy_attributes_to_variant, make_variant_item_code +from erpnext.stock.doctype.quality_inspection.test_quality_inspection import create_quality_inspection_parameter from six import string_types @@ -56,6 +57,8 @@ def make_quality_inspection_template(): qc = frappe.new_doc("Quality Inspection Template") qc.quality_inspection_template_name = qc_template + + create_quality_inspection_parameter("Moisture") qc.append('item_quality_inspection_parameter', { "specification": "Moisture", "value": "< 5%", diff --git a/erpnext/crm/desk_page/crm/crm.json b/erpnext/crm/desk_page/crm/crm.json deleted file mode 100644 index d974beb2de8..00000000000 --- a/erpnext/crm/desk_page/crm/crm.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Sales Pipeline", - "links": "[\n {\n \"description\": \"Database of potential customers.\",\n \"label\": \"Lead\",\n \"name\": \"Lead\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Potential opportunities for selling.\",\n \"label\": \"Opportunity\",\n \"name\": \"Opportunity\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Customer database.\",\n \"label\": \"Customer\",\n \"name\": \"Customer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"All Contacts.\",\n \"label\": \"Contact\",\n \"name\": \"Contact\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Record of all communications of type email, phone, chat, visit, etc.\",\n \"label\": \"Communication\",\n \"name\": \"Communication\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Track Leads by Lead Source.\",\n \"label\": \"Lead Source\",\n \"name\": \"Lead Source\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Helps you keep tracks of Contracts based on Supplier, Customer and Employee\",\n \"label\": \"Contract\",\n \"name\": \"Contract\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Helps you manage appointments with your leads\",\n \"label\": \"Appointment\",\n \"name\": \"Appointment\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Newsletter\",\n \"name\": \"Newsletter\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Reports", - "links": "[\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Lead Details\",\n \"name\": \"Lead Details\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"icon\": \"fa fa-bar-chart\",\n \"label\": \"Sales Funnel\",\n \"name\": \"sales-funnel\",\n \"onboard\": 1,\n \"type\": \"page\"\n },\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Prospects Engaged But Not Converted\",\n \"name\": \"Prospects Engaged But Not Converted\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Opportunity\"\n ],\n \"doctype\": \"Opportunity\",\n \"is_query_report\": true,\n \"label\": \"First Response Time for Opportunity\",\n \"name\": \"First Response Time for Opportunity\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Inactive Customers\",\n \"name\": \"Inactive Customers\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Campaign Efficiency\",\n \"name\": \"Campaign Efficiency\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Lead Owner Efficiency\",\n \"name\": \"Lead Owner Efficiency\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Maintenance", - "links": "[\n {\n \"description\": \"Plan for maintenance visits.\",\n \"label\": \"Maintenance Schedule\",\n \"name\": \"Maintenance Schedule\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Visit report for maintenance call.\",\n \"label\": \"Maintenance Visit\",\n \"name\": \"Maintenance Visit\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Warranty Claim against Serial No.\",\n \"label\": \"Warranty Claim\",\n \"name\": \"Warranty Claim\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Campaign", - "links": "[\n {\n \"description\": \"Sales campaigns.\",\n \"label\": \"Campaign\",\n \"name\": \"Campaign\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Sends Mails to lead or contact based on a Campaign schedule\",\n \"label\": \"Email Campaign\",\n \"name\": \"Email Campaign\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Create and Schedule social media posts\",\n \"label\": \"Social Media Post\",\n \"name\": \"Social Media Post\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"description\": \"Manage Customer Group Tree.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Customer Group\",\n \"link\": \"Tree/Customer Group\",\n \"name\": \"Customer Group\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Manage Territory Tree.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Territory\",\n \"link\": \"Tree/Territory\",\n \"name\": \"Territory\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Manage Sales Person Tree.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Sales Person\",\n \"link\": \"Tree/Sales Person\",\n \"name\": \"Sales Person\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Send mass SMS to your contacts\",\n \"label\": \"SMS Center\",\n \"name\": \"SMS Center\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Logs for maintaining sms delivery status\",\n \"label\": \"SMS Log\",\n \"name\": \"SMS Log\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Setup SMS gateway settings\",\n \"label\": \"SMS Settings\",\n \"name\": \"SMS Settings\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Email Group\",\n \"name\": \"Email Group\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Twitter Settings\",\n \"name\": \"Twitter Settings\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"LinkedIn Settings\",\n \"name\": \"LinkedIn Settings\",\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Modules", - "charts": [ - { - "chart_name": "Territory Wise Sales" - } - ], - "creation": "2020-01-23 14:48:30.183272", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "CRM", - "modified": "2020-08-11 18:55:18.238900", - "modified_by": "Administrator", - "module": "CRM", - "name": "CRM", - "onboarding": "CRM", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "color": "#ffe8cd", - "format": "{} Open", - "label": "Lead", - "link_to": "Lead", - "stats_filter": "{\"status\":\"Open\"}", - "type": "DocType" - }, - { - "color": "#cef6d1", - "format": "{} Assigned", - "label": "Opportunity", - "link_to": "Opportunity", - "stats_filter": "{\"_assign\": [\"like\", '%' + frappe.session.user + '%']}", - "type": "DocType" - }, - { - "label": "Customer", - "link_to": "Customer", - "type": "DocType" - }, - { - "label": "Sales Analytics", - "link_to": "Sales Analytics", - "type": "Report" - }, - { - "label": "Dashboard", - "link_to": "CRM", - "type": "Dashboard" - } - ] -} \ No newline at end of file diff --git a/erpnext/crm/doctype/appointment/appointment.py b/erpnext/crm/doctype/appointment/appointment.py index 63efeb3cb61..2009ebf7cba 100644 --- a/erpnext/crm/doctype/appointment/appointment.py +++ b/erpnext/crm/doctype/appointment/appointment.py @@ -126,7 +126,7 @@ class Appointment(Document): add_assignemnt({ 'doctype': self.doctype, 'name': self.name, - 'assign_to': existing_assignee + 'assign_to': [existing_assignee] }) return if self._assign: @@ -139,7 +139,7 @@ class Appointment(Document): add_assignemnt({ 'doctype': self.doctype, 'name': self.name, - 'assign_to': agent + 'assign_to': [agent] }) break diff --git a/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.js b/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.js index 99b82148d2e..dc3ae8bf41a 100644 --- a/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.js +++ b/erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.js @@ -4,7 +4,7 @@ function check_times(frm) { let from_time = Date.parse('01/01/2019 ' + d.from_time); let to_time = Date.parse('01/01/2019 ' + d.to_time); if (from_time > to_time) { - frappe.throw(__(`In row ${i + 1} of Appointment Booking Slots : "To Time" must be later than "From Time"`)); + frappe.throw(__('In row {0} of Appointment Booking Slots: "To Time" must be later than "From Time".', [i + 1])); } }); } \ No newline at end of file diff --git a/erpnext/crm/doctype/contract/contract.js b/erpnext/crm/doctype/contract/contract.js index ee9e8951301..99688551630 100644 --- a/erpnext/crm/doctype/contract/contract.js +++ b/erpnext/crm/doctype/contract/contract.js @@ -1,23 +1,31 @@ // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -cur_frm.add_fetch("contract_template", "contract_terms", "contract_terms"); -cur_frm.add_fetch("contract_template", "requires_fulfilment", "requires_fulfilment"); - -// Add fulfilment terms from contract template into contract frappe.ui.form.on("Contract", { contract_template: function (frm) { - // Populate the fulfilment terms table from a contract template, if any if (frm.doc.contract_template) { - frappe.model.with_doc("Contract Template", frm.doc.contract_template, function () { - var tabletransfer = frappe.model.get_doc("Contract Template", frm.doc.contract_template); - - frm.doc.fulfilment_terms = []; - $.each(tabletransfer.fulfilment_terms, function (index, row) { - var d = frm.add_child("fulfilment_terms"); - d.requirement = row.requirement; - frm.refresh_field("fulfilment_terms"); - }); + frappe.call({ + method: 'erpnext.crm.doctype.contract_template.contract_template.get_contract_template', + args: { + template_name: frm.doc.contract_template, + doc: frm.doc + }, + callback: function(r) { + if (r && r.message) { + let contract_template = r.message.contract_template; + frm.set_value("contract_terms", r.message.contract_terms); + frm.set_value("requires_fulfilment", contract_template.requires_fulfilment); + + if (frm.doc.requires_fulfilment) { + // Populate the fulfilment terms table from a contract template, if any + r.message.contract_template.fulfilment_terms.forEach(element => { + let d = frm.add_child("fulfilment_terms"); + d.requirement = element.requirement; + }); + frm.refresh_field("fulfilment_terms"); + } + } + } }); } } diff --git a/erpnext/crm/doctype/contract/contract.json b/erpnext/crm/doctype/contract/contract.json index 0026e4a02eb..de3230f0e67 100755 --- a/erpnext/crm/doctype/contract/contract.json +++ b/erpnext/crm/doctype/contract/contract.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "creation": "2018-04-12 06:32:04.582486", @@ -247,7 +248,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2020-03-30 06:56:07.257932", + "modified": "2020-12-07 11:15:58.385521", "modified_by": "Administrator", "module": "CRM", "name": "Contract", diff --git a/erpnext/crm/doctype/contract/contract_list.js b/erpnext/crm/doctype/contract/contract_list.js index 2ef59007f4a..26a2907c7cc 100644 --- a/erpnext/crm/doctype/contract/contract_list.js +++ b/erpnext/crm/doctype/contract/contract_list.js @@ -1,12 +1,12 @@ frappe.listview_settings['Contract'] = { - add_fields: ["status"], - get_indicator: function (doc) { - if (doc.status == "Unsigned") { - return [__(doc.status), "red", "status,=," + doc.status]; - } else if (doc.status == "Active") { - return [__(doc.status), "green", "status,=," + doc.status]; - } else if (doc.status == "Inactive") { - return [__(doc.status), "darkgrey", "status,=," + doc.status]; - } - }, + add_fields: ["status"], + get_indicator: function (doc) { + if (doc.status == "Unsigned") { + return [__(doc.status), "red", "status,=," + doc.status]; + } else if (doc.status == "Active") { + return [__(doc.status), "green", "status,=," + doc.status]; + } else if (doc.status == "Inactive") { + return [__(doc.status), "gray", "status,=," + doc.status]; + } + }, }; \ No newline at end of file diff --git a/erpnext/crm/doctype/contract_template/contract_template.json b/erpnext/crm/doctype/contract_template/contract_template.json index ef9974f8636..7cc5ec13cf7 100644 --- a/erpnext/crm/doctype/contract_template/contract_template.json +++ b/erpnext/crm/doctype/contract_template/contract_template.json @@ -11,7 +11,9 @@ "contract_terms", "sb_fulfilment", "requires_fulfilment", - "fulfilment_terms" + "fulfilment_terms", + "section_break_6", + "contract_template_help" ], "fields": [ { @@ -23,8 +25,7 @@ { "fieldname": "contract_terms", "fieldtype": "Text Editor", - "label": "Contract Terms and Conditions", - "read_only": 1 + "label": "Contract Terms and Conditions" }, { "fieldname": "sb_fulfilment", @@ -42,10 +43,20 @@ "fieldtype": "Table", "label": "Fulfilment Terms and Conditions", "options": "Contract Template Fulfilment Terms" + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, + { + "fieldname": "contract_template_help", + "fieldtype": "HTML", + "label": "Contract Template Help", + "options": "

    Contract Template Example

    \n\n
    Contract for Customer {{ party_name }}\n\n-Valid From : {{ start_date }} \n-Valid To : {{ end_date }}\n
    \n\n

    How to get fieldnames

    \n\n

    The field names you can use in your Contract Template are the fields in the Contract for which you are creating the template. You can find out the fields of any documents via Setup > Customize Form View and selecting the document type (e.g. Contract)

    \n\n

    Templating

    \n\n

    Templates are compiled using the Jinja Templating Language. To learn more about Jinja, read this documentation.

    " } ], "links": [], - "modified": "2020-06-03 00:24:58.179816", + "modified": "2020-12-07 10:44:22.587047", "modified_by": "Administrator", "module": "CRM", "name": "Contract Template", diff --git a/erpnext/crm/doctype/contract_template/contract_template.py b/erpnext/crm/doctype/contract_template/contract_template.py index 601ee9a28b3..69fd86f7fb5 100644 --- a/erpnext/crm/doctype/contract_template/contract_template.py +++ b/erpnext/crm/doctype/contract_template/contract_template.py @@ -5,6 +5,27 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document +from frappe.utils.jinja import validate_template +from six import string_types +import json class ContractTemplate(Document): - pass + def validate(self): + if self.contract_terms: + validate_template(self.contract_terms) + +@frappe.whitelist() +def get_contract_template(template_name, doc): + if isinstance(doc, string_types): + doc = json.loads(doc) + + contract_template = frappe.get_doc("Contract Template", template_name) + contract_terms = None + + if contract_template.contract_terms: + contract_terms = frappe.render_template(contract_template.contract_terms, doc) + + return { + 'contract_template': contract_template, + 'contract_terms': contract_terms + } \ No newline at end of file diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index f5f8b4efb34..1b33fd73acf 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -49,6 +49,7 @@ "phone", "mobile_no", "fax", + "website", "more_info", "type", "market_segment", @@ -56,8 +57,8 @@ "request_type", "column_break3", "company", - "website", "territory", + "language", "unsubscribed", "blog_subscriber", "title" @@ -241,6 +242,7 @@ }, { "depends_on": "eval: doc.__islocal", + "description": "Home, Work, etc.", "fieldname": "address_title", "fieldtype": "Data", "label": "Address Title" @@ -249,7 +251,8 @@ "depends_on": "eval: doc.__islocal", "fieldname": "address_line1", "fieldtype": "Data", - "label": "Address Line 1" + "label": "Address Line 1", + "mandatory_depends_on": "eval: doc.address_title && doc.address_type" }, { "depends_on": "eval: doc.__islocal", @@ -261,7 +264,8 @@ "depends_on": "eval: doc.__islocal", "fieldname": "city", "fieldtype": "Data", - "label": "City/Town" + "label": "City/Town", + "mandatory_depends_on": "eval: doc.address_title && doc.address_type" }, { "depends_on": "eval: doc.__islocal", @@ -280,6 +284,7 @@ "fieldname": "country", "fieldtype": "Link", "label": "Country", + "mandatory_depends_on": "eval: doc.address_title && doc.address_type", "options": "Country" }, { @@ -443,13 +448,19 @@ "fieldtype": "Select", "label": "Address Type", "options": "Billing\nShipping\nOffice\nPersonal\nPlant\nPostal\nShop\nSubsidiary\nWarehouse\nCurrent\nPermanent\nOther" + }, + { + "fieldname": "language", + "fieldtype": "Link", + "label": "Print Language", + "options": "Language" } ], "icon": "fa fa-user", "idx": 5, "image_field": "image", "links": [], - "modified": "2020-06-18 14:39:41.835416", + "modified": "2021-01-06 19:39:58.748978", "modified_by": "Administrator", "module": "CRM", "name": "Lead", diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 99fa703feec..d1d096843bf 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -22,7 +22,8 @@ class Lead(SellingController): load_address_and_contact(self) def before_insert(self): - self.address_doc = self.create_address() + if self.address_title and self.address_type: + self.address_doc = self.create_address() self.contact_doc = self.create_contact() def after_insert(self): @@ -133,15 +134,6 @@ class Lead(SellingController): # skipping country since the system auto-sets it from system defaults address = frappe.new_doc("Address") - mandatory_fields = [ df.fieldname for df in address.meta.fields if df.reqd ] - - if not all([self.get(field) for field in mandatory_fields]): - frappe.msgprint(_('Missing mandatory fields in address. \ - {0} to create address' ).format(" Click here "), - alert=True, indicator='yellow') - return - address.update({addr_field: self.get(addr_field) for addr_field in address_fields}) address.update({info_field: self.get(info_field) for info_field in info_fields}) address.insert() @@ -184,13 +176,13 @@ class Lead(SellingController): "phone": self.mobile_no }) - contact.insert() + contact.insert(ignore_permissions=True) return contact def update_links(self): # update address links - if self.address_doc: + if hasattr(self, 'address_doc'): self.address_doc.append("links", { "link_doctype": "Lead", "link_name": self.name, @@ -360,7 +352,7 @@ def get_lead_with_phone_number(number): leads = frappe.get_all('Lead', or_filters={ 'phone': ['like', '%{}'.format(number)], 'mobile_no': ['like', '%{}'.format(number)] - }, limit=1) + }, limit=1, order_by="creation DESC") lead = leads[0].name if leads else None @@ -369,4 +361,4 @@ def get_lead_with_phone_number(number): def daily_open_lead(): leads = frappe.get_all("Lead", filters = [["contact_date", "Between", [nowdate(), nowdate()]]]) for lead in leads: - frappe.db.set_value("Lead", lead.name, "status", "Open") \ No newline at end of file + frappe.db.set_value("Lead", lead.name, "status", "Open") diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js index 08958b7dd65..ac374a95f4e 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.js +++ b/erpnext/crm/doctype/opportunity/opportunity.js @@ -24,6 +24,12 @@ frappe.ui.form.on("Opportunity", { frm.trigger('set_contact_link'); } }, + contact_date: function(frm) { + if(frm.doc.contact_date < frappe.datetime.now_datetime()){ + frm.set_value("contact_date", ""); + frappe.throw(__("Next follow up date should be greater than now.")) + } + }, onload_post_render: function(frm) { frm.get_field("items").grid.set_multiple_add("item_code", "qty"); diff --git a/erpnext/crm/doctype/opportunity/opportunity.json b/erpnext/crm/doctype/opportunity/opportunity.json index eee13f7e799..2e09a76c0f6 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.json +++ b/erpnext/crm/doctype/opportunity/opportunity.json @@ -54,6 +54,7 @@ "campaign", "column_break1", "transaction_date", + "language", "amended_from", "lost_reasons" ], @@ -419,12 +420,18 @@ "fieldtype": "Duration", "label": "First Response Time", "read_only": 1 + }, + { + "fieldname": "language", + "fieldtype": "Link", + "label": "Print Language", + "options": "Language" } ], "icon": "fa fa-info-sign", "idx": 195, "links": [], - "modified": "2020-08-12 17:34:35.066961", + "modified": "2021-01-06 19:42:46.190051", "modified_by": "Administrator", "module": "CRM", "name": "Opportunity", diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 47b05f306b7..0522ace1e53 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -248,7 +248,6 @@ def make_quotation(source_name, target_doc=None): "doctype": "Quotation", "field_map": { "opportunity_from": "quotation_to", - "opportunity_type": "order_type", "name": "enq_no", } }, diff --git a/erpnext/crm/doctype/utils.py b/erpnext/crm/doctype/utils.py index 885ef0584d2..f244daffea3 100644 --- a/erpnext/crm/doctype/utils.py +++ b/erpnext/crm/doctype/utils.py @@ -78,7 +78,9 @@ def get_scheduled_employees_for_popup(communication_medium): def strip_number(number): if not number: return - # strip 0 from the start of the number for proper number comparisions + # strip + and 0 from the start of the number for proper number comparisions + # eg. +7888383332 should match with 7888383332 # eg. 07888383332 should match with 7888383332 + number = number.lstrip('+') number = number.lstrip('0') - return number \ No newline at end of file + return number diff --git a/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json b/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json index 9f996d9e2be..0ee9317c852 100644 --- a/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json +++ b/erpnext/crm/onboarding_step/create_opportunity/create_opportunity.json @@ -8,12 +8,12 @@ "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 17:38:27.496696", + "modified": "2021-01-21 15:28:52.483839", "modified_by": "Administrator", "name": "Create Opportunity", "owner": "Administrator", "reference_document": "Opportunity", - "show_full_form": 0, + "show_full_form": 1, "title": "Create Opportunity", "validate_action": 1 } \ No newline at end of file diff --git a/erpnext/crm/report/prospects_engaged_but_not_converted/prospects_engaged_but_not_converted.py b/erpnext/crm/report/prospects_engaged_but_not_converted/prospects_engaged_but_not_converted.py index b538a581891..3a9d57d6075 100644 --- a/erpnext/crm/report/prospects_engaged_but_not_converted/prospects_engaged_but_not_converted.py +++ b/erpnext/crm/report/prospects_engaged_but_not_converted/prospects_engaged_but_not_converted.py @@ -19,15 +19,50 @@ def set_defaut_value_for_filters(filters): if not filters.get('lead_age'): filters["lead_age"] = 60 def get_columns(): - return [ - _("Lead") + ":Link/Lead:100", - _("Name") + "::100", - _("Organization") + "::100", - _("Reference Document") + "::150", - _("Reference Name") + ":Dynamic Link/"+_("Reference Document")+":120", - _("Last Communication") + ":Data:200", - _("Last Communication Date") + ":Date:180" - ] + columns = [{ + "label": _("Lead"), + "fieldname": "lead", + "fieldtype": "Link", + "options": "Lead", + "width": 130 + }, + { + "label": _("Name"), + "fieldname": "name", + "width": 120 + }, + { + "label": _("Organization"), + "fieldname": "organization", + "width": 120 + }, + { + "label": _("Reference Document Type"), + "fieldname": "reference_document_type", + "fieldtype": "Link", + "options": "Doctype", + "width": 100 + }, + { + "label": _("Reference Name"), + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "options": "reference_document_type", + "width": 140 + }, + { + "label": _("Last Communication"), + "fieldname": "last_communication", + "fieldtype": "Data", + "width": 200 + }, + { + "label": _("Last Communication Date"), + "fieldname": "last_communication_date", + "fieldtype": "Date", + "width": 100 + }] + return columns def get_data(filters): lead_details = [] diff --git a/erpnext/crm/workspace/crm/crm.json b/erpnext/crm/workspace/crm/crm.json new file mode 100644 index 00000000000..b4fb7d8abe9 --- /dev/null +++ b/erpnext/crm/workspace/crm/crm.json @@ -0,0 +1,407 @@ +{ + "category": "Modules", + "charts": [ + { + "chart_name": "Territory Wise Sales" + } + ], + "creation": "2020-01-23 14:48:30.183272", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "crm", + "idx": 0, + "is_standard": 1, + "label": "CRM", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Sales Pipeline", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lead", + "link_to": "Lead", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Opportunity", + "link_to": "Opportunity", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customer", + "link_to": "Customer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Contact", + "link_to": "Contact", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Communication", + "link_to": "Communication", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lead Source", + "link_to": "Lead Source", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Contract", + "link_to": "Contract", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Appointment", + "link_to": "Appointment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Newsletter", + "link_to": "Newsletter", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Lead", + "hidden": 0, + "is_query_report": 1, + "label": "Lead Details", + "link_to": "Lead Details", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Funnel", + "link_to": "sales-funnel", + "link_type": "Page", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Lead", + "hidden": 0, + "is_query_report": 1, + "label": "Prospects Engaged But Not Converted", + "link_to": "Prospects Engaged But Not Converted", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Opportunity", + "hidden": 0, + "is_query_report": 1, + "label": "First Response Time for Opportunity", + "link_to": "First Response Time for Opportunity", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Inactive Customers", + "link_to": "Inactive Customers", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Lead", + "hidden": 0, + "is_query_report": 1, + "label": "Campaign Efficiency", + "link_to": "Campaign Efficiency", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Lead", + "hidden": 0, + "is_query_report": 1, + "label": "Lead Owner Efficiency", + "link_to": "Lead Owner Efficiency", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Maintenance", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Maintenance Schedule", + "link_to": "Maintenance Schedule", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Maintenance Visit", + "link_to": "Maintenance Visit", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Warranty Claim", + "link_to": "Warranty Claim", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Campaign", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Campaign", + "link_to": "Campaign", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Campaign", + "link_to": "Email Campaign", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Social Media Post", + "link_to": "Social Media Post", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customer Group", + "link_to": "Customer Group", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Territory", + "link_to": "Territory", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Person", + "link_to": "Sales Person", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Center", + "link_to": "SMS Center", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Log", + "link_to": "SMS Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Settings", + "link_to": "SMS Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Group", + "link_to": "Email Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Twitter Settings", + "link_to": "Twitter Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "LinkedIn Settings", + "link_to": "LinkedIn Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:36.871352", + "modified_by": "Administrator", + "module": "CRM", + "name": "CRM", + "onboarding": "CRM", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "color": "Blue", + "format": "{} Open", + "label": "Lead", + "link_to": "Lead", + "stats_filter": "{\"status\":\"Open\"}", + "type": "DocType" + }, + { + "color": "Blue", + "format": "{} Assigned", + "label": "Opportunity", + "link_to": "Opportunity", + "stats_filter": "{\"_assign\": [\"like\", '%' + frappe.session.user + '%']}", + "type": "DocType" + }, + { + "label": "Customer", + "link_to": "Customer", + "type": "DocType" + }, + { + "label": "Sales Analytics", + "link_to": "Sales Analytics", + "type": "Report" + }, + { + "label": "Dashboard", + "link_to": "CRM", + "type": "Dashboard" + } + ] +} \ No newline at end of file diff --git a/erpnext/demo/data/asset.json b/erpnext/demo/data/asset.json index 23029ca5e36..44db2ae9e1b 100644 --- a/erpnext/demo/data/asset.json +++ b/erpnext/demo/data/asset.json @@ -4,48 +4,55 @@ "item_code": "Computer", "gross_purchase_amount": 100000, "asset_owner": "Company", - "available_for_use_date": "2017-01-02" + "available_for_use_date": "2017-01-02", + "location": "Main Location" }, { "asset_name": "Macbook Air - 1", "item_code": "Computer", "gross_purchase_amount": 60000, "asset_owner": "Company", - "available_for_use_date": "2017-10-02" + "available_for_use_date": "2017-10-02", + "location": "Avg Location" }, { "asset_name": "Conferrence Table", "item_code": "Table", "gross_purchase_amount": 30000, "asset_owner": "Company", - "available_for_use_date": "2018-10-02" + "available_for_use_date": "2018-10-02", + "location": "Zany Location" }, { "asset_name": "Lunch Table", "item_code": "Table", "gross_purchase_amount": 20000, "asset_owner": "Company", - "available_for_use_date": "2018-06-02" + "available_for_use_date": "2018-06-02", + "location": "Fletcher Location" }, { "asset_name": "ERPNext", "item_code": "ERP", "gross_purchase_amount": 100000, "asset_owner": "Company", - "available_for_use_date": "2018-09-02" + "available_for_use_date": "2018-09-02", + "location":"Main Location" }, { "asset_name": "Chair 1", "item_code": "Chair", "gross_purchase_amount": 10000, "asset_owner": "Company", - "available_for_use_date": "2018-07-02" + "available_for_use_date": "2018-07-02", + "location": "Zany Location" }, { "asset_name": "Chair 2", "item_code": "Chair", "gross_purchase_amount": 10000, "asset_owner": "Company", - "available_for_use_date": "2018-07-02" + "available_for_use_date": "2018-07-02", + "location": "Avg Location" } ] diff --git a/erpnext/demo/data/location.json b/erpnext/demo/data/location.json new file mode 100644 index 00000000000..b521aa08c48 --- /dev/null +++ b/erpnext/demo/data/location.json @@ -0,0 +1,22 @@ +[ + { + "location_name": "Main Location", + "latitude": 40.0, + "longitude": 20.0 + }, + { + "location_name": "Avg Location", + "latitude": 63.0, + "longitude": 99.3 + }, + { + "location_name": "Zany Location", + "latitude": 47.5, + "longitude": 10.0 + }, + { + "location_name": "Fletcher Location", + "latitude": 100.90, + "longitude": 80 + } +] \ No newline at end of file diff --git a/erpnext/demo/setup/manufacture.py b/erpnext/demo/setup/manufacture.py index d3846369cd0..7d6b5012ea6 100644 --- a/erpnext/demo/setup/manufacture.py +++ b/erpnext/demo/setup/manufacture.py @@ -9,6 +9,7 @@ from erpnext.demo.domains import data from six import iteritems def setup_data(): + import_json("Location") import_json("Asset Category") setup_item() setup_workstation() diff --git a/erpnext/demo/setup/setup_data.py b/erpnext/demo/setup/setup_data.py index a395c7c17ae..05ee28a24a4 100644 --- a/erpnext/demo/setup/setup_data.py +++ b/erpnext/demo/setup/setup_data.py @@ -134,7 +134,7 @@ def setup_employee(): salary_component = frappe.get_doc('Salary Component', d.name) salary_component.append('accounts', dict( company=erpnext.get_default_company(), - default_account=frappe.get_value('Account', dict(account_name=('like', 'Salary%'))) + account=frappe.get_value('Account', dict(account_name=('like', 'Salary%'))) )) salary_component.save() diff --git a/erpnext/demo/user/purchase.py b/erpnext/demo/user/purchase.py index 86757dfaaa5..b7aca79cf9b 100644 --- a/erpnext/demo/user/purchase.py +++ b/erpnext/demo/user/purchase.py @@ -11,7 +11,7 @@ from erpnext.accounts.party import get_party_account_currency from erpnext.exceptions import InvalidCurrency from erpnext.stock.doctype.material_request.material_request import make_request_for_quotation from erpnext.buying.doctype.request_for_quotation.request_for_quotation import \ - make_supplier_quotation as make_quotation_from_rfq + make_supplier_quotation_from_rfq def work(): frappe.set_user(frappe.db.get_global('demo_purchase_user')) @@ -44,7 +44,7 @@ def work(): rfq = frappe.get_doc('Request for Quotation', rfq.name) for supplier in rfq.suppliers: - supplier_quotation = make_quotation_from_rfq(rfq.name, supplier.supplier) + supplier_quotation = make_supplier_quotation_from_rfq(rfq.name, for_supplier=supplier.supplier) supplier_quotation.save() supplier_quotation.submit() diff --git a/erpnext/demo/user/stock.py b/erpnext/demo/user/stock.py index f95a6b83315..d44da7d127e 100644 --- a/erpnext/demo/user/stock.py +++ b/erpnext/demo/user/stock.py @@ -79,7 +79,7 @@ def make_stock_reconciliation(): if item.qty: item.qty = item.qty - round(random.randint(1, item.qty)) try: - stock_reco.insert(ignore_permissions=True) + stock_reco.insert(ignore_permissions=True, ignore_mandatory=True) stock_reco.submit() frappe.db.commit() except OpeningEntryAccountError: diff --git a/erpnext/domains/healthcare.py b/erpnext/domains/healthcare.py index 8bd4c762907..bbeb2c66bcf 100644 --- a/erpnext/domains/healthcare.py +++ b/erpnext/domains/healthcare.py @@ -49,6 +49,22 @@ data = { 'fieldname': 'reference_dn', 'label': 'Reference Name', 'fieldtype': 'Dynamic Link', 'options': 'reference_dt', 'insert_after': 'reference_dt' } + ], + 'Stock Entry': [ + { + 'fieldname': 'inpatient_medication_entry', 'label': 'Inpatient Medication Entry', 'fieldtype': 'Link', 'options': 'Inpatient Medication Entry', + 'insert_after': 'credit_note', 'read_only': True + } + ], + 'Stock Entry Detail': [ + { + 'fieldname': 'patient', 'label': 'Patient', 'fieldtype': 'Link', 'options': 'Patient', + 'insert_after': 'po_detail', 'read_only': True + }, + { + 'fieldname': 'inpatient_medication_entry_child', 'label': 'Inpatient Medication Entry Child', 'fieldtype': 'Data', + 'insert_after': 'patient', 'read_only': True + } ] }, 'on_setup': 'erpnext.healthcare.setup.setup_healthcare' diff --git a/erpnext/education/api.py b/erpnext/education/api.py index bf9f2215f32..afa0be9b9f3 100644 --- a/erpnext/education/api.py +++ b/erpnext/education/api.py @@ -7,7 +7,7 @@ import frappe import json from frappe import _ from frappe.model.mapper import get_mapped_doc -from frappe.utils import flt, cstr +from frappe.utils import flt, cstr, getdate from frappe.email.doctype.email_group.email_group import add_subscribers def get_course(program): @@ -36,6 +36,7 @@ def enroll_student(source_name): student.save() program_enrollment = frappe.new_doc("Program Enrollment") program_enrollment.student = student.name + program_enrollment.student_category = student.student_category program_enrollment.student_name = student.title program_enrollment.program = frappe.db.get_value("Student Applicant", source_name, "program") frappe.publish_realtime('enroll_student_progress', {"progress": [2, 4]}, user=frappe.session.user) @@ -67,6 +68,13 @@ def mark_attendance(students_present, students_absent, course_schedule=None, stu :param date: Date. """ + if student_group: + academic_year = frappe.db.get_value('Student Group', student_group, 'academic_year') + if academic_year: + year_start_date, year_end_date = frappe.db.get_value('Academic Year', academic_year, ['year_start_date', 'year_end_date']) + if getdate(date) < getdate(year_start_date) or getdate(date) > getdate(year_end_date): + frappe.throw(_('Attendance cannot be marked outside of Academic Year {0}').format(academic_year)) + present = json.loads(students_present) absent = json.loads(students_absent) diff --git a/erpnext/education/desk_page/education/education.json b/erpnext/education/desk_page/education/education.json deleted file mode 100644 index 77ee8ecaf6d..00000000000 --- a/erpnext/education/desk_page/education/education.json +++ /dev/null @@ -1,154 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Student and Instructor", - "links": "[\n {\n \"label\": \"Student\",\n \"name\": \"Student\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Instructor\",\n \"name\": \"Instructor\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Guardian\",\n \"name\": \"Guardian\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Student Group\",\n \"name\": \"Student Group\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Student Log\",\n \"name\": \"Student Log\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Masters", - "links": "[\n {\n \"label\": \"Program\",\n \"name\": \"Program\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Course\",\n \"name\": \"Course\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Topic\",\n \"name\": \"Topic\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Room\",\n \"name\": \"Room\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Content Masters", - "links": "[\n {\n \"label\": \"Article\",\n \"name\": \"Article\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Video\",\n \"name\": \"Video\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Quiz\",\n \"name\": \"Quiz\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"label\": \"Education Settings\",\n \"name\": \"Education Settings\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Student Category\",\n \"name\": \"Student Category\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Student Batch Name\",\n \"name\": \"Student Batch Name\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Grading Scale\",\n \"name\": \"Grading Scale\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Academic Term\",\n \"name\": \"Academic Term\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Academic Year\",\n \"name\": \"Academic Year\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Admission", - "links": "[\n {\n \"label\": \"Student Applicant\",\n \"name\": \"Student Applicant\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Student Admission\",\n \"name\": \"Student Admission\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Program Enrollment\",\n \"name\": \"Program Enrollment\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Course Enrollment\",\n \"name\": \"Course Enrollment\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Fees", - "links": "[\n {\n \"label\": \"Fee Structure\",\n \"name\": \"Fee Structure\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Fee Category\",\n \"name\": \"Fee Category\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Fee Schedule\",\n \"name\": \"Fee Schedule\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Fees\",\n \"name\": \"Fees\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Fees\"\n ],\n \"doctype\": \"Fees\",\n \"is_query_report\": true,\n \"label\": \"Student Fee Collection Report\",\n \"name\": \"Student Fee Collection\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Fees\"\n ],\n \"doctype\": \"Fees\",\n \"is_query_report\": true,\n \"label\": \"Program wise Fee Collection Report\",\n \"name\": \"Program wise Fee Collection\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Schedule", - "links": "[\n {\n \"label\": \"Course Schedule\",\n \"name\": \"Course Schedule\",\n \"route\": \"#List/Course Schedule/Calendar\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Course Scheduling Tool\",\n \"name\": \"Course Scheduling Tool\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Attendance", - "links": "[\n {\n \"label\": \"Student Attendance\",\n \"name\": \"Student Attendance\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Student Leave Application\",\n \"name\": \"Student Leave Application\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Student Attendance\"\n ],\n \"doctype\": \"Student Attendance\",\n \"is_query_report\": true,\n \"label\": \"Student Monthly Attendance Sheet\",\n \"name\": \"Student Monthly Attendance Sheet\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Student Attendance\"\n ],\n \"doctype\": \"Student Attendance\",\n \"is_query_report\": true,\n \"label\": \"Absent Student Report\",\n \"name\": \"Absent Student Report\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Student Attendance\"\n ],\n \"doctype\": \"Student Attendance\",\n \"is_query_report\": true,\n \"label\": \"Student Batch-Wise Attendance\",\n \"name\": \"Student Batch-Wise Attendance\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "LMS Activity", - "links": "[\n {\n \"label\": \"Course Enrollment\",\n \"name\": \"Course Enrollment\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Course Activity\",\n \"name\": \"Course Activity\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Quiz Activity\",\n \"name\": \"Quiz Activity\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Assessment", - "links": "[\n {\n \"label\": \"Assessment Plan\",\n \"name\": \"Assessment Plan\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Assessment Group\",\n \"link\": \"Tree/Assessment Group\",\n \"name\": \"Assessment Group\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Assessment Result\",\n \"name\": \"Assessment Result\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Assessment Criteria\",\n \"name\": \"Assessment Criteria\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Assessment Reports", - "links": "[\n {\n \"dependencies\": [\n \"Assessment Result\"\n ],\n \"doctype\": \"Assessment Result\",\n \"is_query_report\": true,\n \"label\": \"Course wise Assessment Report\",\n \"name\": \"Course wise Assessment Report\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Assessment Result\"\n ],\n \"doctype\": \"Assessment Result\",\n \"is_query_report\": true,\n \"label\": \"Final Assessment Grades\",\n \"name\": \"Final Assessment Grades\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Assessment Plan\"\n ],\n \"doctype\": \"Assessment Plan\",\n \"is_query_report\": true,\n \"label\": \"Assessment Plan Status\",\n \"name\": \"Assessment Plan Status\",\n \"type\": \"report\"\n },\n {\n \"label\": \"Student Report Generation Tool\",\n \"name\": \"Student Report Generation Tool\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Tools", - "links": "[\n {\n \"label\": \"Student Attendance Tool\",\n \"name\": \"Student Attendance Tool\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Assessment Result Tool\",\n \"name\": \"Assessment Result Tool\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Student Group Creation Tool\",\n \"name\": \"Student Group Creation Tool\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Program Enrollment Tool\",\n \"name\": \"Program Enrollment Tool\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Course Scheduling Tool\",\n \"name\": \"Course Scheduling Tool\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Other Reports", - "links": "[\n {\n \"dependencies\": [\n \"Program Enrollment\"\n ],\n \"doctype\": \"Program Enrollment\",\n \"is_query_report\": true,\n \"label\": \"Student and Guardian Contact Details\",\n \"name\": \"Student and Guardian Contact Details\",\n \"type\": \"report\"\n }\n]" - } - ], - "category": "Domains", - "charts": [ - { - "chart_name": "Program Enrollments", - "label": "Program Enrollments" - } - ], - "creation": "2020-03-02 17:22:57.066401", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Education", - "modified": "2020-07-27 19:35:18.832694", - "modified_by": "Administrator", - "module": "Education", - "name": "Education", - "onboarding": "Education", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "restrict_to_domain": "Education", - "shortcuts": [ - { - "color": "#cef6d1", - "format": "{} Active", - "label": "Student", - "link_to": "Student", - "stats_filter": "{\n \"enabled\": 1\n}", - "type": "DocType" - }, - { - "color": "#cef6d1", - "format": "{} Active", - "label": "Instructor", - "link_to": "Instructor", - "stats_filter": "{\n \"status\": \"Active\"\n}", - "type": "DocType" - }, - { - "color": "", - "format": "", - "label": "Program", - "link_to": "Program", - "stats_filter": "", - "type": "DocType" - }, - { - "label": "Course", - "link_to": "Course", - "type": "DocType" - }, - { - "color": "#ffe8cd", - "format": "{} Unpaid", - "label": "Fees", - "link_to": "Fees", - "stats_filter": "{\n \"outstanding_amount\": [\"!=\", 0.0]\n}", - "type": "DocType" - }, - { - "label": "Student Monthly Attendance Sheet", - "link_to": "Student Monthly Attendance Sheet", - "type": "Report" - }, - { - "label": "Course Scheduling Tool", - "link_to": "Course Scheduling Tool", - "type": "DocType" - }, - { - "label": "Student Attendance Tool", - "link_to": "Student Attendance Tool", - "type": "DocType" - }, - { - "label": "Dashboard", - "link_to": "Education", - "type": "Dashboard" - } - ] -} \ No newline at end of file diff --git a/erpnext/education/doctype/assessment_plan/assessment_plan.js b/erpnext/education/doctype/assessment_plan/assessment_plan.js index c4c56143c3d..726c0fcecd4 100644 --- a/erpnext/education/doctype/assessment_plan/assessment_plan.js +++ b/erpnext/education/doctype/assessment_plan/assessment_plan.js @@ -30,6 +30,23 @@ frappe.ui.form.on('Assessment Plan', { frappe.set_route('Form', 'Assessment Result Tool'); }, __('Tools')); } + + frm.set_query('course', function() { + return { + query: 'erpnext.education.doctype.program_enrollment.program_enrollment.get_program_courses', + filters: { + 'program': frm.doc.program + } + }; + }); + + frm.set_query('academic_term', function() { + return { + filters: { + 'academic_year': frm.doc.academic_year + } + }; + }); }, course: function(frm) { diff --git a/erpnext/education/doctype/assessment_plan/assessment_plan.json b/erpnext/education/doctype/assessment_plan/assessment_plan.json index 95ed853c219..5066fdf3917 100644 --- a/erpnext/education/doctype/assessment_plan/assessment_plan.json +++ b/erpnext/education/doctype/assessment_plan/assessment_plan.json @@ -12,8 +12,8 @@ "assessment_group", "grading_scale", "column_break_2", - "course", "program", + "course", "academic_year", "academic_term", "section_break_5", @@ -198,7 +198,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2020-05-09 14:56:26.746988", + "modified": "2020-10-23 15:55:35.076251", "modified_by": "Administrator", "module": "Education", "name": "Assessment Plan", diff --git a/erpnext/education/doctype/assessment_result/assessment_result.js b/erpnext/education/doctype/assessment_result/assessment_result.js index 63d1aee0cb0..617a873b820 100644 --- a/erpnext/education/doctype/assessment_result/assessment_result.js +++ b/erpnext/education/doctype/assessment_result/assessment_result.js @@ -7,6 +7,23 @@ frappe.ui.form.on('Assessment Result', { frm.trigger('setup_chart'); } frm.set_df_property('details', 'read_only', 1); + + frm.set_query('course', function() { + return { + query: 'erpnext.education.doctype.program_enrollment.program_enrollment.get_program_courses', + filters: { + 'program': frm.doc.program + } + }; + }); + + frm.set_query('academic_term', function() { + return { + filters: { + 'academic_year': frm.doc.academic_year + } + }; + }); }, onload: function(frm) { diff --git a/erpnext/education/doctype/assessment_result_tool/assessment_result_tool.js b/erpnext/education/doctype/assessment_result_tool/assessment_result_tool.js index 3cd451209f1..053f0c2f1b8 100644 --- a/erpnext/education/doctype/assessment_result_tool/assessment_result_tool.js +++ b/erpnext/education/doctype/assessment_result_tool/assessment_result_tool.js @@ -128,7 +128,7 @@ frappe.ui.form.on('Assessment Result Tool', { result_table.find(`span[data-student=${assessment_result.student}].total-score-grade`).html(assessment_result.grade); let link_span = result_table.find(`span[data-student=${assessment_result.student}].total-result-link`); $(link_span).css("display", "block"); - $(link_span).find("a").attr("href", "#Form/Assessment Result/"+assessment_result.name); + $(link_span).find("a").attr("href", "/app/assessment-result/"+assessment_result.name); } }); } diff --git a/erpnext/education/doctype/course_enrollment/course_enrollment.py b/erpnext/education/doctype/course_enrollment/course_enrollment.py index b082be2aa21..f7aa6e9fc11 100644 --- a/erpnext/education/doctype/course_enrollment/course_enrollment.py +++ b/erpnext/education/doctype/course_enrollment/course_enrollment.py @@ -6,9 +6,13 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import get_link_to_form from functools import reduce class CourseEnrollment(Document): + def validate(self): + self.validate_duplication() + def get_progress(self, student): """ Returns Progress of given student for a particular course enrollment @@ -27,13 +31,15 @@ class CourseEnrollment(Document): return [] def validate_duplication(self): - enrollment = frappe.get_all("Course Enrollment", filters={ + enrollment = frappe.db.exists("Course Enrollment", { "student": self.student, "course": self.course, - "program_enrollment": self.program_enrollment + "program_enrollment": self.program_enrollment, + "name": ("!=", self.name) }) if enrollment: - frappe.throw(_("Student is already enrolled.")) + frappe.throw(_("Student is already enrolled via Course Enrollment {0}").format( + get_link_to_form("Course Enrollment", enrollment)), title=_('Duplicate Entry')) def add_quiz_activity(self, quiz_name, quiz_response, answers, score, status): result = {k: ('Correct' if v else 'Wrong') for k,v in answers.items()} diff --git a/erpnext/education/doctype/course_enrollment/test_course_enrollment.py b/erpnext/education/doctype/course_enrollment/test_course_enrollment.py index 5ecace2a603..e22c7ce0bab 100644 --- a/erpnext/education/doctype/course_enrollment/test_course_enrollment.py +++ b/erpnext/education/doctype/course_enrollment/test_course_enrollment.py @@ -17,8 +17,9 @@ class TestCourseEnrollment(unittest.TestCase): setup_program() student = create_student({"first_name": "_Test First", "last_name": "_Test Last", "email": "_test_student_1@example.com"}) program_enrollment = student.enroll_in_program("_Test Program") - course_enrollment = student.enroll_in_course("_Test Course 1", program_enrollment.name) - make_course_activity(course_enrollment.name, "Article", "_Test Article 1-1") + course_enrollment = frappe.db.get_value("Course Enrollment", + {"course": "_Test Course 1", "student": student.name, "program_enrollment": program_enrollment.name}, 'name') + make_course_activity(course_enrollment, "Article", "_Test Article 1-1") def test_get_progress(self): student = get_student("_test_student_1@example.com") @@ -30,5 +31,14 @@ class TestCourseEnrollment(unittest.TestCase): self.assertTrue(finished in progress) frappe.db.rollback() + def tearDown(self): + for entry in frappe.db.get_all("Course Enrollment"): + frappe.delete_doc("Course Enrollment", entry.name) + + for entry in frappe.db.get_all("Program Enrollment"): + doc = frappe.get_doc("Program Enrollment", entry.name) + doc.cancel() + doc.delete() + diff --git a/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.js b/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.js index 20503f919cc..d57f46ab98e 100644 --- a/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.js +++ b/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.js @@ -19,17 +19,22 @@ frappe.ui.form.on('Course Scheduling Tool', { } const { course_schedules } = r.message; if (course_schedules) { + const course_schedules_html = course_schedules.map(c => ` +
    + + + + `).join(''); + const html = ` -
    {%= __(range3) %} {%= __(range4) %} {%= __(range5) %}{%= __(range6) %} {%= __("Total") %}
    {%= __("Total Outstanding") %}{%= format_number(balance_row["range1"], null, 2) %}{%= format_currency(balance_row["range2"]) %}{%= format_currency(balance_row["range3"]) %}{%= format_currency(balance_row["range4"]) %}{%= format_currency(balance_row["range5"]) %} + {%= format_number(balance_row["age"], null, 2) %} + + {%= format_currency(balance_row["range1"], data[data.length-1]["currency"]) %} + + {%= format_currency(balance_row["range2"], data[data.length-1]["currency"]) %} + + {%= format_currency(balance_row["range3"], data[data.length-1]["currency"]) %} + + {%= format_currency(balance_row["range4"], data[data.length-1]["currency"]) %} + + {%= format_currency(balance_row["range5"], data[data.length-1]["currency"]) %} + {%= format_currency(flt(balance_row["outstanding"]), data[data.length-1]["currency"]) %} -
    {%= __("Future Payments") %} {%= format_currency(flt(balance_row[("future_amount")]), data[data.length-1]["currency"]) %} {%= format_currency(flt(balance_row["outstanding"] - balance_row[("future_amount")]), data[data.length-1]["currency"]) %}
    {%= __("Total") %} - {%= format_currency(data[i]["invoiced"], data[0]["currency"] ) %} - {%= format_currency(data[i]["paid"], data[0]["currency"]) %}{%= format_currency(data[i]["credit_note"], data[0]["currency"]) %} {%= format_currency(data[i]["credit_note"], data[i]["currency"]) %} - {%= format_currency(data[i]["outstanding"], data[0]["currency"]) %}{%= data[i]["future_ref"] %}{%= format_currency(data[i]["future_amount"], data[0]["currency"]) %}{%= format_currency(data[i]["remaining_balance"], data[0]["currency"]) %}{%= format_currency(data[i]["future_amount"], data[i]["currency"]) %}{%= format_currency(data[i]["remaining_balance"], data[i]["currency"]) %} {% if(!(filters.customer || filters.supplier)) { %} {%= data[i]["party"] %} @@ -256,10 +274,10 @@ {% } else { %} {%= __("Total") %}{%= format_currency(data[i]["invoiced"], data[0]["currency"]) %}{%= format_currency(data[i]["paid"], data[0]["currency"]) %}{%= format_currency(data[i]["credit_note"], data[0]["currency"]) %}{%= format_currency(data[i]["outstanding"], data[0]["currency"]) %}{%= format_currency(data[i]["invoiced"], data[i]["currency"]) %}{%= format_currency(data[i]["paid"], data[i]["currency"]) %}{%= format_currency(data[i]["credit_note"], data[i]["currency"]) %}{%= format_currency(data[i]["outstanding"], data[i]["currency"]) %}
    ${c.name}${c.schedule_date}
    - - - - ${course_schedules.map( - c => ` - ` - ).join('')} - -
    ${__('Following course schedules were created')}
    ${__("Course")}${__("Date")}
    ${c.name}${c.schedule_date}
    ` + + + + + ${course_schedules_html} + +
    ${__('Following course schedules were created')}
    ${__("Course")}${__("Date")}
    + `; frappe.msgprint(html); } diff --git a/erpnext/education/doctype/fee_schedule/fee_schedule.js b/erpnext/education/doctype/fee_schedule/fee_schedule.js index 75dd4469e84..0089957df40 100644 --- a/erpnext/education/doctype/fee_schedule/fee_schedule.js +++ b/erpnext/education/doctype/fee_schedule/fee_schedule.js @@ -1,6 +1,7 @@ // Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +frappe.provide("erpnext.accounts.dimensions"); frappe.ui.form.on('Fee Schedule', { setup: function(frm) { frm.add_fetch('fee_structure', 'receivable_account', 'receivable_account'); @@ -8,6 +9,10 @@ frappe.ui.form.on('Fee Schedule', { frm.add_fetch('fee_structure', 'cost_center', 'cost_center'); }, + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, + onload: function(frm) { frm.set_query('receivable_account', function(doc) { return { @@ -43,13 +48,15 @@ frappe.ui.form.on('Fee Schedule', { frm.reload_doc(); } if (data.progress) { - let progress_bar = $(cur_frm.dashboard.progress_area).find('.progress-bar'); + let progress_bar = $(cur_frm.dashboard.progress_area.body).find('.progress-bar'); if (progress_bar) { $(progress_bar).removeClass('progress-bar-danger').addClass('progress-bar-success progress-bar-striped'); $(progress_bar).css('width', data.progress+'%'); } } }); + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, refresh: function(frm) { diff --git a/erpnext/education/doctype/fee_structure/fee_structure.js b/erpnext/education/doctype/fee_structure/fee_structure.js index b331c6d3c0e..310c4105f47 100644 --- a/erpnext/education/doctype/fee_structure/fee_structure.js +++ b/erpnext/education/doctype/fee_structure/fee_structure.js @@ -1,6 +1,8 @@ // Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +frappe.provide("erpnext.accounts.dimensions"); + frappe.ui.form.on('Fee Structure', { setup: function(frm) { frm.add_fetch('company', 'default_receivable_account', 'receivable_account'); @@ -8,6 +10,10 @@ frappe.ui.form.on('Fee Structure', { frm.add_fetch('company', 'cost_center', 'cost_center'); }, + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, + onload: function(frm) { frm.set_query('academic_term', function() { return { @@ -35,6 +41,8 @@ frappe.ui.form.on('Fee Structure', { } }; }); + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, refresh: function(frm) { diff --git a/erpnext/education/doctype/fees/fees.js b/erpnext/education/doctype/fees/fees.js index aaf42b47517..ac66acd00f5 100644 --- a/erpnext/education/doctype/fees/fees.js +++ b/erpnext/education/doctype/fees/fees.js @@ -1,6 +1,7 @@ // Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt +frappe.provide("erpnext.accounts.dimensions"); frappe.ui.form.on("Fees", { setup: function(frm) { @@ -9,15 +10,19 @@ frappe.ui.form.on("Fees", { frm.add_fetch("fee_structure", "cost_center", "cost_center"); }, - onload: function(frm){ - frm.set_query("academic_term",function(){ + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, + + onload: function(frm) { + frm.set_query("academic_term", function() { return{ - "filters":{ + "filters": { "academic_year": (frm.doc.academic_year) } }; }); - frm.set_query("fee_structure",function(){ + frm.set_query("fee_structure", function() { return{ "filters":{ "academic_year": (frm.doc.academic_year) @@ -45,6 +50,8 @@ frappe.ui.form.on("Fees", { if (!frm.doc.posting_date) { frm.doc.posting_date = frappe.datetime.get_today(); } + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, refresh: function(frm) { diff --git a/erpnext/education/doctype/instructor/instructor.js b/erpnext/education/doctype/instructor/instructor.js index abb47eda069..24e80fa9378 100644 --- a/erpnext/education/doctype/instructor/instructor.js +++ b/erpnext/education/doctype/instructor/instructor.js @@ -41,5 +41,24 @@ frappe.ui.form.on("Instructor", { } }; }); + + frm.set_query("academic_term", "instructor_log", function(_doc, cdt, cdn) { + let d = locals[cdt][cdn]; + return { + filters: { + "academic_year": d.academic_year + } + }; + }); + + frm.set_query("course", "instructor_log", function(_doc, cdt, cdn) { + let d = locals[cdt][cdn]; + return { + query: "erpnext.education.doctype.program_enrollment.program_enrollment.get_program_courses", + filters: { + "program": d.program + } + }; + }); } }); \ No newline at end of file diff --git a/erpnext/education/doctype/instructor_log/instructor_log.json b/erpnext/education/doctype/instructor_log/instructor_log.json index dc9380f2c12..5b9e1f9b97d 100644 --- a/erpnext/education/doctype/instructor_log/instructor_log.json +++ b/erpnext/education/doctype/instructor_log/instructor_log.json @@ -1,336 +1,88 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2017-12-27 08:55:52.680284", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2017-12-27 08:55:52.680284", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "academic_year", + "academic_term", + "department", + "column_break_3", + "program", + "course", + "student_group", + "section_break_8", + "other_details" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "academic_year", - "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": "Academic Year", - "length": 0, - "no_copy": 0, - "options": "Academic Year", - "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": "academic_year", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Academic Year", + "options": "Academic Year", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "academic_term", - "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": "Academic Term", - "length": 0, - "no_copy": 0, - "options": "Academic Term", - "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": "academic_term", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Academic Term", + "options": "Academic Term" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "department", - "fieldtype": "Link", - "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": "Department", - "length": 0, - "no_copy": 0, - "options": "Department", - "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": "department", + "fieldtype": "Link", + "label": "Department", + "options": "Department" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_3", - "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 - }, + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "program", - "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": "Program", - "length": 0, - "no_copy": 0, - "options": "Program", - "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": "program", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Program", + "options": "Program", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "course", - "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": "Course", - "length": 0, - "no_copy": 0, - "options": "Course", - "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": "course", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Course", + "options": "Course" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "student_group", - "fieldtype": "Link", - "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": "Student Group", - "length": 0, - "no_copy": 0, - "options": "Student Group", - "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": "student_group", + "fieldtype": "Link", + "label": "Student Group", + "options": "Student Group" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_8", - "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 - }, + "fieldname": "section_break_8", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "other_details", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Other details", - "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 + "fieldname": "other_details", + "fieldtype": "Small Text", + "label": "Other details" } - ], - "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-11-04 03:38:30.902942", - "modified_by": "Administrator", - "module": "Education", - "name": "Instructor Log", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "restrict_to_domain": "Education", - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + ], + "istable": 1, + "links": [], + "modified": "2020-10-23 15:15:50.759657", + "modified_by": "Administrator", + "module": "Education", + "name": "Instructor Log", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "restrict_to_domain": "Education", + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/education/doctype/program/test_program.py b/erpnext/education/doctype/program/test_program.py index 3bcca3a7f18..d7530365117 100644 --- a/erpnext/education/doctype/program/test_program.py +++ b/erpnext/education/doctype/program/test_program.py @@ -49,6 +49,11 @@ class TestProgram(unittest.TestCase): self.assertEqual(course[1].name, "_Test Course 2") frappe.db.rollback() + def tearDown(self): + for dt in ["Program", "Course", "Topic", "Article"]: + for entry in frappe.get_all(dt): + frappe.delete_doc(dt, entry.program) + def make_program(name): program = frappe.get_doc({ "doctype": "Program", @@ -68,7 +73,7 @@ def make_program_and_linked_courses(program_name, course_name_list): program = frappe.get_doc("Program", program_name) course_list = [make_course(course_name) for course_name in course_name_list] for course in course_list: - program.append("courses", {"course": course}) + program.append("courses", {"course": course, "required": 1}) program.save() return program diff --git a/erpnext/education/doctype/program_course/program_course.json b/erpnext/education/doctype/program_course/program_course.json index 940358e4e9b..dc6b3fcef8a 100644 --- a/erpnext/education/doctype/program_course/program_course.json +++ b/erpnext/education/doctype/program_course/program_course.json @@ -17,9 +17,7 @@ "in_list_view": 1, "label": "Course", "options": "Course", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fetch_from": "course.course_name", @@ -27,23 +25,19 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Course Name", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { - "default": "0", + "default": "1", "fieldname": "required", "fieldtype": "Check", "in_list_view": 1, - "label": "Mandatory", - "show_days": 1, - "show_seconds": 1 + "label": "Mandatory" } ], "istable": 1, "links": [], - "modified": "2020-06-09 18:56:10.213241", + "modified": "2020-09-15 18:14:22.816795", "modified_by": "Administrator", "module": "Education", "name": "Program Course", diff --git a/erpnext/education/doctype/program_enrollment/program_enrollment.js b/erpnext/education/doctype/program_enrollment/program_enrollment.js index e3b3e9fdd69..f9c65fbbfb3 100644 --- a/erpnext/education/doctype/program_enrollment/program_enrollment.js +++ b/erpnext/education/doctype/program_enrollment/program_enrollment.js @@ -2,16 +2,24 @@ // For license information, please see license.txt -frappe.ui.form.on("Program Enrollment", { +frappe.ui.form.on('Program Enrollment', { setup: function(frm) { frm.add_fetch('fee_structure', 'total_amount', 'amount'); }, - onload: function(frm, cdt, cdn){ - frm.set_query("academic_term", "fees", function(){ - return{ - "filters":{ - "academic_year": (frm.doc.academic_year) + onload: function(frm) { + frm.set_query('academic_term', function() { + return { + 'filters':{ + 'academic_year': frm.doc.academic_year + } + }; + }); + + frm.set_query('academic_term', 'fees', function() { + return { + 'filters':{ + 'academic_year': frm.doc.academic_year } }; }); @@ -24,9 +32,9 @@ frappe.ui.form.on("Program Enrollment", { }; if (frm.doc.program) { - frm.set_query("course", "courses", function(doc, cdt, cdn) { - return{ - query: "erpnext.education.doctype.program_enrollment.program_enrollment.get_program_courses", + frm.set_query('course', 'courses', function() { + return { + query: 'erpnext.education.doctype.program_enrollment.program_enrollment.get_program_courses', filters: { 'program': frm.doc.program } @@ -34,9 +42,9 @@ frappe.ui.form.on("Program Enrollment", { }); } - frm.set_query("student", function() { + frm.set_query('student', function() { return{ - query: "erpnext.education.doctype.program_enrollment.program_enrollment.get_students", + query: 'erpnext.education.doctype.program_enrollment.program_enrollment.get_students', filters: { 'academic_year': frm.doc.academic_year, 'academic_term': frm.doc.academic_term @@ -49,14 +57,14 @@ frappe.ui.form.on("Program Enrollment", { frm.events.get_courses(frm); if (frm.doc.program) { frappe.call({ - method: "erpnext.education.api.get_fee_schedule", + method: 'erpnext.education.api.get_fee_schedule', args: { - "program": frm.doc.program, - "student_category": frm.doc.student_category + 'program': frm.doc.program, + 'student_category': frm.doc.student_category }, callback: function(r) { - if(r.message) { - frm.set_value("fees" ,r.message); + if (r.message) { + frm.set_value('fees' ,r.message); frm.events.get_courses(frm); } } @@ -65,17 +73,17 @@ frappe.ui.form.on("Program Enrollment", { }, student_category: function() { - frappe.ui.form.trigger("Program Enrollment", "program"); + frappe.ui.form.trigger('Program Enrollment', 'program'); }, get_courses: function(frm) { - frm.set_value("courses",[]); + frm.set_value('courses',[]); frappe.call({ - method: "get_courses", + method: 'get_courses', doc:frm.doc, callback: function(r) { - if(r.message) { - frm.set_value("courses", r.message); + if (r.message) { + frm.set_value('courses', r.message); } } }) @@ -84,10 +92,10 @@ frappe.ui.form.on("Program Enrollment", { frappe.ui.form.on('Program Enrollment Course', { courses_add: function(frm){ - frm.fields_dict['courses'].grid.get_field('course').get_query = function(doc){ + frm.fields_dict['courses'].grid.get_field('course').get_query = function(doc) { var course_list = []; if(!doc.__islocal) course_list.push(doc.name); - $.each(doc.courses, function(idx, val){ + $.each(doc.courses, function(_idx, val) { if (val.course) course_list.push(val.course); }); return { filters: [['Course', 'name', 'not in', course_list]] }; diff --git a/erpnext/education/doctype/program_enrollment/program_enrollment.json b/erpnext/education/doctype/program_enrollment/program_enrollment.json index 1d8a4344a7b..4a00fd04545 100644 --- a/erpnext/education/doctype/program_enrollment/program_enrollment.json +++ b/erpnext/education/doctype/program_enrollment/program_enrollment.json @@ -1,775 +1,218 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 1, - "allow_rename": 0, - "autoname": "EDU-ENR-.YYYY.-.#####", - "beta": 0, - "creation": "2015-12-02 12:58:32.916080", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 0, - "engine": "InnoDB", + "actions": [], + "allow_import": 1, + "autoname": "EDU-ENR-.YYYY.-.#####", + "creation": "2015-12-02 12:58:32.916080", + "doctype": "DocType", + "document_type": "Document", + "engine": "InnoDB", + "field_order": [ + "student", + "student_name", + "student_category", + "student_batch_name", + "school_house", + "column_break_4", + "program", + "academic_year", + "academic_term", + "enrollment_date", + "boarding_student", + "enrolled_courses", + "courses", + "transportation", + "mode_of_transportation", + "column_break_13", + "vehicle_no", + "section_break_7", + "fees", + "amended_from", + "image" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "student", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Student", - "length": 0, - "no_copy": 0, - "options": "Student", - "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": "student", + "fieldtype": "Link", + "in_global_search": 1, + "label": "Student", + "options": "Student", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_from": "student.title", - "fieldname": "student_name", - "fieldtype": "Read Only", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Student Name", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fetch_from": "student.title", + "fieldname": "student_name", + "fieldtype": "Read Only", + "in_global_search": 1, + "label": "Student Name", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "student_category", - "fieldtype": "Link", - "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": "Student Category", - "length": 0, - "no_copy": 0, - "options": "Student Category", - "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": "student_category", + "fieldtype": "Link", + "label": "Student Category", + "options": "Student Category" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "student_batch_name", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Student Batch", - "length": 0, - "no_copy": 0, - "options": "Student Batch Name", - "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_on_submit": 1, + "fieldname": "student_batch_name", + "fieldtype": "Link", + "in_global_search": 1, + "label": "Student Batch", + "options": "Student Batch Name" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "school_house", - "fieldtype": "Link", - "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": "School House", - "length": 0, - "no_copy": 0, - "options": "School House", - "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_on_submit": 1, + "fieldname": "school_house", + "fieldtype": "Link", + "label": "School House", + "options": "School House" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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 - }, + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "program", - "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": 1, - "label": "Program", - "length": 0, - "no_copy": 0, - "options": "Program", - "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": "program", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Program", + "options": "Program", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "academic_year", - "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": 1, - "label": "Academic Year", - "length": 0, - "no_copy": 0, - "options": "Academic Year", - "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": "academic_year", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Academic Year", + "options": "Academic Year", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "academic_term", - "fieldtype": "Link", - "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": "Academic Term", - "length": 0, - "no_copy": 0, - "options": "Academic Term", - "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": "academic_term", + "fieldtype": "Link", + "label": "Academic Term", + "options": "Academic Term" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Today", - "fieldname": "enrollment_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": "Enrollment 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 - }, + "default": "Today", + "fieldname": "enrollment_date", + "fieldtype": "Date", + "label": "Enrollment Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "0", - "description": "Check this if the Student is residing at the Institute's Hostel.", - "fieldname": "boarding_student", - "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": "Boarding Student", - "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 - }, + "default": "0", + "description": "Check this if the Student is residing at the Institute's Hostel.", + "fieldname": "boarding_student", + "fieldtype": "Check", + "label": "Boarding Student" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 1, - "collapsible_depends_on": "vehicle_no", - "columns": 0, - "fieldname": "transportation", - "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, - "label": "Transportation", - "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 - }, + "collapsible": 1, + "collapsible_depends_on": "vehicle_no", + "fieldname": "transportation", + "fieldtype": "Section Break", + "label": "Transportation" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "mode_of_transportation", - "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": "Mode of Transportation", - "length": 0, - "no_copy": 0, - "options": "\nWalking\nInstitute's Bus\nPublic Transport\nSelf-Driving Vehicle\nPick/Drop by Guardian", - "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_on_submit": 1, + "fieldname": "mode_of_transportation", + "fieldtype": "Select", + "label": "Mode of Transportation", + "options": "\nWalking\nInstitute's Bus\nPublic Transport\nSelf-Driving Vehicle\nPick/Drop by Guardian" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_13", - "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 - }, + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "vehicle_no", - "fieldtype": "Data", - "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": "Vehicle/Bus Number", - "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_on_submit": 1, + "fieldname": "vehicle_no", + "fieldtype": "Data", + "label": "Vehicle/Bus Number" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 1, - "columns": 0, - "fieldname": "enrolled_courses", - "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, - "label": "Enrolled courses", - "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 - }, + "fieldname": "enrolled_courses", + "fieldtype": "Section Break", + "label": "Enrolled courses" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "courses", - "fieldtype": "Table", - "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": "Courses", - "length": 0, - "no_copy": 0, - "options": "Program Enrollment Course", - "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_on_submit": 1, + "fieldname": "courses", + "fieldtype": "Table", + "label": "Courses", + "options": "Program Enrollment Course" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 1, - "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, - "label": "Fees", - "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 - }, + "collapsible": 1, + "fieldname": "section_break_7", + "fieldtype": "Section Break", + "label": "Fees" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "fees", - "fieldtype": "Table", - "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": "Fees", - "length": 0, - "no_copy": 0, - "options": "Program Fee", - "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, - "width": "" - }, + "fieldname": "fees", + "fieldtype": "Table", + "label": "Fees", + "options": "Program Fee" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amended_from", - "fieldtype": "Link", - "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": "Amended From", - "length": 0, - "no_copy": 1, - "options": "Program Enrollment", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Program Enrollment", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "image", - "fieldtype": "Attach Image", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Image", - "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 + "fieldname": "image", + "fieldtype": "Attach Image", + "hidden": 1, + "label": "Image" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_field": "image", - "image_view": 0, - "in_create": 0, - "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "menu_index": 0, - "modified": "2018-11-07 21:13:06.502279", - "modified_by": "Administrator", - "module": "Education", - "name": "Program Enrollment", - "name_case": "", - "owner": "Administrator", + ], + "image_field": "image", + "is_submittable": 1, + "links": [], + "modified": "2020-09-15 18:12:11.988565", + "modified_by": "Administrator", + "module": "Education", + "name": "Program Enrollment", + "owner": "Administrator", "permissions": [ { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Academics User", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Academics User", + "share": 1, + "submit": 1, "write": 1 - }, + }, { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "LMS User", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "LMS User", + "share": 1, + "submit": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "restrict_to_domain": "Education", - "show_name_in_global_search": 1, - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "student_name", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "restrict_to_domain": "Education", + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "student_name" } \ No newline at end of file diff --git a/erpnext/education/doctype/program_enrollment/program_enrollment.py b/erpnext/education/doctype/program_enrollment/program_enrollment.py index 3e27670d05d..d18c0f9625c 100644 --- a/erpnext/education/doctype/program_enrollment/program_enrollment.py +++ b/erpnext/education/doctype/program_enrollment/program_enrollment.py @@ -7,12 +7,15 @@ import frappe from frappe import msgprint, _ from frappe.model.document import Document from frappe.desk.reportview import get_match_cond, get_filters_cond -from frappe.utils import comma_and +from frappe.utils import comma_and, get_link_to_form, getdate import erpnext.www.lms as lms class ProgramEnrollment(Document): def validate(self): self.validate_duplication() + self.validate_academic_year() + if self.academic_term: + self.validate_academic_term() if not self.student_name: self.student_name = frappe.db.get_value("Student", self.student, "title") if not self.courses: @@ -23,11 +26,34 @@ class ProgramEnrollment(Document): self.make_fee_records() self.create_course_enrollments() + def validate_academic_year(self): + start_date, end_date = frappe.db.get_value("Academic Year", self.academic_year, ["year_start_date", "year_end_date"]) + if self.enrollment_date: + if start_date and getdate(self.enrollment_date) < getdate(start_date): + frappe.throw(_("Enrollment Date cannot be before the Start Date of the Academic Year {0}").format( + get_link_to_form("Academic Year", self.academic_year))) + + if end_date and getdate(self.enrollment_date) > getdate(end_date): + frappe.throw(_("Enrollment Date cannot be after the End Date of the Academic Term {0}").format( + get_link_to_form("Academic Year", self.academic_year))) + + def validate_academic_term(self): + start_date, end_date = frappe.db.get_value("Academic Term", self.academic_term, ["term_start_date", "term_end_date"]) + if self.enrollment_date: + if start_date and getdate(self.enrollment_date) < getdate(start_date): + frappe.throw(_("Enrollment Date cannot be before the Start Date of the Academic Term {0}").format( + get_link_to_form("Academic Term", self.academic_term))) + + if end_date and getdate(self.enrollment_date) > getdate(end_date): + frappe.throw(_("Enrollment Date cannot be after the End Date of the Academic Term {0}").format( + get_link_to_form("Academic Term", self.academic_term))) + def validate_duplication(self): enrollment = frappe.get_all("Program Enrollment", filters={ "student": self.student, "program": self.program, "academic_year": self.academic_year, + "academic_term": self.academic_term, "docstatus": ("<", 2), "name": ("!=", self.name) }) @@ -61,7 +87,7 @@ class ProgramEnrollment(Document): fees.submit() fee_list.append(fees.name) if fee_list: - fee_list = ["""%s""" % \ + fee_list = ["""%s""" % \ (fee, fee) for fee in fee_list] msgprint(_("Fee Records Created - {0}").format(comma_and(fee_list))) @@ -70,10 +96,9 @@ class ProgramEnrollment(Document): def create_course_enrollments(self): student = frappe.get_doc("Student", self.student) - program = frappe.get_doc("Program", self.program) - course_list = [course.course for course in program.courses] + course_list = [course.course for course in self.courses] for course_name in course_list: - student.enroll_in_course(course_name=course_name, program_enrollment=self.name) + student.enroll_in_course(course_name=course_name, program_enrollment=self.name, enrollment_date=self.enrollment_date) def get_all_course_enrollments(self): course_enrollment_names = frappe.get_list("Course Enrollment", filters={'program_enrollment': self.name}) @@ -99,21 +124,24 @@ class ProgramEnrollment(Document): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_program_courses(doctype, txt, searchfield, start, page_len, filters): - if filters.get('program'): - return frappe.db.sql("""select course, course_name from `tabProgram Course` - where parent = %(program)s and course like %(txt)s {match_cond} - order by - if(locate(%(_txt)s, course), locate(%(_txt)s, course), 99999), - idx desc, - `tabProgram Course`.course asc - limit {start}, {page_len}""".format( - match_cond=get_match_cond(doctype), - start=start, - page_len=page_len), { - "txt": "%{0}%".format(txt), - "_txt": txt.replace('%', ''), - "program": filters['program'] - }) + if not filters.get('program'): + frappe.msgprint(_("Please select a Program first.")) + return [] + + return frappe.db.sql("""select course, course_name from `tabProgram Course` + where parent = %(program)s and course like %(txt)s {match_cond} + order by + if(locate(%(_txt)s, course), locate(%(_txt)s, course), 99999), + idx desc, + `tabProgram Course`.course asc + limit {start}, {page_len}""".format( + match_cond=get_match_cond(doctype), + start=start, + page_len=page_len), { + "txt": "%{0}%".format(txt), + "_txt": txt.replace('%', ''), + "program": filters['program'] + }) @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs diff --git a/erpnext/education/doctype/program_enrollment/test_program_enrollment.py b/erpnext/education/doctype/program_enrollment/test_program_enrollment.py index c6cbee1b75a..fec6422e75f 100644 --- a/erpnext/education/doctype/program_enrollment/test_program_enrollment.py +++ b/erpnext/education/doctype/program_enrollment/test_program_enrollment.py @@ -23,4 +23,13 @@ class TestProgramEnrollment(unittest.TestCase): course_enrollments = student.get_all_course_enrollments() self.assertTrue("_Test Course 1" in course_enrollments.keys()) self.assertTrue("_Test Course 2" in course_enrollments.keys()) - frappe.db.rollback() \ No newline at end of file + frappe.db.rollback() + + def tearDown(self): + for entry in frappe.db.get_all("Course Enrollment"): + frappe.delete_doc("Course Enrollment", entry.name) + + for entry in frappe.db.get_all("Program Enrollment"): + doc = frappe.get_doc("Program Enrollment", entry.name) + doc.cancel() + doc.delete() \ No newline at end of file diff --git a/erpnext/education/doctype/program_enrollment_tool/program_enrollment_tool.py b/erpnext/education/doctype/program_enrollment_tool/program_enrollment_tool.py index 9f8f9f4dc00..8180102c582 100644 --- a/erpnext/education/doctype/program_enrollment_tool/program_enrollment_tool.py +++ b/erpnext/education/doctype/program_enrollment_tool/program_enrollment_tool.py @@ -30,7 +30,7 @@ class ProgramEnrollmentTool(Document): .format(condition), self.as_dict(), as_dict=1) elif self.get_students_from == "Program Enrollment": condition2 = 'and student_batch_name=%(student_batch)s' if self.student_batch else " " - students = frappe.db.sql('''select student, student_name, student_batch_name from `tabProgram Enrollment` + students = frappe.db.sql('''select student, student_name, student_batch_name, student_category from `tabProgram Enrollment` where program=%(program)s and academic_year=%(academic_year)s {0} {1} and docstatus != 2''' .format(condition, condition2), self.as_dict(), as_dict=1) @@ -57,6 +57,7 @@ class ProgramEnrollmentTool(Document): prog_enrollment = frappe.new_doc("Program Enrollment") prog_enrollment.student = stud.student prog_enrollment.student_name = stud.student_name + prog_enrollment.student_category = stud.student_category prog_enrollment.program = self.new_program prog_enrollment.academic_year = self.new_academic_year prog_enrollment.academic_term = self.new_academic_term diff --git a/erpnext/education/doctype/student/student.py b/erpnext/education/doctype/student/student.py index e0d7514177d..81626f19186 100644 --- a/erpnext/education/doctype/student/student.py +++ b/erpnext/education/doctype/student/student.py @@ -147,7 +147,7 @@ class Student(Document): enrollment.save(ignore_permissions=True) except frappe.exceptions.ValidationError: enrollment_name = frappe.get_list("Course Enrollment", filters={"student": self.name, "course": course_name, "program_enrollment": program_enrollment})[0].name - return frappe.get_doc("Program Enrollment", enrollment_name) + return frappe.get_doc("Course Enrollment", enrollment_name) else: return enrollment diff --git a/erpnext/education/doctype/student/test_student.py b/erpnext/education/doctype/student/test_student.py index 8610edbf282..2e5263788f7 100644 --- a/erpnext/education/doctype/student/test_student.py +++ b/erpnext/education/doctype/student/test_student.py @@ -42,6 +42,16 @@ class TestStudent(unittest.TestCase): self.assertTrue("_Test Course 2" in course_enrollments.keys()) frappe.db.rollback() + def tearDown(self): + for entry in frappe.db.get_all("Course Enrollment"): + frappe.delete_doc("Course Enrollment", entry.name) + + for entry in frappe.db.get_all("Program Enrollment"): + doc = frappe.get_doc("Program Enrollment", entry.name) + doc.cancel() + doc.delete() + + def create_student(student_dict): student = get_student(student_dict['email']) if not student: diff --git a/erpnext/education/doctype/student_admission/student_admission.json b/erpnext/education/doctype/student_admission/student_admission.json index 1096888d4d2..75f21625b0b 100644 --- a/erpnext/education/doctype/student_admission/student_admission.json +++ b/erpnext/education/doctype/student_admission/student_admission.json @@ -51,12 +51,14 @@ "fieldname": "admission_start_date", "fieldtype": "Date", "label": "Admission Start Date", + "mandatory_depends_on": "enable_admission_application", "no_copy": 1 }, { "fieldname": "admission_end_date", "fieldtype": "Date", "label": "Admission End Date", + "mandatory_depends_on": "enable_admission_application", "no_copy": 1 }, { @@ -83,6 +85,7 @@ }, { "default": "0", + "depends_on": "published", "fieldname": "enable_admission_application", "fieldtype": "Check", "label": "Enable Admission Application" @@ -91,7 +94,7 @@ "has_web_view": 1, "is_published_field": "published", "links": [], - "modified": "2020-06-15 20:18:38.591626", + "modified": "2020-09-18 00:14:54.615321", "modified_by": "Administrator", "module": "Education", "name": "Student Admission", diff --git a/erpnext/education/doctype/student_admission/student_admission.py b/erpnext/education/doctype/student_admission/student_admission.py index 2781c9c50cd..0febb96aebb 100644 --- a/erpnext/education/doctype/student_admission/student_admission.py +++ b/erpnext/education/doctype/student_admission/student_admission.py @@ -19,6 +19,9 @@ class StudentAdmission(WebsiteGenerator): if not self.route: #pylint: disable=E0203 self.route = "admissions/" + "-".join(self.title.split(" ")) + if self.enable_admission_application and not self.program_details: + frappe.throw(_("Please add programs to enable admission application.")) + def get_context(self, context): context.no_cache = 1 context.show_sidebar = True diff --git a/erpnext/education/doctype/student_admission/templates/student_admission.html b/erpnext/education/doctype/student_admission/templates/student_admission.html index e5a9ead31ed..f9ddac086b1 100644 --- a/erpnext/education/doctype/student_admission/templates/student_admission.html +++ b/erpnext/education/doctype/student_admission/templates/student_admission.html @@ -21,7 +21,7 @@ {% elif frappe.utils.getdate(doc.admission_start_date) > today %} blue"> Application will open {% else %} - darkgrey + gray {% endif %}
    @@ -43,31 +43,35 @@ Program/Std. - Minumum Age - Maximum Age + Description + Minumum Age + Maximum Age Application Fee + {%- if doc.enable_admission_application and frappe.utils.getdate(doc.admission_start_date) <= today -%} + + {% endif %} {% for row in program_details %} {{ row.program }} +
    {{ row.description if row.description else '' }}
    {{ row.min_age }} {{ row.max_age }} {{ row.application_fee }} + {%- if doc.enable_admission_application and frappe.utils.getdate(doc.admission_start_date) <= today -%} + + + {{ _("Apply Now") }} + + + {% endif %} {% endfor %} {% endif %} - {%- if doc.enable_admission_application -%} -
    -

    - - {{ _("Apply Now") }} -

    - {% endif %} {% endblock %} diff --git a/erpnext/education/doctype/student_admission/templates/student_admission_row.html b/erpnext/education/doctype/student_admission/templates/student_admission_row.html index e0497730377..99868d5f020 100644 --- a/erpnext/education/doctype/student_admission/templates/student_admission_row.html +++ b/erpnext/education/doctype/student_admission/templates/student_admission_row.html @@ -1,8 +1,8 @@ -
    +
    {% set today = frappe.utils.getdate(frappe.utils.nowdate()) %} - +
    -
    +
    {{ doc.title }}
    +
    + + Academic Year + +
    + {{ doc.academic_year }} +
    +
    Starts on @@ -27,7 +35,7 @@ Ends on -
    +
    {{ frappe.format_date(doc.admission_end_date) }}
    diff --git a/erpnext/education/doctype/student_admission_program/student_admission_program.json b/erpnext/education/doctype/student_admission_program/student_admission_program.json index e9f041e101f..d14b9a4d913 100644 --- a/erpnext/education/doctype/student_admission_program/student_admission_program.json +++ b/erpnext/education/doctype/student_admission_program/student_admission_program.json @@ -8,6 +8,7 @@ "program", "min_age", "max_age", + "description", "column_break_4", "application_fee", "applicant_naming_series" @@ -18,52 +19,47 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Program", - "options": "Program", - "show_days": 1, - "show_seconds": 1 + "options": "Program" }, { "fieldname": "column_break_4", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "fieldname": "application_fee", "fieldtype": "Currency", "in_list_view": 1, - "label": "Application Fee", - "show_days": 1, - "show_seconds": 1 + "label": "Application Fee" }, { "fieldname": "applicant_naming_series", "fieldtype": "Data", "in_list_view": 1, - "label": "Naming Series (for Student Applicant)", - "show_days": 1, - "show_seconds": 1 + "label": "Naming Series (for Student Applicant)" }, { "fieldname": "min_age", "fieldtype": "Int", "in_list_view": 1, - "label": "Minimum Age", - "show_days": 1, - "show_seconds": 1 + "label": "Minimum Age" }, { "fieldname": "max_age", "fieldtype": "Int", "in_list_view": 1, - "label": "Maximum Age", - "show_days": 1, - "show_seconds": 1 + "label": "Maximum Age" + }, + { + "fetch_from": "program.description", + "fetch_if_empty": 1, + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" } ], "istable": 1, "links": [], - "modified": "2020-06-10 23:06:30.037404", + "modified": "2020-10-05 13:03:42.005985", "modified_by": "Administrator", "module": "Education", "name": "Student Admission Program", diff --git a/erpnext/education/doctype/student_applicant/student_applicant.json b/erpnext/education/doctype/student_applicant/student_applicant.json index bca38fb2647..95f9224a73c 100644 --- a/erpnext/education/doctype/student_applicant/student_applicant.json +++ b/erpnext/education/doctype/student_applicant/student_applicant.json @@ -11,6 +11,7 @@ "middle_name", "last_name", "program", + "student_category", "lms_only", "paid", "column_break_8", @@ -168,6 +169,7 @@ "fieldname": "student_email_id", "fieldtype": "Data", "label": "Student Email Address", + "options": "Email", "unique": 1 }, { @@ -256,12 +258,18 @@ "options": "Student Applicant", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "student_category", + "fieldtype": "Link", + "label": "Student Category", + "options": "Student Category" } ], "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2020-09-07 19:31:30.063563", + "modified": "2021-03-01 23:00:25.119241", "modified_by": "Administrator", "module": "Education", "name": "Student Applicant", diff --git a/erpnext/education/doctype/student_attendance/student_attendance.json b/erpnext/education/doctype/student_attendance/student_attendance.json index 55384b9e530..e6e46d1c1ba 100644 --- a/erpnext/education/doctype/student_attendance/student_attendance.json +++ b/erpnext/education/doctype/student_attendance/student_attendance.json @@ -10,6 +10,7 @@ "naming_series", "student", "student_name", + "student_mobile_number", "course_schedule", "student_group", "column_break_3", @@ -93,11 +94,19 @@ "options": "Student Attendance", "print_hide": 1, "read_only": 1 + }, + { + "fetch_from": "student.student_mobile_number", + "fieldname": "student_mobile_number", + "fieldtype": "Read Only", + "label": "Student Mobile Number", + "options": "Phone" } ], + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-07-08 13:55:42.580181", + "modified": "2021-03-24 00:02:11.005895", "modified_by": "Administrator", "module": "Education", "name": "Student Attendance", diff --git a/erpnext/education/doctype/student_attendance/student_attendance.py b/erpnext/education/doctype/student_attendance/student_attendance.py index c1b6850c563..2e9e6cf8d65 100644 --- a/erpnext/education/doctype/student_attendance/student_attendance.py +++ b/erpnext/education/doctype/student_attendance/student_attendance.py @@ -6,17 +6,20 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document from frappe import _ -from frappe.utils import get_link_to_form +from frappe.utils import get_link_to_form, getdate, formatdate +from erpnext import get_default_company from erpnext.education.api import get_student_group_students - +from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday class StudentAttendance(Document): def validate(self): self.validate_mandatory() + self.validate_date() self.set_date() self.set_student_group() self.validate_student() self.validate_duplication() + self.validate_is_holiday() def set_date(self): if self.course_schedule: @@ -27,6 +30,18 @@ class StudentAttendance(Document): frappe.throw(_('{0} or {1} is mandatory').format(frappe.bold('Student Group'), frappe.bold('Course Schedule')), title=_('Mandatory Fields')) + def validate_date(self): + if not self.leave_application and getdate(self.date) > getdate(): + frappe.throw(_('Attendance cannot be marked for future dates.')) + + if self.student_group: + academic_year = frappe.db.get_value('Student Group', self.student_group, 'academic_year') + if academic_year: + year_start_date, year_end_date = frappe.db.get_value('Academic Year', academic_year, ['year_start_date', 'year_end_date']) + if year_start_date and year_end_date: + if getdate(self.date) < getdate(year_start_date) or getdate(self.date) > getdate(year_end_date): + frappe.throw(_('Attendance cannot be marked outside of Academic Year {0}').format(academic_year)) + def set_student_group(self): if self.course_schedule: self.student_group = frappe.db.get_value('Course Schedule', self.course_schedule, 'student_group') @@ -63,6 +78,21 @@ class StudentAttendance(Document): }) if attendance_record: - record = get_link_to_form('Attendance Record', attendance_record) + record = get_link_to_form('Student Attendance', attendance_record) frappe.throw(_('Student Attendance record {0} already exists against the Student {1}') .format(record, frappe.bold(self.student)), title=_('Duplicate Entry')) + + def validate_is_holiday(self): + holiday_list = get_holiday_list() + if is_holiday(holiday_list, self.date): + frappe.throw(_('Attendance cannot be marked for {0} as it is a holiday.').format( + frappe.bold(formatdate(self.date)))) + +def get_holiday_list(company=None): + if not company: + company = get_default_company() or frappe.get_all('Company')[0].name + + holiday_list = frappe.get_cached_value('Company', company, 'default_holiday_list') + if not holiday_list: + frappe.throw(_('Please set a default Holiday List for Company {0}').format(frappe.bold(get_default_company()))) + return holiday_list diff --git a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.js b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.js index 0384505ec21..b59d8488285 100644 --- a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.js +++ b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.js @@ -52,6 +52,8 @@ frappe.ui.form.on('Student Attendance Tool', { }, date: function(frm) { + if (frm.doc.date > frappe.datetime.get_today()) + frappe.throw(__("Cannot mark attendance for future dates.")); frm.trigger("student_group"); }, @@ -133,8 +135,8 @@ education.StudentsEditor = Class.extend({ return !stud.disabled && !stud.checked; }); - frappe.confirm(__("Do you want to update attendance?
    Present: {0}\ -
    Absent: {1}", [students_present.length, students_absent.length]), + frappe.confirm(__("Do you want to update attendance?
    Present: {0}
    Absent: {1}", + [students_present.length, students_absent.length]), function() { //ifyes if(!frappe.request.ajax_count) { frappe.call({ diff --git a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.json b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.json index 26b28b3ebee..ee8f4842a37 100644 --- a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.json +++ b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.json @@ -1,333 +1,118 @@ { - "allow_copy": 1, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-11-16 17:12:46.437539", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "allow_copy": 1, + "creation": "2016-11-16 17:12:46.437539", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "based_on", + "group_based_on", + "column_break_2", + "student_group", + "academic_year", + "academic_term", + "course_schedule", + "date", + "attendance", + "students_html" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "", - "fieldname": "based_on", - "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": "Based On", - "length": 0, - "no_copy": 0, - "options": "Student Group\nCourse Schedule", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "based_on", + "fieldtype": "Select", + "label": "Based On", + "options": "Student Group\nCourse Schedule" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Batch", - "depends_on": "eval:doc.based_on == \"Student Group\"", - "fieldname": "group_based_on", - "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": "Group Based On", - "length": 0, - "no_copy": 0, - "options": "Batch\nCourse\nActivity", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "Batch", + "depends_on": "eval:doc.based_on == \"Student Group\"", + "fieldname": "group_based_on", + "fieldtype": "Select", + "label": "Group Based On", + "options": "Batch\nCourse\nActivity" + }, { - "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, - "unique": 0 - }, + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.based_on ==\"Student Group\"", - "fieldname": "student_group", - "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": "Student Group", - "length": 0, - "no_copy": 0, - "options": "Student Group", - "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, - "unique": 0 - }, + "depends_on": "eval:doc.based_on ==\"Student Group\"", + "fieldname": "student_group", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Student Group", + "options": "Student Group", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.based_on ==\"Course Schedule\"", - "fieldname": "course_schedule", - "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": "Course Schedule", - "length": 0, - "no_copy": 0, - "options": "Course Schedule", - "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, - "unique": 0 - }, + "depends_on": "eval:doc.based_on ==\"Course Schedule\"", + "fieldname": "course_schedule", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Course Schedule", + "options": "Course Schedule", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.based_on ==\"Student Group\"", - "fieldname": "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": "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, - "unique": 0 - }, + "depends_on": "eval:doc.based_on ==\"Student Group\"", + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval: (doc.course_schedule \n|| (doc.student_group && doc.date))", - "fieldname": "attendance", - "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, - "label": "Attendance", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "depends_on": "eval: (doc.course_schedule \n|| (doc.student_group && doc.date))", + "fieldname": "attendance", + "fieldtype": "Section Break", + "label": "Attendance" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "students_html", - "fieldtype": "HTML", - "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": "Students HTML", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "students_html", + "fieldtype": "HTML", + "label": "Students HTML" + }, + { + "fetch_from": "student_group.academic_year", + "fieldname": "academic_year", + "fieldtype": "Link", + "label": "Academic Year", + "options": "Academic Year", + "read_only": 1 + }, + { + "fetch_from": "student_group.academic_term", + "fieldname": "academic_term", + "fieldtype": "Link", + "label": "Academic Term", + "options": "Academic Term", + "read_only": 1 } - ], - "has_web_view": 0, - "hide_heading": 1, - "hide_toolbar": 1, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 1, - "istable": 0, - "max_attachments": 0, - "modified": "2017-11-10 18:55:36.168044", - "modified_by": "Administrator", - "module": "Education", - "name": "Student Attendance Tool", - "name_case": "", - "owner": "Administrator", + ], + "hide_toolbar": 1, + "issingle": 1, + "links": [], + "modified": "2020-10-23 17:52:28.078971", + "modified_by": "Administrator", + "module": "Education", + "name": "Student Attendance Tool", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Instructor", - "set_user_permissions": 0, - "share": 0, - "submit": 0, + "create": 1, + "read": 1, + "role": "Instructor", "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Academics User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, + "create": 1, + "read": 1, + "role": "Academics User", "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "restrict_to_domain": "Education", - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "restrict_to_domain": "Education", + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py index be2644077aa..028db918812 100644 --- a/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py +++ b/erpnext/education/doctype/student_attendance_tool/student_attendance_tool.py @@ -20,10 +20,10 @@ def get_student_attendance_records(based_on, date=None, student_group=None, cour student_list = frappe.get_list("Student Group Student", fields=["student", "student_name", "group_roll_number"] , \ filters={"parent": student_group, "active": 1}, order_by= "group_roll_number") - if not student_list: - student_list = frappe.get_list("Student Group Student", fields=["student", "student_name", "group_roll_number"] , + if not student_list: + student_list = frappe.get_list("Student Group Student", fields=["student", "student_name", "group_roll_number"] , filters={"parent": student_group, "active": 1}, order_by= "group_roll_number") - + if course_schedule: student_attendance_list= frappe.db.sql('''select student, status from `tabStudent Attendance` where \ course_schedule= %s''', (course_schedule), as_dict=1) @@ -32,7 +32,7 @@ def get_student_attendance_records(based_on, date=None, student_group=None, cour student_group= %s and date= %s and \ (course_schedule is Null or course_schedule='')''', (student_group, date), as_dict=1) - + for attendance in student_attendance_list: for student in student_list: if student.student == attendance.student: diff --git a/erpnext/education/doctype/student_leave_application/student_leave_application.json b/erpnext/education/doctype/student_leave_application/student_leave_application.json index ad5397629b8..31b3da2fbd4 100644 --- a/erpnext/education/doctype/student_leave_application/student_leave_application.json +++ b/erpnext/education/doctype/student_leave_application/student_leave_application.json @@ -11,6 +11,7 @@ "column_break_3", "from_date", "to_date", + "total_leave_days", "section_break_5", "attendance_based_on", "student_group", @@ -110,11 +111,17 @@ { "fieldname": "column_break_11", "fieldtype": "Column Break" + }, + { + "fieldname": "total_leave_days", + "fieldtype": "Float", + "label": "Total Leave Days", + "read_only": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-07-08 13:22:38.329002", + "modified": "2020-09-21 18:10:24.440669", "modified_by": "Administrator", "module": "Education", "name": "Student Leave Application", diff --git a/erpnext/education/doctype/student_leave_application/student_leave_application.py b/erpnext/education/doctype/student_leave_application/student_leave_application.py index c8841c999a9..ef670124c31 100644 --- a/erpnext/education/doctype/student_leave_application/student_leave_application.py +++ b/erpnext/education/doctype/student_leave_application/student_leave_application.py @@ -6,11 +6,14 @@ from __future__ import unicode_literals import frappe from frappe import _ from datetime import timedelta -from frappe.utils import get_link_to_form, getdate +from frappe.utils import get_link_to_form, getdate, date_diff, flt +from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday +from erpnext.education.doctype.student_attendance.student_attendance import get_holiday_list from frappe.model.document import Document class StudentLeaveApplication(Document): def validate(self): + self.validate_holiday_list() self.validate_duplicate() self.validate_from_to_dates('from_date', 'to_date') @@ -39,10 +42,19 @@ class StudentLeaveApplication(Document): frappe.throw(_('Leave application {0} already exists against the student {1}') .format(link, frappe.bold(self.student)), title=_('Duplicate Entry')) + def validate_holiday_list(self): + holiday_list = get_holiday_list() + self.total_leave_days = get_number_of_leave_days(self.from_date, self.to_date, holiday_list) + def update_attendance(self): + holiday_list = get_holiday_list() + for dt in daterange(getdate(self.from_date), getdate(self.to_date)): date = dt.strftime('%Y-%m-%d') + if is_holiday(holiday_list, date): + continue + attendance = frappe.db.exists('Student Attendance', { 'student': self.student, 'date': date, @@ -89,3 +101,19 @@ class StudentLeaveApplication(Document): def daterange(start_date, end_date): for n in range(int ((end_date - start_date).days)+1): yield start_date + timedelta(n) + +def get_number_of_leave_days(from_date, to_date, holiday_list): + number_of_days = date_diff(to_date, from_date) + 1 + + holidays = frappe.db.sql(""" + SELECT + COUNT(DISTINCT holiday_date) + FROM `tabHoliday` h1,`tabHoliday List` h2 + WHERE + h1.parent = h2.name and + h1.holiday_date between %s and %s and + h2.name = %s""", (from_date, to_date, holiday_list))[0][0] + + number_of_days = flt(number_of_days) - flt(holidays) + + return number_of_days diff --git a/erpnext/education/doctype/student_leave_application/test_student_leave_application.py b/erpnext/education/doctype/student_leave_application/test_student_leave_application.py index e9b568ad707..fcdd42825f3 100644 --- a/erpnext/education/doctype/student_leave_application/test_student_leave_application.py +++ b/erpnext/education/doctype/student_leave_application/test_student_leave_application.py @@ -5,13 +5,15 @@ from __future__ import unicode_literals import frappe import unittest -from frappe.utils import getdate, add_days +from frappe.utils import getdate, add_days, add_months +from erpnext import get_default_company from erpnext.education.doctype.student_group.test_student_group import get_random_group from erpnext.education.doctype.student.test_student import create_student class TestStudentLeaveApplication(unittest.TestCase): def setUp(self): frappe.db.sql("""delete from `tabStudent Leave Application`""") + create_holiday_list() def test_attendance_record_creation(self): leave_application = create_leave_application() @@ -35,20 +37,45 @@ class TestStudentLeaveApplication(unittest.TestCase): attendance_status = frappe.db.get_value('Student Attendance', {'leave_application': leave_application.name}, 'docstatus') self.assertTrue(attendance_status, 2) + def test_holiday(self): + today = getdate() + leave_application = create_leave_application(from_date=today, to_date= add_days(today, 1), submit=0) -def create_leave_application(from_date=None, to_date=None, mark_as_present=0): + # holiday list validation + company = get_default_company() or frappe.get_all('Company')[0].name + frappe.db.set_value('Company', company, 'default_holiday_list', '') + self.assertRaises(frappe.ValidationError, leave_application.save) + + frappe.db.set_value('Company', company, 'default_holiday_list', 'Test Holiday List for Student') + leave_application.save() + + leave_application.reload() + self.assertEqual(leave_application.total_leave_days, 1) + + # check no attendance record created for a holiday + leave_application.submit() + self.assertIsNone(frappe.db.exists('Student Attendance', {'leave_application': leave_application.name, 'date': add_days(today, 1)})) + + def tearDown(self): + company = get_default_company() or frappe.get_all('Company')[0].name + frappe.db.set_value('Company', company, 'default_holiday_list', '_Test Holiday List') + + +def create_leave_application(from_date=None, to_date=None, mark_as_present=0, submit=1): student = get_student() - leave_application = frappe.get_doc({ - 'doctype': 'Student Leave Application', - 'student': student.name, - 'attendance_based_on': 'Student Group', - 'student_group': get_random_group().name, - 'from_date': from_date if from_date else getdate(), - 'to_date': from_date if from_date else getdate(), - 'mark_as_present': mark_as_present - }).insert() - leave_application.submit() + leave_application = frappe.new_doc('Student Leave Application') + leave_application.student = student.name + leave_application.attendance_based_on = 'Student Group' + leave_application.student_group = get_random_group().name + leave_application.from_date = from_date if from_date else getdate() + leave_application.to_date = from_date if from_date else getdate() + leave_application.mark_as_present = mark_as_present + + if submit: + leave_application.insert() + leave_application.submit() + return leave_application def create_student_attendance(date=None, status=None): @@ -67,4 +94,22 @@ def get_student(): email='test_student@gmail.com', first_name='Test', last_name='Student' - )) \ No newline at end of file + )) + +def create_holiday_list(): + holiday_list = 'Test Holiday List for Student' + today = getdate() + if not frappe.db.exists('Holiday List', holiday_list): + frappe.get_doc(dict( + doctype = 'Holiday List', + holiday_list_name = holiday_list, + from_date = add_months(today, -6), + to_date = add_months(today, 6), + holidays = [ + dict(holiday_date=add_days(today, 1), description = 'Test') + ] + )).insert() + + company = get_default_company() or frappe.get_all('Company')[0].name + frappe.db.set_value('Company', company, 'default_holiday_list', holiday_list) + return holiday_list \ No newline at end of file diff --git a/erpnext/education/report/absent_student_report/absent_student_report.py b/erpnext/education/report/absent_student_report/absent_student_report.py index 4e57cc6c225..c3487ccaffb 100644 --- a/erpnext/education/report/absent_student_report/absent_student_report.py +++ b/erpnext/education/report/absent_student_report/absent_student_report.py @@ -3,8 +3,10 @@ from __future__ import unicode_literals import frappe -from frappe.utils import cstr, cint, getdate +from frappe.utils import formatdate from frappe import msgprint, _ +from erpnext.education.doctype.student_attendance.student_attendance import get_holiday_list +from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday def execute(filters=None): if not filters: filters = {} @@ -15,6 +17,11 @@ def execute(filters=None): columns = get_columns(filters) date = filters.get("date") + holiday_list = get_holiday_list() + if is_holiday(holiday_list, filters.get("date")): + msgprint(_("No attendance has been marked for {0} as it is a Holiday").format(frappe.bold(formatdate(filters.get("date"))))) + + absent_students = get_absent_students(date) leave_applicants = get_leave_applications(date) if absent_students: diff --git a/erpnext/education/report/student_batch_wise_attendance/student_batch_wise_attendance.py b/erpnext/education/report/student_batch_wise_attendance/student_batch_wise_attendance.py index c65d233ccc1..7793dcf3953 100644 --- a/erpnext/education/report/student_batch_wise_attendance/student_batch_wise_attendance.py +++ b/erpnext/education/report/student_batch_wise_attendance/student_batch_wise_attendance.py @@ -3,8 +3,10 @@ from __future__ import unicode_literals import frappe -from frappe.utils import cstr, cint, getdate +from frappe.utils import formatdate from frappe import msgprint, _ +from erpnext.education.doctype.student_attendance.student_attendance import get_holiday_list +from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday def execute(filters=None): if not filters: filters = {} @@ -12,6 +14,10 @@ def execute(filters=None): if not filters.get("date"): msgprint(_("Please select date"), raise_exception=1) + holiday_list = get_holiday_list() + if is_holiday(holiday_list, filters.get("date")): + msgprint(_("No attendance has been marked for {0} as it is a Holiday").format(frappe.bold(formatdate(filters.get("date"))))) + columns = get_columns(filters) active_student_group = get_active_student_group() diff --git a/erpnext/education/report/student_monthly_attendance_sheet/student_monthly_attendance_sheet.py b/erpnext/education/report/student_monthly_attendance_sheet/student_monthly_attendance_sheet.py index d820bfbb21e..04dc8c0e563 100644 --- a/erpnext/education/report/student_monthly_attendance_sheet/student_monthly_attendance_sheet.py +++ b/erpnext/education/report/student_monthly_attendance_sheet/student_monthly_attendance_sheet.py @@ -7,6 +7,8 @@ from frappe.utils import cstr, cint, getdate, get_first_day, get_last_day, date_ from frappe import msgprint, _ from calendar import monthrange from erpnext.education.api import get_student_group_students +from erpnext.education.doctype.student_attendance.student_attendance import get_holiday_list +from erpnext.support.doctype.issue.issue import get_holidays def execute(filters=None): if not filters: filters = {} @@ -19,26 +21,32 @@ def execute(filters=None): students_list = get_students_list(students) att_map = get_attendance_list(from_date, to_date, filters.get("student_group"), students_list) data = [] + for stud in students: row = [stud.student, stud.student_name] student_status = frappe.db.get_value("Student", stud.student, "enabled") date = from_date total_p = total_a = 0.0 + for day in range(total_days_in_month): status="None" + if att_map.get(stud.student): status = att_map.get(stud.student).get(date, "None") elif not student_status: status = "Inactive" else: status = "None" - status_map = {"Present": "P", "Absent": "A", "None": "", "Inactive":"-"} + + status_map = {"Present": "P", "Absent": "A", "None": "", "Inactive":"-", "Holiday":"H"} row.append(status_map[status]) + if status == "Present": total_p += 1 elif status == "Absent": total_a += 1 date = add_days(date, 1) + row += [total_p, total_a] data.append(row) return columns, data @@ -63,14 +71,19 @@ def get_attendance_list(from_date, to_date, student_group, students_list): and date between %s and %s order by student, date''', (student_group, from_date, to_date), as_dict=1) + att_map = {} students_with_leave_application = get_students_with_leave_application(from_date, to_date, students_list) for d in attendance_list: att_map.setdefault(d.student, frappe._dict()).setdefault(d.date, "") + if students_with_leave_application.get(d.date) and d.student in students_with_leave_application.get(d.date): att_map[d.student][d.date] = "Present" else: att_map[d.student][d.date] = d.status + + att_map = mark_holidays(att_map, from_date, to_date, students_list) + return att_map def get_students_with_leave_application(from_date, to_date, students_list): @@ -108,3 +121,14 @@ def get_attendance_years(): if not year_list: year_list = [getdate().year] return "\n".join(str(year) for year in year_list) + +def mark_holidays(att_map, from_date, to_date, students_list): + holiday_list = get_holiday_list() + holidays = get_holidays(holiday_list) + + for dt in daterange(getdate(from_date), getdate(to_date)): + if dt in holidays: + for student in students_list: + att_map.setdefault(student, frappe._dict()).setdefault(dt, "Holiday") + + return att_map diff --git a/erpnext/education/web_form/student_applicant/student_applicant.json b/erpnext/education/web_form/student_applicant/student_applicant.json index 1810f07a054..7b4eaa18ff9 100644 --- a/erpnext/education/web_form/student_applicant/student_applicant.json +++ b/erpnext/education/web_form/student_applicant/student_applicant.json @@ -8,6 +8,7 @@ "allow_print": 0, "amount": 0.0, "amount_based_on_field": 0, + "apply_document_permissions": 0, "creation": "2016-09-22 13:10:10.792735", "doc_type": "Student Applicant", "docstatus": 0, @@ -16,7 +17,7 @@ "is_standard": 1, "login_required": 1, "max_attachment_size": 0, - "modified": "2020-06-11 22:53:45.875310", + "modified": "2020-10-07 23:13:07.814941", "modified_by": "Administrator", "module": "Education", "name": "student-applicant", @@ -69,19 +70,7 @@ "show_in_filter": 0 }, { - "allow_read_on_all_link_options": 0, - "fieldname": "image", - "fieldtype": "Data", - "hidden": 0, - "label": "Image", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, - "show_in_filter": 0 - }, - { - "allow_read_on_all_link_options": 0, + "allow_read_on_all_link_options": 1, "fieldname": "program", "fieldtype": "Link", "hidden": 0, @@ -94,7 +83,7 @@ "show_in_filter": 0 }, { - "allow_read_on_all_link_options": 0, + "allow_read_on_all_link_options": 1, "fieldname": "academic_year", "fieldtype": "Link", "hidden": 0, @@ -106,6 +95,19 @@ "reqd": 0, "show_in_filter": 0 }, + { + "allow_read_on_all_link_options": 1, + "fieldname": "academic_term", + "fieldtype": "Link", + "hidden": 0, + "label": "Academic Term", + "max_length": 0, + "max_value": 0, + "options": "Academic Term", + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, { "allow_read_on_all_link_options": 0, "fieldname": "date_of_birth", @@ -118,6 +120,19 @@ "reqd": 0, "show_in_filter": 0 }, + { + "allow_read_on_all_link_options": 1, + "fieldname": "gender", + "fieldtype": "Link", + "hidden": 0, + "label": "Gender", + "max_length": 0, + "max_value": 0, + "options": "Gender", + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, { "allow_read_on_all_link_options": 0, "fieldname": "blood_group", @@ -140,7 +155,7 @@ "max_length": 0, "max_value": 0, "read_only": 0, - "reqd": 0, + "reqd": 1, "show_in_filter": 0 }, { @@ -157,7 +172,7 @@ }, { "allow_read_on_all_link_options": 0, - "default": "INDIAN", + "default": "", "fieldname": "nationality", "fieldtype": "Data", "hidden": 0, @@ -205,19 +220,6 @@ "reqd": 0, "show_in_filter": 0 }, - { - "allow_read_on_all_link_options": 0, - "fieldname": "guardians", - "fieldtype": "Table", - "hidden": 0, - "label": "Guardians", - "max_length": 0, - "max_value": 0, - "options": "Student Guardian", - "read_only": 0, - "reqd": 0, - "show_in_filter": 0 - }, { "allow_read_on_all_link_options": 0, "fieldname": "siblings", diff --git a/erpnext/education/workspace/education/education.json b/erpnext/education/workspace/education/education.json new file mode 100644 index 00000000000..bf7496146d9 --- /dev/null +++ b/erpnext/education/workspace/education/education.json @@ -0,0 +1,701 @@ +{ + "category": "Domains", + "charts": [ + { + "chart_name": "Program Enrollments", + "label": "Program Enrollments" + } + ], + "creation": "2020-03-02 17:22:57.066401", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "education", + "idx": 0, + "is_standard": 1, + "label": "Education", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Student and Instructor", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student", + "link_to": "Student", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Instructor", + "link_to": "Instructor", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Guardian", + "link_to": "Guardian", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Group", + "link_to": "Student Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Log", + "link_to": "Student Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Masters", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Program", + "link_to": "Program", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Course", + "link_to": "Course", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Topic", + "link_to": "Topic", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Room", + "link_to": "Room", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Content Masters", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Article", + "link_to": "Article", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Video", + "link_to": "Video", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quiz", + "link_to": "Quiz", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Education Settings", + "link_to": "Education Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Category", + "link_to": "Student Category", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Batch Name", + "link_to": "Student Batch Name", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Grading Scale", + "link_to": "Grading Scale", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Academic Term", + "link_to": "Academic Term", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Academic Year", + "link_to": "Academic Year", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Admission", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Applicant", + "link_to": "Student Applicant", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Admission", + "link_to": "Student Admission", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Program Enrollment", + "link_to": "Program Enrollment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Course Enrollment", + "link_to": "Course Enrollment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Fees", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Fee Structure", + "link_to": "Fee Structure", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Fee Category", + "link_to": "Fee Category", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Fee Schedule", + "link_to": "Fee Schedule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Fees", + "link_to": "Fees", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Fees", + "hidden": 0, + "is_query_report": 1, + "label": "Student Fee Collection Report", + "link_to": "Student Fee Collection", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Fees", + "hidden": 0, + "is_query_report": 1, + "label": "Program wise Fee Collection Report", + "link_to": "Program wise Fee Collection", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Schedule", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Course Schedule", + "link_to": "Course Schedule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Course Scheduling Tool", + "link_to": "Course Scheduling Tool", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Attendance", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Attendance", + "link_to": "Student Attendance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Leave Application", + "link_to": "Student Leave Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Student Attendance", + "hidden": 0, + "is_query_report": 1, + "label": "Student Monthly Attendance Sheet", + "link_to": "Student Monthly Attendance Sheet", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Student Attendance", + "hidden": 0, + "is_query_report": 1, + "label": "Absent Student Report", + "link_to": "Absent Student Report", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Student Attendance", + "hidden": 0, + "is_query_report": 1, + "label": "Student Batch-Wise Attendance", + "link_to": "Student Batch-Wise Attendance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "LMS Activity", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Course Enrollment", + "link_to": "Course Enrollment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Course Activity", + "link_to": "Course Activity", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quiz Activity", + "link_to": "Quiz Activity", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Assessment", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Assessment Plan", + "link_to": "Assessment Plan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Assessment Group", + "link_to": "Assessment Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Assessment Result", + "link_to": "Assessment Result", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Assessment Criteria", + "link_to": "Assessment Criteria", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Assessment Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Assessment Result", + "hidden": 0, + "is_query_report": 1, + "label": "Course wise Assessment Report", + "link_to": "Course wise Assessment Report", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Assessment Result", + "hidden": 0, + "is_query_report": 1, + "label": "Final Assessment Grades", + "link_to": "Final Assessment Grades", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Assessment Plan", + "hidden": 0, + "is_query_report": 1, + "label": "Assessment Plan Status", + "link_to": "Assessment Plan Status", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Report Generation Tool", + "link_to": "Student Report Generation Tool", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Tools", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Attendance Tool", + "link_to": "Student Attendance Tool", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Assessment Result Tool", + "link_to": "Assessment Result Tool", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student Group Creation Tool", + "link_to": "Student Group Creation Tool", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Program Enrollment Tool", + "link_to": "Program Enrollment Tool", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Course Scheduling Tool", + "link_to": "Course Scheduling Tool", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Other Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Program Enrollment", + "hidden": 0, + "is_query_report": 1, + "label": "Student and Guardian Contact Details", + "link_to": "Student and Guardian Contact Details", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:37.448989", + "modified_by": "Administrator", + "module": "Education", + "name": "Education", + "onboarding": "Education", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "restrict_to_domain": "Education", + "shortcuts": [ + { + "color": "Grey", + "format": "{} Active", + "label": "Student", + "link_to": "Student", + "stats_filter": "{\n \"enabled\": 1\n}", + "type": "DocType" + }, + { + "color": "Grey", + "format": "{} Active", + "label": "Instructor", + "link_to": "Instructor", + "stats_filter": "{\n \"status\": \"Active\"\n}", + "type": "DocType" + }, + { + "color": "", + "format": "", + "label": "Program", + "link_to": "Program", + "stats_filter": "", + "type": "DocType" + }, + { + "label": "Course", + "link_to": "Course", + "type": "DocType" + }, + { + "color": "Grey", + "format": "{} Unpaid", + "label": "Fees", + "link_to": "Fees", + "stats_filter": "{\n \"outstanding_amount\": [\"!=\", 0.0]\n}", + "type": "DocType" + }, + { + "label": "Student Monthly Attendance Sheet", + "link_to": "Student Monthly Attendance Sheet", + "type": "Report" + }, + { + "label": "Course Scheduling Tool", + "link_to": "Course Scheduling Tool", + "type": "DocType" + }, + { + "label": "Student Attendance Tool", + "link_to": "Student Attendance Tool", + "type": "DocType" + }, + { + "label": "Dashboard", + "link_to": "Education", + "type": "Dashboard" + } + ] +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/connectors/shopify_connection.py b/erpnext/erpnext_integrations/connectors/shopify_connection.py index d59f9092983..f0a05ed192f 100644 --- a/erpnext/erpnext_integrations/connectors/shopify_connection.py +++ b/erpnext/erpnext_integrations/connectors/shopify_connection.py @@ -2,12 +2,13 @@ from __future__ import unicode_literals import frappe from frappe import _ import json -from frappe.utils import cstr, cint, nowdate, flt +from frappe.utils import cstr, cint, nowdate, getdate, flt, get_request_session, get_datetime from erpnext.erpnext_integrations.utils import validate_webhooks_request from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note, make_sales_invoice from erpnext.erpnext_integrations.doctype.shopify_settings.sync_product import sync_item_from_shopify from erpnext.erpnext_integrations.doctype.shopify_settings.sync_customer import create_customer from erpnext.erpnext_integrations.doctype.shopify_log.shopify_log import make_shopify_log, dump_request_data +from erpnext.erpnext_integrations.doctype.shopify_settings.shopify_settings import get_shopify_url, get_header @frappe.whitelist(allow_guest=True) @validate_webhooks_request("Shopify Settings", 'X-Shopify-Hmac-Sha256', secret_key='shared_secret') @@ -18,7 +19,7 @@ def store_request_data(order=None, event=None): dump_request_data(order, event) -def sync_sales_order(order, request_id=None): +def sync_sales_order(order, request_id=None, old_order_sync=False): frappe.set_user('Administrator') shopify_settings = frappe.get_doc("Shopify Settings") frappe.flags.request_id = request_id @@ -27,7 +28,7 @@ def sync_sales_order(order, request_id=None): try: validate_customer(order, shopify_settings) validate_item(order, shopify_settings) - create_order(order, shopify_settings) + create_order(order, shopify_settings, old_order_sync=old_order_sync) except Exception as e: make_shopify_log(status="Error", exception=e) @@ -77,13 +78,13 @@ def validate_item(order, shopify_settings): if item.get("product_id") and not frappe.db.get_value("Item", {"shopify_product_id": item.get("product_id")}, "name"): sync_item_from_shopify(shopify_settings, item) -def create_order(order, shopify_settings, company=None): +def create_order(order, shopify_settings, old_order_sync=False, company=None): so = create_sales_order(order, shopify_settings, company) if so: if order.get("financial_status") == "paid": - create_sales_invoice(order, shopify_settings, so) + create_sales_invoice(order, shopify_settings, so, old_order_sync=old_order_sync) - if order.get("fulfillments"): + if order.get("fulfillments") and not old_order_sync: create_delivery_note(order, shopify_settings, so) def create_sales_order(shopify_order, shopify_settings, company=None): @@ -92,7 +93,7 @@ def create_sales_order(shopify_order, shopify_settings, company=None): so = frappe.db.get_value("Sales Order", {"shopify_order_id": shopify_order.get("id")}, "name") if not so: - items = get_order_items(shopify_order.get("line_items"), shopify_settings) + items = get_order_items(shopify_order.get("line_items"), shopify_settings, getdate(shopify_order.get('created_at'))) if not items: message = 'Following items exists in the shopify order but relevant records were not found in the shopify Product master' @@ -106,8 +107,10 @@ def create_sales_order(shopify_order, shopify_settings, company=None): "doctype": "Sales Order", "naming_series": shopify_settings.sales_order_series or "SO-Shopify-", "shopify_order_id": shopify_order.get("id"), + "shopify_order_number": shopify_order.get("name"), "customer": customer or shopify_settings.default_customer, - "delivery_date": nowdate(), + "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), + "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), "company": shopify_settings.company, "selling_price_list": shopify_settings.price_list, "ignore_pricing_rule": 1, @@ -132,32 +135,42 @@ def create_sales_order(shopify_order, shopify_settings, company=None): frappe.db.commit() return so -def create_sales_invoice(shopify_order, shopify_settings, so): +def create_sales_invoice(shopify_order, shopify_settings, so, old_order_sync=False): if not frappe.db.get_value("Sales Invoice", {"shopify_order_id": shopify_order.get("id")}, "name")\ and so.docstatus==1 and not so.per_billed and cint(shopify_settings.sync_sales_invoice): + if old_order_sync: + posting_date = getdate(shopify_order.get('created_at')) + else: + posting_date = nowdate() + si = make_sales_invoice(so.name, ignore_permissions=True) si.shopify_order_id = shopify_order.get("id") + si.shopify_order_number = shopify_order.get("name") + si.set_posting_time = 1 + si.posting_date = posting_date + si.due_date = posting_date si.naming_series = shopify_settings.sales_invoice_series or "SI-Shopify-" si.flags.ignore_mandatory = True set_cost_center(si.items, shopify_settings.cost_center) si.insert(ignore_mandatory=True) si.submit() - make_payament_entry_against_sales_invoice(si, shopify_settings) + make_payament_entry_against_sales_invoice(si, shopify_settings, posting_date) frappe.db.commit() def set_cost_center(items, cost_center): for item in items: item.cost_center = cost_center -def make_payament_entry_against_sales_invoice(doc, shopify_settings): +def make_payament_entry_against_sales_invoice(doc, shopify_settings, posting_date=None): from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry - payemnt_entry = get_payment_entry(doc.doctype, doc.name, bank_account=shopify_settings.cash_bank_account) - payemnt_entry.flags.ignore_mandatory = True - payemnt_entry.reference_no = doc.name - payemnt_entry.reference_date = nowdate() - payemnt_entry.insert(ignore_permissions=True) - payemnt_entry.submit() + payment_entry = get_payment_entry(doc.doctype, doc.name, bank_account=shopify_settings.cash_bank_account) + payment_entry.flags.ignore_mandatory = True + payment_entry.reference_no = doc.name + payment_entry.posting_date = posting_date or nowdate() + payment_entry.reference_date = posting_date or nowdate() + payment_entry.insert(ignore_permissions=True) + payment_entry.submit() def create_delivery_note(shopify_order, shopify_settings, so): if not cint(shopify_settings.sync_delivery_note): @@ -169,6 +182,9 @@ def create_delivery_note(shopify_order, shopify_settings, so): dn = make_delivery_note(so.name) dn.shopify_order_id = fulfillment.get("order_id") + dn.shopify_order_number = shopify_order.get("name") + dn.set_posting_time = 1 + dn.posting_date = getdate(fulfillment.get("created_at")) dn.shopify_fulfillment_id = fulfillment.get("id") dn.naming_series = shopify_settings.delivery_note_series or "DN-Shopify-" dn.items = get_fulfillment_items(dn.items, fulfillment.get("line_items"), shopify_settings) @@ -187,7 +203,7 @@ def get_discounted_amount(order): discounted_amount += flt(discount.get("amount")) return discounted_amount -def get_order_items(order_items, shopify_settings): +def get_order_items(order_items, shopify_settings, delivery_date): items = [] all_product_exists = True product_not_exists = [] @@ -205,7 +221,7 @@ def get_order_items(order_items, shopify_settings): "item_code": item_code, "item_name": shopify_item.get("name"), "rate": shopify_item.get("price"), - "delivery_date": nowdate(), + "delivery_date": delivery_date, "qty": shopify_item.get("quantity"), "stock_uom": shopify_item.get("uom") or _("Nos"), "warehouse": shopify_settings.warehouse @@ -244,6 +260,15 @@ def update_taxes_with_shipping_lines(taxes, shipping_lines, shopify_settings): """Shipping lines represents the shipping details, each such shipping detail consists of a list of tax_lines""" for shipping_charge in shipping_lines: + if shipping_charge.get("price"): + taxes.append({ + "charge_type": _("Actual"), + "account_head": get_tax_account_head(shipping_charge), + "description": shipping_charge["title"], + "tax_amount": shipping_charge["price"], + "cost_center": shopify_settings.cost_center + }) + for tax in shipping_charge.get("tax_lines"): taxes.append({ "charge_type": _("Actual"), @@ -265,3 +290,64 @@ def get_tax_account_head(tax): frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) return tax_account + +@frappe.whitelist(allow_guest=True) +def sync_old_orders(): + frappe.set_user('Administrator') + shopify_settings = frappe.get_doc('Shopify Settings') + + if not shopify_settings.sync_missing_orders: + return + + url = get_url(shopify_settings) + session = get_request_session() + + try: + res = session.get(url, headers=get_header(shopify_settings)) + res.raise_for_status() + orders = res.json()["orders"] + + for order in orders: + if is_sync_complete(shopify_settings, order): + stop_sync(shopify_settings) + return + + sync_sales_order(order=order, old_order_sync=True) + last_order_id = order.get('id') + + if last_order_id: + shopify_settings.load_from_db() + shopify_settings.last_order_id = last_order_id + shopify_settings.save() + frappe.db.commit() + + except Exception as e: + raise e + +def stop_sync(shopify_settings): + shopify_settings.sync_missing_orders = 0 + shopify_settings.last_order_id = '' + shopify_settings.save() + frappe.db.commit() + +def get_url(shopify_settings): + last_order_id = shopify_settings.last_order_id + + if not last_order_id: + if shopify_settings.sync_based_on == 'Date': + url = get_shopify_url("admin/api/2020-10/orders.json?limit=250&created_at_min={0}&since_id=0".format( + get_datetime(shopify_settings.from_date)), shopify_settings) + else: + url = get_shopify_url("admin/api/2020-10/orders.json?limit=250&since_id={0}".format( + shopify_settings.from_order_id), shopify_settings) + else: + url = get_shopify_url("admin/api/2020-10/orders.json?limit=250&since_id={0}".format(last_order_id), shopify_settings) + + return url + +def is_sync_complete(shopify_settings, order): + if shopify_settings.sync_based_on == 'Date': + return getdate(shopify_settings.to_date) < getdate(order.get('created_at')) + else: + return cstr(order.get('id')) == cstr(shopify_settings.to_order_id) + diff --git a/erpnext/erpnext_integrations/desk_page/erpnext_integrations/erpnext_integrations.json b/erpnext/erpnext_integrations/desk_page/erpnext_integrations/erpnext_integrations.json deleted file mode 100644 index 8dcc77d1749..00000000000 --- a/erpnext/erpnext_integrations/desk_page/erpnext_integrations/erpnext_integrations.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Marketplace", - "links": "[\n {\n \"description\": \"Woocommerce marketplace settings\",\n \"label\": \"Woocommerce Settings\",\n \"name\": \"Woocommerce Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Amazon MWS settings\",\n \"label\": \"Amazon MWS Settings\",\n \"name\": \"Amazon MWS Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Shopify settings\",\n \"label\": \"Shopify Settings\",\n \"name\": \"Shopify Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Payments", - "links": "[\n {\n \"description\": \"GoCardless payment gateway settings\",\n \"label\": \"GoCardless Settings\",\n \"name\": \"GoCardless Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"description\": \"Plaid settings\",\n \"label\": \"Plaid Settings\",\n \"name\": \"Plaid Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Exotel settings\",\n \"label\": \"Exotel Settings\",\n \"name\": \"Exotel Settings\",\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Modules", - "charts": [], - "creation": "2020-08-20 19:30:48.138801", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends": "Integrations", - "extends_another_page": 1, - "hide_custom": 1, - "idx": 0, - "is_standard": 1, - "label": "ERPNext Integrations", - "modified": "2020-08-23 16:30:51.494655", - "modified_by": "Administrator", - "module": "ERPNext Integrations", - "name": "ERPNext Integrations", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [] -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py index cc75a0afbe0..148c1a6a166 100644 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py +++ b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py @@ -117,7 +117,7 @@ def call_mws_method(mws_method, *args, **kwargs): return response except Exception as e: delay = math.pow(4, x) * 125 - frappe.log_error(message=e, title=str(mws_method)) + frappe.log_error(message=e, title=f'Method "{mws_method.__name__}" failed') time.sleep(delay) continue diff --git a/erpnext/buying/report/quoted_item_comparison/__init__.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/__init__.py similarity index 100% rename from erpnext/buying/report/quoted_item_comparison/__init__.py rename to erpnext/erpnext_integrations/doctype/mpesa_settings/__init__.py diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html b/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html new file mode 100644 index 00000000000..2c4d4bbdecf --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html @@ -0,0 +1,28 @@ + +{% if not jQuery.isEmptyObject(data) %} +
    {{ __("Balance Details") }}
    + + + + + + + + + + + + {% for(const [key, value] of Object.entries(data)) { %} + + + + + + + + {% } %} + +
    {{ __("Account Type") }}{{ __("Current Balance") }}{{ __("Available Balance") }}{{ __("Reserved Balance") }}{{ __("Uncleared Balance") }}
    {%= key %} {%= value["current_balance"] %} {%= value["available_balance"] %} {%= value["reserved_balance"] %} {%= value["uncleared_balance"] %}
    +{% else %} +

    Account Balance Information Not Available.

    +{% endif %} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py new file mode 100644 index 00000000000..554c6b0eb0f --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -0,0 +1,118 @@ +import base64 +import requests +from requests.auth import HTTPBasicAuth +import datetime + +class MpesaConnector(): + def __init__(self, env="sandbox", app_key=None, app_secret=None, sandbox_url="https://sandbox.safaricom.co.ke", + live_url="https://api.safaricom.co.ke"): + """Setup configuration for Mpesa connector and generate new access token.""" + self.env = env + self.app_key = app_key + self.app_secret = app_secret + if env == "sandbox": + self.base_url = sandbox_url + else: + self.base_url = live_url + self.authenticate() + + def authenticate(self): + """ + This method is used to fetch the access token required by Mpesa. + + Returns: + access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa. + """ + authenticate_uri = "/oauth/v1/generate?grant_type=client_credentials" + authenticate_url = "{0}{1}".format(self.base_url, authenticate_uri) + r = requests.get( + authenticate_url, + auth=HTTPBasicAuth(self.app_key, self.app_secret) + ) + self.authentication_token = r.json()['access_token'] + return r.json()['access_token'] + + def get_balance(self, initiator=None, security_credential=None, party_a=None, identifier_type=None, + remarks=None, queue_timeout_url=None,result_url=None): + """ + This method uses Mpesa's Account Balance API to to enquire the balance on a M-Pesa BuyGoods (Till Number). + + Args: + initiator (str): Username used to authenticate the transaction. + security_credential (str): Generate from developer portal. + command_id (str): AccountBalance. + party_a (int): Till number being queried. + identifier_type (int): Type of organization receiving the transaction. (MSISDN/Till Number/Organization short code) + remarks (str): Comments that are sent along with the transaction(maximum 100 characters). + queue_timeout_url (str): The url that handles information of timed out transactions. + result_url (str): The url that receives results from M-Pesa api call. + + Returns: + OriginatorConverstionID (str): The unique request ID for tracking a transaction. + ConversationID (str): The unique request ID returned by mpesa for each request made + ResponseDescription (str): Response Description message + """ + + payload = { + "Initiator": initiator, + "SecurityCredential": security_credential, + "CommandID": "AccountBalance", + "PartyA": party_a, + "IdentifierType": identifier_type, + "Remarks": remarks, + "QueueTimeOutURL": queue_timeout_url, + "ResultURL": result_url + } + headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"} + saf_url = "{0}{1}".format(self.base_url, "/mpesa/accountbalance/v1/query") + r = requests.post(saf_url, headers=headers, json=payload) + return r.json() + + def stk_push(self, business_shortcode=None, passcode=None, amount=None, callback_url=None, reference_code=None, + phone_number=None, description=None): + """ + This method uses Mpesa's Express API to initiate online payment on behalf of a customer. + + Args: + business_shortcode (int): The short code of the organization. + passcode (str): Get from developer portal + amount (int): The amount being transacted + callback_url (str): A CallBack URL is a valid secure URL that is used to receive notifications from M-Pesa API. + reference_code(str): Account Reference: This is an Alpha-Numeric parameter that is defined by your system as an Identifier of the transaction for CustomerPayBillOnline transaction type. + phone_number(int): The Mobile Number to receive the STK Pin Prompt. + description(str): This is any additional information/comment that can be sent along with the request from your system. MAX 13 characters + + Success Response: + CustomerMessage(str): Messages that customers can understand. + CheckoutRequestID(str): This is a global unique identifier of the processed checkout transaction request. + ResponseDescription(str): Describes Success or failure + MerchantRequestID(str): This is a global unique Identifier for any submitted payment request. + ResponseCode(int): 0 means success all others are error codes. e.g.404.001.03 + + Error Reponse: + requestId(str): This is a unique requestID for the payment request + errorCode(str): This is a predefined code that indicates the reason for request failure. + errorMessage(str): This is a predefined code that indicates the reason for request failure. + """ + + time = str(datetime.datetime.now()).split(".")[0].replace("-", "").replace(" ", "").replace(":", "") + password = "{0}{1}{2}".format(str(business_shortcode), str(passcode), time) + encoded = base64.b64encode(bytes(password, encoding='utf8')) + payload = { + "BusinessShortCode": business_shortcode, + "Password": encoded.decode("utf-8"), + "Timestamp": time, + "Amount": amount, + "PartyA": int(phone_number), + "PartyB": reference_code, + "PhoneNumber": int(phone_number), + "CallBackURL": callback_url, + "AccountReference": reference_code, + "TransactionDesc": description, + "TransactionType": "CustomerPayBillOnline" if self.env == "sandbox" else "CustomerBuyGoodsOnline" + } + headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"} + + saf_url = "{0}{1}".format(self.base_url, "/mpesa/stkpush/v1/processrequest") + r = requests.post(saf_url, headers=headers, json=payload) + return r.json() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py new file mode 100644 index 00000000000..0499e88b5e7 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -0,0 +1,53 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +def create_custom_pos_fields(): + """Create custom fields corresponding to POS Settings and POS Invoice.""" + pos_field = { + "POS Invoice": [ + { + "fieldname": "request_for_payment", + "label": "Request for Payment", + "fieldtype": "Button", + "hidden": 1, + "insert_after": "contact_email" + }, + { + "fieldname": "mpesa_receipt_number", + "label": "Mpesa Receipt Number", + "fieldtype": "Data", + "read_only": 1, + "insert_after": "company" + } + ] + } + if not frappe.get_meta("POS Invoice").has_field("request_for_payment"): + create_custom_fields(pos_field) + + record_dict = [{ + "doctype": "POS Field", + "fieldname": "contact_mobile", + "label": "Mobile No", + "fieldtype": "Data", + "options": "Phone", + "parenttype": "POS Settings", + "parent": "POS Settings", + "parentfield": "invoice_fields" + }, + { + "doctype": "POS Field", + "fieldname": "request_for_payment", + "label": "Request for Payment", + "fieldtype": "Button", + "parenttype": "POS Settings", + "parent": "POS Settings", + "parentfield": "invoice_fields" + } + ] + create_pos_settings(record_dict) + +def create_pos_settings(record_dict): + for record in record_dict: + if frappe.db.exists("POS Field", {"fieldname": record.get("fieldname")}): + continue + frappe.get_doc(record).insert() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js new file mode 100644 index 00000000000..7c8ae5c8023 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -0,0 +1,37 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Mpesa Settings', { + onload_post_render: function(frm) { + frm.events.setup_account_balance_html(frm); + }, + + refresh: function(frm) { + frappe.realtime.on("refresh_mpesa_dashboard", function(){ + frm.reload_doc(); + frm.events.setup_account_balance_html(frm); + }); + }, + + get_account_balance: function(frm) { + if (!frm.doc.initiator_name && !frm.doc.security_credential) { + frappe.throw(__("Please set the initiator name and the security credential")); + } + frappe.call({ + method: "get_account_balance_info", + doc: frm.doc + }); + }, + + setup_account_balance_html: function(frm) { + if (!frm.doc.account_balance) return; + $("div").remove(".form-dashboard-section.custom"); + frm.dashboard.add_section( + frappe.render_template('account_balance', { + data: JSON.parse(frm.doc.account_balance) + }) + ); + frm.dashboard.show(); + } + +}); diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json new file mode 100644 index 00000000000..8f3b4271c18 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json @@ -0,0 +1,152 @@ +{ + "actions": [], + "autoname": "field:payment_gateway_name", + "creation": "2020-09-10 13:21:27.398088", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "payment_gateway_name", + "consumer_key", + "consumer_secret", + "initiator_name", + "till_number", + "transaction_limit", + "sandbox", + "column_break_4", + "business_shortcode", + "online_passkey", + "security_credential", + "get_account_balance", + "account_balance" + ], + "fields": [ + { + "fieldname": "payment_gateway_name", + "fieldtype": "Data", + "label": "Payment Gateway Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "consumer_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Consumer Key", + "reqd": 1 + }, + { + "fieldname": "consumer_secret", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Consumer Secret", + "reqd": 1 + }, + { + "fieldname": "till_number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Till Number", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "sandbox", + "fieldtype": "Check", + "label": "Sandbox" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "online_passkey", + "fieldtype": "Password", + "label": " Online PassKey", + "reqd": 1 + }, + { + "fieldname": "initiator_name", + "fieldtype": "Data", + "label": "Initiator Name" + }, + { + "fieldname": "security_credential", + "fieldtype": "Small Text", + "label": "Security Credential" + }, + { + "fieldname": "account_balance", + "fieldtype": "Long Text", + "hidden": 1, + "label": "Account Balance", + "read_only": 1 + }, + { + "fieldname": "get_account_balance", + "fieldtype": "Button", + "label": "Get Account Balance" + }, + { + "depends_on": "eval:(doc.sandbox==0)", + "fieldname": "business_shortcode", + "fieldtype": "Data", + "label": "Business Shortcode", + "mandatory_depends_on": "eval:(doc.sandbox==0)" + }, + { + "default": "150000", + "fieldname": "transaction_limit", + "fieldtype": "Float", + "label": "Transaction Limit", + "non_negative": 1 + } + ], + "links": [], + "modified": "2021-03-02 17:35:14.084342", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "Mpesa Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py new file mode 100644 index 00000000000..b5718026c12 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -0,0 +1,278 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + + +from __future__ import unicode_literals +from json import loads, dumps + +import frappe +from frappe.model.document import Document +from frappe import _ +from frappe.utils import call_hook_method, fmt_money +from frappe.integrations.utils import create_request_log, create_payment_gateway +from frappe.utils import get_request_site_address +from erpnext.erpnext_integrations.utils import create_mode_of_payment +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 + +class MpesaSettings(Document): + supported_currencies = ["KES"] + + def validate_transaction_currency(self, currency): + if currency not in self.supported_currencies: + frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency)) + + def on_update(self): + create_custom_pos_fields() + create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) + call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone") + + # required to fetch the bank account details from the payment gateway account + frappe.db.commit() + create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone") + + def request_for_payment(self, **kwargs): + args = frappe._dict(kwargs) + request_amounts = self.split_request_amount_according_to_transaction_limit(args) + + for i, amount in enumerate(request_amounts): + args.request_amount = amount + if frappe.flags.in_test: + from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_payment_request_response_payload + response = frappe._dict(get_payment_request_response_payload(amount)) + else: + response = frappe._dict(generate_stk_push(**args)) + + self.handle_api_response("CheckoutRequestID", args, response) + + def split_request_amount_according_to_transaction_limit(self, args): + request_amount = args.request_amount + if request_amount > self.transaction_limit: + # make multiple requests + request_amounts = [] + requests_to_be_made = frappe.utils.ceil(request_amount / self.transaction_limit) # 480/150 = ceil(3.2) = 4 + for i in range(requests_to_be_made): + amount = self.transaction_limit + if i == requests_to_be_made - 1: + amount = request_amount - (self.transaction_limit * i) # for 4th request, 480 - (150 * 3) = 30 + request_amounts.append(amount) + else: + request_amounts = [request_amount] + + return request_amounts + + def get_account_balance_info(self): + payload = dict( + reference_doctype="Mpesa Settings", + reference_docname=self.name, + doc_details=vars(self) + ) + + if frappe.flags.in_test: + from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_test_account_balance_response + response = frappe._dict(get_test_account_balance_response()) + else: + response = frappe._dict(get_account_balance(payload)) + + self.handle_api_response("ConversationID", payload, response) + + def handle_api_response(self, global_id, request_dict, response): + """Response received from API calls returns a global identifier for each transaction, this code is returned during the callback.""" + # check error response + if getattr(response, "requestId"): + req_name = getattr(response, "requestId") + error = response + else: + # global checkout id used as request name + req_name = getattr(response, global_id) + error = None + + if not frappe.db.exists('Integration Request', req_name): + create_request_log(request_dict, "Host", "Mpesa", req_name, error) + + if error: + frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) + +def generate_stk_push(**kwargs): + """Generate stk push by making a API call to the stk push API.""" + args = frappe._dict(kwargs) + try: + callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.verify_transaction" + + mpesa_settings = frappe.get_doc("Mpesa Settings", args.payment_gateway[6:]) + env = "production" if not mpesa_settings.sandbox else "sandbox" + # for sandbox, business shortcode is same as till number + business_shortcode = mpesa_settings.business_shortcode if env == "production" else mpesa_settings.till_number + + connector = MpesaConnector(env=env, + app_key=mpesa_settings.consumer_key, + app_secret=mpesa_settings.get_password("consumer_secret")) + + mobile_number = sanitize_mobile_number(args.sender) + + response = connector.stk_push( + business_shortcode=business_shortcode, amount=args.request_amount, + passcode=mpesa_settings.get_password("online_passkey"), + callback_url=callback_url, reference_code=mpesa_settings.till_number, + phone_number=mobile_number, description="POS Payment" + ) + + return response + + except Exception: + frappe.log_error(title=_("Mpesa Express Transaction Error")) + frappe.throw(_("Issue detected with Mpesa configuration, check the error logs for more details"), title=_("Mpesa Express Error")) + +def sanitize_mobile_number(number): + """Add country code and strip leading zeroes from the phone number.""" + return "254" + str(number).lstrip("0") + +@frappe.whitelist(allow_guest=True) +def verify_transaction(**kwargs): + """Verify the transaction result received via callback from stk.""" + transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) + + checkout_id = getattr(transaction_response, "CheckoutRequestID", "") + integration_request = frappe.get_doc("Integration Request", checkout_id) + transaction_data = frappe._dict(loads(integration_request.data)) + total_paid = 0 # for multiple integration request made against a pos invoice + success = False # for reporting successfull callback to point of sale ui + + if transaction_response['ResultCode'] == 0: + if integration_request.reference_doctype and integration_request.reference_docname: + try: + item_response = transaction_response["CallbackMetadata"]["Item"] + amount = fetch_param_value(item_response, "Amount", "Name") + mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") + pr = frappe.get_doc(integration_request.reference_doctype, integration_request.reference_docname) + + mpesa_receipts, completed_payments = get_completed_integration_requests_info( + integration_request.reference_doctype, + integration_request.reference_docname, + checkout_id + ) + + total_paid = amount + sum(completed_payments) + mpesa_receipts = ', '.join(mpesa_receipts + [mpesa_receipt]) + + if total_paid >= pr.grand_total: + pr.run_method("on_payment_authorized", 'Completed') + success = True + + frappe.db.set_value("POS Invoice", pr.reference_name, "mpesa_receipt_number", mpesa_receipts) + integration_request.handle_success(transaction_response) + except Exception: + integration_request.handle_failure(transaction_response) + frappe.log_error(frappe.get_traceback()) + + else: + integration_request.handle_failure(transaction_response) + + frappe.publish_realtime( + event='process_phone_payment', + doctype="POS Invoice", + docname=transaction_data.payment_reference, + user=integration_request.owner, + message={ + 'amount': total_paid, + 'success': success, + 'failure_message': transaction_response["ResultDesc"] if transaction_response['ResultCode'] != 0 else '' + }, + ) + +def get_completed_integration_requests_info(reference_doctype, reference_docname, checkout_id): + output_of_other_completed_requests = frappe.get_all("Integration Request", filters={ + 'name': ['!=', checkout_id], + 'reference_doctype': reference_doctype, + 'reference_docname': reference_docname, + 'status': 'Completed' + }, pluck="output") + + mpesa_receipts, completed_payments = [], [] + + for out in output_of_other_completed_requests: + out = frappe._dict(loads(out)) + item_response = out["CallbackMetadata"]["Item"] + completed_amount = fetch_param_value(item_response, "Amount", "Name") + completed_mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") + completed_payments.append(completed_amount) + mpesa_receipts.append(completed_mpesa_receipt) + + return mpesa_receipts, completed_payments + +def get_account_balance(request_payload): + """Call account balance API to send the request to the Mpesa Servers.""" + try: + mpesa_settings = frappe.get_doc("Mpesa Settings", request_payload.get("reference_docname")) + env = "production" if not mpesa_settings.sandbox else "sandbox" + connector = MpesaConnector(env=env, + app_key=mpesa_settings.consumer_key, + app_secret=mpesa_settings.get_password("consumer_secret")) + + callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" + + response = connector.get_balance(mpesa_settings.initiator_name, mpesa_settings.security_credential, mpesa_settings.till_number, 4, mpesa_settings.name, callback_url, callback_url) + return response + except Exception: + frappe.log_error(title=_("Account Balance Processing Error")) + frappe.throw(_("Please check your configuration and try again"), title=_("Error")) + +@frappe.whitelist(allow_guest=True) +def process_balance_info(**kwargs): + """Process and store account balance information received via callback from the account balance API call.""" + account_balance_response = frappe._dict(kwargs["Result"]) + + conversation_id = getattr(account_balance_response, "ConversationID", "") + request = frappe.get_doc("Integration Request", conversation_id) + + if request.status == "Completed": + return + + transaction_data = frappe._dict(loads(request.data)) + + if account_balance_response["ResultCode"] == 0: + try: + result_params = account_balance_response["ResultParameters"]["ResultParameter"] + + balance_info = fetch_param_value(result_params, "AccountBalance", "Key") + balance_info = format_string_to_json(balance_info) + + ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname) + ref_doc.db_set("account_balance", balance_info) + + request.handle_success(account_balance_response) + frappe.publish_realtime("refresh_mpesa_dashboard", doctype="Mpesa Settings", + docname=transaction_data.reference_docname, user=transaction_data.owner) + except Exception: + request.handle_failure(account_balance_response) + frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) + else: + request.handle_failure(account_balance_response) + +def format_string_to_json(balance_info): + """ + Format string to json. + + e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00''' + => {'Working Account': {'current_balance': '481000.00', + 'available_balance': '481000.00', + 'reserved_balance': '0.00', + 'uncleared_balance': '0.00'}} + """ + balance_dict = frappe._dict() + for account_info in balance_info.split("&"): + account_info = account_info.split('|') + balance_dict[account_info[0]] = dict( + current_balance=fmt_money(account_info[2], currency="KES"), + available_balance=fmt_money(account_info[3], currency="KES"), + reserved_balance=fmt_money(account_info[4], currency="KES"), + uncleared_balance=fmt_money(account_info[5], currency="KES") + ) + return dumps(balance_dict) + +def fetch_param_value(response, key, key_field): + """Fetch the specified key from list of dictionary. Key is identified via the key field.""" + for param in response: + if param[key_field] == key: + return param["Value"] \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py new file mode 100644 index 00000000000..29487962f69 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -0,0 +1,355 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals +from json import dumps +import frappe +import unittest +from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings import process_balance_info, verify_transaction +from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice + +class TestMpesaSettings(unittest.TestCase): + def tearDown(self): + frappe.db.sql('delete from `tabMpesa Settings`') + frappe.db.sql('delete from `tabIntegration Request` where integration_request_service = "Mpesa"') + + def test_creation_of_payment_gateway(self): + create_mpesa_settings(payment_gateway_name="_Test") + + mode_of_payment = frappe.get_doc("Mode of Payment", "Mpesa-_Test") + self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"})) + self.assertTrue(mode_of_payment.name) + self.assertEquals(mode_of_payment.type, "Phone") + + def test_processing_of_account_balance(self): + mpesa_doc = create_mpesa_settings(payment_gateway_name="_Account Balance") + mpesa_doc.get_account_balance_info() + + callback_response = get_account_balance_callback_payload() + process_balance_info(**callback_response) + integration_request = frappe.get_doc("Integration Request", "AG_20200927_00007cdb1f9fb6494315") + + # test integration request creation and successful update of the status on receiving callback response + self.assertTrue(integration_request) + self.assertEquals(integration_request.status, "Completed") + + # test formatting of account balance received as string to json with appropriate currency symbol + mpesa_doc.reload() + self.assertEquals(mpesa_doc.account_balance, dumps({ + "Working Account": { + "current_balance": "Sh 481,000.00", + "available_balance": "Sh 481,000.00", + "reserved_balance": "Sh 0.00", + "uncleared_balance": "Sh 0.00" + } + })) + + integration_request.delete() + + def test_processing_of_callback_payload(self): + create_mpesa_settings(payment_gateway_name="Payment") + mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") + frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") + + pos_invoice = create_pos_invoice(do_not_submit=1) + pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 500}) + pos_invoice.contact_mobile = "093456543894" + pos_invoice.currency = "KES" + pos_invoice.save() + + pr = pos_invoice.create_payment_request() + # test payment request creation + self.assertEquals(pr.payment_gateway, "Mpesa-Payment") + + # submitting payment request creates integration requests with random id + integration_req_ids = frappe.get_all("Integration Request", filters={ + 'reference_doctype': pr.doctype, + 'reference_docname': pr.name, + }, pluck="name") + + callback_response = get_payment_callback_payload(Amount=500, CheckoutRequestID=integration_req_ids[0]) + verify_transaction(**callback_response) + # test creation of integration request + integration_request = frappe.get_doc("Integration Request", integration_req_ids[0]) + + # test integration request creation and successful update of the status on receiving callback response + self.assertTrue(integration_request) + self.assertEquals(integration_request.status, "Completed") + + pos_invoice.reload() + integration_request.reload() + self.assertEquals(pos_invoice.mpesa_receipt_number, "LGR7OWQX0R") + self.assertEquals(integration_request.status, "Completed") + + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") + integration_request.delete() + pr.reload() + pr.cancel() + pr.delete() + pos_invoice.delete() + + def test_processing_of_multiple_callback_payload(self): + create_mpesa_settings(payment_gateway_name="Payment") + mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") + frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") + frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") + + pos_invoice = create_pos_invoice(do_not_submit=1) + pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 1000}) + pos_invoice.contact_mobile = "093456543894" + pos_invoice.currency = "KES" + pos_invoice.save() + + pr = pos_invoice.create_payment_request() + # test payment request creation + self.assertEquals(pr.payment_gateway, "Mpesa-Payment") + + # submitting payment request creates integration requests with random id + integration_req_ids = frappe.get_all("Integration Request", filters={ + 'reference_doctype': pr.doctype, + 'reference_docname': pr.name, + }, pluck="name") + + # create random receipt nos and send it as response to callback handler + mpesa_receipt_numbers = [frappe.utils.random_string(5) for d in integration_req_ids] + + integration_requests = [] + for i in range(len(integration_req_ids)): + callback_response = get_payment_callback_payload( + Amount=500, + CheckoutRequestID=integration_req_ids[i], + MpesaReceiptNumber=mpesa_receipt_numbers[i] + ) + # handle response manually + verify_transaction(**callback_response) + # test completion of integration request + integration_request = frappe.get_doc("Integration Request", integration_req_ids[i]) + self.assertEquals(integration_request.status, "Completed") + integration_requests.append(integration_request) + + # check receipt number once all the integration requests are completed + pos_invoice.reload() + self.assertEquals(pos_invoice.mpesa_receipt_number, ', '.join(mpesa_receipt_numbers)) + + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") + [d.delete() for d in integration_requests] + pr.reload() + pr.cancel() + pr.delete() + pos_invoice.delete() + + def test_processing_of_only_one_succes_callback_payload(self): + create_mpesa_settings(payment_gateway_name="Payment") + mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") + frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") + frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") + + pos_invoice = create_pos_invoice(do_not_submit=1) + pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 1000}) + pos_invoice.contact_mobile = "093456543894" + pos_invoice.currency = "KES" + pos_invoice.save() + + pr = pos_invoice.create_payment_request() + # test payment request creation + self.assertEquals(pr.payment_gateway, "Mpesa-Payment") + + # submitting payment request creates integration requests with random id + integration_req_ids = frappe.get_all("Integration Request", filters={ + 'reference_doctype': pr.doctype, + 'reference_docname': pr.name, + }, pluck="name") + + # create random receipt nos and send it as response to callback handler + mpesa_receipt_numbers = [frappe.utils.random_string(5) for d in integration_req_ids] + + callback_response = get_payment_callback_payload( + Amount=500, + CheckoutRequestID=integration_req_ids[0], + MpesaReceiptNumber=mpesa_receipt_numbers[0] + ) + # handle response manually + verify_transaction(**callback_response) + # test completion of integration request + integration_request = frappe.get_doc("Integration Request", integration_req_ids[0]) + self.assertEquals(integration_request.status, "Completed") + + # now one request is completed + # second integration request fails + # now retrying payment request should make only one integration request again + pr = pos_invoice.create_payment_request() + new_integration_req_ids = frappe.get_all("Integration Request", filters={ + 'reference_doctype': pr.doctype, + 'reference_docname': pr.name, + 'name': ['not in', integration_req_ids] + }, pluck="name") + + self.assertEquals(len(new_integration_req_ids), 1) + + frappe.db.set_value("Customer", "_Test Customer", "default_currency", "") + frappe.db.sql("delete from `tabIntegration Request` where integration_request_service = 'Mpesa'") + pr.reload() + pr.cancel() + pr.delete() + pos_invoice.delete() + +def create_mpesa_settings(payment_gateway_name="Express"): + if frappe.db.exists("Mpesa Settings", payment_gateway_name): + return frappe.get_doc("Mpesa Settings", payment_gateway_name) + + doc = frappe.get_doc(dict( #nosec + doctype="Mpesa Settings", + payment_gateway_name=payment_gateway_name, + consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", + consumer_secret="VI1oS3oBGPJfh3JyvLHw", + online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd", + till_number="174379" + )) + + doc.insert(ignore_permissions=True) + return doc + +def get_test_account_balance_response(): + """Response received after calling the account balance API.""" + return { + "ResultType":0, + "ResultCode":0, + "ResultDesc":"The service request has been accepted successfully.", + "OriginatorConversationID":"10816-694520-2", + "ConversationID":"AG_20200927_00007cdb1f9fb6494315", + "TransactionID":"LGR0000000", + "ResultParameters":{ + "ResultParameter":[ + { + "Key":"ReceiptNo", + "Value":"LGR919G2AV" + }, + { + "Key":"Conversation ID", + "Value":"AG_20170727_00004492b1b6d0078fbe" + }, + { + "Key":"FinalisedTime", + "Value":20170727101415 + }, + { + "Key":"Amount", + "Value":10 + }, + { + "Key":"TransactionStatus", + "Value":"Completed" + }, + { + "Key":"ReasonType", + "Value":"Salary Payment via API" + }, + { + "Key":"TransactionReason" + }, + { + "Key":"DebitPartyCharges", + "Value":"Fee For B2C Payment|KES|33.00" + }, + { + "Key":"DebitAccountType", + "Value":"Utility Account" + }, + { + "Key":"InitiatedTime", + "Value":20170727101415 + }, + { + "Key":"Originator Conversation ID", + "Value":"19455-773836-1" + }, + { + "Key":"CreditPartyName", + "Value":"254708374149 - John Doe" + }, + { + "Key":"DebitPartyName", + "Value":"600134 - Safaricom157" + } + ] + }, + "ReferenceData":{ + "ReferenceItem":{ + "Key":"Occasion", + "Value":"aaaa" + } + } + } + +def get_payment_request_response_payload(Amount=500): + """Response received after successfully calling the stk push process request API.""" + + CheckoutRequestID = frappe.utils.random_string(10) + + return { + "MerchantRequestID": "8071-27184008-1", + "CheckoutRequestID": CheckoutRequestID, + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "CallbackMetadata": { + "Item": [ + { "Name": "Amount", "Value": Amount }, + { "Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R" }, + { "Name": "TransactionDate", "Value": 20201006113336 }, + { "Name": "PhoneNumber", "Value": 254723575670 } + ] + } + } + +def get_payment_callback_payload(Amount=500, CheckoutRequestID="ws_CO_061020201133231972", MpesaReceiptNumber="LGR7OWQX0R"): + """Response received from the server as callback after calling the stkpush process request API.""" + return { + "Body":{ + "stkCallback":{ + "MerchantRequestID":"19465-780693-1", + "CheckoutRequestID":CheckoutRequestID, + "ResultCode":0, + "ResultDesc":"The service request is processed successfully.", + "CallbackMetadata":{ + "Item":[ + { "Name":"Amount", "Value":Amount }, + { "Name":"MpesaReceiptNumber", "Value":MpesaReceiptNumber }, + { "Name":"Balance" }, + { "Name":"TransactionDate", "Value":20170727154800 }, + { "Name":"PhoneNumber", "Value":254721566839 } + ] + } + } + } + } + +def get_account_balance_callback_payload(): + """Response received from the server as callback after calling the account balance API.""" + return { + "Result":{ + "ResultType": 0, + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "OriginatorConversationID": "16470-170099139-1", + "ConversationID": "AG_20200927_00007cdb1f9fb6494315", + "TransactionID": "OIR0000000", + "ResultParameters": { + "ResultParameter": [ + { + "Key": "AccountBalance", + "Value": "Working Account|KES|481000.00|481000.00|0.00|0.00" + }, + { "Key": "BOCompletedTime", "Value": 20200927234123 } + ] + }, + "ReferenceData": { + "ReferenceItem": { + "Key": "QueueTimeoutURL", + "Value": "https://internalsandbox.safaricom.co.ke/mpesa/abresults/v1/submit" + } + } + } + } \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index a033a2a722d..5f990cdd034 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -20,7 +20,7 @@ class PlaidConnector(): client_id=self.settings.plaid_client_id, secret=self.settings.get_password("plaid_secret"), environment=self.settings.plaid_env, - api_version="2019-05-29" + api_version="2020-09-14" ) def get_access_token(self, public_token): @@ -30,20 +30,32 @@ class PlaidConnector(): access_token = response["access_token"] return access_token - def get_link_token(self): - token_request = { + def get_token_request(self, update_mode=False): + country_codes = ["US", "CA", "FR", "IE", "NL", "ES", "GB"] if self.settings.enable_european_access else ["US", "CA"] + args = { "client_name": self.client_name, - "client_id": self.settings.plaid_client_id, - "secret": self.settings.plaid_secret, - "products": self.products, # only allow Plaid-supported languages and countries (LAST: Sep-19-2020) "language": frappe.local.lang if frappe.local.lang in ["en", "fr", "es", "nl"] else "en", - "country_codes": ["US", "CA", "FR", "IE", "NL", "ES", "GB"], + "country_codes": country_codes, "user": { "client_user_id": frappe.generate_hash(frappe.session.user, length=32) } } + if update_mode: + args["access_token"] = self.access_token + else: + args.update({ + "client_id": self.settings.plaid_client_id, + "secret": self.settings.plaid_secret, + "products": self.products, + }) + + return args + + def get_link_token(self, update_mode=False): + token_request = self.get_token_request(update_mode) + try: response = self.client.LinkToken.create(token_request) except InvalidRequestError: diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js index 22a4004955f..bbc2ca8846c 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js @@ -12,9 +12,25 @@ frappe.ui.form.on('Plaid Settings', { refresh: function (frm) { if (frm.doc.enabled) { - frm.add_custom_button('Link a new bank account', () => { + frm.add_custom_button(__('Link a new bank account'), () => { new erpnext.integrations.plaidLink(frm); }); + + frm.add_custom_button(__("Sync Now"), () => { + frappe.call({ + method: "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.enqueue_synchronization", + freeze: true, + callback: () => { + let bank_transaction_link = '
    Bank Transaction'; + + frappe.msgprint({ + title: __("Sync Started"), + message: __("The sync has started in the background, please check the {0} list for new records.", [bank_transaction_link]), + alert: 1 + }); + } + }); + }).addClass("btn-primary"); } } }); @@ -30,10 +46,18 @@ erpnext.integrations.plaidLink = class plaidLink { this.product = ["auth", "transactions"]; this.plaid_env = this.frm.doc.plaid_env; this.client_name = frappe.boot.sitename; - this.token = await this.frm.call("get_link_token").then(resp => resp.message); + this.token = await this.get_link_token(); this.init_plaid(); } + async get_link_token() { + const token = await this.frm.call("get_link_token").then(resp => resp.message); + if (!token) { + frappe.throw(__('Cannot retrieve link token. Check Error Log for more information')); + } + return token; + } + init_plaid() { const me = this; me.loadScript(me.plaidUrl) @@ -78,8 +102,8 @@ erpnext.integrations.plaidLink = class plaidLink { } onScriptError(error) { - frappe.msgprint("There was an issue connecting to Plaid's authentication server"); - frappe.msgprint(error); + frappe.msgprint(__("There was an issue connecting to Plaid's authentication server. Check browser console for more information")); + console.log(error); } plaid_success(token, response) { @@ -107,4 +131,4 @@ erpnext.integrations.plaidLink = class plaidLink { }); }, __("Select a company"), __("Continue")); } -}; +}; \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.json b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.json index 27062172239..e7176ea945c 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.json +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2018-10-25 10:02:48.656165", "doctype": "DocType", "editable_grid": 1, @@ -11,7 +12,8 @@ "plaid_client_id", "plaid_secret", "column_break_7", - "plaid_env" + "plaid_env", + "enable_european_access" ], "fields": [ { @@ -58,10 +60,17 @@ { "fieldname": "column_break_7", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "enable_european_access", + "fieldtype": "Check", + "label": "Enable European Access" } ], "issingle": 1, - "modified": "2020-09-12 02:31:44.542385", + "links": [], + "modified": "2021-03-02 17:35:27.544259", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "Plaid Settings", @@ -79,5 +88,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index e535e81bdef..21f6fee79c8 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -166,7 +166,6 @@ def get_transactions(bank, bank_account=None, start_date=None, end_date=None): related_bank = frappe.db.get_values("Bank Account", bank_account, ["bank", "integration_id"], as_dict=True) access_token = frappe.db.get_value("Bank", related_bank[0].bank, "plaid_access_token") account_id = related_bank[0].integration_id - else: access_token = frappe.db.get_value("Bank", bank, "plaid_access_token") account_id = None @@ -205,8 +204,8 @@ def new_bank_transaction(transaction): "date": getdate(transaction["date"]), "status": status, "bank_account": bank_account, - "debit": debit, - "credit": credit, + "deposit": debit, + "withdrawal": credit, "currency": transaction["iso_currency_code"], "transaction_id": transaction["transaction_id"], "reference_number": transaction["payment_meta"]["reference_number"], @@ -228,13 +227,23 @@ def new_bank_transaction(transaction): def automatic_synchronization(): settings = frappe.get_doc("Plaid Settings", "Plaid Settings") - if settings.enabled == 1 and settings.automatic_sync == 1: - plaid_accounts = frappe.get_all("Bank Account", filters={"integration_id": ["!=", ""]}, fields=["name", "bank"]) + enqueue_synchronization() - for plaid_account in plaid_accounts: - frappe.enqueue( - "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.sync_transactions", - bank=plaid_account.bank, - bank_account=plaid_account.name - ) +@frappe.whitelist() +def enqueue_synchronization(): + plaid_accounts = frappe.get_all("Bank Account", + filters={"integration_id": ["!=", ""]}, + fields=["name", "bank"]) + + for plaid_account in plaid_accounts: + frappe.enqueue( + "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.sync_transactions", + bank=plaid_account.bank, + bank_account=plaid_account.name + ) + +@frappe.whitelist() +def get_link_token_for_update(access_token): + plaid = PlaidConnector(access_token) + return plaid.get_link_token(update_mode=True) diff --git a/erpnext/erpnext_integrations/doctype/shopify_settings/shopify_settings.json b/erpnext/erpnext_integrations/doctype/shopify_settings/shopify_settings.json index 2e10751f967..308e7d163f3 100644 --- a/erpnext/erpnext_integrations/doctype/shopify_settings/shopify_settings.json +++ b/erpnext/erpnext_integrations/doctype/shopify_settings/shopify_settings.json @@ -1,7 +1,9 @@ { + "actions": [], "creation": "2015-05-18 05:21:07.270859", "doctype": "DocType", "document_type": "System", + "engine": "InnoDB", "field_order": [ "status_html", "enable_shopify", @@ -40,7 +42,16 @@ "sales_invoice_series", "section_break_22", "html_16", - "taxes" + "taxes", + "syncing_details_section", + "sync_missing_orders", + "sync_based_on", + "column_break_41", + "from_date", + "to_date", + "from_order_id", + "to_order_id", + "last_order_id" ], "fields": [ { @@ -255,10 +266,71 @@ "fieldtype": "Table", "label": "Shopify Tax Account", "options": "Shopify Tax Account" + }, + { + "collapsible": 1, + "fieldname": "syncing_details_section", + "fieldtype": "Section Break", + "label": "Syncing Missing Orders" + }, + { + "depends_on": "eval:doc.sync_missing_orders", + "fieldname": "last_order_id", + "fieldtype": "Data", + "label": "Last Order Id", + "read_only": 1 + }, + { + "fieldname": "column_break_41", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "On checking this Order from the ", + "fieldname": "sync_missing_orders", + "fieldtype": "Check", + "label": "Sync Missing Old Shopify Orders" + }, + { + "depends_on": "eval:doc.sync_missing_orders", + "fieldname": "sync_based_on", + "fieldtype": "Select", + "label": "Sync Based On", + "mandatory_depends_on": "eval:doc.sync_missing_orders", + "options": "\nDate\nShopify Order Id" + }, + { + "depends_on": "eval:doc.sync_based_on == 'Date' && doc.sync_missing_orders", + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date", + "mandatory_depends_on": "eval:doc.sync_based_on == 'Date' && doc.sync_missing_orders" + }, + { + "depends_on": "eval:doc.sync_based_on == 'Date' && doc.sync_missing_orders", + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date", + "mandatory_depends_on": "eval:doc.sync_based_on == 'Date' && doc.sync_missing_orders" + }, + { + "depends_on": "eval:doc.sync_based_on == 'Shopify Order Id' && doc.sync_missing_orders", + "fieldname": "from_order_id", + "fieldtype": "Data", + "label": "From Order Id", + "mandatory_depends_on": "eval:doc.sync_based_on == 'Shopify Order Id' && doc.sync_missing_orders" + }, + { + "depends_on": "eval:doc.sync_based_on == 'Shopify Order Id' && doc.sync_missing_orders", + "fieldname": "to_order_id", + "fieldtype": "Data", + "label": "To Order Id", + "mandatory_depends_on": "eval:doc.sync_based_on == 'Shopify Order Id' && doc.sync_missing_orders" } ], "issingle": 1, - "modified": "2020-09-18 17:26:09.703215", + "links": [], + "modified": "2021-03-02 17:35:41.953317", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "Shopify Settings", @@ -276,5 +348,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" -} + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/shopify_settings/shopify_settings.py b/erpnext/erpnext_integrations/doctype/shopify_settings/shopify_settings.py index 25ffd281099..cbdf90681d3 100644 --- a/erpnext/erpnext_integrations/doctype/shopify_settings/shopify_settings.py +++ b/erpnext/erpnext_integrations/doctype/shopify_settings/shopify_settings.py @@ -87,7 +87,7 @@ def get_shopify_url(path, settings): def get_header(settings): header = {'Content-Type': 'application/json'} - return header; + return header @frappe.whitelist() def get_series(): @@ -121,17 +121,23 @@ def setup_custom_fields(): ], "Sales Order": [ dict(fieldname='shopify_order_id', label='Shopify Order Id', - fieldtype='Data', insert_after='title', read_only=1, print_hide=1) + fieldtype='Data', insert_after='title', read_only=1, print_hide=1), + dict(fieldname='shopify_order_number', label='Shopify Order Number', + fieldtype='Data', insert_after='shopify_order_id', read_only=1, print_hide=1) ], "Delivery Note":[ dict(fieldname='shopify_order_id', label='Shopify Order Id', fieldtype='Data', insert_after='title', read_only=1, print_hide=1), + dict(fieldname='shopify_order_number', label='Shopify Order Number', + fieldtype='Data', insert_after='shopify_order_id', read_only=1, print_hide=1), dict(fieldname='shopify_fulfillment_id', label='Shopify Fulfillment Id', fieldtype='Data', insert_after='title', read_only=1, print_hide=1) ], "Sales Invoice": [ dict(fieldname='shopify_order_id', label='Shopify Order Id', - fieldtype='Data', insert_after='title', read_only=1, print_hide=1) + fieldtype='Data', insert_after='title', read_only=1, print_hide=1), + dict(fieldname='shopify_order_number', label='Shopify Order Number', + fieldtype='Data', insert_after='shopify_order_id', read_only=1, print_hide=1) ] } diff --git a/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py b/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py index 64ef3dc0859..5f471ab2e78 100644 --- a/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py +++ b/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe import unittest, os, json -from frappe.utils import cstr +from frappe.utils import cstr, cint from erpnext.erpnext_integrations.connectors.shopify_connection import create_order from erpnext.erpnext_integrations.doctype.shopify_settings.sync_product import make_item from erpnext.erpnext_integrations.doctype.shopify_settings.sync_customer import create_customer @@ -13,21 +13,31 @@ from frappe.core.doctype.data_import.data_import import import_doc class ShopifySettings(unittest.TestCase): - def setUp(self): + @classmethod + def setUpClass(cls): frappe.set_user("Administrator") + cls.allow_negative_stock = cint(frappe.db.get_value('Stock Settings', None, 'allow_negative_stock')) + if not cls.allow_negative_stock: + frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 1) + # use the fixture data - import_doc(path=frappe.get_app_path("erpnext", "erpnext_integrations/doctype/shopify_settings/test_data/custom_field.json"), - ignore_links=True, overwrite=True) + import_doc(frappe.get_app_path("erpnext", "erpnext_integrations/doctype/shopify_settings/test_data/custom_field.json")) frappe.reload_doctype("Customer") frappe.reload_doctype("Sales Order") frappe.reload_doctype("Delivery Note") frappe.reload_doctype("Sales Invoice") - self.setup_shopify() + cls.setup_shopify() - def setup_shopify(self): + @classmethod + def tearDownClass(cls): + if not cls.allow_negative_stock: + frappe.db.set_value('Stock Settings', None, 'allow_negative_stock', 0) + + @classmethod + def setup_shopify(cls): shopify_settings = frappe.get_doc("Shopify Settings") shopify_settings.taxes = [] @@ -57,41 +67,40 @@ class ShopifySettings(unittest.TestCase): "delivery_note_series": "DN-" }).save(ignore_permissions=True) - self.shopify_settings = shopify_settings - + cls.shopify_settings = shopify_settings + def test_order(self): - ### Create Customer ### + # Create Customer with open (os.path.join(os.path.dirname(__file__), "test_data", "shopify_customer.json")) as shopify_customer: shopify_customer = json.load(shopify_customer) create_customer(shopify_customer.get("customer"), self.shopify_settings) - ### Create Item ### + # Create Item with open (os.path.join(os.path.dirname(__file__), "test_data", "shopify_item.json")) as shopify_item: shopify_item = json.load(shopify_item) make_item("_Test Warehouse - _TC", shopify_item.get("product")) - - ### Create Order ### + # Create Order with open (os.path.join(os.path.dirname(__file__), "test_data", "shopify_order.json")) as shopify_order: shopify_order = json.load(shopify_order) - create_order(shopify_order.get("order"), self.shopify_settings, "_Test Company") + create_order(shopify_order.get("order"), self.shopify_settings, False, company="_Test Company") sales_order = frappe.get_doc("Sales Order", {"shopify_order_id": cstr(shopify_order.get("order").get("id"))}) self.assertEqual(cstr(shopify_order.get("order").get("id")), sales_order.shopify_order_id) - #check for customer + # Check for customer shopify_order_customer_id = cstr(shopify_order.get("order").get("customer").get("id")) sales_order_customer_id = frappe.get_value("Customer", sales_order.customer, "shopify_customer_id") self.assertEqual(shopify_order_customer_id, sales_order_customer_id) - #check sales invoice + # Check sales invoice sales_invoice = frappe.get_doc("Sales Invoice", {"shopify_order_id": sales_order.shopify_order_id}) self.assertEqual(sales_invoice.rounded_total, sales_order.rounded_total) - #check delivery note + # Check delivery note delivery_note_count = frappe.db.sql("""select count(*) from `tabDelivery Note` where shopify_order_id = %s""", sales_order.shopify_order_id)[0][0] diff --git a/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.js b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.js index fd16d1e84aa..5482b9cc695 100644 --- a/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.js +++ b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.js @@ -23,10 +23,10 @@ frappe.ui.form.on("Tally Migration", { frappe.msgprint({ message: __("An error has occurred during {0}. Check {1} for more details", [ - repl("%(tally_document)s", { + repl("%(tally_document)s", { tally_document: frm.docname }), - "Error Log" + "Error Log" ] ), title: __("Tally Migration Error"), diff --git a/erpnext/erpnext_integrations/taxjar_integration.py b/erpnext/erpnext_integrations/taxjar_integration.py index 24fc3d44b99..f960998c3c9 100644 --- a/erpnext/erpnext_integrations/taxjar_integration.py +++ b/erpnext/erpnext_integrations/taxjar_integration.py @@ -1,5 +1,7 @@ import traceback +import taxjar + import frappe from erpnext import get_default_company from frappe import _ @@ -29,7 +31,6 @@ def get_client(): def create_transaction(doc, method): - import taxjar """Create an order transaction in TaxJar""" if not TAXJAR_CREATE_TRANSACTIONS: diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index 84f7f5a5d41..362f6cf88ee 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -3,6 +3,7 @@ import frappe from frappe import _ import base64, hashlib, hmac from six.moves.urllib.parse import urlparse +from erpnext import get_default_company def validate_webhooks_request(doctype, hmac_key, secret_key='secret'): def innerfn(fn): @@ -41,3 +42,30 @@ def get_webhook_address(connector_name, method, exclude_uri=False): server_url = '{uri.scheme}://{uri.netloc}/api/method/{endpoint}'.format(uri=urlparse(url), endpoint=endpoint) return server_url + +def create_mode_of_payment(gateway, payment_type="General"): + payment_gateway_account = frappe.db.get_value("Payment Gateway Account", { + "payment_gateway": gateway + }, ['payment_account']) + + if not frappe.db.exists("Mode of Payment", gateway) and payment_gateway_account: + mode_of_payment = frappe.get_doc({ + "doctype": "Mode of Payment", + "mode_of_payment": gateway, + "enabled": 1, + "type": payment_type, + "accounts": [{ + "doctype": "Mode of Payment Account", + "company": get_default_company(), + "default_account": payment_gateway_account + }] + }) + mode_of_payment.insert(ignore_permissions=True) + +def get_tracking_url(carrier, tracking_number): + # Return the formatted Tracking URL. + tracking_url = '' + url_reference = frappe.get_value('Parcel Service', carrier, 'url_reference') + if url_reference: + tracking_url = frappe.render_template(url_reference, {'tracking_number': tracking_number}) + return tracking_url diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json new file mode 100644 index 00000000000..4a5e54edd2f --- /dev/null +++ b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json @@ -0,0 +1,116 @@ +{ + "category": "Modules", + "charts": [], + "creation": "2020-08-20 19:30:48.138801", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends": "Integrations", + "extends_another_page": 1, + "hide_custom": 1, + "idx": 0, + "is_standard": 1, + "label": "ERPNext Integrations", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Marketplace", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Woocommerce Settings", + "link_to": "Woocommerce Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Amazon MWS Settings", + "link_to": "Amazon MWS Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shopify Settings", + "link_to": "Shopify Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Payments", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "GoCardless Settings", + "link_to": "GoCardless Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "M-Pesa Settings", + "link_to": "Mpesa Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Plaid Settings", + "link_to": "Plaid Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Exotel Settings", + "link_to": "Exotel Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:35.846528", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "ERPNext Integrations", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [] +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json new file mode 100644 index 00000000000..d258d571318 --- /dev/null +++ b/erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json @@ -0,0 +1,82 @@ +{ + "category": "Modules", + "charts": [], + "creation": "2020-07-31 10:38:54.021237", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends": "Settings", + "extends_another_page": 1, + "hide_custom": 0, + "idx": 0, + "is_standard": 1, + "label": "ERPNext Integrations Settings", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Integrations Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Woocommerce Settings", + "link_to": "Woocommerce Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shopify Settings", + "link_to": "Shopify Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Amazon MWS Settings", + "link_to": "Amazon MWS Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Plaid Settings", + "link_to": "Plaid Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Exotel Settings", + "link_to": "Exotel Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:34.732552", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "ERPNext Integrations Settings", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [] +} \ No newline at end of file diff --git a/erpnext/exceptions.py b/erpnext/exceptions.py index d92af5d7227..04291cd5bd1 100644 --- a/erpnext/exceptions.py +++ b/erpnext/exceptions.py @@ -6,3 +6,5 @@ class PartyFrozen(frappe.ValidationError): pass class InvalidAccountCurrency(frappe.ValidationError): pass class InvalidCurrency(frappe.ValidationError): pass class PartyDisabled(frappe.ValidationError):pass +class InvalidAccountDimensionError(frappe.ValidationError): pass +class MandatoryAccountDimensionError(frappe.ValidationError): pass diff --git a/erpnext/healthcare/desk_page/healthcare/healthcare.json b/erpnext/healthcare/desk_page/healthcare/healthcare.json index 6546b08db99..af601f3eb2e 100644 --- a/erpnext/healthcare/desk_page/healthcare/healthcare.json +++ b/erpnext/healthcare/desk_page/healthcare/healthcare.json @@ -30,6 +30,11 @@ "label": "Laboratory", "links": "[\n\t{\n\t\t\"type\": \"doctype\",\n\t\t\"name\": \"Lab Test\",\n\t\t\"label\": \"Lab Test\"\n\t},\n\t{\n\t\t\"type\": \"doctype\",\n\t\t\"name\": \"Sample Collection\",\n\t\t\"label\": \"Sample Collection\"\n\t},\n\t{\n\t\t\"type\": \"doctype\",\n\t\t\"name\": \"Dosage Form\",\n\t\t\"label\": \"Dosage Form\"\n\t}\n]" }, + { + "hidden": 0, + "label": "Inpatient", + "links": "[\n\t{\n\t\t\"type\": \"doctype\",\n\t\t\"name\": \"Inpatient Record\",\n\t\t\"label\": \"Inpatient Record\"\n\t},\n\t{\n\t\t\"type\": \"doctype\",\n\t\t\"name\": \"Inpatient Medication Order\",\n\t\t\"label\": \"Inpatient Medication Order\"\n\t},\n\t{\n\t\t\"type\": \"doctype\",\n\t\t\"name\": \"Inpatient Medication Entry\",\n\t\t\"label\": \"Inpatient Medication Entry\"\n\t}\n]" + }, { "hidden": 0, "label": "Rehabilitation and Physiotherapy", @@ -38,12 +43,12 @@ { "hidden": 0, "label": "Records and History", - "links": "[\n\t{\n\t\t\"type\": \"page\",\n\t\t\"name\": \"patient_history\",\n\t\t\"label\": \"Patient History\"\n\t},\n\t{\n\t\t\"type\": \"page\",\n\t\t\"name\": \"patient-progress\",\n\t\t\"label\": \"Patient Progress\"\n\t},\n\t{\n\t\t\"type\": \"doctype\",\n\t\t\"name\": \"Patient Medical Record\",\n\t\t\"label\": \"Patient Medical Record\"\n\t},\n\t{\n\t\t\"type\": \"doctype\",\n\t\t\"name\": \"Inpatient Record\",\n\t\t\"label\": \"Inpatient Record\"\n\t}\n]" + "links": "[\n\t{\n\t\t\"type\": \"page\",\n\t\t\"name\": \"patient_history\",\n\t\t\"label\": \"Patient History\"\n\t},\n\t{\n\t\t\"type\": \"page\",\n\t\t\"name\": \"patient-progress\",\n\t\t\"label\": \"Patient Progress\"\n\t},\n\t{\n\t\t\"type\": \"doctype\",\n\t\t\"name\": \"Patient Medical Record\",\n\t\t\"label\": \"Patient Medical Record\"\n\t}\n]" }, { "hidden": 0, "label": "Reports", - "links": "[\n\t{\n\t\t\"type\": \"report\",\n\t\t\"is_query_report\": true,\n\t\t\"name\": \"Patient Appointment Analytics\",\n\t\t\"doctype\": \"Patient Appointment\"\n\t},\n\t{\n\t\t\"type\": \"report\",\n\t\t\"is_query_report\": true,\n\t\t\"name\": \"Lab Test Report\",\n\t\t\"doctype\": \"Lab Test\",\n\t\t\"label\": \"Lab Test Report\"\n\t}\n]" + "links": "[\n\t{\n\t\t\"type\": \"report\",\n\t\t\"is_query_report\": true,\n\t\t\"name\": \"Patient Appointment Analytics\",\n\t\t\"doctype\": \"Patient Appointment\"\n\t},\n\t{\n\t\t\"type\": \"report\",\n\t\t\"is_query_report\": true,\n\t\t\"name\": \"Lab Test Report\",\n\t\t\"doctype\": \"Lab Test\",\n\t\t\"label\": \"Lab Test Report\"\n\t},\n\t{\n\t\t\"type\": \"report\",\n\t\t\"is_query_report\": true,\n\t\t\"name\": \"Inpatient Medication Orders\",\n\t\t\"doctype\": \"Inpatient Medication Order\",\n\t\t\"label\": \"Inpatient Medication Orders\"\n\t}\n]" } ], "category": "Domains", @@ -64,7 +69,7 @@ "idx": 0, "is_standard": 1, "label": "Healthcare", - "modified": "2020-06-25 23:50:56.951698", + "modified": "2020-11-26 22:09:09.164584", "modified_by": "Administrator", "module": "Healthcare", "name": "Healthcare", diff --git a/erpnext/healthcare/doctype/appointment_type/appointment_type.js b/erpnext/healthcare/doctype/appointment_type/appointment_type.js index 15916a5134a..861675acea3 100644 --- a/erpnext/healthcare/doctype/appointment_type/appointment_type.js +++ b/erpnext/healthcare/doctype/appointment_type/appointment_type.js @@ -2,4 +2,82 @@ // For license information, please see license.txt frappe.ui.form.on('Appointment Type', { + refresh: function(frm) { + frm.set_query('price_list', function() { + return { + filters: {'selling': 1} + }; + }); + + frm.set_query('medical_department', 'items', function(doc) { + let item_list = doc.items.map(({medical_department}) => medical_department); + return { + filters: [ + ['Medical Department', 'name', 'not in', item_list] + ] + }; + }); + + frm.set_query('op_consulting_charge_item', 'items', function() { + return { + filters: { + is_stock_item: 0 + } + }; + }); + + frm.set_query('inpatient_visit_charge_item', 'items', function() { + return { + filters: { + is_stock_item: 0 + } + }; + }); + } }); + +frappe.ui.form.on('Appointment Type Service Item', { + op_consulting_charge_item: function(frm, cdt, cdn) { + let d = locals[cdt][cdn]; + if (frm.doc.price_list && d.op_consulting_charge_item) { + frappe.call({ + 'method': 'frappe.client.get_value', + args: { + 'doctype': 'Item Price', + 'filters': { + 'item_code': d.op_consulting_charge_item, + 'price_list': frm.doc.price_list + }, + 'fieldname': ['price_list_rate'] + }, + callback: function(data) { + if (data.message.price_list_rate) { + frappe.model.set_value(cdt, cdn, 'op_consulting_charge', data.message.price_list_rate); + } + } + }); + } + }, + + inpatient_visit_charge_item: function(frm, cdt, cdn) { + let d = locals[cdt][cdn]; + if (frm.doc.price_list && d.inpatient_visit_charge_item) { + frappe.call({ + 'method': 'frappe.client.get_value', + args: { + 'doctype': 'Item Price', + 'filters': { + 'item_code': d.inpatient_visit_charge_item, + 'price_list': frm.doc.price_list + }, + 'fieldname': ['price_list_rate'] + }, + callback: function (data) { + if (data.message.price_list_rate) { + frappe.model.set_value(cdt, cdn, 'inpatient_visit_charge', data.message.price_list_rate); + } + } + }); + } + } +}); \ No newline at end of file diff --git a/erpnext/healthcare/doctype/appointment_type/appointment_type.json b/erpnext/healthcare/doctype/appointment_type/appointment_type.json index 58753bb4f05..38723182878 100644 --- a/erpnext/healthcare/doctype/appointment_type/appointment_type.json +++ b/erpnext/healthcare/doctype/appointment_type/appointment_type.json @@ -12,7 +12,10 @@ "appointment_type", "ip", "default_duration", - "color" + "color", + "billing_section", + "price_list", + "items" ], "fields": [ { @@ -52,10 +55,27 @@ "label": "Color", "no_copy": 1, "report_hide": 1 + }, + { + "fieldname": "billing_section", + "fieldtype": "Section Break", + "label": "Billing" + }, + { + "fieldname": "price_list", + "fieldtype": "Link", + "label": "Price List", + "options": "Price List" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Appointment Type Service Items", + "options": "Appointment Type Service Item" } ], "links": [], - "modified": "2020-02-03 21:06:05.833050", + "modified": "2021-01-22 09:41:05.010524", "modified_by": "Administrator", "module": "Healthcare", "name": "Appointment Type", diff --git a/erpnext/healthcare/doctype/appointment_type/appointment_type.py b/erpnext/healthcare/doctype/appointment_type/appointment_type.py index 1dacffab357..67a24f31e03 100644 --- a/erpnext/healthcare/doctype/appointment_type/appointment_type.py +++ b/erpnext/healthcare/doctype/appointment_type/appointment_type.py @@ -4,6 +4,53 @@ from __future__ import unicode_literals from frappe.model.document import Document +import frappe class AppointmentType(Document): - pass + def validate(self): + if self.items and self.price_list: + for item in self.items: + existing_op_item_price = frappe.db.exists('Item Price', { + 'item_code': item.op_consulting_charge_item, + 'price_list': self.price_list + }) + + if not existing_op_item_price and item.op_consulting_charge_item and item.op_consulting_charge: + make_item_price(self.price_list, item.op_consulting_charge_item, item.op_consulting_charge) + + existing_ip_item_price = frappe.db.exists('Item Price', { + 'item_code': item.inpatient_visit_charge_item, + 'price_list': self.price_list + }) + + if not existing_ip_item_price and item.inpatient_visit_charge_item and item.inpatient_visit_charge: + make_item_price(self.price_list, item.inpatient_visit_charge_item, item.inpatient_visit_charge) + +@frappe.whitelist() +def get_service_item_based_on_department(appointment_type, department): + item_list = frappe.db.get_value('Appointment Type Service Item', + filters = {'medical_department': department, 'parent': appointment_type}, + fieldname = ['op_consulting_charge_item', + 'inpatient_visit_charge_item', 'op_consulting_charge', 'inpatient_visit_charge'], + as_dict = 1 + ) + + # if department wise items are not set up + # use the generic items + if not item_list: + item_list = frappe.db.get_value('Appointment Type Service Item', + filters = {'parent': appointment_type}, + fieldname = ['op_consulting_charge_item', + 'inpatient_visit_charge_item', 'op_consulting_charge', 'inpatient_visit_charge'], + as_dict = 1 + ) + + return item_list + +def make_item_price(price_list, item, item_price): + frappe.get_doc({ + 'doctype': 'Item Price', + 'price_list': price_list, + 'item_code': item, + 'price_list_rate': item_price + }).insert(ignore_permissions=True, ignore_mandatory=True) diff --git a/erpnext/communication/doctype/call_log/__init__.py b/erpnext/healthcare/doctype/appointment_type_service_item/__init__.py similarity index 100% rename from erpnext/communication/doctype/call_log/__init__.py rename to erpnext/healthcare/doctype/appointment_type_service_item/__init__.py diff --git a/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.json b/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.json new file mode 100644 index 00000000000..5ff68cd682c --- /dev/null +++ b/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.json @@ -0,0 +1,67 @@ +{ + "actions": [], + "creation": "2021-01-22 09:34:53.373105", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "medical_department", + "op_consulting_charge_item", + "op_consulting_charge", + "column_break_4", + "inpatient_visit_charge_item", + "inpatient_visit_charge" + ], + "fields": [ + { + "fieldname": "medical_department", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Medical Department", + "options": "Medical Department" + }, + { + "fieldname": "op_consulting_charge_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Out Patient Consulting Charge Item", + "options": "Item" + }, + { + "fieldname": "op_consulting_charge", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Out Patient Consulting Charge" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "inpatient_visit_charge_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Inpatient Visit Charge Item", + "options": "Item" + }, + { + "fieldname": "inpatient_visit_charge", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Inpatient Visit Charge Item" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-01-22 09:35:26.503443", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Appointment Type Service Item", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.py b/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.py new file mode 100644 index 00000000000..b2e0e82bad0 --- /dev/null +++ b/erpnext/healthcare/doctype/appointment_type_service_item/appointment_type_service_item.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class AppointmentTypeServiceItem(Document): + pass diff --git a/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.js b/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.js index eb7d4bdebad..b55d5d6f633 100644 --- a/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.js +++ b/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.js @@ -85,8 +85,7 @@ frappe.ui.form.on('Clinical Procedure', { callback: function(r) { if (r.message) { frappe.show_alert({ - message: __('Stock Entry {0} created', - ['' + r.message + '']), + message: __('Stock Entry {0} created', ['' + r.message + '']), indicator: 'green' }); } @@ -105,8 +104,7 @@ frappe.ui.form.on('Clinical Procedure', { callback: function(r) { if (!r.exc) { if (r.message == 'insufficient stock') { - let msg = __('Stock quantity to start the Procedure is not available in the Warehouse {0}. Do you want to record a Stock Entry?', - [frm.doc.warehouse.bold()]); + let msg = __('Stock quantity to start the Procedure is not available in the Warehouse {0}. Do you want to record a Stock Entry?', [frm.doc.warehouse.bold()]); frappe.confirm( msg, function() { @@ -366,7 +364,7 @@ let calculate_age = function(birth) { let age = new Date(); age.setTime(ageMS); let years = age.getFullYear() - 1970; - return years + ' Year(s) ' + age.getMonth() + ' Month(s) ' + age.getDate() + ' Day(s)'; + return `${years} ${__('Years(s)')} ${age.getMonth()} ${__('Month(s)')} ${age.getDate()} ${__('Day(s)')}`; }; // List Stock items diff --git a/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py b/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py index e55a1433a51..325c2094fbf 100644 --- a/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py +++ b/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py @@ -100,7 +100,6 @@ class ClinicalProcedure(Document): allow_start = self.set_actual_qty() if allow_start: self.db_set('status', 'In Progress') - insert_clinical_procedure_to_medical_record(self) return 'success' return 'insufficient stock' @@ -122,6 +121,7 @@ class ClinicalProcedure(Document): stock_entry.stock_entry_type = 'Material Receipt' stock_entry.to_warehouse = self.warehouse + stock_entry.company = self.company expense_account = get_account(None, 'expense_account', 'Healthcare Settings', self.company) for item in self.items: if item.qty > item.actual_qty: @@ -247,21 +247,3 @@ def make_procedure(source_name, target_doc=None): }, target_doc, set_missing_values) return doc - - -def insert_clinical_procedure_to_medical_record(doc): - subject = frappe.bold(_("Clinical Procedure conducted: ")) + cstr(doc.procedure_template) + "
    " - if doc.practitioner: - subject += frappe.bold(_('Healthcare Practitioner: ')) + doc.practitioner - if subject and doc.notes: - subject += '
    ' + doc.notes - - medical_record = frappe.new_doc('Patient Medical Record') - medical_record.patient = doc.patient - medical_record.subject = subject - medical_record.status = 'Open' - medical_record.communication_date = doc.start_date - medical_record.reference_doctype = 'Clinical Procedure' - medical_record.reference_name = doc.name - medical_record.reference_owner = doc.owner - medical_record.save(ignore_permissions=True) diff --git a/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py b/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py index 4ee5f6bad39..fb72073a07f 100644 --- a/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py +++ b/erpnext/healthcare/doctype/clinical_procedure/test_clinical_procedure.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- + # -*- coding: utf-8 -*- # Copyright (c) 2017, ESS LLP and Contributors # See license.txt from __future__ import unicode_literals @@ -60,6 +60,7 @@ def create_procedure(procedure_template, patient, practitioner): procedure.practitioner = practitioner procedure.consume_stock = procedure_template.allow_stock_consumption procedure.items = procedure_template.items - procedure.warehouse = frappe.db.get_single_value('Stock Settings', 'default_warehouse') + procedure.company = "_Test Company" + procedure.warehouse = "_Test Warehouse - _TC" procedure.submit() return procedure \ No newline at end of file diff --git a/erpnext/healthcare/doctype/drug_prescription/drug_prescription.json b/erpnext/healthcare/doctype/drug_prescription/drug_prescription.json index 5e4d59cacf7..d91e6bf9dc6 100644 --- a/erpnext/healthcare/doctype/drug_prescription/drug_prescription.json +++ b/erpnext/healthcare/doctype/drug_prescription/drug_prescription.json @@ -43,7 +43,8 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Dosage", - "options": "Prescription Dosage" + "options": "Prescription Dosage", + "reqd": 1 }, { "fieldname": "period", @@ -51,14 +52,16 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Period", - "options": "Prescription Duration" + "options": "Prescription Duration", + "reqd": 1 }, { "fieldname": "dosage_form", "fieldtype": "Link", "ignore_user_permissions": 1, "label": "Dosage Form", - "options": "Dosage Form" + "options": "Dosage Form", + "reqd": 1 }, { "fieldname": "column_break_7", @@ -72,7 +75,7 @@ "label": "Comment" }, { - "depends_on": "use_interval", + "depends_on": "usage_interval", "fieldname": "interval", "fieldtype": "Int", "in_list_view": 1, @@ -80,6 +83,7 @@ }, { "default": "1", + "depends_on": "usage_interval", "fieldname": "update_schedule", "fieldtype": "Check", "hidden": 1, @@ -99,12 +103,13 @@ "default": "0", "fieldname": "usage_interval", "fieldtype": "Check", + "hidden": 1, "label": "Dosage by Time Interval" } ], "istable": 1, "links": [], - "modified": "2020-02-26 17:02:42.741338", + "modified": "2020-09-30 23:32:09.495288", "modified_by": "Administrator", "module": "Healthcare", "name": "Drug Prescription", diff --git a/erpnext/healthcare/doctype/exercise/exercise.json b/erpnext/healthcare/doctype/exercise/exercise.json index 2486a5d53ad..683cc6d3c31 100644 --- a/erpnext/healthcare/doctype/exercise/exercise.json +++ b/erpnext/healthcare/doctype/exercise/exercise.json @@ -37,7 +37,8 @@ "depends_on": "eval:doc.parenttype==\"Therapy\";", "fieldname": "counts_completed", "fieldtype": "Int", - "label": "Counts Completed" + "label": "Counts Completed", + "no_copy": 1 }, { "fieldname": "assistance_level", @@ -48,7 +49,7 @@ ], "istable": 1, "links": [], - "modified": "2020-04-10 13:41:06.662351", + "modified": "2020-11-04 18:20:25.583491", "modified_by": "Administrator", "module": "Healthcare", "name": "Exercise", diff --git a/erpnext/healthcare/doctype/exercise_type/exercise_type.js b/erpnext/healthcare/doctype/exercise_type/exercise_type.js index 68db0477c2d..b49b00e219b 100644 --- a/erpnext/healthcare/doctype/exercise_type/exercise_type.js +++ b/erpnext/healthcare/doctype/exercise_type/exercise_type.js @@ -71,7 +71,7 @@ erpnext.ExerciseEditor = Class.extend({ $('.btn-del').on('click', function() { let id = $(this).attr('data-id'); - $('#card-'+id).addClass("zoomOutDelete"); + $('#card-'+id).addClass("zoom-out"); setTimeout(() => { // not using grid_rows[id].remove because diff --git a/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py b/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py index cdf692e68bd..7e7fd824119 100644 --- a/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py +++ b/erpnext/healthcare/doctype/fee_validity/test_fee_validity.py @@ -7,6 +7,7 @@ import frappe import unittest from frappe.utils import nowdate, add_days from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_healthcare_docs, create_appointment, create_healthcare_service_items +from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile test_dependencies = ["Company"] @@ -15,6 +16,7 @@ class TestFeeValidity(unittest.TestCase): frappe.db.sql("""delete from `tabPatient Appointment`""") frappe.db.sql("""delete from `tabFee Validity`""") frappe.db.sql("""delete from `tabPatient`""") + make_pos_profile() def test_fee_validity(self): item = create_healthcare_service_items() diff --git a/erpnext/healthcare/doctype/healthcare_practitioner/healthcare_practitioner.json b/erpnext/healthcare/doctype/healthcare_practitioner/healthcare_practitioner.json index cb747f95ef8..8162f03f6dc 100644 --- a/erpnext/healthcare/doctype/healthcare_practitioner/healthcare_practitioner.json +++ b/erpnext/healthcare/doctype/healthcare_practitioner/healthcare_practitioner.json @@ -159,6 +159,7 @@ "fieldname": "op_consulting_charge", "fieldtype": "Currency", "label": "Out Patient Consulting Charge", + "mandatory_depends_on": "op_consulting_charge_item", "options": "Currency" }, { @@ -174,7 +175,8 @@ { "fieldname": "inpatient_visit_charge", "fieldtype": "Currency", - "label": "Inpatient Visit Charge" + "label": "Inpatient Visit Charge", + "mandatory_depends_on": "inpatient_visit_charge_item" }, { "depends_on": "eval: !doc.__islocal", @@ -280,7 +282,7 @@ ], "image_field": "image", "links": [], - "modified": "2020-04-06 13:44:24.759623", + "modified": "2021-01-22 10:14:43.187675", "modified_by": "Administrator", "module": "Healthcare", "name": "Healthcare Practitioner", diff --git a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit_tree.js b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit_tree.js index a03b579c507..b75f2718271 100644 --- a/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit_tree.js +++ b/erpnext/healthcare/doctype/healthcare_service_unit/healthcare_service_unit_tree.js @@ -12,20 +12,20 @@ frappe.treeview_settings["Healthcare Service Unit"] = { get_tree_nodes: 'erpnext.healthcare.utils.get_children', ignore_fields:["parent_healthcare_service_unit"], onrender: function(node) { - if (node.data.occupied_out_of_vacant!==undefined){ - $('' + if (node.data.occupied_out_of_vacant!==undefined) { + $('' + " " + node.data.occupied_out_of_vacant + '').insertBefore(node.$ul); } if (node.data && node.data.inpatient_occupancy!==undefined) { - if (node.data.inpatient_occupancy == 1){ - if (node.data.occupancy_status == "Occupied"){ - $('' + if (node.data.inpatient_occupancy == 1) { + if (node.data.occupancy_status == "Occupied") { + $('' + " " + node.data.occupancy_status + '').insertBefore(node.$ul); } - if (node.data.occupancy_status == "Vacant"){ - $('' + if (node.data.occupancy_status == "Vacant") { + $('' + " " + node.data.occupancy_status + '').insertBefore(node.$ul); } diff --git a/erpnext/healthcare/doctype/healthcare_settings/healthcare_settings.json b/erpnext/healthcare/doctype/healthcare_settings/healthcare_settings.json index 01043867141..ddf1bce4927 100644 --- a/erpnext/healthcare/doctype/healthcare_settings/healthcare_settings.json +++ b/erpnext/healthcare/doctype/healthcare_settings/healthcare_settings.json @@ -17,6 +17,9 @@ "enable_free_follow_ups", "max_visits", "valid_days", + "inpatient_settings_section", + "allow_discharge_despite_unbilled_services", + "do_not_bill_inpatient_encounters", "healthcare_service_items", "inpatient_visit_charge_item", "op_consulting_charge_item", @@ -302,11 +305,28 @@ "fieldname": "enable_free_follow_ups", "fieldtype": "Check", "label": "Enable Free Follow-ups" + }, + { + "fieldname": "inpatient_settings_section", + "fieldtype": "Section Break", + "label": "Inpatient Settings" + }, + { + "default": "0", + "fieldname": "allow_discharge_despite_unbilled_services", + "fieldtype": "Check", + "label": "Allow Discharge Despite Unbilled Healthcare Services" + }, + { + "default": "0", + "fieldname": "do_not_bill_inpatient_encounters", + "fieldtype": "Check", + "label": "Do Not Bill Patient Encounters for Inpatients" } ], "issingle": 1, "links": [], - "modified": "2020-07-08 15:17:21.543218", + "modified": "2021-01-13 09:04:35.877700", "modified_by": "Administrator", "module": "Healthcare", "name": "Healthcare Settings", diff --git a/erpnext/config/__init__.py b/erpnext/healthcare/doctype/inpatient_medication_entry/__init__.py similarity index 100% rename from erpnext/config/__init__.py rename to erpnext/healthcare/doctype/inpatient_medication_entry/__init__.py diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.js b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.js new file mode 100644 index 00000000000..a7b06b1718b --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.js @@ -0,0 +1,74 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Inpatient Medication Entry', { + refresh: function(frm) { + // Ignore cancellation of doctype on cancel all + frm.ignore_doctypes_on_cancel_all = ['Stock Entry']; + frm.fields_dict['medication_orders'].grid.wrapper.find('.grid-add-row').hide(); + + frm.set_query('item_code', () => { + return { + filters: { + is_stock_item: 1 + } + }; + }); + + frm.set_query('drug_code', 'medication_orders', () => { + return { + filters: { + is_stock_item: 1 + } + }; + }); + + frm.set_query('warehouse', () => { + return { + filters: { + company: frm.doc.company + } + }; + }); + + if (frm.doc.__islocal || frm.doc.docstatus !== 0 || !frm.doc.update_stock) + return; + + frm.add_custom_button(__('Make Stock Entry'), function() { + frappe.call({ + method: 'erpnext.healthcare.doctype.inpatient_medication_entry.inpatient_medication_entry.make_difference_stock_entry', + args: { docname: frm.doc.name }, + freeze: true, + callback: function(r) { + if (r.message) { + var doclist = frappe.model.sync(r.message); + frappe.set_route('Form', doclist[0].doctype, doclist[0].name); + } else { + frappe.msgprint({ + title: __('No Drug Shortage'), + message: __('All the drugs are available with sufficient qty to process this Inpatient Medication Entry.'), + indicator: 'green' + }); + } + } + }); + }); + }, + + patient: function(frm) { + if (frm.doc.patient) + frm.set_value('service_unit', ''); + }, + + get_medication_orders: function(frm) { + frappe.call({ + method: 'get_medication_orders', + doc: frm.doc, + freeze: true, + freeze_message: __('Fetching Pending Medication Orders'), + callback: function() { + refresh_field('medication_orders'); + } + }); + } +}); diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.json b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.json new file mode 100644 index 00000000000..b1a6ee4ed14 --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.json @@ -0,0 +1,204 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2020-09-25 14:13:20.111906", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "company", + "column_break_3", + "posting_date", + "status", + "filters_section", + "item_code", + "assigned_to_practitioner", + "patient", + "practitioner", + "service_unit", + "column_break_11", + "from_date", + "to_date", + "from_time", + "to_time", + "select_medication_orders_section", + "get_medication_orders", + "medication_orders", + "section_break_18", + "update_stock", + "warehouse", + "amended_from" + ], + "fields": [ + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "HLC-IME-.YYYY.-" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Posting Date", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "\nDraft\nSubmitted\nPending\nIn Process\nCompleted\nCancelled", + "read_only": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.__islocal", + "fieldname": "filters_section", + "fieldtype": "Section Break", + "label": "Filters" + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code (Drug)", + "options": "Item" + }, + { + "depends_on": "update_stock", + "description": "Warehouse from where medication stock should be consumed", + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Medication Warehouse", + "mandatory_depends_on": "update_stock", + "options": "Warehouse" + }, + { + "fieldname": "patient", + "fieldtype": "Link", + "label": "Patient", + "options": "Patient" + }, + { + "depends_on": "eval:!doc.patient", + "fieldname": "service_unit", + "fieldtype": "Link", + "label": "Healthcare Service Unit", + "options": "Healthcare Service Unit" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, + { + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date" + }, + { + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Inpatient Medication Entry", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "practitioner", + "fieldtype": "Link", + "label": "Healthcare Practitioner", + "options": "Healthcare Practitioner" + }, + { + "fieldname": "select_medication_orders_section", + "fieldtype": "Section Break", + "label": "Medication Orders" + }, + { + "fieldname": "medication_orders", + "fieldtype": "Table", + "label": "Inpatient Medication Orders", + "options": "Inpatient Medication Entry Detail", + "reqd": 1 + }, + { + "depends_on": "eval:doc.docstatus!==1", + "fieldname": "get_medication_orders", + "fieldtype": "Button", + "label": "Get Pending Medication Orders", + "print_hide": 1 + }, + { + "fieldname": "assigned_to_practitioner", + "fieldtype": "Link", + "label": "Assigned To", + "options": "User" + }, + { + "fieldname": "section_break_18", + "fieldtype": "Section Break", + "label": "Stock Details" + }, + { + "default": "1", + "fieldname": "update_stock", + "fieldtype": "Check", + "label": "Update Stock" + }, + { + "fieldname": "from_time", + "fieldtype": "Time", + "label": "From Time" + }, + { + "fieldname": "to_time", + "fieldtype": "Time", + "label": "To Time" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2021-01-11 12:37:46.749659", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Inpatient Medication Entry", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py new file mode 100644 index 00000000000..e7319085e46 --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import flt, get_link_to_form, getdate, nowtime +from erpnext.stock.utils import get_latest_stock_qty +from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_account + +class InpatientMedicationEntry(Document): + def validate(self): + self.validate_medication_orders() + + def get_medication_orders(self): + # pull inpatient medication orders based on selected filters + orders = get_pending_medication_orders(self) + + if orders: + self.add_mo_to_table(orders) + return self + else: + self.set('medication_orders', []) + frappe.msgprint(_('No pending medication orders found for selected criteria')) + + def add_mo_to_table(self, orders): + # Add medication orders in the child table + self.set('medication_orders', []) + + for data in orders: + self.append('medication_orders', { + 'patient': data.patient, + 'patient_name': data.patient_name, + 'inpatient_record': data.inpatient_record, + 'service_unit': data.service_unit, + 'datetime': "%s %s" % (data.date, data.time or "00:00:00"), + 'drug_code': data.drug, + 'drug_name': data.drug_name, + 'dosage': data.dosage, + 'dosage_form': data.dosage_form, + 'against_imo': data.parent, + 'against_imoe': data.name + }) + + def on_submit(self): + self.validate_medication_orders() + success_msg = "" + if self.update_stock: + stock_entry = self.process_stock() + success_msg += _('Stock Entry {0} created and ').format( + frappe.bold(get_link_to_form('Stock Entry', stock_entry))) + + self.update_medication_orders() + success_msg += _('Inpatient Medication Orders updated successfully') + frappe.msgprint(success_msg, title=_('Success'), indicator='green') + + def validate_medication_orders(self): + for entry in self.medication_orders: + docstatus, is_completed = frappe.db.get_value('Inpatient Medication Order Entry', entry.against_imoe, + ['docstatus', 'is_completed']) + + if docstatus == 2: + frappe.throw(_('Row {0}: Cannot create Inpatient Medication Entry against cancelled Inpatient Medication Order {1}').format( + entry.idx, get_link_to_form(entry.against_imo))) + + if is_completed: + frappe.throw(_('Row {0}: This Medication Order is already marked as completed').format( + entry.idx)) + + def on_cancel(self): + self.cancel_stock_entries() + self.update_medication_orders(on_cancel=True) + + def process_stock(self): + allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock') + if not allow_negative_stock: + self.check_stock_qty() + + return self.make_stock_entry() + + def update_medication_orders(self, on_cancel=False): + orders, order_entry_map = self.get_order_entry_map() + # mark completion status + is_completed = 1 + if on_cancel: + is_completed = 0 + + frappe.db.sql(""" + UPDATE `tabInpatient Medication Order Entry` + SET is_completed = %(is_completed)s + WHERE name IN %(orders)s + """, {'orders': orders, 'is_completed': is_completed}) + + # update status and completed orders count + for order, count in order_entry_map.items(): + medication_order = frappe.get_doc('Inpatient Medication Order', order) + completed_orders = flt(count) + current_value = frappe.db.get_value('Inpatient Medication Order', order, 'completed_orders') + + if on_cancel: + completed_orders = flt(current_value) - flt(count) + else: + completed_orders = flt(current_value) + flt(count) + + medication_order.db_set('completed_orders', completed_orders) + medication_order.set_status() + + def get_order_entry_map(self): + # for marking order completion status + orders = [] + # orders mapped + order_entry_map = dict() + + for entry in self.medication_orders: + orders.append(entry.against_imoe) + parent = entry.against_imo + if not order_entry_map.get(parent): + order_entry_map[parent] = 0 + + order_entry_map[parent] += 1 + + return orders, order_entry_map + + def check_stock_qty(self): + drug_shortage = get_drug_shortage_map(self.medication_orders, self.warehouse) + + if drug_shortage: + message = _('Quantity not available for the following items in warehouse {0}. ').format(frappe.bold(self.warehouse)) + message += _('Please enable Allow Negative Stock in Stock Settings or create Stock Entry to proceed.') + + formatted_item_rows = '' + + for drug, shortage_qty in drug_shortage.items(): + item_link = get_link_to_form('Item', drug) + formatted_item_rows += """ + {0} + {1} + """.format(item_link, frappe.bold(shortage_qty)) + + message += """ + + + + + + {2} +
    {0}{1}
    + """.format(_('Drug Code'), _('Shortage Qty'), formatted_item_rows) + + frappe.throw(message, title=_('Insufficient Stock'), is_minimizable=True, wide=True) + + def make_stock_entry(self): + stock_entry = frappe.new_doc('Stock Entry') + stock_entry.purpose = 'Material Issue' + stock_entry.set_stock_entry_type() + stock_entry.from_warehouse = self.warehouse + stock_entry.company = self.company + stock_entry.inpatient_medication_entry = self.name + cost_center = frappe.get_cached_value('Company', self.company, 'cost_center') + expense_account = get_account(None, 'expense_account', 'Healthcare Settings', self.company) + + for entry in self.medication_orders: + se_child = stock_entry.append('items') + se_child.item_code = entry.drug_code + se_child.item_name = entry.drug_name + se_child.uom = frappe.db.get_value('Item', entry.drug_code, 'stock_uom') + se_child.stock_uom = se_child.uom + se_child.qty = flt(entry.dosage) + # in stock uom + se_child.conversion_factor = 1 + se_child.cost_center = cost_center + se_child.expense_account = expense_account + # references + se_child.patient = entry.patient + se_child.inpatient_medication_entry_child = entry.name + + stock_entry.submit() + return stock_entry.name + + def cancel_stock_entries(self): + stock_entries = frappe.get_all('Stock Entry', {'inpatient_medication_entry': self.name}) + for entry in stock_entries: + doc = frappe.get_doc('Stock Entry', entry.name) + doc.cancel() + + +def get_pending_medication_orders(entry): + filters, values = get_filters(entry) + to_remove = [] + + data = frappe.db.sql(""" + SELECT + ip.inpatient_record, ip.patient, ip.patient_name, + entry.name, entry.parent, entry.drug, entry.drug_name, + entry.dosage, entry.dosage_form, entry.date, entry.time, entry.instructions + FROM + `tabInpatient Medication Order` ip + INNER JOIN + `tabInpatient Medication Order Entry` entry + ON + ip.name = entry.parent + WHERE + ip.docstatus = 1 and + ip.company = %(company)s and + entry.is_completed = 0 + {0} + ORDER BY + entry.date, entry.time + """.format(filters), values, as_dict=1) + + for doc in data: + inpatient_record = doc.inpatient_record + if inpatient_record: + doc['service_unit'] = get_current_healthcare_service_unit(inpatient_record) + + if entry.service_unit and doc.service_unit != entry.service_unit: + to_remove.append(doc) + + for doc in to_remove: + data.remove(doc) + + return data + + +def get_filters(entry): + filters = '' + values = dict(company=entry.company) + if entry.from_date: + filters += ' and entry.date >= %(from_date)s' + values['from_date'] = entry.from_date + + if entry.to_date: + filters += ' and entry.date <= %(to_date)s' + values['to_date'] = entry.to_date + + if entry.from_time: + filters += ' and entry.time >= %(from_time)s' + values['from_time'] = entry.from_time + + if entry.to_time: + filters += ' and entry.time <= %(to_time)s' + values['to_time'] = entry.to_time + + if entry.patient: + filters += ' and ip.patient = %(patient)s' + values['patient'] = entry.patient + + if entry.practitioner: + filters += ' and ip.practitioner = %(practitioner)s' + values['practitioner'] = entry.practitioner + + if entry.item_code: + filters += ' and entry.drug = %(item_code)s' + values['item_code'] = entry.item_code + + if entry.assigned_to_practitioner: + filters += ' and ip._assign LIKE %(assigned_to)s' + values['assigned_to'] = '%' + entry.assigned_to_practitioner + '%' + + return filters, values + + +def get_current_healthcare_service_unit(inpatient_record): + ip_record = frappe.get_doc('Inpatient Record', inpatient_record) + if ip_record.status in ['Admitted', 'Discharge Scheduled'] and ip_record.inpatient_occupancies: + return ip_record.inpatient_occupancies[-1].service_unit + return + + +def get_drug_shortage_map(medication_orders, warehouse): + """ + Returns a dict like { drug_code: shortage_qty } + """ + drug_requirement = dict() + for d in medication_orders: + if not drug_requirement.get(d.drug_code): + drug_requirement[d.drug_code] = 0 + drug_requirement[d.drug_code] += flt(d.dosage) + + drug_shortage = dict() + for drug, required_qty in drug_requirement.items(): + available_qty = get_latest_stock_qty(drug, warehouse) + if flt(required_qty) > flt(available_qty): + drug_shortage[drug] = flt(flt(required_qty) - flt(available_qty)) + + return drug_shortage + + +@frappe.whitelist() +def make_difference_stock_entry(docname): + doc = frappe.get_doc('Inpatient Medication Entry', docname) + drug_shortage = get_drug_shortage_map(doc.medication_orders, doc.warehouse) + + if not drug_shortage: + return None + + stock_entry = frappe.new_doc('Stock Entry') + stock_entry.purpose = 'Material Transfer' + stock_entry.set_stock_entry_type() + stock_entry.to_warehouse = doc.warehouse + stock_entry.company = doc.company + cost_center = frappe.get_cached_value('Company', doc.company, 'cost_center') + expense_account = get_account(None, 'expense_account', 'Healthcare Settings', doc.company) + + for drug, shortage_qty in drug_shortage.items(): + se_child = stock_entry.append('items') + se_child.item_code = drug + se_child.item_name = frappe.db.get_value('Item', drug, 'stock_uom') + se_child.uom = frappe.db.get_value('Item', drug, 'stock_uom') + se_child.stock_uom = se_child.uom + se_child.qty = flt(shortage_qty) + se_child.t_warehouse = doc.warehouse + # in stock uom + se_child.conversion_factor = 1 + se_child.cost_center = cost_center + se_child.expense_account = expense_account + + return stock_entry diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry_dashboard.py b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry_dashboard.py new file mode 100644 index 00000000000..a4bec45596f --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry_dashboard.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from frappe import _ + +def get_data(): + return { + 'fieldname': 'against_imoe', + 'internal_links': { + 'Inpatient Medication Order': ['medication_orders', 'against_imo'] + }, + 'transactions': [ + { + 'label': _('Reference'), + 'items': ['Inpatient Medication Order'] + } + ] + } diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/test_inpatient_medication_entry.py b/erpnext/healthcare/doctype/inpatient_medication_entry/test_inpatient_medication_entry.py new file mode 100644 index 00000000000..7cb5a4814e8 --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry/test_inpatient_medication_entry.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from frappe.utils import add_days, getdate, now_datetime +from erpnext.healthcare.doctype.inpatient_record.test_inpatient_record import create_patient, create_inpatient, get_healthcare_service_unit, mark_invoiced_inpatient_occupancy +from erpnext.healthcare.doctype.inpatient_record.inpatient_record import admit_patient, discharge_patient, schedule_discharge +from erpnext.healthcare.doctype.inpatient_medication_order.test_inpatient_medication_order import create_ipmo, create_ipme +from erpnext.healthcare.doctype.inpatient_medication_entry.inpatient_medication_entry import get_drug_shortage_map, make_difference_stock_entry +from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_account + +class TestInpatientMedicationEntry(unittest.TestCase): + def setUp(self): + frappe.db.sql("""delete from `tabInpatient Record`""") + frappe.db.sql("""delete from `tabInpatient Medication Order`""") + frappe.db.sql("""delete from `tabInpatient Medication Entry`""") + self.patient = create_patient() + + # Admit + ip_record = create_inpatient(self.patient) + ip_record.expected_length_of_stay = 0 + ip_record.save() + ip_record.reload() + service_unit = get_healthcare_service_unit() + admit_patient(ip_record, service_unit, now_datetime()) + self.ip_record = ip_record + + def test_filters_for_fetching_pending_mo(self): + ipmo = create_ipmo(self.patient) + ipmo.submit() + ipmo.reload() + + date = add_days(getdate(), -1) + filters = frappe._dict( + from_date=date, + to_date=date, + from_time='', + to_time='', + item_code='Dextromethorphan', + patient=self.patient + ) + + ipme = create_ipme(filters, update_stock=0) + + # 3 dosages per day + self.assertEqual(len(ipme.medication_orders), 3) + self.assertEqual(getdate(ipme.medication_orders[0].datetime), date) + + def test_ipme_with_stock_update(self): + ipmo = create_ipmo(self.patient) + ipmo.submit() + ipmo.reload() + + date = add_days(getdate(), -1) + filters = frappe._dict( + from_date=date, + to_date=date, + from_time='', + to_time='', + item_code='Dextromethorphan', + patient=self.patient + ) + + make_stock_entry() + ipme = create_ipme(filters, update_stock=1) + ipme.submit() + ipme.reload() + + # test order completed + is_order_completed = frappe.db.get_value('Inpatient Medication Order Entry', + ipme.medication_orders[0].against_imoe, 'is_completed') + self.assertEqual(is_order_completed, 1) + + # test stock entry + stock_entry = frappe.db.exists('Stock Entry', {'inpatient_medication_entry': ipme.name}) + self.assertTrue(stock_entry) + + # check references + stock_entry = frappe.get_doc('Stock Entry', stock_entry) + self.assertEqual(stock_entry.items[0].patient, self.patient) + self.assertEqual(stock_entry.items[0].inpatient_medication_entry_child, ipme.medication_orders[0].name) + + def test_drug_shortage_stock_entry(self): + ipmo = create_ipmo(self.patient) + ipmo.submit() + ipmo.reload() + + date = add_days(getdate(), -1) + filters = frappe._dict( + from_date=date, + to_date=date, + from_time='', + to_time='', + item_code='Dextromethorphan', + patient=self.patient + ) + + # check drug shortage + ipme = create_ipme(filters, update_stock=1) + ipme.warehouse = 'Finished Goods - _TC' + ipme.save() + drug_shortage = get_drug_shortage_map(ipme.medication_orders, ipme.warehouse) + self.assertEqual(drug_shortage.get('Dextromethorphan'), 3) + + # check material transfer for drug shortage + make_stock_entry() + stock_entry = make_difference_stock_entry(ipme.name) + self.assertEqual(stock_entry.items[0].item_code, 'Dextromethorphan') + self.assertEqual(stock_entry.items[0].qty, 3) + stock_entry.from_warehouse = 'Stores - _TC' + stock_entry.submit() + + ipme.reload() + ipme.submit() + + def tearDown(self): + # cleanup - Discharge + schedule_discharge(frappe.as_json({'patient': self.patient})) + self.ip_record.reload() + mark_invoiced_inpatient_occupancy(self.ip_record) + + self.ip_record.reload() + discharge_patient(self.ip_record) + + for entry in frappe.get_all('Inpatient Medication Entry'): + doc = frappe.get_doc('Inpatient Medication Entry', entry.name) + doc.cancel() + + for entry in frappe.get_all('Inpatient Medication Order'): + doc = frappe.get_doc('Inpatient Medication Order', entry.name) + doc.cancel() + +def make_stock_entry(warehouse=None): + frappe.db.set_value('Company', '_Test Company', { + 'stock_adjustment_account': 'Stock Adjustment - _TC', + 'default_inventory_account': 'Stock In Hand - _TC' + }) + stock_entry = frappe.new_doc('Stock Entry') + stock_entry.stock_entry_type = 'Material Receipt' + stock_entry.company = '_Test Company' + stock_entry.to_warehouse = warehouse or 'Stores - _TC' + expense_account = get_account(None, 'expense_account', 'Healthcare Settings', '_Test Company') + se_child = stock_entry.append('items') + se_child.item_code = 'Dextromethorphan' + se_child.item_name = 'Dextromethorphan' + se_child.uom = 'Nos' + se_child.stock_uom = 'Nos' + se_child.qty = 6 + se_child.t_warehouse = 'Stores - _TC' + # in stock uom + se_child.conversion_factor = 1.0 + se_child.expense_account = expense_account + stock_entry.submit() \ No newline at end of file diff --git a/erpnext/non_profit/doctype/membership_settings/__init__.py b/erpnext/healthcare/doctype/inpatient_medication_entry_detail/__init__.py similarity index 100% rename from erpnext/non_profit/doctype/membership_settings/__init__.py rename to erpnext/healthcare/doctype/inpatient_medication_entry_detail/__init__.py diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry_detail/inpatient_medication_entry_detail.json b/erpnext/healthcare/doctype/inpatient_medication_entry_detail/inpatient_medication_entry_detail.json new file mode 100644 index 00000000000..e3d7212169e --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry_detail/inpatient_medication_entry_detail.json @@ -0,0 +1,163 @@ +{ + "actions": [], + "creation": "2020-09-25 14:56:32.636569", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "patient", + "patient_name", + "inpatient_record", + "column_break_4", + "service_unit", + "datetime", + "medication_details_section", + "drug_code", + "drug_name", + "dosage", + "available_qty", + "dosage_form", + "column_break_10", + "instructions", + "references_section", + "against_imo", + "against_imoe" + ], + "fields": [ + { + "columns": 2, + "fieldname": "patient", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Patient", + "options": "Patient", + "reqd": 1 + }, + { + "fetch_from": "patient.patient_name", + "fieldname": "patient_name", + "fieldtype": "Data", + "label": "Patient Name", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "drug_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Drug Code", + "options": "Item", + "reqd": 1 + }, + { + "fetch_from": "drug_code.item_name", + "fieldname": "drug_name", + "fieldtype": "Data", + "label": "Drug Name", + "read_only": 1 + }, + { + "columns": 1, + "fieldname": "dosage", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Dosage", + "reqd": 1 + }, + { + "fieldname": "dosage_form", + "fieldtype": "Link", + "label": "Dosage Form", + "options": "Dosage Form" + }, + { + "fetch_from": "patient.inpatient_record", + "fieldname": "inpatient_record", + "fieldtype": "Link", + "label": "Inpatient Record", + "options": "Inpatient Record", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "references_section", + "fieldtype": "Section Break", + "label": "References" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "medication_details_section", + "fieldtype": "Section Break", + "label": "Medication Details" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "columns": 3, + "fieldname": "datetime", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Datetime", + "reqd": 1 + }, + { + "fieldname": "instructions", + "fieldtype": "Small Text", + "label": "Instructions" + }, + { + "columns": 2, + "fieldname": "service_unit", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Service Unit", + "options": "Healthcare Service Unit", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "against_imo", + "fieldtype": "Link", + "label": "Against Inpatient Medication Order", + "no_copy": 1, + "options": "Inpatient Medication Order", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "against_imoe", + "fieldtype": "Data", + "label": "Against Inpatient Medication Order Entry", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "available_qty", + "fieldtype": "Float", + "hidden": 1, + "label": "Available Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-09-30 14:48:23.648223", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Inpatient Medication Entry Detail", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry_detail/inpatient_medication_entry_detail.py b/erpnext/healthcare/doctype/inpatient_medication_entry_detail/inpatient_medication_entry_detail.py new file mode 100644 index 00000000000..644898d9edc --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_entry_detail/inpatient_medication_entry_detail.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class InpatientMedicationEntryDetail(Document): + pass diff --git a/erpnext/healthcare/doctype/inpatient_medication_order/__init__.py b/erpnext/healthcare/doctype/inpatient_medication_order/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.js b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.js new file mode 100644 index 00000000000..690e2e7a900 --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.js @@ -0,0 +1,107 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Inpatient Medication Order', { + refresh: function(frm) { + if (frm.doc.docstatus === 1) { + frm.trigger("show_progress"); + } + + frm.events.show_medication_order_button(frm); + + frm.set_query('patient', () => { + return { + filters: { + 'inpatient_record': ['!=', ''], + 'inpatient_status': 'Admitted' + } + }; + }); + }, + + show_medication_order_button: function(frm) { + frm.fields_dict['medication_orders'].grid.wrapper.find('.grid-add-row').hide(); + frm.fields_dict['medication_orders'].grid.add_custom_button(__('Add Medication Orders'), () => { + let d = new frappe.ui.Dialog({ + title: __('Add Medication Orders'), + fields: [ + { + fieldname: 'drug_code', + label: __('Drug'), + fieldtype: 'Link', + options: 'Item', + reqd: 1, + "get_query": function () { + return { + filters: {'is_stock_item': 1} + }; + } + }, + { + fieldname: 'dosage', + label: __('Dosage'), + fieldtype: 'Link', + options: 'Prescription Dosage', + reqd: 1 + }, + { + fieldname: 'period', + label: __('Period'), + fieldtype: 'Link', + options: 'Prescription Duration', + reqd: 1 + }, + { + fieldname: 'dosage_form', + label: __('Dosage Form'), + fieldtype: 'Link', + options: 'Dosage Form', + reqd: 1 + } + ], + primary_action_label: __('Add'), + primary_action: () => { + let values = d.get_values(); + if (values) { + frm.call({ + doc: frm.doc, + method: 'add_order_entries', + args: { + order: values + }, + freeze: true, + freeze_message: __('Adding Order Entries'), + callback: function() { + frm.refresh_field('medication_orders'); + } + }); + } + }, + }); + d.show(); + }); + }, + + show_progress: function(frm) { + let bars = []; + let message = ''; + + // completed sessions + let title = __('{0} medication orders completed', [frm.doc.completed_orders]); + if (frm.doc.completed_orders === 1) { + title = __('{0} medication order completed', [frm.doc.completed_orders]); + } + title += __(' out of {0}', [frm.doc.total_orders]); + + bars.push({ + 'title': title, + 'width': (frm.doc.completed_orders / frm.doc.total_orders * 100) + '%', + 'progress_class': 'progress-bar-success' + }); + if (bars[0].width == '0%') { + bars[0].width = '0.5%'; + } + message = title; + frm.dashboard.add_progress(__('Status'), bars, message); + } +}); diff --git a/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.json b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.json new file mode 100644 index 00000000000..e31d2e3e36c --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.json @@ -0,0 +1,196 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2020-09-14 18:33:56.715736", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "patient_details_section", + "naming_series", + "patient_encounter", + "patient", + "patient_name", + "patient_age", + "inpatient_record", + "column_break_6", + "company", + "status", + "practitioner", + "start_date", + "end_date", + "medication_orders_section", + "medication_orders", + "section_break_16", + "total_orders", + "column_break_18", + "completed_orders", + "amended_from" + ], + "fields": [ + { + "fieldname": "patient_details_section", + "fieldtype": "Section Break", + "label": "Patient Details" + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "HLC-IMO-.YYYY.-" + }, + { + "fieldname": "patient_encounter", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Patient Encounter", + "options": "Patient Encounter" + }, + { + "fetch_from": "patient_encounter.patient", + "fieldname": "patient", + "fieldtype": "Link", + "label": "Patient", + "options": "Patient", + "read_only_depends_on": "patient_encounter", + "reqd": 1 + }, + { + "fetch_from": "patient.patient_name", + "fieldname": "patient_name", + "fieldtype": "Data", + "label": "Patient Name", + "read_only": 1 + }, + { + "fieldname": "patient_age", + "fieldtype": "Data", + "label": "Patient Age", + "read_only": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "fetch_from": "patient.inpatient_record", + "fieldname": "inpatient_record", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Inpatient Record", + "options": "Inpatient Record", + "read_only": 1, + "reqd": 1 + }, + { + "fetch_from": "patient_encounter.practitioner", + "fieldname": "practitioner", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Healthcare Practitioner", + "options": "Healthcare Practitioner", + "read_only_depends_on": "patient_encounter" + }, + { + "fieldname": "start_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Start Date", + "reqd": 1 + }, + { + "fieldname": "end_date", + "fieldtype": "Date", + "label": "End Date", + "read_only": 1 + }, + { + "depends_on": "eval: doc.patient && doc.start_date", + "fieldname": "medication_orders_section", + "fieldtype": "Section Break", + "label": "Medication Orders" + }, + { + "fieldname": "medication_orders", + "fieldtype": "Table", + "label": "Medication Orders", + "options": "Inpatient Medication Order Entry" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Inpatient Medication Order", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "\nDraft\nSubmitted\nPending\nIn Process\nCompleted\nCancelled", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_16", + "fieldtype": "Section Break", + "label": "Other Details" + }, + { + "fieldname": "total_orders", + "fieldtype": "Float", + "label": "Total Orders", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "completed_orders", + "fieldtype": "Float", + "label": "Completed Orders", + "no_copy": 1, + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2020-09-30 21:53:27.128591", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Inpatient Medication Order", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "patient_encounter, patient", + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "patient", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.py b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.py new file mode 100644 index 00000000000..33cbbec8129 --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cstr +from erpnext.healthcare.doctype.patient_encounter.patient_encounter import get_prescription_dates + +class InpatientMedicationOrder(Document): + def validate(self): + self.validate_inpatient() + self.validate_duplicate() + self.set_total_orders() + self.set_status() + + def on_submit(self): + self.validate_inpatient() + self.set_status() + + def on_cancel(self): + self.set_status() + + def validate_inpatient(self): + if not self.inpatient_record: + frappe.throw(_('No Inpatient Record found against patient {0}').format(self.patient)) + + def validate_duplicate(self): + existing_mo = frappe.db.exists('Inpatient Medication Order', { + 'patient_encounter': self.patient_encounter, + 'docstatus': ('!=', 2), + 'name': ('!=', self.name) + }) + if existing_mo: + frappe.throw(_('An Inpatient Medication Order {0} against Patient Encounter {1} already exists.').format( + existing_mo, self.patient_encounter), frappe.DuplicateEntryError) + + def set_total_orders(self): + self.db_set('total_orders', len(self.medication_orders)) + + def set_status(self): + status = { + "0": "Draft", + "1": "Submitted", + "2": "Cancelled" + }[cstr(self.docstatus or 0)] + + if self.docstatus == 1: + if not self.completed_orders: + status = 'Pending' + elif self.completed_orders < self.total_orders: + status = 'In Process' + else: + status = 'Completed' + + self.db_set('status', status) + + def add_order_entries(self, order): + if order.get('drug_code'): + dosage = frappe.get_doc('Prescription Dosage', order.get('dosage')) + dates = get_prescription_dates(order.get('period'), self.start_date) + for date in dates: + for dose in dosage.dosage_strength: + entry = self.append('medication_orders') + entry.drug = order.get('drug_code') + entry.drug_name = frappe.db.get_value('Item', order.get('drug_code'), 'item_name') + entry.dosage = dose.strength + entry.dosage_form = order.get('dosage_form') + entry.date = date + entry.time = dose.strength_time + self.end_date = dates[-1] + return diff --git a/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order_list.js b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order_list.js new file mode 100644 index 00000000000..1c318768ea7 --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order_list.js @@ -0,0 +1,16 @@ +frappe.listview_settings['Inpatient Medication Order'] = { + add_fields: ["status"], + filters: [["status", "!=", "Cancelled"]], + get_indicator: function(doc) { + if (doc.status === "Pending") { + return [__("Pending"), "orange", "status,=,Pending"]; + + } else if (doc.status === "In Process") { + return [__("In Process"), "blue", "status,=,In Process"]; + + } else if (doc.status === "Completed") { + return [__("Completed"), "green", "status,=,Completed"]; + + } + } +}; diff --git a/erpnext/healthcare/doctype/inpatient_medication_order/test_inpatient_medication_order.py b/erpnext/healthcare/doctype/inpatient_medication_order/test_inpatient_medication_order.py new file mode 100644 index 00000000000..a21caca8ffa --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_order/test_inpatient_medication_order.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from frappe.utils import add_days, getdate, now_datetime +from erpnext.healthcare.doctype.inpatient_record.test_inpatient_record import create_patient, create_inpatient, get_healthcare_service_unit, mark_invoiced_inpatient_occupancy +from erpnext.healthcare.doctype.inpatient_record.inpatient_record import admit_patient, discharge_patient, schedule_discharge + +class TestInpatientMedicationOrder(unittest.TestCase): + def setUp(self): + frappe.db.sql("""delete from `tabInpatient Record`""") + self.patient = create_patient() + + # Admit + ip_record = create_inpatient(self.patient) + ip_record.expected_length_of_stay = 0 + ip_record.save() + ip_record.reload() + service_unit = get_healthcare_service_unit() + admit_patient(ip_record, service_unit, now_datetime()) + self.ip_record = ip_record + + def test_order_creation(self): + ipmo = create_ipmo(self.patient) + ipmo.submit() + ipmo.reload() + + # 3 dosages per day for 2 days + self.assertEqual(len(ipmo.medication_orders), 6) + self.assertEqual(ipmo.medication_orders[0].date, add_days(getdate(), -1)) + + prescription_dosage = frappe.get_doc('Prescription Dosage', '1-1-1') + for i in range(len(prescription_dosage.dosage_strength)): + self.assertEqual(ipmo.medication_orders[i].time, prescription_dosage.dosage_strength[i].strength_time) + + self.assertEqual(ipmo.medication_orders[3].date, getdate()) + + def test_inpatient_validation(self): + # Discharge + schedule_discharge(frappe.as_json({'patient': self.patient})) + + self.ip_record.reload() + mark_invoiced_inpatient_occupancy(self.ip_record) + + self.ip_record.reload() + discharge_patient(self.ip_record) + + ipmo = create_ipmo(self.patient) + # inpatient validation + self.assertRaises(frappe.ValidationError, ipmo.insert) + + def test_status(self): + ipmo = create_ipmo(self.patient) + ipmo.submit() + ipmo.reload() + + self.assertEqual(ipmo.status, 'Pending') + + filters = frappe._dict(from_date=add_days(getdate(), -1), to_date=add_days(getdate(), -1), from_time='', to_time='') + ipme = create_ipme(filters) + ipme.submit() + ipmo.reload() + self.assertEqual(ipmo.status, 'In Process') + + filters = frappe._dict(from_date=getdate(), to_date=getdate(), from_time='', to_time='') + ipme = create_ipme(filters) + ipme.submit() + ipmo.reload() + self.assertEqual(ipmo.status, 'Completed') + + def tearDown(self): + if frappe.db.get_value('Patient', self.patient, 'inpatient_record'): + # cleanup - Discharge + schedule_discharge(frappe.as_json({'patient': self.patient})) + self.ip_record.reload() + mark_invoiced_inpatient_occupancy(self.ip_record) + + self.ip_record.reload() + discharge_patient(self.ip_record) + + for entry in frappe.get_all('Inpatient Medication Entry'): + doc = frappe.get_doc('Inpatient Medication Entry', entry.name) + doc.cancel() + doc.delete() + + for entry in frappe.get_all('Inpatient Medication Order'): + doc = frappe.get_doc('Inpatient Medication Order', entry.name) + doc.cancel() + doc.delete() + +def create_dosage_form(): + if not frappe.db.exists('Dosage Form', 'Tablet'): + frappe.get_doc({ + 'doctype': 'Dosage Form', + 'dosage_form': 'Tablet' + }).insert() + +def create_drug(item=None): + if not item: + item = 'Dextromethorphan' + drug = frappe.db.exists('Item', {'item_code': 'Dextromethorphan'}) + if not drug: + drug = frappe.get_doc({ + 'doctype': 'Item', + 'item_code': 'Dextromethorphan', + 'item_name': 'Dextromethorphan', + 'item_group': 'Products', + 'stock_uom': 'Nos', + 'is_stock_item': 1, + 'valuation_rate': 50, + 'opening_stock': 20 + }).insert() + +def get_orders(): + create_dosage_form() + create_drug() + return { + 'drug_code': 'Dextromethorphan', + 'drug_name': 'Dextromethorphan', + 'dosage': '1-1-1', + 'dosage_form': 'Tablet', + 'period': '2 Day' + } + +def create_ipmo(patient): + orders = get_orders() + ipmo = frappe.new_doc('Inpatient Medication Order') + ipmo.patient = patient + ipmo.company = '_Test Company' + ipmo.start_date = add_days(getdate(), -1) + ipmo.add_order_entries(orders) + + return ipmo + +def create_ipme(filters, update_stock=0): + ipme = frappe.new_doc('Inpatient Medication Entry') + ipme.company = '_Test Company' + ipme.posting_date = getdate() + ipme.update_stock = update_stock + if update_stock: + ipme.warehouse = 'Stores - _TC' + for key, value in filters.items(): + ipme.set(key, value) + ipme = ipme.get_medication_orders() + + return ipme + diff --git a/erpnext/healthcare/doctype/inpatient_medication_order_entry/__init__.py b/erpnext/healthcare/doctype/inpatient_medication_order_entry/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/doctype/inpatient_medication_order_entry/inpatient_medication_order_entry.json b/erpnext/healthcare/doctype/inpatient_medication_order_entry/inpatient_medication_order_entry.json new file mode 100644 index 00000000000..72999a908eb --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_order_entry/inpatient_medication_order_entry.json @@ -0,0 +1,94 @@ +{ + "actions": [], + "creation": "2020-09-14 21:51:30.259164", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "drug", + "drug_name", + "dosage", + "dosage_form", + "instructions", + "column_break_4", + "date", + "time", + "is_completed" + ], + "fields": [ + { + "fieldname": "drug", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Drug", + "options": "Item", + "reqd": 1 + }, + { + "fetch_from": "drug.item_name", + "fieldname": "drug_name", + "fieldtype": "Data", + "label": "Drug Name", + "read_only": 1 + }, + { + "fieldname": "dosage", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Dosage", + "reqd": 1 + }, + { + "fieldname": "dosage_form", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Dosage Form", + "options": "Dosage Form", + "reqd": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "Time", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "is_completed", + "fieldtype": "Check", + "label": "Is Order Completed", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "instructions", + "fieldtype": "Small Text", + "label": "Instructions" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-09-30 14:03:26.755925", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Inpatient Medication Order Entry", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/inpatient_medication_order_entry/inpatient_medication_order_entry.py b/erpnext/healthcare/doctype/inpatient_medication_order_entry/inpatient_medication_order_entry.py new file mode 100644 index 00000000000..ebfe366346e --- /dev/null +++ b/erpnext/healthcare/doctype/inpatient_medication_order_entry/inpatient_medication_order_entry.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class InpatientMedicationOrderEntry(Document): + pass diff --git a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json index 5ced845c1b0..aaf0e855d42 100644 --- a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json +++ b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json @@ -53,7 +53,7 @@ "discharge_ordered_date", "discharge_practitioner", "discharge_encounter", - "discharge_date", + "discharge_datetime", "cb_discharge", "discharge_instructions", "followup_date", @@ -404,14 +404,15 @@ "permlevel": 1 }, { - "fieldname": "discharge_date", - "fieldtype": "Date", + "fieldname": "discharge_datetime", + "fieldtype": "Datetime", "label": "Discharge Date", "read_only": 1 } ], + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-05-21 02:26:22.144575", + "modified": "2021-03-18 14:44:11.689956", "modified_by": "Administrator", "module": "Healthcare", "name": "Inpatient Record", diff --git a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py index bc769706018..2934316c06f 100644 --- a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py +++ b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe, json from frappe import _ -from frappe.utils import today, now_datetime, getdate, get_datetime +from frappe.utils import today, now_datetime, getdate, get_datetime, get_link_to_form from frappe.model.document import Document from frappe.desk.reportview import get_match_cond @@ -50,7 +50,7 @@ class InpatientRecord(Document): if ip_record: msg = _(("Already {0} Patient {1} with Inpatient Record ").format(ip_record[0].status, self.patient) \ - + """ {0}""".format(ip_record[0].name)) + + """ {0}""".format(ip_record[0].name)) frappe.throw(msg) def admit(self, service_unit, check_in, expected_discharge=None): @@ -113,6 +113,7 @@ def schedule_inpatient(args): inpatient_record.status = 'Admission Scheduled' inpatient_record.save(ignore_permissions = True) + @frappe.whitelist() def schedule_discharge(args): discharge_order = json.loads(args) @@ -126,16 +127,19 @@ def schedule_discharge(args): frappe.db.set_value('Patient', discharge_order['patient'], 'inpatient_status', inpatient_record.status) frappe.db.set_value('Patient Encounter', inpatient_record.discharge_encounter, 'inpatient_status', inpatient_record.status) + def set_details_from_ip_order(inpatient_record, ip_order): for key in ip_order: inpatient_record.set(key, ip_order[key]) + def set_ip_child_records(inpatient_record, inpatient_record_child, encounter_child): for item in encounter_child: table = inpatient_record.append(inpatient_record_child) for df in table.meta.get('fields'): table.set(df.fieldname, item.get(df.fieldname)) + def check_out_inpatient(inpatient_record): if inpatient_record.inpatient_occupancies: for inpatient_occupancy in inpatient_record.inpatient_occupancies: @@ -144,54 +148,88 @@ def check_out_inpatient(inpatient_record): inpatient_occupancy.check_out = now_datetime() frappe.db.set_value("Healthcare Service Unit", inpatient_occupancy.service_unit, "occupancy_status", "Vacant") + def discharge_patient(inpatient_record): - validate_invoiced_inpatient(inpatient_record) - inpatient_record.discharge_date = today() + validate_inpatient_invoicing(inpatient_record) + inpatient_record.discharge_datetime = now_datetime() inpatient_record.status = "Discharged" inpatient_record.save(ignore_permissions = True) -def validate_invoiced_inpatient(inpatient_record): - pending_invoices = [] + +def validate_inpatient_invoicing(inpatient_record): + if frappe.db.get_single_value("Healthcare Settings", "allow_discharge_despite_unbilled_services"): + return + + pending_invoices = get_pending_invoices(inpatient_record) + + if pending_invoices: + message = _("Cannot mark Inpatient Record as Discharged since there are unbilled services. ") + + formatted_doc_rows = '' + + for doctype, docnames in pending_invoices.items(): + formatted_doc_rows += """ + {0} + {1} + """.format(doctype, docnames) + + message += """ + + + + + + {2} +
    {0}{1}
    + """.format(_("Healthcare Service"), _("Documents"), formatted_doc_rows) + + frappe.throw(message, title=_("Unbilled Services"), is_minimizable=True, wide=True) + + +def get_pending_invoices(inpatient_record): + pending_invoices = {} if inpatient_record.inpatient_occupancies: service_unit_names = False for inpatient_occupancy in inpatient_record.inpatient_occupancies: - if inpatient_occupancy.invoiced != 1: + if not inpatient_occupancy.invoiced: if service_unit_names: service_unit_names += ", " + inpatient_occupancy.service_unit else: service_unit_names = inpatient_occupancy.service_unit if service_unit_names: - pending_invoices.append("Inpatient Occupancy (" + service_unit_names + ")") + pending_invoices["Inpatient Occupancy"] = service_unit_names docs = ["Patient Appointment", "Patient Encounter", "Lab Test", "Clinical Procedure"] for doc in docs: - doc_name_list = get_inpatient_docs_not_invoiced(doc, inpatient_record) + doc_name_list = get_unbilled_inpatient_docs(doc, inpatient_record) if doc_name_list: pending_invoices = get_pending_doc(doc, doc_name_list, pending_invoices) - if pending_invoices: - frappe.throw(_("Can not mark Inpatient Record Discharged, there are Unbilled Invoices {0}").format(", " - .join(pending_invoices)), title=_('Unbilled Invoices')) + return pending_invoices + def get_pending_doc(doc, doc_name_list, pending_invoices): if doc_name_list: doc_ids = False for doc_name in doc_name_list: + doc_link = get_link_to_form(doc, doc_name.name) if doc_ids: - doc_ids += ", "+doc_name.name + doc_ids += ", " + doc_link else: - doc_ids = doc_name.name + doc_ids = doc_link if doc_ids: - pending_invoices.append(doc + " (" + doc_ids + ")") + pending_invoices[doc] = doc_ids return pending_invoices -def get_inpatient_docs_not_invoiced(doc, inpatient_record): + +def get_unbilled_inpatient_docs(doc, inpatient_record): return frappe.db.get_list(doc, filters = {'patient': inpatient_record.patient, 'inpatient_record': inpatient_record.name, 'docstatus': 1, 'invoiced': 0}) + def admit_patient(inpatient_record, service_unit, check_in, expected_discharge=None): inpatient_record.admitted_datetime = check_in inpatient_record.status = 'Admitted' @@ -203,6 +241,7 @@ def admit_patient(inpatient_record, service_unit, check_in, expected_discharge=N frappe.db.set_value('Patient', inpatient_record.patient, 'inpatient_status', 'Admitted') frappe.db.set_value('Patient', inpatient_record.patient, 'inpatient_record', inpatient_record.name) + def transfer_patient(inpatient_record, service_unit, check_in): item_line = inpatient_record.append('inpatient_occupancies', {}) item_line.service_unit = service_unit @@ -212,6 +251,7 @@ def transfer_patient(inpatient_record, service_unit, check_in): frappe.db.set_value("Healthcare Service Unit", service_unit, "occupancy_status", "Occupied") + def patient_leave_service_unit(inpatient_record, check_out, leave_from): if inpatient_record.inpatient_occupancies: for inpatient_occupancy in inpatient_record.inpatient_occupancies: @@ -221,6 +261,7 @@ def patient_leave_service_unit(inpatient_record, check_out, leave_from): frappe.db.set_value("Healthcare Service Unit", inpatient_occupancy.service_unit, "occupancy_status", "Vacant") inpatient_record.save(ignore_permissions = True) + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_leave_from(doctype, txt, searchfield, start, page_len, filters): diff --git a/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py b/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py index 2bef5fb5bdd..a8c7720a0a4 100644 --- a/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py +++ b/erpnext/healthcare/doctype/inpatient_record/test_inpatient_record.py @@ -8,6 +8,8 @@ import unittest from frappe.utils import now_datetime, today from frappe.utils.make_random import get_random from erpnext.healthcare.doctype.inpatient_record.inpatient_record import admit_patient, discharge_patient, schedule_discharge +from erpnext.healthcare.doctype.lab_test.test_lab_test import create_patient_encounter +from erpnext.healthcare.utils import get_encounters_to_invoice class TestInpatientRecord(unittest.TestCase): def test_admit_and_discharge(self): @@ -40,6 +42,60 @@ class TestInpatientRecord(unittest.TestCase): self.assertEqual(None, frappe.db.get_value("Patient", patient, "inpatient_record")) self.assertEqual(None, frappe.db.get_value("Patient", patient, "inpatient_status")) + def test_allow_discharge_despite_unbilled_services(self): + frappe.db.sql("""delete from `tabInpatient Record`""") + setup_inpatient_settings(key="allow_discharge_despite_unbilled_services", value=1) + patient = create_patient() + # Schedule Admission + ip_record = create_inpatient(patient) + ip_record.expected_length_of_stay = 0 + ip_record.save(ignore_permissions = True) + + # Admit + service_unit = get_healthcare_service_unit() + admit_patient(ip_record, service_unit, now_datetime()) + + # Discharge + schedule_discharge(frappe.as_json({"patient": patient})) + self.assertEqual("Vacant", frappe.db.get_value("Healthcare Service Unit", service_unit, "occupancy_status")) + + ip_record = frappe.get_doc("Inpatient Record", ip_record.name) + # Should not validate Pending Invoices + ip_record.discharge() + + self.assertEqual(None, frappe.db.get_value("Patient", patient, "inpatient_record")) + self.assertEqual(None, frappe.db.get_value("Patient", patient, "inpatient_status")) + + setup_inpatient_settings(key="allow_discharge_despite_unbilled_services", value=0) + + def test_do_not_bill_patient_encounters_for_inpatients(self): + frappe.db.sql("""delete from `tabInpatient Record`""") + setup_inpatient_settings(key="do_not_bill_inpatient_encounters", value=1) + patient = create_patient() + # Schedule Admission + ip_record = create_inpatient(patient) + ip_record.expected_length_of_stay = 0 + ip_record.save(ignore_permissions = True) + + # Admit + service_unit = get_healthcare_service_unit() + admit_patient(ip_record, service_unit, now_datetime()) + + # Patient Encounter + patient_encounter = create_patient_encounter() + encounters = get_encounters_to_invoice(patient, "_Test Company") + encounter_ids = [entry.reference_name for entry in encounters] + self.assertFalse(patient_encounter.name in encounter_ids) + + # Discharge + schedule_discharge(frappe.as_json({"patient": patient})) + self.assertEqual("Vacant", frappe.db.get_value("Healthcare Service Unit", service_unit, "occupancy_status")) + + ip_record = frappe.get_doc("Inpatient Record", ip_record.name) + mark_invoiced_inpatient_occupancy(ip_record) + discharge_patient(ip_record) + setup_inpatient_settings(key="do_not_bill_inpatient_encounters", value=0) + def test_validate_overlap_admission(self): frappe.db.sql("""delete from `tabInpatient Record`""") patient = create_patient() @@ -63,6 +119,13 @@ def mark_invoiced_inpatient_occupancy(ip_record): inpatient_occupancy.invoiced = 1 ip_record.save(ignore_permissions = True) + +def setup_inpatient_settings(key, value): + settings = frappe.get_single("Healthcare Settings") + settings.set(key, value) + settings.save() + + def create_inpatient(patient): patient_obj = frappe.get_doc('Patient', patient) inpatient_record = frappe.new_doc('Inpatient Record') @@ -76,13 +139,20 @@ def create_inpatient(patient): inpatient_record.phone = patient_obj.phone inpatient_record.inpatient = "Scheduled" inpatient_record.scheduled_date = today() + inpatient_record.company = "_Test Company" return inpatient_record -def get_healthcare_service_unit(): - service_unit = get_random("Healthcare Service Unit", filters={"inpatient_occupancy": 1}) + +def get_healthcare_service_unit(unit_name=None): + if not unit_name: + service_unit = get_random("Healthcare Service Unit", filters={"inpatient_occupancy": 1, "company": "_Test Company"}) + else: + service_unit = frappe.db.exists("Healthcare Service Unit", {"healthcare_service_unit_name": unit_name}) + if not service_unit: service_unit = frappe.new_doc("Healthcare Service Unit") - service_unit.healthcare_service_unit_name = "Test Service Unit Ip Occupancy" + service_unit.healthcare_service_unit_name = unit_name or "Test Service Unit Ip Occupancy" + service_unit.company = "_Test Company" service_unit.service_unit_type = get_service_unit_type() service_unit.inpatient_occupancy = 1 service_unit.occupancy_status = "Vacant" @@ -104,6 +174,7 @@ def get_healthcare_service_unit(): return service_unit.name return service_unit + def get_service_unit_type(): service_unit_type = get_random("Healthcare Service Unit Type", filters={"inpatient_occupancy": 1}) @@ -115,6 +186,7 @@ def get_service_unit_type(): return service_unit_type.name return service_unit_type + def create_patient(): patient = frappe.db.exists('Patient', '_Test IPD Patient') if not patient: diff --git a/erpnext/healthcare/doctype/lab_test/lab_test.js b/erpnext/healthcare/doctype/lab_test/lab_test.js index f1634c12949..bb7976ccfac 100644 --- a/erpnext/healthcare/doctype/lab_test/lab_test.js +++ b/erpnext/healthcare/doctype/lab_test/lab_test.js @@ -258,5 +258,5 @@ var calculate_age = function (dob) { var age = new Date(); age.setTime(ageMS); var years = age.getFullYear() - 1970; - return years + ' Year(s) ' + age.getMonth() + ' Month(s) ' + age.getDate() + ' Day(s)'; + return `${years} ${__('Years(s)')} ${age.getMonth()} ${__('Month(s)')} ${age.getDate()} ${__('Day(s)')}`; }; diff --git a/erpnext/healthcare/doctype/lab_test/lab_test.json b/erpnext/healthcare/doctype/lab_test/lab_test.json index edf1d911aac..ac61fea3ad7 100644 --- a/erpnext/healthcare/doctype/lab_test/lab_test.json +++ b/erpnext/healthcare/doctype/lab_test/lab_test.json @@ -359,6 +359,7 @@ { "fieldname": "normal_test_items", "fieldtype": "Table", + "label": "Normal Test Result", "options": "Normal Test Result", "print_hide": 1 }, @@ -380,6 +381,7 @@ { "fieldname": "sensitivity_test_items", "fieldtype": "Table", + "label": "Sensitivity Test Result", "options": "Sensitivity Test Result", "print_hide": 1, "report_hide": 1 @@ -529,6 +531,7 @@ { "fieldname": "descriptive_test_items", "fieldtype": "Table", + "label": "Descriptive Test Result", "options": "Descriptive Test Result", "print_hide": 1, "report_hide": 1 @@ -549,13 +552,14 @@ { "fieldname": "organism_test_items", "fieldtype": "Table", + "label": "Organism Test Result", "options": "Organism Test Result", "print_hide": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-07-30 18:18:38.516215", + "modified": "2020-11-30 11:04:17.195848", "modified_by": "Administrator", "module": "Healthcare", "name": "Lab Test", diff --git a/erpnext/healthcare/doctype/lab_test/lab_test.py b/erpnext/healthcare/doctype/lab_test/lab_test.py index 2db77438653..4b57cd073d0 100644 --- a/erpnext/healthcare/doctype/lab_test/lab_test.py +++ b/erpnext/healthcare/doctype/lab_test/lab_test.py @@ -17,11 +17,9 @@ class LabTest(Document): self.validate_result_values() self.db_set('submitted_date', getdate()) self.db_set('status', 'Completed') - insert_lab_test_to_medical_record(self) def on_cancel(self): self.db_set('status', 'Cancelled') - delete_lab_test_from_medical_record(self) self.reload() def on_update(self): @@ -330,60 +328,6 @@ def get_employee_by_user_id(user_id): return frappe.get_doc('Employee', emp_id) return None -def insert_lab_test_to_medical_record(doc): - table_row = False - subject = cstr(doc.lab_test_name) - if doc.practitioner: - subject += frappe.bold(_('Healthcare Practitioner: '))+ doc.practitioner + '
    ' - if doc.normal_test_items: - item = doc.normal_test_items[0] - comment = '' - if item.lab_test_comment: - comment = str(item.lab_test_comment) - table_row = frappe.bold(_('Lab Test Conducted: ')) + item.lab_test_name - - if item.lab_test_event: - table_row += frappe.bold(_('Lab Test Event: ')) + item.lab_test_event - - if item.result_value: - table_row += ' ' + frappe.bold(_('Lab Test Result: ')) + item.result_value - - if item.normal_range: - table_row += ' ' + _('Normal Range: ') + item.normal_range - table_row += ' ' + comment - - elif doc.descriptive_test_items: - item = doc.descriptive_test_items[0] - - if item.lab_test_particulars and item.result_value: - table_row = item.lab_test_particulars + ' ' + item.result_value - - elif doc.sensitivity_test_items: - item = doc.sensitivity_test_items[0] - - if item.antibiotic and item.antibiotic_sensitivity: - table_row = item.antibiotic + ' ' + item.antibiotic_sensitivity - - if table_row: - subject += '
    ' + table_row - if doc.lab_test_comment: - subject += '
    ' + cstr(doc.lab_test_comment) - - medical_record = frappe.new_doc('Patient Medical Record') - medical_record.patient = doc.patient - medical_record.subject = subject - medical_record.status = 'Open' - medical_record.communication_date = doc.result_date - medical_record.reference_doctype = 'Lab Test' - medical_record.reference_name = doc.name - medical_record.reference_owner = doc.owner - medical_record.save(ignore_permissions = True) - -def delete_lab_test_from_medical_record(self): - medical_record_id = frappe.db.sql('select name from `tabPatient Medical Record` where reference_name=%s', (self.name)) - - if medical_record_id and medical_record_id[0][0]: - frappe.delete_doc('Patient Medical Record', medical_record_id[0][0]) @frappe.whitelist() def get_lab_test_prescribed(patient): diff --git a/erpnext/healthcare/doctype/patient/patient.js b/erpnext/healthcare/doctype/patient/patient.js index 490f2475001..bce42e51d07 100644 --- a/erpnext/healthcare/doctype/patient/patient.js +++ b/erpnext/healthcare/doctype/patient/patient.js @@ -46,11 +46,11 @@ frappe.ui.form.on('Patient', { } }, onload: function (frm) { - if(!frm.doc.dob){ + if (!frm.doc.dob) { $(frm.fields_dict['age_html'].wrapper).html(''); } - if(frm.doc.dob){ - $(frm.fields_dict['age_html'].wrapper).html('AGE : ' + get_age(frm.doc.dob)); + if (frm.doc.dob) { + $(frm.fields_dict['age_html'].wrapper).html(`${__('AGE')} : ${get_age(frm.doc.dob)}`); } } }); @@ -65,7 +65,7 @@ frappe.ui.form.on('Patient', 'dob', function(frm) { } else { let age_str = get_age(frm.doc.dob); - $(frm.fields_dict['age_html'].wrapper).html('AGE : ' + age_str); + $(frm.fields_dict['age_html'].wrapper).html(`${__('AGE')} : ${age_str}`); } } else { diff --git a/erpnext/healthcare/doctype/patient/patient.py b/erpnext/healthcare/doctype/patient/patient.py index 63dd8d4793a..8603f974c39 100644 --- a/erpnext/healthcare/doctype/patient/patient.py +++ b/erpnext/healthcare/doctype/patient/patient.py @@ -108,7 +108,7 @@ class Patient(Document): if self.dob: dob = getdate(self.dob) age = dateutil.relativedelta.relativedelta(getdate(), dob) - age_str = str(age.years) + ' year(s) ' + str(age.months) + ' month(s) ' + str(age.days) + ' day(s)' + age_str = str(age.years) + ' ' + _("Years(s)") + ' ' + str(age.months) + ' ' + _("Month(s)") + ' ' + str(age.days) + ' ' + _("Day(s)") return age_str def invoice_patient_registration(self): diff --git a/erpnext/healthcare/doctype/patient/patient_dashboard.py b/erpnext/healthcare/doctype/patient/patient_dashboard.py index e3def72334c..39603f77a06 100644 --- a/erpnext/healthcare/doctype/patient/patient_dashboard.py +++ b/erpnext/healthcare/doctype/patient/patient_dashboard.py @@ -18,6 +18,10 @@ def get_data(): { 'label': _('Billing'), 'items': ['Sales Invoice'] + }, + { + 'label': _('Orders'), + 'items': ['Inpatient Medication Order'] } ] } diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js index 2d6b64532b1..2976ef13a1d 100644 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js @@ -22,23 +22,37 @@ frappe.ui.form.on('Patient Appointment', { filters: {'status': 'Active'} }; }); + frm.set_query('practitioner', function() { + if (frm.doc.department) { + return { + filters: { + 'department': frm.doc.department + } + }; + } + }); + + frm.set_query('service_unit', function() { return { + query: 'erpnext.controllers.queries.get_healthcare_service_units', filters: { - 'department': frm.doc.department + company: frm.doc.company, + inpatient_record: frm.doc.inpatient_record } }; }); - frm.set_query('service_unit', function(){ + + frm.set_query('therapy_plan', function() { return { filters: { - 'is_group': false, - 'allow_appointments': true, - 'company': frm.doc.company + 'patient': frm.doc.patient } }; }); + frm.trigger('set_therapy_type_filter'); + if (frm.is_new()) { frm.page.set_primary_action(__('Check Availability'), function() { if (!frm.doc.patient) { @@ -128,6 +142,20 @@ frappe.ui.form.on('Patient Appointment', { patient: function(frm) { if (frm.doc.patient) { frm.trigger('toggle_payment_fields'); + frappe.call({ + method: 'frappe.client.get', + args: { + doctype: 'Patient', + name: frm.doc.patient + }, + callback: function (data) { + let age = null; + if (data.message.dob) { + age = calculate_age(data.message.dob); + } + frappe.model.set_value(frm.doctype, frm.docname, 'patient_age', age); + } + }); } else { frm.set_value('patient_name', ''); frm.set_value('patient_sex', ''); @@ -136,6 +164,55 @@ frappe.ui.form.on('Patient Appointment', { } }, + practitioner: function(frm) { + if (frm.doc.practitioner ) { + frm.events.set_payment_details(frm); + } + }, + + appointment_type: function(frm) { + if (frm.doc.appointment_type) { + frm.events.set_payment_details(frm); + } + }, + + set_payment_details: function(frm) { + frappe.db.get_single_value('Healthcare Settings', 'automate_appointment_invoicing').then(val => { + if (val) { + frappe.call({ + method: 'erpnext.healthcare.utils.get_service_item_and_practitioner_charge', + args: { + doc: frm.doc + }, + callback: function(data) { + if (data.message) { + frappe.model.set_value(frm.doctype, frm.docname, 'paid_amount', data.message.practitioner_charge); + frappe.model.set_value(frm.doctype, frm.docname, 'billing_item', data.message.service_item); + } + } + }); + } + }); + }, + + therapy_plan: function(frm) { + frm.trigger('set_therapy_type_filter'); + }, + + set_therapy_type_filter: function(frm) { + if (frm.doc.therapy_plan) { + frm.call('get_therapy_types').then(r => { + frm.set_query('therapy_type', function() { + return { + filters: { + 'name': ['in', r.message] + } + }; + }); + }); + } + }, + therapy_type: function(frm) { if (frm.doc.therapy_type) { frappe.db.get_value('Therapy Type', frm.doc.therapy_type, 'default_duration', (r) => { @@ -160,14 +237,18 @@ frappe.ui.form.on('Patient Appointment', { // show payment fields as non-mandatory frm.toggle_display('mode_of_payment', 0); frm.toggle_display('paid_amount', 0); + frm.toggle_display('billing_item', 0); frm.toggle_reqd('mode_of_payment', 0); frm.toggle_reqd('paid_amount', 0); + frm.toggle_reqd('billing_item', 0); } else { // if automated appointment invoicing is disabled, hide fields frm.toggle_display('mode_of_payment', data.message ? 1 : 0); frm.toggle_display('paid_amount', data.message ? 1 : 0); + frm.toggle_display('billing_item', data.message ? 1 : 0); frm.toggle_reqd('mode_of_payment', data.message ? 1 : 0); frm.toggle_reqd('paid_amount', data.message ? 1 :0); + frm.toggle_reqd('billing_item', data.message ? 1 : 0); } } }); @@ -510,61 +591,10 @@ let update_status = function(frm, status){ ); }; -frappe.ui.form.on('Patient Appointment', 'practitioner', function(frm) { - if (frm.doc.practitioner) { - frappe.call({ - method: 'frappe.client.get', - args: { - doctype: 'Healthcare Practitioner', - name: frm.doc.practitioner - }, - callback: function (data) { - frappe.model.set_value(frm.doctype, frm.docname, 'department', data.message.department); - frappe.model.set_value(frm.doctype, frm.docname, 'paid_amount', data.message.op_consulting_charge); - frappe.model.set_value(frm.doctype, frm.docname, 'billing_item', data.message.op_consulting_charge_item); - } - }); - } -}); - -frappe.ui.form.on('Patient Appointment', 'patient', function(frm) { - if (frm.doc.patient) { - frappe.call({ - method: 'frappe.client.get', - args: { - doctype: 'Patient', - name: frm.doc.patient - }, - callback: function (data) { - let age = null; - if (data.message.dob) { - age = calculate_age(data.message.dob); - } - frappe.model.set_value(frm.doctype,frm.docname, 'patient_age', age); - } - }); - } -}); - -frappe.ui.form.on('Patient Appointment', 'appointment_type', function(frm) { - if (frm.doc.appointment_type) { - frappe.call({ - method: 'frappe.client.get', - args: { - doctype: 'Appointment Type', - name: frm.doc.appointment_type - }, - callback: function(data) { - frappe.model.set_value(frm.doctype,frm.docname, 'duration',data.message.default_duration); - } - }); - } -}); - let calculate_age = function(birth) { let ageMS = Date.parse(Date()) - Date.parse(birth); let age = new Date(); age.setTime(ageMS); let years = age.getFullYear() - 1970; - return years + ' Year(s) ' + age.getMonth() + ' Month(s) ' + age.getDate() + ' Day(s)'; + return `${years} ${__('Years(s)')} ${age.getMonth()} ${__('Month(s)')} ${age.getDate()} ${__('Day(s)')}`; }; diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json index ac35acc21ac..83c92af36ac 100644 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.json @@ -19,19 +19,19 @@ "inpatient_record", "column_break_1", "company", - "service_unit", - "procedure_template", - "get_procedure_from_encounter", - "procedure_prescription", - "therapy_type", - "get_prescribed_therapies", - "therapy_plan", "practitioner", "practitioner_name", "department", + "service_unit", "section_break_12", "appointment_type", "duration", + "procedure_template", + "get_procedure_from_encounter", + "procedure_prescription", + "therapy_plan", + "therapy_type", + "get_prescribed_therapies", "column_break_17", "appointment_date", "appointment_time", @@ -79,6 +79,7 @@ "set_only_once": 1 }, { + "fetch_from": "appointment_type.default_duration", "fieldname": "duration", "fieldtype": "Int", "in_filter": 1, @@ -144,7 +145,6 @@ "in_standard_filter": 1, "label": "Healthcare Practitioner", "options": "Healthcare Practitioner", - "read_only": 1, "reqd": 1, "search_index": 1, "set_only_once": 1 @@ -158,7 +158,6 @@ "in_standard_filter": 1, "label": "Department", "options": "Medical Department", - "read_only": 1, "search_index": 1, "set_only_once": 1 }, @@ -227,12 +226,14 @@ "fieldname": "mode_of_payment", "fieldtype": "Link", "label": "Mode of Payment", - "options": "Mode of Payment" + "options": "Mode of Payment", + "read_only_depends_on": "invoiced" }, { "fieldname": "paid_amount", "fieldtype": "Currency", - "label": "Paid Amount" + "label": "Paid Amount", + "read_only_depends_on": "invoiced" }, { "fieldname": "column_break_2", @@ -284,7 +285,7 @@ "report_hide": 1 }, { - "depends_on": "eval:doc.patient;", + "depends_on": "eval:doc.patient && doc.therapy_plan;", "fieldname": "therapy_type", "fieldtype": "Link", "label": "Therapy", @@ -292,18 +293,18 @@ "set_only_once": 1 }, { - "depends_on": "eval:doc.patient && doc.__islocal;", + "depends_on": "eval:doc.patient && doc.therapy_plan && doc.__islocal;", "fieldname": "get_prescribed_therapies", "fieldtype": "Button", "label": "Get Prescribed Therapies" }, { - "depends_on": "eval: doc.patient && doc.therapy_type", + "depends_on": "eval: doc.patient;", "fieldname": "therapy_plan", "fieldtype": "Link", "label": "Therapy Plan", - "mandatory_depends_on": "eval: doc.patient && doc.therapy_type", - "options": "Therapy Plan" + "options": "Therapy Plan", + "set_only_once": 1 }, { "fieldname": "ref_sales_invoice", @@ -348,7 +349,7 @@ } ], "links": [], - "modified": "2020-05-21 03:04:21.400893", + "modified": "2021-02-08 13:13:15.116833", "modified_by": "Administrator", "module": "Healthcare", "name": "Patient Appointment", diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py index e685b20a8c8..1f76cd624cd 100755 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py @@ -18,6 +18,7 @@ from erpnext.healthcare.utils import check_fee_validity, get_service_item_and_pr class PatientAppointment(Document): def validate(self): self.validate_overlaps() + self.validate_service_unit() self.set_appointment_datetime() self.validate_customer_created() self.set_status() @@ -25,6 +26,7 @@ class PatientAppointment(Document): def after_insert(self): self.update_prescription_details() + self.set_payment_details() invoice_appointment(self) self.update_fee_validity() send_confirmation_msg(self) @@ -63,19 +65,39 @@ class PatientAppointment(Document): if overlaps: overlapping_details = _('Appointment overlaps with ') - overlapping_details += "{0}
    ".format(overlaps[0][0]) + overlapping_details += "{0}
    ".format(overlaps[0][0]) overlapping_details += _('{0} has appointment scheduled with {1} at {2} having {3} minute(s) duration.').format( overlaps[0][1], overlaps[0][2], overlaps[0][3], overlaps[0][4]) frappe.throw(overlapping_details, title=_('Appointments Overlapping')) + def validate_service_unit(self): + if self.inpatient_record and self.service_unit: + from erpnext.healthcare.doctype.inpatient_medication_entry.inpatient_medication_entry import get_current_healthcare_service_unit + + is_inpatient_occupancy_unit = frappe.db.get_value('Healthcare Service Unit', self.service_unit, + 'inpatient_occupancy') + service_unit = get_current_healthcare_service_unit(self.inpatient_record) + if is_inpatient_occupancy_unit and service_unit != self.service_unit: + msg = _('Patient {0} is not admitted in the service unit {1}').format(frappe.bold(self.patient), frappe.bold(self.service_unit)) + '
    ' + msg += _('Appointment for service units with Inpatient Occupancy can only be created against the unit where patient has been admitted.') + frappe.throw(msg, title=_('Invalid Healthcare Service Unit')) + + def set_appointment_datetime(self): self.appointment_datetime = "%s %s" % (self.appointment_date, self.appointment_time or "00:00:00") + def set_payment_details(self): + if frappe.db.get_single_value('Healthcare Settings', 'automate_appointment_invoicing'): + details = get_service_item_and_practitioner_charge(self) + self.db_set('billing_item', details.get('service_item')) + if not self.paid_amount: + self.db_set('paid_amount', details.get('practitioner_charge')) + def validate_customer_created(self): if frappe.db.get_single_value('Healthcare Settings', 'automate_appointment_invoicing'): if not frappe.db.get_value('Patient', self.patient, 'customer'): msg = _("Please set a Customer linked to the Patient") - msg += " {0}".format(self.patient) + msg += " {0}".format(self.patient) frappe.throw(msg, title=_('Customer Not Found')) def update_prescription_details(self): @@ -91,6 +113,17 @@ class PatientAppointment(Document): if fee_validity: frappe.msgprint(_('{0} has fee validity till {1}').format(self.patient, fee_validity.valid_till)) + def get_therapy_types(self): + if not self.therapy_plan: + return + + therapy_types = [] + doc = frappe.get_doc('Therapy Plan', self.therapy_plan) + for entry in doc.therapy_plan_details: + therapy_types.append(entry.therapy_type) + + return therapy_types + @frappe.whitelist() def check_payment_fields_reqd(patient): @@ -123,31 +156,37 @@ def invoice_appointment(appointment_doc): fee_validity = None if automate_invoicing and not appointment_invoiced and not fee_validity: - sales_invoice = frappe.new_doc('Sales Invoice') - sales_invoice.patient = appointment_doc.patient - sales_invoice.customer = frappe.get_value('Patient', appointment_doc.patient, 'customer') - sales_invoice.appointment = appointment_doc.name - sales_invoice.due_date = getdate() - sales_invoice.company = appointment_doc.company - sales_invoice.debit_to = get_receivable_account(appointment_doc.company) + create_sales_invoice(appointment_doc) - item = sales_invoice.append('items', {}) - item = get_appointment_item(appointment_doc, item) - # Add payments if payment details are supplied else proceed to create invoice as Unpaid - if appointment_doc.mode_of_payment and appointment_doc.paid_amount: - sales_invoice.is_pos = 1 - payment = sales_invoice.append('payments', {}) - payment.mode_of_payment = appointment_doc.mode_of_payment - payment.amount = appointment_doc.paid_amount +def create_sales_invoice(appointment_doc): + sales_invoice = frappe.new_doc('Sales Invoice') + sales_invoice.patient = appointment_doc.patient + sales_invoice.customer = frappe.get_value('Patient', appointment_doc.patient, 'customer') + sales_invoice.appointment = appointment_doc.name + sales_invoice.due_date = getdate() + sales_invoice.company = appointment_doc.company + sales_invoice.debit_to = get_receivable_account(appointment_doc.company) - sales_invoice.set_missing_values(for_validate=True) - sales_invoice.flags.ignore_mandatory = True - sales_invoice.save(ignore_permissions=True) - sales_invoice.submit() - frappe.msgprint(_('Sales Invoice {0} created'.format(sales_invoice.name)), alert=True) - frappe.db.set_value('Patient Appointment', appointment_doc.name, 'invoiced', 1) - frappe.db.set_value('Patient Appointment', appointment_doc.name, 'ref_sales_invoice', sales_invoice.name) + item = sales_invoice.append('items', {}) + item = get_appointment_item(appointment_doc, item) + + # Add payments if payment details are supplied else proceed to create invoice as Unpaid + if appointment_doc.mode_of_payment and appointment_doc.paid_amount: + sales_invoice.is_pos = 1 + payment = sales_invoice.append('payments', {}) + payment.mode_of_payment = appointment_doc.mode_of_payment + payment.amount = appointment_doc.paid_amount + + sales_invoice.set_missing_values(for_validate=True) + sales_invoice.flags.ignore_mandatory = True + sales_invoice.save(ignore_permissions=True) + sales_invoice.submit() + frappe.msgprint(_('Sales Invoice {0} created').format(sales_invoice.name), alert=True) + frappe.db.set_value('Patient Appointment', appointment_doc.name, { + 'invoiced': 1, + 'ref_sales_invoice': sales_invoice.name + }) def check_is_new_patient(patient, name=None): @@ -162,13 +201,14 @@ def check_is_new_patient(patient, name=None): def get_appointment_item(appointment_doc, item): - service_item, practitioner_charge = get_service_item_and_practitioner_charge(appointment_doc) - item.item_code = service_item + details = get_service_item_and_practitioner_charge(appointment_doc) + charge = appointment_doc.paid_amount or details.get('practitioner_charge') + item.item_code = details.get('service_item') item.description = _('Consulting Charges: {0}').format(appointment_doc.practitioner) item.income_account = get_income_account(appointment_doc.practitioner, appointment_doc.company) item.cost_center = frappe.get_cached_value('Company', appointment_doc.company, 'cost_center') - item.rate = practitioner_charge - item.amount = practitioner_charge + item.rate = charge + item.amount = charge item.qty = 1 item.reference_dt = 'Patient Appointment' item.reference_dn = appointment_doc.name diff --git a/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py index eeed157291e..2bb8a53c454 100644 --- a/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py +++ b/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py @@ -5,14 +5,16 @@ from __future__ import unicode_literals import unittest import frappe from erpnext.healthcare.doctype.patient_appointment.patient_appointment import update_status, make_encounter -from frappe.utils import nowdate, add_days +from frappe.utils import nowdate, add_days, now_datetime from frappe.utils.make_random import get_random +from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile class TestPatientAppointment(unittest.TestCase): def setUp(self): frappe.db.sql("""delete from `tabPatient Appointment`""") frappe.db.sql("""delete from `tabFee Validity`""") frappe.db.sql("""delete from `tabPatient Encounter`""") + make_pos_profile() def test_status(self): patient, medical_department, practitioner = create_healthcare_docs() @@ -21,14 +23,17 @@ class TestPatientAppointment(unittest.TestCase): self.assertEquals(appointment.status, 'Open') appointment = create_appointment(patient, practitioner, add_days(nowdate(), 2)) self.assertEquals(appointment.status, 'Scheduled') - create_encounter(appointment) + encounter = create_encounter(appointment) self.assertEquals(frappe.db.get_value('Patient Appointment', appointment.name, 'status'), 'Closed') + encounter.cancel() + self.assertEquals(frappe.db.get_value('Patient Appointment', appointment.name, 'status'), 'Open') def test_start_encounter(self): patient, medical_department, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) appointment = create_appointment(patient, practitioner, add_days(nowdate(), 4), invoice = 1) - self.assertEqual(frappe.db.get_value('Patient Appointment', appointment.name, 'invoiced'), 1) + appointment.reload() + self.assertEqual(appointment.invoiced, 1) encounter = make_encounter(appointment.name) self.assertTrue(encounter) self.assertEqual(encounter.company, appointment.company) @@ -37,7 +42,7 @@ class TestPatientAppointment(unittest.TestCase): # invoiced flag mapped from appointment self.assertEqual(encounter.invoiced, frappe.db.get_value('Patient Appointment', appointment.name, 'invoiced')) - def test_invoicing(self): + def test_auto_invoicing(self): patient, medical_department, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 0) @@ -53,6 +58,50 @@ class TestPatientAppointment(unittest.TestCase): self.assertEqual(frappe.db.get_value('Sales Invoice', sales_invoice_name, 'patient'), appointment.patient) self.assertEqual(frappe.db.get_value('Sales Invoice', sales_invoice_name, 'paid_amount'), appointment.paid_amount) + def test_auto_invoicing_based_on_department(self): + patient, medical_department, practitioner = create_healthcare_docs() + frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) + frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) + appointment_type = create_appointment_type() + + appointment = create_appointment(patient, practitioner, add_days(nowdate(), 2), + invoice=1, appointment_type=appointment_type.name, department='_Test Medical Department') + appointment.reload() + + self.assertEqual(appointment.invoiced, 1) + self.assertEqual(appointment.billing_item, 'HLC-SI-001') + self.assertEqual(appointment.paid_amount, 200) + + sales_invoice_name = frappe.db.get_value('Sales Invoice Item', {'reference_dn': appointment.name}, 'parent') + self.assertTrue(sales_invoice_name) + self.assertEqual(frappe.db.get_value('Sales Invoice', sales_invoice_name, 'paid_amount'), appointment.paid_amount) + + def test_auto_invoicing_according_to_appointment_type_charge(self): + patient, medical_department, practitioner = create_healthcare_docs() + frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) + frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) + + item = create_healthcare_service_items() + items = [{ + 'op_consulting_charge_item': item, + 'op_consulting_charge': 300 + }] + appointment_type = create_appointment_type(args={ + 'name': 'Generic Appointment Type charge', + 'items': items + }) + + appointment = create_appointment(patient, practitioner, add_days(nowdate(), 2), + invoice=1, appointment_type=appointment_type.name) + appointment.reload() + + self.assertEqual(appointment.invoiced, 1) + self.assertEqual(appointment.billing_item, item) + self.assertEqual(appointment.paid_amount, 300) + + sales_invoice_name = frappe.db.get_value('Sales Invoice Item', {'reference_dn': appointment.name}, 'parent') + self.assertTrue(sales_invoice_name) + def test_appointment_cancel(self): patient, medical_department, practitioner = create_healthcare_docs() frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 1) @@ -74,6 +123,59 @@ class TestPatientAppointment(unittest.TestCase): sales_invoice_name = frappe.db.get_value('Sales Invoice Item', {'reference_dn': appointment.name}, 'parent') self.assertEqual(frappe.db.get_value('Sales Invoice', sales_invoice_name, 'status'), 'Cancelled') + def test_appointment_booking_for_admission_service_unit(self): + from erpnext.healthcare.doctype.inpatient_record.inpatient_record import admit_patient, discharge_patient, schedule_discharge + from erpnext.healthcare.doctype.inpatient_record.test_inpatient_record import \ + create_inpatient, get_healthcare_service_unit, mark_invoiced_inpatient_occupancy + + frappe.db.sql("""delete from `tabInpatient Record`""") + patient, medical_department, practitioner = create_healthcare_docs() + patient = create_patient() + # Schedule Admission + ip_record = create_inpatient(patient) + ip_record.expected_length_of_stay = 0 + ip_record.save(ignore_permissions = True) + + # Admit + service_unit = get_healthcare_service_unit('Test Service Unit Ip Occupancy') + admit_patient(ip_record, service_unit, now_datetime()) + + appointment = create_appointment(patient, practitioner, nowdate(), service_unit=service_unit) + self.assertEqual(appointment.service_unit, service_unit) + + # Discharge + schedule_discharge(frappe.as_json({'patient': patient})) + ip_record1 = frappe.get_doc("Inpatient Record", ip_record.name) + mark_invoiced_inpatient_occupancy(ip_record1) + discharge_patient(ip_record1) + + def test_invalid_healthcare_service_unit_validation(self): + from erpnext.healthcare.doctype.inpatient_record.inpatient_record import admit_patient, discharge_patient, schedule_discharge + from erpnext.healthcare.doctype.inpatient_record.test_inpatient_record import \ + create_inpatient, get_healthcare_service_unit, mark_invoiced_inpatient_occupancy + + frappe.db.sql("""delete from `tabInpatient Record`""") + patient, medical_department, practitioner = create_healthcare_docs() + patient = create_patient() + # Schedule Admission + ip_record = create_inpatient(patient) + ip_record.expected_length_of_stay = 0 + ip_record.save(ignore_permissions = True) + + # Admit + service_unit = get_healthcare_service_unit('Test Service Unit Ip Occupancy') + admit_patient(ip_record, service_unit, now_datetime()) + + appointment_service_unit = get_healthcare_service_unit('Test Service Unit Ip Occupancy for Appointment') + appointment = create_appointment(patient, practitioner, nowdate(), service_unit=appointment_service_unit, save=0) + self.assertRaises(frappe.exceptions.ValidationError, appointment.save) + + # Discharge + schedule_discharge(frappe.as_json({'patient': patient})) + ip_record1 = frappe.get_doc("Inpatient Record", ip_record.name) + mark_invoiced_inpatient_occupancy(ip_record1) + discharge_patient(ip_record1) + def create_healthcare_docs(): patient = create_patient() @@ -121,23 +223,28 @@ def create_encounter(appointment): encounter.submit() return encounter -def create_appointment(patient, practitioner, appointment_date, invoice=0, procedure_template=0): +def create_appointment(patient, practitioner, appointment_date, invoice=0, procedure_template=0, + service_unit=None, appointment_type=None, save=1, department=None): item = create_healthcare_service_items() frappe.db.set_value('Healthcare Settings', None, 'inpatient_visit_charge_item', item) frappe.db.set_value('Healthcare Settings', None, 'op_consulting_charge_item', item) appointment = frappe.new_doc('Patient Appointment') appointment.patient = patient appointment.practitioner = practitioner - appointment.department = '_Test Medical Department' + appointment.department = department or '_Test Medical Department' appointment.appointment_date = appointment_date appointment.company = '_Test Company' appointment.duration = 15 + if service_unit: + appointment.service_unit = service_unit if invoice: appointment.mode_of_payment = 'Cash' - appointment.paid_amount = 500 + if appointment_type: + appointment.appointment_type = appointment_type if procedure_template: appointment.procedure_template = create_clinical_procedure_template().get('name') - appointment.save(ignore_permissions=True) + if save: + appointment.save(ignore_permissions=True) return appointment def create_healthcare_service_items(): @@ -148,6 +255,7 @@ def create_healthcare_service_items(): item.item_name = 'Consulting Charges' item.item_group = 'Services' item.is_stock_item = 0 + item.stock_uom = 'Nos' item.save() return item.name @@ -162,4 +270,29 @@ def create_clinical_procedure_template(): template.description = 'Knee Surgery and Rehab' template.rate = 50000 template.save() - return template \ No newline at end of file + return template + +def create_appointment_type(args=None): + if not args: + args = frappe.local.form_dict + + name = args.get('name') or 'Test Appointment Type wise Charge' + + if frappe.db.exists('Appointment Type', name): + return frappe.get_doc('Appointment Type', name) + + else: + item = create_healthcare_service_items() + items = [{ + 'medical_department': '_Test Medical Department', + 'op_consulting_charge_item': item, + 'op_consulting_charge': 200 + }] + return frappe.get_doc({ + 'doctype': 'Appointment Type', + 'appointment_type': args.get('name') or 'Test Appointment Type wise Charge', + 'default_duration': args.get('default_duration') or 20, + 'color': args.get('color') or '#7575ff', + 'price_list': args.get('price_list') or frappe.db.get_value("Price List", {"selling": 1}), + 'items': args.get('items') or items + }).insert() \ No newline at end of file diff --git a/erpnext/healthcare/doctype/patient_encounter/patient_encounter.js b/erpnext/healthcare/doctype/patient_encounter/patient_encounter.js index 6353d19ef16..aaeaa692e63 100644 --- a/erpnext/healthcare/doctype/patient_encounter/patient_encounter.js +++ b/erpnext/healthcare/doctype/patient_encounter/patient_encounter.js @@ -58,6 +58,14 @@ frappe.ui.form.on('Patient Encounter', { create_procedure(frm); },'Create'); + if (frm.doc.drug_prescription && frm.doc.inpatient_record && frm.doc.inpatient_status === "Admitted") { + frm.add_custom_button(__('Inpatient Medication Order'), function() { + frappe.model.open_mapped_doc({ + method: 'erpnext.healthcare.doctype.patient_encounter.patient_encounter.make_ip_medication_order', + frm: frm + }); + }, 'Create'); + } } frm.set_query('patient', function() { @@ -350,5 +358,5 @@ let calculate_age = function(birth) { let age = new Date(); age.setTime(ageMS); let years = age.getFullYear() - 1970; - return years + ' Year(s) ' + age.getMonth() + ' Month(s) ' + age.getDate() + ' Day(s)'; + return `${years} ${__('Years(s)')} ${age.getMonth()} ${__('Month(s)')} ${age.getDate()} ${__('Day(s)')}`; }; diff --git a/erpnext/healthcare/doctype/patient_encounter/patient_encounter.json b/erpnext/healthcare/doctype/patient_encounter/patient_encounter.json index 15675f4673f..b646ff9ebe6 100644 --- a/erpnext/healthcare/doctype/patient_encounter/patient_encounter.json +++ b/erpnext/healthcare/doctype/patient_encounter/patient_encounter.json @@ -210,7 +210,7 @@ { "fieldname": "drug_prescription", "fieldtype": "Table", - "label": "Items", + "label": "Drug Prescription", "options": "Drug Prescription" }, { @@ -328,7 +328,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2020-05-16 21:00:08.644531", + "modified": "2020-11-30 10:39:00.783119", "modified_by": "Administrator", "module": "Healthcare", "name": "Patient Encounter", diff --git a/erpnext/healthcare/doctype/patient_encounter/patient_encounter.py b/erpnext/healthcare/doctype/patient_encounter/patient_encounter.py index 262fc4650af..cc2141790f7 100644 --- a/erpnext/healthcare/doctype/patient_encounter/patient_encounter.py +++ b/erpnext/healthcare/doctype/patient_encounter/patient_encounter.py @@ -6,8 +6,9 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr +from frappe.utils import cstr, getdate, add_days from frappe import _ +from frappe.model.mapper import get_mapped_doc class PatientEncounter(Document): def validate(self): @@ -16,26 +17,69 @@ class PatientEncounter(Document): def on_update(self): if self.appointment: frappe.db.set_value('Patient Appointment', self.appointment, 'status', 'Closed') - update_encounter_medical_record(self) - - def after_insert(self): - insert_encounter_to_medical_record(self) def on_submit(self): - update_encounter_medical_record(self) + if self.therapies: + create_therapy_plan(self) def on_cancel(self): if self.appointment: frappe.db.set_value('Patient Appointment', self.appointment, 'status', 'Open') - delete_medical_record(self) - def on_submit(self): - create_therapy_plan(self) + if self.inpatient_record and self.drug_prescription: + delete_ip_medication_order(self) def set_title(self): self.title = _('{0} with {1}').format(self.patient_name or self.patient, self.practitioner_name or self.practitioner)[:100] +@frappe.whitelist() +def make_ip_medication_order(source_name, target_doc=None): + def set_missing_values(source, target): + target.start_date = source.encounter_date + for entry in source.drug_prescription: + if entry.drug_code: + dosage = frappe.get_doc('Prescription Dosage', entry.dosage) + dates = get_prescription_dates(entry.period, target.start_date) + for date in dates: + for dose in dosage.dosage_strength: + order = target.append('medication_orders') + order.drug = entry.drug_code + order.drug_name = entry.drug_name + order.dosage = dose.strength + order.instructions = entry.comment + order.dosage_form = entry.dosage_form + order.date = date + order.time = dose.strength_time + target.end_date = dates[-1] + + doc = get_mapped_doc('Patient Encounter', source_name, { + 'Patient Encounter': { + 'doctype': 'Inpatient Medication Order', + 'field_map': { + 'name': 'patient_encounter', + 'patient': 'patient', + 'patient_name': 'patient_name', + 'patient_age': 'patient_age', + 'inpatient_record': 'inpatient_record', + 'practitioner': 'practitioner', + 'start_date': 'encounter_date' + }, + } + }, target_doc, set_missing_values) + + return doc + + +def get_prescription_dates(period, start_date): + prescription_duration = frappe.get_doc('Prescription Duration', period) + days = prescription_duration.get_days() + dates = [start_date] + for i in range(1, days): + dates.append(add_days(getdate(start_date), i)) + return dates + + def create_therapy_plan(encounter): if len(encounter.therapies): doc = frappe.new_doc('Therapy Plan') @@ -51,51 +95,8 @@ def create_therapy_plan(encounter): encounter.db_set('therapy_plan', doc.name) frappe.msgprint(_('Therapy Plan {0} created successfully.').format(frappe.bold(doc.name)), alert=True) -def insert_encounter_to_medical_record(doc): - subject = set_subject_field(doc) - medical_record = frappe.new_doc('Patient Medical Record') - medical_record.patient = doc.patient - medical_record.subject = subject - medical_record.status = 'Open' - medical_record.communication_date = doc.encounter_date - medical_record.reference_doctype = 'Patient Encounter' - medical_record.reference_name = doc.name - medical_record.reference_owner = doc.owner - medical_record.save(ignore_permissions=True) -def update_encounter_medical_record(encounter): - medical_record_id = frappe.db.exists('Patient Medical Record', {'reference_name': encounter.name}) - - if medical_record_id and medical_record_id[0][0]: - subject = set_subject_field(encounter) - frappe.db.set_value('Patient Medical Record', medical_record_id[0][0], 'subject', subject) - else: - insert_encounter_to_medical_record(encounter) - -def delete_medical_record(encounter): - frappe.delete_doc_if_exists('Patient Medical Record', 'reference_name', encounter.name) - -def set_subject_field(encounter): - subject = frappe.bold(_('Healthcare Practitioner: ')) + encounter.practitioner + '
    ' - if encounter.symptoms: - subject += frappe.bold(_('Symptoms: ')) + '
    ' - for entry in encounter.symptoms: - subject += cstr(entry.complaint) + '
    ' - else: - subject += frappe.bold(_('No Symptoms')) + '
    ' - - if encounter.diagnosis: - subject += frappe.bold(_('Diagnosis: ')) + '
    ' - for entry in encounter.diagnosis: - subject += cstr(entry.diagnosis) + '
    ' - else: - subject += frappe.bold(_('No Diagnosis')) + '
    ' - - if encounter.drug_prescription: - subject += '
    ' + _('Drug(s) Prescribed.') - if encounter.lab_test_prescription: - subject += '
    ' + _('Test(s) Prescribed.') - if encounter.procedure_prescription: - subject += '
    ' + _('Procedure(s) Prescribed.') - - return subject +def delete_ip_medication_order(encounter): + record = frappe.db.exists('Inpatient Medication Order', {'patient_encounter': encounter.name}) + if record: + frappe.delete_doc('Inpatient Medication Order', record, force=1) \ No newline at end of file diff --git a/erpnext/healthcare/doctype/patient_encounter/patient_encounter_dashboard.py b/erpnext/healthcare/doctype/patient_encounter/patient_encounter_dashboard.py index b08b172bbac..39e54f5b35c 100644 --- a/erpnext/healthcare/doctype/patient_encounter/patient_encounter_dashboard.py +++ b/erpnext/healthcare/doctype/patient_encounter/patient_encounter_dashboard.py @@ -5,12 +5,18 @@ def get_data(): return { 'fieldname': 'encounter', 'non_standard_fieldnames': { - 'Patient Medical Record': 'reference_name' + 'Patient Medical Record': 'reference_name', + 'Inpatient Medication Order': 'patient_encounter' }, 'transactions': [ { 'label': _('Records'), 'items': ['Vital Signs', 'Patient Medical Record'] }, - ] + { + 'label': _('Orders'), + 'items': ['Inpatient Medication Order'] + } + ], + 'disable_create_buttons': ['Inpatient Medication Order'] } diff --git a/erpnext/healthcare/doctype/patient_history_custom_document_type/__init__.py b/erpnext/healthcare/doctype/patient_history_custom_document_type/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/doctype/patient_history_custom_document_type/patient_history_custom_document_type.json b/erpnext/healthcare/doctype/patient_history_custom_document_type/patient_history_custom_document_type.json new file mode 100644 index 00000000000..3025c7b06d7 --- /dev/null +++ b/erpnext/healthcare/doctype/patient_history_custom_document_type/patient_history_custom_document_type.json @@ -0,0 +1,55 @@ +{ + "actions": [], + "creation": "2020-11-25 13:40:23.054469", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "date_fieldname", + "add_edit_fields", + "selected_fields" + ], + "fields": [ + { + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "selected_fields", + "fieldtype": "Code", + "label": "Selected Fields", + "read_only": 1 + }, + { + "fieldname": "add_edit_fields", + "fieldtype": "Button", + "in_list_view": 1, + "label": "Add / Edit Fields" + }, + { + "fieldname": "date_fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Date Fieldname", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-11-30 13:54:37.474671", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Patient History Custom Document Type", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/patient_history_custom_document_type/patient_history_custom_document_type.py b/erpnext/healthcare/doctype/patient_history_custom_document_type/patient_history_custom_document_type.py new file mode 100644 index 00000000000..f0a1f929f45 --- /dev/null +++ b/erpnext/healthcare/doctype/patient_history_custom_document_type/patient_history_custom_document_type.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class PatientHistoryCustomDocumentType(Document): + pass diff --git a/erpnext/healthcare/doctype/patient_history_settings/__init__.py b/erpnext/healthcare/doctype/patient_history_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.js b/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.js new file mode 100644 index 00000000000..453da6a12bf --- /dev/null +++ b/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.js @@ -0,0 +1,133 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Patient History Settings', { + refresh: function(frm) { + frm.set_query('document_type', 'custom_doctypes', () => { + return { + filters: { + custom: 1, + is_submittable: 1, + module: 'Healthcare', + } + }; + }); + }, + + field_selector: function(frm, doc, standard=1) { + let document_fields = []; + if (doc.selected_fields) + document_fields = (JSON.parse(doc.selected_fields)).map(f => f.fieldname); + + frm.call({ + method: 'get_doctype_fields', + doc: frm.doc, + args: { + document_type: doc.document_type, + fields: document_fields + }, + freeze: true, + callback: function(r) { + if (r.message) { + let doctype = 'Patient History Custom Document Type'; + if (standard) + doctype = 'Patient History Standard Document Type'; + + frm.events.show_field_selector_dialog(frm, doc, doctype, r.message); + } + } + }); + }, + + show_field_selector_dialog: function(frm, doc, doctype, doc_fields) { + let d = new frappe.ui.Dialog({ + title: __('{0} Fields', [__(doc.document_type)]), + fields: [ + { + label: __('Select Fields'), + fieldtype: 'MultiCheck', + fieldname: 'fields', + options: doc_fields, + columns: 2 + } + ] + }); + + d.$body.prepend(` + ` + ); + + frappe.utils.setup_search(d.$body, '.unit-checkbox', '.label-area'); + + d.set_primary_action(__('Save'), () => { + let values = d.get_values().fields; + + let selected_fields = []; + + frappe.model.with_doctype(doc.document_type, function() { + for (let idx in values) { + let value = values[idx]; + + let field = frappe.get_meta(doc.document_type).fields.filter((df) => df.fieldname == value)[0]; + if (field) { + selected_fields.push({ + label: field.label, + fieldname: field.fieldname, + fieldtype: field.fieldtype + }); + } + } + + d.refresh(); + frappe.model.set_value(doctype, doc.name, 'selected_fields', JSON.stringify(selected_fields)); + }); + + d.hide(); + }); + + d.show(); + }, + + get_date_field_for_dt: function(frm, row) { + frm.call({ + method: 'get_date_field_for_dt', + doc: frm.doc, + args: { + document_type: row.document_type + }, + callback: function(data) { + if (data.message) { + frappe.model.set_value('Patient History Custom Document Type', + row.name, 'date_fieldname', data.message); + } + } + }); + } +}); + +frappe.ui.form.on('Patient History Custom Document Type', { + document_type: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.document_type) { + frm.events.get_date_field_for_dt(frm, row); + } + }, + + add_edit_fields: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.document_type) { + frm.events.field_selector(frm, row, 0); + } + } +}); + +frappe.ui.form.on('Patient History Standard Document Type', { + add_edit_fields: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.document_type) { + frm.events.field_selector(frm, row); + } + } +}); diff --git a/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.json b/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.json new file mode 100644 index 00000000000..143e2c91eb5 --- /dev/null +++ b/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.json @@ -0,0 +1,55 @@ +{ + "actions": [], + "creation": "2020-11-25 13:41:37.675518", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "standard_doctypes", + "section_break_2", + "custom_doctypes" + ], + "fields": [ + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "fieldname": "custom_doctypes", + "fieldtype": "Table", + "label": "Custom Document Types", + "options": "Patient History Custom Document Type" + }, + { + "fieldname": "standard_doctypes", + "fieldtype": "Table", + "label": "Standard Document Types", + "options": "Patient History Standard Document Type", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2020-11-25 13:43:38.511771", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Patient History Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.py b/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.py new file mode 100644 index 00000000000..2e8c994c3d9 --- /dev/null +++ b/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import json +from frappe import _ +from frappe.utils import cstr, cint +from frappe.model.document import Document +from erpnext.healthcare.page.patient_history.patient_history import get_patient_history_doctypes + +class PatientHistorySettings(Document): + def validate(self): + self.validate_submittable_doctypes() + self.validate_date_fieldnames() + + def validate_submittable_doctypes(self): + for entry in self.custom_doctypes: + if not cint(frappe.db.get_value('DocType', entry.document_type, 'is_submittable')): + msg = _('Row #{0}: Document Type {1} is not submittable. ').format( + entry.idx, frappe.bold(entry.document_type)) + msg += _('Patient Medical Record can only be created for submittable document types.') + frappe.throw(msg) + + def validate_date_fieldnames(self): + for entry in self.custom_doctypes: + field = frappe.get_meta(entry.document_type).get_field(entry.date_fieldname) + if not field: + frappe.throw(_('Row #{0}: No such Field named {1} found in the Document Type {2}.').format( + entry.idx, frappe.bold(entry.date_fieldname), frappe.bold(entry.document_type))) + + if field.fieldtype not in ['Date', 'Datetime']: + frappe.throw(_('Row #{0}: Field {1} in Document Type {2} is not a Date / Datetime field.').format( + entry.idx, frappe.bold(entry.date_fieldname), frappe.bold(entry.document_type))) + + def get_doctype_fields(self, document_type, fields): + multicheck_fields = [] + doc_fields = frappe.get_meta(document_type).fields + + for field in doc_fields: + if field.fieldtype not in frappe.model.no_value_fields or \ + field.fieldtype in frappe.model.table_fields and not field.hidden: + multicheck_fields.append({ + 'label': field.label, + 'value': field.fieldname, + 'checked': 1 if field.fieldname in fields else 0 + }) + + return multicheck_fields + + def get_date_field_for_dt(self, document_type): + meta = frappe.get_meta(document_type) + date_fields = meta.get('fields', { + 'fieldtype': ['in', ['Date', 'Datetime']] + }) + + if date_fields: + return date_fields[0].get('fieldname') + +def create_medical_record(doc, method=None): + medical_record_required = validate_medical_record_required(doc) + if not medical_record_required: + return + + if frappe.db.exists('Patient Medical Record', { 'reference_name': doc.name }): + return + + subject = set_subject_field(doc) + date_field = get_date_field(doc.doctype) + medical_record = frappe.new_doc('Patient Medical Record') + medical_record.patient = doc.patient + medical_record.subject = subject + medical_record.status = 'Open' + medical_record.communication_date = doc.get(date_field) + medical_record.reference_doctype = doc.doctype + medical_record.reference_name = doc.name + medical_record.reference_owner = doc.owner + medical_record.save(ignore_permissions=True) + + +def update_medical_record(doc, method=None): + medical_record_required = validate_medical_record_required(doc) + if not medical_record_required: + return + + medical_record_id = frappe.db.exists('Patient Medical Record', { 'reference_name': doc.name }) + + if medical_record_id: + subject = set_subject_field(doc) + frappe.db.set_value('Patient Medical Record', medical_record_id[0][0], 'subject', subject) + else: + create_medical_record(doc) + + +def delete_medical_record(doc, method=None): + medical_record_required = validate_medical_record_required(doc) + if not medical_record_required: + return + + record = frappe.db.exists('Patient Medical Record', { 'reference_name': doc.name }) + if record: + frappe.delete_doc('Patient Medical Record', record, force=1) + + +def set_subject_field(doc): + from frappe.utils.formatters import format_value + + meta = frappe.get_meta(doc.doctype) + subject = '' + patient_history_fields = get_patient_history_fields(doc) + + for entry in patient_history_fields: + fieldname = entry.get('fieldname') + if entry.get('fieldtype') == 'Table' and doc.get(fieldname): + formatted_value = get_formatted_value_for_table_field(doc.get(fieldname), meta.get_field(fieldname)) + subject += frappe.bold(_(entry.get('label')) + ': ') + '
    ' + cstr(formatted_value) + '
    ' + + else: + if doc.get(fieldname): + formatted_value = format_value(doc.get(fieldname), meta.get_field(fieldname), doc) + subject += frappe.bold(_(entry.get('label')) + ': ') + cstr(formatted_value) + '
    ' + + return subject + + +def get_date_field(doctype): + dt = get_patient_history_config_dt(doctype) + + return frappe.db.get_value(dt, { 'document_type': doctype }, 'date_fieldname') + + +def get_patient_history_fields(doc): + dt = get_patient_history_config_dt(doc.doctype) + patient_history_fields = frappe.db.get_value(dt, { 'document_type': doc.doctype }, 'selected_fields') + + if patient_history_fields: + return json.loads(patient_history_fields) + + +def get_formatted_value_for_table_field(items, df): + child_meta = frappe.get_meta(df.options) + + table_head = '' + table_row = '' + html = '' + create_head = True + for item in items: + table_row += '' + for cdf in child_meta.fields: + if cdf.in_list_view: + if create_head: + table_head += '' + cdf.label + '' + if item.get(cdf.fieldname): + table_row += '' + str(item.get(cdf.fieldname)) + '' + else: + table_row += '' + create_head = False + table_row += '' + + html += "" + table_head + table_row + "
    " + + return html + + +def get_patient_history_config_dt(doctype): + if frappe.db.get_value('DocType', doctype, 'custom'): + return 'Patient History Custom Document Type' + else: + return 'Patient History Standard Document Type' + + +def validate_medical_record_required(doc): + if frappe.flags.in_patch or frappe.flags.in_install or frappe.flags.in_setup_wizard \ + or get_module(doc) != 'Healthcare': + return False + + if doc.doctype not in get_patient_history_doctypes(): + return False + + return True + +def get_module(doc): + module = doc.meta.module + if not module: + module = frappe.db.get_value('DocType', doc.doctype, 'module') + + return module \ No newline at end of file diff --git a/erpnext/healthcare/doctype/patient_history_settings/test_patient_history_settings.py b/erpnext/healthcare/doctype/patient_history_settings/test_patient_history_settings.py new file mode 100644 index 00000000000..c93b788aed7 --- /dev/null +++ b/erpnext/healthcare/doctype/patient_history_settings/test_patient_history_settings.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +import json +from frappe.utils import getdate +from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_patient + +class TestPatientHistorySettings(unittest.TestCase): + def setUp(self): + dt = create_custom_doctype() + settings = frappe.get_single("Patient History Settings") + settings.append("custom_doctypes", { + "document_type": dt.name, + "date_fieldname": "date", + "selected_fields": json.dumps([{ + "label": "Date", + "fieldname": "date", + "fieldtype": "Date" + }, + { + "label": "Rating", + "fieldname": "rating", + "fieldtype": "Rating" + }, + { + "label": "Feedback", + "fieldname": "feedback", + "fieldtype": "Small Text" + }]) + }) + settings.save() + + def test_custom_doctype_medical_record(self): + # tests for medical record creation of standard doctypes in test_patient_medical_record.py + patient = create_patient() + doc = create_doc(patient) + + # check for medical record + medical_rec = frappe.db.exists("Patient Medical Record", {"status": "Open", "reference_name": doc.name}) + self.assertTrue(medical_rec) + + medical_rec = frappe.get_doc("Patient Medical Record", medical_rec) + expected_subject = "Date: {0}
    Rating: 3
    Feedback: Test Patient History Settings
    ".format( + frappe.utils.format_date(getdate())) + self.assertEqual(medical_rec.subject, expected_subject) + self.assertEqual(medical_rec.patient, patient) + self.assertEqual(medical_rec.communication_date, getdate()) + + +def create_custom_doctype(): + if not frappe.db.exists("DocType", "Test Patient Feedback"): + doc = frappe.get_doc({ + "doctype": "DocType", + "module": "Healthcare", + "custom": 1, + "is_submittable": 1, + "fields": [{ + "label": "Date", + "fieldname": "date", + "fieldtype": "Date" + }, + { + "label": "Patient", + "fieldname": "patient", + "fieldtype": "Link", + "options": "Patient" + }, + { + "label": "Rating", + "fieldname": "rating", + "fieldtype": "Rating" + }, + { + "label": "Feedback", + "fieldname": "feedback", + "fieldtype": "Small Text" + }], + "permissions": [{ + "role": "System Manager", + "read": 1 + }], + "name": "Test Patient Feedback", + }) + doc.insert() + return doc + else: + return frappe.get_doc("DocType", "Test Patient Feedback") + + +def create_doc(patient): + doc = frappe.get_doc({ + "doctype": "Test Patient Feedback", + "patient": patient, + "date": getdate(), + "rating": 3, + "feedback": "Test Patient History Settings" + }).insert() + doc.submit() + + return doc \ No newline at end of file diff --git a/erpnext/healthcare/doctype/patient_history_standard_document_type/__init__.py b/erpnext/healthcare/doctype/patient_history_standard_document_type/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/doctype/patient_history_standard_document_type/patient_history_standard_document_type.json b/erpnext/healthcare/doctype/patient_history_standard_document_type/patient_history_standard_document_type.json new file mode 100644 index 00000000000..b43099c4ea9 --- /dev/null +++ b/erpnext/healthcare/doctype/patient_history_standard_document_type/patient_history_standard_document_type.json @@ -0,0 +1,57 @@ +{ + "actions": [], + "creation": "2020-11-25 13:39:36.014814", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "date_fieldname", + "add_edit_fields", + "selected_fields" + ], + "fields": [ + { + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "selected_fields", + "fieldtype": "Code", + "label": "Selected Fields", + "read_only": 1 + }, + { + "fieldname": "add_edit_fields", + "fieldtype": "Button", + "in_list_view": 1, + "label": "Add / Edit Fields" + }, + { + "fieldname": "date_fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Date Fieldname", + "read_only": 1, + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-11-30 13:54:56.773325", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Patient History Standard Document Type", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/patient_history_standard_document_type/patient_history_standard_document_type.py b/erpnext/healthcare/doctype/patient_history_standard_document_type/patient_history_standard_document_type.py new file mode 100644 index 00000000000..2d94911855a --- /dev/null +++ b/erpnext/healthcare/doctype/patient_history_standard_document_type/patient_history_standard_document_type.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class PatientHistoryStandardDocumentType(Document): + pass diff --git a/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py b/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py index aa85a231132..c1d9872a019 100644 --- a/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py +++ b/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py @@ -6,16 +6,19 @@ import unittest import frappe from frappe.utils import nowdate from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_encounter, create_healthcare_docs, create_appointment +from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile class TestPatientMedicalRecord(unittest.TestCase): def setUp(self): frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) + make_pos_profile() def test_medical_record(self): patient, medical_department, practitioner = create_healthcare_docs() appointment = create_appointment(patient, practitioner, nowdate(), invoice=1) encounter = create_encounter(appointment) + # check for encounter medical_rec = frappe.db.exists('Patient Medical Record', {'status': 'Open', 'reference_name': encounter.name}) self.assertTrue(medical_rec) diff --git a/erpnext/healthcare/doctype/sample_collection/sample_collection.js b/erpnext/healthcare/doctype/sample_collection/sample_collection.js index 03903912358..ddf8285bc6d 100644 --- a/erpnext/healthcare/doctype/sample_collection/sample_collection.js +++ b/erpnext/healthcare/doctype/sample_collection/sample_collection.js @@ -36,5 +36,5 @@ var calculate_age = function(birth) { var age = new Date(); age.setTime(ageMS); var years = age.getFullYear() - 1970; - return years + ' Year(s) ' + age.getMonth() + ' Month(s) ' + age.getDate() + ' Day(s)'; + return `${years} ${__('Years(s)')} ${age.getMonth()} ${__('Month(s)')} ${age.getDate()} ${__('Day(s)')}`; }; diff --git a/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py b/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py index 526bb95b70e..7fb159d6b50 100644 --- a/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py +++ b/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py @@ -5,10 +5,10 @@ from __future__ import unicode_literals import frappe import unittest -from frappe.utils import getdate +from frappe.utils import getdate, flt, nowdate from erpnext.healthcare.doctype.therapy_type.test_therapy_type import create_therapy_type -from erpnext.healthcare.doctype.therapy_plan.therapy_plan import make_therapy_session -from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_healthcare_docs, create_patient +from erpnext.healthcare.doctype.therapy_plan.therapy_plan import make_therapy_session, make_sales_invoice +from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import create_healthcare_docs, create_patient, create_appointment class TestTherapyPlan(unittest.TestCase): def test_creation_on_encounter_submission(self): @@ -20,25 +20,54 @@ class TestTherapyPlan(unittest.TestCase): plan = create_therapy_plan() self.assertEquals(plan.status, 'Not Started') - session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab') + session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company') frappe.get_doc(session).submit() self.assertEquals(frappe.db.get_value('Therapy Plan', plan.name, 'status'), 'In Progress') - session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab') + session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company') frappe.get_doc(session).submit() self.assertEquals(frappe.db.get_value('Therapy Plan', plan.name, 'status'), 'Completed') + patient, medical_department, practitioner = create_healthcare_docs() + appointment = create_appointment(patient, practitioner, nowdate()) + session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company', appointment.name) + session = frappe.get_doc(session) + session.submit() + self.assertEquals(frappe.db.get_value('Patient Appointment', appointment.name, 'status'), 'Closed') + session.cancel() + self.assertEquals(frappe.db.get_value('Patient Appointment', appointment.name, 'status'), 'Open') -def create_therapy_plan(): + def test_therapy_plan_from_template(self): + patient = create_patient() + template = create_therapy_plan_template() + # check linked item + self.assertTrue(frappe.db.exists('Therapy Plan Template', {'linked_item': 'Complete Rehab'})) + + plan = create_therapy_plan(template) + # invoice + si = make_sales_invoice(plan.name, patient, '_Test Company', template) + si.save() + + therapy_plan_template_amt = frappe.db.get_value('Therapy Plan Template', template, 'total_amount') + self.assertEquals(si.items[0].amount, therapy_plan_template_amt) + + +def create_therapy_plan(template=None): patient = create_patient() therapy_type = create_therapy_type() plan = frappe.new_doc('Therapy Plan') plan.patient = patient plan.start_date = getdate() - plan.append('therapy_plan_details', { - 'therapy_type': therapy_type.name, - 'no_of_sessions': 2 - }) + + if template: + plan.therapy_plan_template = template + plan = plan.set_therapy_details_from_template() + else: + plan.append('therapy_plan_details', { + 'therapy_type': therapy_type.name, + 'no_of_sessions': 2 + }) + plan.save() return plan @@ -55,3 +84,22 @@ def create_encounter(patient, medical_department, practitioner): encounter.save() encounter.submit() return encounter + +def create_therapy_plan_template(): + template_name = frappe.db.exists('Therapy Plan Template', 'Complete Rehab') + if not template_name: + therapy_type = create_therapy_type() + template = frappe.new_doc('Therapy Plan Template') + template.plan_name = template.item_code = template.item_name = 'Complete Rehab' + template.item_group = 'Services' + rate = frappe.db.get_value('Therapy Type', therapy_type.name, 'rate') + template.append('therapy_types', { + 'therapy_type': therapy_type.name, + 'no_of_sessions': 2, + 'rate': rate, + 'amount': 2 * flt(rate) + }) + template.save() + template_name = template.name + + return template_name diff --git a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.js b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.js index dea0cfeb849..d1f72d625b8 100644 --- a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.js +++ b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.js @@ -13,49 +13,89 @@ frappe.ui.form.on('Therapy Plan', { refresh: function(frm) { if (!frm.doc.__islocal) { frm.trigger('show_progress_for_therapies'); + if (frm.doc.status != 'Completed') { + let therapy_types = (frm.doc.therapy_plan_details || []).map(function(d){ return d.therapy_type; }); + const fields = [{ + fieldtype: 'Link', + label: __('Therapy Type'), + fieldname: 'therapy_type', + options: 'Therapy Type', + reqd: 1, + get_query: function() { + return { + filters: { 'therapy_type': ['in', therapy_types]} + }; + } + }]; + + frm.add_custom_button(__('Therapy Session'), function() { + frappe.prompt(fields, data => { + frappe.call({ + method: 'erpnext.healthcare.doctype.therapy_plan.therapy_plan.make_therapy_session', + args: { + therapy_plan: frm.doc.name, + patient: frm.doc.patient, + therapy_type: data.therapy_type, + company: frm.doc.company + }, + freeze: true, + callback: function(r) { + if (r.message) { + frappe.model.sync(r.message); + frappe.set_route('Form', r.message.doctype, r.message.name); + } + } + }); + }, __('Select Therapy Type'), __('Create')); + }, __('Create')); + } + + if (frm.doc.therapy_plan_template && !frm.doc.invoiced) { + frm.add_custom_button(__('Sales Invoice'), function() { + frm.trigger('make_sales_invoice'); + }, __('Create')); + } } - if (!frm.doc.__islocal && frm.doc.status != 'Completed') { - let therapy_types = (frm.doc.therapy_plan_details || []).map(function(d){ return d.therapy_type }); - const fields = [{ - fieldtype: 'Link', - label: __('Therapy Type'), - fieldname: 'therapy_type', - options: 'Therapy Type', - reqd: 1, - get_query: function() { - return { - filters: { 'therapy_type': ['in', therapy_types]} - } - } - }]; + if (frm.doc.therapy_plan_template) { + frappe.meta.get_docfield('Therapy Plan Detail', 'therapy_type', frm.doc.name).read_only = 1; + frappe.meta.get_docfield('Therapy Plan Detail', 'no_of_sessions', frm.doc.name).read_only = 1; + } + }, - frm.add_custom_button(__('Therapy Session'), function() { - frappe.prompt(fields, data => { - frappe.call({ - method: 'erpnext.healthcare.doctype.therapy_plan.therapy_plan.make_therapy_session', - args: { - therapy_plan: frm.doc.name, - patient: frm.doc.patient, - therapy_type: data.therapy_type - }, - freeze: true, - callback: function(r) { - if (r.message) { - frappe.model.sync(r.message); - frappe.set_route('Form', r.message.doctype, r.message.name); - } - } - }); - }, __('Select Therapy Type'), __('Create')); - }, __('Create')); + make_sales_invoice: function(frm) { + frappe.call({ + args: { + 'reference_name': frm.doc.name, + 'patient': frm.doc.patient, + 'company': frm.doc.company, + 'therapy_plan_template': frm.doc.therapy_plan_template + }, + method: 'erpnext.healthcare.doctype.therapy_plan.therapy_plan.make_sales_invoice', + callback: function(r) { + var doclist = frappe.model.sync(r.message); + frappe.set_route('Form', doclist[0].doctype, doclist[0].name); + } + }); + }, + + therapy_plan_template: function(frm) { + if (frm.doc.therapy_plan_template) { + frappe.call({ + method: 'set_therapy_details_from_template', + doc: frm.doc, + freeze: true, + freeze_message: __('Fetching Template Details'), + callback: function() { + refresh_field('therapy_plan_details'); + } + }); } }, show_progress_for_therapies: function(frm) { let bars = []; let message = ''; - let added_min = false; // completed sessions let title = __('{0} sessions completed', [frm.doc.total_sessions_completed]); @@ -71,7 +111,6 @@ frappe.ui.form.on('Therapy Plan', { }); if (bars[0].width == '0%') { bars[0].width = '0.5%'; - added_min = 0.5; } message = title; frm.dashboard.add_progress(__('Status'), bars, message); diff --git a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.json b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.json index 9edfeb2faa1..c03e9de3320 100644 --- a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.json +++ b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.json @@ -9,11 +9,13 @@ "naming_series", "patient", "patient_name", + "invoiced", "column_break_4", "company", "status", "start_date", "section_break_3", + "therapy_plan_template", "therapy_plan_details", "title", "section_break_9", @@ -46,6 +48,7 @@ "fieldtype": "Table", "label": "Therapy Plan Details", "options": "Therapy Plan Detail", + "read_only_depends_on": "therapy_plan_template", "reqd": 1 }, { @@ -105,11 +108,28 @@ "fieldtype": "Link", "in_standard_filter": 1, "label": "Company", - "options": "Company" + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "therapy_plan_template", + "fieldtype": "Link", + "label": "Therapy Plan Template", + "options": "Therapy Plan Template", + "set_only_once": 1 + }, + { + "default": "0", + "fieldname": "invoiced", + "fieldtype": "Check", + "label": "Invoiced", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], "links": [], - "modified": "2020-05-25 14:38:53.649315", + "modified": "2020-11-04 18:13:13.564999", "modified_by": "Administrator", "module": "Healthcare", "name": "Therapy Plan", diff --git a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py index e0f015f3d7b..ac01c604dda 100644 --- a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py +++ b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document -from frappe.utils import today +from frappe.utils import flt, today class TherapyPlan(Document): def validate(self): @@ -33,19 +33,68 @@ class TherapyPlan(Document): self.db_set('total_sessions', total_sessions) self.db_set('total_sessions_completed', total_sessions_completed) + def set_therapy_details_from_template(self): + # Add therapy types in the child table + self.set('therapy_plan_details', []) + therapy_plan_template = frappe.get_doc('Therapy Plan Template', self.therapy_plan_template) + + for data in therapy_plan_template.therapy_types: + self.append('therapy_plan_details', { + 'therapy_type': data.therapy_type, + 'no_of_sessions': data.no_of_sessions + }) + return self + @frappe.whitelist() -def make_therapy_session(therapy_plan, patient, therapy_type): +def make_therapy_session(therapy_plan, patient, therapy_type, company, appointment=None): therapy_type = frappe.get_doc('Therapy Type', therapy_type) therapy_session = frappe.new_doc('Therapy Session') therapy_session.therapy_plan = therapy_plan + therapy_session.company = company therapy_session.patient = patient therapy_session.therapy_type = therapy_type.name therapy_session.duration = therapy_type.default_duration therapy_session.rate = therapy_type.rate therapy_session.exercises = therapy_type.exercises + therapy_session.appointment = appointment if frappe.flags.in_test: therapy_session.start_date = today() - return therapy_session.as_dict() \ No newline at end of file + return therapy_session.as_dict() + + +@frappe.whitelist() +def make_sales_invoice(reference_name, patient, company, therapy_plan_template): + from erpnext.stock.get_item_details import get_item_details + si = frappe.new_doc('Sales Invoice') + si.company = company + si.patient = patient + si.customer = frappe.db.get_value('Patient', patient, 'customer') + + item = frappe.db.get_value('Therapy Plan Template', therapy_plan_template, 'linked_item') + price_list, price_list_currency = frappe.db.get_values('Price List', {'selling': 1}, ['name', 'currency'])[0] + args = { + 'doctype': 'Sales Invoice', + 'item_code': item, + 'company': company, + 'customer': si.customer, + 'selling_price_list': price_list, + 'price_list_currency': price_list_currency, + 'plc_conversion_rate': 1.0, + 'conversion_rate': 1.0 + } + + item_line = si.append('items', {}) + item_details = get_item_details(args) + item_line.item_code = item + item_line.qty = 1 + item_line.rate = item_details.price_list_rate + item_line.amount = flt(item_line.rate) * flt(item_line.qty) + item_line.reference_dt = 'Therapy Plan' + item_line.reference_dn = reference_name + item_line.description = item_details.description + + si.set_missing_values(for_validate = True) + return si diff --git a/erpnext/healthcare/doctype/therapy_plan/therapy_plan_dashboard.py b/erpnext/healthcare/doctype/therapy_plan/therapy_plan_dashboard.py index df647829dbc..6526acda153 100644 --- a/erpnext/healthcare/doctype/therapy_plan/therapy_plan_dashboard.py +++ b/erpnext/healthcare/doctype/therapy_plan/therapy_plan_dashboard.py @@ -4,10 +4,18 @@ from frappe import _ def get_data(): return { 'fieldname': 'therapy_plan', + 'non_standard_fieldnames': { + 'Sales Invoice': 'reference_dn' + }, 'transactions': [ { 'label': _('Therapy Sessions'), 'items': ['Therapy Session'] + }, + { + 'label': _('Billing'), + 'items': ['Sales Invoice'] } - ] + ], + 'disable_create_buttons': ['Sales Invoice'] } diff --git a/erpnext/healthcare/doctype/therapy_plan_detail/therapy_plan_detail.json b/erpnext/healthcare/doctype/therapy_plan_detail/therapy_plan_detail.json index 9eb20e2ef3b..77f08af07d9 100644 --- a/erpnext/healthcare/doctype/therapy_plan_detail/therapy_plan_detail.json +++ b/erpnext/healthcare/doctype/therapy_plan_detail/therapy_plan_detail.json @@ -30,12 +30,13 @@ "fieldname": "sessions_completed", "fieldtype": "Int", "label": "Sessions Completed", + "no_copy": 1, "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2020-03-30 22:02:01.740109", + "modified": "2020-11-04 18:15:52.173450", "modified_by": "Administrator", "module": "Healthcare", "name": "Therapy Plan Detail", diff --git a/erpnext/healthcare/doctype/therapy_plan_template/__init__.py b/erpnext/healthcare/doctype/therapy_plan_template/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/doctype/therapy_plan_template/test_therapy_plan_template.py b/erpnext/healthcare/doctype/therapy_plan_template/test_therapy_plan_template.py new file mode 100644 index 00000000000..33ee29db7d7 --- /dev/null +++ b/erpnext/healthcare/doctype/therapy_plan_template/test_therapy_plan_template.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestTherapyPlanTemplate(unittest.TestCase): + pass diff --git a/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template.js b/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template.js new file mode 100644 index 00000000000..86de1928e23 --- /dev/null +++ b/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template.js @@ -0,0 +1,57 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Therapy Plan Template', { + refresh: function(frm) { + frm.set_query('therapy_type', 'therapy_types', () => { + return { + filters: { + 'is_billable': 1 + } + }; + }); + }, + + set_totals: function(frm) { + let total_sessions = 0; + let total_amount = 0.0; + frm.doc.therapy_types.forEach((d) => { + if (d.no_of_sessions) total_sessions += cint(d.no_of_sessions); + if (d.amount) total_amount += flt(d.amount); + }); + frm.set_value('total_sessions', total_sessions); + frm.set_value('total_amount', total_amount); + frm.refresh_fields(); + } +}); + +frappe.ui.form.on('Therapy Plan Template Detail', { + therapy_type: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + frappe.call('frappe.client.get', { + doctype: 'Therapy Type', + name: row.therapy_type + }).then((res) => { + row.rate = res.message.rate; + if (!row.no_of_sessions) + row.no_of_sessions = 1; + row.amount = flt(row.rate) * cint(row.no_of_sessions); + frm.refresh_field('therapy_types'); + frm.trigger('set_totals'); + }); + }, + + no_of_sessions: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + row.amount = flt(row.rate) * cint(row.no_of_sessions); + frm.refresh_field('therapy_types'); + frm.trigger('set_totals'); + }, + + rate: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + row.amount = flt(row.rate) * cint(row.no_of_sessions); + frm.refresh_field('therapy_types'); + frm.trigger('set_totals'); + } +}); diff --git a/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template.json b/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template.json new file mode 100644 index 00000000000..48fc896257b --- /dev/null +++ b/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template.json @@ -0,0 +1,132 @@ +{ + "actions": [], + "autoname": "field:plan_name", + "creation": "2020-09-22 17:51:38.861055", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "plan_name", + "linked_item_details_section", + "item_code", + "item_name", + "item_group", + "column_break_6", + "description", + "linked_item", + "therapy_types_section", + "therapy_types", + "section_break_11", + "total_sessions", + "column_break_13", + "total_amount" + ], + "fields": [ + { + "fieldname": "plan_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Plan Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "therapy_types_section", + "fieldtype": "Section Break", + "label": "Therapy Types" + }, + { + "fieldname": "therapy_types", + "fieldtype": "Table", + "label": "Therapy Types", + "options": "Therapy Plan Template Detail", + "reqd": 1 + }, + { + "fieldname": "linked_item", + "fieldtype": "Link", + "label": "Linked Item", + "options": "Item", + "read_only": 1 + }, + { + "fieldname": "linked_item_details_section", + "fieldtype": "Section Break", + "label": "Linked Item Details" + }, + { + "fieldname": "item_code", + "fieldtype": "Data", + "label": "Item Code", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "reqd": 1 + }, + { + "fieldname": "item_group", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "reqd": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Item Description" + }, + { + "fieldname": "total_amount", + "fieldtype": "Currency", + "label": "Total Amount", + "read_only": 1 + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break" + }, + { + "fieldname": "total_sessions", + "fieldtype": "Int", + "label": "Total Sessions", + "read_only": 1 + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-10-08 00:56:58.062105", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Therapy Plan Template", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template.py b/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template.py new file mode 100644 index 00000000000..748c12c6896 --- /dev/null +++ b/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document +from frappe.utils import cint, flt +from erpnext.healthcare.doctype.therapy_type.therapy_type import make_item_price + +class TherapyPlanTemplate(Document): + def after_insert(self): + self.create_item_from_template() + + def validate(self): + self.set_totals() + + def on_update(self): + doc_before_save = self.get_doc_before_save() + if not doc_before_save: return + if doc_before_save.item_name != self.item_name or doc_before_save.item_group != self.item_group \ + or doc_before_save.description != self.description: + self.update_item() + + if doc_before_save.therapy_types != self.therapy_types: + self.update_item_price() + + def set_totals(self): + total_sessions = 0 + total_amount = 0 + + for entry in self.therapy_types: + total_sessions += cint(entry.no_of_sessions) + total_amount += flt(entry.amount) + + self.total_sessions = total_sessions + self.total_amount = total_amount + + def create_item_from_template(self): + uom = frappe.db.exists('UOM', 'Nos') or frappe.db.get_single_value('Stock Settings', 'stock_uom') + + item = frappe.get_doc({ + 'doctype': 'Item', + 'item_code': self.item_code, + 'item_name': self.item_name, + 'item_group': self.item_group, + 'description': self.description, + 'is_sales_item': 1, + 'is_service_item': 1, + 'is_purchase_item': 0, + 'is_stock_item': 0, + 'show_in_website': 0, + 'is_pro_applicable': 0, + 'stock_uom': uom + }).insert(ignore_permissions=True, ignore_mandatory=True) + + make_item_price(item.name, self.total_amount) + self.db_set('linked_item', item.name) + + def update_item(self): + item_doc = frappe.get_doc('Item', {'item_code': self.linked_item}) + item_doc.item_name = self.item_name + item_doc.item_group = self.item_group + item_doc.description = self.description + item_doc.ignore_mandatory = True + item_doc.save(ignore_permissions=True) + + def update_item_price(self): + item_price = frappe.get_doc('Item Price', {'item_code': self.linked_item}) + item_price.item_name = self.item_name + item_price.price_list_rate = self.total_amount + item_price.ignore_mandatory = True + item_price.save(ignore_permissions=True) \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile_dashboard.py b/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template_dashboard.py similarity index 54% rename from erpnext/accounts/doctype/pos_profile/pos_profile_dashboard.py rename to erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template_dashboard.py index 2e4632a8d57..c748fbfcb7c 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile_dashboard.py +++ b/erpnext/healthcare/doctype/therapy_plan_template/therapy_plan_template_dashboard.py @@ -1,14 +1,13 @@ from __future__ import unicode_literals - from frappe import _ - def get_data(): return { - 'fieldname': 'pos_profile', + 'fieldname': 'therapy_plan_template', 'transactions': [ { - 'items': ['Sales Invoice', 'POS Closing Entry', 'POS Opening Entry'] + 'label': _('Therapy Plans'), + 'items': ['Therapy Plan'] } ] } diff --git a/erpnext/healthcare/doctype/therapy_plan_template_detail/__init__.py b/erpnext/healthcare/doctype/therapy_plan_template_detail/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/doctype/therapy_plan_template_detail/therapy_plan_template_detail.json b/erpnext/healthcare/doctype/therapy_plan_template_detail/therapy_plan_template_detail.json new file mode 100644 index 00000000000..5553a118f87 --- /dev/null +++ b/erpnext/healthcare/doctype/therapy_plan_template_detail/therapy_plan_template_detail.json @@ -0,0 +1,54 @@ +{ + "actions": [], + "creation": "2020-10-07 23:04:44.373381", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "therapy_type", + "no_of_sessions", + "rate", + "amount" + ], + "fields": [ + { + "fieldname": "therapy_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Therapy Type", + "options": "Therapy Type", + "reqd": 1 + }, + { + "fieldname": "no_of_sessions", + "fieldtype": "Int", + "in_list_view": 1, + "label": "No of Sessions" + }, + { + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "read_only": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2020-10-07 23:46:54.296322", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Therapy Plan Template Detail", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/healthcare/doctype/therapy_plan_template_detail/therapy_plan_template_detail.py b/erpnext/healthcare/doctype/therapy_plan_template_detail/therapy_plan_template_detail.py new file mode 100644 index 00000000000..7b979fe9fc8 --- /dev/null +++ b/erpnext/healthcare/doctype/therapy_plan_template_detail/therapy_plan_template_detail.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class TherapyPlanTemplateDetail(Document): + pass diff --git a/erpnext/healthcare/doctype/therapy_session/therapy_session.js b/erpnext/healthcare/doctype/therapy_session/therapy_session.js index e66e66751aa..fd200036935 100644 --- a/erpnext/healthcare/doctype/therapy_session/therapy_session.js +++ b/erpnext/healthcare/doctype/therapy_session/therapy_session.js @@ -19,9 +19,22 @@ frappe.ui.form.on('Therapy Session', { } }; }); + + frm.set_query('appointment', function() { + + return { + filters: { + 'status': ['in', ['Open', 'Scheduled']] + } + }; + }); }, refresh: function(frm) { + if (frm.doc.therapy_plan) { + frm.trigger('filter_therapy_types'); + } + if (!frm.doc.__islocal) { frm.dashboard.add_indicator(__('Counts Targeted: {0}', [frm.doc.total_counts_targeted]), 'blue'); frm.dashboard.add_indicator(__('Counts Completed: {0}', [frm.doc.total_counts_completed]), @@ -36,15 +49,43 @@ frappe.ui.form.on('Therapy Session', { }) }, 'Create'); - frm.add_custom_button(__('Sales Invoice'), function() { - frappe.model.open_mapped_doc({ - method: 'erpnext.healthcare.doctype.therapy_session.therapy_session.invoice_therapy_session', - frm: frm, - }) - }, 'Create'); + frappe.db.get_value('Therapy Plan', {'name': frm.doc.therapy_plan}, 'therapy_plan_template', (r) => { + if (r && !r.therapy_plan_template) { + frm.add_custom_button(__('Sales Invoice'), function() { + frappe.model.open_mapped_doc({ + method: 'erpnext.healthcare.doctype.therapy_session.therapy_session.invoice_therapy_session', + frm: frm, + }); + }, 'Create'); + } + }); } }, + therapy_plan: function(frm) { + if (frm.doc.therapy_plan) { + frm.trigger('filter_therapy_types'); + } + }, + + filter_therapy_types: function(frm) { + frappe.call({ + 'method': 'frappe.client.get', + args: { + doctype: 'Therapy Plan', + name: frm.doc.therapy_plan + }, + callback: function(data) { + let therapy_types = (data.message.therapy_plan_details || []).map(function(d){ return d.therapy_type; }); + frm.set_query('therapy_type', function() { + return { + filters: { 'therapy_type': ['in', therapy_types]} + }; + }); + } + }); + }, + patient: function(frm) { if (frm.doc.patient) { frappe.call({ @@ -92,23 +133,12 @@ frappe.ui.form.on('Therapy Session', { 'start_date': data.message.appointment_date, 'start_time': data.message.appointment_time, 'service_unit': data.message.service_unit, - 'company': data.message.company + 'company': data.message.company, + 'duration': data.message.duration }; frm.set_value(values); } }); - } else { - let values = { - 'patient': '', - 'therapy_type': '', - 'therapy_plan': '', - 'practitioner': '', - 'department': '', - 'start_date': '', - 'start_time': '', - 'service_unit': '', - }; - frm.set_value(values); } }, diff --git a/erpnext/healthcare/doctype/therapy_session/therapy_session.json b/erpnext/healthcare/doctype/therapy_session/therapy_session.json index dc0cafcf9c7..0bb2b0ef2ae 100644 --- a/erpnext/healthcare/doctype/therapy_session/therapy_session.json +++ b/erpnext/healthcare/doctype/therapy_session/therapy_session.json @@ -47,7 +47,8 @@ "fieldname": "appointment", "fieldtype": "Link", "label": "Appointment", - "options": "Patient Appointment" + "options": "Patient Appointment", + "set_only_once": 1 }, { "fieldname": "patient", @@ -90,7 +91,8 @@ "fetch_from": "therapy_template.default_duration", "fieldname": "duration", "fieldtype": "Int", - "label": "Duration" + "label": "Duration", + "reqd": 1 }, { "fieldname": "location", @@ -192,6 +194,7 @@ "fieldname": "total_counts_completed", "fieldtype": "Int", "label": "Total Counts Completed", + "no_copy": 1, "read_only": 1 }, { @@ -220,7 +223,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2020-06-30 10:56:10.354268", + "modified": "2020-11-04 18:14:25.999939", "modified_by": "Administrator", "module": "Healthcare", "name": "Therapy Session", diff --git a/erpnext/healthcare/doctype/therapy_session/therapy_session.py b/erpnext/healthcare/doctype/therapy_session/therapy_session.py index 96501837120..51f267f9496 100644 --- a/erpnext/healthcare/doctype/therapy_session/therapy_session.py +++ b/erpnext/healthcare/doctype/therapy_session/therapy_session.py @@ -4,21 +4,52 @@ from __future__ import unicode_literals import frappe +import datetime from frappe.model.document import Document +from frappe.utils import get_time, flt from frappe.model.mapper import get_mapped_doc from frappe import _ -from frappe.utils import cstr, getdate +from frappe.utils import cstr, getdate, get_link_to_form from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_receivable_account, get_income_account class TherapySession(Document): def validate(self): + self.validate_duplicate() self.set_total_counts() + def validate_duplicate(self): + end_time = datetime.datetime.combine(getdate(self.start_date), get_time(self.start_time)) \ + + datetime.timedelta(minutes=flt(self.duration)) + + overlaps = frappe.db.sql(""" + select + name + from + `tabTherapy Session` + where + start_date=%s and name!=%s and docstatus!=2 + and (practitioner=%s or patient=%s) and + ((start_time<%s and start_time + INTERVAL duration MINUTE>%s) or + (start_time>%s and start_time<%s) or + (start_time=%s)) + """, (self.start_date, self.name, self.practitioner, self.patient, + self.start_time, end_time.time(), self.start_time, end_time.time(), self.start_time)) + + if overlaps: + overlapping_details = _('Therapy Session overlaps with {0}').format(get_link_to_form('Therapy Session', overlaps[0][0])) + frappe.throw(overlapping_details, title=_('Therapy Sessions Overlapping')) + def on_submit(self): self.update_sessions_count_in_therapy_plan() - insert_session_medical_record(self) + + def on_update(self): + if self.appointment: + frappe.db.set_value('Patient Appointment', self.appointment, 'status', 'Closed') def on_cancel(self): + if self.appointment: + frappe.db.set_value('Patient Appointment', self.appointment, 'status', 'Open') + self.update_sessions_count_in_therapy_plan(on_cancel=True) def update_sessions_count_in_therapy_plan(self, on_cancel=False): @@ -110,23 +141,3 @@ def get_therapy_item(therapy, item): item.reference_dt = 'Therapy Session' item.reference_dn = therapy.name return item - - -def insert_session_medical_record(doc): - subject = frappe.bold(_('Therapy: ')) + cstr(doc.therapy_type) + '
    ' - if doc.therapy_plan: - subject += frappe.bold(_('Therapy Plan: ')) + cstr(doc.therapy_plan) + '
    ' - if doc.practitioner: - subject += frappe.bold(_('Healthcare Practitioner: ')) + doc.practitioner - subject += frappe.bold(_('Total Counts Targeted: ')) + cstr(doc.total_counts_targeted) + '
    ' - subject += frappe.bold(_('Total Counts Completed: ')) + cstr(doc.total_counts_completed) + '
    ' - - medical_record = frappe.new_doc('Patient Medical Record') - medical_record.patient = doc.patient - medical_record.subject = subject - medical_record.status = 'Open' - medical_record.communication_date = doc.start_date - medical_record.reference_doctype = 'Therapy Session' - medical_record.reference_name = doc.name - medical_record.reference_owner = doc.owner - medical_record.save(ignore_permissions=True) \ No newline at end of file diff --git a/erpnext/healthcare/doctype/therapy_type/therapy_type.py b/erpnext/healthcare/doctype/therapy_type/therapy_type.py index ea3d84e7c5d..6c825b8a58e 100644 --- a/erpnext/healthcare/doctype/therapy_type/therapy_type.py +++ b/erpnext/healthcare/doctype/therapy_type/therapy_type.py @@ -41,7 +41,7 @@ class TherapyType(Document): if self.rate: item_price = frappe.get_doc('Item Price', {'item_code': self.item}) item_price.item_name = self.item_name - item_price.price_list_name = self.rate + item_price.price_list_rate = self.rate item_price.ignore_mandatory = True item_price.save() diff --git a/erpnext/healthcare/doctype/vital_signs/vital_signs.py b/erpnext/healthcare/doctype/vital_signs/vital_signs.py index 69d81ff4b08..35c823d739c 100644 --- a/erpnext/healthcare/doctype/vital_signs/vital_signs.py +++ b/erpnext/healthcare/doctype/vital_signs/vital_signs.py @@ -12,47 +12,7 @@ class VitalSigns(Document): def validate(self): self.set_title() - def on_submit(self): - insert_vital_signs_to_medical_record(self) - - def on_cancel(self): - delete_vital_signs_from_medical_record(self) - def set_title(self): self.title = _('{0} on {1}').format(self.patient_name or self.patient, frappe.utils.format_date(self.signs_date))[:100] -def insert_vital_signs_to_medical_record(doc): - subject = set_subject_field(doc) - medical_record = frappe.new_doc('Patient Medical Record') - medical_record.patient = doc.patient - medical_record.subject = subject - medical_record.status = 'Open' - medical_record.communication_date = doc.signs_date - medical_record.reference_doctype = 'Vital Signs' - medical_record.reference_name = doc.name - medical_record.reference_owner = doc.owner - medical_record.flags.ignore_mandatory = True - medical_record.save(ignore_permissions=True) - -def delete_vital_signs_from_medical_record(doc): - medical_record = frappe.db.get_value('Patient Medical Record', {'reference_name': doc.name}) - if medical_record: - frappe.delete_doc('Patient Medical Record', medical_record) - -def set_subject_field(doc): - subject = '' - if doc.temperature: - subject += frappe.bold(_('Temperature: ')) + cstr(doc.temperature) + '
    ' - if doc.pulse: - subject += frappe.bold(_('Pulse: ')) + cstr(doc.pulse) + '
    ' - if doc.respiratory_rate: - subject += frappe.bold(_('Respiratory Rate: ')) + cstr(doc.respiratory_rate) + '
    ' - if doc.bp: - subject += frappe.bold(_('BP: ')) + cstr(doc.bp) + '
    ' - if doc.bmi: - subject += frappe.bold(_('BMI: ')) + cstr(doc.bmi) + '
    ' - if doc.nutrition_note: - subject += frappe.bold(_('Note: ')) + cstr(doc.nutrition_note) + '
    ' - - return subject diff --git a/erpnext/healthcare/page/patient_history/patient_history.css b/erpnext/healthcare/page/patient_history/patient_history.css index 865d6abee00..1bb589164e6 100644 --- a/erpnext/healthcare/page/patient_history/patient_history.css +++ b/erpnext/healthcare/page/patient_history/patient_history.css @@ -109,6 +109,11 @@ padding-right: 0px; } +.patient-history-filter { + margin-left: 35px; + width: 25%; +} + #page-medical_record .plot-wrapper { padding: 20px 15px; border-bottom: 1px solid #d1d8dd; diff --git a/erpnext/healthcare/page/patient_history/patient_history.html b/erpnext/healthcare/page/patient_history/patient_history.html index 7a9446dffd7..be486c62d1e 100644 --- a/erpnext/healthcare/page/patient_history/patient_history.html +++ b/erpnext/healthcare/page/patient_history/patient_history.html @@ -1,6 +1,5 @@
    -

    {%= __("Select Patient") %}

    @@ -11,6 +10,13 @@
    + +
    +
    +
    +
    +
    +
    diff --git a/erpnext/healthcare/page/patient_history/patient_history.js b/erpnext/healthcare/page/patient_history/patient_history.js index fe5b7bc4883..54343aae449 100644 --- a/erpnext/healthcare/page/patient_history/patient_history.js +++ b/erpnext/healthcare/page/patient_history/patient_history.js @@ -1,141 +1,225 @@ -frappe.provide("frappe.patient_history"); +frappe.provide('frappe.patient_history'); frappe.pages['patient_history'].on_page_load = function(wrapper) { - var me = this; - var page = frappe.ui.make_app_page({ + let me = this; + let page = frappe.ui.make_app_page({ parent: wrapper, title: 'Patient History', single_column: true }); - frappe.breadcrumbs.add("Healthcare"); + frappe.breadcrumbs.add('Healthcare'); let pid = ''; - page.main.html(frappe.render_template("patient_history", {})); - var patient = frappe.ui.form.make_control({ - parent: page.main.find(".patient"), + page.main.html(frappe.render_template('patient_history', {})); + page.main.find('.header-separator').hide(); + + let patient = frappe.ui.form.make_control({ + parent: page.main.find('.patient'), df: { - fieldtype: "Link", - options: "Patient", - fieldname: "patient", - change: function(){ - if(pid != patient.get_value() && patient.get_value()){ + fieldtype: 'Link', + options: 'Patient', + fieldname: 'patient', + placeholder: __('Select Patient'), + only_select: true, + change: function() { + let patient_id = patient.get_value(); + if (pid != patient_id && patient_id) { me.start = 0; - me.page.main.find(".patient_documents_list").html(""); - get_documents(patient.get_value(), me); - show_patient_info(patient.get_value(), me); - show_patient_vital_charts(patient.get_value(), me, "bp", "mmHg", "Blood Pressure"); + me.page.main.find('.patient_documents_list').html(''); + setup_filters(patient_id, me); + get_documents(patient_id, me); + show_patient_info(patient_id, me); + show_patient_vital_charts(patient_id, me, 'bp', 'mmHg', 'Blood Pressure'); } - pid = patient.get_value(); + pid = patient_id; } }, - only_input: true, }); patient.refresh(); - if (frappe.route_options){ + if (frappe.route_options) { patient.set_value(frappe.route_options.patient); } - this.page.main.on("click", ".btn-show-chart", function() { - var btn_show_id = $(this).attr("data-show-chart-id"), pts = $(this).attr("data-pts"); - var title = $(this).attr("data-title"); + this.page.main.on('click', '.btn-show-chart', function() { + let btn_show_id = $(this).attr('data-show-chart-id'), pts = $(this).attr('data-pts'); + let title = $(this).attr('data-title'); show_patient_vital_charts(patient.get_value(), me, btn_show_id, pts, title); }); - this.page.main.on("click", ".btn-more", function() { - var doctype = $(this).attr("data-doctype"), docname = $(this).attr("data-docname"); - if(me.page.main.find("."+docname).parent().find('.document-html').attr('data-fetched') == "1"){ - me.page.main.find("."+docname).hide(); - me.page.main.find("."+docname).parent().find('.document-html').show(); - }else{ - if(doctype && docname){ - let exclude = ["patient", "patient_name", 'patient_sex', "encounter_date"]; + this.page.main.on('click', '.btn-more', function() { + let doctype = $(this).attr('data-doctype'), docname = $(this).attr('data-docname'); + if (me.page.main.find('.'+docname).parent().find('.document-html').attr('data-fetched') == '1') { + me.page.main.find('.'+docname).hide(); + me.page.main.find('.'+docname).parent().find('.document-html').show(); + } else { + if (doctype && docname) { + let exclude = ['patient', 'patient_name', 'patient_sex', 'encounter_date']; frappe.call({ - method: "erpnext.healthcare.utils.render_doc_as_html", + method: 'erpnext.healthcare.utils.render_doc_as_html', args:{ doctype: doctype, docname: docname, exclude_fields: exclude }, + freeze: true, callback: function(r) { - if (r.message){ - me.page.main.find("."+docname).hide(); - me.page.main.find("."+docname).parent().find('.document-html').html(r.message.html+"\ -
    "); - me.page.main.find("."+docname).parent().find('.document-html').show(); - me.page.main.find("."+docname).parent().find('.document-html').attr('data-fetched', "1"); + if (r.message) { + me.page.main.find('.' + docname).hide(); + + me.page.main.find('.' + docname).parent().find('.document-html').html( + `${r.message.html} +
    + + +
    + `); + + me.page.main.find('.' + docname).parent().find('.document-html').show(); + me.page.main.find('.' + docname).parent().find('.document-html').attr('data-fetched', '1'); } - }, - freeze: true + } }); } } }); - this.page.main.on("click", ".btn-less", function() { - var docname = $(this).attr("data-docname"); - me.page.main.find("."+docname).parent().find('.document-id').show(); - me.page.main.find("."+docname).parent().find('.document-html').hide(); + this.page.main.on('click', '.btn-less', function() { + let docname = $(this).attr('data-docname'); + me.page.main.find('.' + docname).parent().find('.document-id').show(); + me.page.main.find('.' + docname).parent().find('.document-html').hide(); }); me.start = 0; - me.page.main.on("click", ".btn-get-records", function(){ + me.page.main.on('click', '.btn-get-records', function() { get_documents(patient.get_value(), me); }); }; -var get_documents = function(patient, me){ +let setup_filters = function(patient, me) { + $('.doctype-filter').empty(); + frappe.xcall( + 'erpnext.healthcare.page.patient_history.patient_history.get_patient_history_doctypes' + ).then(document_types => { + let doctype_filter = frappe.ui.form.make_control({ + parent: $('.doctype-filter'), + df: { + fieldtype: 'MultiSelectList', + fieldname: 'document_type', + placeholder: __('Select Document Type'), + input_class: 'input-xs', + change: () => { + me.start = 0; + me.page.main.find('.patient_documents_list').html(''); + get_documents(patient, me, doctype_filter.get_value(), date_range_field.get_value()); + }, + get_data: () => { + return document_types.map(document_type => { + return { + description: document_type, + value: document_type + }; + }); + }, + } + }); + doctype_filter.refresh(); + + $('.date-filter').empty(); + let date_range_field = frappe.ui.form.make_control({ + df: { + fieldtype: 'DateRange', + fieldname: 'date_range', + placeholder: __('Date Range'), + input_class: 'input-xs', + change: () => { + let selected_date_range = date_range_field.get_value(); + if (selected_date_range && selected_date_range.length === 2) { + me.start = 0; + me.page.main.find('.patient_documents_list').html(''); + get_documents(patient, me, doctype_filter.get_value(), selected_date_range); + } + } + }, + parent: $('.date-filter') + }); + date_range_field.refresh(); + }); +}; + +let get_documents = function(patient, me, document_types="", selected_date_range="") { + let filters = { + name: patient, + start: me.start, + page_length: 20 + }; + if (document_types) + filters['document_types'] = document_types; + if (selected_date_range) + filters['date_range'] = selected_date_range; + frappe.call({ - "method": "erpnext.healthcare.page.patient_history.patient_history.get_feed", - args: { - name: patient, - start: me.start, - page_length: 20 - }, - callback: function (r) { - var data = r.message; - if(data.length){ + 'method': 'erpnext.healthcare.page.patient_history.patient_history.get_feed', + args: filters, + callback: function(r) { + let data = r.message; + if (data.length) { add_to_records(me, data); - }else{ - me.page.main.find(".patient_documents_list").append("


    No more records..

    "); - me.page.main.find(".btn-get-records").hide(); + } else { + me.page.main.find('.patient_documents_list').append(` +
    +

    ${__('No more records..')}

    +
    `); + me.page.main.find('.btn-get-records').hide(); } } }); }; -var add_to_records = function(me, data){ - var details = ""; - me.page.main.find(".patient_documents_list").append(details); + + details += ''; + me.page.main.find('.patient_documents_list').append(details); me.start += data.length; - if(data.length===20){ + + if (data.length === 20) { me.page.main.find(".btn-get-records").show(); - }else{ + } else { me.page.main.find(".btn-get-records").hide(); - me.page.main.find(".patient_documents_list").append("


    No more records..

    "); + me.page.main.find(".patient_documents_list").append(` +
    +

    ${__('No more records..')}

    +
    `); } }; -var add_date_separator = function(data) { - var date = frappe.datetime.str_to_obj(data.creation); +let add_date_separator = function(data) { + let date = frappe.datetime.str_to_obj(data.communication_date); + let pdate = ''; + let diff = frappe.datetime.get_day_diff(frappe.datetime.get_today(), frappe.datetime.obj_to_str(date)); - var diff = frappe.datetime.get_day_diff(frappe.datetime.get_today(), frappe.datetime.obj_to_str(date)); - if(diff < 1) { - var pdate = 'Today'; - } else if(diff < 2) { - pdate = 'Yesterday'; + if (diff < 1) { + pdate = __('Today'); + } else if (diff < 2) { + pdate = __('Yesterday'); } else { - pdate = frappe.datetime.global_date_format(date); + pdate = __('on ') + frappe.datetime.global_date_format(date); } data.date_sep = pdate; return data; }; -var show_patient_info = function(patient, me){ +let show_patient_info = function(patient, me) { frappe.call({ - "method": "erpnext.healthcare.doctype.patient.patient.get_patient_detail", + 'method': 'erpnext.healthcare.doctype.patient.patient.get_patient_detail', args: { patient: patient }, - callback: function (r) { - var data = r.message; - var details = ""; - if(data.image){ - details += "
    "; + callback: function(r) { + let data = r.message; + let details = ''; + if (data.image) { + details += `
    `; } - details += "" + data.patient_name +"
    " + data.sex; - if(data.email) details += "
    " + data.email; - if(data.mobile) details += "
    " + data.mobile; - if(data.occupation) details += "

    Occupation : " + data.occupation; - if(data.blood_group) details += "
    Blood group : " + data.blood_group; - if(data.allergies) details += "

    Allergies : "+ data.allergies.replace("\n", "
    "); - if(data.medication) details += "
    Medication : "+ data.medication.replace("\n", "
    "); - if(data.alcohol_current_use) details += "

    Alcohol use : "+ data.alcohol_current_use; - if(data.alcohol_past_use) details += "
    Alcohol past use : "+ data.alcohol_past_use; - if(data.tobacco_current_use) details += "
    Tobacco use : "+ data.tobacco_current_use; - if(data.tobacco_past_use) details += "
    Tobacco past use : "+ data.tobacco_past_use; - if(data.medical_history) details += "

    Medical history : "+ data.medical_history.replace("\n", "
    "); - if(data.surgical_history) details += "
    Surgical history : "+ data.surgical_history.replace("\n", "
    "); - if(data.surrounding_factors) details += "

    Occupational hazards : "+ data.surrounding_factors.replace("\n", "
    "); - if(data.other_risk_factors) details += "
    Other risk factors : " + data.other_risk_factors.replace("\n", "
    "); - if(data.patient_details) details += "

    More info : " + data.patient_details.replace("\n", "
    "); - if(details){ - details = "
    " + details + "
    "; + details += ` ${data.patient_name}
    ${data.sex}`; + if (data.email) details += `
    ${data.email}`; + if (data.mobile) details += `
    ${data.mobile}`; + if (data.occupation) details += `

    ${__('Occupation')} : ${data.occupation}`; + if (data.blood_group) details += `
    ${__('Blood Group')} : ${data.blood_group}`; + if (data.allergies) details += `

    ${__('Allerigies')} : ${data.allergies.replace("\n", ", ")}`; + if (data.medication) details += `
    ${__('Medication')} : ${data.medication.replace("\n", ", ")}`; + if (data.alcohol_current_use) details += `

    ${__('Alcohol use')} : ${data.alcohol_current_use}`; + if (data.alcohol_past_use) details += `
    ${__('Alcohol past use')} : ${data.alcohol_past_use}`; + if (data.tobacco_current_use) details += `
    ${__('Tobacco use')} : ${data.tobacco_current_use}`; + if (data.tobacco_past_use) details += `
    ${__('Tobacco past use')} : ${data.tobacco_past_use}`; + if (data.medical_history) details += `

    ${__('Medical history')} : ${data.medical_history.replace("\n", ", ")}`; + if (data.surgical_history) details += `
    ${__('Surgical history')} : ${data.surgical_history.replace("\n", ", ")}`; + if (data.surrounding_factors) details += `

    ${__('Occupational hazards')} : ${data.surrounding_factors.replace("\n", ", ")}`; + if (data.other_risk_factors) details += `
    ${__('Other risk factors')} : ${data.other_risk_factors.replace("\n", ", ")}`; + if (data.patient_details) details += `

    ${__('More info')} : ${data.patient_details.replace("\n", ", ")}`; + + if (details) { + details = `
    ` + details + `
    `; } - me.page.main.find(".patient_details").html(details); + me.page.main.find('.patient_details').html(details); } }); }; -var show_patient_vital_charts = function(patient, me, btn_show_id, pts, title) { +let show_patient_vital_charts = function(patient, me, btn_show_id, pts, title) { frappe.call({ - method: "erpnext.healthcare.utils.get_patient_vitals", + method: 'erpnext.healthcare.utils.get_patient_vitals', args:{ patient: patient }, callback: function(r) { - if (r.message){ - var show_chart_btns_html = ""; - me.page.main.find(".show_chart_btns").html(show_chart_btns_html); - var data = r.message; + if (r.message) { + let show_chart_btns_html = ` + `; + + me.page.main.find('.show_chart_btns').html(show_chart_btns_html); + let data = r.message; let labels = [], datasets = []; let bp_systolic = [], bp_diastolic = [], temperature = []; let pulse = [], respiratory_rate = [], bmi = [], height = [], weight = []; - for(var i=0; i d + ' ' + pts, } }); - }else{ - me.page.main.find(".patient_vital_charts").html(""); - me.page.main.find(".show_chart_btns").html(""); + me.page.main.find('.header-separator').show(); + } else { + me.page.main.find('.patient_vital_charts').html(''); + me.page.main.find('.show_chart_btns').html(''); + me.page.main.find('.header-separator').hide(); } } }); diff --git a/erpnext/healthcare/page/patient_history/patient_history.py b/erpnext/healthcare/page/patient_history/patient_history.py index 772aa4ef5eb..4cdfd64a697 100644 --- a/erpnext/healthcare/page/patient_history/patient_history.py +++ b/erpnext/healthcare/page/patient_history/patient_history.py @@ -4,36 +4,70 @@ from __future__ import unicode_literals import frappe +import json from frappe.utils import cint from erpnext.healthcare.utils import render_docs_as_html @frappe.whitelist() -def get_feed(name, start=0, page_length=20): +def get_feed(name, document_types=None, date_range=None, start=0, page_length=20): """get feed""" - result = frappe.db.sql("""select name, owner, creation, - reference_doctype, reference_name, subject - from `tabPatient Medical Record` - where patient=%(patient)s - order by creation desc - limit %(start)s, %(page_length)s""", - { - "patient": name, - "start": cint(start), - "page_length": cint(page_length) - }, as_dict=True) + filters = get_filters(name, document_types, date_range) + + result = frappe.db.get_all('Patient Medical Record', + fields=['name', 'owner', 'communication_date', + 'reference_doctype', 'reference_name', 'subject'], + filters=filters, + order_by='communication_date DESC', + limit=cint(page_length), + start=cint(start) + ) + return result + +def get_filters(name, document_types=None, date_range=None): + filters = {'patient': name} + if document_types: + document_types = json.loads(document_types) + if len(document_types): + filters['reference_doctype'] = ['IN', document_types] + + if date_range: + try: + date_range = json.loads(date_range) + if date_range: + filters['communication_date'] = ['between', [date_range[0], date_range[1]]] + except json.decoder.JSONDecodeError: + pass + + return filters + + @frappe.whitelist() def get_feed_for_dt(doctype, docname): """get feed""" - result = frappe.db.sql("""select name, owner, modified, creation, - reference_doctype, reference_name, subject - from `tabPatient Medical Record` - where reference_name=%(docname)s and reference_doctype=%(doctype)s - order by creation desc""", - { - "docname": docname, - "doctype": doctype - }, as_dict=True) + result = frappe.db.get_all('Patient Medical Record', + fields=['name', 'owner', 'communication_date', + 'reference_doctype', 'reference_name', 'subject'], + filters={ + 'reference_doctype': doctype, + 'reference_name': docname + }, + order_by='communication_date DESC' + ) return result + + +@frappe.whitelist() +def get_patient_history_doctypes(): + document_types = [] + settings = frappe.get_single("Patient History Settings") + + for entry in settings.standard_doctypes: + document_types.append(entry.document_type) + + for entry in settings.custom_doctypes: + document_types.append(entry.document_type) + + return document_types diff --git a/erpnext/healthcare/report/inpatient_medication_orders/__init__.py b/erpnext/healthcare/report/inpatient_medication_orders/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/healthcare/report/inpatient_medication_orders/inpatient_medication_orders.js b/erpnext/healthcare/report/inpatient_medication_orders/inpatient_medication_orders.js new file mode 100644 index 00000000000..a10f83760fa --- /dev/null +++ b/erpnext/healthcare/report/inpatient_medication_orders/inpatient_medication_orders.js @@ -0,0 +1,57 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Inpatient Medication Orders"] = { + "filters": [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), + reqd: 1 + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.now_date(), + reqd: 1 + }, + { + fieldname: "patient", + label: __("Patient"), + fieldtype: "Link", + options: "Patient" + }, + { + fieldname: "service_unit", + label: __("Healthcare Service Unit"), + fieldtype: "Link", + options: "Healthcare Service Unit", + get_query: () => { + var company = frappe.query_report.get_filter_value('company'); + return { + filters: { + 'company': company, + 'is_group': 0 + } + } + } + }, + { + fieldname: "show_completed_orders", + label: __("Show Completed Orders"), + fieldtype: "Check", + default: 1 + } + ] +}; diff --git a/erpnext/healthcare/report/inpatient_medication_orders/inpatient_medication_orders.json b/erpnext/healthcare/report/inpatient_medication_orders/inpatient_medication_orders.json new file mode 100644 index 00000000000..9217fa18919 --- /dev/null +++ b/erpnext/healthcare/report/inpatient_medication_orders/inpatient_medication_orders.json @@ -0,0 +1,36 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2020-11-23 17:25:58.802949", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "modified": "2020-11-23 19:40:20.227591", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Inpatient Medication Orders", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Inpatient Medication Order", + "report_name": "Inpatient Medication Orders", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Healthcare Administrator" + }, + { + "role": "Nursing User" + }, + { + "role": "Physician" + } + ] +} \ No newline at end of file diff --git a/erpnext/healthcare/report/inpatient_medication_orders/inpatient_medication_orders.py b/erpnext/healthcare/report/inpatient_medication_orders/inpatient_medication_orders.py new file mode 100644 index 00000000000..b9077301bad --- /dev/null +++ b/erpnext/healthcare/report/inpatient_medication_orders/inpatient_medication_orders.py @@ -0,0 +1,198 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from erpnext.healthcare.doctype.inpatient_medication_entry.inpatient_medication_entry import get_current_healthcare_service_unit + +def execute(filters=None): + columns = get_columns() + data = get_data(filters) + chart = get_chart_data(data) + + return columns, data, None, chart + +def get_columns(): + return [ + { + "fieldname": "patient", + "fieldtype": "Link", + "label": "Patient", + "options": "Patient", + "width": 200 + }, + { + "fieldname": "healthcare_service_unit", + "fieldtype": "Link", + "label": "Healthcare Service Unit", + "options": "Healthcare Service Unit", + "width": 150 + }, + { + "fieldname": "drug", + "fieldtype": "Link", + "label": "Drug Code", + "options": "Item", + "width": 150 + }, + { + "fieldname": "drug_name", + "fieldtype": "Data", + "label": "Drug Name", + "width": 150 + }, + { + "fieldname": "dosage", + "fieldtype": "Link", + "label": "Dosage", + "options": "Prescription Dosage", + "width": 80 + }, + { + "fieldname": "dosage_form", + "fieldtype": "Link", + "label": "Dosage Form", + "options": "Dosage Form", + "width": 100 + }, + { + "fieldname": "date", + "fieldtype": "Date", + "label": "Date", + "width": 100 + }, + { + "fieldname": "time", + "fieldtype": "Time", + "label": "Time", + "width": 100 + }, + { + "fieldname": "is_completed", + "fieldtype": "Check", + "label": "Is Order Completed", + "width": 100 + }, + { + "fieldname": "healthcare_practitioner", + "fieldtype": "Link", + "label": "Healthcare Practitioner", + "options": "Healthcare Practitioner", + "width": 200 + }, + { + "fieldname": "inpatient_medication_entry", + "fieldtype": "Link", + "label": "Inpatient Medication Entry", + "options": "Inpatient Medication Entry", + "width": 200 + }, + { + "fieldname": "inpatient_record", + "fieldtype": "Link", + "label": "Inpatient Record", + "options": "Inpatient Record", + "width": 200 + } + ] + +def get_data(filters): + conditions, values = get_conditions(filters) + + data = frappe.db.sql(""" + SELECT + parent.patient, parent.inpatient_record, parent.practitioner, + child.drug, child.drug_name, child.dosage, child.dosage_form, + child.date, child.time, child.is_completed, child.name + FROM `tabInpatient Medication Order` parent + INNER JOIN `tabInpatient Medication Order Entry` child + ON child.parent = parent.name + WHERE + parent.docstatus = 1 + {conditions} + ORDER BY date, time + """.format(conditions=conditions), values, as_dict=1) + + data = get_inpatient_details(data, filters.get("service_unit")) + + return data + +def get_conditions(filters): + conditions = "" + values = dict() + + if filters.get("company"): + conditions += " AND parent.company = %(company)s" + values["company"] = filters.get("company") + + if filters.get("from_date") and filters.get("to_date"): + conditions += " AND child.date BETWEEN %(from_date)s and %(to_date)s" + values["from_date"] = filters.get("from_date") + values["to_date"] = filters.get("to_date") + + if filters.get("patient"): + conditions += " AND parent.patient = %(patient)s" + values["patient"] = filters.get("patient") + + if not filters.get("show_completed_orders"): + conditions += " AND child.is_completed = 0" + + return conditions, values + + +def get_inpatient_details(data, service_unit): + service_unit_filtered_data = [] + + for entry in data: + entry["healthcare_service_unit"] = get_current_healthcare_service_unit(entry.inpatient_record) + if entry.is_completed: + entry["inpatient_medication_entry"] = get_inpatient_medication_entry(entry.name) + + if service_unit and entry.healthcare_service_unit and service_unit != entry.healthcare_service_unit: + service_unit_filtered_data.append(entry) + + entry.pop("name", None) + + for entry in service_unit_filtered_data: + data.remove(entry) + + return data + +def get_inpatient_medication_entry(order_entry): + return frappe.db.get_value("Inpatient Medication Entry Detail", {"against_imoe": order_entry}, "parent") + +def get_chart_data(data): + if not data: + return None + + labels = ["Pending", "Completed"] + datasets = [] + + status_wise_data = { + "Pending": 0, + "Completed": 0 + } + + for d in data: + if d.is_completed: + status_wise_data["Completed"] += 1 + else: + status_wise_data["Pending"] += 1 + + datasets.append({ + "name": "Inpatient Medication Order Status", + "values": [status_wise_data.get("Pending"), status_wise_data.get("Completed")] + }) + + chart = { + "data": { + "labels": labels, + "datasets": datasets + }, + "type": "donut", + "height": 300 + } + + chart["fieldtype"] = "Data" + + return chart \ No newline at end of file diff --git a/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py b/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py new file mode 100644 index 00000000000..4b461f1a97d --- /dev/null +++ b/erpnext/healthcare/report/inpatient_medication_orders/test_inpatient_medication_orders.py @@ -0,0 +1,128 @@ +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import unittest +import frappe +import datetime +from frappe.utils import getdate, now_datetime +from erpnext.healthcare.doctype.inpatient_record.test_inpatient_record import create_patient, create_inpatient, get_healthcare_service_unit, mark_invoiced_inpatient_occupancy +from erpnext.healthcare.doctype.inpatient_record.inpatient_record import admit_patient, discharge_patient, schedule_discharge +from erpnext.healthcare.doctype.inpatient_medication_order.test_inpatient_medication_order import create_ipmo, create_ipme +from erpnext.healthcare.report.inpatient_medication_orders.inpatient_medication_orders import execute + +class TestInpatientMedicationOrders(unittest.TestCase): + @classmethod + def setUpClass(self): + frappe.db.sql("delete from `tabInpatient Medication Order` where company='_Test Company'") + frappe.db.sql("delete from `tabInpatient Medication Entry` where company='_Test Company'") + self.patient = create_patient() + self.ip_record = create_records(self.patient) + + def test_inpatient_medication_orders_report(self): + filters = { + 'company': '_Test Company', + 'from_date': getdate(), + 'to_date': getdate(), + 'patient': '_Test IPD Patient', + 'service_unit': 'Test Service Unit Ip Occupancy - _TC' + } + + report = execute(filters) + + expected_data = [ + { + 'patient': '_Test IPD Patient', + 'inpatient_record': self.ip_record.name, + 'practitioner': None, + 'drug': 'Dextromethorphan', + 'drug_name': 'Dextromethorphan', + 'dosage': 1.0, + 'dosage_form': 'Tablet', + 'date': getdate(), + 'time': datetime.timedelta(seconds=32400), + 'is_completed': 0, + 'healthcare_service_unit': 'Test Service Unit Ip Occupancy - _TC' + }, + { + 'patient': '_Test IPD Patient', + 'inpatient_record': self.ip_record.name, + 'practitioner': None, + 'drug': 'Dextromethorphan', + 'drug_name': 'Dextromethorphan', + 'dosage': 1.0, + 'dosage_form': 'Tablet', + 'date': getdate(), + 'time': datetime.timedelta(seconds=50400), + 'is_completed': 0, + 'healthcare_service_unit': 'Test Service Unit Ip Occupancy - _TC' + }, + { + 'patient': '_Test IPD Patient', + 'inpatient_record': self.ip_record.name, + 'practitioner': None, + 'drug': 'Dextromethorphan', + 'drug_name': 'Dextromethorphan', + 'dosage': 1.0, + 'dosage_form': 'Tablet', + 'date': getdate(), + 'time': datetime.timedelta(seconds=75600), + 'is_completed': 0, + 'healthcare_service_unit': 'Test Service Unit Ip Occupancy - _TC' + } + ] + + self.assertEqual(expected_data, report[1]) + + filters = frappe._dict(from_date=getdate(), to_date=getdate(), from_time='', to_time='') + ipme = create_ipme(filters) + ipme.submit() + + filters = { + 'company': '_Test Company', + 'from_date': getdate(), + 'to_date': getdate(), + 'patient': '_Test IPD Patient', + 'service_unit': 'Test Service Unit Ip Occupancy - _TC', + 'show_completed_orders': 0 + } + + report = execute(filters) + self.assertEqual(len(report[1]), 0) + + def tearDown(self): + if frappe.db.get_value('Patient', self.patient, 'inpatient_record'): + # cleanup - Discharge + schedule_discharge(frappe.as_json({'patient': self.patient})) + self.ip_record.reload() + mark_invoiced_inpatient_occupancy(self.ip_record) + + self.ip_record.reload() + discharge_patient(self.ip_record) + + for entry in frappe.get_all('Inpatient Medication Entry'): + doc = frappe.get_doc('Inpatient Medication Entry', entry.name) + doc.cancel() + doc.delete() + + for entry in frappe.get_all('Inpatient Medication Order'): + doc = frappe.get_doc('Inpatient Medication Order', entry.name) + doc.cancel() + doc.delete() + + +def create_records(patient): + frappe.db.sql("""delete from `tabInpatient Record`""") + + # Admit + ip_record = create_inpatient(patient) + ip_record.expected_length_of_stay = 0 + ip_record.save() + ip_record.reload() + service_unit = get_healthcare_service_unit('Test Service Unit Ip Occupancy') + admit_patient(ip_record, service_unit, now_datetime()) + + ipmo = create_ipmo(patient) + ipmo.submit() + + return ip_record diff --git a/erpnext/healthcare/setup.py b/erpnext/healthcare/setup.py index 06840801d37..bf4df7e4c88 100644 --- a/erpnext/healthcare/setup.py +++ b/erpnext/healthcare/setup.py @@ -16,6 +16,7 @@ def setup_healthcare(): create_healthcare_item_groups() create_sensitivity() add_healthcare_service_unit_tree_root() + setup_patient_history_settings() def create_medical_departments(): departments = [ @@ -213,3 +214,82 @@ def get_company(): if company: return company[0].name return None + +def setup_patient_history_settings(): + import json + + settings = frappe.get_single('Patient History Settings') + configuration = get_patient_history_config() + for dt, config in configuration.items(): + settings.append("standard_doctypes", { + "document_type": dt, + "date_fieldname": config[0], + "selected_fields": json.dumps(config[1]) + }) + settings.save() + +def get_patient_history_config(): + return { + "Patient Encounter": ("encounter_date", [ + {"label": "Healthcare Practitioner", "fieldname": "practitioner", "fieldtype": "Link"}, + {"label": "Symptoms", "fieldname": "symptoms", "fieldtype": "Table Multiselect"}, + {"label": "Diagnosis", "fieldname": "diagnosis", "fieldtype": "Table Multiselect"}, + {"label": "Drug Prescription", "fieldname": "drug_prescription", "fieldtype": "Table"}, + {"label": "Lab Tests", "fieldname": "lab_test_prescription", "fieldtype": "Table"}, + {"label": "Clinical Procedures", "fieldname": "procedure_prescription", "fieldtype": "Table"}, + {"label": "Therapies", "fieldname": "therapies", "fieldtype": "Table"}, + {"label": "Review Details", "fieldname": "encounter_comment", "fieldtype": "Small Text"} + ]), + "Clinical Procedure": ("start_date", [ + {"label": "Procedure Template", "fieldname": "procedure_template", "fieldtype": "Link"}, + {"label": "Healthcare Practitioner", "fieldname": "practitioner", "fieldtype": "Link"}, + {"label": "Notes", "fieldname": "notes", "fieldtype": "Small Text"}, + {"label": "Service Unit", "fieldname": "service_unit", "fieldtype": "Healthcare Service Unit"}, + {"label": "Start Time", "fieldname": "start_time", "fieldtype": "Time"}, + {"label": "Sample", "fieldname": "sample", "fieldtype": "Link"} + ]), + "Lab Test": ("result_date", [ + {"label": "Test Template", "fieldname": "template", "fieldtype": "Link"}, + {"label": "Healthcare Practitioner", "fieldname": "practitioner", "fieldtype": "Link"}, + {"label": "Test Name", "fieldname": "lab_test_name", "fieldtype": "Data"}, + {"label": "Lab Technician Name", "fieldname": "employee_name", "fieldtype": "Data"}, + {"label": "Sample ID", "fieldname": "sample", "fieldtype": "Link"}, + {"label": "Normal Test Result", "fieldname": "normal_test_items", "fieldtype": "Table"}, + {"label": "Descriptive Test Result", "fieldname": "descriptive_test_items", "fieldtype": "Table"}, + {"label": "Organism Test Result", "fieldname": "organism_test_items", "fieldtype": "Table"}, + {"label": "Sensitivity Test Result", "fieldname": "sensitivity_test_items", "fieldtype": "Table"}, + {"label": "Comments", "fieldname": "lab_test_comment", "fieldtype": "Table"} + ]), + "Therapy Session": ("start_date", [ + {"label": "Therapy Type", "fieldname": "therapy_type", "fieldtype": "Link"}, + {"label": "Healthcare Practitioner", "fieldname": "practitioner", "fieldtype": "Link"}, + {"label": "Therapy Plan", "fieldname": "therapy_plan", "fieldtype": "Link"}, + {"label": "Duration", "fieldname": "duration", "fieldtype": "Int"}, + {"label": "Location", "fieldname": "location", "fieldtype": "Link"}, + {"label": "Healthcare Service Unit", "fieldname": "service_unit", "fieldtype": "Link"}, + {"label": "Start Time", "fieldname": "start_time", "fieldtype": "Time"}, + {"label": "Exercises", "fieldname": "exercises", "fieldtype": "Table"}, + {"label": "Total Counts Targeted", "fieldname": "total_counts_targeted", "fieldtype": "Int"}, + {"label": "Total Counts Completed", "fieldname": "total_counts_completed", "fieldtype": "Int"} + ]), + "Vital Signs": ("signs_date", [ + {"label": "Body Temperature", "fieldname": "temperature", "fieldtype": "Data"}, + {"label": "Heart Rate / Pulse", "fieldname": "pulse", "fieldtype": "Data"}, + {"label": "Respiratory rate", "fieldname": "respiratory_rate", "fieldtype": "Data"}, + {"label": "Tongue", "fieldname": "tongue", "fieldtype": "Select"}, + {"label": "Abdomen", "fieldname": "abdomen", "fieldtype": "Select"}, + {"label": "Reflexes", "fieldname": "reflexes", "fieldtype": "Select"}, + {"label": "Blood Pressure", "fieldname": "bp", "fieldtype": "Data"}, + {"label": "Notes", "fieldname": "vital_signs_note", "fieldtype": "Small Text"}, + {"label": "Height (In Meter)", "fieldname": "height", "fieldtype": "Float"}, + {"label": "Weight (In Kilogram)", "fieldname": "weight", "fieldtype": "Float"}, + {"label": "BMI", "fieldname": "bmi", "fieldtype": "Float"} + ]), + "Inpatient Medication Order": ("start_date", [ + {"label": "Healthcare Practitioner", "fieldname": "practitioner", "fieldtype": "Link"}, + {"label": "Start Date", "fieldname": "start_date", "fieldtype": "Date"}, + {"label": "End Date", "fieldname": "end_date", "fieldtype": "Date"}, + {"label": "Medication Orders", "fieldname": "medication_orders", "fieldtype": "Table"}, + {"label": "Total Orders", "fieldname": "total_orders", "fieldtype": "Float"} + ]) + } \ No newline at end of file diff --git a/erpnext/healthcare/utils.py b/erpnext/healthcare/utils.py index dbd3b83f09b..d3d22c80b67 100644 --- a/erpnext/healthcare/utils.py +++ b/erpnext/healthcare/utils.py @@ -5,8 +5,11 @@ from __future__ import unicode_literals import math import frappe +import json from frappe import _ +from frappe.utils.formatters import format_value from frappe.utils import time_diff_in_hours, rounded +from six import string_types from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import get_income_account from erpnext.healthcare.doctype.fee_validity.fee_validity import create_fee_validity from erpnext.healthcare.doctype.lab_test.lab_test import create_multiple @@ -23,18 +26,19 @@ def get_healthcare_services_to_invoice(patient, company): items_to_invoice += get_lab_tests_to_invoice(patient, company) items_to_invoice += get_clinical_procedures_to_invoice(patient, company) items_to_invoice += get_inpatient_services_to_invoice(patient, company) + items_to_invoice += get_therapy_plans_to_invoice(patient, company) items_to_invoice += get_therapy_sessions_to_invoice(patient, company) - return items_to_invoice def validate_customer_created(patient): if not frappe.db.get_value('Patient', patient.name, 'customer'): msg = _("Please set a Customer linked to the Patient") - msg += " {0}".format(patient.name) + msg += " {0}".format(patient.name) frappe.throw(msg, title=_('Customer Not Found')) + def get_appointments_to_invoice(patient, company): appointments_to_invoice = [] patient_appointments = frappe.get_list( @@ -62,7 +66,9 @@ def get_appointments_to_invoice(patient, company): income_account = None service_item = None if appointment.practitioner: - service_item, practitioner_charge = get_service_item_and_practitioner_charge(appointment) + details = get_service_item_and_practitioner_charge(appointment) + service_item = details.get('service_item') + practitioner_charge = details.get('practitioner_charge') income_account = get_income_account(appointment.practitioner, appointment.company) appointments_to_invoice.append({ 'reference_type': 'Patient Appointment', @@ -76,11 +82,13 @@ def get_appointments_to_invoice(patient, company): def get_encounters_to_invoice(patient, company): + if not isinstance(patient, str): + patient = patient.name encounters_to_invoice = [] encounters = frappe.get_list( 'Patient Encounter', fields=['*'], - filters={'patient': patient.name, 'company': company, 'invoiced': False, 'docstatus': 1} + filters={'patient': patient, 'company': company, 'invoiced': False, 'docstatus': 1} ) if encounters: for encounter in encounters: @@ -89,7 +97,13 @@ def get_encounters_to_invoice(patient, company): income_account = None service_item = None if encounter.practitioner: - service_item, practitioner_charge = get_service_item_and_practitioner_charge(encounter) + if encounter.inpatient_record and \ + frappe.db.get_single_value('Healthcare Settings', 'do_not_bill_inpatient_encounters'): + continue + + details = get_service_item_and_practitioner_charge(encounter) + service_item = details.get('service_item') + practitioner_charge = details.get('practitioner_charge') income_account = get_income_account(encounter.practitioner, encounter.company) encounters_to_invoice.append({ @@ -165,10 +179,10 @@ def get_clinical_procedures_to_invoice(patient, company): if procedure.invoice_separately_as_consumables and procedure.consume_stock \ and procedure.status == 'Completed' and not procedure.consumption_invoiced: - service_item = get_healthcare_service_item('clinical_procedure_consumable_item') + service_item = frappe.db.get_single_value('Healthcare Settings', 'clinical_procedure_consumable_item') if not service_item: msg = _('Please Configure Clinical Procedure Consumable Item in ') - msg += '''Healthcare Settings''' + msg += '''Healthcare Settings''' frappe.throw(msg, title=_('Missing Configuration')) clinical_procedures_to_invoice.append({ @@ -246,12 +260,44 @@ def get_inpatient_services_to_invoice(patient, company): return services_to_invoice +def get_therapy_plans_to_invoice(patient, company): + therapy_plans_to_invoice = [] + therapy_plans = frappe.get_list( + 'Therapy Plan', + fields=['therapy_plan_template', 'name'], + filters={ + 'patient': patient.name, + 'invoiced': 0, + 'company': company, + 'therapy_plan_template': ('!=', '') + } + ) + for plan in therapy_plans: + therapy_plans_to_invoice.append({ + 'reference_type': 'Therapy Plan', + 'reference_name': plan.name, + 'service': frappe.db.get_value('Therapy Plan Template', plan.therapy_plan_template, 'linked_item') + }) + + return therapy_plans_to_invoice + + def get_therapy_sessions_to_invoice(patient, company): therapy_sessions_to_invoice = [] + therapy_plans = frappe.db.get_all('Therapy Plan', {'therapy_plan_template': ('!=', '')}) + therapy_plans_created_from_template = [] + for entry in therapy_plans: + therapy_plans_created_from_template.append(entry.name) + therapy_sessions = frappe.get_list( 'Therapy Session', fields='*', - filters={'patient': patient.name, 'invoiced': 0, 'company': company} + filters={ + 'patient': patient.name, + 'invoiced': 0, + 'company': company, + 'therapy_plan': ('not in', therapy_plans_created_from_template) + } ) for therapy in therapy_sessions: if not therapy.appointment: @@ -264,24 +310,50 @@ def get_therapy_sessions_to_invoice(patient, company): return therapy_sessions_to_invoice - +@frappe.whitelist() def get_service_item_and_practitioner_charge(doc): + if isinstance(doc, string_types): + doc = json.loads(doc) + doc = frappe.get_doc(doc) + + service_item = None + practitioner_charge = None + department = doc.medical_department if doc.doctype == 'Patient Encounter' else doc.department + is_inpatient = doc.inpatient_record - if is_inpatient: - service_item = get_practitioner_service_item(doc.practitioner, 'inpatient_visit_charge_item') + + if doc.get('appointment_type'): + service_item, practitioner_charge = get_appointment_type_service_item(doc.appointment_type, department, is_inpatient) + + if not service_item and not practitioner_charge: + service_item, practitioner_charge = get_practitioner_service_item(doc.practitioner, is_inpatient) if not service_item: - service_item = get_healthcare_service_item('inpatient_visit_charge_item') - else: - service_item = get_practitioner_service_item(doc.practitioner, 'op_consulting_charge_item') - if not service_item: - service_item = get_healthcare_service_item('op_consulting_charge_item') + service_item = get_healthcare_service_item(is_inpatient) + if not service_item: throw_config_service_item(is_inpatient) - practitioner_charge = get_practitioner_charge(doc.practitioner, is_inpatient) if not practitioner_charge: throw_config_practitioner_charge(is_inpatient, doc.practitioner) + return {'service_item': service_item, 'practitioner_charge': practitioner_charge} + + +def get_appointment_type_service_item(appointment_type, department, is_inpatient): + from erpnext.healthcare.doctype.appointment_type.appointment_type import get_service_item_based_on_department + + item_list = get_service_item_based_on_department(appointment_type, department) + service_item = None + practitioner_charge = None + + if item_list: + if is_inpatient: + service_item = item_list.get('inpatient_visit_charge_item') + practitioner_charge = item_list.get('inpatient_visit_charge') + else: + service_item = item_list.get('op_consulting_charge_item') + practitioner_charge = item_list.get('op_consulting_charge') + return service_item, practitioner_charge @@ -291,7 +363,7 @@ def throw_config_service_item(is_inpatient): service_item_label = _('Inpatient Visit Charge Item') msg = _(('Please Configure {0} in ').format(service_item_label) \ - + '''Healthcare Settings''') + + '''Healthcare Settings''') frappe.throw(msg, title=_('Missing Configuration')) @@ -301,16 +373,31 @@ def throw_config_practitioner_charge(is_inpatient, practitioner): charge_name = _('Inpatient Visit Charge') msg = _(('Please Configure {0} for Healthcare Practitioner').format(charge_name) \ - + ''' {0}'''.format(practitioner)) + + ''' {0}'''.format(practitioner)) frappe.throw(msg, title=_('Missing Configuration')) -def get_practitioner_service_item(practitioner, service_item_field): - return frappe.db.get_value('Healthcare Practitioner', practitioner, service_item_field) +def get_practitioner_service_item(practitioner, is_inpatient): + service_item = None + practitioner_charge = None + + if is_inpatient: + service_item, practitioner_charge = frappe.db.get_value('Healthcare Practitioner', practitioner, ['inpatient_visit_charge_item', 'inpatient_visit_charge']) + else: + service_item, practitioner_charge = frappe.db.get_value('Healthcare Practitioner', practitioner, ['op_consulting_charge_item', 'op_consulting_charge']) + + return service_item, practitioner_charge -def get_healthcare_service_item(service_item_field): - return frappe.db.get_single_value('Healthcare Settings', service_item_field) +def get_healthcare_service_item(is_inpatient): + service_item = None + + if is_inpatient: + service_item = frappe.db.get_single_value('Healthcare Settings', 'inpatient_visit_charge_item') + else: + service_item = frappe.db.get_single_value('Healthcare Settings', 'op_consulting_charge_item') + + return service_item def get_practitioner_charge(practitioner, is_inpatient): @@ -341,7 +428,8 @@ def set_invoiced(item, method, ref_invoice=None): invoiced = True if item.reference_dt == 'Clinical Procedure': - if get_healthcare_service_item('clinical_procedure_consumable_item') == item.item_code: + service_item = frappe.db.get_single_value('Healthcare Settings', 'clinical_procedure_consumable_item') + if service_item == item.item_code: frappe.db.set_value(item.reference_dt, item.reference_dn, 'consumption_invoiced', invoiced) else: frappe.db.set_value(item.reference_dt, item.reference_dn, 'invoiced', invoiced) @@ -363,13 +451,14 @@ def set_invoiced(item, method, ref_invoice=None): def validate_invoiced_on_submit(item): - if item.reference_dt == 'Clinical Procedure' and get_healthcare_service_item('clinical_procedure_consumable_item') == item.item_code: + if item.reference_dt == 'Clinical Procedure' and \ + frappe.db.get_single_value('Healthcare Settings', 'clinical_procedure_consumable_item') == item.item_code: is_invoiced = frappe.db.get_value(item.reference_dt, item.reference_dn, 'consumption_invoiced') else: is_invoiced = frappe.db.get_value(item.reference_dt, item.reference_dn, 'invoiced') if is_invoiced: - frappe.throw(_('The item referenced by {0} - {1} is already invoiced'\ - ).format(item.reference_dt, item.reference_dn)) + frappe.throw(_('The item referenced by {0} - {1} is already invoiced').format( + item.reference_dt, item.reference_dn)) def manage_prescriptions(invoiced, ref_dt, ref_dn, dt, created_check_field): @@ -609,11 +698,15 @@ def render_doc_as_html(doctype, docname, exclude_fields = []): html += "" \ + table_head + table_row + "
    " continue + #on other field types add label and value to html if not df.hidden and not df.print_hide and doc.get(df.fieldname) and df.fieldname not in exclude_fields: - html += '
    {0} : {1}'.format(df.label or df.fieldname, \ - doc.get(df.fieldname)) + if doc.get(df.fieldname): + formatted_value = format_value(doc.get(df.fieldname), meta.get_field(df.fieldname), doc) + html += '
    {0} : {1}'.format(df.label or df.fieldname, formatted_value) + if not has_data : has_data = True + if sec_on and col_on and has_data: doc_html += section_html + html + '
    ' elif sec_on and not col_on and has_data: @@ -621,6 +714,6 @@ def render_doc_as_html(doctype, docname, exclude_fields = []): >
    " \ + section_html + html +'
    ' if doc_html: - doc_html = "
    " %(doctype, docname) + doc_html + '
    ' + doc_html = "
    " %(doctype, docname) + doc_html + '
    ' return {'html': doc_html} diff --git a/erpnext/healthcare/workspace/healthcare/healthcare.json b/erpnext/healthcare/workspace/healthcare/healthcare.json new file mode 100644 index 00000000000..b93dda2e879 --- /dev/null +++ b/erpnext/healthcare/workspace/healthcare/healthcare.json @@ -0,0 +1,536 @@ +{ + "category": "Domains", + "charts": [ + { + "chart_name": "Patient Appointments", + "label": "Patient Appointments" + } + ], + "charts_label": "", + "creation": "2020-03-02 17:23:17.919682", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "healthcare", + "idx": 0, + "is_standard": 1, + "label": "Healthcare", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Masters", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Patient", + "link_to": "Patient", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Healthcare Practitioner", + "link_to": "Healthcare Practitioner", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Practitioner Schedule", + "link_to": "Practitioner Schedule", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Medical Department", + "link_to": "Medical Department", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Healthcare Service Unit Type", + "link_to": "Healthcare Service Unit Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Healthcare Service Unit", + "link_to": "Healthcare Service Unit", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Medical Code Standard", + "link_to": "Medical Code Standard", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Medical Code", + "link_to": "Medical Code", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Consultation Setup", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Appointment Type", + "link_to": "Appointment Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Clinical Procedure Template", + "link_to": "Clinical Procedure Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Prescription Dosage", + "link_to": "Prescription Dosage", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Prescription Duration", + "link_to": "Prescription Duration", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Antibiotic", + "link_to": "Antibiotic", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Consultation", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Patient Appointment", + "link_to": "Patient Appointment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Clinical Procedure", + "link_to": "Clinical Procedure", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Patient Encounter", + "link_to": "Patient Encounter", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Vital Signs", + "link_to": "Vital Signs", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Complaint", + "link_to": "Complaint", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Diagnosis", + "link_to": "Diagnosis", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Fee Validity", + "link_to": "Fee Validity", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Healthcare Settings", + "link_to": "Healthcare Settings", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Laboratory Setup", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lab Test Template", + "link_to": "Lab Test Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lab Test Sample", + "link_to": "Lab Test Sample", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lab Test UOM", + "link_to": "Lab Test UOM", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Sensitivity", + "link_to": "Sensitivity", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Laboratory", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lab Test", + "link_to": "Lab Test", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Sample Collection", + "link_to": "Sample Collection", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Dosage Form", + "link_to": "Dosage Form", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Rehabilitation and Physiotherapy", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Exercise Type", + "link_to": "Exercise Type", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Therapy Type", + "link_to": "Therapy Type", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Therapy Plan", + "link_to": "Therapy Plan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Therapy Session", + "link_to": "Therapy Session", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Patient Assessment Template", + "link_to": "Patient Assessment Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Patient Assessment", + "link_to": "Patient Assessment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Records and History", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Patient History", + "link_to": "patient_history", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Patient Progress", + "link_to": "patient-progress", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Patient Medical Record", + "link_to": "Patient Medical Record", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Inpatient Record", + "link_to": "Inpatient Record", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Patient Appointment Analytics", + "link_to": "Patient Appointment Analytics", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Lab Test Report", + "link_to": "Lab Test Report", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:34.841396", + "modified_by": "Administrator", + "module": "Healthcare", + "name": "Healthcare", + "onboarding": "Healthcare", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "restrict_to_domain": "Healthcare", + "shortcuts": [ + { + "color": "Orange", + "format": "{} Open", + "label": "Patient Appointment", + "link_to": "Patient Appointment", + "stats_filter": "{\n \"status\": \"Open\",\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%']\n}", + "type": "DocType" + }, + { + "color": "Orange", + "format": "{} Active", + "label": "Patient", + "link_to": "Patient", + "stats_filter": "{\n \"status\": \"Active\"\n}", + "type": "DocType" + }, + { + "color": "Green", + "format": "{} Vacant", + "label": "Healthcare Service Unit", + "link_to": "Healthcare Service Unit", + "stats_filter": "{\n \"occupancy_status\": \"Vacant\",\n \"is_group\": 0,\n \"company\": [\"like\", \"%\" + frappe.defaults.get_global_default(\"company\") + \"%\"]\n}", + "type": "DocType" + }, + { + "label": "Healthcare Practitioner", + "link_to": "Healthcare Practitioner", + "type": "DocType" + }, + { + "label": "Patient History", + "link_to": "patient_history", + "type": "Page" + }, + { + "label": "Dashboard", + "link_to": "Healthcare", + "type": "Dashboard" + } + ] +} \ No newline at end of file diff --git a/erpnext/hooks.py b/erpnext/hooks.py index abb34b823df..c2798a36b6d 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -10,22 +10,27 @@ app_color = "#e74c3c" app_email = "info@erpnext.com" app_license = "GNU General Public License (v3)" source_link = "https://github.com/frappe/erpnext" -app_logo_url = '/assets/erpnext/images/erp-icon.svg' +app_logo_url = "/assets/erpnext/images/erpnext-logo.svg" develop_version = '13.x.x-develop' -app_include_js = "assets/js/erpnext.min.js" -app_include_css = "assets/css/erpnext.css" -web_include_js = "assets/js/erpnext-web.min.js" -web_include_css = "assets/css/erpnext-web.css" +app_include_js = "/assets/js/erpnext.min.js" +app_include_css = "/assets/css/erpnext.css" +web_include_js = "/assets/js/erpnext-web.min.js" +web_include_css = "/assets/css/erpnext-web.css" doctype_js = { + "Address": "public/js/address.js", "Communication": "public/js/communication.js", "Event": "public/js/event.js", "Newsletter": "public/js/newsletter.js" } +override_doctype_class = { + 'Address': 'erpnext.accounts.custom.address.ERPNextAddress' +} + welcome_email = "erpnext.setup.utils.welcome_email" # setup wizard @@ -41,6 +46,7 @@ notification_config = "erpnext.startup.notifications.get_notification_config" get_help_messages = "erpnext.utilities.activation.get_help_messages" leaderboards = "erpnext.startup.leaderboard.get_leaderboards" filters_config = "erpnext.startup.filters.get_filters_config" +additional_print_settings = "erpnext.controllers.print_settings.get_print_settings" on_session_creation = [ "erpnext.portal.utils.create_customer_or_supplier", @@ -72,8 +78,8 @@ website_generators = ["Item Group", "Item", "BOM", "Sales Partner", "Job Opening", "Student Admission"] website_context = { - "favicon": "/assets/erpnext/images/favicon.png", - "splash_image": "/assets/erpnext/images/erp-icon.svg" + "favicon": "/assets/erpnext/images/erpnext-favicon.svg", + "splash_image": "/assets/erpnext/images/erpnext-logo.svg" } website_route_rules = [ @@ -189,6 +195,10 @@ sounds = [ {"name": "call-disconnect", "src": "/assets/erpnext/sounds/call-disconnect.mp3", "volume": 0.2}, ] +has_upload_permission = { + "Employee": "erpnext.hr.doctype.employee.employee.has_upload_permission" +} + has_website_permission = { "Sales Order": "erpnext.controllers.website_list_for_contact.has_website_permission", "Quotation": "erpnext.controllers.website_list_for_contact.has_website_permission", @@ -216,6 +226,11 @@ standard_queries = { } doc_events = { + "*": { + "on_submit": "erpnext.healthcare.doctype.patient_history_settings.patient_history_settings.create_medical_record", + "on_update_after_submit": "erpnext.healthcare.doctype.patient_history_settings.patient_history_settings.update_medical_record", + "on_cancel": "erpnext.healthcare.doctype.patient_history_settings.patient_history_settings.delete_medical_record" + }, "Stock Entry": { "on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty", "on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty" @@ -232,6 +247,9 @@ doc_events = { "Website Settings": { "validate": "erpnext.portal.doctype.products_settings.products_settings.home_page_is_products" }, + "Tax Category": { + "validate": "erpnext.regional.india.utils.validate_tax_category" + }, "Sales Invoice": { "on_submit": [ "erpnext.regional.create_transaction_log", @@ -245,7 +263,11 @@ doc_events = { "on_trash": "erpnext.regional.check_deletion_permission" }, "Purchase Invoice": { - "validate": "erpnext.regional.india.utils.update_grand_total_for_rcm" + "validate": [ + "erpnext.regional.india.utils.update_grand_total_for_rcm", + "erpnext.regional.united_arab_emirates.utils.update_grand_total_for_rcm", + "erpnext.regional.united_arab_emirates.utils.validate_returns" + ] }, "Payment Entry": { "on_submit": ["erpnext.regional.create_transaction_log", "erpnext.accounts.doctype.payment_request.payment_request.update_payment_req_status", "erpnext.accounts.doctype.dunning.dunning.resolve_dunning"], @@ -254,17 +276,20 @@ doc_events = { 'Address': { 'validate': ['erpnext.regional.india.utils.validate_gstin_for_india', 'erpnext.regional.italy.utils.set_state_code', 'erpnext.regional.india.utils.update_gst_category'] }, + 'Supplier': { + 'validate': 'erpnext.regional.india.utils.validate_pan_for_india' + }, ('Sales Invoice', 'Sales Order', 'Delivery Note', 'Purchase Invoice', 'Purchase Order', 'Purchase Receipt'): { 'validate': ['erpnext.regional.india.utils.set_place_of_supply'] }, + ('Sales Invoice', 'Purchase Invoice'): { + 'validate': ['erpnext.regional.india.utils.validate_document_name'] + }, "Contact": { "on_trash": "erpnext.support.doctype.issue.issue.update_issue", - "after_insert": "erpnext.communication.doctype.call_log.call_log.set_caller_information", + "after_insert": "erpnext.telephony.doctype.call_log.call_log.link_existing_conversations", "validate": "erpnext.crm.utils.update_lead_phone_numbers" }, - "Lead": { - "after_insert": "erpnext.communication.doctype.call_log.call_log.set_caller_information" - }, "Email Unsubscribe": { "after_insert": "erpnext.crm.doctype.email_campaign.email_campaign.unsubscribe_recipient" }, @@ -277,7 +302,8 @@ doc_events = { # to maintain data integrity we exempted payment entry. it will un-link when sales invoice get cancelled. # if payment entry not in auto cancel exempted doctypes it will cancel payment entry. auto_cancel_exempted_doctypes= [ - "Payment Entry" + "Payment Entry", + "Inpatient Medication Entry" ] scheduler_events = { @@ -301,6 +327,8 @@ scheduler_events = { "erpnext.projects.doctype.project.project.collect_project_status", "erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts", "erpnext.support.doctype.issue.issue.set_service_level_agreement_variance", + "erpnext.erpnext_integrations.connectors.shopify_connection.sync_old_orders", + "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries" ], "daily": [ "erpnext.stock.reorder_item.reorder_item", @@ -327,21 +355,24 @@ scheduler_events = { "erpnext.selling.doctype.quotation.quotation.set_expired_status", "erpnext.healthcare.doctype.patient_appointment.patient_appointment.update_appointment_status", "erpnext.buying.doctype.supplier_quotation.supplier_quotation.set_expired_status", - "erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_auto_email" + "erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_auto_email", + "erpnext.non_profit.doctype.membership.membership.set_expired_status" ], "daily_long": [ "erpnext.setup.doctype.email_digest.email_digest.send", "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.update_latest_price_in_all_boms", "erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry.process_expired_allocation", + "erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment.automatically_allocate_leaves_based_on_leave_policy", "erpnext.hr.utils.generate_leave_encashment", - "erpnext.loan_management.doctype.loan_security_shortfall.loan_security_shortfall.create_process_loan_security_shortfall", - "erpnext.loan_management.doctype.loan_interest_accrual.loan_interest_accrual.process_loan_interest_accrual_for_term_loans", + "erpnext.hr.utils.allocate_earned_leaves", + "erpnext.hr.utils.grant_leaves_automatically", + "erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall.create_process_loan_security_shortfall", + "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_term_loans", "erpnext.crm.doctype.lead.lead.daily_open_lead" ], "monthly_long": [ "erpnext.accounts.deferred_revenue.process_deferred_accounting", - "erpnext.hr.utils.allocate_earned_leaves", - "erpnext.loan_management.doctype.loan_interest_accrual.loan_interest_accrual.process_loan_interest_accrual_for_demand_loans" + "erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual.process_loan_interest_accrual_for_demand_loans" ] } @@ -370,6 +401,15 @@ payment_gateway_enabled = "erpnext.accounts.utils.create_payment_gateway_account communication_doctypes = ["Customer", "Supplier"] +accounting_dimension_doctypes = ["GL Entry", "Sales Invoice", "Purchase Invoice", "Payment Entry", "Asset", + "Expense Claim", "Expense Claim Detail", "Expense Taxes and Charges", "Stock Entry", "Budget", "Payroll Entry", "Delivery Note", + "Sales Invoice Item", "Purchase Invoice Item", "Purchase Order Item", "Journal Entry Account", "Material Request Item", "Delivery Note Item", + "Purchase Receipt Item", "Stock Entry Detail", "Payment Entry Deduction", "Sales Taxes and Charges", "Purchase Taxes and Charges", "Shipping Rule", + "Landed Cost Item", "Asset Value Adjustment", "Loyalty Program", "Fee Schedule", "Fee Structure", "Stock Reconciliation", + "Travel Request", "Fees", "POS Profile", "Opening Invoice Creation Tool", "Opening Invoice Creation Tool Item", "Subscription", + "Subscription Plan" +] + regional_overrides = { 'France': { 'erpnext.tests.test_regional.test_method': 'erpnext.regional.france.utils.test_method' @@ -379,12 +419,15 @@ regional_overrides = { 'erpnext.controllers.taxes_and_totals.get_itemised_tax_breakup_header': 'erpnext.regional.india.utils.get_itemised_tax_breakup_header', 'erpnext.controllers.taxes_and_totals.get_itemised_tax_breakup_data': 'erpnext.regional.india.utils.get_itemised_tax_breakup_data', 'erpnext.accounts.party.get_regional_address_details': 'erpnext.regional.india.utils.get_regional_address_details', + 'erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts': 'erpnext.regional.india.utils.get_regional_round_off_accounts', 'erpnext.hr.utils.calculate_annual_eligible_hra_exemption': 'erpnext.regional.india.utils.calculate_annual_eligible_hra_exemption', 'erpnext.hr.utils.calculate_hra_exemption_for_period': 'erpnext.regional.india.utils.calculate_hra_exemption_for_period', - 'erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_regional_gl_entries': 'erpnext.regional.india.utils.make_regional_gl_entries' + 'erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_regional_gl_entries': 'erpnext.regional.india.utils.make_regional_gl_entries', + 'erpnext.controllers.accounts_controller.validate_einvoice_fields': 'erpnext.regional.india.e_invoice.utils.validate_einvoice_fields' }, 'United Arab Emirates': { - 'erpnext.controllers.taxes_and_totals.update_itemised_tax_data': 'erpnext.regional.united_arab_emirates.utils.update_itemised_tax_data' + 'erpnext.controllers.taxes_and_totals.update_itemised_tax_data': 'erpnext.regional.united_arab_emirates.utils.update_itemised_tax_data', + 'erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_regional_gl_entries': 'erpnext.regional.united_arab_emirates.utils.make_regional_gl_entries', }, 'Saudi Arabia': { 'erpnext.controllers.taxes_and_totals.update_itemised_tax_data': 'erpnext.regional.united_arab_emirates.utils.update_itemised_tax_data' @@ -421,42 +464,43 @@ global_search_doctypes = { {"doctype": "Sales Order", "index": 8}, {"doctype": "Quotation", "index": 9}, {"doctype": "Work Order", "index": 10}, - {"doctype": "Purchase Receipt", "index": 11}, - {"doctype": "Purchase Invoice", "index": 12}, - {"doctype": "Delivery Note", "index": 13}, - {"doctype": "Stock Entry", "index": 14}, - {"doctype": "Material Request", "index": 15}, - {"doctype": "Delivery Trip", "index": 16}, - {"doctype": "Pick List", "index": 17}, - {"doctype": "Salary Slip", "index": 18}, - {"doctype": "Leave Application", "index": 19}, - {"doctype": "Expense Claim", "index": 20}, - {"doctype": "Payment Entry", "index": 21}, - {"doctype": "Lead", "index": 22}, - {"doctype": "Opportunity", "index": 23}, - {"doctype": "Item Price", "index": 24}, - {"doctype": "Purchase Taxes and Charges Template", "index": 25}, - {"doctype": "Sales Taxes and Charges", "index": 26}, - {"doctype": "Asset", "index": 27}, - {"doctype": "Project", "index": 28}, - {"doctype": "Task", "index": 29}, - {"doctype": "Timesheet", "index": 30}, - {"doctype": "Issue", "index": 31}, - {"doctype": "Serial No", "index": 32}, - {"doctype": "Batch", "index": 33}, - {"doctype": "Branch", "index": 34}, - {"doctype": "Department", "index": 35}, - {"doctype": "Employee Grade", "index": 36}, - {"doctype": "Designation", "index": 37}, - {"doctype": "Job Opening", "index": 38}, - {"doctype": "Job Applicant", "index": 39}, - {"doctype": "Job Offer", "index": 40}, - {"doctype": "Salary Structure Assignment", "index": 41}, - {"doctype": "Appraisal", "index": 42}, - {"doctype": "Loan", "index": 43}, - {"doctype": "Maintenance Schedule", "index": 44}, - {"doctype": "Maintenance Visit", "index": 45}, - {"doctype": "Warranty Claim", "index": 46}, + {"doctype": "Purchase Order", "index": 11}, + {"doctype": "Purchase Receipt", "index": 12}, + {"doctype": "Purchase Invoice", "index": 13}, + {"doctype": "Delivery Note", "index": 14}, + {"doctype": "Stock Entry", "index": 15}, + {"doctype": "Material Request", "index": 16}, + {"doctype": "Delivery Trip", "index": 17}, + {"doctype": "Pick List", "index": 18}, + {"doctype": "Salary Slip", "index": 19}, + {"doctype": "Leave Application", "index": 20}, + {"doctype": "Expense Claim", "index": 21}, + {"doctype": "Payment Entry", "index": 22}, + {"doctype": "Lead", "index": 23}, + {"doctype": "Opportunity", "index": 24}, + {"doctype": "Item Price", "index": 25}, + {"doctype": "Purchase Taxes and Charges Template", "index": 26}, + {"doctype": "Sales Taxes and Charges", "index": 27}, + {"doctype": "Asset", "index": 28}, + {"doctype": "Project", "index": 29}, + {"doctype": "Task", "index": 30}, + {"doctype": "Timesheet", "index": 31}, + {"doctype": "Issue", "index": 32}, + {"doctype": "Serial No", "index": 33}, + {"doctype": "Batch", "index": 34}, + {"doctype": "Branch", "index": 35}, + {"doctype": "Department", "index": 36}, + {"doctype": "Employee Grade", "index": 37}, + {"doctype": "Designation", "index": 38}, + {"doctype": "Job Opening", "index": 39}, + {"doctype": "Job Applicant", "index": 40}, + {"doctype": "Job Offer", "index": 41}, + {"doctype": "Salary Structure Assignment", "index": 42}, + {"doctype": "Appraisal", "index": 43}, + {"doctype": "Loan", "index": 44}, + {"doctype": "Maintenance Schedule", "index": 45}, + {"doctype": "Maintenance Visit", "index": 46}, + {"doctype": "Warranty Claim", "index": 47}, ], "Healthcare": [ {'doctype': 'Patient', 'index': 1}, @@ -558,4 +602,8 @@ global_search_doctypes = { {'doctype': 'Hotel Room Package', 'index': 3}, {'doctype': 'Hotel Room Type', 'index': 4} ] -} \ No newline at end of file +} + +additional_timeline_content = { + '*': ['erpnext.telephony.doctype.call_log.call_log.get_linked_call_logs'] +} diff --git a/erpnext/hr/desk_page/hr/hr.json b/erpnext/hr/desk_page/hr/hr.json deleted file mode 100644 index 895cf7290c9..00000000000 --- a/erpnext/hr/desk_page/hr/hr.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Employee", - "links": "[\n {\n \"label\": \"Employee\",\n \"name\": \"Employee\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Employment Type\",\n \"name\": \"Employment Type\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Branch\",\n \"name\": \"Branch\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Department\",\n \"name\": \"Department\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Designation\",\n \"name\": \"Designation\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Employee Grade\",\n \"name\": \"Employee Grade\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Employee Group\",\n \"name\": \"Employee Group\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Employee Health Insurance\",\n \"name\": \"Employee Health Insurance\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Employee Lifecycle", - "links": "[\n {\n \"dependencies\": [\n \"Job Applicant\"\n ],\n \"label\": \"Employee Onboarding\",\n \"name\": \"Employee Onboarding\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Employee Skill Map\",\n \"name\": \"Employee Skill Map\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Employee Promotion\",\n \"name\": \"Employee Promotion\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Employee Transfer\",\n \"name\": \"Employee Transfer\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Employee Separation\",\n \"name\": \"Employee Separation\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Employee Onboarding Template\",\n \"name\": \"Employee Onboarding Template\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Employee Separation Template\",\n \"name\": \"Employee Separation Template\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Shift Management", - "links": "[\n {\n \"label\": \"Shift Type\",\n \"name\": \"Shift Type\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Shift Request\",\n \"name\": \"Shift Request\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Shift Assignment\",\n \"name\": \"Shift Assignment\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Leaves", - "links": "[\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Leave Application\",\n \"name\": \"Leave Application\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Leave Allocation\",\n \"name\": \"Leave Allocation\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Leave Type\"\n ],\n \"label\": \"Leave Policy\",\n \"name\": \"Leave Policy\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Leave Period\",\n \"name\": \"Leave Period\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Leave Type\",\n \"name\": \"Leave Type\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Holiday List\",\n \"name\": \"Holiday List\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Compensatory Leave Request\",\n \"name\": \"Compensatory Leave Request\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Leave Encashment\",\n \"name\": \"Leave Encashment\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Leave Block List\",\n \"name\": \"Leave Block List\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Leave Application\"\n ],\n \"doctype\": \"Leave Application\",\n \"is_query_report\": true,\n \"label\": \"Employee Leave Balance\",\n \"name\": \"Employee Leave Balance\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Attendance", - "links": "[\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"hide_count\": true,\n \"label\": \"Employee Attendance Tool\",\n \"name\": \"Employee Attendance Tool\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Attendance\",\n \"name\": \"Attendance\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Attendance Request\",\n \"name\": \"Attendance Request\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"hide_count\": true,\n \"label\": \"Upload Attendance\",\n \"name\": \"Upload Attendance\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"hide_count\": true,\n \"label\": \"Employee Checkin\",\n \"name\": \"Employee Checkin\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Attendance\"\n ],\n \"doctype\": \"Attendance\",\n \"is_query_report\": true,\n \"label\": \"Monthly Attendance Sheet\",\n \"name\": \"Monthly Attendance Sheet\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Expense Claims", - "links": "[\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Expense Claim\",\n \"name\": \"Expense Claim\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"label\": \"Employee Advance\",\n \"name\": \"Employee Advance\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"label\": \"HR Settings\",\n \"name\": \"HR Settings\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Daily Work Summary Group\",\n \"name\": \"Daily Work Summary Group\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Team Updates\",\n \"name\": \"team-updates\",\n \"type\": \"page\"\n }\n]" - }, - { - "hidden": 0, - "label": "Fleet Management", - "links": "[\n {\n \"label\": \"Vehicle\",\n \"name\": \"Vehicle\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Vehicle Log\",\n \"name\": \"Vehicle Log\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Vehicle\"\n ],\n \"doctype\": \"Vehicle\",\n \"is_query_report\": true,\n \"label\": \"Vehicle Expenses\",\n \"name\": \"Vehicle Expenses\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Recruitment", - "links": "[\n {\n \"label\": \"Job Opening\",\n \"name\": \"Job Opening\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Job Applicant\",\n \"name\": \"Job Applicant\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Job Offer\",\n \"name\": \"Job Offer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Staffing Plan\",\n \"name\": \"Staffing Plan\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Training", - "links": "[\n {\n \"label\": \"Training Program\",\n \"name\": \"Training Program\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Training Event\",\n \"name\": \"Training Event\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Training Result\",\n \"name\": \"Training Result\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Training Feedback\",\n \"name\": \"Training Feedback\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Reports", - "links": "[\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"doctype\": \"Employee\",\n \"is_query_report\": true,\n \"label\": \"Employee Birthday\",\n \"name\": \"Employee Birthday\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"doctype\": \"Employee\",\n \"is_query_report\": true,\n \"label\": \"Employees working on a holiday\",\n \"name\": \"Employees working on a holiday\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"doctype\": \"Employee\",\n \"is_query_report\": true,\n \"label\": \"Department Analytics\",\n \"name\": \"Department Analytics\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Performance", - "links": "[\n {\n \"label\": \"Appraisal\",\n \"name\": \"Appraisal\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Appraisal Template\",\n \"name\": \"Appraisal Template\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Energy Point Rule\",\n \"name\": \"Energy Point Rule\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Energy Point Log\",\n \"name\": \"Energy Point Log\",\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Modules", - "charts": [ - { - "chart_name": "Attendance Count", - "label": "Attendance Count" - } - ], - "creation": "2020-03-02 15:48:58.322521", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "HR", - "modified": "2020-08-11 17:04:38.655417", - "modified_by": "Administrator", - "module": "HR", - "name": "HR", - "onboarding": "Human Resource", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "color": "#cef6d1", - "format": "{} Active", - "label": "Employee", - "link_to": "Employee", - "stats_filter": "{\"status\":\"Active\"}", - "type": "DocType" - }, - { - "color": "#ffe8cd", - "format": "{} Open", - "label": "Leave Application", - "link_to": "Leave Application", - "stats_filter": "{\"status\":\"Open\"}", - "type": "DocType" - }, - { - "label": "Attendance", - "link_to": "Attendance", - "stats_filter": "", - "type": "DocType" - }, - { - "label": "Job Applicant", - "link_to": "Job Applicant", - "type": "DocType" - }, - { - "label": "Monthly Attendance Sheet", - "link_to": "Monthly Attendance Sheet", - "type": "Report" - }, - { - "format": "{} Open", - "label": "Dashboard", - "link_to": "Human Resource", - "stats_filter": "{\n \"status\": \"Open\"\n}", - "type": "Dashboard" - } - ] -} \ No newline at end of file diff --git a/erpnext/hr/doctype/appraisal/appraisal.js b/erpnext/hr/doctype/appraisal/appraisal.js index 02f1557c4ef..1a30ceac6d3 100644 --- a/erpnext/hr/doctype/appraisal/appraisal.js +++ b/erpnext/hr/doctype/appraisal/appraisal.js @@ -10,13 +10,13 @@ frappe.ui.form.on('Appraisal', { }; }, - onload: function(frm) { + onload: function(frm) { if(!frm.doc.status) { frm.set_value('status', 'Draft'); } }, - kra_template: function(frm) { + kra_template: function(frm) { frm.doc.goals = []; erpnext.utils.map_current_doc({ method: "erpnext.hr.doctype.appraisal.appraisal.fetch_appraisal_template", @@ -24,31 +24,56 @@ frappe.ui.form.on('Appraisal', { frm: frm }); }, - - calculate_total: function(frm) { - let goals = frm.doc.goals || []; - let total =0; - for(let i = 0; i 5) { - frappe.msgprint(__("Score must be less than or equal to 5")); - d.score = 0; - refresh_field('score', d.name, 'goals'); - } - d.score_earned = flt(d.per_weightage*d.score, precision("score_earned", d))/100; - } else { - d.score_earned = 0; + if (flt(d.score) > 5) { + frappe.msgprint(__("Score must be less than or equal to 5")); + d.score = 0; + refresh_field('score', d.name, 'goals'); } - refresh_field('score_earned', d.name, 'goals'); - frm.trigger('calculate_total'); + else { + frm.trigger('set_score_earned'); + } + }, + per_weightage: function(frm) { + frm.trigger('set_score_earned'); + }, + goals_remove: function(frm) { + frm.trigger('set_score_earned'); } -}); \ No newline at end of file +}); diff --git a/erpnext/hr/doctype/appraisal/appraisal.json b/erpnext/hr/doctype/appraisal/appraisal.json index 91201d4b820..9ca7bcc354c 100644 --- a/erpnext/hr/doctype/appraisal/appraisal.json +++ b/erpnext/hr/doctype/appraisal/appraisal.json @@ -1,775 +1,254 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "naming_series:", - "beta": 0, - "creation": "2013-01-10 16:34:12", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "autoname": "naming_series:", + "creation": "2013-01-10 16:34:12", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "employee_details", + "naming_series", + "kra_template", + "employee", + "employee_name", + "column_break0", + "status", + "start_date", + "end_date", + "department", + "section_break0", + "goals", + "total_score", + "section_break1", + "remarks", + "other_details", + "company", + "column_break_17", + "amended_from" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "employee_details", - "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, - "label": "", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Section Break", - "permlevel": 0, - "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": "employee_details", + "fieldtype": "Section Break", + "oldfieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "", - "fieldname": "naming_series", - "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": "Series", - "length": 0, - "no_copy": 1, - "options": "HR-APR-.YY.-.MM.", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "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": 1, - "translatable": 0, - "unique": 0 - }, + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "no_copy": 1, + "options": "HR-APR-.YY.-.MM.", + "print_hide": 1, + "reqd": 1, + "set_only_once": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "", - "fieldname": "kra_template", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "Appraisal Template", - "length": 0, - "no_copy": 0, - "oldfieldname": "kra_template", - "oldfieldtype": "Link", - "options": "Appraisal Template", - "permlevel": 0, - "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": "kra_template", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Appraisal Template", + "oldfieldname": "kra_template", + "oldfieldtype": "Link", + "options": "Appraisal Template", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "kra_template", - "description": "", - "fieldname": "employee", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "For Employee", - "length": 0, - "no_copy": 0, - "oldfieldname": "employee", - "oldfieldtype": "Link", - "options": "Employee", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 1, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "kra_template", + "fieldname": "employee", + "fieldtype": "Link", + "in_global_search": 1, + "in_standard_filter": 1, + "label": "For Employee", + "oldfieldname": "employee", + "oldfieldtype": "Link", + "options": "Employee", + "reqd": 1, + "search_index": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "kra_template", - "fieldname": "employee_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "For Employee Name", - "length": 0, - "no_copy": 0, - "oldfieldname": "employee_name", - "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "kra_template", + "fieldname": "employee_name", + "fieldtype": "Data", + "in_global_search": 1, + "label": "For Employee Name", + "oldfieldname": "employee_name", + "oldfieldtype": "Data", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "kra_template", - "fieldname": "column_break0", - "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, - "oldfieldtype": "Column Break", - "permlevel": 0, - "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, + "depends_on": "kra_template", + "fieldname": "column_break0", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break", "width": "50%" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Draft", - "depends_on": "kra_template", - "fieldname": "status", - "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": 1, - "label": "Status", - "length": 0, - "no_copy": 1, - "oldfieldname": "status", - "oldfieldtype": "Select", - "options": "\nDraft\nSubmitted\nCompleted\nCancelled", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 1, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Draft", + "depends_on": "kra_template", + "fieldname": "status", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Status", + "no_copy": 1, + "oldfieldname": "status", + "oldfieldtype": "Select", + "options": "\nDraft\nSubmitted\nCompleted\nCancelled", + "read_only": 1, + "reqd": 1, + "search_index": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "kra_template", - "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": 1, - "in_standard_filter": 0, - "label": "Start Date", - "length": 0, - "no_copy": 0, - "oldfieldname": "start_date", - "oldfieldtype": "Date", - "permlevel": 0, - "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 - }, + "depends_on": "kra_template", + "fieldname": "start_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Start Date", + "oldfieldname": "start_date", + "oldfieldtype": "Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "kra_template", - "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, - "oldfieldname": "end_date", - "oldfieldtype": "Date", - "permlevel": 0, - "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 - }, + "depends_on": "kra_template", + "fieldname": "end_date", + "fieldtype": "Date", + "label": "End Date", + "oldfieldname": "end_date", + "oldfieldtype": "Date", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_from": "employee.department", - "fieldname": "department", - "fieldtype": "Link", - "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": "Department", - "length": 0, - "no_copy": 0, - "options": "Department", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fetch_from": "employee.department", + "fieldname": "department", + "fieldtype": "Link", + "label": "Department", + "options": "Department", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "kra_template", - "fieldname": "section_break0", - "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, - "label": "Goals", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Section Break", - "options": "Simple", - "permlevel": 0, - "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 - }, + "depends_on": "kra_template", + "fieldname": "section_break0", + "fieldtype": "Section Break", + "label": "Goals", + "oldfieldtype": "Section Break", + "options": "Simple" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "goals", - "fieldtype": "Table", - "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": "Goals", - "length": 0, - "no_copy": 0, - "oldfieldname": "appraisal_details", - "oldfieldtype": "Table", - "options": "Appraisal Goal", - "permlevel": 0, - "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": "goals", + "fieldtype": "Table", + "label": "Goals", + "oldfieldname": "appraisal_details", + "oldfieldtype": "Table", + "options": "Appraisal Goal" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "calculate_total_score", - "fieldtype": "Button", - "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": "Calculate Total Score", - "length": 0, - "no_copy": 0, - "oldfieldtype": "Button", - "options": "calculate_total", - "permlevel": 0, - "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": "total_score", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Total Score (Out of 5)", + "no_copy": 1, + "oldfieldname": "total_score", + "oldfieldtype": "Currency", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "total_score", - "fieldtype": "Float", - "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": "Total Score (Out of 5)", - "length": 0, - "no_copy": 1, - "oldfieldname": "total_score", - "oldfieldtype": "Currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "kra_template", + "fieldname": "section_break1", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "kra_template", - "fieldname": "section_break1", - "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, - "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 - }, + "description": "Any other remarks, noteworthy effort that should go in the records.", + "fieldname": "remarks", + "fieldtype": "Text", + "label": "Remarks" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "Any other remarks, noteworthy effort that should go in the records.", - "fieldname": "remarks", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Remarks", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "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 - }, + "depends_on": "kra_template", + "fieldname": "other_details", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "kra_template", - "fieldname": "other_details", - "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, - "label": "", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "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": "company", + "fieldtype": "Link", + "label": "Company", + "oldfieldname": "company", + "oldfieldtype": "Link", + "options": "Company", + "remember_last_selected_value": 1, + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "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": "Company", - "length": 0, - "no_copy": 0, - "oldfieldname": "company", - "oldfieldtype": "Link", - "options": "Company", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 1, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_17", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amended_from", - "fieldtype": "Link", - "hidden": 1, - "ignore_user_permissions": 1, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Amended From", - "length": 0, - "no_copy": 1, - "oldfieldname": "amended_from", - "oldfieldtype": "Data", - "options": "Appraisal", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 1, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "amended_from", + "fieldtype": "Link", + "hidden": 1, + "ignore_user_permissions": 1, + "label": "Amended From", + "no_copy": 1, + "oldfieldname": "amended_from", + "oldfieldtype": "Data", + "options": "Appraisal", + "print_hide": 1, + "read_only": 1, + "report_hide": 1, "width": "150px" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-thumbs-up", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2020-09-18 17:26:09.703215", - "modified_by": "Administrator", - "module": "HR", - "name": "Appraisal", - "owner": "Administrator", + ], + "icon": "fa fa-thumbs-up", + "idx": 1, + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2020-10-03 21:48:33.297065", + "modified_by": "Administrator", + "module": "HR", + "name": "Appraisal", + "owner": "Administrator", "permissions": [ { - "amend": 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": "Employee", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Employee", + "share": 1, "write": 1 - }, + }, { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, "write": 1 - }, + }, { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "HR User", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "submit": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "search_fields": "status, employee, employee_name", - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "timeline_field": "employee", - "title_field": "employee_name", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "search_fields": "status, employee, employee_name", + "sort_field": "modified", + "sort_order": "DESC", + "timeline_field": "employee", + "title_field": "employee_name" } \ No newline at end of file diff --git a/erpnext/hr/doctype/appraisal/appraisal.py b/erpnext/hr/doctype/appraisal/appraisal.py index e69dfa8bfe6..f7601870fac 100644 --- a/erpnext/hr/doctype/appraisal/appraisal.py +++ b/erpnext/hr/doctype/appraisal/appraisal.py @@ -50,7 +50,7 @@ class Appraisal(Document): total_w += flt(d.per_weightage) if int(total_w) != 100: - frappe.throw(_("Total weightage assigned should be 100%. It is {0}").format(str(total_w) + "%")) + frappe.throw(_("Total weightage assigned should be 100%.
    It is {0}").format(str(total_w) + "%")) if frappe.db.get_value("Employee", self.employee, "user_id") != \ frappe.session.user and total == 0: diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index 373b94008e7..18a4fe53c4b 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -131,6 +131,10 @@ def mark_bulk_attendance(data): data = json.loads(data) data = frappe._dict(data) company = frappe.get_value('Employee', data.employee, 'company') + if not data.unmarked_days: + frappe.throw(_("Please select a date.")) + return + for date in data.unmarked_days: doc_dict = { 'doctype': 'Attendance', diff --git a/erpnext/hr/doctype/attendance/attendance_list.js b/erpnext/hr/doctype/attendance/attendance_list.js index 6df3dbd7845..0c7eafe9c61 100644 --- a/erpnext/hr/doctype/attendance/attendance_list.js +++ b/erpnext/hr/doctype/attendance/attendance_list.js @@ -12,7 +12,7 @@ frappe.listview_settings['Attendance'] = { onload: function(list_view) { let me = this; const months = moment.months() - list_view.page.add_inner_button( __("Mark Attendance"), function(){ + list_view.page.add_inner_button( __("Mark Attendance"), function() { let dialog = new frappe.ui.Dialog({ title: __("Mark Attendance"), fields: [ @@ -22,11 +22,12 @@ frappe.listview_settings['Attendance'] = { fieldtype: 'Link', options: 'Employee', reqd: 1, - onchange: function(){ + onchange: function() { dialog.set_df_property("unmarked_days", "hidden", 1); dialog.set_df_property("status", "hidden", 1); dialog.set_df_property("month", "value", ''); dialog.set_df_property("unmarked_days", "options", []); + dialog.no_unmarked_days_left = false; } }, { @@ -35,13 +36,18 @@ frappe.listview_settings['Attendance'] = { fieldname: "month", options: months, reqd: 1, - onchange: function(){ + onchange: function() { if(dialog.fields_dict.employee.value && dialog.fields_dict.month.value) { dialog.set_df_property("status", "hidden", 0); dialog.set_df_property("unmarked_days", "options", []); + dialog.no_unmarked_days_left = false; me.get_multi_select_options(dialog.fields_dict.employee.value, dialog.fields_dict.month.value).then(options =>{ - dialog.set_df_property("unmarked_days", "hidden", 0); - dialog.set_df_property("unmarked_days", "options", options); + if (options.length > 0) { + dialog.set_df_property("unmarked_days", "hidden", 0); + dialog.set_df_property("unmarked_days", "options", options); + } else { + dialog.no_unmarked_days_left = true; + } }); } } @@ -64,21 +70,25 @@ frappe.listview_settings['Attendance'] = { hidden: 1 }, ], - primary_action(data){ - frappe.confirm(__('Mark attendance as ' + data.status + ' for ' + data.month +'' + ' on selected dates?'), () => { - frappe.call({ - method: "erpnext.hr.doctype.attendance.attendance.mark_bulk_attendance", - args: { - data : data - }, - callback: function(r) { - if(r.message === 1) { - frappe.show_alert({message:__("Attendance Marked"), indicator:'blue'}); - cur_dialog.hide(); + primary_action(data) { + if (cur_dialog.no_unmarked_days_left) { + frappe.msgprint(__("Attendance for the month of {0} , has already been marked for the Employee {1}",[dialog.fields_dict.month.value, dialog.fields_dict.employee.value])); + } else { + frappe.confirm(__('Mark attendance as {0} for {1} on selected dates?', [data.status,data.month]), () => { + frappe.call({ + method: "erpnext.hr.doctype.attendance.attendance.mark_bulk_attendance", + args: { + data: data + }, + callback: function(r) { + if (r.message === 1) { + frappe.show_alert({message: __("Attendance Marked"), indicator: 'blue'}); + cur_dialog.hide(); + } } - } + }); }); - }); + } dialog.hide(); list_view.refresh(); }, diff --git a/erpnext/hr/doctype/attendance_request/test_attendance_request.py b/erpnext/hr/doctype/attendance_request/test_attendance_request.py index 92b1eaee2c7..3c42bd9fc35 100644 --- a/erpnext/hr/doctype/attendance_request/test_attendance_request.py +++ b/erpnext/hr/doctype/attendance_request/test_attendance_request.py @@ -8,6 +8,8 @@ import unittest from frappe.utils import nowdate from datetime import date +test_dependencies = ["Employee"] + class TestAttendanceRequest(unittest.TestCase): def setUp(self): for doctype in ["Attendance Request", "Attendance"]: @@ -56,4 +58,4 @@ class TestAttendanceRequest(unittest.TestCase): self.assertEqual(attendance.docstatus, 2) def get_employee(): - return frappe.get_doc("Employee", "_T-Employee-00001") \ No newline at end of file + return frappe.get_doc("Employee", "_T-Employee-00001") diff --git a/erpnext/hr/doctype/compensatory_leave_request/test_compensatory_leave_request.py b/erpnext/hr/doctype/compensatory_leave_request/test_compensatory_leave_request.py index 1615ab30f1d..74ce30108fd 100644 --- a/erpnext/hr/doctype/compensatory_leave_request/test_compensatory_leave_request.py +++ b/erpnext/hr/doctype/compensatory_leave_request/test_compensatory_leave_request.py @@ -10,6 +10,8 @@ from erpnext.hr.doctype.attendance_request.test_attendance_request import get_em from erpnext.hr.doctype.leave_period.test_leave_period import create_leave_period from erpnext.hr.doctype.leave_application.leave_application import get_leave_balance_on +test_dependencies = ["Employee"] + class TestCompensatoryLeaveRequest(unittest.TestCase): def setUp(self): frappe.db.sql(''' delete from `tabCompensatory Leave Request`''') @@ -129,4 +131,4 @@ def create_holiday_list(): ], "holiday_list_name": "_Test Compensatory Leave" }) - holiday_list.save() \ No newline at end of file + holiday_list.save() diff --git a/erpnext/hr/doctype/department_approver/department_approver.py b/erpnext/hr/doctype/department_approver/department_approver.py index 9b2de0e1cbc..d337959d534 100644 --- a/erpnext/hr/doctype/department_approver/department_approver.py +++ b/erpnext/hr/doctype/department_approver/department_approver.py @@ -20,7 +20,7 @@ def get_approvers(doctype, txt, searchfield, start, page_len, filters): approvers = [] department_details = {} department_list = [] - employee = frappe.get_value("Employee", filters.get("employee"), ["department", "leave_approver", "expense_approver", "shift_request_approver"], as_dict=True) + employee = frappe.get_value("Employee", filters.get("employee"), ["employee_name","department", "leave_approver", "expense_approver", "shift_request_approver"], as_dict=True) employee_department = filters.get("department") or employee.department if employee_department: @@ -59,11 +59,9 @@ def get_approvers(doctype, txt, searchfield, start, page_len, filters): and approver.approver=user.name""",(d, "%" + txt + "%", parentfield), as_list=True) if len(approvers) == 0: - frappe.throw(_("Please set {0} for the Employee or for Department: {1}"). - format( - field_name, frappe.bold(employee_department), - frappe.bold(employee.name) - ), - title=_(field_name + " Missing")) + error_msg = _("Please set {0} for the Employee: {1}").format(field_name, frappe.bold(employee.employee_name)) + if department_list: + error_msg += _(" or for Department: {0}").format(frappe.bold(employee_department)) + frappe.throw(error_msg, title=_(field_name + " Missing")) return set(tuple(approver) for approver in approvers) diff --git a/erpnext/hr/doctype/employee/employee.json b/erpnext/hr/doctype/employee/employee.json index 8c02e4f1d64..5123d6a5a78 100644 --- a/erpnext/hr/doctype/employee/employee.json +++ b/erpnext/hr/doctype/employee/employee.json @@ -57,7 +57,6 @@ "column_break_45", "shift_request_approver", "attendance_and_leave_details", - "leave_policy", "attendance_device_id", "column_break_44", "holiday_list", @@ -109,7 +108,6 @@ "encashment_date", "exit_interview_details", "held_on", - "reason_for_resignation", "new_workplace", "feedback", "lft", @@ -412,14 +410,6 @@ "oldfieldtype": "Link", "options": "Branch" }, - { - "fetch_from": "grade.default_leave_policy", - "fetch_if_empty": 1, - "fieldname": "leave_policy", - "fieldtype": "Link", - "label": "Leave Policy", - "options": "Leave Policy" - }, { "description": "Applicable Holiday List", "fieldname": "holiday_list", @@ -673,16 +663,16 @@ "oldfieldtype": "Date" }, { - "depends_on": "eval:doc.status == \"Left\"", "fieldname": "relieving_date", "fieldtype": "Date", "label": "Relieving Date", + "mandatory_depends_on": "eval:doc.status == \"Left\"", "oldfieldname": "relieving_date", "oldfieldtype": "Date" }, { "fieldname": "reason_for_leaving", - "fieldtype": "Data", + "fieldtype": "Small Text", "label": "Reason for Leaving", "oldfieldname": "reason_for_leaving", "oldfieldtype": "Data" @@ -696,6 +686,7 @@ "options": "\nYes\nNo" }, { + "depends_on": "eval:doc.leave_encashed ==\"Yes\"", "fieldname": "encashment_date", "fieldtype": "Date", "label": "Encashment Date", @@ -705,7 +696,6 @@ { "fieldname": "exit_interview_details", "fieldtype": "Column Break", - "label": "Exit Interview Details", "oldfieldname": "col_brk6", "oldfieldtype": "Column Break", "width": "50%" @@ -713,18 +703,10 @@ { "fieldname": "held_on", "fieldtype": "Date", - "label": "Held On", + "label": "Exit Interview Held On", "oldfieldname": "held_on", "oldfieldtype": "Date" }, - { - "fieldname": "reason_for_resignation", - "fieldtype": "Select", - "label": "Reason for Resignation", - "oldfieldname": "reason_for_resignation", - "oldfieldtype": "Select", - "options": "\nBetter Prospects\nHealth Concerns" - }, { "fieldname": "new_workplace", "fieldtype": "Data", @@ -809,37 +791,29 @@ "fieldname": "expense_approver", "fieldtype": "Link", "label": "Expense Approver", - "options": "User", - "show_days": 1, - "show_seconds": 1 + "options": "User" }, { "fieldname": "approvers_section", "fieldtype": "Section Break", - "label": "Approvers", - "show_days": 1, - "show_seconds": 1 + "label": "Approvers" }, { "fieldname": "column_break_45", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "fieldname": "shift_request_approver", "fieldtype": "Link", "label": "Shift Request Approver", - "options": "User", - "show_days": 1, - "show_seconds": 1 + "options": "User" } ], "icon": "fa fa-user", "idx": 24, "image_field": "image", "links": [], - "modified": "2020-07-28 01:36:04.109189", + "modified": "2021-01-02 16:54:33.477439", "modified_by": "Administrator", "module": "HR", "name": "Employee", @@ -881,7 +855,6 @@ "write": 1 } ], - "quick_entry": 1, "search_fields": "employee_name", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index 7338cbb6c85..629bc571181 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -8,7 +8,7 @@ from frappe.utils import getdate, validate_email_address, today, add_years, form from frappe.model.naming import set_name_by_naming_series from frappe import throw, _, scrub from frappe.permissions import add_user_permission, remove_user_permission, \ - set_user_permission_if_allowed, has_permission + set_user_permission_if_allowed, has_permission, get_doc_permissions from frappe.model.document import Document from erpnext.utilities.transaction_base import delete_events from frappe.utils.nestedset import NestedSet @@ -57,13 +57,16 @@ class Employee(NestedSet): remove_user_permission( "Employee", self.name, existing_user_id) + def after_rename(self, old, new, merge): + self.db_set("employee", new) + def set_employee_name(self): self.employee_name = ' '.join(filter(lambda x: x, [self.first_name, self.middle_name, self.last_name])) def validate_user_details(self): data = frappe.db.get_value('User', self.user_id, ['enabled', 'user_image'], as_dict=1) - if data.get("user_image"): + if data.get("user_image") and self.image == '': self.image = data.get("user_image") self.validate_for_enabled_user_id(data.get("enabled", 0)) self.validate_duplicate_user_id() @@ -132,7 +135,7 @@ class Employee(NestedSet): try: frappe.get_doc({ "doctype": "File", - "file_name": self.image, + "file_url": self.image, "attached_to_doctype": "User", "attached_to_name": self.user_id }).insert() @@ -178,8 +181,11 @@ class Employee(NestedSet): ) if reports_to: link_to_employees = [frappe.utils.get_link_to_form('Employee', employee.name, label=employee.employee_name) for employee in reports_to] - throw(_("Employee status cannot be set to 'Left' as following employees are currently reporting to this employee: ") - + ', '.join(link_to_employees), EmployeeLeftValidationError) + message = _("The following employees are currently still reporting to {0}:").format(frappe.bold(self.employee_name)) + message += "

    • " + "
    • ".join(link_to_employees) + message += "

    " + message += _("Please make sure the employees above report to another Active employee.") + throw(message, EmployeeLeftValidationError, _("Cannot Relieve Employee")) if not self.relieving_date: throw(_("Please enter relieving date.")) @@ -212,7 +218,7 @@ class Employee(NestedSet): def validate_preferred_email(self): if self.prefered_contact_email and not self.get(scrub(self.prefered_contact_email)): - frappe.msgprint(_("Please enter " + self.prefered_contact_email)) + frappe.msgprint(_("Please enter {0}").format(self.prefered_contact_email)) def validate_onboarding_process(self): employee_onboarding = frappe.get_all("Employee Onboarding", @@ -272,63 +278,89 @@ def send_birthday_reminders(): if int(frappe.db.get_single_value("HR Settings", "stop_birthday_reminders") or 0): return - birthdays = get_employees_who_are_born_today() + employees_born_today = get_employees_who_are_born_today() - if birthdays: - employee_list = frappe.get_all('Employee', - fields=['name','employee_name'], - filters={'status': 'Active', - 'company': birthdays[0]['company'] - } - ) - employee_emails = get_employee_emails(employee_list) - birthday_names = [name["employee_name"] for name in birthdays] - birthday_emails = [email["user_id"] or email["personal_email"] or email["company_email"] for email in birthdays] + for company, birthday_persons in employees_born_today.items(): + employee_emails = get_all_employee_emails(company) + birthday_person_emails = [get_employee_email(doc) for doc in birthday_persons] + recipients = list(set(employee_emails) - set(birthday_person_emails)) - birthdays.append({'company_email': '','employee_name': '','personal_email': '','user_id': ''}) + reminder_text, message = get_birthday_reminder_text_and_message(birthday_persons) + send_birthday_reminder(recipients, reminder_text, birthday_persons, message) - for e in birthdays: - if e['company_email'] or e['personal_email'] or e['user_id']: - if len(birthday_names) == 1: - continue - recipients = e['company_email'] or e['personal_email'] or e['user_id'] + if len(birthday_persons) > 1: + # special email for people sharing birthdays + for person in birthday_persons: + person_email = person["user_id"] or person["personal_email"] or person["company_email"] + others = [d for d in birthday_persons if d != person] + reminder_text, message = get_birthday_reminder_text_and_message(others) + send_birthday_reminder(person_email, reminder_text, others, message) +def get_employee_email(employee_doc): + return employee_doc["user_id"] or employee_doc["personal_email"] or employee_doc["company_email"] - else: - recipients = list(set(employee_emails) - set(birthday_emails)) - - frappe.sendmail(recipients=recipients, - subject=_("Birthday Reminder"), - message=get_birthday_reminder_message(e, birthday_names), - header=['Birthday Reminder', 'green'], - ) - -def get_birthday_reminder_message(employee, employee_names): - """Get employee birthday reminder message""" - pattern = "
  • " - message = pattern.join(filter(lambda u: u not in (employee['employee_name']), employee_names)) - message = message.title() - - if pattern not in message: - message = "Today is {0}'s birthday \U0001F603".format(message) - +def get_birthday_reminder_text_and_message(birthday_persons): + if len(birthday_persons) == 1: + birthday_person_text = birthday_persons[0]['name'] else: - message = "Today your colleagues are celebrating their birthdays \U0001F382
    • " + message +"
    " + # converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim + person_names = [d['name'] for d in birthday_persons] + last_person = person_names[-1] + birthday_person_text = ", ".join(person_names[:-1]) + birthday_person_text = _("{} & {}").format(birthday_person_text, last_person) - return message + reminder_text = _("Today is {0}'s birthday 🎉").format(birthday_person_text) + message = _("A friendly reminder of an important date for our team.") + message += "
    " + message += _("Everyone, let’s congratulate {0} on their birthday.").format(birthday_person_text) + return reminder_text, message -def get_employees_who_are_born_today(): - """Get Employee properties whose birthday is today.""" - return frappe.db.get_values("Employee", - fieldname=["name", "personal_email", "company", "company_email", "user_id", "employee_name"], - filters={ - "date_of_birth": ("like", "%{}".format(format_datetime(getdate(), "-MM-dd"))), - "status": "Active", - }, - as_dict=True +def send_birthday_reminder(recipients, reminder_text, birthday_persons, message): + frappe.sendmail( + recipients=recipients, + subject=_("Birthday Reminder"), + template="birthday_reminder", + args=dict( + reminder_text=reminder_text, + birthday_persons=birthday_persons, + message=message, + ), + header=_("Birthday Reminder 🎂") ) +def get_employees_who_are_born_today(): + """Get all employee born today & group them based on their company""" + from collections import defaultdict + employees_born_today = frappe.db.multisql({ + "mariadb": """ + SELECT `personal_email`, `company`, `company_email`, `user_id`, `employee_name` AS 'name', `image` + FROM `tabEmployee` + WHERE + DAY(date_of_birth) = DAY(%(today)s) + AND + MONTH(date_of_birth) = MONTH(%(today)s) + AND + `status` = 'Active' + """, + "postgres": """ + SELECT "personal_email", "company", "company_email", "user_id", "employee_name" AS 'name', "image" + FROM "tabEmployee" + WHERE + DATE_PART('day', "date_of_birth") = date_part('day', %(today)s) + AND + DATE_PART('month', "date_of_birth") = date_part('month', %(today)s) + AND + "status" = 'Active' + """, + }, dict(today=today()), as_dict=1) + + grouped_employees = defaultdict(lambda: []) + + for employee_doc in employees_born_today: + grouped_employees[employee_doc.get('company')].append(employee_doc) + + return grouped_employees def get_holiday_list_for_employee(employee, raise_exception=True): if employee: @@ -398,6 +430,26 @@ def create_user(employee, user = None, email=None): user.insert() return user.name +def get_all_employee_emails(company): + '''Returns list of employee emails either based on user_id or company_email''' + employee_list = frappe.get_all('Employee', + fields=['name','employee_name'], + filters={ + 'status': 'Active', + 'company': company + } + ) + employee_emails = [] + for employee in employee_list: + if not employee: + continue + user, company_email, personal_email = frappe.db.get_value('Employee', + employee, ['user_id', 'company_email', 'personal_email']) + email = user or company_email or personal_email + if email: + employee_emails.append(email) + return employee_emails + def get_employee_emails(employee_list): '''Returns list of employee emails either based on user_id or company_email''' employee_emails = [] @@ -414,9 +466,9 @@ def get_employee_emails(employee_list): @frappe.whitelist() def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False): - filters = [] + filters = [['status', '!=', 'Left']] if company and company != 'All Companies': - filters = [['company', '=', company]] + filters.append(['company', '=', company]) fields = ['name as value', 'employee_name as title'] @@ -449,3 +501,10 @@ def has_user_permission_for_employee(user_name, employee_name): 'allow': 'Employee', 'for_value': employee_name }) + +def has_upload_permission(doc, ptype='read', user=None): + if not user: + user = frappe.session.user + if get_doc_permissions(doc, user=user, ptype=ptype).get(ptype): + return True + return doc.user_id == user \ No newline at end of file diff --git a/erpnext/hr/doctype/employee/employee_list.js b/erpnext/hr/doctype/employee/employee_list.js index 7a66d12bf5e..44837030be8 100644 --- a/erpnext/hr/doctype/employee/employee_list.js +++ b/erpnext/hr/doctype/employee/employee_list.js @@ -3,7 +3,7 @@ frappe.listview_settings['Employee'] = { filters: [["status","=", "Active"]], get_indicator: function(doc) { var indicator = [__(doc.status), frappe.utils.guess_colour(doc.status), "status,=," + doc.status]; - indicator[1] = {"Active": "green", "Temporary Leave": "red", "Left": "darkgrey"}[doc.status]; + indicator[1] = {"Active": "green", "Temporary Leave": "red", "Left": "gray"}[doc.status]; return indicator; } }; diff --git a/erpnext/hr/doctype/employee/test_employee.py b/erpnext/hr/doctype/employee/test_employee.py index f4b214adc3c..7d652a7366a 100644 --- a/erpnext/hr/doctype/employee/test_employee.py +++ b/erpnext/hr/doctype/employee/test_employee.py @@ -16,11 +16,13 @@ class TestEmployee(unittest.TestCase): employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) employee.date_of_birth = "1992" + frappe.utils.nowdate()[4:] employee.company_email = "test@example.com" + employee.company = "_Test Company" employee.save() from erpnext.hr.doctype.employee.employee import get_employees_who_are_born_today, send_birthday_reminders - self.assertTrue(employee.name in [e.name for e in get_employees_who_are_born_today()]) + employees_born_today = get_employees_who_are_born_today() + self.assertTrue(employees_born_today.get("_Test Company")) frappe.db.sql("delete from `tabEmail Queue`") @@ -46,6 +48,7 @@ class TestEmployee(unittest.TestCase): self.assertRaises(EmployeeLeftValidationError, employee1_doc.save) def make_employee(user, company=None, **kwargs): + "" if not frappe.db.get_value("User", user): frappe.get_doc({ "doctype": "User", diff --git a/erpnext/hr/doctype/employee_advance/employee_advance.js b/erpnext/hr/doctype/employee_advance/employee_advance.js index cba8ee9a404..5037ceb489e 100644 --- a/erpnext/hr/doctype/employee_advance/employee_advance.js +++ b/erpnext/hr/doctype/employee_advance/employee_advance.js @@ -15,11 +15,21 @@ frappe.ui.form.on('Employee Advance', { }); frm.set_query("advance_account", function() { + if (!frm.doc.employee) { + frappe.msgprint(__("Please select employee first")); + } + let company_currency = erpnext.get_currency(frm.doc.company); + let currencies = [company_currency]; + if (frm.doc.currency && (frm.doc.currency != company_currency)) { + currencies.push(frm.doc.currency); + } + return { filters: { "root_type": "Asset", "is_group": 0, - "company": frm.doc.company + "company": frm.doc.company, + "account_currency": ["in", currencies], } }; }); @@ -63,7 +73,7 @@ frappe.ui.form.on('Employee Advance', { }, __('Create')); }else if (frm.doc.repay_unclaimed_amount_from_salary == 1 && frappe.model.can_create("Additional Salary")){ frm.add_custom_button(__("Deduction from salary"), function() { - frm.events.make_deduction_via_additional_salary(frm) + frm.events.make_deduction_via_additional_salary(frm); }, __('Create')); } } @@ -127,7 +137,9 @@ frappe.ui.form.on('Employee Advance', { 'employee_advance_name': frm.doc.name, 'return_amount': flt(frm.doc.paid_amount - frm.doc.claimed_amount), 'advance_account': frm.doc.advance_account, - 'mode_of_payment': frm.doc.mode_of_payment + 'mode_of_payment': frm.doc.mode_of_payment, + 'currency': frm.doc.currency, + 'exchange_rate': frm.doc.exchange_rate }, callback: function(r) { const doclist = frappe.model.sync(r.message); @@ -138,16 +150,74 @@ frappe.ui.form.on('Employee Advance', { employee: function (frm) { if (frm.doc.employee) { - return frappe.call({ - method: "erpnext.hr.doctype.employee_advance.employee_advance.get_pending_amount", - args: { - "employee": frm.doc.employee, - "posting_date": frm.doc.posting_date - }, - callback: function(r) { - frm.set_value("pending_amount",r.message); - } - }); + frappe.run_serially([ + () => frm.trigger('get_employee_currency'), + () => frm.trigger('get_pending_amount') + ]); } + }, + + get_pending_amount: function(frm) { + frappe.call({ + method: "erpnext.hr.doctype.employee_advance.employee_advance.get_pending_amount", + args: { + "employee": frm.doc.employee, + "posting_date": frm.doc.posting_date + }, + callback: function(r) { + frm.set_value("pending_amount", r.message); + } + }); + }, + + get_employee_currency: function(frm) { + frappe.call({ + method: "erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment.get_employee_currency", + args: { + employee: frm.doc.employee, + }, + callback: function(r) { + if (r.message) { + frm.set_value('currency', r.message); + frm.refresh_fields(); + } + } + }); + }, + + currency: function(frm) { + if (frm.doc.currency) { + var from_currency = frm.doc.currency; + var company_currency; + if (!frm.doc.company) { + company_currency = erpnext.get_currency(frappe.defaults.get_default("Company")); + } else { + company_currency = erpnext.get_currency(frm.doc.company); + } + if (from_currency != company_currency) { + frm.events.set_exchange_rate(frm, from_currency, company_currency); + } else { + frm.set_value("exchange_rate", 1.0); + frm.set_df_property('exchange_rate', 'hidden', 1); + frm.set_df_property("exchange_rate", "description", "" ); + } + frm.refresh_fields(); + } + }, + + set_exchange_rate: function(frm, from_currency, company_currency) { + frappe.call({ + method: "erpnext.setup.utils.get_exchange_rate", + args: { + from_currency: from_currency, + to_currency: company_currency, + }, + callback: function(r) { + frm.set_value("exchange_rate", flt(r.message)); + frm.set_df_property('exchange_rate', 'hidden', 0); + frm.set_df_property("exchange_rate", "description", "1 " + frm.doc.currency + + " = [?] " + company_currency); + } + }); } }); diff --git a/erpnext/hr/doctype/employee_advance/employee_advance.json b/erpnext/hr/doctype/employee_advance/employee_advance.json index 0d909138719..cf6b5404ecf 100644 --- a/erpnext/hr/doctype/employee_advance/employee_advance.json +++ b/erpnext/hr/doctype/employee_advance/employee_advance.json @@ -13,6 +13,8 @@ "department", "column_break_4", "posting_date", + "currency", + "exchange_rate", "repay_unclaimed_amount_from_salary", "section_break_8", "purpose", @@ -91,7 +93,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Advance Amount", - "options": "Company:company:default_currency", + "options": "currency", "reqd": 1 }, { @@ -99,7 +101,7 @@ "fieldtype": "Currency", "label": "Paid Amount", "no_copy": 1, - "options": "Company:company:default_currency", + "options": "currency", "read_only": 1 }, { @@ -107,7 +109,7 @@ "fieldtype": "Currency", "label": "Claimed Amount", "no_copy": 1, - "options": "Company:company:default_currency", + "options": "currency", "read_only": 1 }, { @@ -161,7 +163,7 @@ "fieldname": "return_amount", "fieldtype": "Currency", "label": "Returned Amount", - "options": "Company:company:default_currency", + "options": "currency", "read_only": 1 }, { @@ -175,13 +177,31 @@ "fieldname": "pending_amount", "fieldtype": "Currency", "label": "Pending Amount", - "options": "Company:company:default_currency", + "options": "currency", "read_only": 1 + }, + { + "default": "Company:company:default_currency", + "depends_on": "eval:(doc.docstatus==1 || doc.employee)", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "reqd": 1 + }, + { + "depends_on": "currency", + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate", + "precision": "9", + "print_hide": 1, + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-06-12 12:42:39.833818", + "modified": "2020-11-25 12:01:55.980721", "modified_by": "Administrator", "module": "HR", "name": "Employee Advance", diff --git a/erpnext/hr/doctype/employee_advance/employee_advance.py b/erpnext/hr/doctype/employee_advance/employee_advance.py index 3c435b8cc3b..cb72f6b6d96 100644 --- a/erpnext/hr/doctype/employee_advance/employee_advance.py +++ b/erpnext/hr/doctype/employee_advance/employee_advance.py @@ -19,7 +19,6 @@ class EmployeeAdvance(Document): def validate(self): self.set_status() - self.validate_employee_advance_account() def on_cancel(self): self.ignore_linked_doctypes = ('GL Entry') @@ -38,16 +37,9 @@ class EmployeeAdvance(Document): elif self.docstatus == 2: self.status = "Cancelled" - def validate_employee_advance_account(self): - company_currency = erpnext.get_company_currency(self.company) - if (self.advance_account and - company_currency != frappe.db.get_value('Account', self.advance_account, 'account_currency')): - frappe.throw(_("Advance account currency should be same as company currency {0}") - .format(company_currency)) - def set_total_advance_paid(self): paid_amount = frappe.db.sql(""" - select ifnull(sum(debit_in_account_currency), 0) as paid_amount + select ifnull(sum(debit), 0) as paid_amount from `tabGL Entry` where against_voucher_type = 'Employee Advance' and against_voucher = %s @@ -56,7 +48,7 @@ class EmployeeAdvance(Document): """, (self.name, self.employee), as_dict=1)[0].paid_amount return_amount = frappe.db.sql(""" - select name, ifnull(sum(credit_in_account_currency), 0) as return_amount + select ifnull(sum(credit), 0) as return_amount from `tabGL Entry` where against_voucher_type = 'Employee Advance' and voucher_type != 'Expense Claim' @@ -65,6 +57,11 @@ class EmployeeAdvance(Document): and party = %s """, (self.name, self.employee), as_dict=1)[0].return_amount + if paid_amount != 0: + paid_amount = flt(paid_amount) / flt(self.exchange_rate) + if return_amount != 0: + return_amount = flt(return_amount) / flt(self.exchange_rate) + if flt(paid_amount) > self.advance_amount: frappe.throw(_("Row {0}# Paid Amount cannot be greater than requested advance amount"), EmployeeAdvanceOverPayment) @@ -107,16 +104,27 @@ def make_bank_entry(dt, dn): doc = frappe.get_doc(dt, dn) payment_account = get_default_bank_cash_account(doc.company, account_type="Cash", mode_of_payment=doc.mode_of_payment) + if not payment_account: + frappe.throw(_("Please set a Default Cash Account in Company defaults")) + + advance_account_currency = frappe.db.get_value('Account', doc.advance_account, 'account_currency') + + advance_amount, advance_exchange_rate = get_advance_amount_advance_exchange_rate(advance_account_currency,doc ) + + paying_amount, paying_exchange_rate = get_paying_amount_paying_exchange_rate(payment_account, doc) je = frappe.new_doc("Journal Entry") je.posting_date = nowdate() je.voucher_type = 'Bank Entry' je.company = doc.company je.remark = 'Payment against Employee Advance: ' + dn + '\n' + doc.purpose + je.multi_currency = 1 if advance_account_currency != payment_account.account_currency else 0 je.append("accounts", { "account": doc.advance_account, - "debit_in_account_currency": flt(doc.advance_amount), + "account_currency": advance_account_currency, + "exchange_rate": flt(advance_exchange_rate), + "debit_in_account_currency": flt(advance_amount), "reference_type": "Employee Advance", "reference_name": doc.name, "party_type": "Employee", @@ -128,19 +136,41 @@ def make_bank_entry(dt, dn): je.append("accounts", { "account": payment_account.account, "cost_center": erpnext.get_default_cost_center(doc.company), - "credit_in_account_currency": flt(doc.advance_amount), + "credit_in_account_currency": flt(paying_amount), "account_currency": payment_account.account_currency, - "account_type": payment_account.account_type + "account_type": payment_account.account_type, + "exchange_rate": flt(paying_exchange_rate) }) return je.as_dict() +def get_advance_amount_advance_exchange_rate(advance_account_currency, doc): + if advance_account_currency != doc.currency: + advance_amount = flt(doc.advance_amount) * flt(doc.exchange_rate) + advance_exchange_rate = 1 + else: + advance_amount = doc.advance_amount + advance_exchange_rate = doc.exchange_rate + + return advance_amount, advance_exchange_rate + +def get_paying_amount_paying_exchange_rate(payment_account, doc): + if payment_account.account_currency != doc.currency: + paying_amount = flt(doc.advance_amount) * flt(doc.exchange_rate) + paying_exchange_rate = 1 + else: + paying_amount = doc.advance_amount + paying_exchange_rate = doc.exchange_rate + + return paying_amount, paying_exchange_rate + @frappe.whitelist() def create_return_through_additional_salary(doc): import json doc = frappe._dict(json.loads(doc)) additional_salary = frappe.new_doc('Additional Salary') additional_salary.employee = doc.employee + additional_salary.currency = doc.currency additional_salary.amount = doc.paid_amount - doc.claimed_amount additional_salary.company = doc.company additional_salary.ref_doctype = doc.doctype @@ -149,26 +179,28 @@ def create_return_through_additional_salary(doc): return additional_salary @frappe.whitelist() -def make_return_entry(employee, company, employee_advance_name, return_amount, advance_account, mode_of_payment=None): - return_account = get_default_bank_cash_account(company, account_type='Cash', mode_of_payment = mode_of_payment) - - mode_of_payment_type = '' - if mode_of_payment: - mode_of_payment_type = frappe.get_cached_value('Mode of Payment', mode_of_payment, 'type') - if mode_of_payment_type not in ["Cash", "Bank"]: - # if mode of payment is General then it unset the type - mode_of_payment_type = None - +def make_return_entry(employee, company, employee_advance_name, return_amount, advance_account, currency, exchange_rate, mode_of_payment=None): + bank_cash_account = get_default_bank_cash_account(company, account_type='Cash', mode_of_payment = mode_of_payment) + if not bank_cash_account: + frappe.throw(_("Please set a Default Cash Account in Company defaults")) + + advance_account_currency = frappe.db.get_value('Account', advance_account, 'account_currency') + je = frappe.new_doc('Journal Entry') je.posting_date = nowdate() - # if mode of payment is Bank then voucher type is Bank Entry - je.voucher_type = '{} Entry'.format(mode_of_payment_type) if mode_of_payment_type else 'Cash Entry' + je.voucher_type = get_voucher_type(mode_of_payment) je.company = company je.remark = 'Return against Employee Advance: ' + employee_advance_name + je.multi_currency = 1 if advance_account_currency != bank_cash_account.account_currency else 0 + + advance_account_amount = flt(return_amount) if advance_account_currency==currency \ + else flt(return_amount) * flt(exchange_rate) je.append('accounts', { 'account': advance_account, - 'credit_in_account_currency': return_amount, + 'credit_in_account_currency': advance_account_amount, + 'account_currency': advance_account_currency, + 'exchange_rate': flt(exchange_rate) if advance_account_currency == currency else 1, 'reference_type': 'Employee Advance', 'reference_name': employee_advance_name, 'party_type': 'Employee', @@ -176,13 +208,25 @@ def make_return_entry(employee, company, employee_advance_name, return_amount, 'is_advance': 'Yes' }) + bank_amount = flt(return_amount) if bank_cash_account.account_currency==currency \ + else flt(return_amount) * flt(exchange_rate) + je.append("accounts", { - "account": return_account.account, - "debit_in_account_currency": return_amount, - "account_currency": return_account.account_currency, - "account_type": return_account.account_type + "account": bank_cash_account.account, + "debit_in_account_currency": bank_amount, + "account_currency": bank_cash_account.account_currency, + "account_type": bank_cash_account.account_type, + "exchange_rate": flt(exchange_rate) if bank_cash_account.account_currency == currency else 1 }) return je.as_dict() +def get_voucher_type(mode_of_payment=None): + voucher_type = "Cash Entry" + if mode_of_payment: + mode_of_payment_type = frappe.get_cached_value('Mode of Payment', mode_of_payment, 'type') + if mode_of_payment_type == "Bank": + voucher_type = "Bank Entry" + + return voucher_type \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_advance/test_employee_advance.py b/erpnext/hr/doctype/employee_advance/test_employee_advance.py index 2097e711de4..c88b2b8e49e 100644 --- a/erpnext/hr/doctype/employee_advance/test_employee_advance.py +++ b/erpnext/hr/doctype/employee_advance/test_employee_advance.py @@ -3,15 +3,17 @@ # See license.txt from __future__ import unicode_literals -import frappe +import frappe, erpnext import unittest from frappe.utils import nowdate from erpnext.hr.doctype.employee_advance.employee_advance import make_bank_entry from erpnext.hr.doctype.employee_advance.employee_advance import EmployeeAdvanceOverPayment +from erpnext.hr.doctype.employee.test_employee import make_employee class TestEmployeeAdvance(unittest.TestCase): def test_paid_amount_and_status(self): - advance = make_employee_advance() + employee_name = make_employee("_T@employe.advance") + advance = make_employee_advance(employee_name) journal_entry = make_payment_entry(advance) journal_entry.submit() @@ -33,11 +35,13 @@ def make_payment_entry(advance): return journal_entry -def make_employee_advance(): +def make_employee_advance(employee_name): doc = frappe.new_doc("Employee Advance") - doc.employee = "_T-Employee-00001" + doc.employee = employee_name doc.company = "_Test company" doc.purpose = "For site visit" + doc.currency = erpnext.get_company_currency("_Test company") + doc.exchange_rate = 1 doc.advance_amount = 1000 doc.posting_date = nowdate() doc.advance_account = "_Test Employee Advance - _TC" diff --git a/erpnext/hr/doctype/employee_grade/employee_grade.json b/erpnext/hr/doctype/employee_grade/employee_grade.json index e63ffae0c42..88b061a3c3b 100644 --- a/erpnext/hr/doctype/employee_grade/employee_grade.json +++ b/erpnext/hr/doctype/employee_grade/employee_grade.json @@ -1,167 +1,69 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 1, - "allow_rename": 1, - "autoname": "Prompt", - "beta": 0, - "creation": "2018-04-13 16:14:24.174138", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2018-04-13 16:14:24.174138", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "default_salary_structure" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "default_leave_policy", - "fieldtype": "Link", - "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": "Default Leave Policy", - "length": 0, - "no_copy": 0, - "options": "Leave Policy", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "default_salary_structure", "fieldtype": "Link", - "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": "Default Salary Structure", - "length": 0, - "no_copy": 0, - "options": "Salary Structure", - "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 + "options": "Salary Structure" } ], - "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-09-18 17:17:45.617624", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-08-26 13:12:07.815330", "modified_by": "Administrator", "module": "HR", "name": "Employee Grade", - "name_case": "", "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, "write": 1 }, { - "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": "HR Manager", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 }, { - "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": "HR User", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 } ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, "sort_field": "modified", "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py index 4e9ee3b143a..336e13c9b77 100644 --- a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py +++ b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py @@ -38,7 +38,8 @@ class TestEmployeeOnboarding(unittest.TestCase): onboarding.insert() onboarding.submit() - self.assertEqual(onboarding.project, 'Employee Onboarding : Test Researcher - test@researcher.com') + project_name = frappe.db.get_value("Project", onboarding.project, "project_name") + self.assertEqual(project_name, 'Employee Onboarding : Test Researcher - test@researcher.com') # don't allow making employee if onboarding is not complete self.assertRaises(IncompleteTaskError, make_employee, onboarding.name) diff --git a/erpnext/hr/doctype/employee_transfer/employee_transfer.py b/erpnext/hr/doctype/employee_transfer/employee_transfer.py index c730e022a50..3539970a32a 100644 --- a/erpnext/hr/doctype/employee_transfer/employee_transfer.py +++ b/erpnext/hr/doctype/employee_transfer/employee_transfer.py @@ -50,8 +50,9 @@ class EmployeeTransfer(Document): employee = frappe.get_doc("Employee", self.employee) if self.create_new_employee_id: if self.new_employee_id: - frappe.throw(_("Please delete the Employee {0}\ - to cancel this document").format(self.new_employee_id)) + frappe.throw(_("Please delete the Employee {0} to cancel this document").format( + "{0}".format(self.new_employee_id) + )) #mark the employee as active employee.status = "Active" employee.relieving_date = '' diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.js b/erpnext/hr/doctype/expense_claim/expense_claim.js index 221300b519a..629341ff2a5 100644 --- a/erpnext/hr/doctype/expense_claim/expense_claim.js +++ b/erpnext/hr/doctype/expense_claim/expense_claim.js @@ -2,11 +2,21 @@ // License: GNU General Public License v3. See license.txt frappe.provide("erpnext.hr"); +frappe.provide("erpnext.accounts.dimensions"); -erpnext.hr.ExpenseClaimController = frappe.ui.form.Controller.extend({ - expense_type: function(doc, cdt, cdn) { +frappe.ui.form.on('Expense Claim', { + onload: function(frm) { + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); + }, + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, +}); + +frappe.ui.form.on('Expense Claim Detail', { + expense_type: function(frm, cdt, cdn) { var d = locals[cdt][cdn]; - if(!doc.company) { + if (!frm.doc.company) { d.expense_type = ""; frappe.msgprint(__("Please set the Company")); this.frm.refresh_fields(); @@ -20,7 +30,7 @@ erpnext.hr.ExpenseClaimController = frappe.ui.form.Controller.extend({ method: "erpnext.hr.doctype.expense_claim.expense_claim.get_expense_claim_account_and_cost_center", args: { "expense_claim_type": d.expense_type, - "company": doc.company + "company": frm.doc.company }, callback: function(r) { if (r.message) { @@ -32,8 +42,6 @@ erpnext.hr.ExpenseClaimController = frappe.ui.form.Controller.extend({ } }); -$.extend(cur_frm.cscript, new erpnext.hr.ExpenseClaimController({frm: cur_frm})); - cur_frm.add_fetch('employee', 'company', 'company'); cur_frm.add_fetch('employee','employee_name','employee_name'); cur_frm.add_fetch('expense_type','description','description'); @@ -167,15 +175,6 @@ frappe.ui.form.on("Expense Claim", { }; }); - frm.set_query("cost_center", "expenses", function() { - return { - filters: { - "company": frm.doc.company, - "is_group": 0 - } - }; - }); - frm.set_query("payable_account", function() { return { filters: { diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py index 6e97f0513d6..f9e3a441bf0 100644 --- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py @@ -7,6 +7,7 @@ import unittest from frappe.utils import random_string, nowdate from erpnext.hr.doctype.expense_claim.expense_claim import make_bank_entry from erpnext.accounts.doctype.account.test_account import create_account +from erpnext.hr.doctype.employee.test_employee import make_employee test_records = frappe.get_test_records('Expense Claim') test_dependencies = ['Employee'] @@ -19,35 +20,36 @@ class TestExpenseClaim(unittest.TestCase): frappe.db.sql("""delete from `tabProject` where name = "_Test Project 1" """) frappe.db.sql("update `tabExpense Claim` set project = '', task = ''") - frappe.get_doc({ + project = frappe.get_doc({ "project_name": "_Test Project 1", "doctype": "Project" - }).save() + }) + project.save() task = frappe.get_doc(dict( doctype = 'Task', subject = '_Test Project Task 1', status = 'Open', - project = '_Test Project 1' + project = project.name )).insert() task_name = task.name payable_account = get_payable_account(company_name) - make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", "_Test Project 1", task_name) + make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", project.name, task_name) self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 200) - self.assertEqual(frappe.db.get_value("Project", "_Test Project 1", "total_expense_claim"), 200) + self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 200) - expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC4","_Test Project 1", task_name) + expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC4", project.name, task_name) self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 700) - self.assertEqual(frappe.db.get_value("Project", "_Test Project 1", "total_expense_claim"), 700) + self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 700) expense_claim2.cancel() self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 200) - self.assertEqual(frappe.db.get_value("Project", "_Test Project 1", "total_expense_claim"), 200) + self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 200) def test_expense_claim_status(self): payable_account = get_payable_account(company_name) @@ -126,6 +128,9 @@ def generate_taxes(): def make_expense_claim(payable_account, amount, sanctioned_amount, company, account, project=None, task_name=None, do_not_submit=False, taxes=None): employee = frappe.db.get_value("Employee", {"status": "Active"}) + if not employee: + employee = make_employee("test_employee@expense_claim.com", company=company) + currency, cost_center = frappe.db.get_value('Company', company, ['default_currency', 'cost_center']) expense_claim = { "doctype": "Expense Claim", diff --git a/erpnext/hr/doctype/expense_taxes_and_charges/expense_taxes_and_charges.json b/erpnext/hr/doctype/expense_taxes_and_charges/expense_taxes_and_charges.json index 885e3eed976..020457d4ec6 100644 --- a/erpnext/hr/doctype/expense_taxes_and_charges/expense_taxes_and_charges.json +++ b/erpnext/hr/doctype/expense_taxes_and_charges/expense_taxes_and_charges.json @@ -71,9 +71,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Amount", - "oldfieldname": "tax_amount", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency" + "options": "currency" }, { "columns": 2, @@ -81,9 +79,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Total", - "oldfieldname": "total", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency", + "options": "currency", "read_only": 1 }, { @@ -106,7 +102,7 @@ ], "istable": 1, "links": [], - "modified": "2020-05-11 19:01:26.611758", + "modified": "2020-09-23 20:27:36.027728", "modified_by": "Administrator", "module": "HR", "name": "Expense Taxes and Charges", diff --git a/erpnext/hr/doctype/holiday_list/holiday_list.py b/erpnext/hr/doctype/holiday_list/holiday_list.py index 76dc9429f19..6df7bc88c02 100644 --- a/erpnext/hr/doctype/holiday_list/holiday_list.py +++ b/erpnext/hr/doctype/holiday_list/holiday_list.py @@ -1,3 +1,4 @@ + # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt @@ -32,7 +33,7 @@ class HolidayList(Document): def validate_days(self): - if self.from_date > self.to_date: + if getdate(self.from_date) > getdate(self.to_date): throw(_("To Date cannot be before From Date")) for day in self.get("holidays"): diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json index c42e1d72fcc..09666c5db5b 100644 --- a/erpnext/hr/doctype/hr_settings/hr_settings.json +++ b/erpnext/hr/doctype/hr_settings/hr_settings.json @@ -13,6 +13,7 @@ "stop_birthday_reminders", "expense_approver_mandatory_in_expense_claim", "leave_settings", + "send_leave_notification", "leave_approval_notification_template", "leave_status_notification_template", "role_allowed_to_create_backdated_leave_application", @@ -21,6 +22,7 @@ "show_leaves_of_all_department_members_in_calendar", "auto_leave_encashment", "restrict_backdated_leave_application", + "automatically_allocate_leaves_based_on_leave_policy", "hiring_settings", "check_vacancies" ], @@ -28,144 +30,126 @@ { "fieldname": "employee_settings", "fieldtype": "Section Break", - "label": "Employee Settings", - "show_days": 1, - "show_seconds": 1 + "label": "Employee Settings" }, { "description": "Enter retirement age in years", "fieldname": "retirement_age", "fieldtype": "Data", - "label": "Retirement Age", - "show_days": 1, - "show_seconds": 1 + "label": "Retirement Age" }, { "default": "Naming Series", - "description": "Employee record is created using selected field. ", + "description": "Employee records are created using the selected field", "fieldname": "emp_created_by", "fieldtype": "Select", "label": "Employee Records to be created by", - "options": "Naming Series\nEmployee Number\nFull Name", - "show_days": 1, - "show_seconds": 1 + "options": "Naming Series\nEmployee Number\nFull Name" }, { "fieldname": "column_break_4", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "default": "0", - "description": "Don't send Employee Birthday Reminders", + "description": "Don't send employee birthday reminders", "fieldname": "stop_birthday_reminders", "fieldtype": "Check", - "label": "Stop Birthday Reminders", - "show_days": 1, - "show_seconds": 1 + "label": "Stop Birthday Reminders" }, { "default": "1", "fieldname": "expense_approver_mandatory_in_expense_claim", "fieldtype": "Check", - "label": "Expense Approver Mandatory In Expense Claim", - "show_days": 1, - "show_seconds": 1 + "label": "Expense Approver Mandatory In Expense Claim" }, { "collapsible": 1, "fieldname": "leave_settings", "fieldtype": "Section Break", - "label": "Leave Settings", - "show_days": 1, - "show_seconds": 1 + "label": "Leave Settings" }, { + "depends_on": "eval: doc.send_leave_notification == 1", "fieldname": "leave_approval_notification_template", "fieldtype": "Link", "label": "Leave Approval Notification Template", - "options": "Email Template", - "show_days": 1, - "show_seconds": 1 + "mandatory_depends_on": "eval: doc.send_leave_notification == 1", + "options": "Email Template" }, { + "depends_on": "eval: doc.send_leave_notification == 1", "fieldname": "leave_status_notification_template", "fieldtype": "Link", "label": "Leave Status Notification Template", - "options": "Email Template", - "show_days": 1, - "show_seconds": 1 + "mandatory_depends_on": "eval: doc.send_leave_notification == 1", + "options": "Email Template" }, { "fieldname": "column_break_18", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "default": "1", "fieldname": "leave_approver_mandatory_in_leave_application", "fieldtype": "Check", - "label": "Leave Approver Mandatory In Leave Application", - "show_days": 1, - "show_seconds": 1 + "label": "Leave Approver Mandatory In Leave Application" }, { "default": "0", "fieldname": "show_leaves_of_all_department_members_in_calendar", "fieldtype": "Check", - "label": "Show Leaves Of All Department Members In Calendar", - "show_days": 1, - "show_seconds": 1 + "label": "Show Leaves Of All Department Members In Calendar" }, { "collapsible": 1, "fieldname": "hiring_settings", "fieldtype": "Section Break", - "label": "Hiring Settings", - "show_days": 1, - "show_seconds": 1 + "label": "Hiring Settings" }, { "default": "0", "fieldname": "check_vacancies", "fieldtype": "Check", - "label": "Check Vacancies On Job Offer Creation", - "show_days": 1, - "show_seconds": 1 + "label": "Check Vacancies On Job Offer Creation" }, { "default": "0", "fieldname": "auto_leave_encashment", "fieldtype": "Check", - "label": "Auto Leave Encashment", - "show_days": 1, - "show_seconds": 1 + "label": "Auto Leave Encashment" }, { "default": "0", "fieldname": "restrict_backdated_leave_application", "fieldtype": "Check", - "label": "Restrict Backdated Leave Application", - "show_days": 1, - "show_seconds": 1 + "label": "Restrict Backdated Leave Application" }, { "depends_on": "eval:doc.restrict_backdated_leave_application == 1", "fieldname": "role_allowed_to_create_backdated_leave_application", "fieldtype": "Link", "label": "Role Allowed to Create Backdated Leave Application", - "options": "Role", - "show_days": 1, - "show_seconds": 1 + "options": "Role" + }, + { + "default": "0", + "fieldname": "automatically_allocate_leaves_based_on_leave_policy", + "fieldtype": "Check", + "label": "Automatically Allocate Leaves Based On Leave Policy" + }, + { + "default": "1", + "fieldname": "send_leave_notification", + "fieldtype": "Check", + "label": "Send Leave Notification" } ], "icon": "fa fa-cog", "idx": 1, "issingle": 1, "links": [], - "modified": "2020-06-04 15:15:09.865476", + "modified": "2021-03-14 02:04:22.907159", "modified_by": "Administrator", "module": "HR", "name": "HR Settings", @@ -182,5 +166,6 @@ } ], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.json b/erpnext/hr/doctype/job_applicant/job_applicant.json index c13548ab82e..1360fd1890a 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant.json +++ b/erpnext/hr/doctype/job_applicant/job_applicant.json @@ -11,15 +11,24 @@ "field_order": [ "applicant_name", "email_id", + "phone_number", + "country", "status", "column_break_3", "job_title", "source", "source_name", + "applicant_rating", "section_break_6", "notes", "cover_letter", - "resume_attachment" + "resume_attachment", + "resume_link", + "section_break_16", + "currency", + "column_break_18", + "lower_range", + "upper_range" ], "fields": [ { @@ -91,12 +100,65 @@ "fieldtype": "Data", "label": "Notes", "read_only": 1 + }, + { + "fieldname": "phone_number", + "fieldtype": "Data", + "label": "Phone Number", + "options": "Phone" + }, + { + "fieldname": "country", + "fieldtype": "Link", + "label": "Country", + "options": "Country" + }, + { + "fieldname": "resume_link", + "fieldtype": "Data", + "label": "Resume Link" + }, + { + "fieldname": "applicant_rating", + "fieldtype": "Rating", + "in_list_view": 1, + "label": "Applicant Rating" + }, + { + "fieldname": "section_break_16", + "fieldtype": "Section Break", + "label": "Salary Expectation" + }, + { + "fieldname": "lower_range", + "fieldtype": "Currency", + "label": "Lower Range", + "options": "currency", + "precision": "0" + }, + { + "fieldname": "upper_range", + "fieldtype": "Currency", + "label": "Upper Range", + "options": "currency", + "precision": "0" + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" } ], "icon": "fa fa-user", "idx": 1, + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-01-13 16:19:39.113330", + "modified": "2020-09-18 12:39:02.557563", "modified_by": "Administrator", "module": "HR", "name": "Job Applicant", diff --git a/erpnext/hr/doctype/job_applicant/test_job_applicant.py b/erpnext/hr/doctype/job_applicant/test_job_applicant.py index 6d275c82d9d..872834230e6 100644 --- a/erpnext/hr/doctype/job_applicant/test_job_applicant.py +++ b/erpnext/hr/doctype/job_applicant/test_job_applicant.py @@ -13,11 +13,21 @@ class TestJobApplicant(unittest.TestCase): def create_job_applicant(**args): args = frappe._dict(args) - job_applicant = frappe.get_doc({ - "doctype": "Job Applicant", + + filters = { "applicant_name": args.applicant_name or "_Test Applicant", "email_id": args.email_id or "test_applicant@example.com", + } + + if frappe.db.exists("Job Applicant", filters): + return frappe.get_doc("Job Applicant", filters) + + job_applicant = frappe.get_doc({ + "doctype": "Job Applicant", "status": args.status or "Open" }) + + job_applicant.update(filters) job_applicant.save() - return job_applicant \ No newline at end of file + + return job_applicant diff --git a/erpnext/hr/doctype/job_offer/job_offer.py b/erpnext/hr/doctype/job_offer/job_offer.py index c397a3f5cad..7e650f76917 100644 --- a/erpnext/hr/doctype/job_offer/job_offer.py +++ b/erpnext/hr/doctype/job_offer/job_offer.py @@ -16,7 +16,7 @@ class JobOffer(Document): def validate(self): self.validate_vacancies() - job_offer = frappe.db.exists("Job Offer",{"job_applicant": self.job_applicant}) + job_offer = frappe.db.exists("Job Offer",{"job_applicant": self.job_applicant, "docstatus": ["!=", 2]}) if job_offer and job_offer != self.name: frappe.throw(_("Job Offer: {0} is already for Job Applicant: {1}").format(frappe.bold(job_offer), frappe.bold(self.job_applicant))) diff --git a/erpnext/hr/doctype/job_offer/test_job_offer.py b/erpnext/hr/doctype/job_offer/test_job_offer.py index 88865964500..690a692ddca 100644 --- a/erpnext/hr/doctype/job_offer/test_job_offer.py +++ b/erpnext/hr/doctype/job_offer/test_job_offer.py @@ -13,14 +13,15 @@ from erpnext.hr.doctype.staffing_plan.test_staffing_plan import make_company class TestJobOffer(unittest.TestCase): def test_job_offer_creation_against_vacancies(self): - create_staffing_plan(staffing_details=[{ - "designation": "Designer", + frappe.db.set_value("HR Settings", None, "check_vacancies", 1) + job_applicant = create_job_applicant(email_id="test_job_offer@example.com") + job_offer = create_job_offer(job_applicant=job_applicant.name, designation="UX Designer") + + create_staffing_plan(name='Test No Vacancies', staffing_details=[{ + "designation": "UX Designer", "vacancies": 0, "estimated_cost_per_position": 5000 }]) - frappe.db.set_value("HR Settings", None, "check_vacancies", 1) - job_applicant = create_job_applicant(email_id="test_job_offer@example.com") - job_offer = create_job_offer(job_applicant=job_applicant.name, designation="Researcher") self.assertRaises(frappe.ValidationError, job_offer.submit) # test creation of job offer when vacancies are not present diff --git a/erpnext/hr/doctype/job_opening/job_opening.json b/erpnext/hr/doctype/job_opening/job_opening.json index 4437e02fc84..b8f6df6f7a6 100644 --- a/erpnext/hr/doctype/job_opening/job_opening.json +++ b/erpnext/hr/doctype/job_opening/job_opening.json @@ -1,456 +1,188 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:route", - "beta": 0, - "creation": "2013-01-15 16:13:36", - "custom": 0, - "description": "Description of a Job Opening", - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 0, - "engine": "InnoDB", + "actions": [], + "autoname": "field:route", + "creation": "2013-01-15 16:13:36", + "description": "Description of a Job Opening", + "doctype": "DocType", + "document_type": "Document", + "engine": "InnoDB", + "field_order": [ + "job_title", + "company", + "status", + "column_break_5", + "designation", + "department", + "staffing_plan", + "planned_vacancies", + "section_break_6", + "publish", + "route", + "column_break_12", + "job_application_route", + "section_break_14", + "description", + "section_break_16", + "currency", + "lower_range", + "upper_range", + "column_break_20", + "publish_salary_range" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "job_title", - "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": "Job Title", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "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": "job_title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Job Title", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "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": "Company", - "length": 0, - "no_copy": 0, - "options": "Company", - "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": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "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": 1, - "label": "Status", - "length": 0, - "no_copy": 0, - "options": "Open\nClosed", - "permlevel": 0, - "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": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Open\nClosed" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_5", - "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 - }, + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "designation", - "fieldtype": "Link", - "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": "Designation", - "length": 0, - "no_copy": 0, - "options": "Designation", - "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": "designation", + "fieldtype": "Link", + "label": "Designation", + "options": "Designation", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "department", - "fieldtype": "Link", - "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": "Department", - "length": 0, - "no_copy": 0, - "options": "Department", - "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": "department", + "fieldtype": "Link", + "label": "Department", + "options": "Department" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "staffing_plan", - "fieldtype": "Link", - "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": "Staffing Plan", - "length": 0, - "no_copy": 0, - "options": "Staffing Plan", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "staffing_plan", + "fieldtype": "Link", + "label": "Staffing Plan", + "options": "Staffing Plan", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "staffing_plan", - "fieldname": "planned_vacancies", - "fieldtype": "Int", - "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": "Planned number of Positions", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "staffing_plan", + "fieldname": "planned_vacancies", + "fieldtype": "Int", + "label": "Planned number of Positions", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_6", - "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 - }, + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "publish", - "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": "Publish on website", - "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 - }, + "default": "0", + "fieldname": "publish", + "fieldtype": "Check", + "label": "Publish on website" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "publish", - "fieldname": "route", - "fieldtype": "Data", - "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": "Route", - "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, + "depends_on": "publish", + "fieldname": "route", + "fieldtype": "Data", + "label": "Route", "unique": 1 - }, + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "Job profile, qualifications required etc.", - "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": 1, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "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 + "description": "Job profile, qualifications required etc.", + "fieldname": "description", + "fieldtype": "Text Editor", + "in_list_view": 1, + "label": "Description" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_14", + "fieldtype": "Section Break" + }, + { + "collapsible": 1, + "fieldname": "section_break_16", + "fieldtype": "Section Break" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "lower_range", + "fieldtype": "Currency", + "label": "Lower Range", + "options": "currency", + "precision": "0" + }, + { + "fieldname": "upper_range", + "fieldtype": "Currency", + "label": "Upper Range", + "options": "currency", + "precision": "0" + }, + { + "fieldname": "column_break_20", + "fieldtype": "Column Break" + }, + { + "depends_on": "publish", + "description": "Route to the custom Job Application Webform", + "fieldname": "job_application_route", + "fieldtype": "Data", + "label": "Job Application Route" + }, + { + "default": "0", + "fieldname": "publish_salary_range", + "fieldtype": "Check", + "label": "Publish Salary Range" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-bookmark", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-05-20 15:38:44.705823", - "modified_by": "Administrator", - "module": "HR", - "name": "Job Opening", - "owner": "Administrator", + ], + "icon": "fa fa-bookmark", + "idx": 1, + "links": [], + "modified": "2020-09-18 11:23:29.488923", + "modified_by": "Administrator", + "module": "HR", + "name": "Job Opening", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "HR User", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Guest", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "read": 1, + "role": "Guest" } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_order": "ASC", - "track_changes": 0, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "ASC" } \ No newline at end of file diff --git a/erpnext/hr/doctype/job_opening/job_opening.py b/erpnext/hr/doctype/job_opening/job_opening.py index 00883d75f19..1e897671770 100644 --- a/erpnext/hr/doctype/job_opening/job_opening.py +++ b/erpnext/hr/doctype/job_opening/job_opening.py @@ -43,9 +43,8 @@ class JobOpening(WebsiteGenerator): current_count = designation_counts['employee_count'] + designation_counts['job_openings'] if self.planned_vacancies <= current_count: - frappe.throw(_("Job Openings for designation {0} already open \ - or hiring completed as per Staffing Plan {1}" - .format(self.designation, self.staffing_plan))) + frappe.throw(_("Job Openings for designation {0} already open or hiring completed as per Staffing Plan {1}").format( + self.designation, self.staffing_plan)) def get_context(self, context): context.parents = [{'route': 'jobs', 'title': _('All Jobs') }] @@ -56,7 +55,8 @@ def get_list_context(context): context.get_list = get_job_openings def get_job_openings(doctype, txt=None, filters=None, limit_start=0, limit_page_length=20, order_by=None): - fields = ['name', 'status', 'job_title', 'description'] + fields = ['name', 'status', 'job_title', 'description', 'publish_salary_range', + 'lower_range', 'upper_range', 'currency', 'job_application_route'] filters = filters or {} filters.update({ diff --git a/erpnext/hr/doctype/job_opening/templates/job_opening_row.html b/erpnext/hr/doctype/job_opening/templates/job_opening_row.html index 5da8cc82a2e..c015101600a 100644 --- a/erpnext/hr/doctype/job_opening/templates/job_opening_row.html +++ b/erpnext/hr/doctype/job_opening/templates/job_opening_row.html @@ -1,9 +1,18 @@

    {{ doc.job_title }}

    {{ doc.description }}

    + {%- if doc.publish_salary_range -%} +

    {{_("Salary range per month")}}: {{ frappe.format_value(frappe.utils.flt(doc.lower_range), currency=doc.currency) }} - {{ frappe.format_value(frappe.utils.flt(doc.upper_range), currency=doc.currency) }}

    + {% endif %}
    diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.json b/erpnext/hr/doctype/leave_allocation/leave_allocation.json index 007497e34a5..3a300c0d632 100644 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.json +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "autoname": "naming_series:", "creation": "2013-02-20 19:10:38", @@ -10,6 +11,7 @@ "employee", "employee_name", "department", + "company", "column_break1", "leave_type", "from_date", @@ -24,6 +26,7 @@ "compensatory_request", "leave_period", "leave_policy", + "leave_policy_assignment", "carry_forwarded_leaves_count", "expired", "amended_from", @@ -160,9 +163,10 @@ "read_only": 1 }, { - "fetch_from": "employee.leave_policy", + "fetch_from": "leave_policy_assignment.leave_policy", "fieldname": "leave_policy", "fieldtype": "Link", + "hidden": 1, "in_standard_filter": 1, "label": "Leave Policy", "options": "Leave Policy", @@ -209,12 +213,30 @@ "fieldtype": "Float", "label": "Carry Forwarded Leaves", "read_only": 1 + }, + { + "fieldname": "leave_policy_assignment", + "fieldtype": "Link", + "label": "Leave Policy Assignment", + "options": "Leave Policy Assignment", + "read_only": 1 + }, + { + "fetch_from": "employee.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "read_only": 1, + "reqd": 1 } ], "icon": "fa fa-ok", "idx": 1, + "index_web_pages_for_search": 1, "is_submittable": 1, - "modified": "2019-08-08 15:08:42.440909", + "links": [], + "modified": "2021-01-04 18:46:13.184104", "modified_by": "Administrator", "module": "HR", "name": "Leave Allocation", diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.py b/erpnext/hr/doctype/leave_allocation/leave_allocation.py index 03fe3fa035c..69d605d0633 100755 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.py @@ -18,7 +18,6 @@ class ValueMultiplierError(frappe.ValidationError): pass class LeaveAllocation(Document): def validate(self): self.validate_period() - self.validate_new_leaves_allocated_value() self.validate_allocation_overlap() self.validate_back_dated_allocation() self.set_total_leaves_allocated() @@ -51,9 +50,19 @@ class LeaveAllocation(Document): def on_cancel(self): self.create_leave_ledger_entry(submit=False) + if self.leave_policy_assignment: + self.update_leave_policy_assignments_when_no_allocations_left() if self.carry_forward: self.set_carry_forwarded_leaves_in_previous_allocation(on_cancel=True) + def update_leave_policy_assignments_when_no_allocations_left(self): + allocations = frappe.db.get_list("Leave Allocation", filters = { + "docstatus": 1, + "leave_policy_assignment": self.leave_policy_assignment + }) + if len(allocations) == 0: + frappe.db.set_value("Leave Policy Assignment", self.leave_policy_assignment ,"leaves_allocated", 0) + def validate_period(self): if date_diff(self.to_date, self.from_date) <= 0: frappe.throw(_("To date cannot be before from date")) @@ -62,11 +71,6 @@ class LeaveAllocation(Document): if frappe.db.get_value("Leave Type", self.leave_type, "is_lwp"): frappe.throw(_("Leave Type {0} cannot be allocated since it is leave without pay").format(self.leave_type)) - def validate_new_leaves_allocated_value(self): - """validate that leave allocation is in multiples of 0.5""" - if flt(self.new_leaves_allocated) % 0.5: - frappe.throw(_("Leaves must be allocated in multiples of 0.5"), ValueMultiplierError) - def validate_allocation_overlap(self): leave_allocation = frappe.db.sql(""" SELECT @@ -82,7 +86,7 @@ class LeaveAllocation(Document): frappe.msgprint(_("{0} already allocated for Employee {1} for period {2} to {3}") .format(self.leave_type, self.employee, formatdate(self.from_date), formatdate(self.to_date))) - frappe.throw(_('Reference') + ': {0}' + frappe.throw(_('Reference') + ': {0}' .format(leave_allocation[0][0]), OverlapError) def validate_back_dated_allocation(self): diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation_list.js b/erpnext/hr/doctype/leave_allocation/leave_allocation_list.js index 93f7b8356b3..3ab176f8099 100644 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation_list.js +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation_list.js @@ -5,7 +5,7 @@ frappe.listview_settings['Leave Allocation'] = { get_indicator: function(doc) { if(doc.status==="Expired") { - return [__("Expired"), "darkgrey", "expired, =, 1"]; + return [__("Expired"), "gray", "expired, =, 1"]; } }, }; diff --git a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py index 26f077a6499..0b71036c860 100644 --- a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py @@ -6,6 +6,10 @@ from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation, expire_allocation class TestLeaveAllocation(unittest.TestCase): + @classmethod + def setUpClass(cls): + frappe.db.sql("delete from `tabLeave Period`") + def test_overlapping_allocation(self): frappe.db.sql("delete from `tabLeave Allocation`") @@ -177,4 +181,4 @@ def create_leave_allocation(**args): }) return leave_allocation -test_dependencies = ["Employee", "Leave Type"] \ No newline at end of file +test_dependencies = ["Employee", "Leave Type"] diff --git a/erpnext/hr/doctype/leave_application/leave_application.js b/erpnext/hr/doctype/leave_application/leave_application.js index d62e418b17e..9ccb915908f 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.js +++ b/erpnext/hr/doctype/leave_application/leave_application.js @@ -75,7 +75,8 @@ frappe.ui.form.on("Leave Application", { frm.dashboard.add_section( frappe.render_template('leave_application_dashboard', { data: leave_details - }) + }), + __("Allocated Leaves") ); frm.dashboard.show(); let allowed_leave_types = Object.keys(leave_details); diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py index 3f25f583833..350ceadccdb 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.py +++ b/erpnext/hr/doctype/leave_application/leave_application.py @@ -40,7 +40,8 @@ class LeaveApplication(Document): def on_update(self): if self.status == "Open" and self.docstatus < 1: # notify leave approver about creation - self.notify_leave_approver() + if frappe.db.get_single_value("HR Settings", "send_leave_notification"): + self.notify_leave_approver() def on_submit(self): if self.status == "Open": @@ -50,7 +51,8 @@ class LeaveApplication(Document): self.update_attendance() # notify leave applier about approval - self.notify_employee() + if frappe.db.get_single_value("HR Settings", "send_leave_notification"): + self.notify_employee() self.create_leave_ledger_entry() self.reload() @@ -60,7 +62,8 @@ class LeaveApplication(Document): def on_cancel(self): self.create_leave_ledger_entry(submit=False) # notify leave applier about cancellation - self.notify_employee() + if frappe.db.get_single_value("HR Settings", "send_leave_notification"): + self.notify_employee() self.cancel_attendance() def validate_applicable_after(self): @@ -130,8 +133,7 @@ class LeaveApplication(Document): if self.status == "Approved": for dt in daterange(getdate(self.from_date), getdate(self.to_date)): date = dt.strftime("%Y-%m-%d") - status = "Half Day" if getdate(date) == getdate(self.half_day_date) else "On Leave" - + status = "Half Day" if self.half_day_date and getdate(date) == getdate(self.half_day_date) else "On Leave" attendance_name = frappe.db.exists('Attendance', dict(employee = self.employee, attendance_date = date, docstatus = ('!=', 2))) @@ -246,7 +248,7 @@ class LeaveApplication(Document): def throw_overlap_error(self, d): msg = _("Employee {0} has already applied for {1} between {2} and {3} : ").format(self.employee, d['leave_type'], formatdate(d['from_date']), formatdate(d['to_date'])) \ - + """ {0}""".format(d["name"]) + + """ {0}""".format(d["name"]) frappe.throw(msg, OverlapError) def get_total_leaves_on_half_day(self): @@ -293,7 +295,8 @@ class LeaveApplication(Document): def set_half_day_date(self): if self.from_date == self.to_date and self.half_day == 1: self.half_day_date = self.from_date - elif self.half_day == 0: + + if self.half_day == 0: self.half_day_date = None def notify_employee(self): @@ -376,24 +379,32 @@ class LeaveApplication(Document): if expiry_date: self.create_ledger_entry_for_intermediate_allocation_expiry(expiry_date, submit, lwp) else: + raise_exception = True + if frappe.flags.in_patch: + raise_exception=False + args = dict( leaves=self.total_leave_days * -1, from_date=self.from_date, to_date=self.to_date, is_lwp=lwp, - holiday_list=get_holiday_list_for_employee(self.employee) + holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or '' ) create_leave_ledger_entry(self, args, submit) def create_ledger_entry_for_intermediate_allocation_expiry(self, expiry_date, submit, lwp): ''' splits leave application into two ledger entries to consider expiry of allocation ''' + + raise_exception = True + if frappe.flags.in_patch: + raise_exception=False + args = dict( from_date=self.from_date, to_date=expiry_date, leaves=(date_diff(expiry_date, self.from_date) + 1) * -1, is_lwp=lwp, - holiday_list=get_holiday_list_for_employee(self.employee), - + holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or '' ) create_leave_ledger_entry(self, args, submit) diff --git a/erpnext/hr/doctype/leave_application/leave_application_dashboard.html b/erpnext/hr/doctype/leave_application/leave_application_dashboard.html index d30e3b9f9c6..9f667a68356 100644 --- a/erpnext/hr/doctype/leave_application/leave_application_dashboard.html +++ b/erpnext/hr/doctype/leave_application/leave_application_dashboard.html @@ -1,15 +1,14 @@ {% if not jQuery.isEmptyObject(data) %} -
    {{ __("Allocated Leaves") }}
    - - - - - + + + + + @@ -26,5 +25,5 @@
    {{ __("Leave Type") }}{{ __("Total Allocated Leaves") }}{{ __("Expired Leaves") }}{{ __("Used Leaves") }}{{ __("Pending Leaves") }}{{ __("Available Leaves") }}{{ __("Total Allocated Leave") }}{{ __("Expired Leave") }}{{ __("Used Leave") }}{{ __("Pending Leave") }}{{ __("Available Leave") }}
    {% else %} -

    No Leaves have been allocated.

    -{% endif %} \ No newline at end of file +

    No Leave has been allocated.

    +{% endif %} diff --git a/erpnext/hr/doctype/leave_application/leave_application_list.js b/erpnext/hr/doctype/leave_application/leave_application_list.js index cbb4b73227e..a3c03b1bec7 100644 --- a/erpnext/hr/doctype/leave_application/leave_application_list.js +++ b/erpnext/hr/doctype/leave_application/leave_application_list.js @@ -1,5 +1,6 @@ frappe.listview_settings['Leave Application'] = { add_fields: ["leave_type", "employee", "employee_name", "total_leave_days", "from_date", "to_date"], + has_indicator_for_draft: 1, get_indicator: function (doc) { if (doc.status === "Approved") { return [__("Approved"), "green", "status,=,Approved"]; diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index 6e909c3f01b..b335c485944 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -10,8 +10,9 @@ from frappe.permissions import clear_user_permissions_for_doctype from frappe.utils import add_days, nowdate, now_datetime, getdate, add_months from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation +from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import create_assignment_for_multiple_employees -test_dependencies = ["Leave Allocation", "Leave Block List"] +test_dependencies = ["Leave Allocation", "Leave Block List", "Employee"] _test_records = [ { @@ -410,25 +411,39 @@ class TestLeaveApplication(unittest.TestCase): self.assertEqual(get_leave_balance_on(employee.name, leave_type.name, nowdate(), add_days(nowdate(), 8)), 21) def test_earned_leaves_creation(self): + + frappe.db.sql('''delete from `tabLeave Period`''') + frappe.db.sql('''delete from `tabLeave Policy Assignment`''') + frappe.db.sql('''delete from `tabLeave Allocation`''') + frappe.db.sql('''delete from `tabLeave Ledger Entry`''') + leave_period = get_leave_period() employee = get_employee() leave_type = 'Test Earned Leave Type' - if not frappe.db.exists('Leave Type', leave_type): - frappe.get_doc(dict( - leave_type_name = leave_type, - doctype = 'Leave Type', - is_earned_leave = 1, - earned_leave_frequency = 'Monthly', - rounding = 0.5, - max_leaves_allowed = 6 - )).insert() + frappe.delete_doc_if_exists("Leave Type", 'Test Earned Leave Type', force=1) + frappe.get_doc(dict( + leave_type_name = leave_type, + doctype = 'Leave Type', + is_earned_leave = 1, + earned_leave_frequency = 'Monthly', + rounding = 0.5, + max_leaves_allowed = 6 + )).insert() + leave_policy = frappe.get_doc({ "doctype": "Leave Policy", "leave_policy_details": [{"leave_type": leave_type, "annual_allocation": 6}] }).insert() - frappe.db.set_value("Employee", employee.name, "leave_policy", leave_policy.name) - allocate_leaves(employee, leave_period, leave_type, 0, eligible_leaves = 12) + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + + leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + + frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0]).grant_leave_alloc_for_employee() from erpnext.hr.utils import allocate_earned_leaves i = 0 @@ -624,4 +639,4 @@ def allocate_leaves(employee, leave_period, leave_type, new_leaves_allocated, el "docstatus": 1 }).insert() - allocate_leave.submit() \ No newline at end of file + allocate_leave.submit() diff --git a/erpnext/hr/doctype/leave_encashment/leave_encashment.js b/erpnext/hr/doctype/leave_encashment/leave_encashment.js index 71a34226da4..81936a4a383 100644 --- a/erpnext/hr/doctype/leave_encashment/leave_encashment.js +++ b/erpnext/hr/doctype/leave_encashment/leave_encashment.js @@ -22,7 +22,12 @@ frappe.ui.form.on('Leave Encashment', { } }, employee: function(frm) { - frm.trigger("get_leave_details_for_encashment"); + if (frm.doc.employee) { + frappe.run_serially([ + () => frm.trigger('get_employee_currency'), + () => frm.trigger('get_leave_details_for_encashment') + ]); + } }, leave_type: function(frm) { frm.trigger("get_leave_details_for_encashment"); @@ -40,5 +45,20 @@ frappe.ui.form.on('Leave Encashment', { } }); } - } + }, + + get_employee_currency: function(frm) { + frappe.call({ + method: "erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment.get_employee_currency", + args: { + employee: frm.doc.employee, + }, + callback: function(r) { + if (r.message) { + frm.set_value('currency', r.message); + frm.refresh_fields(); + } + } + }); + }, }); diff --git a/erpnext/hr/doctype/leave_encashment/leave_encashment.json b/erpnext/hr/doctype/leave_encashment/leave_encashment.json index 2cf6ccf5ca0..83eeae3adba 100644 --- a/erpnext/hr/doctype/leave_encashment/leave_encashment.json +++ b/erpnext/hr/doctype/leave_encashment/leave_encashment.json @@ -12,6 +12,7 @@ "employee", "employee_name", "department", + "company", "column_break_4", "leave_type", "leave_allocation", @@ -19,9 +20,11 @@ "encashable_days", "amended_from", "payroll", - "encashment_amount", "encashment_date", - "additional_salary" + "additional_salary", + "column_break_14", + "currency", + "encashment_amount" ], "fields": [ { @@ -109,6 +112,7 @@ "in_list_view": 1, "label": "Encashment Amount", "no_copy": 1, + "options": "currency", "read_only": 1 }, { @@ -124,11 +128,34 @@ "no_copy": 1, "options": "Additional Salary", "read_only": 1 + }, + { + "default": "Company:company:default_currency", + "depends_on": "eval:(doc.docstatus==1 || doc.employee)", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "column_break_14", + "fieldtype": "Column Break" + }, + { + "fetch_from": "employee.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2019-12-16 11:51:57.732223", + "modified": "2020-11-25 11:56:06.777241", "modified_by": "Administrator", "module": "HR", "name": "Leave Encashment", diff --git a/erpnext/hr/doctype/leave_encashment/leave_encashment.py b/erpnext/hr/doctype/leave_encashment/leave_encashment.py index 8913c648c52..4c1a46522f6 100644 --- a/erpnext/hr/doctype/leave_encashment/leave_encashment.py +++ b/erpnext/hr/doctype/leave_encashment/leave_encashment.py @@ -16,10 +16,16 @@ class LeaveEncashment(Document): def validate(self): set_employee_name(self) self.get_leave_details_for_encashment() + self.validate_salary_structure() if not self.encashment_date: self.encashment_date = getdate(nowdate()) + def validate_salary_structure(self): + if not frappe.db.exists('Salary Structure Assignment', {'employee': self.employee}): + frappe.throw(_("There is no Salary Structure assigned to {0}. First assign a Salary Stucture.").format(self.employee)) + + def before_submit(self): if self.encashment_amount <= 0: frappe.throw(_("You can only submit Leave Encashment for a valid encashment amount")) @@ -30,9 +36,10 @@ class LeaveEncashment(Document): additional_salary = frappe.new_doc("Additional Salary") additional_salary.company = frappe.get_value("Employee", self.employee, "company") additional_salary.employee = self.employee + additional_salary.currency = self.currency earning_component = frappe.get_value("Leave Type", self.leave_type, "earning_component") if not earning_component: - frappe.throw(_("Please set Earning Component for Leave type: {0}.".format(self.leave_type))) + frappe.throw(_("Please set Earning Component for Leave type: {0}.").format(self.leave_type)) additional_salary.salary_component = earning_component additional_salary.payroll_date = self.encashment_date additional_salary.amount = self.encashment_amount @@ -98,7 +105,11 @@ class LeaveEncashment(Document): create_leave_ledger_entry(self, args, submit) # create reverse entry for expired leaves - to_date = self.get_leave_allocation().get('to_date') + leave_allocation = self.get_leave_allocation() + if not leave_allocation: + return + + to_date = leave_allocation.get('to_date') if to_date < getdate(nowdate()): args = frappe._dict( leaves=self.encashable_days, diff --git a/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py b/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py index 99f64634161..aafc9642d46 100644 --- a/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py +++ b/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py @@ -9,6 +9,7 @@ from frappe.utils import today, add_months from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure from erpnext.hr.doctype.leave_period.test_leave_period import create_leave_period +from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import create_assignment_for_multiple_employees from erpnext.hr.doctype.leave_policy.test_leave_policy import create_leave_policy\ test_dependencies = ["Leave Type"] @@ -16,6 +17,7 @@ test_dependencies = ["Leave Type"] class TestLeaveEncashment(unittest.TestCase): def setUp(self): frappe.db.sql('''delete from `tabLeave Period`''') + frappe.db.sql('''delete from `tabLeave Policy Assignment`''') frappe.db.sql('''delete from `tabLeave Allocation`''') frappe.db.sql('''delete from `tabLeave Ledger Entry`''') frappe.db.sql('''delete from `tabAdditional Salary`''') @@ -29,14 +31,26 @@ class TestLeaveEncashment(unittest.TestCase): # create employee, salary structure and assignment self.employee = make_employee("test_employee_encashment@example.com") - frappe.db.set_value("Employee", self.employee, "leave_policy", leave_policy.name) + self.leave_period = create_leave_period(add_months(today(), -3), add_months(today(), 3)) + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": self.leave_period.name + } + + leave_policy_assignments = create_assignment_for_multiple_employees([self.employee], frappe._dict(data)) salary_structure = make_salary_structure("Salary Structure for Encashment", "Monthly", self.employee, other_details={"leave_encashment_amount_per_day": 50}) - # create the leave period and assign the leaves - self.leave_period = create_leave_period(add_months(today(), -3), add_months(today(), 3)) - self.leave_period.grant_leave_allocation(employee=self.employee) + #grant Leaves + frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0]).grant_leave_alloc_for_employee() + + + def tearDown(self): + for dt in ["Leave Period", "Leave Allocation", "Leave Ledger Entry", "Additional Salary", "Leave Encashment", "Salary Structure", "Leave Policy"]: + frappe.db.sql("delete from `tab%s`" % dt) def test_leave_balance_value_and_amount(self): frappe.db.sql('''delete from `tabLeave Encashment`''') @@ -45,7 +59,8 @@ class TestLeaveEncashment(unittest.TestCase): employee=self.employee, leave_type="_Test Leave Type Encashment", leave_period=self.leave_period.name, - payroll_date=today() + payroll_date=today(), + currency="INR" )).insert() self.assertEqual(leave_encashment.leave_balance, 10) @@ -65,7 +80,8 @@ class TestLeaveEncashment(unittest.TestCase): employee=self.employee, leave_type="_Test Leave Type Encashment", leave_period=self.leave_period.name, - payroll_date=today() + payroll_date=today(), + currency="INR" )).insert() leave_encashment.submit() diff --git a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.json b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.json index 4abba5f2d4a..d74760a5cf8 100644 --- a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.json +++ b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2019-05-09 15:47:39.760406", "doctype": "DocType", "engine": "InnoDB", @@ -8,6 +9,7 @@ "leave_type", "transaction_type", "transaction_name", + "company", "leaves", "column_break_7", "from_date", @@ -106,12 +108,22 @@ "fieldtype": "Link", "label": "Holiday List", "options": "Holiday List" + }, + { + "fetch_from": "employee.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "read_only": 1, + "reqd": 1 } ], "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, - "modified": "2020-09-04 12:16:36.569066", + "links": [], + "modified": "2021-01-04 18:47:45.146652", "modified_by": "Administrator", "module": "HR", "name": "Leave Ledger Entry", diff --git a/erpnext/hr/doctype/leave_period/leave_period.js b/erpnext/hr/doctype/leave_period/leave_period.js index bad2b8766c8..0e88bc16714 100644 --- a/erpnext/hr/doctype/leave_period/leave_period.js +++ b/erpnext/hr/doctype/leave_period/leave_period.js @@ -2,14 +2,6 @@ // For license information, please see license.txt frappe.ui.form.on('Leave Period', { - refresh: (frm)=>{ - frm.set_df_property("grant_leaves", "hidden", frm.doc.__islocal ? 1:0); - if(!frm.is_new()) { - frm.add_custom_button(__('Grant Leaves'), function () { - frm.trigger("grant_leaves"); - }); - } - }, from_date: (frm)=>{ if (frm.doc.from_date && !frm.doc.to_date) { var a_year_from_start = frappe.datetime.add_months(frm.doc.from_date, 12); @@ -22,73 +14,7 @@ frappe.ui.form.on('Leave Period', { "filters": { "company": frm.doc.company, } - } - }) - }, - grant_leaves: function(frm) { - var d = new frappe.ui.Dialog({ - title: __('Grant Leaves'), - fields: [ - { - "label": "Filter Employees By (Optional)", - "fieldname": "sec_break", - "fieldtype": "Section Break", - }, - { - "label": "Employee Grade", - "fieldname": "grade", - "fieldtype": "Link", - "options": "Employee Grade" - }, - { - "label": "Department", - "fieldname": "department", - "fieldtype": "Link", - "options": "Department" - }, - { - "fieldname": "col_break", - "fieldtype": "Column Break", - }, - { - "label": "Designation", - "fieldname": "designation", - "fieldtype": "Link", - "options": "Designation" - }, - { - "label": "Employee", - "fieldname": "employee", - "fieldtype": "Link", - "options": "Employee" - }, - { - "fieldname": "sec_break", - "fieldtype": "Section Break", - }, - { - "label": "Add unused leaves from previous allocations", - "fieldname": "carry_forward", - "fieldtype": "Check" - } - ], - primary_action: function() { - var data = d.get_values(); - - frappe.call({ - doc: frm.doc, - method: "grant_leave_allocation", - args: data, - callback: function(r) { - if(!r.exc) { - d.hide(); - frm.reload_doc(); - } - } - }); - }, - primary_action_label: __('Grant') + }; }); - d.show(); - } + }, }); diff --git a/erpnext/hr/doctype/leave_period/leave_period.py b/erpnext/hr/doctype/leave_period/leave_period.py index 0973ac71985..28a33f6fac8 100644 --- a/erpnext/hr/doctype/leave_period/leave_period.py +++ b/erpnext/hr/doctype/leave_period/leave_period.py @@ -7,24 +7,10 @@ import frappe from frappe import _ from frappe.utils import getdate, cstr, add_days, date_diff, getdate, ceil from frappe.model.document import Document -from erpnext.hr.utils import validate_overlap, get_employee_leave_policy +from erpnext.hr.utils import validate_overlap from frappe.utils.background_jobs import enqueue -from six import iteritems class LeavePeriod(Document): - def get_employees(self, args): - conditions, values = [], [] - for field, value in iteritems(args): - if value: - conditions.append("{0}=%s".format(field)) - values.append(value) - - condition_str = " and " + " and ".join(conditions) if len(conditions) else "" - - employees = frappe._dict(frappe.db.sql("select name, date_of_joining from tabEmployee where status='Active' {condition}" #nosec - .format(condition=condition_str), tuple(values))) - - return employees def validate(self): self.validate_dates() @@ -33,96 +19,3 @@ class LeavePeriod(Document): def validate_dates(self): if getdate(self.from_date) >= getdate(self.to_date): frappe.throw(_("To date can not be equal or less than from date")) - - - def grant_leave_allocation(self, grade=None, department=None, designation=None, - employee=None, carry_forward=0): - employee_records = self.get_employees({ - "grade": grade, - "department": department, - "designation": designation, - "name": employee - }) - - if employee_records: - if len(employee_records) > 20: - frappe.enqueue(grant_leave_alloc_for_employees, timeout=600, - employee_records=employee_records, leave_period=self, carry_forward=carry_forward) - else: - grant_leave_alloc_for_employees(employee_records, self, carry_forward) - else: - frappe.msgprint(_("No Employee Found")) - -def grant_leave_alloc_for_employees(employee_records, leave_period, carry_forward=0): - leave_allocations = [] - existing_allocations_for = get_existing_allocations(list(employee_records.keys()), leave_period.name) - leave_type_details = get_leave_type_details() - count = 0 - for employee in employee_records.keys(): - if employee in existing_allocations_for: - continue - count +=1 - leave_policy = get_employee_leave_policy(employee) - if leave_policy: - for leave_policy_detail in leave_policy.leave_policy_details: - if not leave_type_details.get(leave_policy_detail.leave_type).is_lwp: - leave_allocation = create_leave_allocation(employee, leave_policy_detail.leave_type, - leave_policy_detail.annual_allocation, leave_type_details, leave_period, carry_forward, employee_records.get(employee)) - leave_allocations.append(leave_allocation) - frappe.db.commit() - frappe.publish_progress(count*100/len(set(employee_records.keys()) - set(existing_allocations_for)), title = _("Allocating leaves...")) - - if leave_allocations: - frappe.msgprint(_("Leaves has been granted sucessfully")) - -def get_existing_allocations(employees, leave_period): - leave_allocations = frappe.db.sql_list(""" - SELECT DISTINCT - employee - FROM `tabLeave Allocation` - WHERE - leave_period=%s - AND employee in (%s) - AND carry_forward=0 - AND docstatus=1 - """ % ('%s', ', '.join(['%s']*len(employees))), [leave_period] + employees) - if leave_allocations: - frappe.msgprint(_("Skipping Leave Allocation for the following employees, as Leave Allocation records already exists against them. {0}") - .format("\n".join(leave_allocations))) - return leave_allocations - -def get_leave_type_details(): - leave_type_details = frappe._dict() - leave_types = frappe.get_all("Leave Type", - fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", "is_carry_forward", "expire_carry_forwarded_leaves_after_days"]) - for d in leave_types: - leave_type_details.setdefault(d.name, d) - return leave_type_details - -def create_leave_allocation(employee, leave_type, new_leaves_allocated, leave_type_details, leave_period, carry_forward, date_of_joining): - ''' Creates leave allocation for the given employee in the provided leave period ''' - if carry_forward and not leave_type_details.get(leave_type).is_carry_forward: - carry_forward = 0 - - # Calculate leaves at pro-rata basis for employees joining after the beginning of the given leave period - if getdate(date_of_joining) > getdate(leave_period.from_date): - remaining_period = ((date_diff(leave_period.to_date, date_of_joining) + 1) / (date_diff(leave_period.to_date, leave_period.from_date) + 1)) - new_leaves_allocated = ceil(new_leaves_allocated * remaining_period) - - # Earned Leaves and Compensatory Leaves are allocated by scheduler, initially allocate 0 - if leave_type_details.get(leave_type).is_earned_leave == 1 or leave_type_details.get(leave_type).is_compensatory == 1: - new_leaves_allocated = 0 - - allocation = frappe.get_doc(dict( - doctype="Leave Allocation", - employee=employee, - leave_type=leave_type, - from_date=leave_period.from_date, - to_date=leave_period.to_date, - new_leaves_allocated=new_leaves_allocated, - leave_period=leave_period.name, - carry_forward=carry_forward - )) - allocation.save(ignore_permissions = True) - allocation.submit() - return allocation.name \ No newline at end of file diff --git a/erpnext/hr/doctype/leave_period/test_leave_period.py b/erpnext/hr/doctype/leave_period/test_leave_period.py index 1762cf917a2..b5857bcd8fe 100644 --- a/erpnext/hr/doctype/leave_period/test_leave_period.py +++ b/erpnext/hr/doctype/leave_period/test_leave_period.py @@ -5,43 +5,11 @@ from __future__ import unicode_literals import frappe, erpnext import unittest -from frappe.utils import today, add_months -from erpnext.hr.doctype.employee.test_employee import make_employee -from erpnext.hr.doctype.leave_application.leave_application import get_leave_balance_on test_dependencies = ["Employee", "Leave Type", "Leave Policy"] class TestLeavePeriod(unittest.TestCase): - def setUp(self): - frappe.db.sql("delete from `tabLeave Period`") - - def test_leave_grant(self): - leave_type = "_Test Leave Type" - - # create the leave policy - leave_policy = frappe.get_doc({ - "doctype": "Leave Policy", - "leave_policy_details": [{ - "leave_type": leave_type, - "annual_allocation": 20 - }] - }).insert() - leave_policy.submit() - - # create employee and assign the leave period - employee = "test_leave_period@employee.com" - employee_doc_name = make_employee(employee) - frappe.db.set_value("Employee", employee_doc_name, "leave_policy", leave_policy.name) - - # clear the already allocated leave - frappe.db.sql('''delete from `tabLeave Allocation` where employee=%s''', "test_leave_period@employee.com") - - # create the leave period - leave_period = create_leave_period(add_months(today(), -3), add_months(today(), 3)) - - # test leave_allocation - leave_period.grant_leave_allocation(employee=employee_doc_name) - self.assertEqual(get_leave_balance_on(employee_doc_name, leave_type, today()), 20) + pass def create_leave_period(from_date, to_date, company=None): leave_period = frappe.db.get_value('Leave Period', diff --git a/erpnext/hr/doctype/leave_policy/leave_policy_dashboard.py b/erpnext/hr/doctype/leave_policy/leave_policy_dashboard.py index ff5dc2ff3e0..e0ec4be2dce 100644 --- a/erpnext/hr/doctype/leave_policy/leave_policy_dashboard.py +++ b/erpnext/hr/doctype/leave_policy/leave_policy_dashboard.py @@ -4,22 +4,10 @@ from frappe import _ def get_data(): return { 'fieldname': 'leave_policy', - 'non_standard_fieldnames': { - 'Employee Grade': 'default_leave_policy' - }, 'transactions': [ - { - 'label': _('Employees'), - 'items': ['Employee', 'Employee Grade'] - }, { 'label': _('Leaves'), 'items': ['Leave Allocation'] }, ] - } - - - - - \ No newline at end of file + } \ No newline at end of file diff --git a/erpnext/hr/doctype/leave_policy_assignment/__init__.py b/erpnext/hr/doctype/leave_policy_assignment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.js b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.js new file mode 100644 index 00000000000..7c32a0dde09 --- /dev/null +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.js @@ -0,0 +1,72 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Leave Policy Assignment', { + onload: function(frm) { + frm.ignore_doctypes_on_cancel_all = ["Leave Ledger Entry"]; + }, + + refresh: function(frm) { + if (frm.doc.docstatus === 1 && frm.doc.leaves_allocated === 0) { + frm.add_custom_button(__("Grant Leave"), function() { + + frappe.call({ + doc: frm.doc, + method: "grant_leave_alloc_for_employee", + callback: function(r) { + let leave_allocations = r.message; + let msg = frm.events.get_success_message(leave_allocations); + frappe.msgprint(msg); + cur_frm.refresh(); + } + }); + }); + } + }, + + get_success_message: function(leave_allocations) { + let msg = __("Leaves has been granted successfully"); + msg += "
    "; + msg += ""; + for (let key in leave_allocations) { + msg += ""; + } + msg += "
    "+__('Leave Type')+""+__("Leave Allocation")+""+__("Leaves Granted")+"
    "+key+""+leave_allocations[key]["name"]+""+leave_allocations[key]["leaves"]+"
    "; + return msg; + }, + + assignment_based_on: function(frm) { + if (frm.doc.assignment_based_on) { + frm.events.set_effective_date(frm); + } else { + frm.set_value("effective_from", ''); + frm.set_value("effective_to", ''); + } + }, + + leave_period: function(frm) { + if (frm.doc.leave_period) { + frm.events.set_effective_date(frm); + } + }, + + set_effective_date: function(frm) { + if (frm.doc.assignment_based_on == "Leave Period" && frm.doc.leave_period) { + frappe.model.with_doc("Leave Period", frm.doc.leave_period, function () { + let from_date = frappe.model.get_value("Leave Period", frm.doc.leave_period, "from_date"); + let to_date = frappe.model.get_value("Leave Period", frm.doc.leave_period, "to_date"); + frm.set_value("effective_from", from_date); + frm.set_value("effective_to", to_date); + + }); + } else if (frm.doc.assignment_based_on == "Joining Date" && frm.doc.employee) { + frappe.model.with_doc("Employee", frm.doc.employee, function () { + let from_date = frappe.model.get_value("Employee", frm.doc.employee, "date_of_joining"); + frm.set_value("effective_from", from_date); + frm.set_value("effective_to", frappe.datetime.add_months(frm.doc.effective_from, 12)); + }); + } + frm.refresh(); + } + +}); diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.json b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.json new file mode 100644 index 00000000000..3373350e733 --- /dev/null +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.json @@ -0,0 +1,168 @@ +{ + "actions": [], + "autoname": "HR-LPOL-ASSGN-.#####", + "creation": "2020-08-19 13:02:43.343666", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "employee", + "employee_name", + "company", + "leave_policy", + "carry_forward", + "column_break_5", + "assignment_based_on", + "leave_period", + "effective_from", + "effective_to", + "leaves_allocated", + "amended_from" + ], + "fields": [ + { + "fieldname": "employee", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Employee", + "options": "Employee", + "reqd": 1 + }, + { + "fetch_from": "employee.employee_name", + "fieldname": "employee_name", + "fieldtype": "Data", + "label": "Employee name", + "read_only": 1 + }, + { + "fieldname": "leave_policy", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Leave Policy", + "options": "Leave Policy", + "reqd": 1 + }, + { + "fieldname": "assignment_based_on", + "fieldtype": "Select", + "label": "Assignment based on", + "options": "\nLeave Period\nJoining Date" + }, + { + "depends_on": "eval:doc.assignment_based_on == \"Leave Period\"", + "fieldname": "leave_period", + "fieldtype": "Link", + "label": "Leave Period", + "mandatory_depends_on": "eval:doc.assignment_based_on == \"Leave Period\"", + "options": "Leave Period" + }, + { + "fieldname": "effective_from", + "fieldtype": "Date", + "label": "Effective From", + "read_only_depends_on": "eval:doc.assignment_based_on", + "reqd": 1 + }, + { + "fieldname": "effective_to", + "fieldtype": "Date", + "label": "Effective To", + "read_only_depends_on": "eval:doc.assignment_based_on == \"Leave Period\"", + "reqd": 1 + }, + { + "fetch_from": "employee.company", + "fieldname": "company", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "read_only": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Leave Policy Assignment", + "print_hide": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "carry_forward", + "fieldtype": "Check", + "label": "Add unused leaves from previous allocations" + }, + { + "default": "0", + "fieldname": "leaves_allocated", + "fieldtype": "Check", + "hidden": 1, + "label": "Leaves Allocated", + "no_copy": 1, + "print_hide": 1 + } + ], + "is_submittable": 1, + "links": [], + "modified": "2021-03-01 17:54:01.014509", + "modified_by": "Administrator", + "module": "HR", + "name": "Leave Policy Assignment", + "owner": "Administrator", + "permissions": [ + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py new file mode 100644 index 00000000000..4064c56e44c --- /dev/null +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document +from frappe import _, bold +from frappe.utils import getdate, date_diff, comma_and, formatdate, get_datetime, flt +from math import ceil +import json +from six import string_types + +class LeavePolicyAssignment(Document): + + def validate(self): + self.validate_policy_assignment_overlap() + self.set_dates() + + def set_dates(self): + if self.assignment_based_on == "Leave Period": + self.effective_from, self.effective_to = frappe.db.get_value("Leave Period", self.leave_period, ["from_date", "to_date"]) + elif self.assignment_based_on == "Joining Date": + self.effective_from = frappe.db.get_value("Employee", self.employee, "date_of_joining") + + def validate_policy_assignment_overlap(self): + leave_policy_assignments = frappe.get_all("Leave Policy Assignment", filters = { + "employee": self.employee, + "name": ("!=", self.name), + "docstatus": 1, + "effective_to": (">=", self.effective_from), + "effective_from": ("<=", self.effective_to) + }) + + if len(leave_policy_assignments): + frappe.throw(_("Leave Policy: {0} already assigned for Employee {1} for period {2} to {3}") + .format(bold(self.leave_policy), bold(self.employee), bold(formatdate(self.effective_from)), bold(formatdate(self.effective_to)))) + + def grant_leave_alloc_for_employee(self): + if self.leaves_allocated: + frappe.throw(_("Leave already have been assigned for this Leave Policy Assignment")) + else: + leave_allocations = {} + leave_type_details = get_leave_type_details() + + leave_policy = frappe.get_doc("Leave Policy", self.leave_policy) + date_of_joining = frappe.db.get_value("Employee", self.employee, "date_of_joining") + + for leave_policy_detail in leave_policy.leave_policy_details: + if not leave_type_details.get(leave_policy_detail.leave_type).is_lwp: + leave_allocation, new_leaves_allocated = self.create_leave_allocation( + leave_policy_detail.leave_type, leave_policy_detail.annual_allocation, + leave_type_details, date_of_joining + ) + + leave_allocations[leave_policy_detail.leave_type] = {"name": leave_allocation, "leaves": new_leaves_allocated} + + self.db_set("leaves_allocated", 1) + return leave_allocations + + def create_leave_allocation(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining): + # Creates leave allocation for the given employee in the provided leave period + carry_forward = self.carry_forward + if self.carry_forward and not leave_type_details.get(leave_type).is_carry_forward: + carry_forward = 0 + + new_leaves_allocated = self.get_new_leaves(leave_type, new_leaves_allocated, + leave_type_details, date_of_joining) + + allocation = frappe.get_doc(dict( + doctype="Leave Allocation", + employee=self.employee, + leave_type=leave_type, + from_date=self.effective_from, + to_date=self.effective_to, + new_leaves_allocated=new_leaves_allocated, + leave_period=self.leave_period or None, + leave_policy_assignment = self.name, + leave_policy = self.leave_policy, + carry_forward=carry_forward + )) + allocation.save(ignore_permissions = True) + allocation.submit() + return allocation.name, new_leaves_allocated + + def get_new_leaves(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining): + from frappe.model.meta import get_field_precision + precision = get_field_precision(frappe.get_meta("Leave Allocation").get_field("new_leaves_allocated")) + + # Earned Leaves and Compensatory Leaves are allocated by scheduler, initially allocate 0 + if leave_type_details.get(leave_type).is_compensatory == 1: + new_leaves_allocated = 0 + + elif leave_type_details.get(leave_type).is_earned_leave == 1: + if self.assignment_based_on == "Leave Period": + new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining) + else: + new_leaves_allocated = 0 + # Calculate leaves at pro-rata basis for employees joining after the beginning of the given leave period + elif getdate(date_of_joining) > getdate(self.effective_from): + remaining_period = ((date_diff(self.effective_to, date_of_joining) + 1) / (date_diff(self.effective_to, self.effective_from) + 1)) + new_leaves_allocated = ceil(new_leaves_allocated * remaining_period) + + return flt(new_leaves_allocated, precision) + + def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining): + from erpnext.hr.utils import get_monthly_earned_leave + + current_month = get_datetime().month + current_year = get_datetime().year + + from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date") + if getdate(date_of_joining) > getdate(from_date): + from_date = date_of_joining + + from_date_month = get_datetime(from_date).month + from_date_year = get_datetime(from_date).year + + months_passed = 0 + if current_year == from_date_year and current_month > from_date_month: + months_passed = current_month - from_date_month + elif current_year > from_date_year: + months_passed = (12 - from_date_month) + current_month + + if months_passed > 0: + monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated, + leave_type_details.get(leave_type).earned_leave_frequency, leave_type_details.get(leave_type).rounding) + new_leaves_allocated = monthly_earned_leave * months_passed + + return new_leaves_allocated + + +@frappe.whitelist() +def grant_leave_for_multiple_employees(leave_policy_assignments): + leave_policy_assignments = json.loads(leave_policy_assignments) + not_granted = [] + for assignment in leave_policy_assignments: + try: + frappe.get_doc("Leave Policy Assignment", assignment).grant_leave_alloc_for_employee() + except Exception: + not_granted.append(assignment) + + if len(not_granted): + msg = _("Leave not Granted for Assignments:")+ bold(comma_and(not_granted)) + _(". Please Check documents") + else: + msg = _("Leave granted Successfully") + frappe.msgprint(msg) + +@frappe.whitelist() +def create_assignment_for_multiple_employees(employees, data): + + if isinstance(employees, string_types): + employees= json.loads(employees) + + if isinstance(data, string_types): + data = frappe._dict(json.loads(data)) + + docs_name = [] + for employee in employees: + assignment = frappe.new_doc("Leave Policy Assignment") + assignment.employee = employee + assignment.assignment_based_on = data.assignment_based_on or None + assignment.leave_policy = data.leave_policy + assignment.effective_from = getdate(data.effective_from) or None + assignment.effective_to = getdate(data.effective_to) or None + assignment.leave_period = data.leave_period or None + assignment.carry_forward = data.carry_forward + + assignment.save() + assignment.submit() + docs_name.append(assignment.name) + return docs_name + + +def automatically_allocate_leaves_based_on_leave_policy(): + today = getdate() + automatically_allocate_leaves_based_on_leave_policy = frappe.db.get_single_value( + 'HR Settings', 'automatically_allocate_leaves_based_on_leave_policy' + ) + + pending_assignments = frappe.get_list( + "Leave Policy Assignment", + filters = {"docstatus": 1, "leaves_allocated": 0, "effective_from": today} + ) + + if len(pending_assignments) and automatically_allocate_leaves_based_on_leave_policy: + for assignment in pending_assignments: + frappe.get_doc("Leave Policy Assignment", assignment.name).grant_leave_alloc_for_employee() + + +def get_leave_type_details(): + leave_type_details = frappe._dict() + leave_types = frappe.get_all("Leave Type", + fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", + "is_carry_forward", "expire_carry_forwarded_leaves_after_days", "earned_leave_frequency", "rounding"]) + for d in leave_types: + leave_type_details.setdefault(d.name, d) + return leave_type_details + diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment_list.js b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment_list.js new file mode 100644 index 00000000000..468f243885c --- /dev/null +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment_list.js @@ -0,0 +1,138 @@ +frappe.listview_settings['Leave Policy Assignment'] = { + onload: function (list_view) { + let me = this; + list_view.page.add_inner_button(__("Bulk Leave Policy Assignment"), function () { + me.dialog = new frappe.ui.form.MultiSelectDialog({ + doctype: "Employee", + target: cur_list, + setters: { + company: '', + department: '', + }, + data_fields: [{ + fieldname: 'leave_policy', + fieldtype: 'Link', + options: 'Leave Policy', + label: __('Leave Policy'), + reqd: 1 + }, + { + fieldname: 'assignment_based_on', + fieldtype: 'Select', + options: ["", "Leave Period"], + label: __('Assignment Based On'), + onchange: () => { + if (cur_dialog.fields_dict.assignment_based_on.value === "Leave Period") { + cur_dialog.set_df_property("effective_from", "read_only", 1); + cur_dialog.set_df_property("leave_period", "reqd", 1); + cur_dialog.set_df_property("effective_to", "read_only", 1); + } else { + cur_dialog.set_df_property("effective_from", "read_only", 0); + cur_dialog.set_df_property("leave_period", "reqd", 0); + cur_dialog.set_df_property("effective_to", "read_only", 0); + cur_dialog.set_value("effective_from", ""); + cur_dialog.set_value("effective_to", ""); + } + } + }, + { + fieldname: "leave_period", + fieldtype: 'Link', + options: "Leave Period", + label: __('Leave Period'), + depends_on: doc => { + return doc.assignment_based_on == 'Leave Period'; + }, + onchange: () => { + if (cur_dialog.fields_dict.leave_period.value) { + me.set_effective_date(); + } + } + }, + { + fieldtype: "Column Break" + }, + { + fieldname: 'effective_from', + fieldtype: 'Date', + label: __('Effective From'), + reqd: 1 + }, + { + fieldname: 'effective_to', + fieldtype: 'Date', + label: __('Effective To'), + reqd: 1 + }, + { + fieldname: 'carry_forward', + fieldtype: 'Check', + label: __('Add unused leaves from previous allocations') + } + ], + get_query() { + return { + filters: { + status: ['=', 'Active'] + } + }; + }, + add_filters_group: 1, + primary_action_label: "Assign", + action(employees, data) { + frappe.call({ + method: 'erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment.create_assignment_for_multiple_employees', + async: false, + args: { + employees: employees, + data: data + } + }); + cur_dialog.hide(); + } + }); + }); + + list_view.page.add_inner_button(__("Grant Leaves"), function () { + me.dialog = new frappe.ui.form.MultiSelectDialog({ + doctype: "Leave Policy Assignment", + target: cur_list, + setters: { + company: '', + employee: '', + }, + get_query() { + return { + filters: { + docstatus: ['=', 1], + leaves_allocated: ['=', 0] + } + }; + }, + add_filters_group: 1, + primary_action_label: "Grant Leaves", + action(leave_policy_assignments) { + frappe.call({ + method: 'erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment.grant_leave_for_multiple_employees', + async: false, + args: { + leave_policy_assignments: leave_policy_assignments + } + }); + me.dialog.hide(); + } + }); + }); + }, + + set_effective_date: function () { + if (cur_dialog.fields_dict.assignment_based_on.value === "Leave Period" && cur_dialog.fields_dict.leave_period.value) { + frappe.model.with_doc("Leave Period", cur_dialog.fields_dict.leave_period.value, function () { + let from_date = frappe.model.get_value("Leave Period", cur_dialog.fields_dict.leave_period.value, "from_date"); + let to_date = frappe.model.get_value("Leave Period", cur_dialog.fields_dict.leave_period.value, "to_date"); + cur_dialog.set_value("effective_from", from_date); + cur_dialog.set_value("effective_to", to_date); + }); + } + } +}; \ No newline at end of file diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py new file mode 100644 index 00000000000..838e794795f --- /dev/null +++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from erpnext.hr.doctype.leave_application.test_leave_application import get_leave_period, get_employee +from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import create_assignment_for_multiple_employees +from erpnext.hr.doctype.leave_policy.test_leave_policy import create_leave_policy + +test_dependencies = ["Employee"] + +class TestLeavePolicyAssignment(unittest.TestCase): + + def setUp(self): + for doctype in ["Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]: + frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec + + def test_grant_leaves(self): + leave_period = get_leave_period() + employee = get_employee() + + # create the leave policy with leave type "_Test Leave Type", allocation = 10 + leave_policy = create_leave_policy() + leave_policy.submit() + + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + + leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + + leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0]) + leave_policy_assignment_doc.grant_leave_alloc_for_employee() + leave_policy_assignment_doc.reload() + + self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1) + + leave_allocation = frappe.get_list("Leave Allocation", filters={ + "employee": employee.name, + "leave_policy":leave_policy.name, + "leave_policy_assignment": leave_policy_assignments[0], + "docstatus": 1})[0] + + leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation) + + self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10) + self.assertEqual(leave_alloc_doc.leave_type, "_Test Leave Type") + self.assertEqual(leave_alloc_doc.from_date, leave_period.from_date) + self.assertEqual(leave_alloc_doc.to_date, leave_period.to_date) + self.assertEqual(leave_alloc_doc.leave_policy, leave_policy.name) + self.assertEqual(leave_alloc_doc.leave_policy_assignment, leave_policy_assignments[0]) + + def test_allow_to_grant_all_leave_after_cancellation_of_every_leave_allocation(self): + leave_period = get_leave_period() + employee = get_employee() + + # create the leave policy with leave type "_Test Leave Type", allocation = 10 + leave_policy = create_leave_policy() + leave_policy.submit() + + + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name + } + + leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data)) + + leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0]) + leave_policy_assignment_doc.grant_leave_alloc_for_employee() + leave_policy_assignment_doc.reload() + + + # every leave is allocated no more leave can be granted now + self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1) + + leave_allocation = frappe.get_list("Leave Allocation", filters={ + "employee": employee.name, + "leave_policy":leave_policy.name, + "leave_policy_assignment": leave_policy_assignments[0], + "docstatus": 1})[0] + + leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation) + + # User all allowed to grant leave when there is no allocation against assignment + leave_alloc_doc.cancel() + leave_alloc_doc.delete() + + leave_policy_assignment_doc.reload() + + + # User are now allowed to grant leave + self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0) + + def tearDown(self): + for doctype in ["Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]: + frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec + + diff --git a/erpnext/hr/doctype/leave_type/leave_type.json b/erpnext/hr/doctype/leave_type/leave_type.json index 0af832f903e..fc577ef1d3d 100644 --- a/erpnext/hr/doctype/leave_type/leave_type.json +++ b/erpnext/hr/doctype/leave_type/leave_type.json @@ -15,6 +15,8 @@ "column_break_3", "is_carry_forward", "is_lwp", + "is_ppl", + "fraction_of_daily_salary_per_leave", "is_optional_leave", "allow_negative", "include_holiday", @@ -31,6 +33,7 @@ "is_earned_leave", "earned_leave_frequency", "column_break_22", + "based_on_date_of_joining", "rounding" ], "fields": [ @@ -77,6 +80,7 @@ }, { "default": "0", + "depends_on": "eval:doc.is_ppl == 0", "fieldname": "is_lwp", "fieldtype": "Check", "label": "Is Leave Without Pay" @@ -168,7 +172,7 @@ "fieldname": "rounding", "fieldtype": "Select", "label": "Rounding", - "options": "0.5\n1.0" + "options": "\n0.25\n0.5\n1.0" }, { "depends_on": "is_carry_forward", @@ -183,12 +187,34 @@ { "fieldname": "column_break_22", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:doc.is_earned_leave", + "description": "If checked, leave will be granted on the day of joining every month.", + "fieldname": "based_on_date_of_joining", + "fieldtype": "Check", + "label": "Based On Date Of Joining" + }, + { + "default": "0", + "depends_on": "eval:doc.is_lwp == 0", + "fieldname": "is_ppl", + "fieldtype": "Check", + "label": "Is Partially Paid Leave" + }, + { + "depends_on": "eval:doc.is_ppl == 1", + "fieldname": "fraction_of_daily_salary_per_leave", + "fieldtype": "Float", + "label": "Fraction of Daily Salary per Leave", + "mandatory_depends_on": "eval:doc.is_ppl == 1" } ], "icon": "fa fa-flag", "idx": 1, "links": [], - "modified": "2019-12-12 12:48:37.780254", + "modified": "2021-03-02 11:22:33.776320", "modified_by": "Administrator", "module": "HR", "name": "Leave Type", diff --git a/erpnext/hr/doctype/leave_type/leave_type.py b/erpnext/hr/doctype/leave_type/leave_type.py index c0d12968416..21f180b857d 100644 --- a/erpnext/hr/doctype/leave_type/leave_type.py +++ b/erpnext/hr/doctype/leave_type/leave_type.py @@ -21,3 +21,9 @@ class LeaveType(Document): leave_allocation = [l['name'] for l in leave_allocation] if leave_allocation: frappe.throw(_('Leave application is linked with leave allocations {0}. Leave application cannot be set as leave without pay').format(", ".join(leave_allocation))) #nosec + + if self.is_lwp and self.is_ppl: + frappe.throw(_("Leave Type can be either without pay or partial pay")) + + if self.is_ppl and (self.fraction_of_daily_salary_per_leave < 0 or self.fraction_of_daily_salary_per_leave > 1): + frappe.throw(_("The fraction of Daily Salary per Leave should be between 0 and 1")) diff --git a/erpnext/hr/doctype/leave_type/test_leave_type.py b/erpnext/hr/doctype/leave_type/test_leave_type.py index 0c4f435860a..7fef2975c8a 100644 --- a/erpnext/hr/doctype/leave_type/test_leave_type.py +++ b/erpnext/hr/doctype/leave_type/test_leave_type.py @@ -18,9 +18,14 @@ def create_leave_type(**args): "allow_encashment": args.allow_encashment or 0, "is_earned_leave": args.is_earned_leave or 0, "is_lwp": args.is_lwp or 0, + "is_ppl":args.is_ppl or 0, "is_carry_forward": args.is_carry_forward or 0, "expire_carry_forwarded_leaves_after_days": args.expire_carry_forwarded_leaves_after_days or 0, "encashment_threshold_days": args.encashment_threshold_days or 5, "earning_component": "Leave Encashment" }) + + if leave_type.is_ppl: + leave_type.fraction_of_daily_salary_per_leave = args.fraction_of_daily_salary_per_leave or 0.5 + return leave_type \ No newline at end of file diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 2c385e80f46..ab65260c091 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -88,7 +88,7 @@ def get_events(start, end, filters=None): def add_assignments(events, start, end, conditions=None): query = """select name, start_date, end_date, employee_name, - employee, docstatus + employee, docstatus, shift_type from `tabShift Assignment` where start_date >= %(start_date)s or end_date <= %(end_date)s @@ -97,18 +97,40 @@ def add_assignments(events, start, end, conditions=None): if conditions: query += conditions - for d in frappe.db.sql(query, {"start_date":start, "end_date":end}, as_dict=True): - e = { - "name": d.name, - "doctype": "Shift Assignment", - "start_date": d.start_date, - "end_date": d.end_date if d.end_date else nowdate(), - "title": cstr(d.employee_name) + ": "+ \ - cstr(d.shift_type), - "docstatus": d.docstatus - } - if e not in events: - events.append(e) + records = frappe.db.sql(query, {"start_date":start, "end_date":end}, as_dict=True) + shift_timing_map = get_shift_type_timing([d.shift_type for d in records]) + + for d in records: + daily_event_start = d.start_date + daily_event_end = d.end_date if d.end_date else getdate() + delta = timedelta(days=1) + while daily_event_start <= daily_event_end: + start_timing = frappe.utils.get_datetime(daily_event_start)+ shift_timing_map[d.shift_type]['start_time'] + end_timing = frappe.utils.get_datetime(daily_event_start)+ shift_timing_map[d.shift_type]['end_time'] + daily_event_start += delta + e = { + "name": d.name, + "doctype": "Shift Assignment", + "start_date": start_timing, + "end_date": end_timing, + "title": cstr(d.employee_name) + ": "+ \ + cstr(d.shift_type), + "docstatus": d.docstatus, + "allDay": 0 + } + if e not in events: + events.append(e) + + return events + +def get_shift_type_timing(shift_types): + shift_timing_map = {} + data = frappe.get_all("Shift Type", filters = {"name": ("IN", shift_types)}, fields = ['name', 'start_time', 'end_time']) + + for d in data: + shift_timing_map[d.name] = d + + return shift_timing_map def get_employee_shift(employee, for_date=nowdate(), consider_default_shift=False, next_shift_direction=None): diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment_calendar.js b/erpnext/hr/doctype/shift_assignment/shift_assignment_calendar.js index 17a986deb21..bb692e1402e 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment_calendar.js +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment_calendar.js @@ -6,14 +6,8 @@ frappe.views.calendar["Shift Assignment"] = { "start": "start_date", "end": "end_date", "id": "name", - "docstatus": 1 - }, - options: { - header: { - left: 'prev,next today', - center: 'title', - right: 'month' - } + "docstatus": 1, + "allDay": "allDay", }, get_events_method: "erpnext.hr.doctype.shift_assignment.shift_assignment.get_events" } \ No newline at end of file diff --git a/erpnext/hr/doctype/shift_request/shift_request.py b/erpnext/hr/doctype/shift_request/shift_request.py index 1c2801bf08f..473193d5ac4 100644 --- a/erpnext/hr/doctype/shift_request/shift_request.py +++ b/erpnext/hr/doctype/shift_request/shift_request.py @@ -87,5 +87,5 @@ class ShiftRequest(Document): def throw_overlap_error(self, d): msg = _("Employee {0} has already applied for {1} between {2} and {3} : ").format(self.employee, d['shift_type'], formatdate(d['from_date']), formatdate(d['to_date'])) \ - + """ {0}""".format(d["name"]) + + """ {0}""".format(d["name"]) frappe.throw(msg, OverlapError) \ No newline at end of file diff --git a/erpnext/hr/doctype/shift_request/test_shift_request.py b/erpnext/hr/doctype/shift_request/test_shift_request.py index 3dcfcbf4a5b..230bb2b0e4e 100644 --- a/erpnext/hr/doctype/shift_request/test_shift_request.py +++ b/erpnext/hr/doctype/shift_request/test_shift_request.py @@ -7,6 +7,8 @@ import frappe import unittest from frappe.utils import nowdate, add_days +test_dependencies = ["Shift Type"] + class TestShiftRequest(unittest.TestCase): def setUp(self): for doctype in ["Shift Request", "Shift Assignment"]: @@ -46,4 +48,4 @@ def set_shift_approver(department): department_doc = frappe.get_doc("Department", department) department_doc.append('shift_request_approver',{'approver': "test1@example.com"}) department_doc.save() - department_doc.reload() \ No newline at end of file + department_doc.reload() diff --git a/erpnext/hr/doctype/shift_type/test_records.json b/erpnext/hr/doctype/shift_type/test_records.json new file mode 100644 index 00000000000..9040b915a17 --- /dev/null +++ b/erpnext/hr/doctype/shift_type/test_records.json @@ -0,0 +1,8 @@ +[ + { + "doctype": "Shift Type", + "name": "Day Shift", + "start_time": "9:00:00", + "end_time": "18:00:00" + } +] diff --git a/erpnext/hr/doctype/shift_type/test_shift_type.py b/erpnext/hr/doctype/shift_type/test_shift_type.py index 535072a0358..bc4f0eafcd5 100644 --- a/erpnext/hr/doctype/shift_type/test_shift_type.py +++ b/erpnext/hr/doctype/shift_type/test_shift_type.py @@ -7,14 +7,4 @@ import frappe import unittest class TestShiftType(unittest.TestCase): - def test_make_shift_type(self): - if frappe.db.exists("Shift Type", "Day Shift"): - return - shift_type = frappe.get_doc({ - "doctype": "Shift Type", - "name": "Day Shift", - "start_time": "9:00:00", - "end_time": "18:00:00" - }) - shift_type.insert() - \ No newline at end of file + pass diff --git a/erpnext/hr/doctype/skill/skill.json b/erpnext/hr/doctype/skill/skill.json index 518297395bd..4c8a8c92c10 100644 --- a/erpnext/hr/doctype/skill/skill.json +++ b/erpnext/hr/doctype/skill/skill.json @@ -3,7 +3,7 @@ "allow_events_in_timeline": 0, "allow_guest_to_view": 0, "allow_import": 0, - "allow_rename": 0, + "allow_rename": 1, "autoname": "field:skill_name", "beta": 0, "creation": "2019-04-16 09:54:39.486915", @@ -16,7 +16,7 @@ "fields": [ { "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, + "allow_in_quick_entry": 1, "allow_on_submit": 0, "bold": 0, "collapsible": 0, @@ -46,6 +46,12 @@ "set_only_once": 0, "translatable": 0, "unique": 1 + }, + { + "allow_in_quick_entry": 1, + "fieldname": "description", + "fieldtype": "Text", + "label": "Description" } ], "has_web_view": 0, @@ -56,7 +62,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2019-04-16 09:55:00.536328", + "modified": "2021-02-26 10:55:00.536328", "modified_by": "Administrator", "module": "HR", "name": "Skill", @@ -110,4 +116,4 @@ "track_changes": 1, "track_seen": 0, "track_views": 0 -} \ No newline at end of file +} diff --git a/erpnext/hr/doctype/staffing_plan/staffing_plan.py b/erpnext/hr/doctype/staffing_plan/staffing_plan.py index 5b84d00bd6f..533149a8235 100644 --- a/erpnext/hr/doctype/staffing_plan/staffing_plan.py +++ b/erpnext/hr/doctype/staffing_plan/staffing_plan.py @@ -39,6 +39,7 @@ class StaffingPlan(Document): detail.current_count = designation_counts['employee_count'] detail.current_openings = designation_counts['job_openings'] + detail.total_estimated_cost = 0 if detail.number_of_positions > 0: if detail.vacancies > 0 and detail.estimated_cost_per_position: detail.total_estimated_cost = cint(detail.vacancies) * flt(detail.estimated_cost_per_position) diff --git a/erpnext/hr/doctype/upload_attendance/upload_attendance.js b/erpnext/hr/doctype/upload_attendance/upload_attendance.js index 9df2948a157..29aa85484a8 100644 --- a/erpnext/hr/doctype/upload_attendance/upload_attendance.js +++ b/erpnext/hr/doctype/upload_attendance/upload_attendance.js @@ -24,10 +24,10 @@ erpnext.hr.AttendanceControlPanel = frappe.ui.form.Controller.extend({ } window.location.href = repl(frappe.request.url + '?cmd=%(cmd)s&from_date=%(from_date)s&to_date=%(to_date)s', { - cmd: "erpnext.hr.doctype.upload_attendance.upload_attendance.get_template", - from_date: this.frm.doc.att_fr_date, - to_date: this.frm.doc.att_to_date, - }); + cmd: "erpnext.hr.doctype.upload_attendance.upload_attendance.get_template", + from_date: this.frm.doc.att_fr_date, + to_date: this.frm.doc.att_to_date, + }); }, show_upload() { diff --git a/erpnext/hr/doctype/upload_attendance/upload_attendance.py b/erpnext/hr/doctype/upload_attendance/upload_attendance.py index edf05e827b9..674c8e3eb45 100644 --- a/erpnext/hr/doctype/upload_attendance/upload_attendance.py +++ b/erpnext/hr/doctype/upload_attendance/upload_attendance.py @@ -28,7 +28,12 @@ def get_template(): w = UnicodeWriter() w = add_header(w) - w = add_data(w, args) + try: + w = add_data(w, args) + except Exception as e: + frappe.clear_messages() + frappe.respond_as_web_page("Holiday List Missing", html=e) + return # write out response as a type csv frappe.response['result'] = cstr(w.getvalue()) diff --git a/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py b/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py index e9dc7764f7b..cf0048c1a76 100644 --- a/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py +++ b/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py @@ -6,18 +6,28 @@ from __future__ import unicode_literals import frappe import unittest from frappe.utils import nowdate,flt, cstr,random_string +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.vehicle_log.vehicle_log import make_expense_claim class TestVehicleLog(unittest.TestCase): + def setUp(self): + employee_id = frappe.db.sql("""select name from `tabEmployee` where name='testdriver@example.com'""") + self.employee_id = employee_id[0][0] if employee_id else None + + if not self.employee_id: + self.employee_id = make_employee("testdriver@example.com", company="_Test Company") + + self.license_plate = get_vehicle(self.employee_id) + + def tearDown(self): + frappe.delete_doc("Vehicle", self.license_plate, force=1) + frappe.delete_doc("Employee", self.employee_id, force=1) + def test_make_vehicle_log_and_syncing_of_odometer_value(self): - employee_id = frappe.db.sql("""select name from `tabEmployee` where status='Active' order by modified desc limit 1""") - employee_id = employee_id[0][0] if employee_id else None - - license_plate = get_vehicle(employee_id) - vehicle_log = frappe.get_doc({ "doctype": "Vehicle Log", - "license_plate": cstr(license_plate), - "employee":employee_id, + "license_plate": cstr(self.license_plate), + "employee": self.employee_id, "date":frappe.utils.nowdate(), "odometer":5010, "fuel_qty":frappe.utils.flt(50), @@ -27,7 +37,7 @@ class TestVehicleLog(unittest.TestCase): vehicle_log.submit() #checking value of vehicle odometer value on submit. - vehicle = frappe.get_doc("Vehicle", license_plate) + vehicle = frappe.get_doc("Vehicle", self.license_plate) self.assertEqual(vehicle.last_odometer, vehicle_log.odometer) #checking value vehicle odometer on vehicle log cancellation. @@ -40,6 +50,28 @@ class TestVehicleLog(unittest.TestCase): self.assertEqual(vehicle.last_odometer, current_odometer - distance_travelled) + vehicle_log.delete() + + def test_vehicle_log_fuel_expense(self): + vehicle_log = frappe.get_doc({ + "doctype": "Vehicle Log", + "license_plate": cstr(self.license_plate), + "employee": self.employee_id, + "date": frappe.utils.nowdate(), + "odometer":5010, + "fuel_qty":frappe.utils.flt(50), + "price": frappe.utils.flt(500) + }) + vehicle_log.save() + vehicle_log.submit() + + expense_claim = make_expense_claim(vehicle_log.name) + fuel_expense = expense_claim.expenses[0].amount + self.assertEqual(fuel_expense, 50*500) + + vehicle_log.cancel() + frappe.delete_doc("Expense Claim", expense_claim.name) + frappe.delete_doc("Vehicle Log", vehicle_log.name) def get_vehicle(employee_id): license_plate=random_string(10).upper() diff --git a/erpnext/hr/doctype/vehicle_log/vehicle_log.py b/erpnext/hr/doctype/vehicle_log/vehicle_log.py index 8affab2a18d..04c94e37d5a 100644 --- a/erpnext/hr/doctype/vehicle_log/vehicle_log.py +++ b/erpnext/hr/doctype/vehicle_log/vehicle_log.py @@ -32,7 +32,7 @@ def make_expense_claim(docname): vehicle_log = frappe.get_doc("Vehicle Log", docname) service_expense = sum([flt(d.expense_amount) for d in vehicle_log.service_detail]) - claim_amount = service_expense + flt(vehicle_log.price) + claim_amount = service_expense + (flt(vehicle_log.price) * flt(vehicle_log.fuel_qty) or 1) if not claim_amount: frappe.throw(_("No additional expenses has been added")) diff --git a/erpnext/hr/page/team_updates/team_updates.js b/erpnext/hr/page/team_updates/team_updates.js index da1f5316a0f..358329748e6 100644 --- a/erpnext/hr/page/team_updates/team_updates.js +++ b/erpnext/hr/page/team_updates/team_updates.js @@ -36,12 +36,12 @@ frappe.team_updates = { start: me.start }, callback: function(r) { - if(r.message) { + if (r.message && r.message.length > 0) { r.message.forEach(function(d) { me.add_row(d); }); } else { - frappe.show_alert({message:__('No more updates'), indicator:'darkgrey'}); + frappe.show_alert({message: __('No more updates'), indicator: 'gray'}); me.more.parent().addClass('hidden'); } } @@ -75,6 +75,6 @@ frappe.team_updates = { } me.last_feed_date = date; - $(frappe.render_template('team_update_row', data)).appendTo(me.body) + $(frappe.render_template('team_update_row', data)).appendTo(me.body); } -} \ No newline at end of file +} diff --git a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py index 1b923581841..06f9160363c 100644 --- a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py +++ b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py @@ -40,17 +40,17 @@ def get_columns(): 'fieldname': 'opening_balance', 'width': 130, }, { - 'label': _('Leaves Allocated'), + 'label': _('Leave Allocated'), 'fieldtype': 'float', 'fieldname': 'leaves_allocated', 'width': 130, }, { - 'label': _('Leaves Taken'), + 'label': _('Leave Taken'), 'fieldtype': 'float', 'fieldname': 'leaves_taken', 'width': 130, }, { - 'label': _('Leaves Expired'), + 'label': _('Leave Expired'), 'fieldtype': 'float', 'fieldname': 'leaves_expired', 'width': 130, diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index 46082129e24..c5929c6bf99 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -36,6 +36,8 @@ def execute(filters=None): conditions, filters = get_conditions(filters) columns, days = get_columns(filters) att_map = get_attendance_list(conditions, filters) + if not att_map: + return columns, [], None, None if filters.group_by: emp_map, group_by_parameters = get_employee_details(filters.group_by, filters.company) @@ -65,10 +67,14 @@ def execute(filters=None): if filters.group_by: emp_att_map = {} for parameter in group_by_parameters: - data.append([ ""+ parameter + ""]) - record, aaa = add_data(emp_map[parameter], att_map, filters, holiday_map, conditions, default_holiday_list, leave_list=leave_list) - emp_att_map.update(aaa) - data += record + emp_map_set = set([key for key in emp_map[parameter].keys()]) + att_map_set = set([key for key in att_map.keys()]) + if (att_map_set & emp_map_set): + parameter_row = [""+ parameter + ""] + ['' for day in range(filters["total_days_in_month"] + 2)] + data.append(parameter_row) + record, emp_att_data = add_data(emp_map[parameter], att_map, filters, holiday_map, conditions, default_holiday_list, leave_list=leave_list) + emp_att_map.update(emp_att_data) + data += record else: record, emp_att_map = add_data(emp_map, att_map, filters, holiday_map, conditions, default_holiday_list, leave_list=leave_list) data += record @@ -237,6 +243,9 @@ def get_attendance_list(conditions, filters): status from tabAttendance where docstatus = 1 %s order by employee, attendance_date""" % conditions, filters, as_dict=1) + if not attendance_list: + msgprint(_("No attendance record found"), alert=True, indicator="orange") + att_map = {} for d in attendance_list: att_map.setdefault(d.employee, frappe._dict()).setdefault(d.day_of_month, "") diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 8d95924681a..0c4c1cafb07 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -1,16 +1,19 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals -import frappe, erpnext -from frappe import _ -from frappe.utils import formatdate, format_datetime, getdate, get_datetime, nowdate, flt, cstr, add_days, today -from frappe.model.document import Document -from frappe.desk.form import assign_to +import erpnext +import frappe from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee +from frappe import _ +from frappe.desk.form import assign_to +from frappe.model.document import Document +from frappe.utils import (add_days, cstr, flt, format_datetime, formatdate, + get_datetime, getdate, nowdate, today, unique) + class DuplicateDeclarationError(frappe.ValidationError): pass + class EmployeeBoardingController(Document): ''' Create the project and the task for the boarding process @@ -48,27 +51,38 @@ class EmployeeBoardingController(Document): continue task = frappe.get_doc({ - "doctype": "Task", - "project": self.project, - "subject": activity.activity_name + " : " + self.employee_name, - "description": activity.description, - "department": self.department, - "company": self.company, - "task_weight": activity.task_weight - }).insert(ignore_permissions=True) + "doctype": "Task", + "project": self.project, + "subject": activity.activity_name + " : " + self.employee_name, + "description": activity.description, + "department": self.department, + "company": self.company, + "task_weight": activity.task_weight + }).insert(ignore_permissions=True) activity.db_set("task", task.name) + users = [activity.user] if activity.user else [] if activity.role: - user_list = frappe.db.sql_list('''select distinct(parent) from `tabHas Role` - where parenttype='User' and role=%s''', activity.role) - users = users + user_list + user_list = frappe.db.sql_list(''' + SELECT + DISTINCT(has_role.parent) + FROM + `tabHas Role` has_role + LEFT JOIN `tabUser` user + ON has_role.parent = user.name + WHERE + has_role.parenttype = 'User' + AND user.enabled = 1 + AND has_role.role = %s + ''', activity.role) + users = unique(users + user_list) if "Administrator" in users: users.remove("Administrator") # assign the task the users if users: - self.assign_task_to_users(task, set(users)) + self.assign_task_to_users(task, users) def assign_task_to_users(self, task, users): for user in users: @@ -211,23 +225,10 @@ def get_doc_condition(doctype): def throw_overlap_error(doc, exists_for, overlap_doc, from_date, to_date): msg = _("A {0} exists between {1} and {2} (").format(doc.doctype, formatdate(from_date), formatdate(to_date)) \ - + """ {1}""".format(doc.doctype, overlap_doc) \ + + """ {1}""".format(doc.doctype, overlap_doc) \ + _(") for {0}").format(exists_for) frappe.throw(msg) -def get_employee_leave_policy(employee): - leave_policy = frappe.db.get_value("Employee", employee, "leave_policy") - if not leave_policy: - employee_grade = frappe.db.get_value("Employee", employee, "grade") - if employee_grade: - leave_policy = frappe.db.get_value("Employee Grade", employee_grade, "default_leave_policy") - if not leave_policy: - frappe.throw(_("Employee {0} of grade {1} have no default leave policy").format(employee, employee_grade)) - if leave_policy: - return frappe.get_doc("Leave Policy", leave_policy) - else: - frappe.throw(_("Please set leave policy for employee {0} in Employee / Grade record").format(employee)) - def validate_duplicate_exemption_for_payroll_period(doctype, docname, payroll_period, employee): existing_record = frappe.db.exists(doctype, { "payroll_period": payroll_period, @@ -300,43 +301,77 @@ def generate_leave_encashment(): def allocate_earned_leaves(): '''Allocate earned leaves to Employees''' - e_leave_types = frappe.get_all("Leave Type", - fields=["name", "max_leaves_allowed", "earned_leave_frequency", "rounding"], - filters={'is_earned_leave' : 1}) + e_leave_types = get_earned_leaves() today = getdate() - divide_by_frequency = {"Yearly": 1, "Half-Yearly": 6, "Quarterly": 4, "Monthly": 12} for e_leave_type in e_leave_types: - leave_allocations = frappe.db.sql("""select name, employee, from_date, to_date from `tabLeave Allocation` where %s - between from_date and to_date and docstatus=1 and leave_type=%s""", (today, e_leave_type.name), as_dict=1) + + leave_allocations = get_leave_allocations(today, e_leave_type.name) + for allocation in leave_allocations: - leave_policy = get_employee_leave_policy(allocation.employee) - if not leave_policy: + + if not allocation.leave_policy_assignment and not allocation.leave_policy: continue - if not e_leave_type.earned_leave_frequency == "Monthly": - if not check_frequency_hit(allocation.from_date, today, e_leave_type.earned_leave_frequency): - continue + + leave_policy = allocation.leave_policy if allocation.leave_policy else frappe.db.get_value( + "Leave Policy Assignment", allocation.leave_policy_assignment, ["leave_policy"]) + annual_allocation = frappe.db.get_value("Leave Policy Detail", filters={ - 'parent': leave_policy.name, + 'parent': leave_policy, 'leave_type': e_leave_type.name }, fieldname=['annual_allocation']) - if annual_allocation: - earned_leaves = flt(annual_allocation) / divide_by_frequency[e_leave_type.earned_leave_frequency] - if e_leave_type.rounding == "0.5": - earned_leaves = round(earned_leaves * 2) / 2 - else: - earned_leaves = round(earned_leaves) - allocation = frappe.get_doc('Leave Allocation', allocation.name) - new_allocation = flt(allocation.total_leaves_allocated) + flt(earned_leaves) + from_date=allocation.from_date - if new_allocation > e_leave_type.max_leaves_allowed and e_leave_type.max_leaves_allowed > 0: - new_allocation = e_leave_type.max_leaves_allowed + if e_leave_type.based_on_date_of_joining_date: + from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") - if new_allocation == allocation.total_leaves_allocated: - continue - allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) - create_additional_leave_ledger_entry(allocation, earned_leaves, today) + if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date): + update_previous_leave_allocation(allocation, annual_allocation, e_leave_type) + +def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type): + earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding) + + allocation = frappe.get_doc('Leave Allocation', allocation.name) + new_allocation = flt(allocation.total_leaves_allocated) + flt(earned_leaves) + + if new_allocation > e_leave_type.max_leaves_allowed and e_leave_type.max_leaves_allowed > 0: + new_allocation = e_leave_type.max_leaves_allowed + + if new_allocation != allocation.total_leaves_allocated: + allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) + today_date = today() + create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) + +def get_monthly_earned_leave(annual_leaves, frequency, rounding): + earned_leaves = 0.0 + divide_by_frequency = {"Yearly": 1, "Half-Yearly": 6, "Quarterly": 4, "Monthly": 12} + if annual_leaves: + earned_leaves = flt(annual_leaves) / divide_by_frequency[frequency] + if rounding: + if rounding == "0.25": + earned_leaves = round(earned_leaves * 4) / 4 + elif rounding == "0.5": + earned_leaves = round(earned_leaves * 2) / 2 + else: + earned_leaves = round(earned_leaves) + + return earned_leaves + + +def get_leave_allocations(date, leave_type): + return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy + from `tabLeave Allocation` + where + %s between from_date and to_date and docstatus=1 + and leave_type=%s""", + (date, leave_type), as_dict=1) + + +def get_earned_leaves(): + return frappe.get_all("Leave Type", + fields=["name", "max_leaves_allowed", "earned_leave_frequency", "rounding", "based_on_date_of_joining"], + filters={'is_earned_leave' : 1}) def create_additional_leave_ledger_entry(allocation, leaves, date): ''' Create leave ledger entry for leave types ''' @@ -345,24 +380,32 @@ def create_additional_leave_ledger_entry(allocation, leaves, date): allocation.unused_leaves = 0 allocation.create_leave_ledger_entry() -def check_frequency_hit(from_date, to_date, frequency): - '''Return True if current date matches frequency''' - from_dt = get_datetime(from_date) - to_dt = get_datetime(to_date) +def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date): + import calendar from dateutil import relativedelta - rd = relativedelta.relativedelta(to_dt, from_dt) - months = rd.months - if frequency == "Quarterly": - if not months % 3: + + from_date = get_datetime(from_date) + to_date = get_datetime(to_date) + rd = relativedelta.relativedelta(to_date, from_date) + #last day of month + last_day = calendar.monthrange(to_date.year, to_date.month)[1] + + if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day): + if frequency == "Monthly": return True - elif frequency == "Half-Yearly": - if not months % 6: + elif frequency == "Quarterly" and rd.months % 3: return True - elif frequency == "Yearly": - if not months % 12: + elif frequency == "Half-Yearly" and rd.months % 6: return True + elif frequency == "Yearly" and rd.months % 12: + return True + + if frappe.flags.in_test: + return True + return False + def get_salary_assignment(employee, date): assignment = frappe.db.sql(""" select * from `tabSalary Structure Assignment` @@ -454,3 +497,10 @@ def get_previous_claimed_amount(employee, payroll_period, non_pro_rata=False, co if sum_of_claimed_amount and flt(sum_of_claimed_amount[0].total_amount) > 0: total_claimed_amount = sum_of_claimed_amount[0].total_amount return total_claimed_amount + +def grant_leaves_automatically(): + automatically_allocate_leaves_based_on_leave_policy = frappe.db.get_singles_value("HR Settings", "automatically_allocate_leaves_based_on_leave_policy") + if automatically_allocate_leaves_based_on_leave_policy: + lpa = frappe.db.get_all("Leave Policy Assignment", filters={"effective_from": getdate(), "docstatus": 1, "leaves_allocated":0}) + for assignment in lpa: + frappe.get_doc("Leave Policy Assignment", assignment.name).grant_leave_alloc_for_employee() diff --git a/erpnext/hr/web_form/job_application/job_application.json b/erpnext/hr/web_form/job_application/job_application.json index f630570c4c2..512ba5c5555 100644 --- a/erpnext/hr/web_form/job_application/job_application.json +++ b/erpnext/hr/web_form/job_application/job_application.json @@ -1,86 +1,200 @@ { - "accept_payment": 0, - "allow_comments": 1, - "allow_delete": 0, - "allow_edit": 1, - "allow_incomplete": 0, - "allow_multiple": 1, - "allow_print": 0, - "amount": 0.0, - "amount_based_on_field": 0, - "creation": "2016-09-10 02:53:16.598314", - "doc_type": "Job Applicant", - "docstatus": 0, - "doctype": "Web Form", - "idx": 0, - "introduction_text": "", - "is_standard": 1, - "login_required": 0, - "max_attachment_size": 0, - "modified": "2016-12-20 00:21:44.081622", - "modified_by": "Administrator", - "module": "HR", - "name": "job-application", - "owner": "Administrator", - "published": 1, - "route": "job_application", - "show_sidebar": 1, - "sidebar_items": [], - "success_message": "Thank you for applying.", - "success_url": "/jobs", - "title": "Job Application", + "accept_payment": 0, + "allow_comments": 1, + "allow_delete": 0, + "allow_edit": 1, + "allow_incomplete": 0, + "allow_multiple": 1, + "allow_print": 0, + "amount": 0.0, + "amount_based_on_field": 0, + "apply_document_permissions": 0, + "client_script": "frappe.web_form.on('resume_link', (field, value) => {\n if (!frappe.utils.is_url(value)) {\n frappe.msgprint(__('Resume link not valid'));\n }\n});\n", + "creation": "2016-09-10 02:53:16.598314", + "doc_type": "Job Applicant", + "docstatus": 0, + "doctype": "Web Form", + "idx": 0, + "introduction_text": "", + "is_standard": 1, + "login_required": 0, + "max_attachment_size": 0, + "modified": "2020-10-07 19:27:17.143355", + "modified_by": "Administrator", + "module": "HR", + "name": "job-application", + "owner": "Administrator", + "published": 1, + "route": "job_application", + "route_to_success_link": 0, + "show_attachments": 0, + "show_in_grid": 0, + "show_sidebar": 1, + "sidebar_items": [], + "success_message": "Thank you for applying.", + "success_url": "/jobs", + "title": "Job Application", "web_form_fields": [ { - "fieldname": "job_title", - "fieldtype": "Data", - "hidden": 0, - "label": "Job Opening", - "max_length": 0, - "max_value": 0, - "options": "", - "read_only": 1, - "reqd": 0 - }, + "allow_read_on_all_link_options": 0, + "fieldname": "job_title", + "fieldtype": "Data", + "hidden": 0, + "label": "Job Opening", + "max_length": 0, + "max_value": 0, + "options": "", + "read_only": 1, + "reqd": 0, + "show_in_filter": 0 + }, { - "fieldname": "applicant_name", - "fieldtype": "Data", - "hidden": 0, - "label": "Applicant Name", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 1 - }, + "allow_read_on_all_link_options": 0, + "fieldname": "applicant_name", + "fieldtype": "Data", + "hidden": 0, + "label": "Applicant Name", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 1, + "show_in_filter": 0 + }, { - "fieldname": "email_id", - "fieldtype": "Data", - "hidden": 0, - "label": "Email Address", - "max_length": 0, - "max_value": 0, - "options": "Email", - "read_only": 0, - "reqd": 1 - }, + "allow_read_on_all_link_options": 0, + "fieldname": "email_id", + "fieldtype": "Data", + "hidden": 0, + "label": "Email Address", + "max_length": 0, + "max_value": 0, + "options": "Email", + "read_only": 0, + "reqd": 1, + "show_in_filter": 0 + }, { - "fieldname": "cover_letter", - "fieldtype": "Text", - "hidden": 0, - "label": "Cover Letter", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0 - }, + "allow_read_on_all_link_options": 0, + "fieldname": "phone_number", + "fieldtype": "Data", + "hidden": 0, + "label": "Phone Number", + "max_length": 0, + "max_value": 0, + "options": "Phone", + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, { - "fieldname": "resume_attachment", - "fieldtype": "Attach", - "hidden": 0, - "label": "Resume Attachment", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0 + "allow_read_on_all_link_options": 0, + "fieldname": "country", + "fieldtype": "Link", + "hidden": 0, + "label": "Country of Residence", + "max_length": 0, + "max_value": 0, + "options": "Country", + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "cover_letter", + "fieldtype": "Text", + "hidden": 0, + "label": "Cover Letter", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "resume_link", + "fieldtype": "Data", + "hidden": 0, + "label": "Resume Link", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "", + "fieldtype": "Section Break", + "hidden": 0, + "label": "Expected Salary Range per month", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 1, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "currency", + "fieldtype": "Link", + "hidden": 0, + "label": "Currency", + "max_length": 0, + "max_value": 0, + "options": "Currency", + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "", + "fieldtype": "Column Break", + "hidden": 0, + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "lower_range", + "fieldtype": "Currency", + "hidden": 0, + "label": "Lower Range", + "max_length": 0, + "max_value": 0, + "options": "currency", + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "", + "fieldtype": "Column Break", + "hidden": 0, + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "upper_range", + "fieldtype": "Currency", + "hidden": 0, + "label": "Upper Range", + "max_length": 0, + "max_value": 0, + "options": "currency", + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 } ] } \ No newline at end of file diff --git a/erpnext/hr/workspace/hr/hr.json b/erpnext/hr/workspace/hr/hr.json new file mode 100644 index 00000000000..f650b24d861 --- /dev/null +++ b/erpnext/hr/workspace/hr/hr.json @@ -0,0 +1,829 @@ +{ + "category": "Modules", + "charts": [ + { + "chart_name": "Outgoing Salary", + "label": "Outgoing Salary" + } + ], + "creation": "2020-03-02 15:48:58.322521", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "hr", + "idx": 0, + "is_standard": 1, + "label": "HR", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee", + "link_to": "Employee", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employment Type", + "link_to": "Employment Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Branch", + "link_to": "Branch", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Department", + "link_to": "Department", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Designation", + "link_to": "Designation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Grade", + "link_to": "Employee Grade", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Group", + "link_to": "Employee Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Health Insurance", + "link_to": "Employee Health Insurance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Lifecycle", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Job Applicant", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Onboarding", + "link_to": "Employee Onboarding", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Skill Map", + "link_to": "Employee Skill Map", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Promotion", + "link_to": "Employee Promotion", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Transfer", + "link_to": "Employee Transfer", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Separation", + "link_to": "Employee Separation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Onboarding Template", + "link_to": "Employee Onboarding Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Separation Template", + "link_to": "Employee Separation Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Shift Management", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shift Type", + "link_to": "Shift Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shift Request", + "link_to": "Shift Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shift Assignment", + "link_to": "Shift Assignment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Leaves", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Application", + "link_to": "Leave Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Allocation", + "link_to": "Leave Allocation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Leave Type", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Policy", + "link_to": "Leave Policy", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Period", + "link_to": "Leave Period", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Type", + "link_to": "Leave Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Holiday List", + "link_to": "Holiday List", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Compensatory Leave Request", + "link_to": "Compensatory Leave Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Encashment", + "link_to": "Leave Encashment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Block List", + "link_to": "Leave Block List", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Leave Application", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Leave Balance", + "link_to": "Employee Leave Balance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Attendance", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Attendance Tool", + "link_to": "Employee Attendance Tool", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Attendance", + "link_to": "Attendance", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Attendance Request", + "link_to": "Attendance Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Upload Attendance", + "link_to": "Upload Attendance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Checkin", + "link_to": "Employee Checkin", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Attendance", + "hidden": 0, + "is_query_report": 1, + "label": "Monthly Attendance Sheet", + "link_to": "Monthly Attendance Sheet", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Expense Claims", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Expense Claim", + "link_to": "Expense Claim", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Advance", + "link_to": "Employee Advance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "HR Settings", + "link_to": "HR Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Daily Work Summary Group", + "link_to": "Daily Work Summary Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Team Updates", + "link_to": "team-updates", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Fleet Management", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Vehicle", + "link_to": "Vehicle", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Vehicle Log", + "link_to": "Vehicle Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Vehicle", + "hidden": 0, + "is_query_report": 1, + "label": "Vehicle Expenses", + "link_to": "Vehicle Expenses", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Recruitment", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Job Opening", + "link_to": "Job Opening", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Job Applicant", + "link_to": "Job Applicant", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Job Offer", + "link_to": "Job Offer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Staffing Plan", + "link_to": "Staffing Plan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Loans", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Application", + "link_to": "Loan Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan", + "link_to": "Loan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Type", + "link_to": "Loan Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Training", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Training Program", + "link_to": "Training Program", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Training Event", + "link_to": "Training Event", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Training Result", + "link_to": "Training Result", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Training Feedback", + "link_to": "Training Feedback", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Birthday", + "link_to": "Employee Birthday", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 1, + "label": "Employees working on a holiday", + "link_to": "Employees working on a holiday", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Performance", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Appraisal", + "link_to": "Appraisal", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Appraisal Template", + "link_to": "Appraisal Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Energy Point Rule", + "link_to": "Energy Point Rule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Energy Point Log", + "link_to": "Energy Point Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Tax and Benefits", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Tax Exemption Declaration", + "link_to": "Employee Tax Exemption Declaration", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Tax Exemption Proof Submission", + "link_to": "Employee Tax Exemption Proof Submission", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee, Payroll Period", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Other Income", + "link_to": "Employee Other Income", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Benefit Application", + "link_to": "Employee Benefit Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Benefit Claim", + "link_to": "Employee Benefit Claim", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Tax Exemption Category", + "link_to": "Employee Tax Exemption Category", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Tax Exemption Sub Category", + "link_to": "Employee Tax Exemption Sub Category", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2021-01-21 13:38:38.941001", + "modified_by": "Administrator", + "module": "HR", + "name": "HR", + "onboarding": "Human Resource", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "color": "Green", + "format": "{} Active", + "label": "Employee", + "link_to": "Employee", + "stats_filter": "{\"status\":\"Active\"}", + "type": "DocType" + }, + { + "color": "Yellow", + "format": "{} Open", + "label": "Leave Application", + "link_to": "Leave Application", + "stats_filter": "{\"status\":\"Open\"}", + "type": "DocType" + }, + { + "label": "Attendance", + "link_to": "Attendance", + "stats_filter": "", + "type": "DocType" + }, + { + "label": "Job Applicant", + "link_to": "Job Applicant", + "type": "DocType" + }, + { + "label": "Monthly Attendance Sheet", + "link_to": "Monthly Attendance Sheet", + "type": "Report" + }, + { + "format": "{} Open", + "label": "Dashboard", + "link_to": "Human Resource", + "stats_filter": "{\n \"status\": \"Open\"\n}", + "type": "Dashboard" + } + ] +} \ No newline at end of file diff --git a/erpnext/loan_management/dashboard_chart/loan_disbursements/loan_disbursements.json b/erpnext/loan_management/dashboard_chart/loan_disbursements/loan_disbursements.json new file mode 100644 index 00000000000..b8abf210f81 --- /dev/null +++ b/erpnext/loan_management/dashboard_chart/loan_disbursements/loan_disbursements.json @@ -0,0 +1,29 @@ +{ + "based_on": "disbursement_date", + "chart_name": "Loan Disbursements", + "chart_type": "Sum", + "creation": "2021-02-06 18:40:36.148470", + "docstatus": 0, + "doctype": "Dashboard Chart", + "document_type": "Loan Disbursement", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan Disbursement\",\"docstatus\",\"=\",\"1\",false]]", + "group_by_type": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "modified": "2021-02-06 18:40:49.308663", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Loan Disbursements", + "number_of_groups": 0, + "owner": "Administrator", + "source": "", + "time_interval": "Daily", + "timeseries": 1, + "timespan": "Last Month", + "type": "Line", + "use_report_chart": 0, + "value_based_on": "disbursed_amount", + "y_axis": [] +} \ No newline at end of file diff --git a/erpnext/loan_management/dashboard_chart/loan_interest_accrual/loan_interest_accrual.json b/erpnext/loan_management/dashboard_chart/loan_interest_accrual/loan_interest_accrual.json new file mode 100644 index 00000000000..aa0f78a2f6e --- /dev/null +++ b/erpnext/loan_management/dashboard_chart/loan_interest_accrual/loan_interest_accrual.json @@ -0,0 +1,31 @@ +{ + "based_on": "posting_date", + "chart_name": "Loan Interest Accrual", + "chart_type": "Sum", + "color": "#39E4A5", + "creation": "2021-02-18 20:07:04.843876", + "docstatus": 0, + "doctype": "Dashboard Chart", + "document_type": "Loan Interest Accrual", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan Interest Accrual\",\"docstatus\",\"=\",\"1\",false]]", + "group_by_type": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "last_synced_on": "2021-02-21 21:01:26.022634", + "modified": "2021-02-21 21:01:44.930712", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Loan Interest Accrual", + "number_of_groups": 0, + "owner": "Administrator", + "source": "", + "time_interval": "Monthly", + "timeseries": 1, + "timespan": "Last Year", + "type": "Line", + "use_report_chart": 0, + "value_based_on": "interest_amount", + "y_axis": [] +} \ No newline at end of file diff --git a/erpnext/loan_management/dashboard_chart/new_loans/new_loans.json b/erpnext/loan_management/dashboard_chart/new_loans/new_loans.json new file mode 100644 index 00000000000..35bd43b994f --- /dev/null +++ b/erpnext/loan_management/dashboard_chart/new_loans/new_loans.json @@ -0,0 +1,31 @@ +{ + "based_on": "creation", + "chart_name": "New Loans", + "chart_type": "Count", + "color": "#449CF0", + "creation": "2021-02-06 16:59:27.509170", + "docstatus": 0, + "doctype": "Dashboard Chart", + "document_type": "Loan", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan\",\"docstatus\",\"=\",\"1\",false]]", + "group_by_type": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "last_synced_on": "2021-02-21 20:55:33.515025", + "modified": "2021-02-21 21:00:33.900821", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "New Loans", + "number_of_groups": 0, + "owner": "Administrator", + "source": "", + "time_interval": "Daily", + "timeseries": 1, + "timespan": "Last Month", + "type": "Bar", + "use_report_chart": 0, + "value_based_on": "", + "y_axis": [] +} \ No newline at end of file diff --git a/erpnext/loan_management/dashboard_chart/top_10_pledged_loan_securities/top_10_pledged_loan_securities.json b/erpnext/loan_management/dashboard_chart/top_10_pledged_loan_securities/top_10_pledged_loan_securities.json new file mode 100644 index 00000000000..76c27b062d6 --- /dev/null +++ b/erpnext/loan_management/dashboard_chart/top_10_pledged_loan_securities/top_10_pledged_loan_securities.json @@ -0,0 +1,31 @@ +{ + "based_on": "", + "chart_name": "Top 10 Pledged Loan Securities", + "chart_type": "Custom", + "color": "#EC864B", + "creation": "2021-02-06 22:02:46.284479", + "docstatus": 0, + "doctype": "Dashboard Chart", + "document_type": "", + "dynamic_filters_json": "[]", + "filters_json": "[]", + "group_by_type": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "last_synced_on": "2021-02-21 21:00:57.043034", + "modified": "2021-02-21 21:01:10.048623", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Top 10 Pledged Loan Securities", + "number_of_groups": 0, + "owner": "Administrator", + "source": "Top 10 Pledged Loan Securities", + "time_interval": "Yearly", + "timeseries": 0, + "timespan": "Last Year", + "type": "Bar", + "use_report_chart": 0, + "value_based_on": "", + "y_axis": [] +} \ No newline at end of file diff --git a/erpnext/loan_management/dashboard_chart_source/__init__.py b/erpnext/loan_management/dashboard_chart_source/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/__init__.py b/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/top_10_pledged_loan_securities.js b/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/top_10_pledged_loan_securities.js new file mode 100644 index 00000000000..cf75cc8e41a --- /dev/null +++ b/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/top_10_pledged_loan_securities.js @@ -0,0 +1,14 @@ +frappe.provide('frappe.dashboards.chart_sources'); + +frappe.dashboards.chart_sources["Top 10 Pledged Loan Securities"] = { + method: "erpnext.loan_management.dashboard_chart_source.top_10_pledged_loan_securities.top_10_pledged_loan_securities.get_data", + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company") + } + ] +}; \ No newline at end of file diff --git a/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/top_10_pledged_loan_securities.json b/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/top_10_pledged_loan_securities.json new file mode 100644 index 00000000000..42c9b1c335a --- /dev/null +++ b/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/top_10_pledged_loan_securities.json @@ -0,0 +1,13 @@ +{ + "creation": "2021-02-06 22:01:01.332628", + "docstatus": 0, + "doctype": "Dashboard Chart Source", + "idx": 0, + "modified": "2021-02-06 22:01:01.332628", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Top 10 Pledged Loan Securities", + "owner": "Administrator", + "source_name": "Top 10 Pledged Loan Securities ", + "timeseries": 0 +} \ No newline at end of file diff --git a/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/top_10_pledged_loan_securities.py b/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/top_10_pledged_loan_securities.py new file mode 100644 index 00000000000..6bb04401bed --- /dev/null +++ b/erpnext/loan_management/dashboard_chart_source/top_10_pledged_loan_securities/top_10_pledged_loan_securities.py @@ -0,0 +1,76 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe +from frappe.utils.dashboard import cache_source +from erpnext.loan_management.report.applicant_wise_loan_security_exposure.applicant_wise_loan_security_exposure \ + import get_loan_security_details +from six import iteritems + +@frappe.whitelist() +@cache_source +def get_data(chart_name = None, chart = None, no_cache = None, filters = None, from_date = None, + to_date = None, timespan = None, time_interval = None, heatmap_year = None): + if chart_name: + chart = frappe.get_doc('Dashboard Chart', chart_name) + else: + chart = frappe._dict(frappe.parse_json(chart)) + + filters = {} + current_pledges = {} + + if filters: + filters = frappe.parse_json(filters)[0] + + conditions = "" + labels = [] + values = [] + + if filters.get('company'): + conditions = "AND company = %(company)s" + + loan_security_details = get_loan_security_details() + + unpledges = frappe._dict(frappe.db.sql(""" + SELECT u.loan_security, sum(u.qty) as qty + FROM `tabLoan Security Unpledge` up, `tabUnpledge` u + WHERE u.parent = up.name + AND up.status = 'Approved' + {conditions} + GROUP BY u.loan_security + """.format(conditions=conditions), filters, as_list=1)) + + pledges = frappe._dict(frappe.db.sql(""" + SELECT p.loan_security, sum(p.qty) as qty + FROM `tabLoan Security Pledge` lp, `tabPledge`p + WHERE p.parent = lp.name + AND lp.status = 'Pledged' + {conditions} + GROUP BY p.loan_security + """.format(conditions=conditions), filters, as_list=1)) + + for security, qty in iteritems(pledges): + current_pledges.setdefault(security, qty) + current_pledges[security] -= unpledges.get(security, 0.0) + + sorted_pledges = dict(sorted(current_pledges.items(), key=lambda item: item[1], reverse=True)) + + count = 0 + for security, qty in iteritems(sorted_pledges): + values.append(qty * loan_security_details.get(security, {}).get('latest_price', 0)) + labels.append(security) + count +=1 + + ## Just need top 10 securities + if count == 10: + break + + return { + 'labels': labels, + 'datasets': [{ + 'name': 'Top 10 Securities', + 'chartType': 'bar', + 'values': values + }] + } \ No newline at end of file diff --git a/erpnext/loan_management/desk_page/loan/loan.json b/erpnext/loan_management/desk_page/loan/loan.json deleted file mode 100644 index 3bdd1ce56e7..00000000000 --- a/erpnext/loan_management/desk_page/loan/loan.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Loan", - "links": "[\n {\n \"description\": \"Loan Type for interest and penalty rates\",\n \"label\": \"Loan Type\",\n \"name\": \"Loan Type\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Loan Applications from customers and employees.\",\n \"label\": \"Loan Application\",\n \"name\": \"Loan Application\",\n \"type\": \"doctype\"\n },\n { \"dependencies\": [\n \"Loan Type\"\n ],\n \"description\": \"Loans provided to customers and employees.\",\n \"label\": \"Loan\",\n \"name\": \"Loan\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Loan Processes", - "links": "[\n {\n \"label\": \"Process Loan Security Shortfall\",\n \"name\": \"Process Loan Security Shortfall\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Process Loan Interest Accrual\",\n \"name\": \"Process Loan Interest Accrual\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Disbursement and Repayment", - "links": "[\n {\n \"label\": \"Loan Disbursement\",\n \"name\": \"Loan Disbursement\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Loan Repayment\",\n \"name\": \"Loan Repayment\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Loan Interest Accrual\",\n \"name\": \"Loan Interest Accrual\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Loan Security", - "links": "[\n {\n \"label\": \"Loan Security Type\",\n \"name\": \"Loan Security Type\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Loan Security Price\",\n \"name\": \"Loan Security Price\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Loan Security\",\n \"name\": \"Loan Security\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Loan Security Pledge\",\n \"name\": \"Loan Security Pledge\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Loan Security Unpledge\",\n \"name\": \"Loan Security Unpledge\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Loan Security Shortfall\",\n \"name\": \"Loan Security Shortfall\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Reports", - "links": "[\n {\n \"doctype\": \"Loan Repayment\",\n \"is_query_report\": true,\n \"label\": \"Loan Repayment and Closure\",\n \"name\": \"Loan Repayment and Closure\",\n \"route\": \"#query-report/Loan Repayment and Closure\",\n \"type\": \"report\"\n },\n {\n \"doctype\": \"Loan Security Pledge\",\n \"is_query_report\": true,\n \"label\": \"Loan Security Status\",\n \"name\": \"Loan Security Status\",\n \"route\": \"#query-report/Loan Security Status\",\n \"type\": \"report\"\n }\n]" - } - ], - "category": "Modules", - "charts": [], - "creation": "2020-03-12 16:35:55.299820", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "idx": 0, - "is_standard": 1, - "label": "Loan", - "modified": "2020-06-07 19:42:14.947902", - "modified_by": "Administrator", - "module": "Loan Management", - "name": "Loan", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "color": "#ffe8cd", - "format": "{} Open", - "label": "Loan Application", - "link_to": "Loan Application", - "stats_filter": "{ \"status\": \"Open\" }", - "type": "DocType" - }, - { - "label": "Loan", - "link_to": "Loan", - "type": "DocType" - } - ] -} \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js index 9b4c21770ed..28af3a9c415 100644 --- a/erpnext/loan_management/doctype/loan/loan.js +++ b/erpnext/loan_management/doctype/loan/loan.js @@ -7,10 +7,14 @@ frappe.ui.form.on('Loan', { setup: function(frm) { frm.make_methods = { 'Loan Disbursement': function() { frm.trigger('make_loan_disbursement') }, - 'Loan Security Unpledge': function() { frm.trigger('create_loan_security_unpledge') } + 'Loan Security Unpledge': function() { frm.trigger('create_loan_security_unpledge') }, + 'Loan Write Off': function() { frm.trigger('make_loan_write_off_entry') } } }, onload: function (frm) { + // Ignore loan security pledge on cancel of loan + frm.ignore_doctypes_on_cancel_all = ["Loan Security Pledge"]; + frm.set_query("loan_application", function () { return { "filters": { @@ -21,6 +25,14 @@ frappe.ui.form.on('Loan', { }; }); + frm.set_query("loan_type", function () { + return { + "filters": { + "docstatus": 1 + } + }; + }); + $.each(["penalty_income_account", "interest_income_account"], function(i, field) { frm.set_query(field, function () { return { @@ -49,24 +61,33 @@ frappe.ui.form.on('Loan', { refresh: function (frm) { if (frm.doc.docstatus == 1) { - if (frm.doc.status == "Sanctioned" || frm.doc.status == 'Partially Disbursed') { + if (["Disbursed", "Partially Disbursed"].includes(frm.doc.status) && (!frm.doc.repay_from_salary)) { + frm.add_custom_button(__('Request Loan Closure'), function() { + frm.trigger("request_loan_closure"); + },__('Status')); + + frm.add_custom_button(__('Loan Repayment'), function() { + frm.trigger("make_repayment_entry"); + },__('Create')); + } + + if (["Sanctioned", "Partially Disbursed"].includes(frm.doc.status)) { frm.add_custom_button(__('Loan Disbursement'), function() { frm.trigger("make_loan_disbursement"); },__('Create')); } - if (["Disbursed", "Partially Disbursed"].includes(frm.doc.status) && (!frm.doc.repay_from_salary)) { - frm.add_custom_button(__('Loan Repayment'), function() { - frm.trigger("make_repayment_entry"); - },__('Create')); - - } - if (frm.doc.status == "Loan Closure Requested") { frm.add_custom_button(__('Loan Security Unpledge'), function() { frm.trigger("create_loan_security_unpledge"); },__('Create')); } + + if (["Loan Closure Requested", "Disbursed", "Partially Disbursed"].includes(frm.doc.status)) { + frm.add_custom_button(__('Loan Write Off'), function() { + frm.trigger("make_loan_write_off_entry"); + },__('Create')); + } } frm.trigger("toggle_fields"); }, @@ -117,6 +138,38 @@ frappe.ui.form.on('Loan', { }) }, + make_loan_write_off_entry: function(frm) { + frappe.call({ + args: { + "loan": frm.doc.name, + "company": frm.doc.company, + "as_dict": 1 + }, + method: "erpnext.loan_management.doctype.loan.loan.make_loan_write_off", + callback: function (r) { + if (r.message) + var doc = frappe.model.sync(r.message)[0]; + frappe.set_route("Form", doc.doctype, doc.name); + } + }) + }, + + request_loan_closure: function(frm) { + frappe.confirm(__("Do you really want to close this loan"), + function() { + frappe.call({ + args: { + 'loan': frm.doc.name + }, + method: "erpnext.loan_management.doctype.loan.loan.request_loan_closure", + callback: function() { + frm.reload_doc(); + } + }); + } + ); + }, + create_loan_security_unpledge: function(frm) { frappe.call({ method: "erpnext.loan_management.doctype.loan.loan.unpledge_security", diff --git a/erpnext/loan_management/doctype/loan/loan.json b/erpnext/loan_management/doctype/loan/loan.json index aa5e21b426b..acf09f5c037 100644 --- a/erpnext/loan_management/doctype/loan/loan.json +++ b/erpnext/loan_management/doctype/loan/loan.json @@ -26,11 +26,11 @@ "disbursed_amount", "column_break_11", "maximum_loan_amount", - "is_term_loan", "repayment_method", "repayment_periods", "monthly_repayment_amount", "repayment_start_date", + "is_term_loan", "account_info", "mode_of_payment", "payment_account", @@ -43,6 +43,7 @@ "section_break_17", "total_payment", "total_principal_paid", + "written_off_amount", "column_break_19", "total_interest_payable", "total_amount_paid", @@ -75,6 +76,7 @@ "fieldname": "loan_application", "fieldtype": "Link", "label": "Loan Application", + "no_copy": 1, "options": "Loan Application" }, { @@ -134,6 +136,7 @@ "fieldname": "loan_amount", "fieldtype": "Currency", "label": "Loan Amount", + "non_negative": 1, "options": "Company:company:default_currency" }, { @@ -148,7 +151,8 @@ "depends_on": "eval:doc.status==\"Disbursed\"", "fieldname": "disbursement_date", "fieldtype": "Date", - "label": "Disbursement Date" + "label": "Disbursement Date", + "no_copy": 1 }, { "depends_on": "is_term_loan", @@ -252,6 +256,7 @@ "fieldname": "total_payment", "fieldtype": "Currency", "label": "Total Payable Amount", + "no_copy": 1, "options": "Company:company:default_currency", "read_only": 1 }, @@ -265,6 +270,7 @@ "fieldname": "total_interest_payable", "fieldtype": "Currency", "label": "Total Interest Payable", + "no_copy": 1, "options": "Company:company:default_currency", "read_only": 1 }, @@ -273,6 +279,7 @@ "fieldname": "total_amount_paid", "fieldtype": "Currency", "label": "Total Amount Paid", + "no_copy": 1, "options": "Company:company:default_currency", "read_only": 1 }, @@ -289,8 +296,7 @@ "default": "0", "fieldname": "is_secured_loan", "fieldtype": "Check", - "label": "Is Secured Loan", - "read_only": 1 + "label": "Is Secured Loan" }, { "default": "0", @@ -313,6 +319,7 @@ "fieldname": "total_principal_paid", "fieldtype": "Currency", "label": "Total Principal Paid", + "no_copy": 1, "options": "Company:company:default_currency", "read_only": 1 }, @@ -320,21 +327,33 @@ "fieldname": "disbursed_amount", "fieldtype": "Currency", "label": "Disbursed Amount", + "no_copy": 1, "options": "Company:company:default_currency", "read_only": 1 }, { + "depends_on": "eval:doc.is_secured_loan", "fetch_from": "loan_application.maximum_loan_amount", "fieldname": "maximum_loan_amount", "fieldtype": "Currency", "label": "Maximum Loan Amount", + "no_copy": 1, + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "written_off_amount", + "fieldtype": "Currency", + "label": "Written Off Amount", + "no_copy": 1, "options": "Company:company:default_currency", "read_only": 1 } ], + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-08-01 12:36:11.255233", + "modified": "2020-11-24 12:27:23.208240", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan", diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py index d1b7589a17e..83a813f947b 100644 --- a/erpnext/loan_management/doctype/loan/loan.py +++ b/erpnext/loan_management/doctype/loan/loan.py @@ -6,12 +6,16 @@ from __future__ import unicode_literals import frappe, math, json import erpnext from frappe import _ +from six import string_types from frappe.utils import flt, rounded, add_months, nowdate, getdate, now_datetime from erpnext.loan_management.doctype.loan_security_unpledge.loan_security_unpledge import get_pledged_security_qty from erpnext.controllers.accounts_controller import AccountsController +from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts class Loan(AccountsController): def validate(self): + if self.applicant_type == 'Employee' and self.repay_from_salary: + validate_employee_currency_with_company_currency(self.applicant, self.company) self.set_loan_amount() self.validate_loan_amount() self.set_missing_fields() @@ -137,9 +141,12 @@ class Loan(AccountsController): }) def unlink_loan_security_pledge(self): - frappe.db.sql("""UPDATE `tabLoan Security Pledge` SET - loan = '', status = 'Unpledged' - where name = %s """, (self.loan_security_pledge)) + pledges = frappe.get_all('Loan Security Pledge', fields=['name'], filters={'loan': self.name}) + pledge_list = [d.name for d in pledges] + if pledge_list: + frappe.db.sql("""UPDATE `tabLoan Security Pledge` SET + loan = '', status = 'Unpledged' + where name in (%s) """ % (', '.join(['%s']*len(pledge_list))), tuple(pledge_list)) #nosec def update_total_amount_paid(doc): total_amount_paid = 0 @@ -182,6 +189,28 @@ def get_monthly_repayment_amount(repayment_method, loan_amount, rate_of_interest monthly_repayment_amount = math.ceil(flt(loan_amount) / repayment_periods) return monthly_repayment_amount +@frappe.whitelist() +def request_loan_closure(loan, posting_date=None): + if not posting_date: + posting_date = getdate() + + amounts = calculate_amounts(loan, posting_date) + pending_amount = amounts['payable_amount'] + amounts['unaccrued_interest'] + + loan_type = frappe.get_value('Loan', loan, 'loan_type') + write_off_limit = frappe.get_value('Loan Type', loan_type, 'write_off_amount') + + # checking greater than 0 as there may be some minor precision error + if not pending_amount: + frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested') + elif pending_amount < write_off_limit: + # Auto create loan write off and update status as loan closure requested + write_off = make_loan_write_off(loan) + write_off.submit() + frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested') + else: + frappe.throw(_("Cannot close loan as there is an outstanding of {0}").format(pending_amount)) + @frappe.whitelist() def get_loan_application(loan_application): loan = frappe.get_doc("Loan Application", loan_application) @@ -200,6 +229,7 @@ def make_loan_disbursement(loan, company, applicant_type, applicant, pending_amo disbursement_entry.applicant = applicant disbursement_entry.company = company disbursement_entry.disbursement_date = nowdate() + disbursement_entry.posting_date = nowdate() disbursement_entry.disbursed_amount = pending_amount if as_dict: @@ -223,10 +253,45 @@ def make_repayment_entry(loan, applicant_type, applicant, loan_type, company, as return repayment_entry @frappe.whitelist() -def unpledge_security(loan=None, loan_security_pledge=None, as_dict=0, save=0, submit=0, approve=0): - # if loan is passed it will be considered as full unpledge +def make_loan_write_off(loan, company=None, posting_date=None, amount=0, as_dict=0): + if not company: + company = frappe.get_value('Loan', loan, 'company') + + if not posting_date: + posting_date = getdate() + + amounts = calculate_amounts(loan, posting_date) + pending_amount = amounts['pending_principal_amount'] + + if amount and (amount > pending_amount): + frappe.throw('Write Off amount cannot be greater than pending loan amount') + + if not amount: + amount = pending_amount + + # get default write off account from company master + write_off_account = frappe.get_value('Company', company, 'write_off_account') + + write_off = frappe.new_doc('Loan Write Off') + write_off.loan = loan + write_off.posting_date = posting_date + write_off.write_off_account = write_off_account + write_off.write_off_amount = amount + write_off.save() + + if as_dict: + return write_off.as_dict() + else: + return write_off + +@frappe.whitelist() +def unpledge_security(loan=None, loan_security_pledge=None, security_map=None, as_dict=0, save=0, submit=0, approve=0): + # if no security_map is passed it will be considered as full unpledge + if security_map and isinstance(security_map, string_types): + security_map = json.loads(security_map) + if loan: - pledge_qty_map = get_pledged_security_qty(loan) + pledge_qty_map = security_map or get_pledged_security_qty(loan) loan_doc = frappe.get_doc('Loan', loan) unpledge_request = create_loan_security_unpledge(pledge_qty_map, loan_doc.name, loan_doc.company, loan_doc.applicant_type, loan_doc.applicant) @@ -274,5 +339,24 @@ def create_loan_security_unpledge(unpledge_map, loan, company, applicant_type, a return unpledge_request +def validate_employee_currency_with_company_currency(applicant, company): + from erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment import get_employee_currency + if not applicant: + frappe.throw(_("Please select Applicant")) + if not company: + frappe.throw(_("Please select Company")) + employee_currency = get_employee_currency(applicant) + company_currency = erpnext.get_company_currency(company) + if employee_currency != company_currency: + frappe.throw(_("Loan cannot be repayed from salary for Employee {0} because salary is processed in currency {1}") + .format(applicant, employee_currency)) +@frappe.whitelist() +def get_shortfall_applicants(): + loans = frappe.get_all('Loan Security Shortfall', {'status': 'Pending'}, pluck='loan') + applicants = set(frappe.get_all('Loan', {'name': ('in', loans)}, pluck='name')) + return { + "value": len(applicants), + "fieldtype": "Int" + } \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan/loan_dashboard.py b/erpnext/loan_management/doctype/loan/loan_dashboard.py index 90d5ae26506..7a8190f7450 100644 --- a/erpnext/loan_management/doctype/loan/loan_dashboard.py +++ b/erpnext/loan_management/doctype/loan/loan_dashboard.py @@ -13,7 +13,7 @@ def get_data(): 'items': ['Loan Security Pledge', 'Loan Security Shortfall', 'Loan Disbursement'] }, { - 'items': ['Loan Repayment', 'Loan Interest Accrual', 'Loan Security Unpledge'] + 'items': ['Loan Repayment', 'Loan Interest Accrual', 'Loan Write Off', 'Loan Security Unpledge'] } ] } \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan/loan_list.js b/erpnext/loan_management/doctype/loan/loan_list.js new file mode 100644 index 00000000000..6591b729968 --- /dev/null +++ b/erpnext/loan_management/doctype/loan/loan_list.js @@ -0,0 +1,16 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +frappe.listview_settings['Loan'] = { + get_indicator: function(doc) { + var status_color = { + "Draft": "red", + "Sanctioned": "blue", + "Disbursed": "orange", + "Partially Disbursed": "yellow", + "Loan Closure Requested": "green", + "Closed": "green" + }; + return [__(doc.status), status_color[doc.status], "status,=,"+doc.status]; + }, +}; diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py index 5a4a19a0fbd..4b9a89486ad 100644 --- a/erpnext/loan_management/doctype/loan/test_loan.py +++ b/erpnext/loan_management/doctype/loan/test_loan.py @@ -14,11 +14,12 @@ from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_ process_loan_interest_accrual_for_term_loans) from erpnext.loan_management.doctype.loan_interest_accrual.loan_interest_accrual import days_in_year from erpnext.loan_management.doctype.process_loan_security_shortfall.process_loan_security_shortfall import create_process_loan_security_shortfall -from erpnext.loan_management.doctype.loan.loan import unpledge_security +from erpnext.loan_management.doctype.loan.loan import unpledge_security, request_loan_closure, make_loan_write_off from erpnext.loan_management.doctype.loan_security_unpledge.loan_security_unpledge import get_pledged_security_qty from erpnext.loan_management.doctype.loan_application.loan_application import create_pledge from erpnext.loan_management.doctype.loan_disbursement.loan_disbursement import get_disbursal_amount from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts +from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure class TestLoan(unittest.TestCase): def setUp(self): @@ -44,6 +45,7 @@ class TestLoan(unittest.TestCase): create_loan_security_price("Test Security 2", 250, "Nos", get_datetime() , get_datetime(add_to_date(nowdate(), hours=24))) self.applicant1 = make_employee("robert_loan@loan.com") + make_salary_structure("Test Salary Structure Loan", "Monthly", employee=self.applicant1, currency='INR', company="_Test Company") if not frappe.db.exists("Customer", "_Test Loan Customer"): frappe.get_doc(get_customer_dict('_Test Loan Customer')).insert(ignore_permissions=True) @@ -132,7 +134,7 @@ class TestLoan(unittest.TestCase): loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) create_pledge(loan_application) - loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date=get_first_day(nowdate())) + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') loan.submit() self.assertEquals(loan.loan_amount, 1000000) @@ -142,30 +144,30 @@ class TestLoan(unittest.TestCase): no_of_days = date_diff(last_date, first_date) + 1 - accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ - / (days_in_year(get_datetime(first_date).year) * 100) + accrued_interest_amount = flt((loan.loan_amount * loan.rate_of_interest * no_of_days) + / (days_in_year(get_datetime(first_date).year) * 100), 2) make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) process_loan_interest_accrual_for_demand_loans(posting_date = last_date) - repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 10), "Regular Payment", 111118.68) + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 10), 111119) repayment_entry.save() repayment_entry.submit() - penalty_amount = (accrued_interest_amount * 4 * 25) / (100 * days_in_year(get_datetime(first_date).year)) - self.assertEquals(flt(repayment_entry.penalty_amount, 2), flt(penalty_amount, 2)) + penalty_amount = (accrued_interest_amount * 5 * 25) / 100 + self.assertEquals(flt(repayment_entry.penalty_amount,0), flt(penalty_amount, 0)) - amounts = frappe.db.get_value('Loan Interest Accrual', {'loan': loan.name}, ['paid_interest_amount', - 'paid_principal_amount']) + amounts = frappe.db.get_all('Loan Interest Accrual', {'loan': loan.name}, ['paid_interest_amount']) loan.load_from_db() - self.assertEquals(amounts[0], repayment_entry.interest_payable) - self.assertEquals(flt(loan.total_principal_paid, 2), flt(repayment_entry.amount_paid - - penalty_amount - amounts[0], 2)) + total_interest_paid = amounts[0]['paid_interest_amount'] + amounts[1]['paid_interest_amount'] + self.assertEquals(amounts[1]['paid_interest_amount'], repayment_entry.interest_payable) + self.assertEquals(flt(loan.total_principal_paid, 0), flt(repayment_entry.amount_paid - + penalty_amount - total_interest_paid, 0)) - def test_loan_closure_repayment(self): + def test_loan_closure(self): pledge = [{ "loan_security": "Test Security 1", "qty": 4000.00 @@ -174,7 +176,7 @@ class TestLoan(unittest.TestCase): loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) create_pledge(loan_application) - loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date=get_first_day(nowdate())) + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') loan.submit() self.assertEquals(loan.loan_amount, 1000000) @@ -184,10 +186,10 @@ class TestLoan(unittest.TestCase): no_of_days = date_diff(last_date, first_date) + 1 - # Adding 6 since repayment is made 5 days late after due date + # Adding 5 since repayment is made 5 days late after due date # and since payment type is loan closure so interest should be considered for those - # 6 days as well though in grace period - no_of_days += 6 + # 5 days as well though in grace period + no_of_days += 5 accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ / (days_in_year(get_datetime(first_date).year) * 100) @@ -195,15 +197,17 @@ class TestLoan(unittest.TestCase): make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) process_loan_interest_accrual_for_demand_loans(posting_date = last_date) - repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 6), - "Loan Closure", flt(loan.loan_amount + accrued_interest_amount)) + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), + flt(loan.loan_amount + accrued_interest_amount)) + repayment_entry.submit() amount = frappe.db.get_value('Loan Interest Accrual', {'loan': loan.name}, ['sum(paid_interest_amount)']) - self.assertEquals(flt(amount, 2),flt(accrued_interest_amount, 2)) + self.assertEquals(flt(amount, 0),flt(accrued_interest_amount, 0)) self.assertEquals(flt(repayment_entry.penalty_amount, 5), 0) + request_loan_closure(loan.name) loan.load_from_db() self.assertEquals(loan.status, "Loan Closure Requested") @@ -230,8 +234,7 @@ class TestLoan(unittest.TestCase): process_loan_interest_accrual_for_term_loans(posting_date=nowdate()) - repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(nowdate(), 5), - "Regular Payment", 89768.75) + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(nowdate(), 5), 89768.75) repayment_entry.submit() @@ -272,6 +275,11 @@ class TestLoan(unittest.TestCase): frappe.db.sql(""" UPDATE `tabLoan Security Price` SET loan_security_price = 250 where loan_security='Test Security 2'""") + create_process_loan_security_shortfall() + loan_security_shortfall = frappe.get_doc("Loan Security Shortfall", {"loan": loan.name}) + self.assertEquals(loan_security_shortfall.status, "Completed") + self.assertEquals(loan_security_shortfall.shortfall_amount, 0) + def test_loan_security_unpledge(self): pledge = [{ "loan_security": "Test Security 1", @@ -281,7 +289,7 @@ class TestLoan(unittest.TestCase): loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) create_pledge(loan_application) - loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date=get_first_day(nowdate())) + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') loan.submit() self.assertEquals(loan.loan_amount, 1000000) @@ -291,7 +299,7 @@ class TestLoan(unittest.TestCase): no_of_days = date_diff(last_date, first_date) + 1 - no_of_days += 6 + no_of_days += 5 accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ / (days_in_year(get_datetime(first_date).year) * 100) @@ -299,10 +307,10 @@ class TestLoan(unittest.TestCase): make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) process_loan_interest_accrual_for_demand_loans(posting_date = last_date) - repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 6), - "Loan Closure", flt(loan.loan_amount + accrued_interest_amount)) + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), flt(loan.loan_amount + accrued_interest_amount)) repayment_entry.submit() + request_loan_closure(loan.name) loan.load_from_db() self.assertEquals(loan.status, "Loan Closure Requested") @@ -317,11 +325,69 @@ class TestLoan(unittest.TestCase): self.assertEqual(loan.status, 'Closed') self.assertEquals(sum(pledged_qty.values()), 0) - amounts = amounts = calculate_amounts(loan.name, add_days(last_date, 6), "Regular Repayment") + amounts = amounts = calculate_amounts(loan.name, add_days(last_date, 5)) self.assertEqual(amounts['pending_principal_amount'], 0) - self.assertEqual(amounts['payable_principal_amount'], 0) + self.assertEquals(amounts['payable_principal_amount'], 0.0) self.assertEqual(amounts['interest_amount'], 0) + def test_partial_loan_security_unpledge(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 2000.00 + }, + { + "loan_security": "Test Security 2", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) + create_pledge(loan_application) + + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + self.assertEquals(loan.loan_amount, 1000000) + + first_date = '2019-10-01' + last_date = '2019-10-30' + + make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) + process_loan_interest_accrual_for_demand_loans(posting_date = last_date) + + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), 600000) + repayment_entry.submit() + + unpledge_map = {'Test Security 2': 2000} + + unpledge_request = unpledge_security(loan=loan.name, security_map = unpledge_map, save=1) + unpledge_request.submit() + unpledge_request.status = 'Approved' + unpledge_request.save() + unpledge_request.submit() + unpledge_request.load_from_db() + self.assertEqual(unpledge_request.docstatus, 1) + + def test_santined_loan_security_unpledge(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) + create_pledge(loan_application) + + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + self.assertEquals(loan.loan_amount, 1000000) + + unpledge_map = {'Test Security 1': 4000} + unpledge_request = unpledge_security(loan=loan.name, security_map = unpledge_map, save=1) + unpledge_request.submit() + unpledge_request.status = 'Approved' + unpledge_request.save() + unpledge_request.submit() + def test_disbursal_check_with_shortfall(self): pledges = [{ "loan_security": "Test Security 2", @@ -381,7 +447,7 @@ class TestLoan(unittest.TestCase): loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) create_pledge(loan_application) - loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date=get_first_day(nowdate())) + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') loan.submit() self.assertEquals(loan.loan_amount, 1000000) @@ -391,7 +457,7 @@ class TestLoan(unittest.TestCase): no_of_days = date_diff(last_date, first_date) + 1 - no_of_days += 6 + no_of_days += 5 accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ / (days_in_year(get_datetime(first_date).year) * 100) @@ -399,20 +465,192 @@ class TestLoan(unittest.TestCase): make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) process_loan_interest_accrual_for_demand_loans(posting_date = last_date) - amounts = calculate_amounts(loan.name, add_days(last_date, 6), "Regular Repayment") + amounts = calculate_amounts(loan.name, add_days(last_date, 5)) - repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 6), - "Loan Closure", flt(loan.loan_amount + accrued_interest_amount)) + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), flt(loan.loan_amount + accrued_interest_amount)) repayment_entry.submit() amounts = frappe.db.get_value('Loan Interest Accrual', {'loan': loan.name}, ['paid_interest_amount', 'paid_principal_amount']) + request_loan_closure(loan.name) loan.load_from_db() self.assertEquals(loan.status, "Loan Closure Requested") - amounts = calculate_amounts(loan.name, add_days(last_date, 6), "Regular Repayment") - self.assertEquals(amounts['pending_principal_amount'], 0.0) + amounts = calculate_amounts(loan.name, add_days(last_date, 5)) + self.assertEqual(amounts['pending_principal_amount'], 0.0) + + def test_partial_unaccrued_interest_payment(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) + create_pledge(loan_application) + + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + self.assertEquals(loan.loan_amount, 1000000) + + first_date = '2019-10-01' + last_date = '2019-10-30' + + no_of_days = date_diff(last_date, first_date) + 1 + + no_of_days += 5.5 + + # get partial unaccrued interest amount + paid_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ + / (days_in_year(get_datetime(first_date).year) * 100) + + make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) + process_loan_interest_accrual_for_demand_loans(posting_date = last_date) + + amounts = calculate_amounts(loan.name, add_days(last_date, 5)) + + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), + paid_amount) + + repayment_entry.submit() + repayment_entry.load_from_db() + + partial_accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * 5) \ + / (days_in_year(get_datetime(first_date).year) * 100) + + interest_amount = flt(amounts['interest_amount'] + partial_accrued_interest_amount, 2) + self.assertEqual(flt(repayment_entry.total_interest_paid, 0), flt(interest_amount, 0)) + + def test_penalty(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) + create_pledge(loan_application) + + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + self.assertEquals(loan.loan_amount, 1000000) + + first_date = '2019-10-01' + last_date = '2019-10-30' + + make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) + process_loan_interest_accrual_for_demand_loans(posting_date = last_date) + + amounts = calculate_amounts(loan.name, add_days(last_date, 1)) + paid_amount = amounts['interest_amount']/2 + + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), + paid_amount) + + repayment_entry.submit() + + # 30 days - grace period + penalty_days = 30 - 4 + penalty_applicable_amount = flt(amounts['interest_amount']/2) + penalty_amount = flt((((penalty_applicable_amount * 25) / 100) * penalty_days), 2) + process = process_loan_interest_accrual_for_demand_loans(posting_date = '2019-11-30') + + calculated_penalty_amount = frappe.db.get_value('Loan Interest Accrual', + {'process_loan_interest_accrual': process, 'loan': loan.name}, 'penalty_amount') + + self.assertEquals(calculated_penalty_amount, penalty_amount) + + def test_loan_write_off_limit(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) + create_pledge(loan_application) + + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + self.assertEquals(loan.loan_amount, 1000000) + + first_date = '2019-10-01' + last_date = '2019-10-30' + + no_of_days = date_diff(last_date, first_date) + 1 + no_of_days += 5 + + accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ + / (days_in_year(get_datetime(first_date).year) * 100) + + make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) + process_loan_interest_accrual_for_demand_loans(posting_date = last_date) + + # repay 50 less so that it can be automatically written off + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), + flt(loan.loan_amount + accrued_interest_amount - 50)) + + repayment_entry.submit() + + amount = frappe.db.get_value('Loan Interest Accrual', {'loan': loan.name}, ['sum(paid_interest_amount)']) + + self.assertEquals(flt(amount, 0),flt(accrued_interest_amount, 0)) + self.assertEquals(flt(repayment_entry.penalty_amount, 5), 0) + + amounts = calculate_amounts(loan.name, add_days(last_date, 5)) + self.assertEquals(flt(amounts['pending_principal_amount'], 0), 50) + + request_loan_closure(loan.name) + loan.load_from_db() + self.assertEquals(loan.status, "Loan Closure Requested") + + def test_loan_amount_write_off(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge) + create_pledge(loan_application) + + loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + self.assertEquals(loan.loan_amount, 1000000) + + first_date = '2019-10-01' + last_date = '2019-10-30' + + no_of_days = date_diff(last_date, first_date) + 1 + no_of_days += 5 + + accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ + / (days_in_year(get_datetime(first_date).year) * 100) + + make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) + process_loan_interest_accrual_for_demand_loans(posting_date = last_date) + + # repay 100 less so that it can be automatically written off + repayment_entry = create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5), + flt(loan.loan_amount + accrued_interest_amount - 100)) + + repayment_entry.submit() + + amount = frappe.db.get_value('Loan Interest Accrual', {'loan': loan.name}, ['sum(paid_interest_amount)']) + + self.assertEquals(flt(amount, 0),flt(accrued_interest_amount, 0)) + self.assertEquals(flt(repayment_entry.penalty_amount, 5), 0) + + amounts = calculate_amounts(loan.name, add_days(last_date, 5)) + self.assertEquals(flt(amounts['pending_principal_amount'], 0), 100) + + we = make_loan_write_off(loan.name, amount=amounts['pending_principal_amount']) + we.submit() + + amounts = calculate_amounts(loan.name, add_days(last_date, 5)) + self.assertEquals(flt(amounts['pending_principal_amount'], 0), 0) + def create_loan_accounts(): if not frappe.db.exists("Account", "Loans and Advances (Assets) - _TC"): @@ -496,7 +734,8 @@ def create_loan_type(loan_name, maximum_loan_amount, rate_of_interest, penalty_i "interest_income_account": interest_income_account, "penalty_income_account": penalty_income_account, "repayment_method": repayment_method, - "repayment_periods": repayment_periods + "repayment_periods": repayment_periods, + "write_off_amount": 100 }).insert() loan_type.submit() @@ -532,7 +771,7 @@ def create_loan_security(): "haircut": 50.00, }).insert(ignore_permissions=True) -def create_loan_security_pledge(applicant, pledges, loan_application): +def create_loan_security_pledge(applicant, pledges, loan_application=None, loan=None): lsp = frappe.new_doc("Loan Security Pledge") lsp.applicant_type = 'Customer' @@ -540,11 +779,13 @@ def create_loan_security_pledge(applicant, pledges, loan_application): lsp.company = "_Test Company" lsp.loan_application = loan_application + if loan: + lsp.loan = loan + for pledge in pledges: lsp.append('securities', { "loan_security": pledge['loan_security'], - "qty": pledge['qty'], - "haircut": pledge['haircut'] + "qty": pledge['qty'] }) lsp.save() @@ -582,12 +823,11 @@ def create_loan_security_price(loan_security, loan_security_price, uom, from_dat "valid_upto": to_date }).insert(ignore_permissions=True) -def create_repayment_entry(loan, applicant, posting_date, payment_type, paid_amount): +def create_repayment_entry(loan, applicant, posting_date, paid_amount): lr = frappe.get_doc({ "doctype": "Loan Repayment", "against_loan": loan, - "payment_type": payment_type, "company": "_Test Company", "posting_date": posting_date or nowdate(), "applicant": applicant, diff --git a/erpnext/loan_management/doctype/loan_application/loan_application.py b/erpnext/loan_management/doctype/loan_application/loan_application.py index bac6e638d74..9c0147e55ba 100644 --- a/erpnext/loan_management/doctype/loan_application/loan_application.py +++ b/erpnext/loan_management/doctype/loan_application/loan_application.py @@ -127,6 +127,7 @@ def create_loan(source_name, target_doc=None, submit=0): target_doc.loan_account = account_details.loan_account target_doc.interest_income_account = account_details.interest_income_account target_doc.penalty_income_account = account_details.penalty_income_account + target_doc.loan_application = source_name doclist = get_mapped_doc("Loan Application", source_name, { @@ -196,7 +197,7 @@ def get_proposed_pledge(securities): security.qty = cint(security.amount/security.loan_security_price) security.amount = security.qty * security.loan_security_price - security.post_haircut_amount = security.amount - (security.amount * security.haircut/100) + security.post_haircut_amount = cint(security.amount - (security.amount * security.haircut/100)) maximum_loan_amount += security.post_haircut_amount diff --git a/erpnext/loan_management/doctype/loan_application/test_loan_application.py b/erpnext/loan_management/doctype/loan_application/test_loan_application.py index 687c58000e2..2a659e9fc2e 100644 --- a/erpnext/loan_management/doctype/loan_application/test_loan_application.py +++ b/erpnext/loan_management/doctype/loan_application/test_loan_application.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe import unittest -from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_employee +from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_employee, make_salary_structure from erpnext.loan_management.doctype.loan.test_loan import create_loan_type, create_loan_accounts class TestLoanApplication(unittest.TestCase): @@ -14,6 +14,7 @@ class TestLoanApplication(unittest.TestCase): create_loan_type("Home Loan", 500000, 9.2, 0, 1, 0, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC', 'Repay Over Number of Periods', 18) self.applicant = make_employee("kate_loan@loan.com", "_Test Company") + make_salary_structure("Test Salary Structure Loan", "Monthly", employee=self.applicant, currency='INR') self.create_loan_application() def create_loan_application(self): @@ -29,7 +30,6 @@ class TestLoanApplication(unittest.TestCase): }) loan_application.insert() - def test_loan_totals(self): loan_application = frappe.get_doc("Loan Application", {"applicant":self.applicant}) diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json index c437a987eb4..cd5df4d3cd1 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json @@ -26,19 +26,24 @@ { "fieldname": "against_loan", "fieldtype": "Link", + "in_list_view": 1, "label": "Against Loan ", - "options": "Loan" + "options": "Loan", + "reqd": 1 }, { "fieldname": "disbursement_date", "fieldtype": "Date", - "label": "Disbursement Date" + "label": "Disbursement Date", + "reqd": 1 }, { "fieldname": "disbursed_amount", "fieldtype": "Currency", "label": "Disbursed Amount", - "options": "Company:company:default_currency" + "non_negative": 1, + "options": "Company:company:default_currency", + "reqd": 1 }, { "fieldname": "amended_from", @@ -53,17 +58,21 @@ "fetch_from": "against_loan.company", "fieldname": "company", "fieldtype": "Link", + "in_list_view": 1, "label": "Company", "options": "Company", - "read_only": 1 + "read_only": 1, + "reqd": 1 }, { "fetch_from": "against_loan.applicant", "fieldname": "applicant", "fieldtype": "Dynamic Link", + "in_list_view": 1, "label": "Applicant", "options": "applicant_type", - "read_only": 1 + "read_only": 1, + "reqd": 1 }, { "collapsible": 1, @@ -102,9 +111,11 @@ "fetch_from": "against_loan.applicant_type", "fieldname": "applicant_type", "fieldtype": "Select", + "in_list_view": 1, "label": "Applicant Type", "options": "Employee\nMember\nCustomer", - "read_only": 1 + "read_only": 1, + "reqd": 1 }, { "fieldname": "bank_account", @@ -117,9 +128,10 @@ "fieldtype": "Column Break" } ], + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-04-29 05:20:41.629911", + "modified": "2020-11-06 10:04:30.882322", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Disbursement", diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py index 260fada8936..f341e81065f 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py @@ -17,6 +17,7 @@ class LoanDisbursement(AccountsController): def validate(self): self.set_missing_values() + self.validate_disbursal_amount() def on_submit(self): self.set_status_and_amounts() @@ -40,57 +41,21 @@ class LoanDisbursement(AccountsController): if not self.bank_account and self.applicant_type == "Customer": self.bank_account = frappe.db.get_value("Customer", self.applicant, "default_bank_account") - def set_status_and_amounts(self, cancel=0): + def validate_disbursal_amount(self): + possible_disbursal_amount = get_disbursal_amount(self.against_loan) + if self.disbursed_amount > possible_disbursal_amount: + frappe.throw(_("Disbursed Amount cannot be greater than {0}").format(possible_disbursal_amount)) + + def set_status_and_amounts(self, cancel=0): loan_details = frappe.get_all("Loan", fields = ["loan_amount", "disbursed_amount", "total_payment", "total_principal_paid", "total_interest_payable", "status", "is_term_loan", "is_secured_loan"], filters= { "name": self.against_loan })[0] if cancel: - disbursed_amount = loan_details.disbursed_amount - self.disbursed_amount - total_payment = loan_details.total_payment - - if loan_details.disbursed_amount > loan_details.loan_amount: - topup_amount = loan_details.disbursed_amount - loan_details.loan_amount - if topup_amount > self.disbursed_amount: - topup_amount = self.disbursed_amount - - total_payment = total_payment - topup_amount - - if disbursed_amount == 0: - status = "Sanctioned" - elif disbursed_amount >= loan_details.loan_amount: - status = "Disbursed" - else: - status = "Partially Disbursed" + disbursed_amount, status, total_payment = self.get_values_on_cancel(loan_details) else: - disbursed_amount = self.disbursed_amount + loan_details.disbursed_amount - total_payment = loan_details.total_payment - - possible_disbursal_amount = get_disbursal_amount(self.against_loan) - - if self.disbursed_amount > possible_disbursal_amount: - frappe.throw(_("Disbursed Amount cannot be greater than {0}").format(possible_disbursal_amount)) - - if loan_details.status == "Disbursed" and not loan_details.is_term_loan: - process_loan_interest_accrual_for_demand_loans(posting_date=add_days(self.disbursement_date, -1), - loan=self.against_loan) - - if disbursed_amount > loan_details.loan_amount: - topup_amount = disbursed_amount - loan_details.loan_amount - - if topup_amount < 0: - topup_amount = 0 - - if topup_amount > self.disbursed_amount: - topup_amount = self.disbursed_amount - - total_payment = total_payment + topup_amount - - if flt(disbursed_amount) >= loan_details.loan_amount: - status = "Disbursed" - else: - status = "Partially Disbursed" + disbursed_amount, status, total_payment = self.get_values_on_submit(loan_details) frappe.db.set_value("Loan", self.against_loan, { "disbursement_date": self.disbursement_date, @@ -99,6 +64,53 @@ class LoanDisbursement(AccountsController): "total_payment": total_payment }) + def get_values_on_cancel(self, loan_details): + disbursed_amount = loan_details.disbursed_amount - self.disbursed_amount + total_payment = loan_details.total_payment + + if loan_details.disbursed_amount > loan_details.loan_amount: + topup_amount = loan_details.disbursed_amount - loan_details.loan_amount + if topup_amount > self.disbursed_amount: + topup_amount = self.disbursed_amount + + total_payment = total_payment - topup_amount + + if disbursed_amount == 0: + status = "Sanctioned" + + elif disbursed_amount >= loan_details.loan_amount: + status = "Disbursed" + else: + status = "Partially Disbursed" + + return disbursed_amount, status, total_payment + + def get_values_on_submit(self, loan_details): + disbursed_amount = self.disbursed_amount + loan_details.disbursed_amount + total_payment = loan_details.total_payment + + if loan_details.status in ("Disbursed", "Partially Disbursed") and not loan_details.is_term_loan: + process_loan_interest_accrual_for_demand_loans(posting_date=add_days(self.disbursement_date, -1), + loan=self.against_loan, accrual_type="Disbursement") + + if disbursed_amount > loan_details.loan_amount: + topup_amount = disbursed_amount - loan_details.loan_amount + + if topup_amount < 0: + topup_amount = 0 + + if topup_amount > self.disbursed_amount: + topup_amount = self.disbursed_amount + + total_payment = total_payment + topup_amount + + if flt(disbursed_amount) >= loan_details.loan_amount: + status = "Disbursed" + else: + status = "Partially Disbursed" + + return disbursed_amount, status, total_payment + def make_gl_entries(self, cancel=0, adv_adj=0): gle_map = [] loan_details = frappe.get_doc("Loan", self.against_loan) @@ -111,7 +123,7 @@ class LoanDisbursement(AccountsController): "debit_in_account_currency": self.disbursed_amount, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": "Against Loan:" + self.against_loan, + "remarks": _("Disbursement against loan:") + self.against_loan, "cost_center": self.cost_center, "party_type": self.applicant_type, "party": self.applicant, @@ -127,10 +139,8 @@ class LoanDisbursement(AccountsController): "credit_in_account_currency": self.disbursed_amount, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": "Against Loan:" + self.against_loan, + "remarks": _("Disbursement against loan:") + self.against_loan, "cost_center": self.cost_center, - "party_type": self.applicant_type, - "party": self.applicant, "posting_date": self.disbursement_date }) ) @@ -155,15 +165,16 @@ def get_total_pledged_security_value(loan): pledged_securities = get_pledged_security_qty(loan) for security, qty in pledged_securities.items(): - security_value += (loan_security_price_map.get(security) * qty * hair_cut_map.get(security))/100 + after_haircut_percentage = 100 - hair_cut_map.get(security) + security_value += (loan_security_price_map.get(security) * qty * after_haircut_percentage)/100 return security_value @frappe.whitelist() -def get_disbursal_amount(loan): - loan_details = frappe.get_all("Loan", fields = ["loan_amount", "disbursed_amount", "total_payment", - "total_principal_paid", "total_interest_payable", "status", "is_term_loan", "is_secured_loan"], - filters= { "name": loan })[0] +def get_disbursal_amount(loan, on_current_security_price=0): + loan_details = frappe.get_value("Loan", loan, ["loan_amount", "disbursed_amount", "total_payment", + "total_principal_paid", "total_interest_payable", "status", "is_term_loan", "is_secured_loan", + "maximum_loan_amount"], as_dict=1) if loan_details.is_secured_loan and frappe.get_all('Loan Security Shortfall', filters={'loan': loan, 'status': 'Pending'}): @@ -173,17 +184,24 @@ def get_disbursal_amount(loan): pending_principal_amount = flt(loan_details.total_payment) - flt(loan_details.total_interest_payable) \ - flt(loan_details.total_principal_paid) else: - pending_principal_amount = flt(loan_details.disbursed_amount) + pending_principal_amount = flt(loan_details.disbursed_amount) - flt(loan_details.total_interest_payable) \ + - flt(loan_details.total_principal_paid) security_value = 0.0 - if loan_details.is_secured_loan: + if loan_details.is_secured_loan and on_current_security_price: security_value = get_total_pledged_security_value(loan) + if loan_details.is_secured_loan and not on_current_security_price: + security_value = flt(loan_details.maximum_loan_amount) + if not security_value and not loan_details.is_secured_loan: security_value = flt(loan_details.loan_amount) disbursal_amount = flt(security_value) - flt(pending_principal_amount) + if loan_details.is_term_loan and (disbursal_amount + loan_details.loan_amount) > loan_details.loan_amount: + disbursal_amount = loan_details.loan_amount - loan_details.disbursed_amount + return disbursal_amount diff --git a/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py index 2cb26376126..a8753877a6a 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py @@ -8,9 +8,10 @@ from frappe.utils import (nowdate, add_days, get_datetime, get_first_day, get_la from erpnext.loan_management.doctype.loan.test_loan import (create_loan_type, create_loan_security_pledge, create_repayment_entry, create_loan_application, make_loan_disbursement_entry, create_loan_accounts, create_loan_security_type, create_loan_security, create_demand_loan, create_loan_security_price) from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import process_loan_interest_accrual_for_demand_loans -from erpnext.loan_management.doctype.loan_interest_accrual.loan_interest_accrual import days_in_year +from erpnext.loan_management.doctype.loan_interest_accrual.loan_interest_accrual import days_in_year, get_per_day_interest from erpnext.selling.doctype.customer.test_customer import get_customer_dict from erpnext.loan_management.doctype.loan_application.loan_application import create_pledge +from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts class TestLoanDisbursement(unittest.TestCase): @@ -60,8 +61,7 @@ class TestLoanDisbursement(unittest.TestCase): self.assertRaises(frappe.ValidationError, make_loan_disbursement_entry, loan.name, 500000, first_date) - repayment_entry = create_repayment_entry(loan.name, self.applicant, add_days(get_last_day(nowdate()), 5), - "Regular Payment", 611095.89) + repayment_entry = create_repayment_entry(loan.name, self.applicant, add_days(get_last_day(nowdate()), 5), 611095.89) repayment_entry.submit() loan.reload() @@ -69,3 +69,50 @@ class TestLoanDisbursement(unittest.TestCase): # After repayment loan disbursement entry should go through make_loan_disbursement_entry(loan.name, 500000, disbursement_date=add_days(last_date, 16)) + # check for disbursement accrual + loan_interest_accrual = frappe.db.get_value('Loan Interest Accrual', {'loan': loan.name, + 'accrual_type': 'Disbursement'}) + + self.assertTrue(loan_interest_accrual) + + def test_loan_topup_with_additional_pledge(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant, 'Demand Loan', pledge) + create_pledge(loan_application) + + loan = create_demand_loan(self.applicant, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + self.assertEquals(loan.loan_amount, 1000000) + + first_date = '2019-10-01' + last_date = '2019-10-30' + + # Disbursed 10,00,000 amount + make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) + process_loan_interest_accrual_for_demand_loans(posting_date = last_date) + amounts = calculate_amounts(loan.name, add_days(last_date, 1)) + + previous_interest = amounts['interest_amount'] + + pledge1 = [{ + "loan_security": "Test Security 1", + "qty": 2000.00 + }] + + create_loan_security_pledge(self.applicant, pledge1, loan=loan.name) + + # Topup 500000 + make_loan_disbursement_entry(loan.name, 500000, disbursement_date=add_days(last_date, 1)) + process_loan_interest_accrual_for_demand_loans(posting_date = add_days(last_date, 15)) + amounts = calculate_amounts(loan.name, add_days(last_date, 15)) + + per_day_interest = get_per_day_interest(1500000, 13.5, '2019-10-30') + interest = per_day_interest * 15 + + self.assertEquals(amounts['pending_principal_amount'], 1500000) + self.assertEquals(amounts['interest_amount'], flt(interest + previous_interest, 2)) diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.json b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.json index 5fc3e8f4b60..185bf7a6663 100644 --- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.json +++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.json @@ -14,6 +14,7 @@ "column_break_4", "company", "posting_date", + "accrual_type", "is_term_loan", "section_break_7", "pending_principal_amount", @@ -21,10 +22,13 @@ "paid_principal_amount", "column_break_14", "interest_amount", + "total_pending_interest_amount", "paid_interest_amount", + "penalty_amount", "section_break_15", "process_loan_interest_accrual", "repayment_schedule_name", + "last_accrual_date", "amended_from" ], "fields": [ @@ -139,6 +143,7 @@ "read_only": 1 }, { + "depends_on": "eval:doc.is_term_loan", "fieldname": "paid_principal_amount", "fieldtype": "Currency", "label": "Paid Principal Amount", @@ -149,12 +154,38 @@ "fieldtype": "Currency", "label": "Paid Interest Amount", "options": "Company:company:default_currency" + }, + { + "fieldname": "accrual_type", + "fieldtype": "Select", + "label": "Accrual Type", + "options": "Regular\nRepayment\nDisbursement" + }, + { + "fieldname": "penalty_amount", + "fieldtype": "Currency", + "label": "Penalty Amount", + "options": "Company:company:default_currency" + }, + { + "fieldname": "last_accrual_date", + "fieldtype": "Date", + "hidden": 1, + "label": "Last Accrual Date", + "read_only": 1 + }, + { + "fieldname": "total_pending_interest_amount", + "fieldtype": "Currency", + "label": "Total Pending Interest Amount", + "options": "Company:company:default_currency" } ], "in_create": 1, + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-04-16 11:24:23.258404", + "modified": "2021-01-10 00:15:21.544140", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Interest Accrual", diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py index 2d959bf3be6..7978350adf8 100644 --- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py +++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py @@ -22,6 +22,8 @@ class LoanInterestAccrual(AccountsController): if not self.interest_amount and not self.payable_principal_amount: frappe.throw(_("Interest Amount or Principal Amount is mandatory")) + if not self.last_accrual_date: + self.last_accrual_date = get_last_accrual_date(self.loan) def on_submit(self): self.make_gl_entries() @@ -50,7 +52,8 @@ class LoanInterestAccrual(AccountsController): "debit_in_account_currency": self.interest_amount, "against_voucher_type": "Loan", "against_voucher": self.loan, - "remarks": _("Against Loan:") + self.loan, + "remarks": _("Interest accrued from {0} to {1} against loan: {2}").format( + self.last_accrual_date, self.posting_date, self.loan), "cost_center": erpnext.get_default_cost_center(self.company), "posting_date": self.posting_date }) @@ -59,14 +62,13 @@ class LoanInterestAccrual(AccountsController): gle_map.append( self.get_gl_dict({ "account": self.interest_income_account, - "party_type": self.applicant_type, - "party": self.applicant, "against": self.loan_account, "credit": self.interest_amount, "credit_in_account_currency": self.interest_amount, "against_voucher_type": "Loan", "against_voucher": self.loan, - "remarks": _("Against Loan:") + self.loan, + "remarks": ("Interest accrued from {0} to {1} against loan: {2}").format( + self.last_accrual_date, self.posting_date, self.loan), "cost_center": erpnext.get_default_cost_center(self.company), "posting_date": self.posting_date }) @@ -79,21 +81,27 @@ class LoanInterestAccrual(AccountsController): # For Eg: If Loan disbursement date is '01-09-2019' and disbursed amount is 1000000 and # rate of interest is 13.5 then first loan interest accural will be on '01-10-2019' # which means interest will be accrued for 30 days which should be equal to 11095.89 -def calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_interest): +def calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_interest, accrual_type): + from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts + no_of_days = get_no_of_days_for_interest_accural(loan, posting_date) + precision = cint(frappe.db.get_default("currency_precision")) or 2 if no_of_days <= 0: return if loan.status == 'Disbursed': pending_principal_amount = flt(loan.total_payment) - flt(loan.total_interest_payable) \ - - flt(loan.total_principal_paid) + - flt(loan.total_principal_paid) - flt(loan.written_off_amount) else: - pending_principal_amount = loan.disbursed_amount + pending_principal_amount = flt(loan.disbursed_amount) - flt(loan.total_interest_payable) \ + - flt(loan.total_principal_paid) - flt(loan.written_off_amount) - interest_per_day = (pending_principal_amount * loan.rate_of_interest) / (days_in_year(get_datetime(posting_date).year) * 100) + interest_per_day = get_per_day_interest(pending_principal_amount, loan.rate_of_interest, posting_date) payable_interest = interest_per_day * no_of_days + pending_amounts = calculate_amounts(loan.name, posting_date, payment_type='Loan Closure') + args = frappe._dict({ 'loan': loan.name, 'applicant_type': loan.applicant_type, @@ -102,13 +110,17 @@ def calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_i 'loan_account': loan.loan_account, 'pending_principal_amount': pending_principal_amount, 'interest_amount': payable_interest, + 'total_pending_interest_amount': pending_amounts['interest_amount'], + 'penalty_amount': pending_amounts['penalty_amount'], 'process_loan_interest': process_loan_interest, - 'posting_date': posting_date + 'posting_date': posting_date, + 'accrual_type': accrual_type }) - make_loan_interest_accrual_entry(args) + if flt(payable_interest, precision) > 0.0: + make_loan_interest_accrual_entry(args) -def make_accrual_interest_entry_for_demand_loans(posting_date, process_loan_interest, open_loans=None, loan_type=None): +def make_accrual_interest_entry_for_demand_loans(posting_date, process_loan_interest, open_loans=None, loan_type=None, accrual_type="Regular"): query_filters = { "status": ('in', ['Disbursed', 'Partially Disbursed']), "docstatus": 1 @@ -123,13 +135,13 @@ def make_accrual_interest_entry_for_demand_loans(posting_date, process_loan_inte open_loans = frappe.get_all("Loan", fields=["name", "total_payment", "total_amount_paid", "loan_account", "interest_income_account", "is_term_loan", "status", "disbursement_date", "disbursed_amount", "applicant_type", "applicant", - "rate_of_interest", "total_interest_payable", "total_principal_paid", "repayment_start_date"], + "rate_of_interest", "total_interest_payable", "written_off_amount", "total_principal_paid", "repayment_start_date"], filters=query_filters) for loan in open_loans: - calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_interest) + calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_interest, accrual_type) -def make_accrual_interest_entry_for_term_loans(posting_date, process_loan_interest, term_loan=None, loan_type=None): +def make_accrual_interest_entry_for_term_loans(posting_date, process_loan_interest, term_loan=None, loan_type=None, accrual_type="Regular"): curr_date = posting_date or add_days(nowdate(), 1) term_loans = get_term_loans(curr_date, term_loan, loan_type) @@ -148,7 +160,8 @@ def make_accrual_interest_entry_for_term_loans(posting_date, process_loan_intere 'payable_principal': loan.principal_amount, 'process_loan_interest': process_loan_interest, 'repayment_schedule_name': loan.payment_entry, - 'posting_date': posting_date + 'posting_date': posting_date, + 'accrual_type': accrual_type }) make_loan_interest_accrual_entry(args) @@ -192,31 +205,34 @@ def make_loan_interest_accrual_entry(args): loan_interest_accrual.loan_account = args.loan_account loan_interest_accrual.pending_principal_amount = flt(args.pending_principal_amount, precision) loan_interest_accrual.interest_amount = flt(args.interest_amount, precision) + loan_interest_accrual.total_pending_interest_amount = flt(args.total_pending_interest_amount, precision) + loan_interest_accrual.penalty_amount = flt(args.penalty_amount, precision) loan_interest_accrual.posting_date = args.posting_date or nowdate() loan_interest_accrual.process_loan_interest_accrual = args.process_loan_interest loan_interest_accrual.repayment_schedule_name = args.repayment_schedule_name loan_interest_accrual.payable_principal_amount = args.payable_principal + loan_interest_accrual.accrual_type = args.accrual_type loan_interest_accrual.save() loan_interest_accrual.submit() def get_no_of_days_for_interest_accural(loan, posting_date): - last_interest_accrual_date = get_last_accural_date_in_current_month(loan) + last_interest_accrual_date = get_last_accrual_date(loan.name) no_of_days = date_diff(posting_date or nowdate(), last_interest_accrual_date) + 1 return no_of_days -def get_last_accural_date_in_current_month(loan): +def get_last_accrual_date(loan): last_posting_date = frappe.db.sql(""" SELECT MAX(posting_date) from `tabLoan Interest Accrual` - WHERE loan = %s""", (loan.name)) + WHERE loan = %s and docstatus = 1""", (loan)) if last_posting_date[0][0]: # interest for last interest accrual date is already booked, so add 1 day return add_days(last_posting_date[0][0], 1) else: - return loan.disbursement_date + return frappe.db.get_value('Loan', loan, 'disbursement_date') def days_in_year(year): days = 365 @@ -226,3 +242,9 @@ def days_in_year(year): return days +def get_per_day_interest(principal_amount, rate_of_interest, posting_date=None): + if not posting_date: + posting_date = getdate() + + return flt((principal_amount * rate_of_interest) / (days_in_year(get_datetime(posting_date).year) * 100)) + diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py index 4b85b218696..85e008ac293 100644 --- a/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py +++ b/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe import unittest from frappe.utils import (nowdate, add_days, get_datetime, get_first_day, get_last_day, date_diff, flt, add_to_date) -from erpnext.loan_management.doctype.loan.test_loan import (create_loan_type, create_loan_security_pledge, create_loan_security_price, +from erpnext.loan_management.doctype.loan.test_loan import (create_loan_type, create_loan_security_price, make_loan_disbursement_entry, create_loan_accounts, create_loan_security_type, create_loan_security, create_demand_loan, create_loan_application) from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import process_loan_interest_accrual_for_demand_loans from erpnext.loan_management.doctype.loan_interest_accrual.loan_interest_accrual import days_in_year @@ -37,10 +37,8 @@ class TestLoanInterestAccrual(unittest.TestCase): loan_application = create_loan_application('_Test Company', self.applicant, 'Demand Loan', pledge) create_pledge(loan_application) - loan = create_demand_loan(self.applicant, "Demand Loan", loan_application, posting_date=get_first_day(nowdate())) - loan.submit() first_date = '2019-10-01' @@ -50,11 +48,46 @@ class TestLoanInterestAccrual(unittest.TestCase): accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ / (days_in_year(get_datetime(first_date).year) * 100) - make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) - process_loan_interest_accrual_for_demand_loans(posting_date=last_date) - loan_interest_accural = frappe.get_doc("Loan Interest Accrual", {'loan': loan.name}) - self.assertEquals(flt(loan_interest_accural.interest_amount, 2), flt(accrued_interest_amount, 2)) + self.assertEquals(flt(loan_interest_accural.interest_amount, 0), flt(accrued_interest_amount, 0)) + + def test_accumulated_amounts(self): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', self.applicant, 'Demand Loan', pledge) + create_pledge(loan_application) + loan = create_demand_loan(self.applicant, "Demand Loan", loan_application, + posting_date=get_first_day(nowdate())) + loan.submit() + + first_date = '2019-10-01' + last_date = '2019-10-30' + + no_of_days = date_diff(last_date, first_date) + 1 + accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ + / (days_in_year(get_datetime(first_date).year) * 100) + make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=first_date) + process_loan_interest_accrual_for_demand_loans(posting_date=last_date) + loan_interest_accrual = frappe.get_doc("Loan Interest Accrual", {'loan': loan.name}) + + self.assertEquals(flt(loan_interest_accrual.interest_amount, 0), flt(accrued_interest_amount, 0)) + + next_start_date = '2019-10-31' + next_end_date = '2019-11-29' + + no_of_days = date_diff(next_end_date, next_start_date) + 1 + process = process_loan_interest_accrual_for_demand_loans(posting_date=next_end_date) + new_accrued_interest_amount = (loan.loan_amount * loan.rate_of_interest * no_of_days) \ + / (days_in_year(get_datetime(first_date).year) * 100) + + total_pending_interest_amount = flt(accrued_interest_amount + new_accrued_interest_amount, 0) + + loan_interest_accrual = frappe.get_doc("Loan Interest Accrual", {'loan': loan.name, + 'process_loan_interest_accrual': process}) + self.assertEquals(flt(loan_interest_accrual.total_pending_interest_amount, 0), total_pending_interest_amount) diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json index 5942455919f..2b5df4be243 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json @@ -10,11 +10,11 @@ "applicant_type", "applicant", "loan_type", - "payment_type", "column_break_3", "company", "posting_date", "is_term_loan", + "rate_of_interest", "payment_details_section", "due_date", "pending_principal_amount", @@ -31,6 +31,7 @@ "column_break_21", "reference_date", "principal_amount_paid", + "total_interest_paid", "repayment_details", "amended_from" ], @@ -95,15 +96,6 @@ "fieldname": "column_break_9", "fieldtype": "Column Break" }, - { - "default": "Regular Payment", - "fieldname": "payment_type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Payment Type", - "options": "\nRegular Payment\nLoan Closure", - "reqd": 1 - }, { "fieldname": "payable_amount", "fieldtype": "Currency", @@ -116,6 +108,7 @@ "fieldname": "amount_paid", "fieldtype": "Currency", "label": "Amount Paid", + "non_negative": 1, "options": "Company:company:default_currency", "reqd": 1 }, @@ -195,6 +188,7 @@ "fieldtype": "Currency", "hidden": 1, "label": "Principal Amount Paid", + "options": "Company:company:default_currency", "read_only": 1 }, { @@ -217,11 +211,27 @@ "hidden": 1, "label": "Repayment Details", "options": "Loan Repayment Detail" + }, + { + "fieldname": "total_interest_paid", + "fieldtype": "Currency", + "hidden": 1, + "label": "Total Interest Paid", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fetch_from": "loan_type.rate_of_interest", + "fieldname": "rate_of_interest", + "fieldtype": "Percent", + "label": "Rate Of Interest", + "read_only": 1 } ], + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-05-16 09:40:15.581165", + "modified": "2020-11-05 10:06:58.792841", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment", diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index 97dbc44bf13..bac06c4e9e6 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -14,14 +14,15 @@ from erpnext.controllers.accounts_controller import AccountsController from erpnext.accounts.general_ledger import make_gl_entries from erpnext.loan_management.doctype.loan_security_shortfall.loan_security_shortfall import update_shortfall_status from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import process_loan_interest_accrual_for_demand_loans +from erpnext.loan_management.doctype.loan_interest_accrual.loan_interest_accrual import get_per_day_interest, get_last_accrual_date class LoanRepayment(AccountsController): def validate(self): - amounts = calculate_amounts(self.against_loan, self.posting_date, self.payment_type) + amounts = calculate_amounts(self.against_loan, self.posting_date) self.set_missing_values(amounts) self.validate_amount() - self.allocate_amounts(amounts['pending_accrual_entries']) + self.allocate_amounts(amounts) def before_submit(self): self.book_unaccrued_interest() @@ -32,8 +33,8 @@ class LoanRepayment(AccountsController): def on_cancel(self): self.mark_as_unpaid() - self.make_gl_entries(cancel=1) self.ignore_linked_doctypes = ['GL Entry'] + self.make_gl_entries(cancel=1) def set_missing_values(self, amounts): precision = cint(frappe.db.get_default("currency_precision")) or 2 @@ -72,33 +73,38 @@ class LoanRepayment(AccountsController): msg = _("Paid amount cannot be less than {0}").format(self.penalty_amount) frappe.throw(msg) - if self.payment_type == "Loan Closure" and flt(self.amount_paid, precision) < flt(self.payable_amount, precision): - msg = _("Amount of {0} is required for Loan closure").format(self.payable_amount) - frappe.throw(msg) - def book_unaccrued_interest(self): - if self.payment_type == 'Loan Closure': - total_interest_paid = 0 - for payment in self.repayment_details: - total_interest_paid += payment.paid_interest_amount + precision = cint(frappe.db.get_default("currency_precision")) or 2 + if self.total_interest_paid > self.interest_payable: + if not self.is_term_loan: + # get last loan interest accrual date + last_accrual_date = get_last_accrual_date(self.against_loan) - if total_interest_paid < self.interest_payable: - if not self.is_term_loan: - process = process_loan_interest_accrual_for_demand_loans(posting_date=self.posting_date, - loan=self.against_loan) + # get posting date upto which interest has to be accrued + per_day_interest = get_per_day_interest(self.pending_principal_amount, + self.rate_of_interest, self.posting_date) - lia = frappe.db.get_value('Loan Interest Accrual', {'process_loan_interest_accrual': - process}, ['name', 'interest_amount', 'payable_principal_amount'], as_dict=1) + no_of_days = flt(flt(self.total_interest_paid - self.interest_payable, + precision)/per_day_interest, 0) - 1 - self.append('repayment_details', { - 'loan_interest_accrual': lia.name, - 'paid_interest_amount': lia.interest_amount, - 'paid_principal_amount': lia.payable_principal_amount - }) + posting_date = add_days(last_accrual_date, no_of_days) + + # book excess interest paid + process = process_loan_interest_accrual_for_demand_loans(posting_date=posting_date, + loan=self.against_loan, accrual_type="Repayment") + + # get loan interest accrual to update paid amount + lia = frappe.db.get_value('Loan Interest Accrual', {'process_loan_interest_accrual': + process}, ['name', 'interest_amount', 'payable_principal_amount'], as_dict=1) + + self.append('repayment_details', { + 'loan_interest_accrual': lia.name, + 'paid_interest_amount': flt(self.total_interest_paid - self.interest_payable, precision), + 'paid_principal_amount': 0.0, + 'accrual_type': 'Repayment' + }) def update_paid_amount(self): - precision = cint(frappe.db.get_default("currency_precision")) or 2 - loan = frappe.get_doc("Loan", self.against_loan) for payment in self.repayment_details: @@ -106,13 +112,7 @@ class LoanRepayment(AccountsController): SET paid_principal_amount = `paid_principal_amount` + %s, paid_interest_amount = `paid_interest_amount` + %s WHERE name = %s""", - (flt(payment.paid_principal_amount, precision), flt(payment.paid_interest_amount, precision), payment.loan_interest_accrual)) - - if flt(loan.total_principal_paid + self.principal_amount_paid, precision) >= flt(loan.total_payment, precision): - if loan.is_secured_loan: - frappe.db.set_value("Loan", self.against_loan, "status", "Loan Closure Requested") - else: - frappe.db.set_value("Loan", self.against_loan, "status", "Closed") + (flt(payment.paid_principal_amount), flt(payment.paid_interest_amount), payment.loan_interest_accrual)) frappe.db.sql(""" UPDATE `tabLoan` SET total_amount_paid = %s, total_principal_paid = %s WHERE name = %s """, (loan.total_amount_paid + self.amount_paid, @@ -123,6 +123,8 @@ class LoanRepayment(AccountsController): def mark_as_unpaid(self): loan = frappe.get_doc("Loan", self.against_loan) + no_of_repayments = len(self.repayment_details) + for payment in self.repayment_details: frappe.db.sql(""" UPDATE `tabLoan Interest Accrual` SET paid_principal_amount = `paid_principal_amount` - %s, @@ -130,6 +132,12 @@ class LoanRepayment(AccountsController): WHERE name = %s""", (payment.paid_principal_amount, payment.paid_interest_amount, payment.loan_interest_accrual)) + # Cancel repayment interest accrual + # checking idx as a preventive measure, repayment accrual will always be the last entry + if payment.accrual_type == 'Repayment' and payment.idx == no_of_repayments: + lia_doc = frappe.get_doc('Loan Interest Accrual', payment.loan_interest_accrual) + lia_doc.cancel() + frappe.db.sql(""" UPDATE `tabLoan` SET total_amount_paid = %s, total_principal_paid = %s WHERE name = %s """, (loan.total_amount_paid - self.amount_paid, loan.total_principal_paid - self.principal_amount_paid, self.against_loan)) @@ -137,15 +145,15 @@ class LoanRepayment(AccountsController): if loan.status == "Loan Closure Requested": frappe.db.set_value("Loan", self.against_loan, "status", "Disbursed") - def allocate_amounts(self, paid_entries): + def allocate_amounts(self, repayment_details): self.set('repayment_details', []) self.principal_amount_paid = 0 total_interest_paid = 0 interest_paid = self.amount_paid - self.penalty_amount - if self.amount_paid - self.penalty_amount > 0 and paid_entries: + if self.amount_paid - self.penalty_amount > 0: interest_paid = self.amount_paid - self.penalty_amount - for lia, amounts in iteritems(paid_entries): + for lia, amounts in iteritems(repayment_details.get('pending_accrual_entries', [])): if amounts['interest_amount'] + amounts['payable_principal_amount'] <= interest_paid: interest_amount = amounts['interest_amount'] paid_principal = amounts['payable_principal_amount'] @@ -169,10 +177,22 @@ class LoanRepayment(AccountsController): 'paid_principal_amount': paid_principal }) - if self.payment_type == 'Loan Closure' and total_interest_paid < self.interest_payable: - unaccrued_interest = self.interest_payable - total_interest_paid - interest_paid -= unaccrued_interest + if repayment_details['unaccrued_interest'] and interest_paid: + # no of days for which to accrue interest + # Interest can only be accrued for an entire day and not partial + if interest_paid > repayment_details['unaccrued_interest']: + interest_paid -= repayment_details['unaccrued_interest'] + total_interest_paid += repayment_details['unaccrued_interest'] + else: + # get no of days for which interest can be paid + per_day_interest = get_per_day_interest(self.pending_principal_amount, + self.rate_of_interest, self.posting_date) + no_of_days = cint(interest_paid/per_day_interest) + total_interest_paid += no_of_days * per_day_interest + interest_paid -= no_of_days * per_day_interest + + self.total_interest_paid = total_interest_paid if interest_paid: self.principal_amount_paid += interest_paid @@ -189,7 +209,7 @@ class LoanRepayment(AccountsController): "debit_in_account_currency": self.penalty_amount, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": _("Against Loan:") + self.against_loan, + "remarks": _("Penalty against loan:") + self.against_loan, "cost_center": self.cost_center, "party_type": self.applicant_type, "party": self.applicant, @@ -205,10 +225,8 @@ class LoanRepayment(AccountsController): "credit_in_account_currency": self.penalty_amount, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": _("Against Loan:") + self.against_loan, + "remarks": _("Penalty against loan:") + self.against_loan, "cost_center": self.cost_center, - "party_type": self.applicant_type, - "party": self.applicant, "posting_date": getdate(self.posting_date) }) ) @@ -219,13 +237,11 @@ class LoanRepayment(AccountsController): "against": loan_details.loan_account + ", " + loan_details.interest_income_account + ", " + loan_details.penalty_income_account, "debit": self.amount_paid, - "debit_in_account_currency": self.amount_paid , + "debit_in_account_currency": self.amount_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": _("Against Loan:") + self.against_loan, + "remarks": _("Repayment against Loan: ") + self.against_loan, "cost_center": self.cost_center, - "party_type": self.applicant_type, - "party": self.applicant, "posting_date": getdate(self.posting_date) }) ) @@ -240,7 +256,7 @@ class LoanRepayment(AccountsController): "credit_in_account_currency": self.amount_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": _("Against Loan:") + self.against_loan, + "remarks": _("Repayment against Loan: ") + self.against_loan, "cost_center": self.cost_center, "posting_date": getdate(self.posting_date) }) @@ -273,7 +289,8 @@ def get_accrued_interest_entries(against_loan): unpaid_accrued_entries = frappe.db.sql( """ SELECT name, posting_date, interest_amount - paid_interest_amount as interest_amount, - payable_principal_amount - paid_principal_amount as payable_principal_amount + payable_principal_amount - paid_principal_amount as payable_principal_amount, + accrual_type FROM `tabLoan Interest Accrual` WHERE @@ -282,6 +299,7 @@ def get_accrued_interest_entries(against_loan): payable_principal_amount - paid_principal_amount > 0) AND docstatus = 1 + ORDER BY posting_date """, (against_loan), as_dict=1) return unpaid_accrued_entries @@ -289,7 +307,7 @@ def get_accrued_interest_entries(against_loan): # This function returns the amounts that are payable at the time of loan repayment based on posting date # So it pulls all the unpaid Loan Interest Accrual Entries and calculates the penalty if applicable -def get_amounts(amounts, against_loan, posting_date, payment_type): +def get_amounts(amounts, against_loan, posting_date): precision = cint(frappe.db.get_default("currency_precision")) or 2 against_loan_doc = frappe.get_doc("Loan", against_loan) @@ -311,10 +329,10 @@ def get_amounts(amounts, against_loan, posting_date, payment_type): due_date = add_days(entry.posting_date, 1) no_of_late_days = date_diff(posting_date, - add_days(due_date, loan_type_details.grace_period_in_days)) + add_days(due_date, loan_type_details.grace_period_in_days)) + 1 - if no_of_late_days > 0 and (not against_loan_doc.repay_from_salary): - penalty_amount += (entry.interest_amount * (loan_type_details.penalty_interest_rate / 100) * no_of_late_days)/365 + if no_of_late_days > 0 and (not against_loan_doc.repay_from_salary) and entry.accrual_type == 'Regular': + penalty_amount += (entry.interest_amount * (loan_type_details.penalty_interest_rate / 100) * no_of_late_days) total_pending_interest += entry.interest_amount payable_principal_amount += entry.payable_principal_amount @@ -324,23 +342,27 @@ def get_amounts(amounts, against_loan, posting_date, payment_type): 'payable_principal_amount': flt(entry.payable_principal_amount, precision) }) - if not final_due_date: + if due_date and not final_due_date: final_due_date = add_days(due_date, loan_type_details.grace_period_in_days) if against_loan_doc.status in ('Disbursed', 'Loan Closure Requested', 'Closed'): - pending_principal_amount = against_loan_doc.total_payment - against_loan_doc.total_principal_paid - against_loan_doc.total_interest_payable + pending_principal_amount = against_loan_doc.total_payment - against_loan_doc.total_principal_paid \ + - against_loan_doc.total_interest_payable - against_loan_doc.written_off_amount else: - pending_principal_amount = against_loan_doc.disbursed_amount + pending_principal_amount = against_loan_doc.disbursed_amount - against_loan_doc.total_principal_paid \ + - against_loan_doc.total_interest_payable - against_loan_doc.written_off_amount - if payment_type == "Loan Closure": - if due_date: - pending_days = date_diff(posting_date, due_date) + 1 - else: - pending_days = date_diff(posting_date, against_loan_doc.disbursement_date) + 1 + unaccrued_interest = 0 + if due_date: + pending_days = date_diff(posting_date, due_date) + 1 + else: + last_accrual_date = get_last_accrual_date(against_loan_doc.name) + pending_days = date_diff(posting_date, last_accrual_date) + 1 - payable_principal_amount = pending_principal_amount - per_day_interest = (payable_principal_amount * (loan_type_details.rate_of_interest / 100))/365 - total_pending_interest += (pending_days * per_day_interest) + if pending_days > 0: + principal_amount = flt(pending_principal_amount, precision) + per_day_interest = get_per_day_interest(principal_amount, loan_type_details.rate_of_interest, posting_date) + unaccrued_interest += (pending_days * per_day_interest) amounts["pending_principal_amount"] = flt(pending_principal_amount, precision) amounts["payable_principal_amount"] = flt(payable_principal_amount, precision) @@ -348,6 +370,7 @@ def get_amounts(amounts, against_loan, posting_date, payment_type): amounts["penalty_amount"] = flt(penalty_amount, precision) amounts["payable_amount"] = flt(payable_principal_amount + total_pending_interest + penalty_amount, precision) amounts["pending_accrual_entries"] = pending_accrual_entries + amounts["unaccrued_interest"] = flt(unaccrued_interest, precision) if final_due_date: amounts["due_date"] = final_due_date @@ -355,7 +378,7 @@ def get_amounts(amounts, against_loan, posting_date, payment_type): return amounts @frappe.whitelist() -def calculate_amounts(against_loan, posting_date, payment_type): +def calculate_amounts(against_loan, posting_date, payment_type=''): amounts = { 'penalty_amount': 0.0, @@ -363,10 +386,17 @@ def calculate_amounts(against_loan, posting_date, payment_type): 'pending_principal_amount': 0.0, 'payable_principal_amount': 0.0, 'payable_amount': 0.0, + 'unaccrued_interest': 0.0, 'due_date': '' } - amounts = get_amounts(amounts, against_loan, posting_date, payment_type) + amounts = get_amounts(amounts, against_loan, posting_date) + + # update values for closure + if payment_type == 'Loan Closure': + amounts['payable_principal_amount'] = amounts['pending_principal_amount'] + amounts['interest_amount'] += amounts['unaccrued_interest'] + amounts['payable_amount'] = amounts['payable_principal_amount'] + amounts['interest_amount'] return amounts diff --git a/erpnext/loan_management/doctype/loan_repayment_detail/loan_repayment_detail.json b/erpnext/loan_management/doctype/loan_repayment_detail/loan_repayment_detail.json index cff1dbb1d29..4b9b191e26b 100644 --- a/erpnext/loan_management/doctype/loan_repayment_detail/loan_repayment_detail.json +++ b/erpnext/loan_management/doctype/loan_repayment_detail/loan_repayment_detail.json @@ -7,7 +7,8 @@ "field_order": [ "loan_interest_accrual", "paid_principal_amount", - "paid_interest_amount" + "paid_interest_amount", + "accrual_type" ], "fields": [ { @@ -27,11 +28,20 @@ "fieldtype": "Currency", "label": "Paid Interest Amount", "options": "Company:company:default_currency" + }, + { + "fetch_from": "loan_interest_accrual.accrual_type", + "fetch_if_empty": 1, + "fieldname": "accrual_type", + "fieldtype": "Select", + "label": "Accrual Type", + "options": "Regular\nRepayment\nDisbursement" } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-04-15 21:50:03.837019", + "modified": "2020-10-23 08:09:18.267030", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Repayment Detail", diff --git a/erpnext/loan_management/doctype/loan_security/loan_security.json b/erpnext/loan_management/doctype/loan_security/loan_security.json index 1d0bb309104..c698601ea46 100644 --- a/erpnext/loan_management/doctype/loan_security/loan_security.json +++ b/erpnext/loan_management/doctype/loan_security/loan_security.json @@ -25,6 +25,7 @@ }, { "fetch_from": "loan_security_type.haircut", + "fetch_if_empty": 1, "fieldname": "haircut", "fieldtype": "Percent", "label": "Haircut %" @@ -64,8 +65,9 @@ "reqd": 1 } ], + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-04-29 13:21:26.043492", + "modified": "2020-10-26 07:34:48.601766", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Security", diff --git a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py index 2bb6fd84e58..cbc8376aa56 100644 --- a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py +++ b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py @@ -78,7 +78,7 @@ class LoanSecurityPledge(Document): self.maximum_loan_value = maximum_loan_value def update_loan(loan, maximum_value_against_pledge): - maximum_loan_value = frappe.db.get_value('Loan', {'name': loan}, ['maximum_loan_value']) + maximum_loan_value = frappe.db.get_value('Loan', {'name': loan}, ['maximum_loan_amount']) - frappe.db.sql(""" UPDATE `tabLoan` SET maximum_loan_value=%s, is_secured_loan=1 + frappe.db.sql(""" UPDATE `tabLoan` SET maximum_loan_amount=%s, is_secured_loan=1 WHERE name=%s""", (maximum_loan_value + maximum_value_against_pledge, loan)) diff --git a/erpnext/loan_management/doctype/loan_security_price/loan_security_price.json b/erpnext/loan_management/doctype/loan_security_price/loan_security_price.json index a55b482bd66..b6e87637567 100644 --- a/erpnext/loan_management/doctype/loan_security_price/loan_security_price.json +++ b/erpnext/loan_management/doctype/loan_security_price/loan_security_price.json @@ -7,6 +7,7 @@ "engine": "InnoDB", "field_order": [ "loan_security", + "loan_security_name", "loan_security_type", "column_break_2", "uom", @@ -79,10 +80,18 @@ "label": "Loan Security Type", "options": "Loan Security Type", "read_only": 1 + }, + { + "fetch_from": "loan_security.loan_security_name", + "fieldname": "loan_security_name", + "fieldtype": "Data", + "label": "Loan Security Name", + "read_only": 1 } ], + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-06-11 03:41:33.900340", + "modified": "2021-01-17 07:41:49.598086", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Security Price", diff --git a/erpnext/loan_management/doctype/loan_security_shortfall/loan_security_shortfall.py b/erpnext/loan_management/doctype/loan_security_shortfall/loan_security_shortfall.py index 0f42bde3c4e..b5e78981d04 100644 --- a/erpnext/loan_management/doctype/loan_security_shortfall/loan_security_shortfall.py +++ b/erpnext/loan_management/doctype/loan_security_shortfall/loan_security_shortfall.py @@ -22,7 +22,7 @@ def update_shortfall_status(loan, security_value): if security_value >= loan_security_shortfall.shortfall_amount: frappe.db.set_value("Loan Security Shortfall", loan_security_shortfall.name, { "status": "Completed", - "shortfall_value": loan_security_shortfall.shortfall_amount}) + "shortfall_amount": loan_security_shortfall.shortfall_amount}) else: frappe.db.set_value("Loan Security Shortfall", loan_security_shortfall.name, "shortfall_amount", loan_security_shortfall.shortfall_amount - security_value) @@ -55,6 +55,9 @@ def check_for_ltv_shortfall(process_loan_security_shortfall): 'total_interest_payable', 'disbursed_amount', 'status'], filters={'status': ('in',['Disbursed','Partially Disbursed']), 'is_secured_loan': 1}) + loan_shortfall_map = frappe._dict(frappe.get_all("Loan Security Shortfall", + fields=["loan", "name"], filters={"status": "Pending"}, as_list=1)) + loan_security_map = {} for loan in loans: @@ -71,17 +74,21 @@ def check_for_ltv_shortfall(process_loan_security_shortfall): for security, qty in pledged_securities.items(): if not ltv_ratio: ltv_ratio = get_ltv_ratio(security) - security_value += loan_security_price_map.get(security) * qty + security_value += flt(loan_security_price_map.get(security)) * flt(qty) - current_ratio = (outstanding_amount/security_value) * 100 + current_ratio = (outstanding_amount/security_value) * 100 if security_value else 0 if current_ratio > ltv_ratio: shortfall_amount = outstanding_amount - ((security_value * ltv_ratio) / 100) create_loan_security_shortfall(loan.name, outstanding_amount, security_value, shortfall_amount, process_loan_security_shortfall) + elif loan_shortfall_map.get(loan.name): + shortfall_amount = outstanding_amount - ((security_value * ltv_ratio) / 100) + if shortfall_amount <= 0: + shortfall = loan_shortfall_map.get(loan.name) + update_pending_shortfall(shortfall) def create_loan_security_shortfall(loan, loan_amount, security_value, shortfall_amount, process_loan_security_shortfall): - existing_shortfall = frappe.db.get_value("Loan Security Shortfall", {"loan": loan, "status": "Pending"}, "name") if existing_shortfall: @@ -102,3 +109,11 @@ def get_ltv_ratio(loan_security): ltv_ratio = frappe.db.get_value('Loan Security Type', loan_security_type, 'loan_to_value_ratio') return ltv_ratio +def update_pending_shortfall(shortfall): + # Get all pending loan security shortfall + frappe.db.set_value("Loan Security Shortfall", shortfall, + { + "status": "Completed", + "shortfall_amount": 0 + }) + diff --git a/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py b/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py index b3eb6001e42..c4c2d683780 100644 --- a/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py +++ b/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py @@ -30,6 +30,8 @@ class LoanSecurityUnpledge(Document): d.idx, frappe.bold(d.loan_security))) def validate_unpledge_qty(self): + from erpnext.loan_management.doctype.loan_security_shortfall.loan_security_shortfall import get_ltv_ratio + pledge_qty_map = get_pledged_security_qty(self.loan) ltv_ratio_map = frappe._dict(frappe.get_all("Loan Security Type", @@ -42,33 +44,53 @@ class LoanSecurityUnpledge(Document): "valid_upto": (">=", get_datetime()) }, as_list=1)) - total_payment, principal_paid, interest_payable = frappe.get_value("Loan", self.loan, ['total_payment', 'total_principal_paid', - 'total_interest_payable']) + loan_details = frappe.get_value("Loan", self.loan, ['total_payment', 'total_principal_paid', + 'total_interest_payable', 'written_off_amount', 'disbursed_amount', 'status'], as_dict=1) + + if loan_details.status == 'Disbursed': + pending_principal_amount = flt(loan_details.total_payment) - flt(loan_details.total_interest_payable) \ + - flt(loan_details.total_principal_paid) - flt(loan_details.written_off_amount) + else: + pending_principal_amount = flt(loan_details.disbursed_amount) - flt(loan_details.total_interest_payable) \ + - flt(loan_details.total_principal_paid) - flt(loan_details.written_off_amount) - pending_principal_amount = flt(total_payment) - flt(interest_payable) - flt(principal_paid) security_value = 0 + unpledge_qty_map = {} + ltv_ratio = 0 for security in self.securities: pledged_qty = pledge_qty_map.get(security.loan_security, 0) if security.qty > pledged_qty: - frappe.throw(_("""Row {0}: {1} {2} of {3} is pledged against Loan {4}. - You are trying to unpledge more""").format(security.idx, pledged_qty, security.uom, - frappe.bold(security.loan_security), frappe.bold(self.loan))) + msg = _("Row {0}: {1} {2} of {3} is pledged against Loan {4}.").format(security.idx, pledged_qty, security.uom, + frappe.bold(security.loan_security), frappe.bold(self.loan)) + msg += "
    " + msg += _("You are trying to unpledge more.") + frappe.throw(msg, title=_("Loan Security Unpledge Error")) - qty_after_unpledge = pledged_qty - security.qty - ltv_ratio = ltv_ratio_map.get(security.loan_security_type) + unpledge_qty_map.setdefault(security.loan_security, 0) + unpledge_qty_map[security.loan_security] += security.qty - current_price = loan_security_price_map.get(security.loan_security) - if not current_price: - frappe.throw(_("No valid Loan Security Price found for {0}").format(frappe.bold(security.loan_security))) + for security in pledge_qty_map: + if not ltv_ratio: + ltv_ratio = get_ltv_ratio(security) + qty_after_unpledge = pledge_qty_map.get(security, 0) - unpledge_qty_map.get(security, 0) + current_price = loan_security_price_map.get(security) security_value += qty_after_unpledge * current_price if not security_value and flt(pending_principal_amount, 2) > 0: - frappe.throw("Cannot Unpledge, loan to value ratio is breaching") + self._throw(security_value, pending_principal_amount, ltv_ratio) if security_value and flt(pending_principal_amount/security_value) * 100 > ltv_ratio: - frappe.throw("Cannot Unpledge, loan to value ratio is breaching") + self._throw(security_value, pending_principal_amount, ltv_ratio) + + def _throw(self, security_value, pending_principal_amount, ltv_ratio): + msg = _("Loan Security Value after unpledge is {0}").format(frappe.bold(security_value)) + msg += '
    ' + msg += _("Pending principal amount is {0}").format(frappe.bold(flt(pending_principal_amount, 2))) + msg += '
    ' + msg += _("Loan To Security Value ratio must always be {0}").format(frappe.bold(ltv_ratio)) + frappe.throw(msg, title=_("Loan To Value ratio breach")) def on_update_after_submit(self): self.approve() diff --git a/erpnext/loan_management/doctype/loan_type/loan_type.json b/erpnext/loan_management/doctype/loan_type/loan_type.json index 669490a4480..3ef53044c20 100644 --- a/erpnext/loan_management/doctype/loan_type/loan_type.json +++ b/erpnext/loan_management/doctype/loan_type/loan_type.json @@ -11,6 +11,7 @@ "rate_of_interest", "penalty_interest_rate", "grace_period_in_days", + "write_off_amount", "column_break_2", "company", "is_term_loan", @@ -76,7 +77,6 @@ "reqd": 1 }, { - "description": "This account is used for booking loan repayments from the borrower and also disbursing loans to the borrower", "fieldname": "payment_account", "fieldtype": "Link", "label": "Payment Account", @@ -84,7 +84,6 @@ "reqd": 1 }, { - "description": "This account is capital account which is used to allocate capital for loan disbursal account ", "fieldname": "loan_account", "fieldtype": "Link", "label": "Loan Account", @@ -96,7 +95,6 @@ "fieldtype": "Column Break" }, { - "description": "This account will be used for booking loan interest accruals", "fieldname": "interest_income_account", "fieldtype": "Link", "label": "Interest Income Account", @@ -104,7 +102,6 @@ "reqd": 1 }, { - "description": "This account will be used for booking penalties levied due to delayed repayments", "fieldname": "penalty_income_account", "fieldtype": "Link", "label": "Penalty Income Account", @@ -113,7 +110,6 @@ }, { "default": "0", - "description": "If this is not checked the loan by default will be considered as a Demand Loan", "fieldname": "is_term_loan", "fieldtype": "Check", "label": "Is Term Loan" @@ -145,17 +141,27 @@ "label": "Company", "options": "Company", "reqd": 1 + }, + { + "allow_on_submit": 1, + "description": "Loan Write Off will be automatically created on loan closure request if pending amount is below this limit", + "fieldname": "write_off_amount", + "fieldtype": "Currency", + "label": "Auto Write Off Amount ", + "options": "Company:company:default_currency" } ], + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-06-07 18:55:59.346292", + "modified": "2021-01-17 06:51:26.082879", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Type", "owner": "Administrator", "permissions": [ { + "cancel": 1, "create": 1, "delete": 1, "email": 1, @@ -165,6 +171,7 @@ "report": 1, "role": "Loan Manager", "share": 1, + "submit": 1, "write": 1 }, { diff --git a/erpnext/loan_management/doctype/loan_write_off/__init__.py b/erpnext/loan_management/doctype/loan_write_off/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.js b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.js new file mode 100644 index 00000000000..4e3319c2087 --- /dev/null +++ b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.js @@ -0,0 +1,36 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +{% include 'erpnext/loan_management/loan_common.js' %}; + +frappe.ui.form.on('Loan Write Off', { + loan: function(frm) { + frm.trigger('show_pending_principal_amount'); + }, + onload: function(frm) { + frm.trigger('show_pending_principal_amount'); + }, + refresh: function(frm) { + frm.set_query('write_off_account', function(){ + return { + filters: { + 'company': frm.doc.company, + 'root_type': 'Expense', + 'is_group': 0 + } + } + }); + }, + show_pending_principal_amount: function(frm) { + if (frm.doc.loan && frm.doc.docstatus === 0) { + frappe.db.get_value('Loan', frm.doc.loan, ['total_payment', 'total_interest_payable', + 'total_principal_paid', 'written_off_amount'], function(values) { + frm.set_df_property('write_off_amount', 'description', + "Pending principal amount is " + cstr(flt(values.total_payment - values.total_interest_payable + - values.total_principal_paid - values.written_off_amount, 2))); + frm.refresh_field('write_off_amount'); + }); + + } + } +}); diff --git a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.json b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.json new file mode 100644 index 00000000000..4617a62f5b6 --- /dev/null +++ b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.json @@ -0,0 +1,157 @@ +{ + "actions": [], + "autoname": "LM-WO-.#####", + "creation": "2020-10-16 11:09:14.495066", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "loan", + "applicant_type", + "applicant", + "column_break_3", + "company", + "posting_date", + "accounting_dimensions_section", + "cost_center", + "section_break_9", + "write_off_account", + "column_break_11", + "write_off_amount", + "amended_from" + ], + "fields": [ + { + "fieldname": "loan", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Loan", + "options": "Loan", + "reqd": 1 + }, + { + "default": "Today", + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Posting Date", + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fetch_from": "loan.company", + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "read_only": 1, + "reqd": 1 + }, + { + "fetch_from": "loan.applicant_type", + "fieldname": "applicant_type", + "fieldtype": "Select", + "label": "Applicant Type", + "options": "Employee\nMember\nCustomer", + "read_only": 1 + }, + { + "fetch_from": "loan.applicant", + "fieldname": "applicant", + "fieldtype": "Dynamic Link", + "label": "Applicant ", + "options": "applicant_type", + "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": "section_break_9", + "fieldtype": "Section Break", + "label": "Write Off Details" + }, + { + "fieldname": "write_off_account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Write Off Account", + "options": "Account", + "reqd": 1 + }, + { + "fieldname": "write_off_amount", + "fieldtype": "Currency", + "label": "Write Off Amount", + "options": "Company:company:default_currency", + "reqd": 1 + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Loan Write Off", + "print_hide": 1, + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2020-10-26 07:13:43.663924", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Loan Write Off", + "owner": "Administrator", + "permissions": [ + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Loan Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py new file mode 100644 index 00000000000..54a3f2cbb1f --- /dev/null +++ b/erpnext/loan_management/doctype/loan_write_off/loan_write_off.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe, erpnext +from frappe import _ +from frappe.utils import getdate, flt, cint +from erpnext.controllers.accounts_controller import AccountsController +from erpnext.accounts.general_ledger import make_gl_entries + +class LoanWriteOff(AccountsController): + def validate(self): + self.set_missing_values() + self.validate_write_off_amount() + + def set_missing_values(self): + if not self.cost_center: + self.cost_center = erpnext.get_default_cost_center(self.company) + + def validate_write_off_amount(self): + precision = cint(frappe.db.get_default("currency_precision")) or 2 + total_payment, principal_paid, interest_payable, written_off_amount = frappe.get_value("Loan", self.loan, + ['total_payment', 'total_principal_paid','total_interest_payable', 'written_off_amount']) + + pending_principal_amount = flt(flt(total_payment) - flt(interest_payable) - flt(principal_paid) - flt(written_off_amount), + precision) + + if self.write_off_amount > pending_principal_amount: + frappe.throw(_("Write off amount cannot be greater than pending principal amount")) + + def on_submit(self): + self.update_outstanding_amount() + self.make_gl_entries() + + def on_cancel(self): + self.update_outstanding_amount(cancel=1) + self.ignore_linked_doctypes = ['GL Entry'] + self.make_gl_entries(cancel=1) + + def update_outstanding_amount(self, cancel=0): + written_off_amount = frappe.db.get_value('Loan', self.loan, 'written_off_amount') + + if cancel: + written_off_amount -= self.write_off_amount + else: + written_off_amount += self.write_off_amount + + frappe.db.set_value('Loan', self.loan, 'written_off_amount', written_off_amount) + + + def make_gl_entries(self, cancel=0): + gl_entries = [] + loan_details = frappe.get_doc("Loan", self.loan) + + gl_entries.append( + self.get_gl_dict({ + "account": self.write_off_account, + "against": loan_details.loan_account, + "debit": self.write_off_amount, + "debit_in_account_currency": self.write_off_amount, + "against_voucher_type": "Loan", + "against_voucher": self.loan, + "remarks": _("Against Loan:") + self.loan, + "cost_center": self.cost_center, + "posting_date": getdate(self.posting_date) + }) + ) + + gl_entries.append( + self.get_gl_dict({ + "account": loan_details.loan_account, + "party_type": loan_details.applicant_type, + "party": loan_details.applicant, + "against": self.write_off_account, + "credit": self.write_off_amount, + "credit_in_account_currency": self.write_off_amount, + "against_voucher_type": "Loan", + "against_voucher": self.loan, + "remarks": _("Against Loan:") + self.loan, + "cost_center": self.cost_center, + "posting_date": getdate(self.posting_date) + }) + ) + + make_gl_entries(gl_entries, cancel=cancel, merge_entries=False) + + diff --git a/erpnext/non_profit/doctype/membership_settings/test_membership_settings.py b/erpnext/loan_management/doctype/loan_write_off/test_loan_write_off.py similarity index 79% rename from erpnext/non_profit/doctype/membership_settings/test_membership_settings.py rename to erpnext/loan_management/doctype/loan_write_off/test_loan_write_off.py index 2ad7984583d..9f6700e2749 100644 --- a/erpnext/non_profit/doctype/membership_settings/test_membership_settings.py +++ b/erpnext/loan_management/doctype/loan_write_off/test_loan_write_off.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe import unittest -class TestMembershipSettings(unittest.TestCase): +class TestLoanWriteOff(unittest.TestCase): pass diff --git a/erpnext/loan_management/doctype/pledge/pledge.json b/erpnext/loan_management/doctype/pledge/pledge.json index f22a21e3be6..c23479c8251 100644 --- a/erpnext/loan_management/doctype/pledge/pledge.json +++ b/erpnext/loan_management/doctype/pledge/pledge.json @@ -1,10 +1,12 @@ { + "actions": [], "creation": "2019-09-09 17:06:16.756573", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "loan_security", + "loan_security_name", "loan_security_type", "loan_security_code", "uom", @@ -49,7 +51,8 @@ "fieldname": "qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Quantity" + "label": "Quantity", + "non_negative": 1 }, { "fieldname": "loan_security_price", @@ -83,10 +86,18 @@ "label": "Post Haircut Amount", "options": "Company:company:default_currency", "read_only": 1 + }, + { + "fetch_from": "loan_security.loan_security_name", + "fieldname": "loan_security_name", + "fieldtype": "Data", + "label": "Loan Security Name", + "read_only": 1 } ], "istable": 1, - "modified": "2019-12-03 10:59:58.001421", + "links": [], + "modified": "2021-01-17 07:41:12.452514", "modified_by": "Administrator", "module": "Loan Management", "name": "Pledge", diff --git a/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.json b/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.json index 0ef098f278f..828df2e35f7 100644 --- a/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.json +++ b/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.json @@ -10,6 +10,7 @@ "loan_type", "loan", "process_type", + "accrual_type", "amended_from" ], "fields": [ @@ -47,17 +48,27 @@ "hidden": 1, "label": "Process Type", "read_only": 1 + }, + { + "fieldname": "accrual_type", + "fieldtype": "Select", + "hidden": 1, + "label": "Accrual Type", + "options": "Regular\nRepayment\nDisbursement", + "read_only": 1 } ], + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-04-09 22:52:53.911416", + "modified": "2020-11-06 13:28:51.478909", "modified_by": "Administrator", "module": "Loan Management", "name": "Process Loan Interest Accrual", "owner": "Administrator", "permissions": [ { + "cancel": 1, "create": 1, "delete": 1, "email": 1, @@ -67,9 +78,11 @@ "report": 1, "role": "System Manager", "share": 1, + "submit": 1, "write": 1 }, { + "cancel": 1, "create": 1, "delete": 1, "email": 1, @@ -79,6 +92,7 @@ "report": 1, "role": "Loan Manager", "share": 1, + "submit": 1, "write": 1 } ], diff --git a/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py b/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py index 0fa96860d08..11333dc2aaf 100644 --- a/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py +++ b/erpnext/loan_management/doctype/process_loan_interest_accrual/process_loan_interest_accrual.py @@ -20,19 +20,20 @@ class ProcessLoanInterestAccrual(Document): if (not self.loan or not loan_doc.is_term_loan) and self.process_type != 'Term Loans': make_accrual_interest_entry_for_demand_loans(self.posting_date, self.name, - open_loans = open_loans, loan_type = self.loan_type) + open_loans = open_loans, loan_type = self.loan_type, accrual_type=self.accrual_type) if (not self.loan or loan_doc.is_term_loan) and self.process_type != 'Demand Loans': make_accrual_interest_entry_for_term_loans(self.posting_date, self.name, term_loan=self.loan, - loan_type=self.loan_type) + loan_type=self.loan_type, accrual_type=self.accrual_type) -def process_loan_interest_accrual_for_demand_loans(posting_date=None, loan_type=None, loan=None): +def process_loan_interest_accrual_for_demand_loans(posting_date=None, loan_type=None, loan=None, accrual_type="Regular"): loan_process = frappe.new_doc('Process Loan Interest Accrual') loan_process.posting_date = posting_date or nowdate() loan_process.loan_type = loan_type loan_process.process_type = 'Demand Loans' loan_process.loan = loan + loan_process.accrual_type = accrual_type loan_process.submit() diff --git a/erpnext/loan_management/doctype/process_loan_security_shortfall/process_loan_security_shortfall.json b/erpnext/loan_management/doctype/process_loan_security_shortfall/process_loan_security_shortfall.json index ffc36711324..3feb3055a6a 100644 --- a/erpnext/loan_management/doctype/process_loan_security_shortfall/process_loan_security_shortfall.json +++ b/erpnext/loan_management/doctype/process_loan_security_shortfall/process_loan_security_shortfall.json @@ -30,7 +30,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2020-02-01 08:14:05.845161", + "modified": "2021-01-17 03:59:14.494557", "modified_by": "Administrator", "module": "Loan Management", "name": "Process Loan Security Shortfall", @@ -45,7 +45,9 @@ "read": 1, "report": 1, "role": "System Manager", + "select": 1, "share": 1, + "submit": 1, "write": 1 }, { @@ -57,7 +59,9 @@ "read": 1, "report": 1, "role": "Loan Manager", + "select": 1, "share": 1, + "submit": 1, "write": 1 } ], diff --git a/erpnext/loan_management/doctype/proposed_pledge/proposed_pledge.json b/erpnext/loan_management/doctype/proposed_pledge/proposed_pledge.json index aee7c2ced5f..a0b3a79b56c 100644 --- a/erpnext/loan_management/doctype/proposed_pledge/proposed_pledge.json +++ b/erpnext/loan_management/doctype/proposed_pledge/proposed_pledge.json @@ -1,10 +1,12 @@ { + "actions": [], "creation": "2019-08-29 22:29:37.628178", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "loan_security", + "loan_security_name", "qty", "loan_security_price", "amount", @@ -39,7 +41,8 @@ "fieldname": "qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Quantity" + "label": "Quantity", + "non_negative": 1 }, { "fieldname": "loan_security", @@ -54,10 +57,19 @@ "label": "Post Haircut Amount", "options": "Company:company:default_currency", "read_only": 1 + }, + { + "fetch_from": "loan_security.loan_security_name", + "fieldname": "loan_security_name", + "fieldtype": "Data", + "label": "Loan Security Name", + "read_only": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2019-12-02 10:23:11.498308", + "links": [], + "modified": "2021-01-17 07:29:01.671722", "modified_by": "Administrator", "module": "Loan Management", "name": "Proposed Pledge", diff --git a/erpnext/loan_management/doctype/unpledge/unpledge.json b/erpnext/loan_management/doctype/unpledge/unpledge.json index ee192d7377d..0091e6c43d4 100644 --- a/erpnext/loan_management/doctype/unpledge/unpledge.json +++ b/erpnext/loan_management/doctype/unpledge/unpledge.json @@ -6,6 +6,7 @@ "engine": "InnoDB", "field_order": [ "loan_security", + "loan_security_name", "loan_security_type", "loan_security_code", "haircut", @@ -52,6 +53,7 @@ "fieldtype": "Float", "in_list_view": 1, "label": "Quantity", + "non_negative": 1, "reqd": 1 }, { @@ -60,11 +62,19 @@ "fieldtype": "Percent", "label": "Haircut", "read_only": 1 + }, + { + "fetch_from": "loan_security.loan_security_name", + "fieldname": "loan_security_name", + "fieldtype": "Data", + "label": "Loan Security Name", + "read_only": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-05-06 10:50:18.448552", + "modified": "2021-01-17 07:36:20.212342", "modified_by": "Administrator", "module": "Loan Management", "name": "Unpledge", diff --git a/erpnext/loan_management/loan_common.js b/erpnext/loan_management/loan_common.js index d9dd415296e..50b68da30e3 100644 --- a/erpnext/loan_management/loan_common.js +++ b/erpnext/loan_management/loan_common.js @@ -8,14 +8,14 @@ frappe.ui.form.on(cur_frm.doctype, { frm.refresh_field('applicant_type'); } - if (['Loan Disbursement', 'Loan Repayment', 'Loan Interest Accrual'].includes(frm.doc.doctype) + if (['Loan Disbursement', 'Loan Repayment', 'Loan Interest Accrual', 'Loan Write Off'].includes(frm.doc.doctype) && frm.doc.docstatus > 0) { frm.add_custom_button(__("Accounting Ledger"), function() { frappe.route_options = { voucher_no: frm.doc.name, company: frm.doc.company, - from_date: frm.doc.posting_date, + from_date: moment(frm.doc.posting_date).format('YYYY-MM-DD'), to_date: moment(frm.doc.modified).format('YYYY-MM-DD'), show_cancelled_entries: frm.doc.docstatus === 2 }; diff --git a/erpnext/loan_management/loan_management_dashboard/loan_dashboard/loan_dashboard.json b/erpnext/loan_management/loan_management_dashboard/loan_dashboard/loan_dashboard.json new file mode 100644 index 00000000000..e060253d34c --- /dev/null +++ b/erpnext/loan_management/loan_management_dashboard/loan_dashboard/loan_dashboard.json @@ -0,0 +1,70 @@ +{ + "cards": [ + { + "card": "New Loans" + }, + { + "card": "Active Loans" + }, + { + "card": "Closed Loans" + }, + { + "card": "Total Disbursed" + }, + { + "card": "Open Loan Applications" + }, + { + "card": "New Loan Applications" + }, + { + "card": "Total Sanctioned Amount" + }, + { + "card": "Active Securities" + }, + { + "card": "Applicants With Unpaid Shortfall" + }, + { + "card": "Total Shortfall Amount" + }, + { + "card": "Total Repayment" + }, + { + "card": "Total Write Off" + } + ], + "charts": [ + { + "chart": "New Loans", + "width": "Half" + }, + { + "chart": "Loan Disbursements", + "width": "Half" + }, + { + "chart": "Top 10 Pledged Loan Securities", + "width": "Half" + }, + { + "chart": "Loan Interest Accrual", + "width": "Half" + } + ], + "creation": "2021-02-06 16:52:43.484752", + "dashboard_name": "Loan Dashboard", + "docstatus": 0, + "doctype": "Dashboard", + "idx": 0, + "is_default": 0, + "is_standard": 1, + "modified": "2021-02-21 20:53:47.531699", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Loan Dashboard", + "owner": "Administrator" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/active_loans/active_loans.json b/erpnext/loan_management/number_card/active_loans/active_loans.json new file mode 100644 index 00000000000..7e0db472882 --- /dev/null +++ b/erpnext/loan_management/number_card/active_loans/active_loans.json @@ -0,0 +1,23 @@ +{ + "aggregate_function_based_on": "", + "creation": "2021-02-06 17:10:26.132493", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan\",\"docstatus\",\"=\",\"1\",false],[\"Loan\",\"status\",\"in\",[\"Disbursed\",\"Partially Disbursed\",null],false]]", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Active Loans", + "modified": "2021-02-06 17:29:20.304087", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Active Loans", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Monthly", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/active_securities/active_securities.json b/erpnext/loan_management/number_card/active_securities/active_securities.json new file mode 100644 index 00000000000..298e41061a8 --- /dev/null +++ b/erpnext/loan_management/number_card/active_securities/active_securities.json @@ -0,0 +1,23 @@ +{ + "aggregate_function_based_on": "", + "creation": "2021-02-06 19:07:21.344199", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan Security", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan Security\",\"disabled\",\"=\",0,false]]", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Active Securities", + "modified": "2021-02-06 19:07:26.671516", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Active Securities", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/applicants_with_unpaid_shortfall/applicants_with_unpaid_shortfall.json b/erpnext/loan_management/number_card/applicants_with_unpaid_shortfall/applicants_with_unpaid_shortfall.json new file mode 100644 index 00000000000..3b9eba15536 --- /dev/null +++ b/erpnext/loan_management/number_card/applicants_with_unpaid_shortfall/applicants_with_unpaid_shortfall.json @@ -0,0 +1,21 @@ +{ + "creation": "2021-02-07 18:55:12.632616", + "docstatus": 0, + "doctype": "Number Card", + "filters_json": "null", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Applicants With Unpaid Shortfall", + "method": "erpnext.loan_management.doctype.loan.loan.get_shortfall_applicants", + "modified": "2021-02-07 21:46:27.369795", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Applicants With Unpaid Shortfall", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Custom" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/closed_loans/closed_loans.json b/erpnext/loan_management/number_card/closed_loans/closed_loans.json new file mode 100644 index 00000000000..c2f22442653 --- /dev/null +++ b/erpnext/loan_management/number_card/closed_loans/closed_loans.json @@ -0,0 +1,23 @@ +{ + "aggregate_function_based_on": "", + "creation": "2021-02-21 19:51:49.261813", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan\",\"docstatus\",\"=\",\"1\",false],[\"Loan\",\"status\",\"=\",\"Closed\",false]]", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Closed Loans", + "modified": "2021-02-21 19:51:54.087903", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Closed Loans", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/last_interest_accrual/last_interest_accrual.json b/erpnext/loan_management/number_card/last_interest_accrual/last_interest_accrual.json new file mode 100644 index 00000000000..65c8ce67d21 --- /dev/null +++ b/erpnext/loan_management/number_card/last_interest_accrual/last_interest_accrual.json @@ -0,0 +1,21 @@ +{ + "creation": "2021-02-07 21:57:14.758007", + "docstatus": 0, + "doctype": "Number Card", + "filters_json": "null", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Last Interest Accrual", + "method": "erpnext.loan_management.doctype.loan.loan.get_last_accrual_date", + "modified": "2021-02-07 21:59:47.525197", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Last Interest Accrual", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Custom" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/new_loan_applications/new_loan_applications.json b/erpnext/loan_management/number_card/new_loan_applications/new_loan_applications.json new file mode 100644 index 00000000000..7e655ff35c7 --- /dev/null +++ b/erpnext/loan_management/number_card/new_loan_applications/new_loan_applications.json @@ -0,0 +1,23 @@ +{ + "aggregate_function_based_on": "", + "creation": "2021-02-06 17:59:10.051269", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan Application", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan Application\",\"docstatus\",\"=\",\"1\",false],[\"Loan Application\",\"creation\",\"Timespan\",\"today\",false]]", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "New Loan Applications", + "modified": "2021-02-06 17:59:21.880979", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "New Loan Applications", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/new_loans/new_loans.json b/erpnext/loan_management/number_card/new_loans/new_loans.json new file mode 100644 index 00000000000..424f0f14958 --- /dev/null +++ b/erpnext/loan_management/number_card/new_loans/new_loans.json @@ -0,0 +1,23 @@ +{ + "aggregate_function_based_on": "", + "creation": "2021-02-06 17:56:34.624031", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan\",\"docstatus\",\"=\",\"1\",false],[\"Loan\",\"creation\",\"Timespan\",\"today\",false]]", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "New Loans", + "modified": "2021-02-06 17:58:20.209166", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "New Loans", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/open_loan_applications/open_loan_applications.json b/erpnext/loan_management/number_card/open_loan_applications/open_loan_applications.json new file mode 100644 index 00000000000..1d5e84ed7f0 --- /dev/null +++ b/erpnext/loan_management/number_card/open_loan_applications/open_loan_applications.json @@ -0,0 +1,23 @@ +{ + "aggregate_function_based_on": "", + "creation": "2021-02-06 17:23:32.509899", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan Application", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan Application\",\"docstatus\",\"=\",\"1\",false],[\"Loan Application\",\"status\",\"=\",\"Open\",false]]", + "function": "Count", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Open Loan Applications", + "modified": "2021-02-06 17:29:09.761011", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Open Loan Applications", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Monthly", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/total_disbursed/total_disbursed.json b/erpnext/loan_management/number_card/total_disbursed/total_disbursed.json new file mode 100644 index 00000000000..4a3f8699a04 --- /dev/null +++ b/erpnext/loan_management/number_card/total_disbursed/total_disbursed.json @@ -0,0 +1,23 @@ +{ + "aggregate_function_based_on": "disbursed_amount", + "creation": "2021-02-06 16:52:19.505462", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan Disbursement", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan Disbursement\",\"docstatus\",\"=\",\"1\",false]]", + "function": "Sum", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Total Disbursed Amount", + "modified": "2021-02-06 17:29:38.453870", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Total Disbursed", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Monthly", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/total_repayment/total_repayment.json b/erpnext/loan_management/number_card/total_repayment/total_repayment.json new file mode 100644 index 00000000000..38de42b89c8 --- /dev/null +++ b/erpnext/loan_management/number_card/total_repayment/total_repayment.json @@ -0,0 +1,24 @@ +{ + "aggregate_function_based_on": "amount_paid", + "color": "#29CD42", + "creation": "2021-02-21 19:27:45.989222", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan Repayment", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan Repayment\",\"docstatus\",\"=\",\"1\",false]]", + "function": "Sum", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Total Repayment", + "modified": "2021-02-21 19:34:59.656546", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Total Repayment", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/total_sanctioned_amount/total_sanctioned_amount.json b/erpnext/loan_management/number_card/total_sanctioned_amount/total_sanctioned_amount.json new file mode 100644 index 00000000000..dfb9d24e925 --- /dev/null +++ b/erpnext/loan_management/number_card/total_sanctioned_amount/total_sanctioned_amount.json @@ -0,0 +1,23 @@ +{ + "aggregate_function_based_on": "loan_amount", + "creation": "2021-02-06 17:05:04.704162", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan\",\"docstatus\",\"=\",\"1\",false],[\"Loan\",\"status\",\"=\",\"Sanctioned\",false]]", + "function": "Sum", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Total Sanctioned Amount", + "modified": "2021-02-06 17:29:29.930557", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Total Sanctioned Amount", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Monthly", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/total_shortfall_amount/total_shortfall_amount.json b/erpnext/loan_management/number_card/total_shortfall_amount/total_shortfall_amount.json new file mode 100644 index 00000000000..aa6b0937323 --- /dev/null +++ b/erpnext/loan_management/number_card/total_shortfall_amount/total_shortfall_amount.json @@ -0,0 +1,23 @@ +{ + "aggregate_function_based_on": "shortfall_amount", + "creation": "2021-02-09 08:07:20.096995", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan Security Shortfall", + "dynamic_filters_json": "[]", + "filters_json": "[]", + "function": "Sum", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Total Unpaid Shortfall Amount", + "modified": "2021-02-09 08:09:00.355547", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Total Shortfall Amount", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/number_card/total_write_off/total_write_off.json b/erpnext/loan_management/number_card/total_write_off/total_write_off.json new file mode 100644 index 00000000000..c85169acf8d --- /dev/null +++ b/erpnext/loan_management/number_card/total_write_off/total_write_off.json @@ -0,0 +1,24 @@ +{ + "aggregate_function_based_on": "write_off_amount", + "color": "#CB2929", + "creation": "2021-02-21 19:48:29.004429", + "docstatus": 0, + "doctype": "Number Card", + "document_type": "Loan Write Off", + "dynamic_filters_json": "[]", + "filters_json": "[[\"Loan Write Off\",\"docstatus\",\"=\",\"1\",false]]", + "function": "Sum", + "idx": 0, + "is_public": 0, + "is_standard": 1, + "label": "Total Write Off", + "modified": "2021-02-21 19:48:58.604159", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Total Write Off", + "owner": "Administrator", + "report_function": "Sum", + "show_percentage_stats": 1, + "stats_time_interval": "Daily", + "type": "Document Type" +} \ No newline at end of file diff --git a/erpnext/loan_management/report/applicant_wise_loan_security_exposure/__init__.py b/erpnext/loan_management/report/applicant_wise_loan_security_exposure/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/loan_management/report/applicant_wise_loan_security_exposure/applicant_wise_loan_security_exposure.js b/erpnext/loan_management/report/applicant_wise_loan_security_exposure/applicant_wise_loan_security_exposure.js new file mode 100644 index 00000000000..73d60c40458 --- /dev/null +++ b/erpnext/loan_management/report/applicant_wise_loan_security_exposure/applicant_wise_loan_security_exposure.js @@ -0,0 +1,16 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Applicant-Wise Loan Security Exposure"] = { + "filters": [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + } + ] +}; diff --git a/erpnext/loan_management/report/applicant_wise_loan_security_exposure/applicant_wise_loan_security_exposure.json b/erpnext/loan_management/report/applicant_wise_loan_security_exposure/applicant_wise_loan_security_exposure.json new file mode 100644 index 00000000000..a778cd7055d --- /dev/null +++ b/erpnext/loan_management/report/applicant_wise_loan_security_exposure/applicant_wise_loan_security_exposure.json @@ -0,0 +1,29 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-01-15 23:48:38.913514", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-01-15 23:48:38.913514", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Applicant-Wise Loan Security Exposure", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Loan Security", + "report_name": "Applicant-Wise Loan Security Exposure", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Loan Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/loan_management/report/applicant_wise_loan_security_exposure/applicant_wise_loan_security_exposure.py b/erpnext/loan_management/report/applicant_wise_loan_security_exposure/applicant_wise_loan_security_exposure.py new file mode 100644 index 00000000000..0ccd149e5fb --- /dev/null +++ b/erpnext/loan_management/report/applicant_wise_loan_security_exposure/applicant_wise_loan_security_exposure.py @@ -0,0 +1,139 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import erpnext +from frappe import _ +from frappe.utils import get_datetime, flt +from six import iteritems + +def execute(filters=None): + columns = get_columns(filters) + data = get_data(filters) + return columns, data + + +def get_columns(filters): + columns = [ + {"label": _("Applicant Type"), "fieldname": "applicant_type", "options": "DocType", "width": 100}, + {"label": _("Applicant Name"), "fieldname": "applicant_name", "fieldtype": "Dynamic Link", "options": "applicant_type", "width": 150}, + {"label": _("Loan Security"), "fieldname": "loan_security", "fieldtype": "Link", "options": "Loan Security", "width": 160}, + {"label": _("Loan Security Code"), "fieldname": "loan_security_code", "fieldtype": "Data", "width": 100}, + {"label": _("Loan Security Name"), "fieldname": "loan_security_name", "fieldtype": "Data", "width": 150}, + {"label": _("Haircut"), "fieldname": "haircut", "fieldtype": "Percent", "width": 100}, + {"label": _("Loan Security Type"), "fieldname": "loan_security_type", "fieldtype": "Link", "options": "Loan Security Type", "width": 120}, + {"label": _("Disabled"), "fieldname": "disabled", "fieldtype": "Check", "width": 80}, + {"label": _("Total Qty"), "fieldname": "total_qty", "fieldtype": "Float", "width": 100}, + {"label": _("Latest Price"), "fieldname": "latest_price", "fieldtype": "Currency", "options": "currency", "width": 100}, + {"label": _("Price Valid Upto"), "fieldname": "price_valid_upto", "fieldtype": "Datetime", "width": 100}, + {"label": _("Current Value"), "fieldname": "current_value", "fieldtype": "Currency", "options": "currency", "width": 100}, + {"label": _("% Of Applicant Portfolio"), "fieldname": "portfolio_percent", "fieldtype": "Percentage", "width": 100}, + {"label": _("Currency"), "fieldname": "currency", "fieldtype": "Currency", "options": "Currency", "hidden": 1, "width": 100}, + ] + + return columns + +def get_data(filters): + data = [] + loan_security_details = get_loan_security_details() + pledge_values, total_value_map, applicant_type_map = get_applicant_wise_total_loan_security_qty(filters, + loan_security_details) + + currency = erpnext.get_company_currency(filters.get('company')) + + for key, qty in iteritems(pledge_values): + if qty: + row = {} + current_value = flt(qty * loan_security_details.get(key[1], {}).get('latest_price', 0)) + valid_upto = loan_security_details.get(key[1], {}).get('valid_upto') + + row.update(loan_security_details.get(key[1])) + row.update({ + 'applicant_type': applicant_type_map.get(key[0]), + 'applicant_name': key[0], + 'total_qty': qty, + 'current_value': current_value, + 'price_valid_upto': valid_upto, + 'portfolio_percent': flt(current_value * 100 / total_value_map.get(key[0]), 2) if total_value_map.get(key[0]) \ + else 0.0, + 'currency': currency + }) + + data.append(row) + + return data + +def get_loan_security_details(): + security_detail_map = {} + loan_security_price_map = {} + lsp_validity_map = {} + + loan_security_prices = frappe.db.sql(""" + SELECT loan_security, loan_security_price, valid_upto + FROM `tabLoan Security Price` t1 + WHERE valid_from >= (SELECT MAX(valid_from) FROM `tabLoan Security Price` t2 + WHERE t1.loan_security = t2.loan_security) + """, as_dict=1) + + for security in loan_security_prices: + loan_security_price_map.setdefault(security.loan_security, security.loan_security_price) + lsp_validity_map.setdefault(security.loan_security, security.valid_upto) + + loan_security_details = frappe.get_all('Loan Security', fields=['name as loan_security', + 'loan_security_code', 'loan_security_name', 'haircut', 'loan_security_type', + 'disabled']) + + for security in loan_security_details: + security.update({ + 'latest_price': flt(loan_security_price_map.get(security.loan_security)), + 'valid_upto': lsp_validity_map.get(security.loan_security) + }) + + security_detail_map.setdefault(security.loan_security, security) + + return security_detail_map + +def get_applicant_wise_total_loan_security_qty(filters, loan_security_details): + current_pledges = {} + total_value_map = {} + applicant_type_map = {} + applicant_wise_unpledges = {} + conditions = "" + + if filters.get('company'): + conditions = "AND company = %(company)s" + + unpledges = frappe.db.sql(""" + SELECT up.applicant, u.loan_security, sum(u.qty) as qty + FROM `tabLoan Security Unpledge` up, `tabUnpledge` u + WHERE u.parent = up.name + AND up.status = 'Approved' + {conditions} + GROUP BY up.applicant, u.loan_security + """.format(conditions=conditions), filters, as_dict=1) + + for unpledge in unpledges: + applicant_wise_unpledges.setdefault((unpledge.applicant, unpledge.loan_security), unpledge.qty) + + pledges = frappe.db.sql(""" + SELECT lp.applicant_type, lp.applicant, p.loan_security, sum(p.qty) as qty + FROM `tabLoan Security Pledge` lp, `tabPledge`p + WHERE p.parent = lp.name + AND lp.status = 'Pledged' + {conditions} + GROUP BY lp.applicant, p.loan_security + """.format(conditions=conditions), filters, as_dict=1) + + for security in pledges: + current_pledges.setdefault((security.applicant, security.loan_security), security.qty) + total_value_map.setdefault(security.applicant, 0.0) + applicant_type_map.setdefault(security.applicant, security.applicant_type) + + current_pledges[(security.applicant, security.loan_security)] -= \ + applicant_wise_unpledges.get((security.applicant, security.loan_security), 0.0) + + total_value_map[security.applicant] += current_pledges.get((security.applicant, security.loan_security)) \ + * loan_security_details.get(security.loan_security, {}).get('latest_price', 0) + + return current_pledges, total_value_map, applicant_type_map \ No newline at end of file diff --git a/erpnext/loan_management/report/loan_interest_report/__init__.py b/erpnext/loan_management/report/loan_interest_report/__init__.py new file mode 100644 index 00000000000..e69de29bb2d 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 new file mode 100644 index 00000000000..a227b6d7973 --- /dev/null +++ b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.js @@ -0,0 +1,16 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Loan Interest Report"] = { + "filters": [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + } + ] +}; diff --git a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.json b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.json new file mode 100644 index 00000000000..321d6064e33 --- /dev/null +++ b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.json @@ -0,0 +1,29 @@ +{ + "add_total_row": 1, + "columns": [], + "creation": "2021-01-10 02:03:26.742693", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-01-10 02:03:26.742693", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Loan Interest Report", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Loan Interest Accrual", + "report_name": "Loan Interest Report", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Loan Manager" + } + ] +} \ No newline at end of file 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 new file mode 100644 index 00000000000..0f72c3cce7c --- /dev/null +++ b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py @@ -0,0 +1,183 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import erpnext +from frappe import _ +from frappe.utils import flt, getdate, add_days +from erpnext.loan_management.report.applicant_wise_loan_security_exposure.applicant_wise_loan_security_exposure \ + import get_loan_security_details + + +def execute(filters=None): + columns = get_columns(filters) + data = get_active_loan_details(filters) + return columns, data + +def get_columns(filters): + columns = [ + {"label": _("Loan"), "fieldname": "loan", "fieldtype": "Link", "options": "Loan", "width": 160}, + {"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 160}, + {"label": _("Applicant Type"), "fieldname": "applicant_type", "options": "DocType", "width": 100}, + {"label": _("Applicant Name"), "fieldname": "applicant_name", "fieldtype": "Dynamic Link", "options": "applicant_type", "width": 150}, + {"label": _("Loan Type"), "fieldname": "loan_type", "fieldtype": "Link", "options": "Loan Type", "width": 100}, + {"label": _("Sanctioned Amount"), "fieldname": "sanctioned_amount", "fieldtype": "Currency", "options": "currency", "width": 120}, + {"label": _("Disbursed Amount"), "fieldname": "disbursed_amount", "fieldtype": "Currency", "options": "currency", "width": 120}, + {"label": _("Penalty Amount"), "fieldname": "penalty", "fieldtype": "Currency", "options": "currency", "width": 120}, + {"label": _("Accrued Interest"), "fieldname": "accrued_interest", "fieldtype": "Currency", "options": "currency", "width": 120}, + {"label": _("Total Repayment"), "fieldname": "total_repayment", "fieldtype": "Currency", "options": "currency", "width": 120}, + {"label": _("Principal Outstanding"), "fieldname": "principal_outstanding", "fieldtype": "Currency", "options": "currency", "width": 120}, + {"label": _("Interest Outstanding"), "fieldname": "interest_outstanding", "fieldtype": "Currency", "options": "currency", "width": 120}, + {"label": _("Total Outstanding"), "fieldname": "total_outstanding", "fieldtype": "Currency", "options": "currency", "width": 120}, + {"label": _("Undue Booked Interest"), "fieldname": "undue_interest", "fieldtype": "Currency", "options": "currency", "width": 120}, + {"label": _("Interest %"), "fieldname": "rate_of_interest", "fieldtype": "Percent", "width": 100}, + {"label": _("Penalty Interest %"), "fieldname": "penalty_interest", "fieldtype": "Percent", "width": 100}, + {"label": _("Loan To Value Ratio"), "fieldname": "loan_to_value", "fieldtype": "Percent", "width": 100}, + {"label": _("Currency"), "fieldname": "currency", "fieldtype": "Currency", "options": "Currency", "hidden": 1, "width": 100}, + ] + + return columns + +def get_active_loan_details(filters): + + filter_obj = {"status": ("!=", "Closed")} + if filters.get('company'): + filter_obj.update({'company': filters.get('company')}) + + loan_details = frappe.get_all("Loan", + fields=["name as loan", "applicant_type", "applicant as applicant_name", "loan_type", + "disbursed_amount", "rate_of_interest", "total_payment", "total_principal_paid", + "total_interest_payable", "written_off_amount", "status"], + filters=filter_obj) + + loan_list = [d.loan for d in loan_details] + + current_pledges = get_loan_wise_pledges(filters) + loan_wise_security_value = get_loan_wise_security_value(filters, current_pledges) + + 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) + currency = erpnext.get_company_currency(filters.get('company')) + + for loan in loan_details: + loan.update({ + "sanctioned_amount": flt(sanctioned_amount_map.get(loan.applicant_name)), + "principal_outstanding": flt(loan.total_payment) - flt(loan.total_principal_paid) \ + - flt(loan.total_interest_payable) - flt(loan.written_off_amount), + "total_repayment": flt(payments.get(loan.loan)), + "accrued_interest": flt(accrual_map.get(loan.loan, {}).get("accrued_interest")), + "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), + "undue_interest": flt(accrual_map.get(loan.loan, {}).get("undue_interest")), + "loan_to_value": 0.0, + "currency": currency + }) + + loan['total_outstanding'] = loan['principal_outstanding'] + loan['interest_outstanding'] \ + + loan['penalty'] + + if loan_wise_security_value.get(loan.loan): + loan['loan_to_value'] = flt((loan['principal_outstanding'] * 100) / loan_wise_security_value.get(loan.loan)) + + return loan_details + +def get_sanctioned_amount_map(): + return frappe._dict(frappe.get_all("Sanctioned Loan Amount", fields=["applicant", "sanctioned_amount_limit"], + as_list=1)) + +def get_payments(loans): + return frappe._dict(frappe.get_all("Loan Repayment", fields=["against_loan", "sum(amount_paid)"], + filters={"against_loan": ("in", loans)}, group_by="against_loan", as_list=1)) + +def get_interest_accruals(loans): + accrual_map = {} + + interest_accruals = frappe.get_all("Loan Interest Accrual", + fields=["loan", "interest_amount", "posting_date", "penalty_amount", + "paid_interest_amount", "accrual_type"], filters={"loan": ("in", loans)}, order_by="posting_date desc") + + for entry in interest_accruals: + accrual_map.setdefault(entry.loan, { + "accrued_interest": 0.0, + "undue_interest": 0.0, + "interest_outstanding": 0.0, + "last_accrual_date": '', + "due_date": '' + }) + + if entry.accrual_type == 'Regular': + if not accrual_map[entry.loan]['due_date']: + accrual_map[entry.loan]['due_date'] = add_days(entry.posting_date, 1) + if not accrual_map[entry.loan]['last_accrual_date']: + accrual_map[entry.loan]['last_accrual_date'] = entry.posting_date + + due_date = accrual_map[entry.loan]['due_date'] + last_accrual_date = accrual_map[entry.loan]['last_accrual_date'] + + if due_date and getdate(entry.posting_date) < getdate(due_date): + accrual_map[entry.loan]["interest_outstanding"] += entry.interest_amount - entry.paid_interest_amount + else: + accrual_map[entry.loan]['undue_interest'] += entry.interest_amount - entry.paid_interest_amount + + accrual_map[entry.loan]["accrued_interest"] += entry.interest_amount + + if last_accrual_date and getdate(entry.posting_date) == last_accrual_date: + accrual_map[entry.loan]["penalty"] = entry.penalty_amount + + return accrual_map + +def get_penal_interest_rate_map(): + return frappe._dict(frappe.get_all("Loan Type", fields=["name", "penalty_interest_rate"], as_list=1)) + +def get_loan_wise_pledges(filters): + loan_wise_unpledges = {} + current_pledges = {} + + conditions = "" + + if filters.get('company'): + conditions = "AND company = %(company)s" + + unpledges = frappe.db.sql(""" + SELECT up.loan, u.loan_security, sum(u.qty) as qty + FROM `tabLoan Security Unpledge` up, `tabUnpledge` u + WHERE u.parent = up.name + AND up.status = 'Approved' + {conditions} + GROUP BY up.loan, u.loan_security + """.format(conditions=conditions), filters, as_dict=1) + + for unpledge in unpledges: + loan_wise_unpledges.setdefault((unpledge.loan, unpledge.loan_security), unpledge.qty) + + pledges = frappe.db.sql(""" + SELECT lp.loan, p.loan_security, sum(p.qty) as qty + FROM `tabLoan Security Pledge` lp, `tabPledge`p + WHERE p.parent = lp.name + AND lp.status = 'Pledged' + {conditions} + GROUP BY lp.loan, p.loan_security + """.format(conditions=conditions), filters, as_dict=1) + + for security in pledges: + current_pledges.setdefault((security.loan, security.loan_security), security.qty) + current_pledges[(security.loan, security.loan_security)] -= \ + loan_wise_unpledges.get((security.loan, security.loan_security), 0.0) + + return current_pledges + +def get_loan_wise_security_value(filters, current_pledges): + loan_security_details = get_loan_security_details() + loan_wise_security_value = {} + + for key in current_pledges: + qty = current_pledges.get(key) + loan_wise_security_value.setdefault(key[0], 0.0) + loan_wise_security_value[key[0]] += \ + flt(qty * loan_security_details.get(key[1], {}).get('latest_price', 0)) + + return loan_wise_security_value \ No newline at end of file diff --git a/erpnext/loan_management/report/loan_repayment_and_closure/loan_repayment_and_closure.py b/erpnext/loan_management/report/loan_repayment_and_closure/loan_repayment_and_closure.py index b63cc8ed5ac..c6f6b990cc5 100644 --- a/erpnext/loan_management/report/loan_repayment_and_closure/loan_repayment_and_closure.py +++ b/erpnext/loan_management/report/loan_repayment_and_closure/loan_repayment_and_closure.py @@ -103,7 +103,7 @@ def get_data(filters): loan_repayments = frappe.get_all("Loan Repayment", filters = query_filters, - fields=["posting_date", "applicant", "name", "against_loan", "payment_type", "payable_amount", + fields=["posting_date", "applicant", "name", "against_loan", "payable_amount", "pending_principal_amount", "interest_payable", "penalty_amount", "amount_paid"] ) diff --git a/erpnext/loan_management/report/loan_security_exposure/__init__.py b/erpnext/loan_management/report/loan_security_exposure/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/loan_management/report/loan_security_exposure/loan_security_exposure.js b/erpnext/loan_management/report/loan_security_exposure/loan_security_exposure.js new file mode 100644 index 00000000000..777f29624a7 --- /dev/null +++ b/erpnext/loan_management/report/loan_security_exposure/loan_security_exposure.js @@ -0,0 +1,16 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Loan Security Exposure"] = { + "filters": [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + } + ] +}; diff --git a/erpnext/loan_management/report/loan_security_exposure/loan_security_exposure.json b/erpnext/loan_management/report/loan_security_exposure/loan_security_exposure.json new file mode 100644 index 00000000000..d4dca08212d --- /dev/null +++ b/erpnext/loan_management/report/loan_security_exposure/loan_security_exposure.json @@ -0,0 +1,29 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-01-16 08:08:01.694583", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-01-16 08:08:01.694583", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Loan Security Exposure", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Loan Security", + "report_name": "Loan Security Exposure", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Loan Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/loan_management/report/loan_security_exposure/loan_security_exposure.py b/erpnext/loan_management/report/loan_security_exposure/loan_security_exposure.py new file mode 100644 index 00000000000..887a86a46c5 --- /dev/null +++ b/erpnext/loan_management/report/loan_security_exposure/loan_security_exposure.py @@ -0,0 +1,84 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import erpnext +from frappe import _ +from frappe.utils import flt +from six import iteritems +from erpnext.loan_management.report.applicant_wise_loan_security_exposure.applicant_wise_loan_security_exposure \ + import get_loan_security_details, get_applicant_wise_total_loan_security_qty + +def execute(filters=None): + columns = get_columns(filters) + data = get_data(filters) + return columns, data + +def get_columns(filters): + columns = [ + {"label": _("Loan Security"), "fieldname": "loan_security", "fieldtype": "Link", "options": "Loan Security", "width": 160}, + {"label": _("Loan Security Code"), "fieldname": "loan_security_code", "fieldtype": "Data", "width": 100}, + {"label": _("Loan Security Name"), "fieldname": "loan_security_name", "fieldtype": "Data", "width": 150}, + {"label": _("Haircut"), "fieldname": "haircut", "fieldtype": "Percent", "width": 100}, + {"label": _("Loan Security Type"), "fieldname": "loan_security_type", "fieldtype": "Link", "options": "Loan Security Type", "width": 120}, + {"label": _("Disabled"), "fieldname": "disabled", "fieldtype": "Check", "width": 80}, + {"label": _("Total Qty"), "fieldname": "total_qty", "fieldtype": "Float", "width": 100}, + {"label": _("Latest Price"), "fieldname": "latest_price", "fieldtype": "Currency", "options": "currency", "width": 100}, + {"label": _("Price Valid Upto"), "fieldname": "price_valid_upto", "fieldtype": "Datetime", "width": 100}, + {"label": _("Current Value"), "fieldname": "current_value", "fieldtype": "Currency", "options": "currency", "width": 100}, + {"label": _("% Of Total Portfolio"), "fieldname": "portfolio_percent", "fieldtype": "Percentage", "width": 100}, + {"label": _("Pledged Applicant Count"), "fieldname": "pledged_applicant_count", "fieldtype": "Percentage", "width": 100}, + {"label": _("Currency"), "fieldname": "currency", "fieldtype": "Currency", "options": "Currency", "hidden": 1, "width": 100}, + ] + + return columns + +def get_data(filters): + data = [] + loan_security_details = get_loan_security_details() + current_pledges, total_portfolio_value = get_company_wise_loan_security_details(filters, loan_security_details) + currency = erpnext.get_company_currency(filters.get('company')) + + for security, value in iteritems(current_pledges): + if value.get('qty'): + row = {} + current_value = flt(value.get('qty', 0) * loan_security_details.get(security, {}).get('latest_price', 0)) + valid_upto = loan_security_details.get(security, {}).get('valid_upto') + + row.update(loan_security_details.get(security)) + row.update({ + 'total_qty': value.get('qty'), + 'current_value': current_value, + 'price_valid_upto': valid_upto, + 'portfolio_percent': flt(current_value * 100 / total_portfolio_value, 2), + 'pledged_applicant_count': value.get('applicant_count'), + 'currency': currency + }) + + data.append(row) + + return data + + +def get_company_wise_loan_security_details(filters, loan_security_details): + pledge_values, total_value_map, applicant_type_map = get_applicant_wise_total_loan_security_qty(filters, + loan_security_details) + + total_portfolio_value = 0 + security_wise_map = {} + for key, qty in iteritems(pledge_values): + security_wise_map.setdefault(key[1], { + 'qty': 0.0, + 'applicant_count': 0.0 + }) + + security_wise_map[key[1]]['qty'] += qty + if qty: + security_wise_map[key[1]]['applicant_count'] += 1 + + total_portfolio_value += flt(qty * loan_security_details.get(key[1], {}).get('latest_price', 0)) + + return security_wise_map, total_portfolio_value + + + diff --git a/erpnext/loan_management/workspace/loan_management/loan_management.json b/erpnext/loan_management/workspace/loan_management/loan_management.json new file mode 100644 index 00000000000..18559dceef7 --- /dev/null +++ b/erpnext/loan_management/workspace/loan_management/loan_management.json @@ -0,0 +1,251 @@ +{ + "category": "Modules", + "charts": [], + "creation": "2020-03-12 16:35:55.299820", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "loan", + "idx": 0, + "is_default": 0, + "is_standard": 1, + "label": "Loan Management", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Loan", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Type", + "link_to": "Loan Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Application", + "link_to": "Loan Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan", + "link_to": "Loan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Loan Processes", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Process Loan Security Shortfall", + "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_to": "Process Loan Interest Accrual", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Disbursement and Repayment", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Disbursement", + "link_to": "Loan Disbursement", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Repayment", + "link_to": "Loan Repayment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Write Off", + "link_to": "Loan Write Off", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Interest Accrual", + "link_to": "Loan Interest Accrual", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Type", + "link_to": "Loan Security Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Price", + "link_to": "Loan Security Price", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security", + "link_to": "Loan Security", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Pledge", + "link_to": "Loan Security Pledge", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Unpledge", + "link_to": "Loan Security Unpledge", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Security Shortfall", + "link_to": "Loan Security Shortfall", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Loan Repayment and Closure", + "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_to": "Loan Security Status", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2021-02-18 17:31:53.586508", + "modified_by": "Administrator", + "module": "Loan Management", + "name": "Loan Management", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 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" + } + ] +} \ No newline at end of file diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js index e940b6050c6..ddbcdfde57e 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js +++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js @@ -66,7 +66,7 @@ erpnext.maintenance.MaintenanceSchedule = frappe.ui.form.Controller.extend({ company: me.frm.doc.company } }); - }, __("Get items from")); + }, __("Get Items From")); } else if (this.frm.doc.docstatus === 1) { this.frm.add_custom_button(__('Create Maintenance Visit'), function() { frappe.model.open_mapped_doc({ diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js index 2e2a9ce0401..4cbb02a5b3f 100644 --- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js +++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js @@ -62,7 +62,7 @@ erpnext.maintenance.MaintenanceVisit = frappe.ui.form.Controller.extend({ company: me.frm.doc.company } }) - }, __("Get items from")); + }, __("Get Items From")); this.frm.add_custom_button(__('Warranty Claim'), function() { erpnext.utils.map_current_doc({ @@ -78,7 +78,7 @@ erpnext.maintenance.MaintenanceVisit = frappe.ui.form.Controller.extend({ company: me.frm.doc.company } }) - }, __("Get items from")); + }, __("Get Items From")); this.frm.add_custom_button(__('Sales Order'), function() { erpnext.utils.map_current_doc({ @@ -94,7 +94,7 @@ erpnext.maintenance.MaintenanceVisit = frappe.ui.form.Controller.extend({ order_type: me.frm.doc.order_type, } }) - }, __("Get items from")); + }, __("Get Items From")); } }, }); diff --git a/erpnext/manufacturing/desk_page/manufacturing/manufacturing.json b/erpnext/manufacturing/desk_page/manufacturing/manufacturing.json deleted file mode 100644 index 8d11294164f..00000000000 --- a/erpnext/manufacturing/desk_page/manufacturing/manufacturing.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Production", - "links": "[\n {\n \"dependencies\": [\n \"Item\",\n \"BOM\"\n ],\n \"description\": \"Orders released for production.\",\n \"label\": \"Work Order\",\n \"name\": \"Work Order\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"BOM\"\n ],\n \"description\": \"Generate Material Requests (MRP) and Work Orders.\",\n \"label\": \"Production Plan\",\n \"name\": \"Production Plan\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\"\n ],\n \"label\": \"Stock Entry\",\n \"name\": \"Stock Entry\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Job Card\",\n \"name\": \"Job Card\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Downtime Entry\",\n \"name\": \"Downtime Entry\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Bill of Materials", - "links": "[\n {\n \"description\": \"All Products or Services.\",\n \"label\": \"Item\",\n \"name\": \"Item\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\"\n ],\n \"description\": \"Bill of Materials (BOM)\",\n \"label\": \"Bill of Materials\",\n \"name\": \"BOM\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Where manufacturing operations are carried.\",\n \"label\": \"Workstation\",\n \"name\": \"Workstation\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Details of the operations carried out.\",\n \"label\": \"Operation\",\n \"name\": \"Operation\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Routing\",\n \"name\": \"Routing\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Reports", - "links": "[{\n\t\"dependencies\": [\"Work Order\"],\n\t\"name\": \"Production Planning Report\",\n\t\"is_query_report\": true,\n\t\"type\": \"report\",\n\t\"doctype\": \"Work Order\",\n\t\"label\": \"Production Planning Report\"\n}, {\n\t\"dependencies\": [\"Work Order\"],\n\t\"name\": \"Work Order Summary\",\n\t\"is_query_report\": true,\n\t\"type\": \"report\",\n\t\"doctype\": \"Work Order\",\n\t\"label\": \"Work Order Summary\"\n}, {\n\t\"dependencies\": [\"Quality Inspection\"],\n\t\"name\": \"Quality Inspection Summary\",\n\t\"is_query_report\": true,\n\t\"type\": \"report\",\n\t\"doctype\": \"Quality Inspection\",\n\t\"label\": \"Quality Inspection Summary\"\n}, {\n\t\"dependencies\": [\"Downtime Entry\"],\n\t\"name\": \"Downtime Analysis\",\n\t\"is_query_report\": true,\n\t\"type\": \"report\",\n\t\"doctype\": \"Downtime Entry\",\n\t\"label\": \"Downtime Analysis\"\n}, {\n\t\"dependencies\": [\"Job Card\"],\n\t\"name\": \"Job Card Summary\",\n\t\"is_query_report\": true,\n\t\"type\": \"report\",\n\t\"doctype\": \"Job Card\",\n\t\"label\": \"Job Card Summary\"\n}, {\n\t\"dependencies\": [\"BOM\"],\n\t\"name\": \"BOM Search\",\n\t\"is_query_report\": true,\n\t\"type\": \"report\",\n\t\"doctype\": \"BOM\",\n\t\"label\": \"BOM Search\"\n}, {\n\t\"dependencies\": [\"BOM\"],\n\t\"name\": \"BOM Stock Report\",\n\t\"is_query_report\": true,\n\t\"type\": \"report\",\n\t\"doctype\": \"BOM\",\n\t\"label\": \"BOM Stock Report\"\n}, {\n\t\"dependencies\": [\"Work Order\"],\n\t\"name\": \"Production Analytics\",\n\t\"is_query_report\": true,\n\t\"type\": \"report\",\n\t\"doctype\": \"Work Order\",\n\t\"label\": \"Production Analytics\"\n}, {\n\t\"dependencies\": [\"BOM\"],\n\t\"name\": \"BOM Operations Time\",\n\t\"is_query_report\": true,\n\t\"type\": \"report\",\n\t\"doctype\": \"BOM\",\n\t\"label\": \"BOM Operations Time\"\n}]" - }, - { - "hidden": 0, - "label": "Tools", - "links": "[\n {\n \"description\": \"Replace BOM and update latest price in all BOMs\",\n \"label\": \"BOM Update Tool\",\n \"name\": \"BOM Update Tool\",\n \"type\": \"doctype\"\n },\n {\n \"data_doctype\": \"BOM\",\n \"description\": \"Compare BOMs for changes in Raw Materials and Operations\",\n \"label\": \"BOM Comparison Tool\",\n \"name\": \"bom-comparison-tool\",\n \"type\": \"page\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"description\": \"Global settings for all manufacturing processes.\",\n \"label\": \"Manufacturing Settings\",\n \"name\": \"Manufacturing Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Help", - "links": "[\n {\n \"label\": \"Work Order\",\n \"name\": \"Work Order\",\n \"type\": \"help\",\n \"youtube_id\": \"ZotgLyp2YFY\"\n }\n]" - } - ], - "category": "Domains", - "charts": [ - { - "chart_name": "Produced Quantity" - } - ], - "creation": "2020-03-02 17:11:37.032604", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Manufacturing", - "modified": "2020-05-28 13:54:02.048419", - "modified_by": "Administrator", - "module": "Manufacturing", - "name": "Manufacturing", - "onboarding": "Manufacturing", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "restrict_to_domain": "Manufacturing", - "shortcuts": [ - { - "color": "#cef6d1", - "format": "{} Active", - "label": "Item", - "link_to": "Item", - "restrict_to_domain": "Manufacturing", - "stats_filter": "{\n \"disabled\": 0\n}", - "type": "DocType" - }, - { - "color": "#cef6d1", - "format": "{} Active", - "label": "BOM", - "link_to": "BOM", - "restrict_to_domain": "Manufacturing", - "stats_filter": "{\n \"is_active\": 1\n}", - "type": "DocType" - }, - { - "color": "#ffe8cd", - "format": "{} Open", - "label": "Work Order", - "link_to": "Work Order", - "restrict_to_domain": "Manufacturing", - "stats_filter": "{ \n \"status\": [\"in\", \n [\"Draft\", \"Not Started\", \"In Process\"]\n ]\n}", - "type": "DocType" - }, - { - "color": "#ffe8cd", - "format": "{} Open", - "label": "Production Plan", - "link_to": "Production Plan", - "restrict_to_domain": "Manufacturing", - "stats_filter": "{ \n \"status\": [\"not in\", [\"Completed\"]]\n}", - "type": "DocType" - }, - { - "label": "Forecasting", - "link_to": "Exponential Smoothing Forecasting", - "type": "Report" - }, - { - "label": "Work Order Summary", - "link_to": "Work Order Summary", - "restrict_to_domain": "Manufacturing", - "type": "Report" - }, - { - "label": "BOM Stock Report", - "link_to": "BOM Stock Report", - "type": "Report" - }, - { - "label": "Production Planning Report", - "link_to": "Production Planning Report", - "type": "Report" - }, - { - "label": "Dashboard", - "link_to": "Manufacturing", - "restrict_to_domain": "Manufacturing", - "type": "Dashboard" - } - ] -} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 47b42072412..fbfd801a114 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -134,7 +134,7 @@ frappe.ui.form.on("BOM", { frm.set_intro(__('This is a Template BOM and will be used to make the work order for {0} of the item {1}', [ `variants`, - `${frm.doc.item}`, + `${frm.doc.item}`, ]), true); frm.$wrapper.find(".variants-intro").on("click", () => { @@ -411,7 +411,7 @@ cur_frm.cscript.hour_rate = function(doc) { cur_frm.cscript.time_in_mins = cur_frm.cscript.hour_rate; -cur_frm.cscript.bom_no = function(doc, cdt, cdn) { +cur_frm.cscript.bom_no = function(doc, cdt, cdn) { get_bom_material_detail(doc, cdt, cdn, false); }; @@ -419,22 +419,28 @@ cur_frm.cscript.is_default = function(doc) { if (doc.is_default) cur_frm.set_value("is_active", 1); }; -var get_bom_material_detail= function(doc, cdt, cdn, scrap_items) { +var get_bom_material_detail = function(doc, cdt, cdn, scrap_items) { + if (!doc.company) { + frappe.throw({message: __("Please select a Company first."), title: __("Mandatory")}); + } + var d = locals[cdt][cdn]; if (d.item_code) { return frappe.call({ doc: doc, method: "get_bom_material_detail", args: { - 'item_code': d.item_code, - 'bom_no': d.bom_no != null ? d.bom_no: '', + "company": doc.company, + "item_code": d.item_code, + "bom_no": d.bom_no != null ? d.bom_no: '', "scrap_items": scrap_items, - 'qty': d.qty, + "qty": d.qty, "stock_qty": d.stock_qty, "include_item_in_manufacturing": d.include_item_in_manufacturing, "uom": d.uom, "stock_uom": d.stock_uom, - "conversion_factor": d.conversion_factor + "conversion_factor": d.conversion_factor, + "sourced_by_supplier": d.sourced_by_supplier }, callback: function(r) { d = locals[cdt][cdn]; @@ -467,7 +473,7 @@ cur_frm.cscript.rate = function(doc, cdt, cdn) { } if (d.bom_no) { - frappe.msgprint(__("You can not change rate if BOM mentioned agianst any item")); + frappe.msgprint(__("You cannot change the rate if BOM is mentioned against any Item.")); get_bom_material_detail(doc, cdt, cdn, scrap_items); } else { erpnext.bom.calculate_rm_cost(doc); @@ -616,6 +622,22 @@ frappe.ui.form.on("BOM Item", "item_code", function(frm, cdt, cdn) { refresh_field("allow_alternative_item", d.name, d.parentfield); }); +frappe.ui.form.on("BOM Item", "sourced_by_supplier", function(frm, cdt, cdn) { + var d = locals[cdt][cdn]; + if (d.sourced_by_supplier) { + d.rate = 0; + refresh_field("rate", d.name, d.parentfield); + } +}); + +frappe.ui.form.on("BOM Item", "rate", function(frm, cdt, cdn) { + var d = locals[cdt][cdn]; + if (d.sourced_by_supplier) { + d.rate = 0; + refresh_field("rate", d.name, d.parentfield); + } +}); + frappe.ui.form.on("BOM Operation", "operations_remove", function(frm) { erpnext.bom.calculate_op_cost(frm.doc); erpnext.bom.calculate_total(frm.doc); diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 3189433837f..03beedb6635 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -55,15 +55,20 @@ class BOM(WebsiteGenerator): conflicting_bom = frappe.get_doc("BOM", name) if conflicting_bom.item != self.item: + msg = (_("A BOM with name {0} already exists for item {1}.") + .format(frappe.bold(name), frappe.bold(conflicting_bom.item))) - frappe.throw(_("""A BOM with name {0} already exists for item {1}. -
    Did you rename the item? Please contact Administrator / Tech support - """).format(frappe.bold(name), frappe.bold(conflicting_bom.item))) + frappe.throw(_("{0}{1} Did you rename the item? Please contact Administrator / Tech support") + .format(msg, "
    ")) self.name = name def validate(self): self.route = frappe.scrub(self.name).replace('_', '-') + + if not self.company: + frappe.throw(_("Please select a Company first."), title=_("Mandatory")) + self.clear_operations() self.validate_main_item() self.validate_currency() @@ -72,8 +77,10 @@ class BOM(WebsiteGenerator): self.validate_uom_is_interger() self.set_bom_material_details() self.validate_materials() + self.set_routing_operations() self.validate_operations() self.calculate_cost() + self.update_stock_qty() self.update_cost(update_parent=False, from_child_bom=True, save=False) def get_context(self, context): @@ -82,8 +89,6 @@ class BOM(WebsiteGenerator): def on_update(self): frappe.cache().hdel('bom_children', self.name) self.check_recursion() - self.update_stock_qty() - self.update_exploded_items() def on_submit(self): self.manage_default_bom() @@ -111,24 +116,20 @@ class BOM(WebsiteGenerator): def get_routing(self): if self.routing: self.set("operations", []) - for d in frappe.get_all("BOM Operation", fields = ["*"], - filters = {'parenttype': 'Routing', 'parent': self.routing}, order_by="idx"): - child = self.append('operations', { - "operation": d.operation, - "workstation": d.workstation, - "description": d.description, - "time_in_mins": d.time_in_mins, - "batch_size": d.batch_size, - "operating_cost": d.operating_cost, - "idx": d.idx - }) - child.hour_rate = flt(d.hour_rate / self.conversion_rate, 2) + fields = ["sequence_id", "operation", "workstation", "description", + "time_in_mins", "batch_size", "operating_cost", "idx", "hour_rate"] + + for row in frappe.get_all("BOM Operation", fields = fields, + filters = {'parenttype': 'Routing', 'parent': self.routing}, order_by="sequence_id, idx"): + child = self.append('operations', row) + child.hour_rate = flt(row.hour_rate / self.conversion_rate, 2) def set_bom_material_details(self): for item in self.get("items"): self.validate_bom_currecny(item) ret = self.get_bom_material_detail({ + "company": self.company, "item_code": item.item_code, "item_name": item.item_name, "bom_no": item.bom_no, @@ -137,7 +138,8 @@ class BOM(WebsiteGenerator): "qty": item.qty, "uom": item.uom, "stock_uom": item.stock_uom, - "conversion_factor": item.conversion_factor + "conversion_factor": item.conversion_factor, + "sourced_by_supplier": item.sourced_by_supplier }) for r in ret: if not item.get(r): @@ -172,7 +174,8 @@ class BOM(WebsiteGenerator): 'qty' : args.get("qty") or args.get("stock_qty") or 1, 'stock_qty' : args.get("qty") or args.get("stock_qty") or 1, 'base_rate' : flt(rate) * (flt(self.conversion_rate) or 1), - 'include_item_in_manufacturing': cint(args['transfer_for_manufacture']) or 0 + 'include_item_in_manufacturing': cint(args.get('transfer_for_manufacture')), + 'sourced_by_supplier' : args.get('sourced_by_supplier', 0) } return ret_item @@ -191,8 +194,8 @@ class BOM(WebsiteGenerator): if arg.get('scrap_items'): rate = get_valuation_rate(arg) elif arg: - #Customer Provided parts will have zero rate - if not frappe.db.get_value('Item', arg["item_code"], 'is_customer_provided_item'): + #Customer Provided parts and Supplier sourced parts will have zero rate + if not frappe.db.get_value('Item', arg["item_code"], 'is_customer_provided_item') and not arg.get('sourced_by_supplier'): if arg.get('bom_no') and self.set_rate_of_sub_assembly_item_based_on_bom: rate = flt(self.get_bom_unitcost(arg['bom_no'])) * (arg.get("conversion_factor") or 1) else: @@ -205,7 +208,6 @@ class BOM(WebsiteGenerator): else: frappe.msgprint(_("{0} not found for item {1}") .format(self.rm_cost_as_per, arg["item_code"]), alert=True) - return flt(rate) * flt(self.plc_conversion_rate or 1) / (self.conversion_rate or 1) def update_cost(self, update_parent=True, from_child_bom=False, save=True): @@ -216,12 +218,14 @@ class BOM(WebsiteGenerator): for d in self.get("items"): rate = self.get_rm_rate({ + "company": self.company, "item_code": d.item_code, "bom_no": d.bom_no, "qty": d.qty, "uom": d.uom, "stock_uom": d.stock_uom, - "conversion_factor": d.conversion_factor + "conversion_factor": d.conversion_factor, + "sourced_by_supplier": d.sourced_by_supplier }) if rate: @@ -238,7 +242,8 @@ class BOM(WebsiteGenerator): self.calculate_cost() if save: self.db_update() - self.update_exploded_items() + + self.update_exploded_items(save=save) # update parent BOMs if self.total_cost != existing_bom_cost and update_parent: @@ -319,8 +324,6 @@ class BOM(WebsiteGenerator): m.uom = m.stock_uom m.qty = m.stock_qty - m.db_update() - def validate_uom_is_interger(self): from erpnext.utilities.transaction_base import validate_uom_is_integer validate_uom_is_integer(self, "uom", "qty", "BOM Item") @@ -373,15 +376,6 @@ class BOM(WebsiteGenerator): if raise_exception: frappe.throw(_("BOM recursion: {0} cannot be parent or child of {1}").format(self.name, self.name)) - def update_cost_and_exploded_items(self, bom_list=[]): - bom_list = self.traverse_tree(bom_list) - for bom in bom_list: - bom_obj = frappe.get_doc("BOM", bom) - bom_obj.check_recursion(bom_list=bom_list) - bom_obj.update_exploded_items() - - return bom_list - def traverse_tree(self, bom_list=None): def _get_children(bom_no): children = frappe.cache().hget('bom_children', bom_no) @@ -473,10 +467,10 @@ class BOM(WebsiteGenerator): d.rate = rate d.amount = (d.stock_qty or d.qty) * rate - def update_exploded_items(self): + def update_exploded_items(self, save=True): """ Update Flat BOM, following will be correct data""" self.get_exploded_items() - self.add_exploded_items() + self.add_exploded_items(save=save) def get_exploded_items(self): """ Get all raw materials including items from child bom""" @@ -495,7 +489,8 @@ class BOM(WebsiteGenerator): 'stock_uom' : d.stock_uom, 'stock_qty' : flt(d.stock_qty), 'rate' : flt(d.base_rate) / (flt(d.conversion_factor) or 1.0), - 'include_item_in_manufacturing': d.include_item_in_manufacturing + 'include_item_in_manufacturing': d.include_item_in_manufacturing, + 'sourced_by_supplier': d.sourced_by_supplier })) def company_currency(self): @@ -521,6 +516,7 @@ class BOM(WebsiteGenerator): bom_item.stock_qty, bom_item.rate, bom_item.include_item_in_manufacturing, + bom_item.sourced_by_supplier, bom_item.stock_qty / ifnull(bom.quantity, 1) AS qty_consumed_per_unit FROM `tabBOM Explosion Item` bom_item, tabBOM bom WHERE @@ -539,14 +535,17 @@ class BOM(WebsiteGenerator): 'stock_uom' : d['stock_uom'], 'stock_qty' : d['qty_consumed_per_unit'] * stock_qty, 'rate' : flt(d['rate']), - 'include_item_in_manufacturing': d.get('include_item_in_manufacturing', 0) + 'include_item_in_manufacturing': d.get('include_item_in_manufacturing', 0), + 'sourced_by_supplier': d.get('sourced_by_supplier', 0) })) - def add_exploded_items(self): + def add_exploded_items(self, save=True): "Add items to Flat BOM table" - frappe.db.sql("""delete from `tabBOM Explosion Item` where parent=%s""", self.name) self.set('exploded_items', []) + if save: + frappe.db.sql("""delete from `tabBOM Explosion Item` where parent=%s""", self.name) + for d in sorted(self.cur_exploded_items, key=itemgetter(0)): ch = self.append('exploded_items', {}) for i in self.cur_exploded_items[d].keys(): @@ -554,7 +553,9 @@ class BOM(WebsiteGenerator): ch.amount = flt(ch.stock_qty) * flt(ch.rate) ch.qty_consumed_per_unit = flt(ch.stock_qty) / flt(self.quantity) ch.docstatus = self.docstatus - ch.db_insert() + + if save: + ch.db_insert() def validate_bom_links(self): if not self.is_active: @@ -566,6 +567,10 @@ class BOM(WebsiteGenerator): if act_pbom and act_pbom[0][0]: frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs")) + def set_routing_operations(self): + if self.routing and self.with_operations and not self.operations: + self.get_routing() + def validate_operations(self): if self.with_operations and not self.get('operations'): frappe.throw(_("Operations cannot be left blank")) @@ -612,10 +617,20 @@ def get_valuation_rate(args): """ Get weighted average of valuation rate from all warehouses """ total_qty, total_value, valuation_rate = 0.0, 0.0, 0.0 - for d in frappe.db.sql("""select actual_qty, stock_value from `tabBin` - where item_code=%s""", args['item_code'], as_dict=1): - total_qty += flt(d.actual_qty) - total_value += flt(d.stock_value) + item_bins = frappe.db.sql(""" + select + bin.actual_qty, bin.stock_value + from + `tabBin` bin, `tabWarehouse` warehouse + where + bin.item_code=%(item)s + and bin.warehouse = warehouse.name + and warehouse.company=%(company)s""", + {"item": args['item_code'], "company": args['company']}, as_dict=1) + + for d in item_bins: + total_qty += flt(d.actual_qty) + total_value += flt(d.stock_value) if total_qty: valuation_rate = total_value / total_qty @@ -679,7 +694,7 @@ def get_bom_items_as_dict(bom, company, qty=1, fetch_exploded=1, fetch_scrap_ite is_stock_item=is_stock_item, qty_field="stock_qty", select_columns = """, bom_item.source_warehouse, bom_item.operation, - bom_item.include_item_in_manufacturing, bom_item.description, bom_item.rate, + bom_item.include_item_in_manufacturing, bom_item.description, bom_item.rate, bom_item.sourced_by_supplier, (Select idx from `tabBOM Item` where item_code = bom_item.item_code and parent = %(parent)s limit 1) as idx""") items = frappe.db.sql(query, { "parent": bom, "qty": qty, "bom": bom, "company": company }, as_dict=True) @@ -692,7 +707,7 @@ def get_bom_items_as_dict(bom, company, qty=1, fetch_exploded=1, fetch_scrap_ite query = query.format(table="BOM Item", where_conditions="", is_stock_item=is_stock_item, qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty", select_columns = """, bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse, - bom_item.idx, bom_item.operation, bom_item.include_item_in_manufacturing, + bom_item.idx, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier, bom_item.description, bom_item.base_rate as rate """) items = frappe.db.sql(query, { "qty": qty, "bom": bom, "company": company }, as_dict=True) diff --git a/erpnext/manufacturing/doctype/bom/bom_item_preview.html b/erpnext/manufacturing/doctype/bom/bom_item_preview.html index c782f7bf0ee..6cd5f8cb3cf 100644 --- a/erpnext/manufacturing/doctype/bom/bom_item_preview.html +++ b/erpnext/manufacturing/doctype/bom/bom_item_preview.html @@ -12,11 +12,11 @@

    {% if data.value %} - + {{ __("Open BOM {0}", [data.value.bold()]) }} {% endif %} {% if data.item_code %} - + {{ __("Open Item {0}", [data.item_code.bold()]) }} {% endif %}

    diff --git a/erpnext/manufacturing/doctype/bom/bom_list.js b/erpnext/manufacturing/doctype/bom/bom_list.js index 94cb466bd8a..4b5887f180c 100644 --- a/erpnext/manufacturing/doctype/bom/bom_list.js +++ b/erpnext/manufacturing/doctype/bom/bom_list.js @@ -8,7 +8,7 @@ frappe.listview_settings['BOM'] = { } else if(doc.is_active) { return [__("Active"), "blue", "is_active,=,Yes"]; } else if(!doc.is_active) { - return [__("Not active"), "darkgrey", "is_active,=,No"]; + return [__("Not active"), "gray", "is_active,=,No"]; } } }; diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 3dfd03b1395..32394788723 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -10,6 +10,8 @@ from frappe.test_runner import make_test_records from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost from six import string_types +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order test_records = frappe.get_test_records('BOM') @@ -138,6 +140,73 @@ class TestBOM(unittest.TestCase): self.assertEqual(bom.items[0].rate, 20) + def test_subcontractor_sourced_item(self): + item_code = "_Test Subcontracted FG Item 1" + + if not frappe.db.exists('Item', item_code): + make_item(item_code, { + 'is_stock_item': 1, + 'is_sub_contracted_item': 1, + 'stock_uom': 'Nos' + }) + + if not frappe.db.exists('Item', "Test Extra Item 1"): + make_item("Test Extra Item 1", { + 'is_stock_item': 1, + 'stock_uom': 'Nos' + }) + + if not frappe.db.exists('Item', "Test Extra Item 2"): + make_item("Test Extra Item 2", { + 'is_stock_item': 1, + 'stock_uom': 'Nos' + }) + + if not frappe.db.exists('Item', "Test Extra Item 3"): + make_item("Test Extra Item 3", { + 'is_stock_item': 1, + 'stock_uom': 'Nos' + }) + bom = frappe.get_doc({ + 'doctype': 'BOM', + 'is_default': 1, + 'item': item_code, + 'currency': 'USD', + 'quantity': 1, + 'company': '_Test Company' + }) + + for item in ["Test Extra Item 1", "Test Extra Item 2"]: + item_doc = frappe.get_doc('Item', item) + + bom.append('items', { + 'item_code': item, + 'qty': 1, + 'uom': item_doc.stock_uom, + 'stock_uom': item_doc.stock_uom, + 'rate': item_doc.valuation_rate + }) + + bom.append('items', { + 'item_code': "Test Extra Item 3", + 'qty': 1, + 'uom': item_doc.stock_uom, + 'stock_uom': item_doc.stock_uom, + 'rate': 0, + 'sourced_by_supplier': 1 + }) + bom.insert(ignore_permissions=True) + bom.update_cost() + bom.submit() + # test that sourced_by_supplier rate is zero even after updating cost + self.assertEqual(bom.items[2].rate, 0) + # test in Purchase Order sourced_by_supplier is not added to Supplied Item + po = create_purchase_order(item_code=item_code, qty=1, + is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC") + bom_items = sorted([d.item_code for d in bom.items if d.sourced_by_supplier != 1]) + supplied_items = sorted([d.rm_item_code for d in po.supplied_items]) + self.assertEquals(bom_items, supplied_items) + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) diff --git a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json index 9fadbef0f54..f01d856e72a 100644 --- a/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json +++ b/erpnext/manufacturing/doctype/bom_explosion_item/bom_explosion_item.json @@ -1,626 +1,181 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "hash", - "beta": 0, - "creation": "2013-03-07 11:42:57", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "hash", + "creation": "2013-03-07 11:42:57", + "doctype": "DocType", + "document_type": "Setup", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "cb", + "source_warehouse", + "operation", + "section_break_3", + "description", + "column_break_2", + "image", + "image_view", + "section_break_4", + "stock_qty", + "rate", + "qty_consumed_per_unit", + "column_break_8", + "stock_uom", + "amount", + "include_item_in_manufacturing", + "sourced_by_supplier" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_code", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Item Code", - "length": 0, - "no_copy": 0, - "oldfieldname": "item_code", - "oldfieldtype": "Link", - "options": "Item", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "item_code", + "fieldtype": "Link", + "in_global_search": 1, + "in_list_view": 1, + "label": "Item Code", + "oldfieldname": "item_code", + "oldfieldtype": "Link", + "options": "Item", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Item Name", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "item_name", + "fieldtype": "Data", + "in_global_search": 1, + "in_list_view": 1, + "label": "Item Name", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "cb", - "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 - }, + "fieldname": "cb", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "source_warehouse", - "fieldtype": "Link", - "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": "Source Warehouse", - "length": 0, - "no_copy": 0, - "options": "Warehouse", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "source_warehouse", + "fieldtype": "Link", + "label": "Source Warehouse", + "options": "Warehouse", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "operation", - "fieldtype": "Link", - "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": "Operation", - "length": 0, - "no_copy": 0, - "options": "Operation", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "operation", + "fieldtype": "Link", + "label": "Operation", + "options": "Operation", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_3", - "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 - }, + "fieldname": "section_break_3", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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": 1, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "oldfieldname": "description", - "oldfieldtype": "Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": "300px", - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, + "fieldname": "description", + "fieldtype": "Text Editor", + "in_list_view": 1, + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "print_width": "300px", + "read_only": 1, "width": "300px" - }, + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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 - }, + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "image", - "fieldtype": "Attach", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Image", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "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": "image", + "fieldtype": "Attach", + "hidden": 1, + "label": "Image", + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "image_view", - "fieldtype": "Image", - "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": "Image View", - "length": 0, - "no_copy": 0, - "options": "image", - "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": "image_view", + "fieldtype": "Image", + "label": "Image View", + "options": "image" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_4", - "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 - }, + "fieldname": "section_break_4", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "stock_qty", - "fieldtype": "Float", - "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": "Stock Qty", - "length": 0, - "no_copy": 0, - "oldfieldname": "qty", - "oldfieldtype": "Currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "stock_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Stock Qty", + "oldfieldname": "qty", + "oldfieldtype": "Currency", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "rate", - "fieldtype": "Currency", - "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": "Rate", - "length": 0, - "no_copy": 0, - "oldfieldname": "standard_rate", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate", + "oldfieldname": "standard_rate", + "oldfieldtype": "Currency", + "options": "Company:company:default_currency", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "qty_consumed_per_unit", - "fieldtype": "Float", - "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": "Qty Consumed Per Unit", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "qty_consumed_per_unit", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Qty Consumed Per Unit", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_8", - "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 - }, + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "stock_uom", - "fieldtype": "Link", - "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": "Stock UOM", - "length": 0, - "no_copy": 0, - "oldfieldname": "stock_uom", - "oldfieldtype": "Link", - "options": "UOM", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "oldfieldname": "stock_uom", + "oldfieldtype": "Link", + "options": "UOM", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amount", - "fieldtype": "Currency", - "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": "Amount", - "length": 0, - "no_copy": 0, - "oldfieldname": "amount_as_per_sr", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "oldfieldname": "amount_as_per_sr", + "oldfieldtype": "Currency", + "options": "Company:company:default_currency", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "include_item_in_manufacturing", - "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": "Include Item In Manufacturing", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "default": "0", + "fieldname": "include_item_in_manufacturing", + "fieldtype": "Check", + "label": "Include Item In Manufacturing", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "sourced_by_supplier", + "fieldtype": "Check", + "label": "Sourced by Supplier", + "read_only": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2018-11-20 19:04:59.813773", - "modified_by": "Administrator", - "module": "Manufacturing", - "name": "BOM Explosion Item", - "owner": "Administrator", - "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + ], + "idx": 1, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-10-08 16:21:29.386212", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "BOM Explosion Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json index e34be61bc75..4c9877f52b2 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.json +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json @@ -37,7 +37,9 @@ "section_break_27", "has_variants", "include_item_in_manufacturing", - "original_item" + "original_item", + "column_break_33", + "sourced_by_supplier" ], "fields": [ { @@ -272,12 +274,23 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "column_break_33", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "sourced_by_supplier", + "fieldtype": "Check", + "label": "Sourced by Supplier" } ], "idx": 1, + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-04-09 14:30:26.535546", + "modified": "2020-10-08 14:19:37.563300", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Item", diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index 0350e2cb374..07464e3e766 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -1,10 +1,12 @@ { + "actions": [], "creation": "2013-02-22 01:27:49", "doctype": "DocType", "document_type": "Setup", "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "sequence_id", "operation", "workstation", "description", @@ -106,11 +108,19 @@ "fieldname": "batch_size", "fieldtype": "Int", "label": "Batch Size" + }, + { + "depends_on": "eval:doc.parenttype == \"Routing\"", + "fieldname": "sequence_id", + "fieldtype": "Int", + "label": "Sequence ID" } ], "idx": 1, + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2020-06-16 17:01:11.128420", + "links": [], + "modified": "2020-10-13 18:14:10.018774", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index b051b3243fd..4e8dd41022b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -31,6 +31,16 @@ frappe.ui.form.on('Job Card', { } } + frm.set_query("quality_inspection", function() { + return { + query: "erpnext.stock.doctype.quality_inspection.quality_inspection.quality_inspection_query", + filters: { + "item_code": frm.doc.production_item, + "reference_name": frm.doc.name + } + }; + }); + frm.trigger("toggle_operation_number"); if (frm.doc.docstatus == 0 && (frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 087ab6b484b..5713f697e99 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -20,6 +20,7 @@ "production_item", "item_name", "for_quantity", + "quality_inspection", "wip_warehouse", "column_break_12", "employee", @@ -36,6 +37,7 @@ "items", "more_information", "operation_id", + "sequence_id", "transferred_qty", "requested_qty", "column_break_20", @@ -297,10 +299,26 @@ "fieldname": "operation_row_number", "fieldtype": "Select", "label": "Operation Row Number" + }, + { + "fieldname": "sequence_id", + "fieldtype": "Int", + "label": "Sequence Id", + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval:!doc.__islocal;", + "fieldname": "quality_inspection", + "fieldtype": "Link", + "label": "Quality Inspection", + "no_copy": 1, + "options": "Quality Inspection" } ], "is_submittable": 1, - "modified": "2020-08-24 15:21:21.398267", + "links": [], + "modified": "2020-11-19 18:26:50.531664", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 8855e0acf59..7aaf2a08eca 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe import datetime -from frappe import _ +from frappe import _, bold from frappe.model.mapper import get_mapped_doc from frappe.model.document import Document from frappe.utils import (flt, cint, time_diff_in_hours, get_datetime, getdate, @@ -16,12 +16,15 @@ from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings class OverlapError(frappe.ValidationError): pass class OperationMismatchError(frappe.ValidationError): pass +class OperationSequenceError(frappe.ValidationError): pass +class JobCardCancelError(frappe.ValidationError): pass class JobCard(Document): def validate(self): self.validate_time_logs() self.set_status() self.validate_operation_id() + self.validate_sequence_id() def validate_time_logs(self): self.total_completed_qty = 0.0 @@ -196,14 +199,14 @@ class JobCard(Document): def validate_job_card(self): if not self.time_logs: frappe.throw(_("Time logs are required for {0} {1}") - .format(frappe.bold("Job Card"), get_link_to_form("Job Card", self.name))) + .format(bold("Job Card"), get_link_to_form("Job Card", self.name))) if self.for_quantity and self.total_completed_qty != self.for_quantity: - total_completed_qty = frappe.bold(_("Total Completed Qty")) - qty_to_manufacture = frappe.bold(_("Qty to Manufacture")) + total_completed_qty = bold(_("Total Completed Qty")) + qty_to_manufacture = bold(_("Qty to Manufacture")) - frappe.throw(_("The {0} ({1}) must be equal to {2} ({3})" - .format(total_completed_qty, frappe.bold(self.total_completed_qty), qty_to_manufacture,frappe.bold(self.for_quantity)))) + frappe.throw(_("The {0} ({1}) must be equal to {2} ({3})") + .format(total_completed_qty, bold(self.total_completed_qty), qty_to_manufacture,bold(self.for_quantity))) def update_work_order(self): if not self.work_order: @@ -213,38 +216,70 @@ class JobCard(Document): from_time_list, to_time_list = [], [] field = "operation_id" - data = frappe.get_all('Job Card', - fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"], - filters = {"docstatus": 1, "work_order": self.work_order, field: self.get(field)}) - + data = self.get_current_operation_data() if data and len(data) > 0: - for_quantity = data[0].completed_qty - time_in_mins = data[0].time_in_mins + for_quantity = flt(data[0].completed_qty) + time_in_mins = flt(data[0].time_in_mins) - if self.get(field): - time_data = frappe.db.sql(""" + wo = frappe.get_doc('Work Order', self.work_order) + if self.operation_id: + self.validate_produced_quantity(for_quantity, wo) + self.update_work_order_data(for_quantity, time_in_mins, wo) + + def validate_produced_quantity(self, for_quantity, wo): + if self.docstatus < 2: return + + if wo.produced_qty > for_quantity: + first_part_msg = (_("The {0} {1} is used to calculate the valuation cost for the finished good {2}.") + .format(frappe.bold(_("Job Card")), frappe.bold(self.name), frappe.bold(self.production_item))) + + second_part_msg = (_("Kindly cancel the Manufacturing Entries first against the work order {0}.") + .format(frappe.bold(get_link_to_form("Work Order", self.work_order)))) + + frappe.throw(_("{0} {1}").format(first_part_msg, second_part_msg), + JobCardCancelError, title = _("Error")) + + def update_work_order_data(self, for_quantity, time_in_mins, wo): + time_data = frappe.db.sql(""" SELECT min(from_time) as start_time, max(to_time) as end_time FROM `tabJob Card` jc, `tabJob Card Time Log` jctl WHERE jctl.parent = jc.name and jc.work_order = %s - and jc.{0} = %s and jc.docstatus = 1 - """.format(field), (self.work_order, self.get(field)), as_dict=1) + and jc.operation_id = %s and jc.docstatus = 1 + """, (self.work_order, self.operation_id), as_dict=1) - wo = frappe.get_doc('Work Order', self.work_order) + for data in wo.operations: + if data.get("name") == self.operation_id: + data.completed_qty = for_quantity + data.actual_operation_time = time_in_mins + data.actual_start_time = time_data[0].start_time if time_data else None + data.actual_end_time = time_data[0].end_time if time_data else None + if data.get("workstation") != self.workstation: + # workstations can change in a job card + data.workstation = self.workstation - for data in wo.operations: - if data.get("name") == self.get(field): - data.completed_qty = for_quantity - data.actual_operation_time = time_in_mins - data.actual_start_time = time_data[0].start_time if time_data else None - data.actual_end_time = time_data[0].end_time if time_data else None + wo.flags.ignore_validate_update_after_submit = True + wo.update_operation_status() + wo.calculate_operating_cost() + wo.set_actual_dates() + wo.save() - wo.flags.ignore_validate_update_after_submit = True - wo.update_operation_status() - wo.calculate_operating_cost() - wo.set_actual_dates() - wo.save() + def get_current_operation_data(self): + return frappe.get_all('Job Card', + fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"], + filters = {"docstatus": 1, "work_order": self.work_order, "operation_id": self.operation_id}) + + def set_transferred_qty_in_job_card(self, ste_doc): + for row in ste_doc.items: + if not row.job_card_item: continue + + qty = frappe.db.sql(""" SELECT SUM(qty) from `tabStock Entry Detail` sed, `tabStock Entry` se + WHERE sed.job_card_item = %s and se.docstatus = 1 and sed.parent = se.name and + se.purpose = 'Material Transfer for Manufacture' + """, (row.job_card_item))[0][0] + + frappe.db.set_value('Job Card Item', row.job_card_item, 'transferred_qty', flt(qty)) def set_transferred_qty(self, update_status=False): if not self.items: @@ -258,7 +293,8 @@ class JobCard(Document): self.transferred_qty = frappe.db.get_value('Stock Entry', { 'job_card': self.name, 'work_order': self.work_order, - 'docstatus': 1 + 'docstatus': 1, + 'purpose': 'Material Transfer for Manufacture' }, 'sum(fg_completed_qty)') or 0 self.db_set("transferred_qty", self.transferred_qty) @@ -310,9 +346,32 @@ class JobCard(Document): def validate_operation_id(self): if (self.get("operation_id") and self.get("operation_row_number") and self.operation and self.work_order and frappe.get_cached_value("Work Order Operation", self.operation_row_number, "name") != self.operation_id): - work_order = frappe.bold(get_link_to_form("Work Order", self.work_order)) + work_order = bold(get_link_to_form("Work Order", self.work_order)) frappe.throw(_("Operation {0} does not belong to the work order {1}") - .format(frappe.bold(self.operation), work_order), OperationMismatchError) + .format(bold(self.operation), work_order), OperationMismatchError) + + def validate_sequence_id(self): + if not (self.work_order and self.sequence_id): return + + current_operation_qty = 0.0 + data = self.get_current_operation_data() + if data and len(data) > 0: + current_operation_qty = flt(data[0].completed_qty) + + current_operation_qty += flt(self.total_completed_qty) + + data = frappe.get_all("Work Order Operation", + fields = ["operation", "status", "completed_qty"], + filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ('<', self.sequence_id)}, + order_by = "sequence_id, idx") + + message = "Job Card {0}: As per the sequence of the operations in the work order {1}".format(bold(self.name), + bold(get_link_to_form("Work Order", self.work_order))) + + for row in data: + if row.status != "Completed" and row.completed_qty < current_operation_qty: + frappe.throw(_("{0}, complete the operation {1} before the operation {2}.") + .format(message, bold(row.operation), bold(self.operation)), OperationSequenceError) @frappe.whitelist() def get_operation_details(work_order, operation): @@ -326,17 +385,19 @@ def get_operation_details(work_order, operation): @frappe.whitelist() def get_operations(doctype, txt, searchfield, start, page_len, filters): - if filters.get("work_order"): - args = {"parent": filters.get("work_order")} - if txt: - args["operation"] = ("like", "%{0}%".format(txt)) + if not filters.get("work_order"): + frappe.msgprint(_("Please select a Work Order first.")) + return [] + args = {"parent": filters.get("work_order")} + if txt: + args["operation"] = ("like", "%{0}%".format(txt)) - return frappe.get_all("Work Order Operation", - filters = args, - fields = ["distinct operation as operation"], - limit_start = start, - limit_page_length = page_len, - order_by="idx asc", as_list=1) + return frappe.get_all("Work Order Operation", + filters = args, + fields = ["distinct operation as operation"], + limit_start = start, + limit_page_length = page_len, + order_by="idx asc", as_list=1) @frappe.whitelist() def make_material_request(source_name, target_doc=None): @@ -374,6 +435,7 @@ def make_stock_entry(source_name, target_doc=None): target.purpose = "Material Transfer for Manufacture" target.from_bom = 1 target.fg_completed_qty = source.get('for_quantity', 0) - source.get('transferred_qty', 0) + target.set_transfer_qty() target.calculate_rate_and_amount() target.set_missing_values() target.set_stock_entry_type() @@ -391,9 +453,10 @@ def make_stock_entry(source_name, target_doc=None): "field_map": { "source_warehouse": "s_warehouse", "required_qty": "qty", - "uom": "stock_uom" + "name": "job_card_item" }, "postprocess": update_item, + "condition": lambda doc: doc.required_qty > 0 } }, target_doc, set_missing_values) diff --git a/erpnext/manufacturing/doctype/job_card/job_card_calendar.js b/erpnext/manufacturing/doctype/job_card/job_card_calendar.js index cf07698ad6a..f4877fdca0b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card_calendar.js +++ b/erpnext/manufacturing/doctype/job_card/job_card_calendar.js @@ -8,7 +8,17 @@ frappe.views.calendar["Job Card"] = { "allDay": "allDay", "progress": "progress" }, - gantt: true, + gantt: { + field_map: { + "start": "started_time", + "end": "started_time", + "id": "name", + "title": "subject", + "color": "color", + "allDay": "allDay", + "progress": "progress" + } + }, filters: [ { "fieldtype": "Link", diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json index bc9fe108ca6..100ef4ca3a3 100644 --- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json +++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json @@ -1,363 +1,120 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2018-07-09 17:20:44.737289", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2018-07-09 17:20:44.737289", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "source_warehouse", + "uom", + "item_group", + "column_break_3", + "stock_uom", + "item_name", + "description", + "qty_section", + "required_qty", + "column_break_9", + "transferred_qty", + "allow_alternative_item" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_code", - "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": "Item Code", - "length": 0, - "no_copy": 0, - "options": "Item", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "source_warehouse", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 1, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Source Warehouse", - "length": 0, - "no_copy": 0, - "options": "Warehouse", - "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": "source_warehouse", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "in_list_view": 1, + "label": "Source Warehouse", + "options": "Warehouse" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "uom", - "fieldtype": "Link", - "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": "UOM", - "length": 0, - "no_copy": 0, - "options": "UOM", - "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": "uom", + "fieldtype": "Link", + "label": "UOM", + "options": "UOM" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_3", - "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 - }, + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_name", - "fieldtype": "Data", - "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": "Item Name", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "description", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "description", + "fieldtype": "Text", + "label": "Description", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "qty_section", - "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, - "label": "Qty", - "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 - }, + "fieldname": "qty_section", + "fieldtype": "Section Break", + "label": "Qty" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "required_qty", - "fieldtype": "Float", - "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": "Required Qty", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "required_qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Required Qty", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_9", - "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 - }, + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "allow_alternative_item", - "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": "Allow Alternative Item", - "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 + "default": "0", + "fieldname": "allow_alternative_item", + "fieldtype": "Check", + "label": "Allow Alternative Item" + }, + { + "fetch_from": "item_code.item_group", + "fieldname": "item_group", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "read_only": 1 + }, + { + "fetch_from": "item_code.stock_uom", + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM" + }, + { + "fieldname": "transferred_qty", + "fieldtype": "Float", + "label": "Transferred Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 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": 1, - "max_attachments": 0, - "modified": "2018-08-28 15:23:48.099459", - "modified_by": "Administrator", - "module": "Manufacturing", - "name": "Job Card Item", - "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": 1, - "track_seen": 0, - "track_views": 0 + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-02-11 13:50:13.804108", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Job Card Item", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index 86fa7a8901a..b7634da87c2 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2014-11-27 14:12:07.542534", "doctype": "DocType", "document_type": "Document", @@ -36,7 +37,7 @@ { "default": "0", "depends_on": "eval:!doc.disable_capacity_planning", - "description": "Plan time logs outside Workstation Working Hours.", + "description": "Plan time logs outside Workstation working hours", "fieldname": "allow_overtime", "fieldtype": "Check", "label": "Allow Overtime" @@ -56,17 +57,17 @@ { "default": "30", "depends_on": "eval:!doc.disable_capacity_planning", - "description": "Try planning operations for X days in advance.", + "description": "Plan operations X days in advance", "fieldname": "capacity_planning_for_days", "fieldtype": "Int", "label": "Capacity Planning For (Days)" }, { "depends_on": "eval:!doc.disable_capacity_planning", - "description": "Default 10 mins", + "description": "Default: 10 mins", "fieldname": "mins_between_operations", "fieldtype": "Int", - "label": "Time Between Operations (in mins)" + "label": "Time Between Operations (Mins)" }, { "fieldname": "section_break_6", @@ -92,14 +93,14 @@ }, { "default": "0", - "description": "Allow multiple Material Consumption against a Work Order", + "description": "Allow multiple material consumptions against a Work Order", "fieldname": "material_consumption", "fieldtype": "Check", "label": "Allow Multiple Material Consumption" }, { "default": "0", - "description": "Update BOM cost automatically via Scheduler, based on latest valuation rate / price list rate / last purchase rate of raw materials.", + "description": "Update BOM cost automatically via scheduler, based on the latest Valuation Rate/Price List Rate/Last Purchase Rate of raw materials", "fieldname": "update_bom_costs_automatically", "fieldtype": "Check", "label": "Update BOM Cost Automatically" @@ -135,7 +136,7 @@ { "fieldname": "over_production_for_sales_and_work_order_section", "fieldtype": "Section Break", - "label": "Over Production for Sales and Work Order" + "label": "Overproduction for Sales and Work Order" }, { "fieldname": "raw_materials_consumption_section", @@ -157,8 +158,10 @@ } ], "icon": "icon-wrench", + "index_web_pages_for_search": 1, "issingle": 1, - "modified": "2019-11-26 13:10:45.569341", + "links": [], + "modified": "2020-10-13 10:55:43.996581", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", @@ -175,4 +178,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json index f93b244a504..6c60bbde86c 100644 --- a/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json +++ b/erpnext/manufacturing/doctype/material_request_plan_item/material_request_plan_item.json @@ -11,10 +11,14 @@ "from_warehouse", "warehouse", "column_break_4", + "required_bom_qty", "quantity", "uom", "projected_qty", "actual_qty", + "ordered_qty", + "reserved_qty_for_production", + "safety_stock", "item_details", "description", "min_order_qty", @@ -129,11 +133,40 @@ "fieldtype": "Link", "label": "From Warehouse", "options": "Warehouse" + }, + { + "fetch_from": "item_code.safety_stock", + "fieldname": "safety_stock", + "fieldtype": "Float", + "label": "Safety Stock", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "ordered_qty", + "fieldtype": "Float", + "label": "Ordered Qty", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "reserved_qty_for_production", + "fieldtype": "Float", + "label": "Reserved Qty for Production", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "required_bom_qty", + "fieldtype": "Float", + "label": "Required Qty as per BOM", + "no_copy": 1, + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2020-02-03 12:22:29.913302", + "modified": "2021-03-26 12:41:13.013149", "modified_by": "Administrator", "module": "Manufacturing", "name": "Material Request Plan Item", diff --git a/erpnext/manufacturing/doctype/operation/test_operation.py b/erpnext/manufacturing/doctype/operation/test_operation.py index 17d206a4e1f..00672317018 100644 --- a/erpnext/manufacturing/doctype/operation/test_operation.py +++ b/erpnext/manufacturing/doctype/operation/test_operation.py @@ -9,3 +9,23 @@ test_records = frappe.get_test_records('Operation') class TestOperation(unittest.TestCase): pass + +def make_operation(*args, **kwargs): + args = args if args else kwargs + if isinstance(args, tuple): + args = args[0] + + args = frappe._dict(args) + + try: + doc = frappe.get_doc({ + "doctype": "Operation", + "name": args.operation, + "workstation": args.workstation + }) + + doc.insert() + + return doc + except frappe.DuplicateEntryError: + return frappe.get_doc("Operation", args.operation) \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index 1a64bc5e248..15ec6209c11 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -56,23 +56,35 @@ frappe.ui.form.on('Production Plan', { refresh: function(frm) { if (frm.doc.docstatus === 1) { frm.trigger("show_progress"); + + if (frm.doc.status !== "Completed") { + if (frm.doc.po_items && frm.doc.status !== "Closed") { + frm.add_custom_button(__("Work Order"), ()=> { + frm.trigger("make_work_order"); + }, __('Create')); + } + + if (frm.doc.mr_items && !in_list(['Material Requested', 'Closed'], frm.doc.status)) { + frm.add_custom_button(__("Material Request"), ()=> { + frm.trigger("make_material_request"); + }, __('Create')); + } + + if (frm.doc.status === "Closed") { + frm.add_custom_button(__("Re-open"), function() { + frm.events.close_open_production_plan(frm, false); + }, __("Status")); + } else { + frm.add_custom_button(__("Close"), function() { + frm.events.close_open_production_plan(frm, true); + }, __("Status")); + } + } } - if (frm.doc.docstatus === 1 && frm.doc.po_items - && frm.doc.status != 'Completed') { - frm.add_custom_button(__("Work Order"), ()=> { - frm.trigger("make_work_order"); - }, __('Create')); + if (frm.doc.status !== "Closed") { + frm.page.set_inner_btn_group_as_primary(__('Create')); } - - if (frm.doc.docstatus === 1 && frm.doc.mr_items - && !in_list(['Material Requested', 'Completed'], frm.doc.status)) { - frm.add_custom_button(__("Material Request"), ()=> { - frm.trigger("make_material_request"); - }, __('Create')); - } - - frm.page.set_inner_btn_group_as_primary(__('Create')); frm.trigger("material_requirement"); const projected_qty_formula = ` @@ -121,6 +133,18 @@ frappe.ui.form.on('Production Plan', { set_field_options("projected_qty_formula", projected_qty_formula); }, + close_open_production_plan: (frm, close=false) => { + frappe.call({ + method: "set_status", + freeze: true, + doc: frm.doc, + args: {close : close}, + callback: function() { + frm.reload_doc(); + } + }); + }, + make_work_order: function(frm) { frappe.call({ method: "make_work_order", @@ -227,7 +251,8 @@ frappe.ui.form.on('Production Plan', { get_items_for_material_requests: function(frm, warehouses) { const set_fields = ['actual_qty', 'item_code','item_name', 'description', 'uom', 'from_warehouse', - 'min_order_qty', 'quantity', 'sales_order', 'warehouse', 'projected_qty', 'material_request_type']; + 'min_order_qty', 'required_bom_qty', 'quantity', 'sales_order', 'warehouse', 'projected_qty', 'ordered_qty', + 'reserved_qty_for_production', 'material_request_type']; frappe.call({ method: "erpnext.manufacturing.doctype.production_plan.production_plan.get_items_for_material_requests", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 90e8b22ed91..f11470086af 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -19,6 +19,7 @@ "column_break2", "from_date", "to_date", + "sales_order_status", "sales_orders_detail", "get_sales_orders", "sales_orders", @@ -31,6 +32,7 @@ "material_request_planning", "include_non_stock_items", "include_subcontracted_items", + "include_safety_stock", "ignore_existing_ordered_qty", "column_break_25", "for_warehouse", @@ -275,7 +277,7 @@ "fieldtype": "Select", "label": "Status", "no_copy": 1, - "options": "\nDraft\nSubmitted\nNot Started\nIn Process\nCompleted\nStopped\nCancelled\nMaterial Requested", + "options": "\nDraft\nSubmitted\nNot Started\nIn Process\nCompleted\nClosed\nCancelled\nMaterial Requested", "print_hide": 1, "read_only": 1 }, @@ -301,12 +303,26 @@ "label": "Warehouses", "options": "Production Plan Material Request Warehouse", "read_only": 1 + }, + { + "depends_on": "eval: doc.get_items_from == \"Sales Order\"", + "fieldname": "sales_order_status", + "fieldtype": "Select", + "label": "Sales Order Status", + "options": "\nTo Deliver and Bill\nTo Bill\nTo Deliver" + }, + { + "default": "0", + "fieldname": "include_safety_stock", + "fieldtype": "Check", + "label": "Include Safety Stock in Required Qty Calculation" } ], "icon": "fa fa-calendar", + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-02-03 00:25:25.934202", + "modified": "2021-03-08 11:17:25.470147", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index c8892376b7a..109c8b5647f 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -219,13 +219,17 @@ class ProductionPlan(Document): filters = {'docstatus': 0, 'production_plan': ("=", self.name)}): frappe.delete_doc('Work Order', d.name) - def set_status(self): + def set_status(self, close=None): self.status = { 0: 'Draft', 1: 'Submitted', 2: 'Cancelled' }.get(self.docstatus) + if close: + self.db_set('status', 'Closed') + return + if self.total_produced_qty > 0: self.status = "In Process" if self.total_produced_qty == self.total_planned_qty: @@ -235,6 +239,9 @@ class ProductionPlan(Document): self.update_ordered_status() self.update_requested_status() + if close is not None: + self.db_set('status', self.status) + def update_ordered_status(self): update_status = False for d in self.po_items: @@ -312,7 +319,7 @@ class ProductionPlan(Document): frappe.flags.mute_messages = False if wo_list: - wo_list = ["""%s""" % \ + wo_list = ["""%s""" % \ (p, p) for p in wo_list] msgprint(_("{0} created").format(comma_and(wo_list))) else : @@ -322,12 +329,13 @@ class ProductionPlan(Document): work_orders = [] bom_data = {} - get_sub_assembly_items(item.get("bom_no"), bom_data) + get_sub_assembly_items(item.get("bom_no"), bom_data, item.get("qty")) for key, data in bom_data.items(): data.update({ - 'qty': data.get("stock_qty") * item.get("qty"), + 'qty': data.get("stock_qty"), 'production_plan': self.name, + 'use_multi_level_bom': item.get("use_multi_level_bom"), 'company': self.company, 'fg_warehouse': item.get("fg_warehouse"), 'update_consumed_material_cost_in_project': 0 @@ -381,7 +389,6 @@ class ProductionPlan(Document): "transaction_date": nowdate(), "status": "Draft", "company": self.company, - "requested_by": frappe.session.user, 'material_request_type': material_request_type, 'customer': item_doc.customer or '' }) @@ -416,7 +423,7 @@ class ProductionPlan(Document): frappe.flags.mute_messages = False if material_request_list: - material_request_list = ["""{1}""".format(m.name, m.name) \ + material_request_list = ["""{1}""".format(m.name, m.name) \ for m in material_request_list] msgprint(_("{0} created").format(comma_and(material_request_list))) else : @@ -427,12 +434,14 @@ def download_raw_materials(doc): if isinstance(doc, string_types): doc = frappe._dict(json.loads(doc)) - item_list = [['Item Code', 'Description', 'Stock UOM', 'Required Qty', 'Warehouse', - 'projected Qty', 'Actual Qty']] + item_list = [['Item Code', 'Description', 'Stock UOM', 'Warehouse', 'Required Qty as per BOM', + 'Projected Qty', 'Actual Qty', 'Ordered Qty', 'Reserved Qty for Production', + 'Safety Stock', 'Required Qty']] for d in get_items_for_material_requests(doc): - item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('quantity'), - d.get('warehouse'), d.get('projected_qty'), d.get('actual_qty')]) + item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('warehouse'), + d.get('required_bom_qty'), d.get('projected_qty'), d.get('actual_qty'), d.get('ordered_qty'), + d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')]) if not doc.get('for_warehouse'): row = {'item_code': d.get('item_code')} @@ -440,8 +449,9 @@ def download_raw_materials(doc): if d.get("warehouse") == bin_dict.get('warehouse'): continue - item_list.append(['', '', '', '', bin_dict.get('warehouse'), - bin_dict.get('projected_qty', 0), bin_dict.get('actual_qty', 0)]) + item_list.append(['', '', '', bin_dict.get('warehouse'), '', + bin_dict.get('projected_qty', 0), bin_dict.get('actual_qty', 0), + bin_dict.get('ordered_qty', 0), bin_dict.get('reserved_qty_for_production', 0)]) build_csv_response(item_list, doc.name) @@ -475,7 +485,7 @@ def get_subitems(doc, data, item_details, bom_no, company, include_non_stock_ite ifnull(%(parent_qty)s * sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * %(planned_qty)s, 0) as qty, item.is_sub_contracted_item as is_sub_contracted, bom_item.source_warehouse, item.default_bom as default_bom, bom_item.description as description, - bom_item.stock_uom as stock_uom, item.min_order_qty as min_order_qty, + bom_item.stock_uom as stock_uom, item.min_order_qty as min_order_qty, item.safety_stock as safety_stock, item_default.default_warehouse, item.purchase_uom, item_uom.conversion_factor FROM `tabBOM Item` bom_item @@ -511,8 +521,8 @@ def get_subitems(doc, data, item_details, bom_no, company, include_non_stock_ite include_non_stock_items, include_subcontracted_items, d.qty) return item_details -def get_material_request_items(row, sales_order, - company, ignore_existing_ordered_qty, warehouse, bin_dict): +def get_material_request_items(row, sales_order, company, + ignore_existing_ordered_qty, include_safety_stock, warehouse, bin_dict): total_qty = row['qty'] required_qty = 0 @@ -536,17 +546,24 @@ def get_material_request_items(row, sales_order, if frappe.db.get_value("UOM", row['purchase_uom'], "must_be_whole_number"): required_qty = ceil(required_qty) + if include_safety_stock: + required_qty += flt(row['safety_stock']) + if required_qty > 0: return { 'item_code': row.item_code, 'item_name': row.item_name, 'quantity': required_qty, + 'required_bom_qty': total_qty, 'description': row.description, 'stock_uom': row.get("stock_uom"), 'warehouse': warehouse or row.get('source_warehouse') \ or row.get('default_warehouse') or item_group_defaults.get("default_warehouse"), + 'safety_stock': row.safety_stock, 'actual_qty': bin_dict.get("actual_qty", 0), 'projected_qty': bin_dict.get("projected_qty", 0), + 'ordered_qty': bin_dict.get("ordered_qty", 0), + 'reserved_qty_for_production': bin_dict.get("reserved_qty_for_production", 0), 'min_order_qty': row['min_order_qty'], 'material_request_type': row.get("default_material_request_type"), 'sales_order': sales_order, @@ -564,6 +581,8 @@ def get_sales_orders(self): so_filter += " and so.customer = %(customer)s" if self.project: so_filter += " and so.project = %(project)s" + if self.sales_order_status: + so_filter += "and so.status = %(sales_order_status)s" if self.item_code: item_filter += " and so_item.item_code = %(item)s" @@ -587,8 +606,8 @@ def get_sales_orders(self): "customer": self.customer, "project": self.project, "item": self.item_code, - "company": self.company - + "company": self.company, + "sales_order_status": self.sales_order_status }, as_dict=1) return open_so @@ -611,7 +630,8 @@ def get_bin_details(row, company, for_warehouse=None, all_warehouse=False): """.format(lft, rgt, company) return frappe.db.sql(""" select ifnull(sum(projected_qty),0) as projected_qty, - ifnull(sum(actual_qty),0) as actual_qty, warehouse from `tabBin` + ifnull(sum(actual_qty),0) as actual_qty, ifnull(sum(ordered_qty),0) as ordered_qty, + ifnull(sum(reserved_qty_for_production),0) as reserved_qty_for_production, warehouse from `tabBin` where item_code = %(item_code)s {conditions} group by item_code, warehouse """.format(conditions=conditions), { "item_code": row['item_code'] }, as_dict=1) @@ -651,6 +671,7 @@ def get_items_for_material_requests(doc, warehouses=None): company = doc.get('company') ignore_existing_ordered_qty = doc.get('ignore_existing_ordered_qty') + include_safety_stock = doc.get('include_safety_stock') so_item_details = frappe._dict() for data in po_items: @@ -702,6 +723,7 @@ def get_items_for_material_requests(doc, warehouses=None): 'description' : item_master.description, 'stock_uom' : item_master.stock_uom, 'conversion_factor' : conversion_factor, + 'safety_stock': item_master.safety_stock } ) @@ -723,7 +745,7 @@ def get_items_for_material_requests(doc, warehouses=None): if details.qty > 0: items = get_material_request_items(details, sales_order, company, - ignore_existing_ordered_qty, warehouse, bin_dict) + ignore_existing_ordered_qty, include_safety_stock, warehouse, bin_dict) if items: mr_items.append(items) @@ -735,10 +757,12 @@ def get_items_for_material_requests(doc, warehouses=None): mr_items = new_mr_items if not mr_items: - frappe.msgprint(_("""As raw materials projected quantity is more than required quantity, - there is no need to create material request for the warehouse {0}. - Still if you want to make material request, - kindly enable Ignore Existing Projected Quantity checkbox""").format(doc.get('for_warehouse'))) + to_enable = frappe.bold(_("Ignore Existing Projected Quantity")) + warehouse = frappe.bold(doc.get('for_warehouse')) + message = _("As there are sufficient raw materials, Material Request is not required for Warehouse {0}.").format(warehouse) + "

    " + message += _(" If you still want to proceed, please enable {0}.").format(to_enable) + + frappe.msgprint(message, title=_("Note")) return mr_items @@ -782,7 +806,7 @@ def get_item_data(item_code): # "description": item_details.get("description") } -def get_sub_assembly_items(bom_no, bom_data): +def get_sub_assembly_items(bom_no, bom_data, to_produce_qty): data = get_children('BOM', parent = bom_no) for d in data: if d.expandable: @@ -799,6 +823,6 @@ def get_sub_assembly_items(bom_no, bom_data): }) bom_item = bom_data.get(key) - bom_item["stock_qty"] += d.stock_qty / d.parent_bom_qty + bom_item["stock_qty"] += (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) - get_sub_assembly_items(bom_item.get("bom_no"), bom_data) + get_sub_assembly_items(bom_item.get("bom_no"), bom_data, bom_item["stock_qty"]) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan_list.js b/erpnext/manufacturing/doctype/production_plan/production_plan_list.js index d377ef0af76..c2e3e6d7124 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan_list.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan_list.js @@ -1,16 +1,17 @@ frappe.listview_settings['Production Plan'] = { add_fields: ["status"], - filters: [["status", "!=", "Stopped"]], - get_indicator: function(doc) { - if(doc.status==="Submitted") { + filters: [["status", "!=", "Closed"]], + get_indicator: function (doc) { + if (doc.status === "Submitted") { return [__("Not Started"), "orange", "status,=,Submitted"]; } else { return [__(doc.status), { "Draft": "red", "In Process": "orange", "Completed": "green", - "Material Requested": "darkgrey", - "Cancelled": "darkgrey" + "Material Requested": "yellow", + "Cancelled": "gray", + "Closed": "grey" }[doc.status], "status,=," + doc.status]; } } diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index ca67d71bb0c..27335aa204b 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -137,7 +137,8 @@ class TestProductionPlan(unittest.TestCase): 'from_date': so.transaction_date, 'to_date': so.transaction_date, 'customer': so.customer, - 'item_code': item + 'item_code': item, + 'sales_order_status': so.status }) sales_orders = get_sales_orders(pln) or {} sales_orders = [d.get('name') for d in sales_orders if d.get('name') == sales_order] @@ -158,6 +159,46 @@ class TestProductionPlan(unittest.TestCase): self.assertTrue(mr.material_request_type, 'Customer Provided') self.assertTrue(mr.customer, '_Test Customer') + def test_production_plan_with_multi_level_bom(self): + #|Item Code | Qty | + #|Test BOM 1 | 1 | + #| Test BOM 2 | 2 | + #| Test BOM 3 | 3 | + + for item_code in ["Test BOM 1", "Test BOM 2", "Test BOM 3", "Test RM BOM 1"]: + create_item(item_code, is_stock_item=1) + + # created bom upto 3 level + if not frappe.db.get_value('BOM', {'item': "Test BOM 3"}): + make_bom(item = "Test BOM 3", raw_materials = ["Test RM BOM 1"], rm_qty=3) + + if not frappe.db.get_value('BOM', {'item': "Test BOM 2"}): + make_bom(item = "Test BOM 2", raw_materials = ["Test BOM 3"], rm_qty=3) + + if not frappe.db.get_value('BOM', {'item': "Test BOM 1"}): + make_bom(item = "Test BOM 1", raw_materials = ["Test BOM 2"], rm_qty=2) + + item_code = "Test BOM 1" + pln = frappe.new_doc('Production Plan') + pln.company = "_Test Company" + pln.append("po_items", { + "item_code": item_code, + "bom_no": frappe.db.get_value('BOM', {'item': "Test BOM 1"}), + "planned_qty": 3, + "make_work_order_for_sub_assembly_items": 1 + }) + + pln.submit() + pln.make_work_order() + + #last level sub-assembly work order produce qty + to_produce_qty = frappe.db.get_value("Work Order", + {"production_plan": pln.name, "production_item": "Test BOM 3"}, "qty") + + self.assertEqual(to_produce_qty, 18.0) + pln.cancel() + frappe.delete_doc("Production Plan", pln.name) + def create_production_plan(**args): args = frappe._dict(args) @@ -197,7 +238,9 @@ def make_bom(**args): 'item': args.item, 'currency': args.currency or 'USD', 'quantity': args.quantity or 1, - 'company': args.company or '_Test Company' + 'company': args.company or '_Test Company', + 'routing': args.routing, + 'with_operations': args.with_operations or 0 }) for item in args.raw_materials: @@ -205,12 +248,16 @@ def make_bom(**args): bom.append('items', { 'item_code': item, - 'qty': 1, + 'qty': args.rm_qty or 1.0, 'uom': item_doc.stock_uom, 'stock_uom': item_doc.stock_uom, 'rate': item_doc.valuation_rate or args.rate, }) - bom.insert(ignore_permissions=True) - bom.submit() - return bom \ No newline at end of file + if not args.do_not_save: + bom.insert(ignore_permissions=True) + + if not args.do_not_submit: + bom.submit() + + return bom diff --git a/erpnext/manufacturing/doctype/production_plan_material_request_warehouse/production_plan_material_request_warehouse.json b/erpnext/manufacturing/doctype/production_plan_material_request_warehouse/production_plan_material_request_warehouse.json index 53e33c02657..e72f48943c8 100644 --- a/erpnext/manufacturing/doctype/production_plan_material_request_warehouse/production_plan_material_request_warehouse.json +++ b/erpnext/manufacturing/doctype/production_plan_material_request_warehouse/production_plan_material_request_warehouse.json @@ -11,30 +11,20 @@ { "fieldname": "warehouse", "fieldtype": "Link", + "in_list_view": 1, "label": "Warehouse", "options": "Warehouse" } ], + "index_web_pages_for_search": 1, + "istable": 1, "links": [], - "modified": "2020-02-02 10:37:16.650836", + "modified": "2020-10-26 12:55:00.778201", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Material Request Warehouse", "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], + "permissions": [], "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js index d7589fa3907..9b1a8ca670e 100644 --- a/erpnext/manufacturing/doctype/routing/routing.js +++ b/erpnext/manufacturing/doctype/routing/routing.js @@ -2,6 +2,21 @@ // For license information, please see license.txt frappe.ui.form.on('Routing', { + refresh: function(frm) { + frm.trigger("display_sequence_id_column"); + }, + + onload: function(frm) { + frm.trigger("display_sequence_id_column"); + }, + + display_sequence_id_column: function(frm) { + frappe.meta.get_docfield("BOM Operation", "sequence_id", + frm.doc.name).in_list_view = true; + + frm.fields_dict.operations.grid.refresh(); + }, + calculate_operating_cost: function(frm, child) { const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, 2); frappe.model.set_value(child.doctype, child.name, "operating_cost", operating_cost); diff --git a/erpnext/manufacturing/doctype/routing/routing.py b/erpnext/manufacturing/doctype/routing/routing.py index ecd0ba8be8b..8312d7436c2 100644 --- a/erpnext/manufacturing/doctype/routing/routing.py +++ b/erpnext/manufacturing/doctype/routing/routing.py @@ -3,7 +3,22 @@ # For license information, please see license.txt from __future__ import unicode_literals +import frappe +from frappe.utils import cint +from frappe import _ from frappe.model.document import Document class Routing(Document): - pass + def validate(self): + self.set_routing_id() + + def set_routing_id(self): + sequence_id = 0 + for row in self.operations: + if not row.sequence_id: + row.sequence_id = sequence_id + 1 + elif sequence_id and row.sequence_id and cint(sequence_id) > cint(row.sequence_id): + frappe.throw(_("At row #{0}: the sequence id {1} cannot be less than previous row sequence id {2}") + .format(row.idx, row.sequence_id, sequence_id)) + + sequence_id = row.sequence_id \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py index 53ad1527325..73d05a61570 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -4,6 +4,88 @@ from __future__ import unicode_literals import unittest +import frappe +from frappe.test_runner import make_test_records +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.manufacturing.doctype.operation.test_operation import make_operation +from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError +from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation +from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record class TestRouting(unittest.TestCase): - pass + def test_sequence_id(self): + item_code = "Test Routing Item - A" + operations = [{"operation": "Test Operation A", "workstation": "Test Workstation A", "time_in_mins": 30}, + {"operation": "Test Operation B", "workstation": "Test Workstation A", "time_in_mins": 20}] + + make_test_records("UOM") + + setup_operations(operations) + routing_doc = create_routing(routing_name="Testing Route", operations=operations) + bom_doc = setup_bom(item_code=item_code, routing=routing_doc.name) + wo_doc = make_wo_order_test_record(production_item = item_code, bom_no=bom_doc.name) + + for row in routing_doc.operations: + self.assertEqual(row.sequence_id, row.idx) + + for data in frappe.get_all("Job Card", + filters={"work_order": wo_doc.name}, order_by="sequence_id desc"): + job_card_doc = frappe.get_doc("Job Card", data.name) + job_card_doc.time_logs[0].completed_qty = 10 + if job_card_doc.sequence_id != 1: + self.assertRaises(OperationSequenceError, job_card_doc.save) + else: + job_card_doc.save() + self.assertEqual(job_card_doc.total_completed_qty, 10) + + wo_doc.cancel() + wo_doc.delete() + +def setup_operations(rows): + for row in rows: + make_workstation(row) + make_operation(row) + +def create_routing(**args): + args = frappe._dict(args) + + doc = frappe.new_doc("Routing") + doc.update(args) + + if not args.do_not_save: + try: + for operation in args.operations: + doc.append("operations", operation) + + doc.insert() + except frappe.DuplicateEntryError: + doc = frappe.get_doc("Routing", args.routing_name) + + return doc + +def setup_bom(**args): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + args = frappe._dict(args) + + if not frappe.db.exists('Item', args.item_code): + make_item(args.item_code, { + 'is_stock_item': 1 + }) + + if not args.raw_materials: + if not frappe.db.exists('Item', "Test Extra Item 1"): + make_item("Test Extra Item N-1", { + 'is_stock_item': 1, + }) + + args.raw_materials = ['Test Extra Item N-1'] + + name = frappe.db.get_value('BOM', {'item': args.item_code}, 'name') + if not name: + bom_doc = make_bom(item = args.item_code, raw_materials = args.get("raw_materials"), + routing = args.routing, with_operations=1) + else: + bom_doc = frappe.get_doc("BOM", name) + + return bom_doc \ 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 b7c7c328697..08291d1eae9 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -5,8 +5,7 @@ from __future__ import unicode_literals import unittest import frappe -from frappe.utils import flt, time_diff_in_hours, now, add_months, cint, today -from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_perpetual_inventory +from frappe.utils import flt, now, add_months, cint, today, add_to_date from erpnext.manufacturing.doctype.work_order.work_order import (make_stock_entry, ItemHasVariantError, stop_unstop, StockOverProductionError, OverProductionError, CapacityError) from erpnext.stock.doctype.stock_entry import test_stock_entry @@ -15,10 +14,10 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde from erpnext.stock.doctype.item.test_item import make_item from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse +from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError class TestWorkOrder(unittest.TestCase): def setUp(self): - set_perpetual_inventory(0) self.warehouse = '_Test Warehouse 2 - _TC' self.item = '_Test Item' @@ -83,7 +82,7 @@ class TestWorkOrder(unittest.TestCase): wo_order.set_work_order_operations() self.assertEqual(wo_order.planned_operating_cost, cost*2) - def test_resered_qty_for_partial_completion(self): + def test_reserved_qty_for_partial_completion(self): item = "_Test Item" warehouse = create_warehouse("Test Warehouse for reserved_qty - _TC") @@ -95,11 +94,11 @@ class TestWorkOrder(unittest.TestCase): wo_order = make_wo_order_test_record(item="_Test FG Item", qty=2, source_warehouse=warehouse, skip_transfer=1) - bin1_on_submit = get_bin(item, warehouse) + reserved_qty_on_submission = cint(get_bin(item, warehouse).reserved_qty_for_production) # reserved qty for production is updated - self.assertEqual(cint(bin1_at_start.reserved_qty_for_production) + 2, - cint(bin1_on_submit.reserved_qty_for_production)) + self.assertEqual(cint(bin1_at_start.reserved_qty_for_production) + 2, reserved_qty_on_submission) + test_stock_entry.make_stock_entry(item_code="_Test Item", target=warehouse, qty=100, basic_rate=100) @@ -112,7 +111,7 @@ class TestWorkOrder(unittest.TestCase): bin1_at_completion = get_bin(item, warehouse) self.assertEqual(cint(bin1_at_completion.reserved_qty_for_production), - cint(bin1_on_submit.reserved_qty_for_production) - 1) + reserved_qty_on_submission - 1) def test_production_item(self): wo_order = make_wo_order_test_record(item="_Test FG Item", qty=1, do_not_save=True) @@ -193,6 +192,42 @@ class TestWorkOrder(unittest.TestCase): self.assertEqual(cint(bin1_on_end_production.projected_qty), cint(bin1_on_end_production.projected_qty)) + def test_backflush_qty_for_overpduction_manufacture(self): + cancel_stock_entry = [] + allow_overproduction("overproduction_percentage_for_work_order", 30) + wo_order = make_wo_order_test_record(planned_start_date=now(), qty=100) + ste1 = test_stock_entry.make_stock_entry(item_code="_Test Item", + target="_Test Warehouse - _TC", qty=120, basic_rate=5000.0) + ste2 = test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100", + target="_Test Warehouse - _TC", qty=240, basic_rate=1000.0) + + cancel_stock_entry.extend([ste1.name, ste2.name]) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60)) + s.submit() + cancel_stock_entry.append(s.name) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 60)) + s.submit() + cancel_stock_entry.append(s.name) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 60)) + s.submit() + cancel_stock_entry.append(s.name) + + s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 50)) + s1.submit() + cancel_stock_entry.append(s1.name) + + self.assertEqual(s1.items[0].qty, 50) + self.assertEqual(s1.items[1].qty, 100) + cancel_stock_entry.reverse() + for ste in cancel_stock_entry: + doc = frappe.get_doc("Stock Entry", ste) + doc.cancel() + + allow_overproduction("overproduction_percentage_for_work_order", 0) + def test_reserved_qty_for_stopped_production(self): test_stock_entry.make_stock_entry(item_code="_Test Item", target= self.warehouse, qty=100, basic_rate=100) @@ -335,21 +370,49 @@ class TestWorkOrder(unittest.TestCase): self.assertEqual(ste.total_additional_costs, 1000) def test_job_card(self): + stock_entries = [] data = frappe.get_cached_value('BOM', {'docstatus': 1, 'with_operations': 1, 'company': '_Test Company'}, ['name', 'item']) - if data: - frappe.db.set_value("Manufacturing Settings", - None, "disable_capacity_planning", 0) + bom, bom_item = data - bom, bom_item = data + bom_doc = frappe.get_doc('BOM', bom) + work_order = make_wo_order_test_record(item=bom_item, qty=1, + bom_no=bom, source_warehouse="_Test Warehouse - _TC") - bom_doc = frappe.get_doc('BOM', bom) - work_order = make_wo_order_test_record(item=bom_item, qty=1, bom_no=bom) - self.assertTrue(work_order.planned_end_date) + for row in work_order.required_items: + stock_entry_doc = test_stock_entry.make_stock_entry(item_code=row.item_code, + target="_Test Warehouse - _TC", qty=row.required_qty, basic_rate=100) + stock_entries.append(stock_entry_doc) - job_cards = frappe.get_all('Job Card', filters = {'work_order': work_order.name}) - self.assertEqual(len(job_cards), len(bom_doc.operations)) + ste = frappe.get_doc(make_stock_entry(work_order.name, "Material Transfer for Manufacture", 1)) + ste.submit() + stock_entries.append(ste) + + job_cards = frappe.get_all('Job Card', filters = {'work_order': work_order.name}) + self.assertEqual(len(job_cards), len(bom_doc.operations)) + + for i, job_card in enumerate(job_cards): + doc = frappe.get_doc("Job Card", job_card) + doc.append("time_logs", { + "from_time": now(), + "hours": i, + "to_time": add_to_date(now(), i), + "completed_qty": doc.for_quantity + }) + doc.submit() + + ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) + ste1.submit() + stock_entries.append(ste1) + + for job_card in job_cards: + doc = frappe.get_doc("Job Card", job_card) + self.assertRaises(JobCardCancelError, doc.cancel) + + stock_entries.reverse() + for stock_entry in stock_entries: + stock_entry.cancel() def test_capcity_planning(self): frappe.db.set_value("Manufacturing Settings", None, { @@ -407,6 +470,177 @@ class TestWorkOrder(unittest.TestCase): ste1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 1)) self.assertEqual(len(ste1.items), 3) + def test_cost_center_for_manufacture(self): + wo_order = make_wo_order_test_record() + ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty) + self.assertEquals(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC") + + def test_operation_time_with_batch_size(self): + fg_item = "Test Batch Size Item For BOM" + rm1 = "Test Batch Size Item RM 1 For BOM" + + for item in ["Test Batch Size Item For BOM", "Test Batch Size Item RM 1 For BOM"]: + make_item(item, { + "include_item_in_manufacturing": 1, + "is_stock_item": 1 + }) + + bom_name = frappe.db.get_value("BOM", + {"item": fg_item, "is_active": 1, "with_operations": 1}, "name") + + if not bom_name: + bom = make_bom(item=fg_item, rate=1000, raw_materials = [rm1], do_not_save=True) + bom.with_operations = 1 + bom.append("operations", { + "operation": "_Test Operation 1", + "workstation": "_Test Workstation 1", + "description": "Test Data", + "operating_cost": 100, + "time_in_mins": 40, + "batch_size": 5 + }) + + bom.save() + bom.submit() + bom_name = bom.name + + work_order = make_wo_order_test_record(item=fg_item, + planned_start_date=now(), qty=1, do_not_save=True) + + work_order.set_work_order_operations() + work_order.save() + self.assertEqual(work_order.operations[0].time_in_mins, 8.0) + + work_order1 = make_wo_order_test_record(item=fg_item, + planned_start_date=now(), qty=5, do_not_save=True) + + work_order1.set_work_order_operations() + work_order1.save() + self.assertEqual(work_order1.operations[0].time_in_mins, 40.0) + + def test_partial_material_consumption(self): + frappe.db.set_value("Manufacturing Settings", None, "material_consumption", 1) + wo_order = make_wo_order_test_record(planned_start_date=now(), qty=4) + + ste_cancel_list = [] + ste1 = test_stock_entry.make_stock_entry(item_code="_Test Item", + target="_Test Warehouse - _TC", qty=20, basic_rate=5000.0) + ste2 = test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100", + target="_Test Warehouse - _TC", qty=20, basic_rate=1000.0) + + ste_cancel_list.extend([ste1, ste2]) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 4)) + s.submit() + ste_cancel_list.append(s) + + ste1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 2)) + ste1.submit() + ste_cancel_list.append(ste1) + + ste3 = frappe.get_doc(make_stock_entry(wo_order.name, "Material Consumption for Manufacture", 2)) + self.assertEquals(ste3.fg_completed_qty, 2) + + expected_qty = {"_Test Item": 2, "_Test Item Home Desktop 100": 4} + for row in ste3.items: + self.assertEquals(row.qty, expected_qty.get(row.item_code)) + ste_cancel_list.reverse() + for ste_doc in ste_cancel_list: + ste_doc.cancel() + + frappe.db.set_value("Manufacturing Settings", None, "material_consumption", 0) + + def test_extra_material_transfer(self): + frappe.db.set_value("Manufacturing Settings", None, "material_consumption", 0) + frappe.db.set_value("Manufacturing Settings", None, "backflush_raw_materials_based_on", + "Material Transferred for Manufacture") + + wo_order = make_wo_order_test_record(planned_start_date=now(), qty=4) + + ste_cancel_list = [] + ste1 = test_stock_entry.make_stock_entry(item_code="_Test Item", + target="_Test Warehouse - _TC", qty=20, basic_rate=5000.0) + ste2 = test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100", + target="_Test Warehouse - _TC", qty=20, basic_rate=1000.0) + + ste_cancel_list.extend([ste1, ste2]) + + itemwise_qty = {} + s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 4)) + for row in s.items: + row.qty = row.qty + 2 + itemwise_qty.setdefault(row.item_code, row.qty) + + s.submit() + ste_cancel_list.append(s) + + ste3 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 2)) + for ste_row in ste3.items: + if itemwise_qty.get(ste_row.item_code) and ste_row.s_warehouse: + self.assertEquals(ste_row.qty, itemwise_qty.get(ste_row.item_code) / 2) + + ste3.submit() + ste_cancel_list.append(ste3) + + ste2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 2)) + for ste_row in ste2.items: + if itemwise_qty.get(ste_row.item_code) and ste_row.s_warehouse: + self.assertEquals(ste_row.qty, itemwise_qty.get(ste_row.item_code) / 2) + ste_cancel_list.reverse() + for ste_doc in ste_cancel_list: + ste_doc.cancel() + + frappe.db.set_value("Manufacturing Settings", None, "backflush_raw_materials_based_on", "BOM") + + def test_make_stock_entry_for_customer_provided_item(self): + finished_item = 'Test Item for Make Stock Entry 1' + make_item(finished_item, { + "include_item_in_manufacturing": 1, + "is_stock_item": 1 + }) + + customer_provided_item = 'CUST-0987' + make_item(customer_provided_item, { + 'is_purchase_item': 0, + 'is_customer_provided_item': 1, + "is_stock_item": 1, + "include_item_in_manufacturing": 1, + 'customer': '_Test Customer' + }) + + if not frappe.db.exists('BOM', {'item': finished_item}): + make_bom(item=finished_item, raw_materials=[customer_provided_item], rm_qty=1) + + company = "_Test Company with perpetual inventory" + customer_warehouse = create_warehouse("Test Customer Provided Warehouse", company=company) + wo = make_wo_order_test_record(item=finished_item, qty=1, source_warehouse=customer_warehouse, + company=company) + + ste = frappe.get_doc(make_stock_entry(wo.name, purpose='Material Transfer for Manufacture')) + ste.insert() + + self.assertEqual(len(ste.items), 1) + for item in ste.items: + self.assertEqual(item.allow_zero_valuation_rate, 1) + self.assertEqual(item.valuation_rate, 0) + + def test_valuation_rate_missing_on_make_stock_entry(self): + item_name = 'Test Valuation Rate Missing' + make_item(item_name, { + "is_stock_item": 1, + "include_item_in_manufacturing": 1, + }) + + if not frappe.db.get_value('BOM', {'item': item_name}): + make_bom(item=item_name, raw_materials=[item_name], rm_qty=1) + + company = "_Test Company with perpetual inventory" + source_warehouse = create_warehouse("Test Valuation Rate Missing Warehouse", company=company) + wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse, + company=company) + + self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture') + def get_scrap_item_details(bom_no): scrap_items = {} for item in frappe.db.sql("""select item_code, stock_qty from `tabBOM Scrap Item` @@ -424,6 +658,15 @@ def allow_overproduction(fieldname, percentage): def make_wo_order_test_record(**args): args = frappe._dict(args) + if args.company and args.company != "_Test Company": + warehouse_map = { + "fg_warehouse": "_Test FG Warehouse", + "wip_warehouse": "_Test WIP Warehouse" + } + + for attr, wh_name in warehouse_map.items(): + if not args.get(attr): + args[attr] = create_warehouse(wh_name, company=args.company) wo_order = frappe.new_doc("Work Order") wo_order.production_item = args.production_item or args.item or args.item_code or "_Test FG Item" diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index a244f582c42..a6086fb88da 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -545,7 +545,8 @@ erpnext.work_order = { var tbl = frm.doc.required_items || []; var tbl_lenght = tbl.length; for (var i = 0, len = tbl_lenght; i < len; i++) { - if (flt(frm.doc.required_items[i].required_qty) > flt(frm.doc.required_items[i].consumed_qty)) { + let wo_item_qty = frm.doc.required_items[i].transferred_qty || frm.doc.required_items[i].required_qty; + if (flt(wo_item_qty) > flt(frm.doc.required_items[i].consumed_qty)) { counter += 1; } } @@ -636,7 +637,7 @@ erpnext.work_order = { description: __('Max: {0}', [max]), default: max }, data => { - max += (max * (frm.doc.__onload.overproduction_percentage || 0.0)) / 100; + max += (frm.doc.qty * (frm.doc.__onload.overproduction_percentage || 0.0)) / 100; if (data.qty > max) { frappe.msgprint(__('Quantity must not be more than {0}', [max])); diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 585a09db2bf..cd9edeeea83 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -333,8 +333,7 @@ "fieldname": "operations", "fieldtype": "Table", "label": "Operations", - "options": "Work Order Operation", - "read_only": 1 + "options": "Work Order Operation" }, { "depends_on": "operations", @@ -496,7 +495,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2020-05-05 19:32:43.323054", + "modified": "2021-03-16 13:27:51.116484", "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 b7d968e9745..3d64ad4318d 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -378,7 +378,7 @@ class WorkOrder(Document): select operation, description, workstation, idx, base_hour_rate as hour_rate, time_in_mins, - "Pending" as status, parent as bom, batch_size + "Pending" as status, parent as bom, batch_size, sequence_id from `tabBOM Operation` where @@ -403,7 +403,7 @@ class WorkOrder(Document): bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity") for d in self.get("operations"): - d.time_in_mins = flt(d.time_in_mins) / flt(bom_qty) * math.ceil(flt(self.qty) / flt(d.batch_size)) + d.time_in_mins = flt(d.time_in_mins) / flt(bom_qty) * (flt(self.qty) / flt(d.batch_size)) self.calculate_operating_cost() @@ -434,7 +434,7 @@ class WorkOrder(Document): elif flt(d.completed_qty) <= max_allowed_qty_for_wo: d.status = "Completed" else: - frappe.throw(_("Completed Qty can not be greater than 'Qty to Manufacture'")) + frappe.throw(_("Completed Qty cannot be greater than 'Qty to Manufacture'")) def set_actual_dates(self): if self.get("operations"): @@ -456,10 +456,10 @@ class WorkOrder(Document): if data and len(data): dates = [d.posting_datetime for d in data] - self.actual_start_date = min(dates) + self.db_set('actual_start_date', min(dates)) if self.status == "Completed": - self.actual_end_date = max(dates) + self.db_set('actual_end_date', max(dates)) self.set_lead_time() @@ -528,6 +528,10 @@ class WorkOrder(Document): if not reset_only_qty: self.required_items = [] + operation = None + if self.get('operations') and len(self.operations) == 1: + operation = self.operations[0].operation + if self.bom_no and self.qty: item_dict = get_bom_items_as_dict(self.bom_no, self.company, qty=self.qty, fetch_exploded = self.use_multi_level_bom) @@ -536,6 +540,9 @@ class WorkOrder(Document): for d in self.get("required_items"): if item_dict.get(d.item_code): d.required_qty = item_dict.get(d.item_code).get("qty") + + if not d.operation: + d.operation = operation else: # Attribute a big number (999) to idx for sorting putpose in case idx is NULL # For instance in BOM Explosion Item child table, the items coming from sub assembly items @@ -543,7 +550,7 @@ class WorkOrder(Document): self.append('required_items', { 'rate': item.rate, 'amount': item.amount, - 'operation': item.operation, + 'operation': item.operation or operation, 'item_code': item.item_code, 'item_name': item.item_name, 'description': item.description, @@ -725,6 +732,7 @@ def add_variant_item(variant_items, wo_doc, bom_no, table_name="items"): args.update(item_data) args["rate"] = get_bom_item_rate({ + "company": wo_doc.company, "item_code": args.get("item_code"), "qty": args.get("required_qty"), "uom": args.get("stock_uom"), @@ -865,6 +873,7 @@ def create_job_card(work_order, row, qty=0, enable_capacity_planning=False, auto 'bom_no': work_order.bom_no, 'project': work_order.project, 'company': work_order.company, + 'sequence_id': row.get("sequence_id"), 'wip_warehouse': work_order.wip_warehouse }) @@ -877,7 +886,7 @@ def create_job_card(work_order, row, qty=0, enable_capacity_planning=False, auto doc.schedule_time_logs(row) doc.insert() - frappe.msgprint(_("Job card {0} created").format(get_link_to_form("Job Card", doc.name))) + frappe.msgprint(_("Job card {0} created").format(get_link_to_form("Job Card", doc.name)), alert=True) return doc diff --git a/erpnext/manufacturing/doctype/work_order/work_order_list.js b/erpnext/manufacturing/doctype/work_order/work_order_list.js index 8d18395acd4..81c23bb7104 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order_list.js +++ b/erpnext/manufacturing/doctype/work_order/work_order_list.js @@ -12,7 +12,7 @@ frappe.listview_settings['Work Order'] = { "Not Started": "red", "In Process": "orange", "Completed": "green", - "Cancelled": "darkgrey" + "Cancelled": "gray" }[doc.status], "status,=," + doc.status]; } } diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index 3f5e18e8130..8c5cde9a13c 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -8,6 +8,7 @@ "details", "operation", "bom", + "sequence_id", "description", "col_break1", "completed_qty", @@ -187,11 +188,19 @@ "fieldtype": "Int", "label": "Batch Size", "read_only": 1 + }, + { + "fieldname": "sequence_id", + "fieldtype": "Int", + "label": "Sequence ID", + "print_hide": 1, + "read_only": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2019-12-03 19:24:29.594189", + "modified": "2020-10-14 12:58:49.241252", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation", diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index 8266cf7b779..c6699bee48c 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -21,17 +21,22 @@ class TestWorkstation(unittest.TestCase): self.assertRaises(WorkstationHolidayError, check_if_within_operating_hours, "_Test Workstation 1", "Operation 1", "2013-02-01 10:00:00", "2013-02-02 20:00:00") -def make_workstation(**args): +def make_workstation(*args, **kwargs): + args = args if args else kwargs + if isinstance(args, tuple): + args = args[0] + args = frappe._dict(args) + workstation_name = args.workstation_name or args.workstation try: doc = frappe.get_doc({ "doctype": "Workstation", - "workstation_name": args.workstation_name + "workstation_name": workstation_name }) doc.insert() return doc except frappe.DuplicateEntryError: - return frappe.get_doc("Workstation", args.workstation_name) \ No newline at end of file + return frappe.get_doc("Workstation", workstation_name) \ No newline at end of file diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py index f7b407b7922..ffd9242e1b8 100644 --- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py +++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py @@ -88,11 +88,11 @@ def get_bom_stock(filters): GROUP BY bom_item.item_code""".format(qty_field=qty_field, table=table, conditions=conditions, bom=bom), as_dict=1) def get_manufacturer_records(): - details = frappe.get_list('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no, parent"]) + details = frappe.get_list('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "parent"]) manufacture_details = frappe._dict() for detail in details: dic = manufacture_details.setdefault(detail.get('parent'), {}) dic.setdefault('manufacturer', []).append(detail.get('manufacturer')) dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no')) - return manufacture_details \ No newline at end of file + return manufacture_details 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 2ac6fa073bf..7beecaceedf 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.js @@ -25,11 +25,11 @@ 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){ - value = `${data['Item']}` + if (column.id == "item") { + if (data["enough_parts_to_build"] > 0) { + value = `${data['item']}`; } else { - value = `${data['Item']}` + value = `${data['item']}`; } } return value 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 75ebcbc971b..1c6758e6f36 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py @@ -20,6 +20,7 @@ def get_columns(): _("Item") + ":Link/Item:150", _("Description") + "::300", _("BOM Qty") + ":Float:160", + _("BOM UoM") + "::160", _("Required Qty") + ":Float:120", _("In Stock Qty") + ":Float:120", _("Enough Parts to Build") + ":Float:200", @@ -32,7 +33,7 @@ def get_bom_stock(filters): bom = filters.get("bom") table = "`tabBOM Item`" - qty_field = "qty" + qty_field = "stock_qty" qty_to_produce = filters.get("qty_to_produce", 1) if int(qty_to_produce) <= 0: @@ -40,7 +41,6 @@ def get_bom_stock(filters): if filters.get("show_exploded_view"): table = "`tabBOM Explosion Item`" - qty_field = "stock_qty" if filters.get("warehouse"): warehouse_details = frappe.db.get_value("Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1) @@ -59,6 +59,7 @@ def get_bom_stock(filters): bom_item.item_code, bom_item.description , bom_item.{qty_field}, + bom_item.stock_uom, bom_item.{qty_field} * {qty_to_produce} / bom.quantity, sum(ledger.actual_qty) as actual_qty, sum(FLOOR(ledger.actual_qty / (bom_item.{qty_field} * {qty_to_produce} / bom.quantity))) diff --git a/erpnext/manufacturing/report/exponential_smoothing_forecasting/exponential_smoothing_forecasting.py b/erpnext/manufacturing/report/exponential_smoothing_forecasting/exponential_smoothing_forecasting.py index 2ca9f1694b3..fc27d355984 100644 --- a/erpnext/manufacturing/report/exponential_smoothing_forecasting/exponential_smoothing_forecasting.py +++ b/erpnext/manufacturing/report/exponential_smoothing_forecasting/exponential_smoothing_forecasting.py @@ -61,7 +61,7 @@ class ForecastingReport(ExponentialSmoothingForecast): from_date = add_years(self.filters.from_date, cint(self.filters.no_of_years) * -1) self.period_list = get_period_list(from_date, self.filters.to_date, - from_date, self.filters.to_date, None, self.filters.periodicity, ignore_fiscal_year=True) + from_date, self.filters.to_date, "Date Range", self.filters.periodicity, ignore_fiscal_year=True) order_data = self.get_data_for_forecast() or [] diff --git a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py index ebc01c65afb..806d268ffde 100644 --- a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py +++ b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py @@ -124,7 +124,7 @@ class ProductionPlanReport(object): if self.filters.include_subassembly_raw_materials else "(bom_item.qty / bom.quantity)") raw_materials = frappe.db.sql(""" SELECT bom_item.parent, bom_item.item_code, - bom_item.item_name as raw_material_name, {0} as required_qty + bom_item.item_name as raw_material_name, {0} as required_qty_per_unit FROM `tabBOM` as bom, `tab{1}` as bom_item WHERE @@ -208,7 +208,7 @@ class ProductionPlanReport(object): warehouses = self.mrp_warehouses or [] for d in self.raw_materials_dict.get(key): if self.filters.based_on != "Work Order": - d.required_qty = d.required_qty * data.qty_to_manufacture + d.required_qty = d.required_qty_per_unit * data.qty_to_manufacture if not warehouses: warehouses = [data.warehouse] diff --git a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json new file mode 100644 index 00000000000..a355203e4d7 --- /dev/null +++ b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json @@ -0,0 +1,350 @@ +{ + "category": "Domains", + "charts": [ + { + "chart_name": "Produced Quantity" + } + ], + "creation": "2020-03-02 17:11:37.032604", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "organization", + "idx": 0, + "is_standard": 1, + "label": "Manufacturing", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Production", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Item, BOM", + "hidden": 0, + "is_query_report": 0, + "label": "Work Order", + "link_to": "Work Order", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, BOM", + "hidden": 0, + "is_query_report": 0, + "label": "Production Plan", + "link_to": "Production Plan", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Stock Entry", + "link_to": "Stock Entry", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Job Card", + "link_to": "Job Card", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Downtime Entry", + "link_to": "Downtime Entry", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Bill of Materials", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item", + "link_to": "Item", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Bill of Materials", + "link_to": "BOM", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Workstation", + "link_to": "Workstation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Operation", + "link_to": "Operation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Routing", + "link_to": "Routing", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Work Order", + "hidden": 0, + "is_query_report": 1, + "label": "Production Planning Report", + "link_to": "Production Planning Report", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Work Order", + "hidden": 0, + "is_query_report": 1, + "label": "Work Order Summary", + "link_to": "Work Order Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Quality Inspection", + "hidden": 0, + "is_query_report": 1, + "label": "Quality Inspection Summary", + "link_to": "Quality Inspection Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Downtime Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Downtime Analysis", + "link_to": "Downtime Analysis", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Job Card", + "hidden": 0, + "is_query_report": 1, + "label": "Job Card Summary", + "link_to": "Job Card Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "BOM", + "hidden": 0, + "is_query_report": 1, + "label": "BOM Search", + "link_to": "BOM Search", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "BOM", + "hidden": 0, + "is_query_report": 1, + "label": "BOM Stock Report", + "link_to": "BOM Stock Report", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Work Order", + "hidden": 0, + "is_query_report": 1, + "label": "Production Analytics", + "link_to": "Production Analytics", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "BOM", + "hidden": 0, + "is_query_report": 1, + "label": "BOM Operations Time", + "link_to": "BOM Operations Time", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Tools", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "BOM Update Tool", + "link_to": "BOM Update Tool", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "BOM Comparison Tool", + "link_to": "bom-comparison-tool", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Manufacturing Settings", + "link_to": "Manufacturing Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:39.365928", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Manufacturing", + "onboarding": "Manufacturing", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "restrict_to_domain": "Manufacturing", + "shortcuts": [ + { + "color": "Green", + "format": "{} Active", + "label": "Item", + "link_to": "Item", + "restrict_to_domain": "Manufacturing", + "stats_filter": "{\n \"disabled\": 0\n}", + "type": "DocType" + }, + { + "color": "Green", + "format": "{} Active", + "label": "BOM", + "link_to": "BOM", + "restrict_to_domain": "Manufacturing", + "stats_filter": "{\n \"is_active\": 1\n}", + "type": "DocType" + }, + { + "color": "Yellow", + "format": "{} Open", + "label": "Work Order", + "link_to": "Work Order", + "restrict_to_domain": "Manufacturing", + "stats_filter": "{ \n \"status\": [\"in\", \n [\"Draft\", \"Not Started\", \"In Process\"]\n ]\n}", + "type": "DocType" + }, + { + "color": "Yellow", + "format": "{} Open", + "label": "Production Plan", + "link_to": "Production Plan", + "restrict_to_domain": "Manufacturing", + "stats_filter": "{ \n \"status\": [\"not in\", [\"Completed\"]]\n}", + "type": "DocType" + }, + { + "label": "Forecasting", + "link_to": "Exponential Smoothing Forecasting", + "type": "Report" + }, + { + "label": "Work Order Summary", + "link_to": "Work Order Summary", + "restrict_to_domain": "Manufacturing", + "type": "Report" + }, + { + "label": "BOM Stock Report", + "link_to": "BOM Stock Report", + "type": "Report" + }, + { + "label": "Production Planning Report", + "link_to": "Production Planning Report", + "type": "Report" + }, + { + "label": "Dashboard", + "link_to": "Manufacturing", + "restrict_to_domain": "Manufacturing", + "type": "Dashboard" + } + ] +} \ No newline at end of file diff --git a/erpnext/modules.txt b/erpnext/modules.txt index 1e2aeea36a8..62f5dce8460 100644 --- a/erpnext/modules.txt +++ b/erpnext/modules.txt @@ -25,4 +25,5 @@ Hub Node Quality Management Communication Loan Management -Payroll \ No newline at end of file +Payroll +Telephony \ No newline at end of file diff --git a/erpnext/non_profit/desk_page/non_profit/non_profit.json b/erpnext/non_profit/desk_page/non_profit/non_profit.json deleted file mode 100644 index ebe61948935..00000000000 --- a/erpnext/non_profit/desk_page/non_profit/non_profit.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Loan Management", - "links": "[\n {\n \"description\": \"Define various loan types\",\n \"label\": \"Loan Type\",\n \"name\": \"Loan Type\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Loan Application\",\n \"label\": \"Loan Application\",\n \"name\": \"Loan Application\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Loan\",\n \"name\": \"Loan\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Grant Application", - "links": "[\n {\n \"description\": \"Grant information.\",\n \"label\": \"Grant Application\",\n \"name\": \"Grant Application\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Membership", - "links": "[\n {\n \"description\": \"Member information.\",\n \"label\": \"Member\",\n \"name\": \"Member\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Membership Details\",\n \"label\": \"Membership\",\n \"name\": \"Membership\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Membership Type Details\",\n \"label\": \"Membership Type\",\n \"name\": \"Membership Type\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Billing and Gateway Settings\",\n \"label\": \"Membership Settings\",\n \"name\": \"Membership Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Volunteer", - "links": "[\n {\n \"description\": \"Volunteer information.\",\n \"label\": \"Volunteer\",\n \"name\": \"Volunteer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Volunteer Type information.\",\n \"label\": \"Volunteer Type\",\n \"name\": \"Volunteer Type\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Chapter", - "links": "[\n {\n \"description\": \"Chapter information.\",\n \"label\": \"Chapter\",\n \"name\": \"Chapter\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Donor", - "links": "[\n {\n \"description\": \"Donor information.\",\n \"label\": \"Donor\",\n \"name\": \"Donor\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Donor Type information.\",\n \"label\": \"Donor Type\",\n \"name\": \"Donor Type\",\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Domains", - "charts": [], - "creation": "2020-03-02 17:23:47.811421", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "idx": 0, - "is_standard": 1, - "label": "Non Profit", - "modified": "2020-04-13 13:41:52.373705", - "modified_by": "Administrator", - "module": "Non Profit", - "name": "Non Profit", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "restrict_to_domain": "Non Profit", - "shortcuts": [ - { - "label": "Member", - "link_to": "Member", - "type": "DocType" - }, - { - "label": "Membership Settings", - "link_to": "Membership Settings", - "type": "DocType" - }, - { - "label": "Membership", - "link_to": "Membership", - "type": "DocType" - }, - { - "label": "Chapter", - "link_to": "Chapter", - "type": "DocType" - }, - { - "label": "Chapter Member", - "link_to": "Chapter Member", - "type": "DocType" - } - ] -} \ No newline at end of file diff --git a/erpnext/non_profit/doctype/donation/__init__.py b/erpnext/non_profit/doctype/donation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/non_profit/doctype/donation/donation.js b/erpnext/non_profit/doctype/donation/donation.js new file mode 100644 index 00000000000..10e82201440 --- /dev/null +++ b/erpnext/non_profit/doctype/donation/donation.js @@ -0,0 +1,26 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Donation', { + refresh: function(frm) { + if (frm.doc.docstatus === 1 && !frm.doc.paid) { + frm.add_custom_button(__('Create Payment Entry'), function() { + frm.events.make_payment_entry(frm); + }); + } + }, + + make_payment_entry: function(frm) { + return frappe.call({ + method: 'erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry', + args: { + 'dt': frm.doc.doctype, + 'dn': frm.doc.name + }, + callback: function(r) { + var doc = frappe.model.sync(r.message); + frappe.set_route('Form', doc[0].doctype, doc[0].name); + } + }); + }, +}); diff --git a/erpnext/non_profit/doctype/donation/donation.json b/erpnext/non_profit/doctype/donation/donation.json new file mode 100644 index 00000000000..6759569d54d --- /dev/null +++ b/erpnext/non_profit/doctype/donation/donation.json @@ -0,0 +1,156 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2021-02-17 10:28:52.645731", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "donor", + "donor_name", + "email", + "column_break_4", + "company", + "date", + "payment_details_section", + "paid", + "amount", + "mode_of_payment", + "razorpay_payment_id", + "amended_from" + ], + "fields": [ + { + "fieldname": "donor", + "fieldtype": "Link", + "label": "Donor", + "options": "Donor", + "reqd": 1 + }, + { + "fetch_from": "donor.donor_name", + "fieldname": "donor_name", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Donor Name", + "read_only": 1 + }, + { + "fetch_from": "donor.email", + "fieldname": "email", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Email", + "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "date", + "fieldtype": "Date", + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "payment_details_section", + "fieldtype": "Section Break", + "label": "Payment Details" + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "reqd": 1 + }, + { + "fieldname": "mode_of_payment", + "fieldtype": "Link", + "label": "Mode of Payment", + "options": "Mode of Payment" + }, + { + "fieldname": "razorpay_payment_id", + "fieldtype": "Data", + "label": "Razorpay Payment ID", + "read_only": 1 + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "NPO-DTN-.YYYY.-" + }, + { + "default": "0", + "fieldname": "paid", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Paid" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Donation", + "print_hide": 1, + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2021-03-11 10:53:11.269005", + "modified_by": "Administrator", + "module": "Non Profit", + "name": "Donation", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Non Profit Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + } + ], + "search_fields": "donor_name, email", + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "donor_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/non_profit/doctype/donation/donation.py b/erpnext/non_profit/doctype/donation/donation.py new file mode 100644 index 00000000000..4fd1a30ab9e --- /dev/null +++ b/erpnext/non_profit/doctype/donation/donation.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import six +import json +from frappe.model.document import Document +from frappe import _ +from frappe.utils import getdate, flt, get_link_to_form +from frappe.email import sendmail_to_system_managers +from erpnext.non_profit.doctype.membership.membership import verify_signature + +class Donation(Document): + def validate(self): + if not self.donor or not frappe.db.exists('Donor', self.donor): + # for web forms + user_type = frappe.db.get_value('User', frappe.session.user, 'user_type') + if user_type == 'Website User': + self.create_donor_for_website_user() + else: + frappe.throw(_('Please select a Member')) + + def create_donor_for_website_user(self): + donor_name = frappe.get_value('Donor', dict(email=frappe.session.user)) + + if not donor_name: + user = frappe.get_doc('User', frappe.session.user) + donor = frappe.get_doc(dict( + doctype='Donor', + donor_type=self.get('donor_type'), + email=frappe.session.user, + member_name=user.get_fullname() + )).insert(ignore_permissions=True) + donor_name = donor.name + + if self.get('__islocal'): + self.donor = donor_name + + def on_payment_authorized(self, *args, **kwargs): + self.load_from_db() + self.create_payment_entry() + + def create_payment_entry(self, date=None): + settings = frappe.get_doc('Non Profit Settings') + if not settings.automate_donation_payment_entries: + return + + if not settings.donation_payment_account: + frappe.throw(_('You need to set Payment Account for Donation in {0}').format( + get_link_to_form('Non Profit Settings', 'Non Profit Settings'))) + + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + + frappe.flags.ignore_account_permission = True + pe = get_payment_entry(dt=self.doctype, dn=self.name) + frappe.flags.ignore_account_permission = False + pe.paid_from = settings.donation_debit_account + pe.paid_to = settings.donation_payment_account + pe.posting_date = date or getdate() + pe.reference_no = self.name + pe.reference_date = date or getdate() + pe.flags.ignore_mandatory = True + pe.insert() + pe.submit() + + +@frappe.whitelist(allow_guest=True) +def capture_razorpay_donations(*args, **kwargs): + """ + Creates Donation from Razorpay Webhook Request Data on payment.captured event + Creates Donor from email if not found + """ + data = frappe.request.get_data(as_text=True) + + try: + verify_signature(data, endpoint='Donation') + except Exception as e: + log = frappe.log_error(e, 'Donation Webhook Verification Error') + notify_failure(log) + return { 'status': 'Failed', 'reason': e } + + if isinstance(data, six.string_types): + data = json.loads(data) + data = frappe._dict(data) + + payment = data.payload.get('payment', {}).get('entity', {}) + payment = frappe._dict(payment) + + try: + if not data.event == 'payment.captured': + return + + # to avoid capturing subscription payments as donations + if payment.description and 'subscription' in str(payment.description).lower(): + return + + donor = get_donor(payment.email) + if not donor: + donor = create_donor(payment) + + donation = create_donation(donor, payment) + donation.run_method('create_payment_entry') + + except Exception as e: + message = '{0}\n\n{1}\n\n{2}: {3}'.format(e, frappe.get_traceback(), _('Payment ID'), payment.id) + log = frappe.log_error(message, _('Error creating donation entry for {0}').format(donor.name)) + notify_failure(log) + return { 'status': 'Failed', 'reason': e } + + return { 'status': 'Success' } + + +def create_donation(donor, payment): + if not frappe.db.exists('Mode of Payment', payment.method): + create_mode_of_payment(payment.method) + + company = get_company_for_donations() + donation = frappe.get_doc({ + 'doctype': 'Donation', + 'company': company, + 'donor': donor.name, + 'donor_name': donor.donor_name, + 'email': donor.email, + 'date': getdate(), + 'amount': flt(payment.amount) / 100, # Convert to rupees from paise + 'mode_of_payment': payment.method, + 'razorpay_payment_id': payment.id + }).insert(ignore_mandatory=True) + + donation.submit() + return donation + + +def get_donor(email): + donors = frappe.get_all('Donor', + filters={'email': email}, + order_by='creation desc') + + try: + return frappe.get_doc('Donor', donors[0]['name']) + except Exception: + return None + + +@frappe.whitelist() +def create_donor(payment): + donor_details = frappe._dict(payment) + donor_type = frappe.db.get_single_value('Non Profit Settings', 'default_donor_type') + + donor = frappe.new_doc('Donor') + donor.update({ + 'donor_name': donor_details.email, + 'donor_type': donor_type, + 'email': donor_details.email, + 'contact': donor_details.contact + }) + + if donor_details.get('notes'): + donor = get_additional_notes(donor, donor_details) + + donor.insert(ignore_mandatory=True) + return donor + + +def get_company_for_donations(): + company = frappe.db.get_single_value('Non Profit Settings', 'donation_company') + if not company: + from erpnext.healthcare.setup import get_company + company = get_company() + return company + + +def get_additional_notes(donor, donor_details): + if type(donor_details.notes) == dict: + for k, v in donor_details.notes.items(): + notes = '\n'.join('{}: {}'.format(k, v)) + + # extract donor name from notes + if 'name' in k.lower(): + donor.update({ + 'donor_name': donor_details.notes.get(k) + }) + + # extract pan from notes + if 'pan' in k.lower(): + donor.update({ + 'pan_number': donor_details.notes.get(k) + }) + + donor.add_comment('Comment', notes) + + elif type(donor_details.notes) == str: + donor.add_comment('Comment', donor_details.notes) + + return donor + + +def create_mode_of_payment(method): + frappe.get_doc({ + 'doctype': 'Mode of Payment', + 'mode_of_payment': method + }).insert(ignore_mandatory=True) + + +def notify_failure(log): + try: + content = ''' + Dear System Manager, + Razorpay webhook for creating donation failed due to some reason. + Please check the error log linked below + Error Log: {0} + Regards, Administrator + '''.format(get_link_to_form('Error Log', log.name)) + + sendmail_to_system_managers(_('[Important] [ERPNext] Razorpay donation webhook failed, please check.'), content) + except Exception: + pass + diff --git a/erpnext/non_profit/doctype/donation/donation_dashboard.py b/erpnext/non_profit/doctype/donation/donation_dashboard.py new file mode 100644 index 00000000000..7e25c8d2173 --- /dev/null +++ b/erpnext/non_profit/doctype/donation/donation_dashboard.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from frappe import _ + +def get_data(): + return { + 'fieldname': 'donation', + 'non_standard_fieldnames': { + 'Payment Entry': 'reference_name' + }, + 'transactions': [ + { + 'label': _('Payment'), + 'items': ['Payment Entry'] + } + ] + } \ No newline at end of file diff --git a/erpnext/non_profit/doctype/donation/test_donation.py b/erpnext/non_profit/doctype/donation/test_donation.py new file mode 100644 index 00000000000..c6a534dac34 --- /dev/null +++ b/erpnext/non_profit/doctype/donation/test_donation.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from erpnext.non_profit.doctype.donation.donation import create_donation + +class TestDonation(unittest.TestCase): + def setUp(self): + create_donor_type() + settings = frappe.get_doc('Non Profit Settings') + settings.company = '_Test Company' + settings.donation_company = '_Test Company' + settings.default_donor_type = '_Test Donor' + settings.automate_donation_payment_entries = 1 + settings.donation_debit_account = 'Debtors - _TC' + settings.donation_payment_account = 'Cash - _TC' + settings.creation_user = 'Administrator' + settings.flags.ignore_permissions = True + settings.save() + + def test_payment_entry_for_donations(self): + donor = create_donor() + create_mode_of_payment() + payment = frappe._dict({ + 'amount': 100, + 'method': 'Debit Card', + 'id': 'pay_MeXAmsgeKOhq7O' + }) + donation = create_donation(donor, payment) + + self.assertTrue(donation.name) + + # Naive test to check if at all payment entry is generated + # This method is actually triggered from Payment Gateway + # In any case if details were missing, this would throw an error + donation.on_payment_authorized() + donation.reload() + + self.assertEquals(donation.paid, 1) + self.assertTrue(frappe.db.exists('Payment Entry', {'reference_no': donation.name})) + + +def create_donor_type(): + if not frappe.db.exists('Donor Type', '_Test Donor'): + frappe.get_doc({ + 'doctype': 'Donor Type', + 'donor_type': '_Test Donor' + }).insert() + + +def create_donor(): + donor = frappe.db.exists('Donor', 'donor@test.com') + if donor: + return frappe.get_doc('Donor', 'donor@test.com') + else: + return frappe.get_doc({ + 'doctype': 'Donor', + 'donor_name': '_Test Donor', + 'donor_type': '_Test Donor', + 'email': 'donor@test.com' + }).insert() + + +def create_mode_of_payment(): + if not frappe.db.exists('Mode of Payment', 'Debit Card'): + frappe.get_doc({ + 'doctype': 'Mode of Payment', + 'mode_of_payment': 'Debit Card', + 'accounts': [{ + 'company': '_Test Company', + 'default_account': 'Cash - _TC' + }] + }).insert() \ No newline at end of file diff --git a/erpnext/non_profit/doctype/donor/donor.json b/erpnext/non_profit/doctype/donor/donor.json index 96392658f1a..72f24ef9226 100644 --- a/erpnext/non_profit/doctype/donor/donor.json +++ b/erpnext/non_profit/doctype/donor/donor.json @@ -76,8 +76,13 @@ } ], "image_field": "image", - "links": [], - "modified": "2020-09-16 23:46:04.083274", + "links": [ + { + "link_doctype": "Donation", + "link_fieldname": "donor" + } + ], + "modified": "2021-02-17 16:36:33.470731", "modified_by": "Administrator", "module": "Non Profit", "name": "Donor", diff --git a/erpnext/non_profit/doctype/donor/donor.py b/erpnext/non_profit/doctype/donor/donor.py index 9121d0cdfc8..fb70e59575b 100644 --- a/erpnext/non_profit/doctype/donor/donor.py +++ b/erpnext/non_profit/doctype/donor/donor.py @@ -11,3 +11,8 @@ class Donor(Document): """Load address and contacts in `__onload`""" load_address_and_contact(self) + def validate(self): + from frappe.utils import validate_email_address + if self.email: + validate_email_address(self.email.strip(), True) + diff --git a/erpnext/non_profit/doctype/member/member.js b/erpnext/non_profit/doctype/member/member.js index 199dcfc04f5..6b8f1b1deb6 100644 --- a/erpnext/non_profit/doctype/member/member.js +++ b/erpnext/non_profit/doctype/member/member.js @@ -3,7 +3,7 @@ frappe.ui.form.on('Member', { setup: function(frm) { - frappe.db.get_single_value("Membership Settings", "enable_razorpay").then(val => { + frappe.db.get_single_value('Non Profit Settings', 'enable_razorpay_for_memberships').then(val => { if (val && (frm.doc.subscription_id || frm.doc.customer_id)) { frm.set_df_property('razorpay_details_section', 'hidden', false); } diff --git a/erpnext/non_profit/doctype/member/member.json b/erpnext/non_profit/doctype/member/member.json index 992ef16d644..f190cfae755 100644 --- a/erpnext/non_profit/doctype/member/member.json +++ b/erpnext/non_profit/doctype/member/member.json @@ -12,7 +12,6 @@ "membership_expiry_date", "column_break_5", "membership_type", - "email", "email_id", "image", "customer_section", @@ -64,13 +63,6 @@ "options": "Membership Type", "reqd": 1 }, - { - "fieldname": "email", - "fieldtype": "Link", - "in_list_view": 1, - "label": "User", - "options": "User" - }, { "fieldname": "image", "fieldtype": "Attach Image", @@ -178,7 +170,7 @@ ], "image_field": "image", "links": [], - "modified": "2020-09-16 23:44:13.596948", + "modified": "2020-11-09 12:12:10.174647", "modified_by": "Administrator", "module": "Non Profit", "name": "Member", diff --git a/erpnext/non_profit/doctype/member/member.py b/erpnext/non_profit/doctype/member/member.py index 44b975e9e9d..3ba2ee71c67 100644 --- a/erpnext/non_profit/doctype/member/member.py +++ b/erpnext/non_profit/doctype/member/member.py @@ -7,7 +7,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.contacts.address_and_contact import load_address_and_contact -from frappe.utils import cint +from frappe.utils import cint, get_link_to_form from frappe.integrations.utils import get_payment_gateway_controller from erpnext.non_profit.doctype.membership_type.membership_type import get_membership_type @@ -18,8 +18,6 @@ class Member(Document): def validate(self): - if self.email: - self.validate_email_type(self.email) if self.email_id: self.validate_email_type(self.email_id) @@ -28,9 +26,10 @@ class Member(Document): validate_email_address(email.strip(), True) def setup_subscription(self): - membership_settings = frappe.get_doc("Membership Settings") - if not membership_settings.enable_razorpay: - frappe.throw("Please enable Razorpay to setup subscription") + non_profit_settings = frappe.get_doc('Non Profit Settings') + if not non_profit_settings.enable_razorpay_for_memberships: + frappe.throw('Please check Enable Razorpay for Memberships in {0} to setup subscription').format( + get_link_to_form('Non Profit Settings', 'Non Profit Settings')) controller = get_payment_gateway_controller("Razorpay") settings = controller.get_settings({}) @@ -42,7 +41,7 @@ class Member(Document): subscription_details = { "plan_id": plan_id, - "billing_frequency": cint(membership_settings.billing_frequency), + "billing_frequency": cint(non_profit_settings.billing_frequency), "customer_notify": 1 } @@ -57,14 +56,16 @@ class Member(Document): def make_customer_and_link(self): if self.customer: frappe.msgprint(_("A customer is already linked to this Member")) - cust = create_customer(frappe._dict({ + + customer = create_customer(frappe._dict({ 'fullname': self.member_name, - 'email': self.email_id or self.user, + 'email': self.email_id, 'phone': None })) - self.customer = cust + self.customer = customer self.save() + frappe.msgprint(_("Customer {0} has been created succesfully.").format(self.customer)) def get_or_create_member(user_details): @@ -177,4 +178,4 @@ def register_member(fullname, email, rzpay_plan_id, subscription_id, pan=None, m mobile=mobile )) - return member.name \ No newline at end of file + return member.name diff --git a/erpnext/non_profit/doctype/membership/membership.js b/erpnext/non_profit/doctype/membership/membership.js index ee8a8c0a7ba..31872048a06 100644 --- a/erpnext/non_profit/doctype/membership/membership.js +++ b/erpnext/non_profit/doctype/membership/membership.js @@ -3,21 +3,30 @@ frappe.ui.form.on('Membership', { setup: function(frm) { - frappe.db.get_single_value("Membership Settings", "enable_razorpay").then(val => { - if (val) frm.set_df_property('razorpay_details_section', 'hidden', false); + frappe.db.get_single_value("Non Profit Settings", "enable_razorpay_for_memberships").then(val => { + if (val) frm.set_df_property("razorpay_details_section", "hidden", false); }) }, refresh: function(frm) { + if (frm.doc.__islocal) + return; + !frm.doc.invoice && frm.add_custom_button("Generate Invoice", () => { - frm.call("generate_invoice", { - save: true - }).then(() => { - frm.reload_doc(); + frm.call({ + doc: frm.doc, + method: "generate_invoice", + args: {save: true}, + freeze: true, + freeze_message: __("Creating Membership Invoice"), + callback: function(r) { + if (r.invoice) + frm.reload_doc(); + } }); }); - frappe.db.get_single_value("Membership Settings", "send_email").then(val => { + frappe.db.get_single_value("Non Profit Settings", "send_email").then(val => { if (val) frm.add_custom_button("Send Acknowledgement", () => { frm.call("send_acknowlement").then(() => { frm.reload_doc(); @@ -27,6 +36,6 @@ frappe.ui.form.on('Membership', { }, onload: function(frm) { - frm.add_fetch('membership_type', 'amount', 'amount'); + frm.add_fetch("membership_type", "amount", "amount"); } }); diff --git a/erpnext/non_profit/doctype/membership/membership.json b/erpnext/non_profit/doctype/membership/membership.json index 7f218966a02..11d32f9c2b4 100644 --- a/erpnext/non_profit/doctype/membership/membership.json +++ b/erpnext/non_profit/doctype/membership/membership.json @@ -7,8 +7,10 @@ "engine": "InnoDB", "field_order": [ "member", + "member_name", "membership_type", "column_break_3", + "company", "membership_status", "membership_validity_section", "from_date", @@ -46,6 +48,8 @@ { "fieldname": "membership_status", "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, "label": "Membership Status", "options": "New\nCurrent\nExpired\nPending\nCancelled" }, @@ -122,11 +126,25 @@ "fieldtype": "Link", "label": "Invoice", "options": "Sales Invoice" + }, + { + "fetch_from": "member.member_name", + "fieldname": "member_name", + "fieldtype": "Data", + "label": "Member Name", + "read_only": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-09-19 14:28:11.532696", + "modified": "2021-02-19 14:33:44.925122", "modified_by": "Administrator", "module": "Non Profit", "name": "Membership", @@ -158,7 +176,9 @@ } ], "restrict_to_domain": "Non Profit", + "search_fields": "member, member_name", "sort_field": "modified", "sort_order": "DESC", + "title_field": "member_name", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/non_profit/doctype/membership/membership.py b/erpnext/non_profit/doctype/membership/membership.py index 4c85cb60e8b..52447e43860 100644 --- a/erpnext/non_profit/doctype/membership/membership.py +++ b/erpnext/non_profit/doctype/membership/membership.py @@ -6,6 +6,7 @@ from __future__ import unicode_literals import json import frappe import six +import os from datetime import datetime from frappe.model.document import Document from frappe.email import sendmail_to_system_managers @@ -14,33 +15,43 @@ from erpnext.non_profit.doctype.member.member import create_member from frappe import _ import erpnext - class Membership(Document): def validate(self): if not self.member or not frappe.db.exists("Member", self.member): - member_name = frappe.get_value('Member', dict(email=frappe.session.user)) + # for web forms + user_type = frappe.db.get_value("User", frappe.session.user, "user_type") + if user_type == "Website User": + self.create_member_from_website_user() + else: + frappe.throw(_("Please select a Member")) - if not member_name: - user = frappe.get_doc('User', frappe.session.user) - member = frappe.get_doc(dict( - doctype='Member', - email=frappe.session.user, - membership_type=self.membership_type, - member_name=user.get_fullname() - )).insert(ignore_permissions=True) - member_name = member.name + self.validate_membership_period() - if self.get("__islocal"): - self.member = member_name + def create_member_from_website_user(self): + member_name = frappe.get_value("Member", dict(email_id=frappe.session.user)) + if not member_name: + user = frappe.get_doc("User", frappe.session.user) + member = frappe.get_doc(dict( + doctype="Member", + email_id=frappe.session.user, + membership_type=self.membership_type, + member_name=user.get_fullname() + )).insert(ignore_permissions=True) + member_name = member.name + + if self.get("__islocal"): + self.member = member_name + + def validate_membership_period(self): # get last membership (if active) - last_membership = erpnext.get_last_membership() + last_membership = erpnext.get_last_membership(self.member) # if person applied for offline membership - if last_membership and not frappe.session.user == "Administrator": + if last_membership and last_membership.name != self.name and not frappe.session.user == "Administrator": # if last membership does not expire in 30 days, then do not allow to renew if getdate(add_days(last_membership.to_date, -30)) > getdate(nowdate()) : - frappe.throw(_('You can only renew if your membership expires within 30 days')) + frappe.throw(_("You can only renew if your membership expires within 30 days")) self.from_date = add_days(last_membership.to_date, 1) elif frappe.session.user == "Administrator": @@ -48,17 +59,22 @@ class Membership(Document): else: self.from_date = nowdate() - if frappe.db.get_single_value("Membership Settings", "billing_cycle") == "Yearly": + if frappe.db.get_single_value("Non Profit Settings", "billing_cycle") == "Yearly": self.to_date = add_years(self.from_date, 1) else: self.to_date = add_months(self.from_date, 1) def on_payment_authorized(self, status_changed_to=None): - if status_changed_to in ("Completed", "Authorized"): - self.load_from_db() - self.db_set('paid', 1) + if status_changed_to not in ("Completed", "Authorized"): + return + self.load_from_db() + self.db_set("paid", 1) + settings = frappe.get_doc("Non Profit Settings") + if settings.allow_invoicing and settings.automate_membership_invoicing: + self.generate_invoice(with_payment_entry=settings.automate_membership_payment_entries, save=True) - def generate_invoice(self, save=True): + + def generate_invoice(self, save=True, with_payment_entry=False): if not (self.paid or self.currency or self.amount): frappe.throw(_("The payment for this membership is not paid. To generate invoice fill the payment details")) @@ -66,34 +82,66 @@ class Membership(Document): frappe.throw(_("An invoice is already linked to this document")) member = frappe.get_doc("Member", self.member) - plan = frappe.get_doc("Membership Type", self.membership_type) - settings = frappe.get_doc("Membership Settings") - if not member.customer: - frappe.throw(_("No customer linked to member {}", [member.name])) + frappe.throw(_("No customer linked to member {0}").format(frappe.bold(self.member))) - if not settings.debit_account: - frappe.throw(_("You need to set Debit Account in Membership Settings")) - - if not settings.company: - frappe.throw(_("You need to set Default Company for invoicing in Membership Settings")) + plan = frappe.get_doc("Membership Type", self.membership_type) + settings = frappe.get_doc("Non Profit Settings") + self.validate_membership_type_and_settings(plan, settings) invoice = make_invoice(self, member, plan, settings) + self.reload() self.invoice = invoice.name + if with_payment_entry: + self.make_payment_entry(settings, invoice) + if save: self.save() return invoice + def validate_membership_type_and_settings(self, plan, settings): + settings_link = get_link_to_form("Membership Type", self.membership_type) + + if not settings.membership_debit_account: + frappe.throw(_("You need to set Debit Account in {0}").format(settings_link)) + + if not settings.company: + frappe.throw(_("You need to set Default Company for invoicing in {0}").format(settings_link)) + + if not plan.linked_item: + frappe.throw(_("Please set a Linked Item for the Membership Type {0}").format( + get_link_to_form("Membership Type", self.membership_type))) + + def make_payment_entry(self, settings, invoice): + if not settings.membership_payment_account: + frappe.throw(_("You need to set Payment Account for Membership in {0}").format( + get_link_to_form("Non Profit Settings", "Non Profit Settings"))) + + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + frappe.flags.ignore_account_permission = True + pe = get_payment_entry(dt="Sales Invoice", dn=invoice.name, bank_amount=invoice.grand_total) + frappe.flags.ignore_account_permission=False + pe.paid_to = settings.membership_payment_account + pe.reference_no = self.name + pe.reference_date = getdate() + pe.flags.ignore_mandatory = True + pe.save() + pe.submit() + def send_acknowlement(self): - settings = frappe.get_doc("Membership Settings") + settings = frappe.get_doc("Non Profit Settings") if not settings.send_email: - frappe.throw(_("You need to enable Send Acknowledge Email in Membership Settings")) + frappe.throw(_("You need to enable Send Acknowledge Email in {0}").format( + get_link_to_form("Non Profit Settings", "Non Profit Settings"))) member = frappe.get_doc("Member", self.member) + if not member.email_id: + frappe.throw(_("Email address of member {0} is missing").format(frappe.utils.get_link_to_form("Member", self.member))) + plan = frappe.get_doc("Membership Type", self.membership_type) - email = member.email_id if member.email_id else member.email + email = member.email_id attachments = [frappe.attach_print("Membership", self.name, print_format=settings.membership_print_format)] if self.invoice and settings.send_invoice: @@ -112,55 +160,65 @@ class Membership(Document): } if not frappe.flags.in_test: - frappe.enqueue(method=frappe.sendmail, queue='short', timeout=300, is_async=True, **email_args) + frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args) else: frappe.sendmail(**email_args) def generate_and_send_invoice(self): - invoice = self.generate_invoice(False) + self.generate_invoice(save=False) self.send_acknowlement() + def make_invoice(membership, member, plan, settings): invoice = frappe.get_doc({ - 'doctype': 'Sales Invoice', - 'customer': member.customer, - 'debit_to': settings.debit_account, - 'currency': membership.currency, - 'is_pos': 0, - 'items': [ + "doctype": "Sales Invoice", + "customer": member.customer, + "debit_to": settings.membership_debit_account, + "currency": membership.currency, + "company": settings.company, + "is_pos": 0, + "items": [ { - 'item_code': plan.linked_item, - 'rate': membership.amount, - 'qty': 1 + "item_code": plan.linked_item, + "rate": membership.amount, + "qty": 1 } ] }) - - invoice.insert(ignore_permissions=True) + invoice.set_missing_values() + invoice.insert() invoice.submit() + frappe.msgprint(_("Sales Invoice created successfully")) + return invoice + def get_member_based_on_subscription(subscription_id, email): members = frappe.get_all("Member", filters={ - 'subscription_id': subscription_id, - 'email_id': email + "subscription_id": subscription_id, + "email_id": email }, order_by="creation desc") try: - return frappe.get_doc("Member", members[0]['name']) + return frappe.get_doc("Member", members[0]["name"]) except: return None -def verify_signature(data): - signature = frappe.request.headers.get('X-Razorpay-Signature') - settings = frappe.get_doc("Membership Settings") - key = settings.get_webhook_secret() +def verify_signature(data, endpoint="Membership"): + if frappe.flags.in_test or os.environ.get("CI"): + return True + signature = frappe.request.headers.get("X-Razorpay-Signature") + + settings = frappe.get_doc("Non Profit Settings") + key = settings.get_webhook_secret(endpoint) controller = frappe.get_doc("Razorpay Settings") controller.verify_signature(data, signature, key) + frappe.set_user(settings.creation_user) + @frappe.whitelist(allow_guest=True) def trigger_razorpay_subscription(*args, **kwargs): @@ -168,18 +226,18 @@ def trigger_razorpay_subscription(*args, **kwargs): try: verify_signature(data) except Exception as e: - log = frappe.log_error(e, "Webhook Verification Error") + log = frappe.log_error(e, "Membership Webhook Verification Error") notify_failure(log) - return { 'status': 'Failed', 'reason': e} + return { "status": "Failed", "reason": e} if isinstance(data, six.string_types): data = json.loads(data) data = frappe._dict(data) - subscription = data.payload.get("subscription", {}).get('entity', {}) + subscription = data.payload.get("subscription", {}).get("entity", {}) subscription = frappe._dict(subscription) - payment = data.payload.get("payment", {}).get('entity', {}) + payment = data.payload.get("payment", {}).get("entity", {}) payment = frappe._dict(payment) try: @@ -189,23 +247,22 @@ def trigger_razorpay_subscription(*args, **kwargs): member = get_member_based_on_subscription(subscription.id, payment.email) if not member: member = create_member(frappe._dict({ - 'fullname': payment.email, - 'email': payment.email, - 'plan_id': get_plan_from_razorpay_id(subscription.plan_id) + "fullname": payment.email, + "email": payment.email, + "plan_id": get_plan_from_razorpay_id(subscription.plan_id) })) member.subscription_id = subscription.id member.customer_id = payment.customer_id - if subscription.notes and type(subscription.notes) == dict: - notes = '\n'.join("{}: {}".format(k, v) for k, v in subscription.notes.items()) - member.add_comment("Comment", notes) - elif subscription.notes and type(subscription.notes) == str: - member.add_comment("Comment", subscription.notes) + if subscription.get("notes"): + member = get_additional_notes(member, subscription) + company = get_company_for_memberships() # Update Membership membership = frappe.new_doc("Membership") membership.update({ + "company": company, "member": member.name, "membership_status": "Current", "membership_type": member.membership_type, @@ -216,39 +273,91 @@ def trigger_razorpay_subscription(*args, **kwargs): "to_date": datetime.fromtimestamp(subscription.current_end), "amount": payment.amount / 100 # Convert to rupees from paise }) - membership.insert(ignore_permissions=True) + membership.flags.ignore_mandatory = True + membership.insert() # Update membership values member.subscription_start = datetime.fromtimestamp(subscription.start_at) member.subscription_end = datetime.fromtimestamp(subscription.end_at) member.subscription_activated = 1 - member.save(ignore_permissions=True) + member.flags.ignore_mandatory = True + member.save() + + settings = frappe.get_doc("Non Profit Settings") + if settings.allow_invoicing and settings.automate_membership_invoicing: + membership.reload() + membership.generate_invoice(with_payment_entry=settings.automate_membership_payment_entries, save=True) + except Exception as e: - message = "{0}\n\n{1}\n\n{2}: {3}".format(e, frappe.get_traceback(), __("Payment ID"), payment.id) + message = "{0}\n\n{1}\n\n{2}: {3}".format(e, frappe.get_traceback(), _("Payment ID"), payment.id) log = frappe.log_error(message, _("Error creating membership entry for {0}").format(member.name)) notify_failure(log) - return { 'status': 'Failed', 'reason': e} + return { "status": "Failed", "reason": e} - return { 'status': 'Success' } + return { "status": "Success" } + + +def get_company_for_memberships(): + company = frappe.db.get_single_value("Non Profit Settings", "company") + if not company: + from erpnext.healthcare.setup import get_company + company = get_company() + return company + + +def get_additional_notes(member, subscription): + if type(subscription.notes) == dict: + for k, v in subscription.notes.items(): + notes = "\n".join("{}: {}".format(k, v)) + + # extract member name from notes + if "name" in k.lower(): + member.update({ + "member_name": subscription.notes.get(k) + }) + + # extract pan number from notes + if "pan" in k.lower(): + member.update({ + "pan_number": subscription.notes.get(k) + }) + + member.add_comment("Comment", notes) + + elif type(subscription.notes) == str: + member.add_comment("Comment", subscription.notes) + + return member def notify_failure(log): try: - content = """Dear System Manager, -Razorpay webhook for creating renewing membership subscription failed due to some reason. Please check the following error log linked below + content = """ + Dear System Manager, + Razorpay webhook for creating renewing membership subscription failed due to some reason. + Please check the following error log linked below + Error Log: {0} + Regards, Administrator + """.format(get_link_to_form("Error Log", log.name)) -Error Log: {0} - -Regards, -Administrator""".format(get_link_to_form("Error Log", log.name)) sendmail_to_system_managers("[Important] [ERPNext] Razorpay membership webhook failed , please check.", content) except: pass + def get_plan_from_razorpay_id(plan_id): - plan = frappe.get_all("Membership Type", filters={'razorpay_plan_id': plan_id}, order_by="creation desc") + plan = frappe.get_all("Membership Type", filters={"razorpay_plan_id": plan_id}, order_by="creation desc") try: - return plan[0]['name'] + return plan[0]["name"] except: return None + + +def set_expired_status(): + frappe.db.sql(""" + UPDATE + `tabMembership` SET `status` = 'Expired' + WHERE + `status` not in ('Cancelled') AND `to_date` < %s + """, (nowdate())) \ No newline at end of file diff --git a/erpnext/non_profit/doctype/membership/membership_list.js b/erpnext/non_profit/doctype/membership/membership_list.js new file mode 100644 index 00000000000..a959159899d --- /dev/null +++ b/erpnext/non_profit/doctype/membership/membership_list.js @@ -0,0 +1,15 @@ +frappe.listview_settings['Membership'] = { + get_indicator: function(doc) { + if (doc.membership_status == 'New') { + return [__('New'), 'blue', 'membership_status,=,New']; + } else if (doc.membership_status === 'Current') { + return [__('Current'), 'green', 'membership_status,=,Current']; + } else if (doc.membership_status === 'Pending') { + return [__('Pending'), 'yellow', 'membership_status,=,Pending']; + } else if (doc.membership_status === 'Expired') { + return [__('Expired'), 'grey', 'membership_status,=,Expired']; + } else { + return [__('Cancelled'), 'red', 'membership_status,=,Cancelled']; + } + } +}; diff --git a/erpnext/non_profit/doctype/membership/test_membership.py b/erpnext/non_profit/doctype/membership/test_membership.py index b23f4062a97..31da792e534 100644 --- a/erpnext/non_profit/doctype/membership/test_membership.py +++ b/erpnext/non_profit/doctype/membership/test_membership.py @@ -2,8 +2,117 @@ # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt from __future__ import unicode_literals - import unittest +import frappe +import erpnext +from erpnext.non_profit.doctype.member.member import create_member +from frappe.utils import nowdate, add_months class TestMembership(unittest.TestCase): - pass + def setUp(self): + plan = setup_membership() + + # make test member + self.member_doc = create_member(frappe._dict({ + 'fullname': "_Test_Member", + 'email': "_test_member_erpnext@example.com", + 'plan_id': plan.name + })) + self.member_doc.make_customer_and_link() + self.member = self.member_doc.name + + def test_auto_generate_invoice_and_payment_entry(self): + entry = make_membership(self.member) + + # Naive test to see if at all invoice was generated and attached to member + # In any case if details were missing, the invoicing would throw an error + invoice = entry.generate_invoice(save=True) + self.assertEqual(invoice.name, entry.invoice) + + def test_renew_within_30_days(self): + # create a membership for two months + # Should work fine + make_membership(self.member, { "from_date": nowdate() }) + make_membership(self.member, { "from_date": add_months(nowdate(), 1) }) + + from frappe.utils.user import add_role + add_role("test@example.com", "Non Profit Manager") + frappe.set_user("test@example.com") + + # create next membership with expiry not within 30 days + self.assertRaises(frappe.ValidationError, make_membership, self.member, { + "from_date": add_months(nowdate(), 2), + }) + + frappe.set_user("Administrator") + # create the same membership but as administrator + make_membership(self.member, { + "from_date": add_months(nowdate(), 2), + "to_date": add_months(nowdate(), 3), + }) + +def set_config(key, value): + frappe.db.set_value("Non Profit Settings", None, key, value) + +def make_membership(member, payload={}): + data = { + "doctype": "Membership", + "member": member, + "membership_status": "Current", + "membership_type": "_rzpy_test_milythm", + "currency": "INR", + "paid": 1, + "from_date": nowdate(), + "amount": 100 + } + data.update(payload) + membership = frappe.get_doc(data) + membership.insert(ignore_permissions=True, ignore_if_duplicate=True) + return membership + +def create_item(item_code): + if not frappe.db.exists("Item", item_code): + item = frappe.new_doc("Item") + item.item_code = item_code + item.item_name = item_code + item.stock_uom = "Nos" + item.description = item_code + item.item_group = "All Item Groups" + item.is_stock_item = 0 + item.save() + else: + item = frappe.get_doc("Item", item_code) + return item + +def setup_membership(): + # Get default company + company = frappe.get_doc("Company", erpnext.get_default_company()) + + # update non profit settings + settings = frappe.get_doc("Non Profit Settings") + # Enable razorpay + settings.enable_razorpay_for_memberships = 1 + settings.billing_cycle = "Monthly" + settings.billing_frequency = 24 + # Enable invoicing + settings.allow_invoicing = 1 + settings.automate_membership_payment_entries = 1 + settings.company = company.name + settings.donation_company = company.name + settings.membership_payment_account = company.default_cash_account + settings.membership_debit_account = company.default_receivable_account + settings.flags.ignore_mandatory = True + settings.save() + + # make test plan + if not frappe.db.exists("Membership Type", "_rzpy_test_milythm"): + plan = frappe.new_doc("Membership Type") + plan.membership_type = "_rzpy_test_milythm" + plan.amount = 100 + plan.razorpay_plan_id = "_rzpy_test_milythm" + plan.linked_item = create_item("_Test Item for Non Profit Membership").name + plan.insert() + else: + plan = frappe.get_doc("Membership Type", "_rzpy_test_milythm") + + return plan \ No newline at end of file diff --git a/erpnext/non_profit/doctype/membership_settings/membership_settings.js b/erpnext/non_profit/doctype/membership_settings/membership_settings.js deleted file mode 100644 index 1d894027b01..00000000000 --- a/erpnext/non_profit/doctype/membership_settings/membership_settings.js +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on("Membership Settings", { - refresh: function(frm) { - if (frm.doc.webhook_secret) { - frm.add_custom_button(__("Revoke "), () => { - frm.call("revoke_key").then(() => { - frm.refresh(); - }) - }); - } - - frm.set_query('inv_print_format', function(doc) { - return { - filters: { - "doc_type": "Sales Invoice" - } - }; - }); - - frm.set_query('membership_print_format', function(doc) { - return { - filters: { - "doc_type": "Membership" - } - }; - }); - - frm.set_query('debit_account', function(doc) { - return { - filters: { - 'account_type': 'Receivable', - 'is_group': 0, - 'company': frm.doc.company - } - }; - }); - - let docs_url = "https://docs.erpnext.com/docs/user/manual/en/non_profit/membership"; - - frm.set_intro(__("You can learn more about memberships in the manual. ") + `${__('ERPNext Docs')}`, true); - - frm.trigger("add_generate_button"); - frm.trigger("add_copy_buttonn"); - }, - - add_generate_button: function(frm) { - let label; - - if (frm.doc.webhook_secret) { - label = __("Regenerate Webhook Secret"); - } else { - label = __("Generate Webhook Secret"); - } - frm.add_custom_button(label, () => { - frm.call("generate_webhook_key").then(() => { - frm.refresh(); - }); - }); - }, - - add_copy_buttonn: function(frm) { - if (frm.doc.webhook_secret) { - frm.add_custom_button(__("Copy Webhook URL"), () => { - frappe.utils.copy_to_clipboard(`https://${frappe.boot.sitename}/api/method/erpnext.non_profit.doctype.membership.membership.trigger_razorpay_subscription`); - }); - } - } -}); diff --git a/erpnext/non_profit/doctype/membership_settings/membership_settings.json b/erpnext/non_profit/doctype/membership_settings/membership_settings.json deleted file mode 100644 index 5b6bab5b0a0..00000000000 --- a/erpnext/non_profit/doctype/membership_settings/membership_settings.json +++ /dev/null @@ -1,164 +0,0 @@ -{ - "actions": [], - "creation": "2020-03-29 12:57:03.005120", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enable_razorpay", - "razorpay_settings_section", - "billing_cycle", - "billing_frequency", - "webhook_secret", - "column_break_6", - "enable_auto_invoicing", - "company", - "debit_account", - "column_break_9", - "send_email", - "send_invoice", - "membership_print_format", - "inv_print_format", - "email_template" - ], - "fields": [ - { - "fieldname": "billing_cycle", - "fieldtype": "Select", - "label": "Billing Cycle", - "options": "Monthly\nYearly" - }, - { - "default": "0", - "fieldname": "enable_razorpay", - "fieldtype": "Check", - "label": "Enable RazorPay For Memberships" - }, - { - "depends_on": "eval:doc.enable_razorpay", - "fieldname": "razorpay_settings_section", - "fieldtype": "Section Break", - "label": "RazorPay Settings" - }, - { - "description": "The number of billing cycles for which the customer should be charged. For example, if a customer is buying a 1-year membership that should be billed on a monthly basis, this value should be 12.", - "fieldname": "billing_frequency", - "fieldtype": "Int", - "label": "Billing Frequency" - }, - { - "fieldname": "webhook_secret", - "fieldtype": "Password", - "label": "Webhook Secret", - "read_only": 1 - }, - { - "fieldname": "column_break_6", - "fieldtype": "Section Break", - "label": "Invoicing" - }, - { - "default": "0", - "fieldname": "enable_auto_invoicing", - "fieldtype": "Check", - "label": "Enable Auto Invoicing", - "mandatory_depends_on": "eval:doc.send_invoice" - }, - { - "depends_on": "eval:doc.enable_auto_invoicing", - "fieldname": "debit_account", - "fieldtype": "Link", - "label": "Debit Account", - "mandatory_depends_on": "eval:doc.enable_auto_invoicing", - "options": "Account" - }, - { - "fieldname": "column_break_9", - "fieldtype": "Column Break" - }, - { - "depends_on": "eval:doc.enable_auto_invoicing", - "fieldname": "company", - "fieldtype": "Link", - "label": "Company", - "mandatory_depends_on": "eval:doc.enable_auto_invoicing", - "options": "Company" - }, - { - "default": "0", - "depends_on": "eval:doc.enable_auto_invoicing && doc.send_email", - "fieldname": "send_invoice", - "fieldtype": "Check", - "label": "Send Invoice with Email" - }, - { - "default": "0", - "fieldname": "send_email", - "fieldtype": "Check", - "label": "Send Membership Acknowledgement" - }, - { - "depends_on": "eval: doc.send_invoice", - "fieldname": "inv_print_format", - "fieldtype": "Link", - "label": "Invoice Print Format", - "mandatory_depends_on": "eval: doc.send_invoice", - "options": "Print Format" - }, - { - "depends_on": "eval:doc.send_email", - "fieldname": "membership_print_format", - "fieldtype": "Link", - "label": "Membership Print Format", - "options": "Print Format" - }, - { - "depends_on": "eval:doc.send_email", - "fieldname": "email_template", - "fieldtype": "Link", - "label": "Email Template", - "mandatory_depends_on": "eval:doc.send_email", - "options": "Email Template" - } - ], - "issingle": 1, - "links": [], - "modified": "2020-08-05 17:26:37.287395", - "modified_by": "Administrator", - "module": "Non Profit", - "name": "Membership Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "Non Profit Manager", - "share": 1, - "write": 1 - }, - { - "email": 1, - "print": 1, - "read": 1, - "role": "Non Profit Member", - "share": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/erpnext/non_profit/doctype/membership_settings/membership_settings.py b/erpnext/non_profit/doctype/membership_settings/membership_settings.py deleted file mode 100644 index f3b2eee6f97..00000000000 --- a/erpnext/non_profit/doctype/membership_settings/membership_settings.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe import _ -from frappe.integrations.utils import get_payment_gateway_controller -from frappe.model.document import Document - -class MembershipSettings(Document): - def generate_webhook_key(self): - key = frappe.generate_hash(length=20) - self.webhook_secret = key - self.save() - - frappe.msgprint( - _("Here is your webhook secret, this will be shown to you only once.") + "

    " + key, - _("Webhook Secret") - ); - - def revoke_key(self): - self.webhook_secret = None; - self.save() - - def get_webhook_secret(self): - return self.get_password(fieldname="webhook_secret", raise_exception=False) - -@frappe.whitelist() -def get_plans_for_membership(*args, **kwargs): - controller = get_payment_gateway_controller("Razorpay") - plans = controller.get_plans() - return [plan.get("item") for plan in plans.get("items")] \ No newline at end of file diff --git a/erpnext/non_profit/doctype/membership_type/membership_type.js b/erpnext/non_profit/doctype/membership_type/membership_type.js index 43311a2c965..2f2427629c3 100644 --- a/erpnext/non_profit/doctype/membership_type/membership_type.js +++ b/erpnext/non_profit/doctype/membership_type/membership_type.js @@ -2,13 +2,21 @@ // For license information, please see license.txt frappe.ui.form.on('Membership Type', { - refresh: function(frm) { - frappe.db.get_single_value("Membership Settings", "enable_razorpay").then(val => { + refresh: function (frm) { + frappe.db.get_single_value('Non Profit Settings', 'enable_razorpay_for_memberships').then(val => { if (val) frm.set_df_property('razorpay_plan_id', 'hidden', false); }); - frappe.db.get_single_value("Membership Settings", "enable_auto_invoicing").then(val => { + frappe.db.get_single_value('Non Profit Settings', 'allow_invoicing').then(val => { if (val) frm.set_df_property('linked_item', 'hidden', false); }); + + frm.set_query('linked_item', () => { + return { + filters: { + is_stock_item: 0 + } + }; + }); } }); diff --git a/erpnext/non_profit/doctype/membership_type/membership_type.py b/erpnext/non_profit/doctype/membership_type/membership_type.py index b95b04316f2..022829bd3a6 100644 --- a/erpnext/non_profit/doctype/membership_type/membership_type.py +++ b/erpnext/non_profit/doctype/membership_type/membership_type.py @@ -5,9 +5,14 @@ from __future__ import unicode_literals from frappe.model.document import Document import frappe +from frappe import _ class MembershipType(Document): - pass + def validate(self): + if self.linked_item: + is_stock_item = frappe.db.get_value("Item", self.linked_item, "is_stock_item") + if is_stock_item: + frappe.throw(_("The Linked Item should be a service item")) def get_membership_type(razorpay_id): return frappe.db.exists("Membership Type", {"razorpay_plan_id": razorpay_id}) \ No newline at end of file diff --git a/erpnext/non_profit/doctype/non_profit_settings/__init__.py b/erpnext/non_profit/doctype/non_profit_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.js b/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.js new file mode 100644 index 00000000000..4c4ca9834b0 --- /dev/null +++ b/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.js @@ -0,0 +1,133 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Non Profit Settings", { + refresh: function(frm) { + frm.set_query("inv_print_format", function() { + return { + filters: { + "doc_type": "Sales Invoice" + } + }; + }); + + frm.set_query("membership_print_format", function() { + return { + filters: { + "doc_type": "Membership" + } + }; + }); + + frm.set_query("membership_debit_account", function() { + return { + filters: { + "account_type": "Receivable", + "is_group": 0, + "company": frm.doc.company + } + }; + }); + + frm.set_query("donation_debit_account", function() { + return { + filters: { + "account_type": "Receivable", + "is_group": 0, + "company": frm.doc.donation_company + } + }; + }); + + frm.set_query("membership_payment_account", function () { + var account_types = ["Bank", "Cash"]; + return { + filters: { + "account_type": ["in", account_types], + "is_group": 0, + "company": frm.doc.company + } + }; + }); + + frm.set_query("donation_payment_account", function () { + var account_types = ["Bank", "Cash"]; + return { + filters: { + "account_type": ["in", account_types], + "is_group": 0, + "company": frm.doc.donation_company + } + }; + }); + + let docs_url = "https://docs.erpnext.com/docs/user/manual/en/non_profit/membership"; + + frm.set_intro(__("You can learn more about memberships in the manual. ") + `${__('ERPNext Docs')}`, true); + frm.trigger("setup_buttons_for_membership"); + frm.trigger("setup_buttons_for_donation"); + }, + + setup_buttons_for_membership: function(frm) { + let label; + + if (frm.doc.membership_webhook_secret) { + + frm.add_custom_button(__("Copy Webhook URL"), () => { + frappe.utils.copy_to_clipboard(`https://${frappe.boot.sitename}/api/method/erpnext.non_profit.doctype.membership.membership.trigger_razorpay_subscription`); + }, __("Memberships")); + + frm.add_custom_button(__("Revoke Key"), () => { + frm.call("revoke_key", { + key: "membership_webhook_secret" + }).then(() => { + frm.refresh(); + }); + }, __("Memberships")); + + label = __("Regenerate Webhook Secret"); + + } else { + label = __("Generate Webhook Secret"); + } + + frm.add_custom_button(label, () => { + frm.call("generate_webhook_secret", { + field: "membership_webhook_secret" + }).then(() => { + frm.refresh(); + }); + }, __("Memberships")); + }, + + setup_buttons_for_donation: function(frm) { + let label; + + if (frm.doc.donation_webhook_secret) { + label = __("Regenerate Webhook Secret"); + + frm.add_custom_button(__("Copy Webhook URL"), () => { + frappe.utils.copy_to_clipboard(`https://${frappe.boot.sitename}/api/method/erpnext.non_profit.doctype.donation.donation.capture_razorpay_donations`); + }, __("Donations")); + + frm.add_custom_button(__("Revoke Key"), () => { + frm.call("revoke_key", { + key: "donation_webhook_secret" + }).then(() => { + frm.refresh(); + }); + }, __("Donations")); + + } else { + label = __("Generate Webhook Secret"); + } + + frm.add_custom_button(label, () => { + frm.call("generate_webhook_secret", { + field: "donation_webhook_secret" + }).then(() => { + frm.refresh(); + }); + }, __("Donations")); + } +}); diff --git a/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.json b/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.json new file mode 100644 index 00000000000..25ff0c1bb02 --- /dev/null +++ b/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.json @@ -0,0 +1,273 @@ +{ + "actions": [], + "creation": "2020-03-29 12:57:03.005120", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enable_razorpay_for_memberships", + "razorpay_settings_section", + "billing_cycle", + "billing_frequency", + "membership_webhook_secret", + "column_break_6", + "allow_invoicing", + "automate_membership_invoicing", + "automate_membership_payment_entries", + "company", + "membership_debit_account", + "membership_payment_account", + "column_break_9", + "send_email", + "send_invoice", + "membership_print_format", + "inv_print_format", + "email_template", + "donation_settings_section", + "donation_company", + "default_donor_type", + "donation_webhook_secret", + "column_break_22", + "automate_donation_payment_entries", + "donation_debit_account", + "donation_payment_account", + "section_break_27", + "creation_user" + ], + "fields": [ + { + "fieldname": "billing_cycle", + "fieldtype": "Select", + "label": "Billing Cycle", + "options": "Monthly\nYearly" + }, + { + "depends_on": "eval:doc.enable_razorpay_for_memberships", + "fieldname": "razorpay_settings_section", + "fieldtype": "Section Break", + "label": "RazorPay Settings for Memberships" + }, + { + "description": "The number of billing cycles for which the customer should be charged. For example, if a customer is buying a 1-year membership that should be billed on a monthly basis, this value should be 12.", + "fieldname": "billing_frequency", + "fieldtype": "Int", + "label": "Billing Frequency" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Section Break", + "label": "Membership Invoicing" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "description": "This company will be set for the Memberships created via webhook.", + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.allow_invoicing && doc.send_email", + "fieldname": "send_invoice", + "fieldtype": "Check", + "label": "Send Invoice with Email" + }, + { + "default": "0", + "fieldname": "send_email", + "fieldtype": "Check", + "label": "Send Membership Acknowledgement" + }, + { + "depends_on": "eval: doc.send_invoice", + "fieldname": "inv_print_format", + "fieldtype": "Link", + "label": "Invoice Print Format", + "mandatory_depends_on": "eval: doc.send_invoice", + "options": "Print Format" + }, + { + "depends_on": "eval:doc.send_email", + "fieldname": "membership_print_format", + "fieldtype": "Link", + "label": "Membership Print Format", + "options": "Print Format" + }, + { + "depends_on": "eval:doc.send_email", + "fieldname": "email_template", + "fieldtype": "Link", + "label": "Email Template", + "mandatory_depends_on": "eval:doc.send_email", + "options": "Email Template" + }, + { + "default": "0", + "fieldname": "allow_invoicing", + "fieldtype": "Check", + "label": "Allow Invoicing for Memberships", + "mandatory_depends_on": "eval:doc.send_invoice || doc.make_payment_entry" + }, + { + "default": "0", + "depends_on": "eval:doc.allow_invoicing", + "description": "Automatically create an invoice when payment is authorized from a web form entry", + "fieldname": "automate_membership_invoicing", + "fieldtype": "Check", + "label": "Automate Invoicing for Web Forms" + }, + { + "default": "0", + "depends_on": "eval:doc.allow_invoicing", + "description": "Auto creates Payment Entry for Sales Invoices created for Membership from web forms.", + "fieldname": "automate_membership_payment_entries", + "fieldtype": "Check", + "label": "Automate Payment Entry Creation" + }, + { + "default": "0", + "fieldname": "enable_razorpay_for_memberships", + "fieldtype": "Check", + "label": "Enable RazorPay For Memberships" + }, + { + "depends_on": "eval:doc.automate_membership_payment_entries", + "description": "Account for accepting membership payments", + "fieldname": "membership_payment_account", + "fieldtype": "Link", + "label": "Membership Payment To", + "mandatory_depends_on": "eval:doc.automate_membership_payment_entries", + "options": "Account" + }, + { + "fieldname": "membership_webhook_secret", + "fieldtype": "Password", + "label": "Membership Webhook Secret", + "read_only": 1 + }, + { + "fieldname": "donation_webhook_secret", + "fieldtype": "Password", + "label": "Donation Webhook Secret", + "read_only": 1 + }, + { + "depends_on": "automate_donation_payment_entries", + "description": "Account for accepting donation payments", + "fieldname": "donation_payment_account", + "fieldtype": "Link", + "label": "Donation Payment To", + "mandatory_depends_on": "automate_donation_payment_entries", + "options": "Account" + }, + { + "default": "0", + "description": "Auto creates Payment Entry for Donations created from web forms.", + "fieldname": "automate_donation_payment_entries", + "fieldtype": "Check", + "label": "Automate Donation Payment Entries" + }, + { + "depends_on": "eval:doc.allow_invoicing", + "fieldname": "membership_debit_account", + "fieldtype": "Link", + "label": "Debit Account", + "mandatory_depends_on": "eval:doc.allow_invoicing", + "options": "Account" + }, + { + "depends_on": "automate_donation_payment_entries", + "fieldname": "donation_debit_account", + "fieldtype": "Link", + "label": "Debit Account", + "mandatory_depends_on": "automate_donation_payment_entries", + "options": "Account" + }, + { + "description": "This company will be set for the Donations created via webhook.", + "fieldname": "donation_company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "donation_settings_section", + "fieldtype": "Section Break", + "label": "Donation Settings" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "description": "This Donor Type will be set for the Donor created via Donation web form entry.", + "fieldname": "default_donor_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Default Donor Type", + "options": "Donor Type", + "reqd": 1 + }, + { + "fieldname": "section_break_27", + "fieldtype": "Section Break" + }, + { + "description": "The user that will be used to create Donations, Memberships, Invoices, and Payment Entries. This user should have the relevant permissions.", + "fieldname": "creation_user", + "fieldtype": "Link", + "label": "Creation User", + "options": "User", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2021-03-11 10:43:38.124240", + "modified_by": "Administrator", + "module": "Non Profit", + "name": "Non Profit Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Non Profit Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "print": 1, + "read": 1, + "role": "Non Profit Member", + "share": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.py b/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.py new file mode 100644 index 00000000000..108554c6a08 --- /dev/null +++ b/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.integrations.utils import get_payment_gateway_controller +from frappe.model.document import Document + +class NonProfitSettings(Document): + def generate_webhook_secret(self, field="membership_webhook_secret"): + key = frappe.generate_hash(length=20) + self.set(field, key) + self.save() + + secret_for = "Membership" if field == "membership_webhook_secret" else "Donation" + + frappe.msgprint( + _("Here is your webhook secret for {0} API, this will be shown to you only once.").format(secret_for) + "

    " + key, + _("Webhook Secret") + ) + + def revoke_key(self, key): + self.set(key, None) + self.save() + + def get_webhook_secret(self, endpoint="Membership"): + fieldname = "membership_webhook_secret" if endpoint == "Membership" else "donation_webhook_secret" + return self.get_password(fieldname=fieldname, raise_exception=False) + +@frappe.whitelist() +def get_plans_for_membership(*args, **kwargs): + controller = get_payment_gateway_controller("Razorpay") + plans = controller.get_plans() + return [plan.get("item") for plan in plans.get("items")] \ No newline at end of file diff --git a/erpnext/non_profit/doctype/non_profit_settings/test_non_profit_settings.py b/erpnext/non_profit/doctype/non_profit_settings/test_non_profit_settings.py new file mode 100644 index 00000000000..3f0ede32e59 --- /dev/null +++ b/erpnext/non_profit/doctype/non_profit_settings/test_non_profit_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestNonProfitSettings(unittest.TestCase): + pass diff --git a/erpnext/non_profit/workspace/non_profit/non_profit.json b/erpnext/non_profit/workspace/non_profit/non_profit.json new file mode 100644 index 00000000000..2557d77d881 --- /dev/null +++ b/erpnext/non_profit/workspace/non_profit/non_profit.json @@ -0,0 +1,251 @@ +{ + "category": "Domains", + "charts": [], + "creation": "2020-03-02 17:23:47.811421", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "non-profit", + "idx": 0, + "is_default": 0, + "is_standard": 1, + "label": "Non Profit", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Loan Management", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Type", + "link_to": "Loan Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan Application", + "link_to": "Loan Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loan", + "link_to": "Loan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Grant Application", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Grant Application", + "link_to": "Grant Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Membership", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Member", + "link_to": "Member", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Membership", + "link_to": "Membership", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Membership Type", + "link_to": "Membership Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Membership Settings", + "link_to": "Non Profit Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Volunteer", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Volunteer", + "link_to": "Volunteer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Volunteer Type", + "link_to": "Volunteer Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Chapter", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Chapter", + "link_to": "Chapter", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Donation", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Donor", + "link_to": "Donor", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Donor Type", + "link_to": "Donor Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Donation", + "link_to": "Donation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Tax Exemption Certification (India)", + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Tax Exemption 80G Certificate", + "link_to": "Tax Exemption 80G Certificate", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2021-03-11 11:38:09.140655", + "modified_by": "Administrator", + "module": "Non Profit", + "name": "Non Profit", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "restrict_to_domain": "Non Profit", + "shortcuts": [ + { + "label": "Member", + "link_to": "Member", + "type": "DocType" + }, + { + "label": "Non Profit Settings", + "link_to": "Non Profit Settings", + "type": "DocType" + }, + { + "label": "Membership", + "link_to": "Membership", + "type": "DocType" + }, + { + "label": "Chapter", + "link_to": "Chapter", + "type": "DocType" + }, + { + "label": "Chapter Member", + "link_to": "Chapter Member", + "type": "DocType" + } + ] +} \ No newline at end of file diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 6087ce29aa5..46f0d4ae79e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -450,7 +450,6 @@ erpnext.patches.v8_9.set_member_party_type erpnext.patches.v9_0.add_user_to_child_table_in_pos_profile erpnext.patches.v9_0.set_schedule_date_for_material_request_and_purchase_order erpnext.patches.v9_0.student_admission_childtable_migrate -erpnext.patches.v9_0.fix_subscription_next_date #2017-10-23 erpnext.patches.v9_0.add_healthcare_domain erpnext.patches.v9_0.set_variant_item_description erpnext.patches.v9_0.set_uoms_in_variant_field @@ -632,7 +631,7 @@ execute:frappe.reload_doc('desk', 'doctype', 'dashboard_chart_source') execute:frappe.reload_doc('desk', 'doctype', 'dashboard_chart') execute:frappe.reload_doc('desk', 'doctype', 'dashboard_chart_field') erpnext.patches.v12_0.remove_bank_remittance_custom_fields -erpnext.patches.v12_0.generate_leave_ledger_entries #27-08-2020 +erpnext.patches.v12_0.generate_leave_ledger_entries #04-11-2020 execute:frappe.delete_doc_if_exists("Report", "Loan Repayment") erpnext.patches.v12_0.move_credit_limit_to_customer_credit_limit erpnext.patches.v12_0.add_variant_of_in_item_attribute_table @@ -678,7 +677,7 @@ erpnext.patches.v13_0.move_tax_slabs_from_payroll_period_to_income_tax_slab #123 erpnext.patches.v12_0.fix_quotation_expired_status erpnext.patches.v12_0.update_appointment_reminder_scheduler_entry erpnext.patches.v12_0.rename_pos_closing_doctype -erpnext.patches.v13_0.replace_pos_payment_mode_table +erpnext.patches.v13_0.replace_pos_payment_mode_table #2020-12-29 erpnext.patches.v12_0.remove_duplicate_leave_ledger_entries #2020-05-22 erpnext.patches.v13_0.patch_to_fix_reverse_linking_in_additional_salary_encashment_and_incentive execute:frappe.reload_doc("HR", "doctype", "Employee Advance") @@ -691,6 +690,7 @@ erpnext.patches.v13_0.update_old_loans erpnext.patches.v12_0.set_serial_no_status #2020-05-21 erpnext.patches.v12_0.update_price_list_currency_in_bom execute:frappe.reload_doctype('Dashboard') +execute:frappe.reload_doc('desk', 'doctype', 'number_card_link') execute:frappe.delete_doc_if_exists('Dashboard', 'Accounts') erpnext.patches.v13_0.update_actual_start_and_end_date_in_wo erpnext.patches.v13_0.set_company_field_in_healthcare_doctypes #2020-05-25 @@ -711,6 +711,7 @@ erpnext.patches.v13_0.delete_old_sales_reports execute:frappe.delete_doc_if_exists("DocType", "Bank Reconciliation") erpnext.patches.v13_0.move_doctype_reports_and_notification_from_hr_to_payroll #22-06-2020 erpnext.patches.v13_0.move_payroll_setting_separately_from_hr_settings #22-06-2020 +execute:frappe.reload_doc("regional", "doctype", "e_invoice_settings") erpnext.patches.v13_0.check_is_income_tax_component #22-06-2020 erpnext.patches.v13_0.loyalty_points_entry_for_pos_invoice #22-07-2020 erpnext.patches.v12_0.add_taxjar_integration_field @@ -719,7 +720,7 @@ erpnext.patches.v13_0.delete_report_requested_items_to_order erpnext.patches.v12_0.update_item_tax_template_company erpnext.patches.v13_0.move_branch_code_to_bank_account erpnext.patches.v13_0.healthcare_lab_module_rename_doctypes -erpnext.patches.v13_0.add_standard_navbar_items #4 +erpnext.patches.v13_0.add_standard_navbar_items #2021-03-24 erpnext.patches.v13_0.stock_entry_enhancements erpnext.patches.v12_0.update_state_code_for_daman_and_diu erpnext.patches.v12_0.rename_lost_reason_detail @@ -729,3 +730,35 @@ erpnext.patches.v13_0.setting_custom_roles_for_some_regional_reports erpnext.patches.v13_0.rename_issue_doctype_fields erpnext.patches.v13_0.change_default_pos_print_format erpnext.patches.v13_0.set_youtube_video_id +erpnext.patches.v13_0.set_app_name +erpnext.patches.v13_0.print_uom_after_quantity_patch +erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account +erpnext.patches.v13_0.create_healthcare_custom_fields_in_stock_entry_detail +erpnext.patches.v12_0.setup_einvoice_fields #2020-12-02 +erpnext.patches.v13_0.updates_for_multi_currency_payroll +erpnext.patches.v13_0.update_reason_for_resignation_in_employee +execute:frappe.delete_doc("Report", "Quoted Item Comparison") +erpnext.patches.v13_0.update_member_email_address +erpnext.patches.v13_0.update_custom_fields_for_shopify +erpnext.patches.v13_0.updates_for_multi_currency_payroll +erpnext.patches.v13_0.create_leave_policy_assignment_based_on_employee_current_leave_policy +erpnext.patches.v13_0.update_pos_closing_entry_in_merge_log +erpnext.patches.v13_0.add_po_to_global_search +erpnext.patches.v13_0.update_returned_qty_in_pr_dn +execute:frappe.rename_doc("Workspace", "Loan", "Loan Management", ignore_if_exists=True, force=True) +erpnext.patches.v13_0.create_uae_pos_invoice_fields +erpnext.patches.v13_0.update_project_template_tasks +erpnext.patches.v13_0.set_company_in_leave_ledger_entry +erpnext.patches.v13_0.convert_qi_parameter_to_link_field +erpnext.patches.v13_0.setup_patient_history_settings_for_standard_doctypes +erpnext.patches.v13_0.add_naming_series_to_old_projects # 1-02-2021 +erpnext.patches.v12_0.add_state_code_for_ladakh +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.setup_fields_for_80g_certificate_and_donation +erpnext.patches.v13_0.rename_membership_settings_to_non_profit_settings +erpnext.patches.v13_0.setup_gratuity_rule_for_india_and_uae +erpnext.patches.v13_0.setup_uae_vat_fields +execute:frappe.db.set_value('System Settings', None, 'app_name', 'ERPNext') +erpnext.patches.v13_0.rename_discharge_date_in_ip_record diff --git a/erpnext/patches/v11_0/create_salary_structure_assignments.py b/erpnext/patches/v11_0/create_salary_structure_assignments.py index c51c38182cc..a908c16715a 100644 --- a/erpnext/patches/v11_0/create_salary_structure_assignments.py +++ b/erpnext/patches/v11_0/create_salary_structure_assignments.py @@ -8,8 +8,8 @@ from frappe.utils import getdate from erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment import DuplicateAssignment def execute(): - frappe.reload_doc('Payroll', 'doctype', 'salary_structure') - frappe.reload_doc("Payroll", "doctype", "salary_structure_assignment") + frappe.reload_doc('Payroll', 'doctype', 'Salary Structure') + frappe.reload_doc("Payroll", "doctype", "Salary Structure Assignment") frappe.db.sql(""" delete from `tabSalary Structure Assignment` where salary_structure in (select name from `tabSalary Structure` where is_active='No' or docstatus!=1) @@ -33,6 +33,13 @@ def execute(): AND employee in (select name from `tabEmployee` where ifNull(status, '') != 'Left') """.format(cols), as_dict=1) + all_companies = frappe.db.get_all("Company", fields=["name", "default_currency"]) + for d in all_companies: + company = d.name + company_currency = d.default_currency + + frappe.db.sql("""update `tabSalary Structure` set currency = %s where company=%s""", (company_currency, company)) + for d in ss_details: try: joining_date, relieving_date = frappe.db.get_value("Employee", d.employee, @@ -42,6 +49,7 @@ def execute(): from_date = joining_date elif relieving_date and getdate(from_date) > relieving_date: continue + company_currency = frappe.db.get_value('Company', d.company, 'default_currency') s = frappe.new_doc("Salary Structure Assignment") s.employee = d.employee @@ -52,6 +60,7 @@ def execute(): s.base = d.get("base") s.variable = d.get("variable") s.company = d.company + s.currency = company_currency # to migrate the data of the old employees s.flags.old_employee = True diff --git a/erpnext/patches/v11_0/refactor_autoname_naming.py b/erpnext/patches/v11_0/refactor_autoname_naming.py index 5dc5d3bf0cf..b997ba2db22 100644 --- a/erpnext/patches/v11_0/refactor_autoname_naming.py +++ b/erpnext/patches/v11_0/refactor_autoname_naming.py @@ -20,7 +20,7 @@ doctype_series_map = { 'Certified Consultant': 'NPO-CONS-.YYYY.-.#####', 'Chat Room': 'CHAT-ROOM-.#####', 'Compensatory Leave Request': 'HR-CMP-.YY.-.MM.-.#####', - 'Custom Script': 'SYS-SCR-.#####', + 'Client Script': 'SYS-SCR-.#####', 'Employee Benefit Application': 'HR-BEN-APP-.YY.-.MM.-.#####', 'Employee Benefit Application Detail': '', 'Employee Benefit Claim': 'HR-BEN-CLM-.YY.-.MM.-.#####', diff --git a/erpnext/patches/v11_1/update_bank_transaction_status.py b/erpnext/patches/v11_1/update_bank_transaction_status.py index 1acdfcccf9f..544bc5e6911 100644 --- a/erpnext/patches/v11_1/update_bank_transaction_status.py +++ b/erpnext/patches/v11_1/update_bank_transaction_status.py @@ -7,9 +7,20 @@ import frappe def execute(): frappe.reload_doc("accounts", "doctype", "bank_transaction") - frappe.db.sql(""" UPDATE `tabBank Transaction` - SET status = 'Reconciled' - WHERE - status = 'Settled' and (debit = allocated_amount or credit = allocated_amount) - and ifnull(allocated_amount, 0) > 0 - """) \ No newline at end of file + bank_transaction_fields = frappe.get_meta("Bank Transaction").get_valid_columns() + + if 'debit' in bank_transaction_fields: + frappe.db.sql(""" UPDATE `tabBank Transaction` + SET status = 'Reconciled' + WHERE + status = 'Settled' and (debit = allocated_amount or credit = allocated_amount) + and ifnull(allocated_amount, 0) > 0 + """) + + elif 'deposit' in bank_transaction_fields: + frappe.db.sql(""" UPDATE `tabBank Transaction` + SET status = 'Reconciled' + WHERE + status = 'Settled' and (deposit = allocated_amount or withdrawal = allocated_amount) + and ifnull(allocated_amount, 0) > 0 + """) \ No newline at end of file diff --git a/erpnext/patches/v12_0/add_state_code_for_ladakh.py b/erpnext/patches/v12_0/add_state_code_for_ladakh.py new file mode 100644 index 00000000000..29a7b4bd602 --- /dev/null +++ b/erpnext/patches/v12_0/add_state_code_for_ladakh.py @@ -0,0 +1,17 @@ +import frappe +from erpnext.regional.india import states + +def execute(): + + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + custom_fields = ['Address-gst_state', 'Tax Category-gst_state'] + + # Update options in gst_state custom fields + for field in custom_fields: + if frappe.db.exists('Custom Field', field): + gst_state_field = frappe.get_doc('Custom Field', field) + gst_state_field.options = '\n'.join(states) + gst_state_field.save() diff --git a/erpnext/patches/v12_0/generate_leave_ledger_entries.py b/erpnext/patches/v12_0/generate_leave_ledger_entries.py index 342c12996d1..fe072d7eb96 100644 --- a/erpnext/patches/v12_0/generate_leave_ledger_entries.py +++ b/erpnext/patches/v12_0/generate_leave_ledger_entries.py @@ -11,8 +11,6 @@ def execute(): frappe.reload_doc("HR", "doctype", "Leave Ledger Entry") frappe.reload_doc("HR", "doctype", "Leave Encashment") frappe.reload_doc("HR", "doctype", "Leave Type") - if frappe.db.a_row_exists("Leave Ledger Entry"): - return if not frappe.get_meta("Leave Allocation").has_field("unused_leaves"): frappe.reload_doc("HR", "doctype", "Leave Allocation") @@ -53,7 +51,7 @@ def generate_encashment_leave_ledger_entries(): for encashment in leave_encashments: if not frappe.db.exists("Leave Ledger Entry", {'transaction_type': 'Leave Encashment', 'transaction_name': encashment.name}): - frappe.get_doc("Leave Enchashment", encashment).create_leave_ledger_entry() + frappe.get_doc("Leave Encashment", encashment).create_leave_ledger_entry() def generate_expiry_allocation_ledger_entries(): ''' fix ledger entries for missing leave allocation transaction ''' diff --git a/erpnext/patches/v12_0/setup_einvoice_fields.py b/erpnext/patches/v12_0/setup_einvoice_fields.py new file mode 100644 index 00000000000..2474bc3b82c --- /dev/null +++ b/erpnext/patches/v12_0/setup_einvoice_fields.py @@ -0,0 +1,56 @@ +from __future__ import unicode_literals +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from erpnext.regional.india.setup import add_permissions, add_print_formats + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + frappe.reload_doc("custom", "doctype", "custom_field") + frappe.reload_doc("regional", "doctype", "e_invoice_settings") + custom_fields = { + 'Sales Invoice': [ + dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1, + depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'), + + dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='irn', no_copy=1, print_hide=1), + + dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1), + + dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), + + dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), + + dict(fieldname='signed_einvoice', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='signed_qr_code', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, no_copy=1, print_hide=1, read_only=1) + ] + } + create_custom_fields(custom_fields, update=True) + add_permissions() + add_print_formats() + + einvoice_cond = 'in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category)' + t = { + 'mode_of_transport': [{'default': None}], + 'distance': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.transporter'}], + 'gst_vehicle_type': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}], + 'lr_date': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}], + 'lr_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}], + 'vehicle_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}], + 'ewaybill': [ + {'read_only_depends_on': 'eval:doc.irn && doc.ewaybill'}, + {'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)'} + ] + } + + for field, conditions in t.items(): + for c in conditions: + [(prop, value)] = c.items() + frappe.db.set_value('Custom Field', { 'fieldname': field }, prop, value) diff --git a/erpnext/patches/v13_0/add_naming_series_to_old_projects.py b/erpnext/patches/v13_0/add_naming_series_to_old_projects.py new file mode 100644 index 00000000000..5ed9040f1ed --- /dev/null +++ b/erpnext/patches/v13_0/add_naming_series_to_old_projects.py @@ -0,0 +1,13 @@ +from __future__ import unicode_literals +import frappe +from frappe.custom.doctype.property_setter.property_setter import make_property_setter, delete_property_setter + +def execute(): + frappe.reload_doc("projects", "doctype", "project") + + frappe.db.sql("""UPDATE `tabProject` + SET + naming_series = 'PROJ-.####' + WHERE + naming_series is NULL""") + diff --git a/erpnext/patches/v13_0/add_po_to_global_search.py b/erpnext/patches/v13_0/add_po_to_global_search.py new file mode 100644 index 00000000000..1c60b18e5b2 --- /dev/null +++ b/erpnext/patches/v13_0/add_po_to_global_search.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals +import frappe + + +def execute(): + global_search_settings = frappe.get_single("Global Search Settings") + + if "Purchase Order" in ( + dt.document_type for dt in global_search_settings.allowed_in_global_search + ): + return + + global_search_settings.append( + "allowed_in_global_search", {"document_type": "Purchase Order"} + ) + + global_search_settings.save(ignore_permissions=True) diff --git a/erpnext/patches/v13_0/convert_qi_parameter_to_link_field.py b/erpnext/patches/v13_0/convert_qi_parameter_to_link_field.py new file mode 100644 index 00000000000..289b6a761e3 --- /dev/null +++ b/erpnext/patches/v13_0/convert_qi_parameter_to_link_field.py @@ -0,0 +1,23 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc('stock', 'doctype', 'quality_inspection_parameter') + + # get all distinct parameters from QI readigs table + reading_params = frappe.db.get_all("Quality Inspection Reading", fields=["distinct specification"]) + reading_params = [d.specification for d in reading_params] + + # get all distinct parameters from QI Template as some may be unused in QI + template_params = frappe.db.get_all("Item Quality Inspection Parameter", fields=["distinct specification"]) + template_params = [d.specification for d in template_params] + + params = list(set(reading_params + template_params)) + + for parameter in params: + if not frappe.db.exists("Quality Inspection Parameter", parameter): + frappe.get_doc({ + "doctype": "Quality Inspection Parameter", + "parameter": parameter, + "description": parameter + }).insert(ignore_permissions=True) \ No newline at end of file diff --git a/erpnext/patches/v13_0/create_healthcare_custom_fields_in_stock_entry_detail.py b/erpnext/patches/v13_0/create_healthcare_custom_fields_in_stock_entry_detail.py new file mode 100644 index 00000000000..585e5406265 --- /dev/null +++ b/erpnext/patches/v13_0/create_healthcare_custom_fields_in_stock_entry_detail.py @@ -0,0 +1,10 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from erpnext.domains.healthcare import data + +def execute(): + if 'Healthcare' not in frappe.get_active_domains(): + return + + if data['custom_fields']: + create_custom_fields(data['custom_fields']) \ No newline at end of file diff --git a/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py b/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py new file mode 100644 index 00000000000..90dc0e2e18b --- /dev/null +++ b/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py @@ -0,0 +1,79 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals + +import frappe + +def execute(): + if "leave_policy" in frappe.db.get_table_columns("Employee"): + employees_with_leave_policy = frappe.db.sql("SELECT name, leave_policy FROM `tabEmployee` WHERE leave_policy IS NOT NULL and leave_policy != ''", as_dict = 1) + + employee_with_assignment = [] + leave_policy =[] + + #for employee + + for employee in employees_with_leave_policy: + alloc = frappe.db.exists("Leave Allocation", {"employee":employee.name, "leave_policy": employee.leave_policy, "docstatus": 1}) + if not alloc: + create_assignment(employee.name, employee.leave_policy) + + employee_with_assignment.append(employee.name) + leave_policy.append(employee.leave_policy) + + + if "default_leave_policy" in frappe.db.get_table_columns("Employee"): + employee_grade_with_leave_policy = frappe.db.sql("SELECT name, default_leave_policy FROM `tabEmployee Grade` WHERE default_leave_policy IS NOT NULL and default_leave_policy!=''", as_dict = 1) + + #for whole employee Grade + + for grade in employee_grade_with_leave_policy: + employees = get_employee_with_grade(grade.name) + for employee in employees: + + if employee not in employee_with_assignment: #Will ensure no duplicate + alloc = frappe.db.exists("Leave Allocation", {"employee":employee.name, "leave_policy": grade.default_leave_policy, "docstatus": 1}) + if not alloc: + create_assignment(employee.name, grade.default_leave_policy) + leave_policy.append(grade.default_leave_policy) + + #for old Leave allocation and leave policy from allocation, which may got updated in employee grade. + leave_allocations = frappe.db.sql("SELECT leave_policy, leave_period, employee FROM `tabLeave Allocation` WHERE leave_policy IS NOT NULL and leave_policy != '' and docstatus = 1 ", as_dict = 1) + + for allocation in leave_allocations: + if allocation.leave_policy not in leave_policy: + create_assignment(allocation.employee, allocation.leave_policy, leave_period=allocation.leave_period, + allocation_exists=True) + +def create_assignment(employee, leave_policy, leave_period=None, allocation_exists = False): + + filters = {"employee":employee, "leave_policy": leave_policy} + if leave_period: + filters["leave_period"] = leave_period + + frappe.reload_doc('hr', 'doctype', 'leave_policy_assignment') + + if not frappe.db.exists("Leave Policy Assignment" , filters): + lpa = frappe.new_doc("Leave Policy Assignment") + lpa.employee = employee + lpa.leave_policy = leave_policy + + lpa.flags.ignore_mandatory = True + if allocation_exists: + lpa.assignment_based_on = 'Leave Period' + lpa.leave_period = leave_period + lpa.leaves_allocated = 1 + + lpa.save() + if allocation_exists: + lpa.submit() + #Updating old Leave Allocation + frappe.db.sql("Update `tabLeave Allocation` set leave_policy_assignment = %s", lpa.name) + + +def get_employee_with_grade(grade): + return frappe.get_list("Employee", filters = {"grade": grade}) + + + diff --git a/erpnext/patches/v13_0/create_uae_pos_invoice_fields.py b/erpnext/patches/v13_0/create_uae_pos_invoice_fields.py new file mode 100644 index 00000000000..48d5cb4cc8f --- /dev/null +++ b/erpnext/patches/v13_0/create_uae_pos_invoice_fields.py @@ -0,0 +1,14 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals + +import frappe +from erpnext.regional.united_arab_emirates.setup import make_custom_fields + +def execute(): + company = frappe.get_all('Company', filters = {'country': ['in', ['Saudi Arabia', 'United Arab Emirates']]}) + if not company: + return + + make_custom_fields() \ No newline at end of file diff --git a/erpnext/patches/v13_0/delete_old_bank_reconciliation_doctypes.py b/erpnext/patches/v13_0/delete_old_bank_reconciliation_doctypes.py new file mode 100644 index 00000000000..af1f6e7ec17 --- /dev/null +++ b/erpnext/patches/v13_0/delete_old_bank_reconciliation_doctypes.py @@ -0,0 +1,26 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals + +import frappe +from frappe.model.utils.rename_field import rename_field + +def execute(): + doctypes = [ + "Bank Statement Settings", + "Bank Statement Settings Item", + "Bank Statement Transaction Entry", + "Bank Statement Transaction Invoice Item", + "Bank Statement Transaction Payment Item", + "Bank Statement Transaction Settings Item", + "Bank Statement Transaction Settings", + ] + + for doctype in doctypes: + frappe.delete_doc("DocType", doctype, force=1) + + frappe.delete_doc("Page", "bank-reconciliation", force=1) + + rename_field("Bank Transaction", "debit", "deposit") + rename_field("Bank Transaction", "credit", "withdrawal") 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 new file mode 100644 index 00000000000..d968e1fb763 --- /dev/null +++ b/erpnext/patches/v13_0/item_reposting_for_incorrect_sl_and_gl.py @@ -0,0 +1,63 @@ +import frappe +from frappe import _ +from frappe.utils import getdate, get_time, today +from erpnext.stock.stock_ledger import update_entries_after +from erpnext.accounts.utils import update_gl_entries_after + +def execute(): + for doctype in ('repost_item_valuation', 'stock_entry_detail', 'purchase_receipt_item', + 'purchase_invoice_item', 'delivery_note_item', 'sales_invoice_item', 'packed_item'): + frappe.reload_doc('stock', 'doctype', doctype) + frappe.reload_doc('buying', 'doctype', 'purchase_receipt_item_supplied') + + reposting_project_deployed_on = get_creation_time() + posting_date = getdate(reposting_project_deployed_on) + posting_time = get_time(reposting_project_deployed_on) + + if posting_date == today(): + return + + frappe.clear_cache() + frappe.flags.warehouse_account_map = {} + + data = frappe.db.sql(''' + SELECT + name, item_code, warehouse, voucher_type, voucher_no, posting_date, posting_time + FROM + `tabStock Ledger Entry` + WHERE + creation > %s + and is_cancelled = 0 + ORDER BY timestamp(posting_date, posting_time) asc, creation asc + ''', reposting_project_deployed_on, as_dict=1) + + frappe.db.auto_commit_on_many_writes = 1 + print("Reposting Stock Ledger Entries...") + total_sle = len(data) + i = 0 + for d in data: + update_entries_after({ + "item_code": d.item_code, + "warehouse": d.warehouse, + "posting_date": d.posting_date, + "posting_time": d.posting_time, + "voucher_type": d.voucher_type, + "voucher_no": d.voucher_no, + "sle_id": d.name + }, allow_negative_stock=True) + + i += 1 + if i%100 == 0: + print(i, "/", total_sle) + + + print("Reposting General Ledger Entries...") + + for row in frappe.get_all('Company', filters= {'enable_perpetual_inventory': 1}): + update_gl_entries_after(posting_date, posting_time, company=row.name) + + frappe.db.auto_commit_on_many_writes = 0 + +def get_creation_time(): + return frappe.db.sql(''' SELECT create_time FROM + INFORMATION_SCHEMA.TABLES where TABLE_NAME = "tabRepost Item Valuation" ''', as_list=1)[0][0] \ No newline at end of file diff --git a/erpnext/patches/v13_0/print_uom_after_quantity_patch.py b/erpnext/patches/v13_0/print_uom_after_quantity_patch.py new file mode 100644 index 00000000000..0de3728f5c6 --- /dev/null +++ b/erpnext/patches/v13_0/print_uom_after_quantity_patch.py @@ -0,0 +1,10 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals + +import frappe +from erpnext.setup.install import create_print_uom_after_qty_custom_field + +def execute(): + create_print_uom_after_qty_custom_field() diff --git a/erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py b/erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py new file mode 100644 index 00000000000..491dc82f784 --- /dev/null +++ b/erpnext/patches/v13_0/rename_discharge_date_in_ip_record.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals +import frappe +from frappe.model.utils.rename_field import rename_field + +def execute(): + frappe.reload_doc("Healthcare", "doctype", "Inpatient Record") + if frappe.db.has_column("Inpatient Record", "discharge_date"): + rename_field("Inpatient Record", "discharge_date", "discharge_datetime") diff --git a/erpnext/patches/v13_0/rename_issue_doctype_fields.py b/erpnext/patches/v13_0/rename_issue_doctype_fields.py index 5bd65965790..fa1dfed6435 100644 --- a/erpnext/patches/v13_0/rename_issue_doctype_fields.py +++ b/erpnext/patches/v13_0/rename_issue_doctype_fields.py @@ -29,7 +29,7 @@ def execute(): 'response_by_variance': response_by_variance, 'resolution_by_variance': resolution_by_variance, 'first_response_time': mins_to_first_response - }) + }, update_modified=False) # commit after every 100 updates count += 1 if count%100 == 0: @@ -44,7 +44,7 @@ def execute(): count = 0 for entry in opportunities: mins_to_first_response = convert_to_seconds(entry.mins_to_first_response, 'Minutes') - frappe.db.set_value('Opportunity', entry.name, 'first_response_time', mins_to_first_response) + frappe.db.set_value('Opportunity', entry.name, 'first_response_time', mins_to_first_response, update_modified=False) # commit after every 100 updates count += 1 if count%100 == 0: @@ -53,7 +53,7 @@ def execute(): # renamed reports from "Minutes to First Response for Issues" to "First Response Time for Issues". Same for Opportunity for report in ['Minutes to First Response for Issues', 'Minutes to First Response for Opportunity']: if frappe.db.exists('Report', report): - frappe.delete_doc('Report', report) + frappe.delete_doc('Report', report, ignore_permissions=True) def convert_to_seconds(value, unit): diff --git a/erpnext/patches/v13_0/rename_membership_settings_to_non_profit_settings.py b/erpnext/patches/v13_0/rename_membership_settings_to_non_profit_settings.py new file mode 100644 index 00000000000..3fa09a7baaa --- /dev/null +++ b/erpnext/patches/v13_0/rename_membership_settings_to_non_profit_settings.py @@ -0,0 +1,22 @@ +from __future__ import unicode_literals +import frappe +from frappe.model.utils.rename_field import rename_field + +def execute(): + if frappe.db.table_exists("Membership Settings"): + frappe.rename_doc("DocType", "Membership Settings", "Non Profit Settings") + frappe.reload_doctype("Non Profit Settings", force=True) + + if frappe.db.table_exists("Non Profit Settings"): + rename_fields_map = { + "enable_invoicing": "allow_invoicing", + "create_for_web_forms": "automate_membership_invoicing", + "make_payment_entry": "automate_membership_payment_entries", + "enable_razorpay": "enable_razorpay_for_memberships", + "debit_account": "membership_debit_account", + "payment_account": "membership_payment_account", + "webhook_secret": "membership_webhook_secret" + } + + for old_name, new_name in rename_fields_map.items(): + rename_field("Non Profit Settings", old_name, new_name) \ No newline at end of file diff --git a/erpnext/patches/v13_0/replace_pos_payment_mode_table.py b/erpnext/patches/v13_0/replace_pos_payment_mode_table.py index 1ca211bf1be..7cb264830ab 100644 --- a/erpnext/patches/v13_0/replace_pos_payment_mode_table.py +++ b/erpnext/patches/v13_0/replace_pos_payment_mode_table.py @@ -6,12 +6,10 @@ from __future__ import unicode_literals import frappe def execute(): - frappe.reload_doc("accounts", "doctype", "POS Payment Method") + frappe.reload_doc("accounts", "doctype", "pos_payment_method") pos_profiles = frappe.get_all("POS Profile") for pos_profile in pos_profiles: - if not pos_profile.get("payments"): return - payments = frappe.db.sql(""" select idx, parentfield, parenttype, parent, mode_of_payment, `default` from `tabSales Invoice Payment` where parent=%s """, pos_profile.name, as_dict=1) diff --git a/erpnext/patches/v13_0/set_app_name.py b/erpnext/patches/v13_0/set_app_name.py new file mode 100644 index 00000000000..3f886f1d159 --- /dev/null +++ b/erpnext/patches/v13_0/set_app_name.py @@ -0,0 +1,7 @@ +import frappe +from frappe import _ + +def execute(): + frappe.reload_doctype("System Settings") + settings = frappe.get_doc("System Settings") + settings.db_set("app_name", "ERPNext", commit=True) diff --git a/erpnext/patches/v13_0/set_company_in_leave_ledger_entry.py b/erpnext/patches/v13_0/set_company_in_leave_ledger_entry.py new file mode 100644 index 00000000000..66857c4e659 --- /dev/null +++ b/erpnext/patches/v13_0/set_company_in_leave_ledger_entry.py @@ -0,0 +1,7 @@ +import frappe + +def execute(): + frappe.reload_doc('HR', 'doctype', 'Leave Allocation') + frappe.reload_doc('HR', 'doctype', 'Leave Ledger Entry') + frappe.db.sql("""update `tabLeave Ledger Entry` as lle set company = (select company from `tabEmployee` where employee = lle.employee)""") + frappe.db.sql("""update `tabLeave Allocation` as la set company = (select company from `tabEmployee` where employee = la.employee)""") \ No newline at end of file diff --git a/erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py b/erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py new file mode 100644 index 00000000000..edca2383930 --- /dev/null +++ b/erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + """Set the payment gateway account as Email for all the existing payment channel.""" + doc_meta = frappe.get_meta("Payment Gateway Account") + if doc_meta.get_field("payment_channel"): + return + + frappe.reload_doc("Accounts", "doctype", "Payment Gateway Account") + set_payment_channel_as_email() + +def set_payment_channel_as_email(): + frappe.db.sql(""" + UPDATE `tabPayment Gateway Account` + SET `payment_channel` = "Email" + """) \ No newline at end of file diff --git a/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py b/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py new file mode 100644 index 00000000000..833c355d5f8 --- /dev/null +++ b/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py @@ -0,0 +1,13 @@ +import frappe +from erpnext.regional.india.setup import make_custom_fields + +def execute(): + if frappe.get_all('Company', filters = {'country': 'India'}): + make_custom_fields() + + if not frappe.db.exists('Party Type', 'Donor'): + frappe.get_doc({ + 'doctype': 'Party Type', + 'party_type': 'Donor', + 'account_type': 'Receivable' + }).insert(ignore_permissions=True) diff --git a/erpnext/patches/v13_0/setup_gratuity_rule_for_india_and_uae.py b/erpnext/patches/v13_0/setup_gratuity_rule_for_india_and_uae.py new file mode 100644 index 00000000000..01fd6a158e9 --- /dev/null +++ b/erpnext/patches/v13_0/setup_gratuity_rule_for_india_and_uae.py @@ -0,0 +1,16 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc('payroll', 'doctype', 'gratuity_rule') + frappe.reload_doc('payroll', 'doctype', 'gratuity_rule_slab') + frappe.reload_doc('payroll', 'doctype', 'gratuity_applicable_component') + if frappe.db.exists("Company", {"country": "India"}): + from erpnext.regional.india.setup import create_gratuity_rule + create_gratuity_rule() + if frappe.db.exists("Company", {"country": "United Arab Emirates"}): + from erpnext.regional.united_arab_emirates.setup import create_gratuity_rule + create_gratuity_rule() diff --git a/erpnext/patches/v13_0/setup_patient_history_settings_for_standard_doctypes.py b/erpnext/patches/v13_0/setup_patient_history_settings_for_standard_doctypes.py new file mode 100644 index 00000000000..de08aa26b3b --- /dev/null +++ b/erpnext/patches/v13_0/setup_patient_history_settings_for_standard_doctypes.py @@ -0,0 +1,13 @@ +from __future__ import unicode_literals +import frappe +from erpnext.healthcare.setup import setup_patient_history_settings + +def execute(): + if "Healthcare" not in frappe.get_active_domains(): + return + + frappe.reload_doc("healthcare", "doctype", "Patient History Settings") + frappe.reload_doc("healthcare", "doctype", "Patient History Standard Document Type") + frappe.reload_doc("healthcare", "doctype", "Patient History Custom Document Type") + + setup_patient_history_settings() \ No newline at end of file diff --git a/erpnext/patches/v13_0/setup_uae_vat_fields.py b/erpnext/patches/v13_0/setup_uae_vat_fields.py new file mode 100644 index 00000000000..d7a5c682df7 --- /dev/null +++ b/erpnext/patches/v13_0/setup_uae_vat_fields.py @@ -0,0 +1,12 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from erpnext.regional.united_arab_emirates.setup import setup + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'United Arab Emirates'}) + if not company: + return + + setup() \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_custom_fields_for_shopify.py b/erpnext/patches/v13_0/update_custom_fields_for_shopify.py new file mode 100644 index 00000000000..f1d2ea2d747 --- /dev/null +++ b/erpnext/patches/v13_0/update_custom_fields_for_shopify.py @@ -0,0 +1,10 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe +from erpnext.erpnext_integrations.doctype.shopify_settings.shopify_settings import setup_custom_fields + +def execute(): + if frappe.db.get_single_value('Shopify Settings', 'enable_shopify'): + setup_custom_fields() diff --git a/erpnext/patches/v13_0/update_member_email_address.py b/erpnext/patches/v13_0/update_member_email_address.py new file mode 100644 index 00000000000..4056f84069c --- /dev/null +++ b/erpnext/patches/v13_0/update_member_email_address.py @@ -0,0 +1,23 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.utils.rename_field import rename_field + +def execute(): + """add value to email_id column from email""" + + if frappe.db.has_column("Member", "email"): + # Get all members + for member in frappe.db.get_all("Member", pluck="name"): + # Check if email_id already exists + if not frappe.db.get_value("Member", member, "email_id"): + # fetch email id from the user linked field email + email = frappe.db.get_value("Member", member, "email") + + # Set the value for it + frappe.db.set_value("Member", member, "email_id", email) + + if frappe.db.exists("DocType", "Membership Settings"): + rename_field("Membership Settings", "enable_auto_invoicing", "enable_invoicing") diff --git a/erpnext/patches/v13_0/update_old_loans.py b/erpnext/patches/v13_0/update_old_loans.py index 77239429c51..8cf09aa6925 100644 --- a/erpnext/patches/v13_0/update_old_loans.py +++ b/erpnext/patches/v13_0/update_old_loans.py @@ -1,10 +1,12 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import nowdate +from frappe.utils import nowdate, flt from erpnext.accounts.doctype.account.test_account import create_account from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import process_loan_interest_accrual_for_term_loans from erpnext.loan_management.doctype.loan.loan import make_repayment_entry +from erpnext.loan_management.doctype.loan_repayment.loan_repayment import get_accrued_interest_entries +from frappe.model.naming import make_autoname def execute(): @@ -18,15 +20,29 @@ def execute(): frappe.reload_doc('loan_management', 'doctype', 'loan_repayment_detail') frappe.reload_doc('loan_management', 'doctype', 'loan_interest_accrual') frappe.reload_doc('accounts', 'doctype', 'gl_entry') + frappe.reload_doc('accounts', 'doctype', 'journal_entry_account') updated_loan_types = [] + loans_to_close = [] + + # Update old loan status as closed + if frappe.db.has_column('Repayment Schedule', 'paid'): + loans_list = frappe.db.sql("""SELECT distinct parent from `tabRepayment Schedule` + where paid = 0 and docstatus = 1""", as_dict=1) + + loans_to_close = [d.parent for d in loans_list] + + if loans_to_close: + frappe.db.sql("UPDATE `tabLoan` set status = 'Closed' where name not in (%s)" % (', '.join(['%s'] * len(loans_to_close))), tuple(loans_to_close)) loans = frappe.get_all('Loan', fields=['name', 'loan_type', 'company', 'status', 'mode_of_payment', - 'applicant_type', 'applicant', 'loan_account', 'payment_account', 'interest_income_account']) + 'applicant_type', 'applicant', 'loan_account', 'payment_account', 'interest_income_account'], + filters={'docstatus': 1, 'status': ('!=', 'Closed')}) for loan in loans: # Update details in Loan Types and Loan loan_type_company = frappe.db.get_value('Loan Type', loan.loan_type, 'company') + loan_type = loan.loan_type group_income_account = frappe.get_value('Account', {'company': loan.company, 'is_group': 1, 'root_type': 'Income', 'account_name': _('Indirect Income')}) @@ -38,7 +54,26 @@ def execute(): penalty_account = create_account(company=loan.company, account_type='Income Account', account_name='Penalty Account', parent_account=group_income_account) - if not loan_type_company: + # Same loan type used for multiple companies + if loan_type_company and loan_type_company != loan.company: + # get loan type for appropriate company + loan_type_name = frappe.get_value('Loan Type', {'company': loan.company, + 'mode_of_payment': loan.mode_of_payment, 'loan_account': loan.loan_account, + 'payment_account': loan.payment_account, 'interest_income_account': loan.interest_income_account, + 'penalty_income_account': loan.penalty_income_account}, 'name') + + if not loan_type_name: + loan_type_name = create_loan_type(loan, loan_type_name, penalty_account) + + # update loan type in loan + frappe.db.sql("UPDATE `tabLoan` set loan_type = %s where name = %s", (loan_type_name, + loan.name)) + + loan_type = loan_type_name + if loan_type_name not in updated_loan_types: + updated_loan_types.append(loan_type_name) + + elif not loan_type_company: loan_type_doc = frappe.get_doc('Loan Type', loan.loan_type) loan_type_doc.is_term_loan = 1 loan_type_doc.company = loan.company @@ -49,8 +84,9 @@ def execute(): loan_type_doc.penalty_income_account = penalty_account loan_type_doc.submit() updated_loan_types.append(loan.loan_type) + loan_type = loan.loan_type - if loan.loan_type in updated_loan_types: + if loan_type in updated_loan_types: if loan.status == 'Fully Disbursed': status = 'Disbursed' elif loan.status == 'Repaid/Closed': @@ -64,25 +100,48 @@ def execute(): 'status': status }) - process_loan_interest_accrual_for_term_loans(posting_date=nowdate(), loan_type=loan.loan_type, + process_loan_interest_accrual_for_term_loans(posting_date=nowdate(), loan_type=loan_type, loan=loan.name) - payments = frappe.db.sql(''' SELECT j.name, a.debit, a.debit_in_account_currency, j.posting_date - FROM `tabJournal Entry` j, `tabJournal Entry Account` a - WHERE a.parent = j.name and a.reference_type='Loan' and a.reference_name = %s - and account = %s - ''', (loan.name, loan.loan_account), as_dict=1) - for payment in payments: - repayment_entry = make_repayment_entry(loan.name, loan.loan_applicant_type, loan.applicant, - loan.loan_type, loan.company) + if frappe.db.has_column('Repayment Schedule', 'paid'): + total_principal, total_interest = frappe.db.get_value('Repayment Schedule', {'paid': 1, 'parent': loan.name}, + ['sum(principal_amount) as total_principal', 'sum(interest_amount) as total_interest']) - repayment_entry.amount_paid = payment.debit_in_account_currency - repayment_entry.posting_date = payment.posting_date - repayment_entry.save() - repayment_entry.submit() + accrued_entries = get_accrued_interest_entries(loan.name) + for entry in accrued_entries: + interest_paid = 0 + principal_paid = 0 - jv = frappe.get_doc('Journal Entry', payment.name) - jv.flags.ignore_links = True - jv.cancel() + if flt(total_interest) > flt(entry.interest_amount): + interest_paid = flt(entry.interest_amount) + else: + interest_paid = flt(total_interest) + if flt(total_principal) > flt(entry.payable_principal_amount): + principal_paid = flt(entry.payable_principal_amount) + else: + principal_paid = flt(total_principal) + + frappe.db.sql(""" UPDATE `tabLoan Interest Accrual` + SET paid_principal_amount = `paid_principal_amount` + %s, + paid_interest_amount = `paid_interest_amount` + %s + WHERE name = %s""", + (principal_paid, interest_paid, entry.name)) + + total_principal = flt(total_principal) - principal_paid + total_interest = flt(total_interest) - interest_paid + +def create_loan_type(loan, loan_type_name, penalty_account): + loan_type_doc = frappe.new_doc('Loan Type') + loan_type_doc.loan_name = make_autoname("Loan Type-.####") + loan_type_doc.is_term_loan = 1 + loan_type_doc.company = loan.company + loan_type_doc.mode_of_payment = loan.mode_of_payment + loan_type_doc.payment_account = loan.payment_account + loan_type_doc.loan_account = loan.loan_account + loan_type_doc.interest_income_account = loan.interest_income_account + loan_type_doc.penalty_income_account = penalty_account + loan_type_doc.submit() + + return loan_type_doc.name diff --git a/erpnext/patches/v13_0/update_pos_closing_entry_in_merge_log.py b/erpnext/patches/v13_0/update_pos_closing_entry_in_merge_log.py new file mode 100644 index 00000000000..262e38dd056 --- /dev/null +++ b/erpnext/patches/v13_0/update_pos_closing_entry_in_merge_log.py @@ -0,0 +1,25 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc("accounts", "doctype", "POS Invoice Merge Log") + frappe.reload_doc("accounts", "doctype", "POS Closing Entry") + if frappe.db.count('POS Invoice Merge Log'): + frappe.db.sql(''' + UPDATE + `tabPOS Invoice Merge Log` log, `tabPOS Invoice Reference` log_ref + SET + log.pos_closing_entry = ( + SELECT clo_ref.parent FROM `tabPOS Invoice Reference` clo_ref + WHERE clo_ref.pos_invoice = log_ref.pos_invoice + AND clo_ref.parenttype = 'POS Closing Entry' LIMIT 1 + ) + WHERE + log_ref.parent = log.name + ''') + + frappe.db.sql('''UPDATE `tabPOS Closing Entry` SET status = 'Submitted' where docstatus = 1''') + frappe.db.sql('''UPDATE `tabPOS Closing Entry` SET status = 'Cancelled' where docstatus = 2''') diff --git a/erpnext/patches/v13_0/update_project_template_tasks.py b/erpnext/patches/v13_0/update_project_template_tasks.py new file mode 100644 index 00000000000..8cc27d217fe --- /dev/null +++ b/erpnext/patches/v13_0/update_project_template_tasks.py @@ -0,0 +1,47 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc("projects", "doctype", "project_template") + frappe.reload_doc("projects", "doctype", "project_template_task") + frappe.reload_doc("projects", "doctype", "task") + + # Update property setter status if any + property_setter = frappe.db.get_value('Property Setter', {'doc_type': 'Task', + 'field_name': 'status', 'property': 'options'}) + + if property_setter: + property_setter_doc = frappe.get_doc('Property Setter', {'doc_type': 'Task', + 'field_name': 'status', 'property': 'options'}) + property_setter_doc.value += "\nTemplate" + property_setter_doc.save() + + for template_name in frappe.get_all('Project Template'): + template = frappe.get_doc("Project Template", template_name.name) + replace_tasks = False + new_tasks = [] + for task in template.tasks: + if task.subject: + replace_tasks = True + new_task = frappe.get_doc(dict( + doctype = "Task", + subject = task.subject, + start = task.start, + duration = task.duration, + task_weight = task.task_weight, + description = task.description, + is_template = 1 + )).insert() + new_tasks.append(new_task) + + if replace_tasks: + template.tasks = [] + for tsk in new_tasks: + template.append("tasks", { + "task": tsk.name, + "subject": tsk.subject + }) + template.save() \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_reason_for_resignation_in_employee.py b/erpnext/patches/v13_0/update_reason_for_resignation_in_employee.py new file mode 100644 index 00000000000..792118fbee2 --- /dev/null +++ b/erpnext/patches/v13_0/update_reason_for_resignation_in_employee.py @@ -0,0 +1,15 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc("hr", "doctype", "employee") + + if frappe.db.has_column("Employee", "reason_for_resignation"): + frappe.db.sql(""" UPDATE `tabEmployee` + SET reason_for_leaving = reason_for_resignation + WHERE status = 'Left' and reason_for_leaving is null and reason_for_resignation is not null + """) + diff --git a/erpnext/patches/v13_0/update_returned_qty_in_pr_dn.py b/erpnext/patches/v13_0/update_returned_qty_in_pr_dn.py new file mode 100644 index 00000000000..7f42cd92e3c --- /dev/null +++ b/erpnext/patches/v13_0/update_returned_qty_in_pr_dn.py @@ -0,0 +1,27 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc('stock', 'doctype', 'purchase_receipt') + frappe.reload_doc('stock', 'doctype', 'purchase_receipt_item') + frappe.reload_doc('stock', 'doctype', 'delivery_note') + frappe.reload_doc('stock', 'doctype', 'delivery_note_item') + + def update_from_return_docs(doctype): + for return_doc in frappe.get_all(doctype, filters={'is_return' : 1, 'docstatus' : 1}): + # Update original receipt/delivery document from return + return_doc = frappe.get_cached_doc(doctype, return_doc.name) + return_doc.update_prevdoc_status() + return_against = frappe.get_doc(doctype, return_doc.return_against) + return_against.update_billing_status() + + # Set received qty in stock uom in PR, as returned qty is checked against it + frappe.db.sql(""" update `tabPurchase Receipt Item` + set received_stock_qty = received_qty * conversion_factor + where docstatus = 1 """) + + for doctype in ('Purchase Receipt', 'Delivery Note'): + update_from_return_docs(doctype) \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py b/erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py new file mode 100644 index 00000000000..c26cddbe4e5 --- /dev/null +++ b/erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py @@ -0,0 +1,9 @@ +import frappe + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + if frappe.db.exists('Custom Field', { 'fieldname': 'vehicle_no' }): + frappe.db.set_value('Custom Field', { 'fieldname': 'vehicle_no' }, 'mandatory_depends_on', '') diff --git a/erpnext/patches/v13_0/updates_for_multi_currency_payroll.py b/erpnext/patches/v13_0/updates_for_multi_currency_payroll.py new file mode 100644 index 00000000000..340bf4947b6 --- /dev/null +++ b/erpnext/patches/v13_0/updates_for_multi_currency_payroll.py @@ -0,0 +1,136 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.model.utils.rename_field import rename_field + +def execute(): + + frappe.reload_doc('Accounts', 'doctype', 'Salary Component Account') + if frappe.db.has_column('Salary Component Account', 'default_account'): + rename_field("Salary Component Account", "default_account", "account") + + doctype_list = [ + { + 'module':'HR', + 'doctype':'Employee Advance' + }, + { + 'module':'HR', + 'doctype':'Leave Encashment' + }, + { + 'module':'Payroll', + 'doctype':'Additional Salary' + }, + { + 'module':'Payroll', + 'doctype':'Employee Benefit Application' + }, + { + 'module':'Payroll', + 'doctype':'Employee Benefit Claim' + }, + { + 'module':'Payroll', + 'doctype':'Employee Incentive' + }, + { + 'module':'Payroll', + 'doctype':'Employee Tax Exemption Declaration' + }, + { + 'module':'Payroll', + 'doctype':'Employee Tax Exemption Proof Submission' + }, + { + 'module':'Payroll', + 'doctype':'Income Tax Slab' + }, + { + 'module':'Payroll', + 'doctype':'Payroll Entry' + }, + { + 'module':'Payroll', + 'doctype':'Retention Bonus' + }, + { + 'module':'Payroll', + 'doctype':'Salary Structure' + }, + { + 'module':'Payroll', + 'doctype':'Salary Structure Assignment' + }, + { + 'module':'Payroll', + 'doctype':'Salary Slip' + }, + ] + + for item in doctype_list: + frappe.reload_doc(item['module'], 'doctype', item['doctype']) + + # update company in employee advance based on employee company + for dt in ['Employee Incentive', 'Leave Encashment', 'Employee Benefit Application', 'Employee Benefit Claim']: + frappe.db.sql(""" + update `tab{doctype}` + set company = (select company from tabEmployee where name=`tab{doctype}`.employee) + """.format(doctype=dt)) + + # update exchange rate for employee advance + frappe.db.sql("update `tabEmployee Advance` set exchange_rate=1") + + # get all companies and it's currency + all_companies = frappe.db.get_all("Company", fields=["name", "default_currency", "default_payroll_payable_account"]) + for d in all_companies: + company = d.name + company_currency = d.default_currency + default_payroll_payable_account = d.default_payroll_payable_account + + if not default_payroll_payable_account: + default_payroll_payable_account = frappe.db.get_value("Account", + {"account_name": _("Payroll Payable"), "company": company, "account_currency": company_currency, "is_group": 0}) + + # update currency in following doctypes based on company currency + doctypes_for_currency = ['Employee Advance', 'Leave Encashment', 'Employee Benefit Application', + 'Employee Benefit Claim', 'Employee Incentive', 'Additional Salary', + 'Employee Tax Exemption Declaration', 'Employee Tax Exemption Proof Submission', + 'Income Tax Slab', 'Retention Bonus', 'Salary Structure'] + + for dt in doctypes_for_currency: + frappe.db.sql("""update `tab{doctype}` set currency = %s where company=%s""" + .format(doctype=dt), (company_currency, company)) + + # update fields in payroll entry + frappe.db.sql(""" + update `tabPayroll Entry` + set currency = %s, + exchange_rate = 1, + payroll_payable_account=%s + where company=%s + """, (company_currency, default_payroll_payable_account, company)) + + # update fields in Salary Structure Assignment + frappe.db.sql(""" + update `tabSalary Structure Assignment` + set currency = %s, + payroll_payable_account=%s + where company=%s + """, (company_currency, default_payroll_payable_account, company)) + + # update fields in Salary Slip + frappe.db.sql(""" + update `tabSalary Slip` + set currency = %s, + exchange_rate = 1, + base_hour_rate = hour_rate, + base_gross_pay = gross_pay, + base_total_deduction = total_deduction, + base_net_pay = net_pay, + base_rounded_total = rounded_total, + base_total_in_words = total_in_words + where company=%s + """, (company_currency, company)) diff --git a/erpnext/patches/v4_0/map_charge_to_taxes_and_charges.py b/erpnext/patches/v4_0/map_charge_to_taxes_and_charges.py index ad043dd99d3..97e217aa054 100644 --- a/erpnext/patches/v4_0/map_charge_to_taxes_and_charges.py +++ b/erpnext/patches/v4_0/map_charge_to_taxes_and_charges.py @@ -5,11 +5,11 @@ from __future__ import unicode_literals import frappe def execute(): - # udpate sales cycle + # update sales cycle for d in ['Sales Invoice', 'Sales Order', 'Quotation', 'Delivery Note']: frappe.db.sql("""update `tab%s` set taxes_and_charges=charge""" % d) - # udpate purchase cycle + # update purchase cycle for d in ['Purchase Invoice', 'Purchase Order', 'Supplier Quotation', 'Purchase Receipt']: frappe.db.sql("""update `tab%s` set taxes_and_charges=purchase_other_charges""" % d) diff --git a/erpnext/patches/v5_0/replace_renamed_fields_in_custom_scripts_and_print_formats.py b/erpnext/patches/v5_0/replace_renamed_fields_in_custom_scripts_and_print_formats.py index ef3f1d6c0a0..c564f8b02ab 100644 --- a/erpnext/patches/v5_0/replace_renamed_fields_in_custom_scripts_and_print_formats.py +++ b/erpnext/patches/v5_0/replace_renamed_fields_in_custom_scripts_and_print_formats.py @@ -9,7 +9,7 @@ def execute(): # NOTE: sequence is important renamed_fields = get_all_renamed_fields() - for dt, script_field, ref_dt_field in (("Custom Script", "script", "dt"), ("Print Format", "html", "doc_type")): + for dt, script_field, ref_dt_field in (("Client Script", "script", "dt"), ("Print Format", "html", "doc_type")): cond1 = " or ".join("""{0} like "%%{1}%%" """.format(script_field, d[0].replace("_", "\\_")) for d in renamed_fields) cond2 = " and standard = 'No'" if dt == "Print Format" else "" diff --git a/erpnext/patches/v7_0/po_status_issue_for_pr_return.py b/erpnext/patches/v7_0/po_status_issue_for_pr_return.py index 6e92ffb8a01..910814fd227 100644 --- a/erpnext/patches/v7_0/po_status_issue_for_pr_return.py +++ b/erpnext/patches/v7_0/po_status_issue_for_pr_return.py @@ -7,19 +7,23 @@ import frappe def execute(): parent_list = [] count = 0 - for data in frappe.db.sql(""" - select + + frappe.reload_doc('stock', 'doctype', 'purchase_receipt') + frappe.reload_doc('stock', 'doctype', 'purchase_receipt_item') + + for data in frappe.db.sql(""" + select `tabPurchase Receipt Item`.purchase_order, `tabPurchase Receipt Item`.name, `tabPurchase Receipt Item`.item_code, `tabPurchase Receipt Item`.idx, `tabPurchase Receipt Item`.parent - from + from `tabPurchase Receipt Item`, `tabPurchase Receipt` where `tabPurchase Receipt Item`.parent = `tabPurchase Receipt`.name and `tabPurchase Receipt Item`.purchase_order_item is null and `tabPurchase Receipt Item`.purchase_order is not null and `tabPurchase Receipt`.is_return = 1""", as_dict=1): - name = frappe.db.get_value('Purchase Order Item', + name = frappe.db.get_value('Purchase Order Item', {'item_code': data.item_code, 'parent': data.purchase_order, 'idx': data.idx}, 'name') if name: diff --git a/erpnext/patches/v7_0/remove_doctypes_and_reports.py b/erpnext/patches/v7_0/remove_doctypes_and_reports.py index 746cae0e1ca..2356e2f6ee4 100644 --- a/erpnext/patches/v7_0/remove_doctypes_and_reports.py +++ b/erpnext/patches/v7_0/remove_doctypes_and_reports.py @@ -7,7 +7,7 @@ def execute(): where name in('Time Log Batch', 'Time Log Batch Detail', 'Time Log')""") frappe.db.sql("""delete from `tabDocField` where parent in ('Time Log', 'Time Log Batch')""") - frappe.db.sql("""update `tabCustom Script` set dt = 'Timesheet' where dt = 'Time Log'""") + frappe.db.sql("""update `tabClient Script` set dt = 'Timesheet' where dt = 'Time Log'""") for data in frappe.db.sql(""" select label, fieldname from `tabCustom Field` where dt = 'Time Log'""", as_dict=1): custom_field = frappe.get_doc({ diff --git a/erpnext/patches/v9_0/fix_subscription_next_date.py b/erpnext/patches/v9_0/fix_subscription_next_date.py deleted file mode 100644 index 4595c8dc998..00000000000 --- a/erpnext/patches/v9_0/fix_subscription_next_date.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) 2017, Frappe and Contributors -# License: GNU General Public License v3. See license.txt - -from __future__ import unicode_literals -import frappe -from frappe.utils import getdate -from frappe.automation.doctype.auto_repeat.auto_repeat import get_next_schedule_date - -def execute(): - frappe.reload_doc('accounts', 'doctype', 'subscription') - fields = ["name", "reference_doctype", "reference_document", - "start_date", "frequency", "repeat_on_day"] - - for d in fields: - if not frappe.db.has_column('Subscription', d): - return - - doctypes = ('Purchase Order', 'Sales Order', 'Purchase Invoice', 'Sales Invoice') - for data in frappe.get_all('Subscription', - fields = fields, - filters = {'reference_doctype': ('in', doctypes), 'docstatus': 1}): - - recurring_id = frappe.db.get_value(data.reference_doctype, data.reference_document, "recurring_id") - if recurring_id: - frappe.db.sql("update `tab{0}` set subscription=%s where recurring_id=%s" - .format(data.reference_doctype), (data.name, recurring_id)) - - date_field = 'transaction_date' - if data.reference_doctype in ['Sales Invoice', 'Purchase Invoice']: - date_field = 'posting_date' - - start_date = frappe.db.get_value(data.reference_doctype, data.reference_document, date_field) - - if start_date and getdate(start_date) != getdate(data.start_date): - last_ref_date = frappe.db.sql(""" - select {0} - from `tab{1}` - where subscription=%s and docstatus < 2 - order by creation desc - limit 1 - """.format(date_field, data.reference_doctype), data.name)[0][0] - - next_schedule_date = get_next_schedule_date(last_ref_date, data.frequency, data.repeat_on_day) - - frappe.db.set_value("Subscription", data.name, { - "start_date": start_date, - "next_schedule_date": next_schedule_date - }, None) \ No newline at end of file diff --git a/erpnext/payroll/desk_page/payroll/payroll.json b/erpnext/payroll/desk_page/payroll/payroll.json deleted file mode 100644 index 285e3b3a135..00000000000 --- a/erpnext/payroll/desk_page/payroll/payroll.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Payroll", - "links": "[\n {\n \"label\": \"Salary Component\",\n \"name\": \"Salary Component\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n \n },\n {\n \"label\": \"Salary Structure\",\n \"name\": \"Salary Structure\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Salary Structure Assignment\",\n \"name\": \"Salary Structure Assignment\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Payroll Entry\",\n \"name\": \"Payroll Entry\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Salary Slip\",\n \"name\": \"Salary Slip\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Taxation", - "links": "[\n {\n \"label\": \"Payroll Period\",\n \"name\": \"Payroll Period\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n \n },\n {\n \"label\": \"Income Tax Slab\",\n \"name\": \"Income Tax Slab\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n \n },\n {\n \"label\": \"Employee Other Income\",\n \"name\": \"Employee Other Income\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n \n },\n {\n \"label\": \"Employee Tax Exemption Declaration\",\n \"name\": \"Employee Tax Exemption Declaration\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n \n },\n {\n \"label\": \"Employee Tax Exemption Proof Submission\",\n \"name\": \"Employee Tax Exemption Proof Submission\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n \n },\n {\n \"label\": \"Employee Tax Exemption Category\",\n \"name\": \"Employee Tax Exemption Category\",\n \"type\": \"doctype\"\n \n },\n {\n \"label\": \"Employee Tax Exemption Sub Category\",\n \"name\": \"Employee Tax Exemption Sub Category\",\n \"type\": \"doctype\"\n \n }\n]" - }, - { - "hidden": 0, - "label": "Compensations", - "links": "[\n {\n \"label\": \"Additional Salary\",\n \"name\": \"Additional Salary\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n \n },\n {\n \"label\": \"Retention Bonus\",\n \"name\": \"Retention Bonus\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Employee Incentive\",\n \"name\": \"Employee Incentive\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Employee Benefit Application\",\n \"name\": \"Employee Benefit Application\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Employee Benefit Claim\",\n \"name\": \"Employee Benefit Claim\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Reports", - "links": "[\n {\n \"dependencies\": [\n \"Salary Slip\"\n ],\n \"doctype\": \"Salary Slip\",\n \"is_query_report\": true,\n \"label\": \"Salary Register\",\n \"name\": \"Salary Register\",\n \"type\": \"report\"\n \n },\n {\n \"dependencies\": [\n \"Salary Slip\"\n ],\n \"doctype\": \"Salary Slip\",\n \"label\": \"Salary Payments Based On Payment Mode\",\n \"is_query_report\": true,\n \"name\": \"Salary Payments Based On Payment Mode\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Salary Slip\"\n ],\n \"doctype\": \"Salary Slip\",\n \"label\": \"Salary Payments via ECS\",\n \"is_query_report\": true,\n \"name\": \"Salary Payments via ECS\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Salary Slip\"\n ],\n \"doctype\": \"Salary Slip\",\n \"label\": \"Income Tax Deductions\",\n \"is_query_report\": true,\n \"name\": \"Income Tax Deductions\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Salary Slip\"\n ],\n \"doctype\": \"Salary Slip\",\n \"label\": \"Professional Tax Deductions\",\n \"is_query_report\": true,\n \"name\": \"Professional Tax Deductions\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Salary Slip\"\n ],\n \"doctype\": \"Salary Slip\",\n \"label\": \"Provident Fund Deductions\",\n \"is_query_report\": true,\n \"name\": \"Provident Fund Deductions\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Payroll Entry\"\n ],\n \"doctype\": \"Payroll Entry\",\n \"is_query_report\": true,\n \"label\": \"Bank Remittance\",\n \"name\": \"Bank Remittance\",\n \"type\": \"report\"\n \n }\n]" - } - ], - "category": "Modules", - "charts": [ - { - "chart_name": "Outgoing Salary", - "label": "Outgoing Salary" - } - ], - "creation": "2020-05-27 19:54:23.405607", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Payroll", - "modified": "2020-08-10 19:38:45.976209", - "modified_by": "Administrator", - "module": "Payroll", - "name": "Payroll", - "onboarding": "Payroll", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "label": "Salary Structure", - "link_to": "Salary Structure", - "type": "DocType" - }, - { - "label": "Payroll Entry", - "link_to": "Payroll Entry", - "type": "DocType" - }, - { - "color": "", - "format": "{} Pending", - "label": "Salary Slip", - "link_to": "Salary Slip", - "stats_filter": "{\"status\": \"Draft\"}", - "type": "DocType" - }, - { - "label": "Income Tax Slab", - "link_to": "Income Tax Slab", - "type": "DocType" - }, - { - "label": "Salary Register", - "link_to": "Salary Register", - "type": "Report" - }, - { - "label": "Dashboard", - "link_to": "Payroll", - "type": "Dashboard" - } - ] -} \ No newline at end of file diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.js b/erpnext/payroll/doctype/additional_salary/additional_salary.js index d56cd4e967d..d1ed91fac70 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.js +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.js @@ -12,5 +12,64 @@ frappe.ui.form.on('Additional Salary', { } }; }); + + frm.trigger('set_earning_component'); + }, + + employee: function(frm) { + if (frm.doc.employee) { + frappe.run_serially([ + () => frm.trigger('get_employee_currency'), + () => frm.trigger('set_company') + ]); + } else { + frm.set_value("company", null); + } + }, + + set_company: function(frm) { + frappe.call({ + method: "frappe.client.get_value", + args: { + doctype: "Employee", + fieldname: "company", + filters: { + name: frm.doc.employee + } + }, + callback: function(data) { + if (data.message) { + frm.set_value("company", data.message.company); + } + } + }); + }, + + company: function(frm) { + frm.trigger('set_earning_component'); + }, + + set_earning_component: function(frm) { + if (!frm.doc.company) return; + frm.set_query("salary_component", function() { + return { + filters: {type: ["in", ["earning", "deduction"]], company: frm.doc.company} + }; + }); + }, + + get_employee_currency: function(frm) { + frappe.call({ + method: "erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment.get_employee_currency", + args: { + employee: frm.doc.employee, + }, + callback: function(r) { + if (r.message) { + frm.set_value('currency', r.message); + frm.refresh_fields(); + } + } + }); }, }); diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.json b/erpnext/payroll/doctype/additional_salary/additional_salary.json index 69cb5da893e..2b29f667fbc 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.json +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.json @@ -11,20 +11,21 @@ "employee", "employee_name", "salary_component", - "overwrite_salary_structure_amount", - "deduct_full_tax_on_selected_payroll_date", + "type", + "amount", "ref_doctype", "ref_docname", + "amended_from", "column_break_5", "company", - "is_recurring", + "department", + "currency", "from_date", "to_date", "payroll_date", - "type", - "department", - "amount", - "amended_from" + "is_recurring", + "overwrite_salary_structure_amount", + "deduct_full_tax_on_selected_payroll_date" ], "fields": [ { @@ -59,6 +60,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Amount", + "options": "currency", "reqd": 1 }, { @@ -159,11 +161,22 @@ "label": "Reference Document", "options": "ref_doctype", "read_only": 1 + }, + { + "default": "Company:company:default_currency", + "depends_on": "eval:(doc.docstatus==1 || doc.employee)", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "read_only": 1, + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-06-22 21:10:50.374063", + "modified": "2020-10-20 17:51:13.419716", "modified_by": "Administrator", "module": "Payroll", "name": "Additional Salary", diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.py b/erpnext/payroll/doctype/additional_salary/additional_salary.py index e3dc9070ec5..13b6c05e22d 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.py +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.py @@ -9,23 +9,21 @@ from frappe import _, bold from frappe.utils import getdate, date_diff, comma_and, formatdate class AdditionalSalary(Document): - def on_submit(self): if self.ref_doctype == "Employee Advance" and self.ref_docname: frappe.db.set_value("Employee Advance", self.ref_docname, "return_amount", self.amount) - def before_insert(self): - if frappe.db.exists("Additional Salary", {"employee": self.employee, "salary_component": self.salary_component, - "amount": self.amount, "payroll_date": self.payroll_date, "company": self.company, "docstatus": 1}): - - frappe.throw(_("Additional Salary Component Exists.")) - def validate(self): self.validate_dates() + self.validate_salary_structure() self.validate_recurring_additional_salary_overlap() if self.amount < 0: frappe.throw(_("Amount should not be less than zero.")) + def validate_salary_structure(self): + if not frappe.db.exists('Salary Structure Assignment', {'employee': self.employee}): + frappe.throw(_("There is no Salary Structure assigned to {0}. First assign a Salary Stucture.").format(self.employee)) + def validate_recurring_additional_salary_overlap(self): if self.is_recurring: additional_salaries = frappe.db.sql(""" @@ -84,10 +82,11 @@ class AdditionalSalary(Document): no_of_days = date_diff(getdate(end_date), getdate(start_date)) + 1 return amount_per_day * no_of_days -@frappe.whitelist() -def get_additional_salary_component(employee, start_date, end_date, component_type): - additional_salaries = frappe.db.sql(""" - select name, salary_component, type, amount, overwrite_salary_structure_amount, deduct_full_tax_on_selected_payroll_date +def get_additional_salaries(employee, start_date, end_date, component_type): + additional_salary_list = frappe.db.sql(""" + select name, salary_component as component, type, amount, + overwrite_salary_structure_amount as overwrite, + deduct_full_tax_on_selected_payroll_date from `tabAdditional Salary` where employee=%(employee)s and docstatus = 1 @@ -97,7 +96,7 @@ def get_additional_salary_component(employee, start_date, end_date, component_ty from_date <= %(to_date)s and to_date >= %(to_date)s ) and type = %(component_type)s - order by salary_component, overwrite_salary_structure_amount DESC + order by salary_component, overwrite ASC """, { 'employee': employee, 'from_date': start_date, @@ -105,38 +104,18 @@ def get_additional_salary_component(employee, start_date, end_date, component_ty 'component_type': "Earning" if component_type == "earnings" else "Deduction" }, as_dict=1) - existing_salary_components= [] - salary_components_details = {} - additional_salary_details = [] + additional_salaries = [] + components_to_overwrite = [] - overwrites_components = [ele.salary_component for ele in additional_salaries if ele.overwrite_salary_structure_amount == 1] + for d in additional_salary_list: + if d.overwrite: + if d.component in components_to_overwrite: + frappe.throw(_("Multiple Additional Salaries with overwrite " + "property exist for Salary Component {0} between {1} and {2}.").format( + frappe.bold(d.component), start_date, end_date), title=_("Error")) - component_fields = ["depends_on_payment_days", "salary_component_abbr", "is_tax_applicable", "variable_based_on_taxable_salary", 'type'] - for d in additional_salaries: + components_to_overwrite.append(d.component) - if d.salary_component not in existing_salary_components: - component = frappe.get_all("Salary Component", filters={'name': d.salary_component}, fields=component_fields) - struct_row = frappe._dict({'salary_component': d.salary_component}) - if component: - struct_row.update(component[0]) + additional_salaries.append(d) - struct_row['deduct_full_tax_on_selected_payroll_date'] = d.deduct_full_tax_on_selected_payroll_date - struct_row['is_additional_component'] = 1 - - salary_components_details[d.salary_component] = struct_row - - - if overwrites_components.count(d.salary_component) > 1: - frappe.throw(_("Multiple Additional Salaries with overwrite property exist for Salary Component: {0} between {1} and {2}.".format(d.salary_component, start_date, end_date)), title=_("Error")) - else: - additional_salary_details.append({ - 'name': d.name, - 'component': d.salary_component, - 'amount': d.amount, - 'type': d.type, - 'overwrite': d.overwrite_salary_structure_amount, - }) - - existing_salary_components.append(d.salary_component) - - return salary_components_details, additional_salary_details + return additional_salaries diff --git a/erpnext/payroll/doctype/additional_salary/test_additional_salary.py b/erpnext/payroll/doctype/additional_salary/test_additional_salary.py index de26543b571..4d47f25fcf3 100644 --- a/erpnext/payroll/doctype/additional_salary/test_additional_salary.py +++ b/erpnext/payroll/doctype/additional_salary/test_additional_salary.py @@ -8,6 +8,7 @@ from frappe.utils import nowdate, add_days from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.payroll.doctype.salary_component.test_salary_component import create_salary_component from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_employee_salary_slip, setup_test +from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure class TestAdditionalSalary(unittest.TestCase): @@ -15,12 +16,19 @@ class TestAdditionalSalary(unittest.TestCase): def setUp(self): setup_test() + def tearDown(self): + for dt in ["Salary Slip", "Additional Salary", "Salary Structure Assignment", "Salary Structure"]: + frappe.db.sql("delete from `tab%s`" % dt) + def test_recurring_additional_salary(self): + amount = 0 + salary_component = None emp_id = make_employee("test_additional@salary.com") frappe.db.set_value("Employee", emp_id, "relieving_date", add_days(nowdate(), 1800)) + salary_structure = make_salary_structure("Test Salary Structure Additional Salary", "Monthly", employee=emp_id) add_sal = get_additional_salary(emp_id) - - ss = make_employee_salary_slip("test_additional@salary.com", "Monthly") + + ss = make_employee_salary_slip("test_additional@salary.com", "Monthly", salary_structure=salary_structure.name) for earning in ss.earnings: if earning.salary_component == "Recurring Salary Component": amount = earning.amount @@ -29,8 +37,6 @@ class TestAdditionalSalary(unittest.TestCase): self.assertEqual(amount, add_sal.amount) self.assertEqual(salary_component, add_sal.salary_component) - - def get_additional_salary(emp_id): create_salary_component("Recurring Salary Component") add_sal = frappe.new_doc("Additional Salary") @@ -40,6 +46,7 @@ def get_additional_salary(emp_id): add_sal.from_date = add_days(nowdate(), -50) add_sal.to_date = add_days(nowdate(), 180) add_sal.amount = 5000 + add_sal.currency = erpnext.get_default_currency() add_sal.save() add_sal.submit() diff --git a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.js b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.js index f509df31e83..6756cd93e75 100644 --- a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.js +++ b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.js @@ -3,7 +3,12 @@ frappe.ui.form.on('Employee Benefit Application', { employee: function(frm) { - frm.trigger('set_earning_component'); + if (frm.doc.employee) { + frappe.run_serially([ + () => frm.trigger('get_employee_currency'), + () => frm.trigger('set_earning_component') + ]); + } var method, args; if(frm.doc.employee && frm.doc.date && frm.doc.payroll_period){ method = "erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application.get_max_benefits_remaining"; @@ -38,9 +43,26 @@ frappe.ui.form.on('Employee Benefit Application', { }); }, + get_employee_currency: function(frm) { + if (frm.doc.employee) { + frappe.call({ + method: "erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment.get_employee_currency", + args: { + employee: frm.doc.employee, + }, + callback: function(r) { + if (r.message) { + frm.set_value('currency', r.message); + frm.refresh_fields(); + } + } + }); + } + }, + payroll_period: function(frm) { var method, args; - if(frm.doc.employee && frm.doc.date && frm.doc.payroll_period){ + if (frm.doc.employee && frm.doc.date && frm.doc.payroll_period) { method = "erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application.get_max_benefits_remaining"; args = { employee: frm.doc.employee, @@ -60,11 +82,14 @@ var get_max_benefits=function(frm, method, args) { method: method, args: args, callback: function (data) { - if(!data.exc){ - if(data.message){ + if (!data.exc) { + if (data.message) { frm.set_value("max_benefits", data.message); + } else { + frm.set_value("max_benefits", 0); } } + frm.refresh_fields(); } }); }; @@ -82,14 +107,19 @@ var calculate_all = function(doc) { var tbl = doc.employee_benefits || []; var pro_rata_dispensed_amount = 0; var total_amount = 0; - for(var i = 0; i < tbl.length; i++){ - if(cint(tbl[i].amount) > 0) { - total_amount += flt(tbl[i].amount); - } - if(tbl[i].pay_against_benefit_claim != 1){ - pro_rata_dispensed_amount += flt(tbl[i].amount); + if (doc.max_benefits === 0) { + doc.employee_benefits = []; + } else { + for (var i = 0; i < tbl.length; i++) { + if (cint(tbl[i].amount) > 0) { + total_amount += flt(tbl[i].amount); + } + if (tbl[i].pay_against_benefit_claim != 1) { + pro_rata_dispensed_amount += flt(tbl[i].amount); + } } } + doc.total_amount = total_amount; doc.remaining_benefit = doc.max_benefits - total_amount; doc.pro_rata_dispensed_amount = pro_rata_dispensed_amount; diff --git a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.json b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.json index b0c1bd6c3e5..4c45580bf01 100644 --- a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.json +++ b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.json @@ -10,17 +10,20 @@ "field_order": [ "employee", "employee_name", + "currency", "max_benefits", "remaining_benefit", "column_break_2", "date", "payroll_period", "department", + "company", "amended_from", "section_break_4", "employee_benefits", "totals", "total_amount", + "column_break", "pro_rata_dispensed_amount" ], "fields": [ @@ -43,12 +46,14 @@ "fieldname": "max_benefits", "fieldtype": "Currency", "label": "Max Benefits (Yearly)", + "options": "currency", "read_only": 1 }, { "fieldname": "remaining_benefit", "fieldtype": "Currency", "label": "Remaining Benefits (Yearly)", + "options": "currency", "read_only": 1 }, { @@ -108,18 +113,42 @@ "fieldname": "total_amount", "fieldtype": "Currency", "label": "Total Amount", + "options": "currency", "read_only": 1 }, { "fieldname": "pro_rata_dispensed_amount", "fieldtype": "Currency", "label": "Dispensed Amount (Pro-rated)", + "options": "currency", "read_only": 1 + }, + { + "default": "Company:company:default_currency", + "depends_on": "eval:(doc.docstatus==1 || doc.employee)", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "read_only": 1, + "reqd": 1 + }, + { + "fetch_from": "employee.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "column_break", + "fieldtype": "Column Break" } ], "is_submittable": 1, "links": [], - "modified": "2020-06-22 22:58:31.271922", + "modified": "2020-12-14 15:52:08.566418", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Benefit Application", diff --git a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py index ef844fbd3b5..27df30a459c 100644 --- a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py +++ b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py @@ -33,8 +33,8 @@ class EmployeeBenefitApplication(Document): benefit_given = get_sal_slip_total_benefit_given(self.employee, payroll_period, component = benefit.earning_component) benefit_claim_remining = benefit_claimed - benefit_given if benefit_claimed > 0 and benefit_claim_remining > benefit.amount: - frappe.throw(_("An amount of {0} already claimed for the component {1},\ - set the amount equal or greater than {2}").format(benefit_claimed, benefit.earning_component, benefit_claim_remining)) + frappe.throw(_("An amount of {0} already claimed for the component {1}, set the amount equal or greater than {2}").format( + benefit_claimed, benefit.earning_component, benefit_claim_remining)) def validate_remaining_benefit_amount(self): # check salary structure earnings have flexi component (sum of max_benefit_amount) @@ -62,11 +62,11 @@ class EmployeeBenefitApplication(Document): if pro_rata_amount == 0 and non_pro_rata_amount == 0: frappe.throw(_("Please add the remaining benefits {0} to any of the existing component").format(self.remaining_benefit)) elif non_pro_rata_amount > 0 and non_pro_rata_amount < rounded(self.remaining_benefit): - frappe.throw(_("You can claim only an amount of {0}, the rest amount {1} should be in the application \ - as pro-rata component").format(non_pro_rata_amount, self.remaining_benefit - non_pro_rata_amount)) + frappe.throw(_("You can claim only an amount of {0}, the rest amount {1} should be in the application as pro-rata component").format( + non_pro_rata_amount, self.remaining_benefit - non_pro_rata_amount)) elif non_pro_rata_amount == 0: - frappe.throw(_("Please add the remaining benefits {0} to the application as \ - pro-rata component").format(self.remaining_benefit)) + frappe.throw(_("Please add the remaining benefits {0} to the application as pro-rata component").format( + self.remaining_benefit)) def validate_max_benefit_for_component(self): if self.employee_benefits: @@ -115,7 +115,7 @@ def get_max_benefits_remaining(employee, on_date, payroll_period): if max_benefits and max_benefits > 0: have_depends_on_payment_days = False per_day_amount_total = 0 - payroll_period_days = get_payroll_period_days(on_date, on_date, employee)[0] + payroll_period_days = get_payroll_period_days(on_date, on_date, employee)[1] payroll_period_obj = frappe.get_doc("Payroll Period", payroll_period) # Get all salary slip flexi amount in the payroll period @@ -239,4 +239,17 @@ def get_earning_components(doctype, txt, searchfield, start, page_len, filters): """, salary_structure) else: frappe.throw(_("Salary Structure not found for employee {0} and date {1}") - .format(filters['employee'], filters['date'])) \ No newline at end of file + .format(filters['employee'], filters['date'])) + +@frappe.whitelist() +def get_earning_components_max_benefits(employee, date, earning_component): + salary_structure = get_assigned_salary_structure(employee, date) + amount = frappe.db.sql(""" + select amount + from `tabSalary Detail` + where parent = %s and is_flexible_benefit = 1 + and salary_component = %s + order by name + """, salary_structure, earning_component) + + return amount if amount else 0 \ No newline at end of file diff --git a/erpnext/payroll/doctype/employee_benefit_application_detail/employee_benefit_application_detail.json b/erpnext/payroll/doctype/employee_benefit_application_detail/employee_benefit_application_detail.json index fa6b4da2af3..c93d356c209 100644 --- a/erpnext/payroll/doctype/employee_benefit_application_detail/employee_benefit_application_detail.json +++ b/erpnext/payroll/doctype/employee_benefit_application_detail/employee_benefit_application_detail.json @@ -33,6 +33,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Max Benefit Amount", + "options": "currency", "read_only": 1 }, { @@ -40,12 +41,13 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Amount", + "options": "currency", "reqd": 1 } ], "istable": 1, "links": [], - "modified": "2020-06-22 23:45:00.519134", + "modified": "2020-09-29 16:22:15.783854", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Benefit Application Detail", diff --git a/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.js b/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.js index 6db6cb86b3d..ea9ccd52055 100644 --- a/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.js +++ b/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.js @@ -12,5 +12,24 @@ frappe.ui.form.on('Employee Benefit Claim', { }, employee: function(frm) { frm.set_value("earning_component", null); + if (frm.doc.employee) { + frappe.call({ + method: "erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment.get_employee_currency", + args: { + employee: frm.doc.employee, + }, + callback: function(r) { + if (r.message) { + frm.set_value('currency', r.message); + frm.set_df_property('currency', 'hidden', 0); + } + } + }); + } + if (!frm.doc.earning_component) { + frm.doc.max_amount_eligible = null; + frm.doc.claimed_amount = null; + } + frm.refresh_fields(); } }); diff --git a/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.json b/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.json index ae4c218615a..da24aacda1b 100644 --- a/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.json +++ b/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.json @@ -12,6 +12,8 @@ "department", "column_break_3", "claim_date", + "currency", + "company", "benefit_type_and_amount", "earning_component", "max_amount_eligible", @@ -76,6 +78,7 @@ "fieldname": "max_amount_eligible", "fieldtype": "Currency", "label": "Max Amount Eligible", + "options": "currency", "read_only": 1 }, { @@ -92,6 +95,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Claimed Amount", + "options": "currency", "reqd": 1 }, { @@ -119,11 +123,29 @@ "fieldname": "attachments", "fieldtype": "Attach", "label": "Attachments" + }, + { + "default": "Company:company:default_currency", + "fieldname": "currency", + "fieldtype": "Link", + "hidden": 1, + "label": "Currency", + "options": "Currency", + "read_only": 1, + "reqd": 1 + }, + { + "fetch_from": "employee.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-06-22 23:01:50.791676", + "modified": "2020-11-25 11:49:56.097352", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Benefit Claim", diff --git a/erpnext/payroll/doctype/employee_incentive/employee_incentive.js b/erpnext/payroll/doctype/employee_incentive/employee_incentive.js index db0f83aac9a..b2809b164a0 100644 --- a/erpnext/payroll/doctype/employee_incentive/employee_incentive.js +++ b/erpnext/payroll/doctype/employee_incentive/employee_incentive.js @@ -10,13 +10,60 @@ frappe.ui.form.on('Employee Incentive', { } }; }); + frm.trigger('set_earning_component'); + }, + employee: function(frm) { + if (frm.doc.employee) { + frappe.run_serially([ + () => frm.trigger('get_employee_currency'), + () => frm.trigger('set_company') + ]); + } else { + frm.set_value("company", null); + } + }, + + set_company: function(frm) { + frappe.call({ + method: "frappe.client.get_value", + args: { + doctype: "Employee", + fieldname: "company", + filters: { + name: frm.doc.employee + } + }, + callback: function(data) { + if (data.message) { + frm.set_value("company", data.message.company); + frm.trigger('set_earning_component'); + } + } + }); + }, + + set_earning_component: function(frm) { + if (!frm.doc.company) return; frm.set_query("salary_component", function() { return { - filters: { - "type": "Earning" - } + filters: {type: "earning", company: frm.doc.company} }; }); - } + }, + + get_employee_currency: function(frm) { + frappe.call({ + method: "erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment.get_employee_currency", + args: { + employee: frm.doc.employee, + }, + callback: function(r) { + if (r.message) { + frm.set_value('currency', r.message); + frm.refresh_fields(); + } + } + }); + }, }); diff --git a/erpnext/payroll/doctype/employee_incentive/employee_incentive.json b/erpnext/payroll/doctype/employee_incentive/employee_incentive.json index 204c9a40b1d..e5b1052b3a5 100644 --- a/erpnext/payroll/doctype/employee_incentive/employee_incentive.json +++ b/erpnext/payroll/doctype/employee_incentive/employee_incentive.json @@ -7,10 +7,12 @@ "engine": "InnoDB", "field_order": [ "employee", - "incentive_amount", "employee_name", - "salary_component", + "company", + "currency", + "incentive_amount", "column_break_5", + "salary_component", "payroll_date", "department", "amended_from" @@ -28,6 +30,7 @@ "fieldname": "incentive_amount", "fieldtype": "Currency", "label": "Incentive Amount", + "options": "currency", "reqd": 1 }, { @@ -70,11 +73,29 @@ "label": "Salary Component", "options": "Salary Component", "reqd": 1 + }, + { + "default": "Company:company:default_currency", + "depends_on": "eval:(doc.docstatus==1 || doc.employee)", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-06-22 22:42:51.209630", + "modified": "2020-10-20 17:22:16.468042", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Incentive", diff --git a/erpnext/payroll/doctype/employee_incentive/employee_incentive.py b/erpnext/payroll/doctype/employee_incentive/employee_incentive.py index 84a97f6bb2e..ead3db126f7 100644 --- a/erpnext/payroll/doctype/employee_incentive/employee_incentive.py +++ b/erpnext/payroll/doctype/employee_incentive/employee_incentive.py @@ -4,14 +4,23 @@ from __future__ import unicode_literals import frappe +from frappe import _ from frappe.model.document import Document class EmployeeIncentive(Document): + def validate(self): + self.validate_salary_structure() + + def validate_salary_structure(self): + if not frappe.db.exists('Salary Structure Assignment', {'employee': self.employee}): + frappe.throw(_("There is no Salary Structure assigned to {0}. First assign a Salary Stucture.").format(self.employee)) + def on_submit(self): company = frappe.db.get_value('Employee', self.employee, 'company') additional_salary = frappe.new_doc('Additional Salary') additional_salary.employee = self.employee + additional_salary.currency = self.currency additional_salary.salary_component = self.salary_component additional_salary.overwrite_salary_structure_amount = 0 additional_salary.amount = self.incentive_amount diff --git a/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.json b/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.json index de7c348bb2c..83d4ae53df8 100644 --- a/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.json +++ b/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.json @@ -14,6 +14,7 @@ "column_break_2", "payroll_period", "company", + "currency", "amended_from", "section_break_8", "declarations", @@ -92,6 +93,7 @@ "fieldname": "total_declared_amount", "fieldtype": "Currency", "label": "Total Declared Amount", + "options": "currency", "read_only": 1 }, { @@ -102,12 +104,22 @@ "fieldname": "total_exemption_amount", "fieldtype": "Currency", "label": "Total Exemption Amount", + "options": "currency", "read_only": 1 + }, + { + "default": "Company:company:default_currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-06-22 22:49:43.829892", + "modified": "2020-10-20 16:42:24.493761", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Tax Exemption Declaration", diff --git a/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py b/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py index 9549fd1b757..311f3527f6e 100644 --- a/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py +++ b/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py @@ -22,6 +22,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase): "employee": frappe.get_value("Employee", {"user_id":"employee@taxexepmtion.com"}, "name"), "company": erpnext.get_default_company(), "payroll_period": "_Test Payroll Period", + "currency": erpnext.get_default_currency(), "declarations": [ dict(exemption_sub_category = "_Test Sub Category", exemption_category = "_Test Category", @@ -39,6 +40,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase): "employee": frappe.get_value("Employee", {"user_id":"employee@taxexepmtion.com"}, "name"), "company": erpnext.get_default_company(), "payroll_period": "_Test Payroll Period", + "currency": erpnext.get_default_currency(), "declarations": [ dict(exemption_sub_category = "_Test Sub Category", exemption_category = "_Test Category", @@ -54,6 +56,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase): "employee": frappe.get_value("Employee", {"user_id":"employee@taxexepmtion.com"}, "name"), "company": erpnext.get_default_company(), "payroll_period": "_Test Payroll Period", + "currency": erpnext.get_default_currency(), "declarations": [ dict(exemption_sub_category = "_Test Sub Category", exemption_category = "_Test Category", @@ -70,6 +73,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase): "employee": frappe.get_value("Employee", {"user_id":"employee@taxexepmtion.com"}, "name"), "company": erpnext.get_default_company(), "payroll_period": "_Test Payroll Period", + "currency": erpnext.get_default_currency(), "declarations": [ dict(exemption_sub_category = "_Test Sub Category", exemption_category = "_Test Category", @@ -82,19 +86,21 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase): self.assertEqual(declaration.total_exemption_amount, 100000) -def create_payroll_period(): - if not frappe.db.exists("Payroll Period", "_Test Payroll Period"): +def create_payroll_period(**args): + args = frappe._dict(args) + name = args.name or "_Test Payroll Period" + if not frappe.db.exists("Payroll Period", name): from datetime import date payroll_period = frappe.get_doc(dict( doctype = 'Payroll Period', - name = "_Test Payroll Period", - company = erpnext.get_default_company(), - start_date = date(date.today().year, 1, 1), - end_date = date(date.today().year, 12, 31) + name = name, + company = args.company or erpnext.get_default_company(), + start_date = args.start_date or date(date.today().year, 1, 1), + end_date = args.end_date or date(date.today().year, 12, 31) )).insert() return payroll_period else: - return frappe.get_doc("Payroll Period", "_Test Payroll Period") + return frappe.get_doc("Payroll Period", name) def create_exemption_category(): if not frappe.db.exists("Employee Tax Exemption Category", "_Test Category"): diff --git a/erpnext/payroll/doctype/employee_tax_exemption_declaration_category/employee_tax_exemption_declaration_category.json b/erpnext/payroll/doctype/employee_tax_exemption_declaration_category/employee_tax_exemption_declaration_category.json index 8c2f9aa370a..723a3df3c7f 100644 --- a/erpnext/payroll/doctype/employee_tax_exemption_declaration_category/employee_tax_exemption_declaration_category.json +++ b/erpnext/payroll/doctype/employee_tax_exemption_declaration_category/employee_tax_exemption_declaration_category.json @@ -35,6 +35,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Maximum Exempted Amount", + "options": "currency", "read_only": 1, "reqd": 1 }, @@ -43,12 +44,13 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Declared Amount", + "options": "currency", "reqd": 1 } ], "istable": 1, "links": [], - "modified": "2020-06-22 23:41:03.638739", + "modified": "2020-10-20 16:43:09.606265", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Tax Exemption Declaration Category", diff --git a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.js b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.js index 715d7553b00..497f35c41e3 100644 --- a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.js +++ b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.js @@ -54,5 +54,9 @@ frappe.ui.form.on('Employee Tax Exemption Proof Submission', { }); }); } + }, + + currency: function(frm) { + frm.refresh_fields(); } }); diff --git a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.json b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.json index b62b5aab0b4..53f18cb1fe3 100644 --- a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.json +++ b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.json @@ -11,6 +11,7 @@ "employee", "employee_name", "department", + "currency", "column_break_2", "submission_date", "payroll_period", @@ -97,6 +98,7 @@ "fieldname": "total_actual_amount", "fieldtype": "Currency", "label": "Total Actual Amount", + "options": "currency", "read_only": 1 }, { @@ -107,6 +109,7 @@ "fieldname": "exemption_amount", "fieldtype": "Currency", "label": "Total Exemption Amount", + "options": "currency", "read_only": 1 }, { @@ -126,11 +129,20 @@ "options": "Employee Tax Exemption Proof Submission", "print_hide": 1, "read_only": 1 + }, + { + "default": "Company:company:default_currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-06-22 22:53:10.412321", + "modified": "2020-10-20 16:47:03.410020", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Tax Exemption Proof Submission", diff --git a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission_detail/employee_tax_exemption_proof_submission_detail.json b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission_detail/employee_tax_exemption_proof_submission_detail.json index c1f532050ac..2fd8b94efdb 100644 --- a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission_detail/employee_tax_exemption_proof_submission_detail.json +++ b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission_detail/employee_tax_exemption_proof_submission_detail.json @@ -34,6 +34,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Maximum Exemption Amount", + "options": "currency", "read_only": 1, "reqd": 1 }, @@ -48,12 +49,13 @@ "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "Actual Amount" + "label": "Actual Amount", + "options": "currency" } ], "istable": 1, "links": [], - "modified": "2020-06-22 23:37:08.265600", + "modified": "2020-10-20 16:47:31.480870", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Tax Exemption Proof Submission Detail", diff --git a/erpnext/payroll/doctype/gratuity/__init__.py b/erpnext/payroll/doctype/gratuity/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/payroll/doctype/gratuity/gratuity.js b/erpnext/payroll/doctype/gratuity/gratuity.js new file mode 100644 index 00000000000..565d2c49f94 --- /dev/null +++ b/erpnext/payroll/doctype/gratuity/gratuity.js @@ -0,0 +1,72 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Gratuity', { + setup: function (frm) { + frm.set_query('salary_component', function () { + return { + filters: { + type: "Earning" + } + }; + }); + frm.set_query("expense_account", function () { + return { + filters: { + "root_type": "Expense", + "is_group": 0, + "company": frm.doc.company + } + }; + }); + + frm.set_query("payable_account", function () { + return { + filters: { + "root_type": "Liability", + "is_group": 0, + "company": frm.doc.company + } + }; + }); + }, + refresh: function (frm) { + if (frm.doc.docstatus === 1 && frm.doc.pay_via_salary_slip === 0 && frm.doc.status === "Unpaid") { + frm.add_custom_button(__("Create Payment Entry"), function () { + return frappe.call({ + method: 'erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry', + args: { + "dt": frm.doc.doctype, + "dn": frm.doc.name + }, + callback: function (r) { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + } + }); + }); + } + }, + employee: function (frm) { + frm.events.calculate_work_experience_and_amount(frm); + }, + gratuity_rule: function (frm) { + frm.events.calculate_work_experience_and_amount(frm); + }, + calculate_work_experience_and_amount: function (frm) { + + if (frm.doc.employee && frm.doc.gratuity_rule) { + frappe.call({ + method: "erpnext.payroll.doctype.gratuity.gratuity.calculate_work_experience_and_amount", + args: { + employee: frm.doc.employee, + gratuity_rule: frm.doc.gratuity_rule + } + }).then((r) => { + frm.set_value("current_work_experience", r.message['current_work_experience']); + frm.set_value("amount", r.message['amount']); + }); + } + } + +}); \ No newline at end of file diff --git a/erpnext/payroll/doctype/gratuity/gratuity.json b/erpnext/payroll/doctype/gratuity/gratuity.json new file mode 100644 index 00000000000..5cffd7eebf9 --- /dev/null +++ b/erpnext/payroll/doctype/gratuity/gratuity.json @@ -0,0 +1,232 @@ +{ + "actions": [], + "autoname": "HR-GRA-PAY-.#####", + "creation": "2020-08-05 20:52:13.024683", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "employee", + "employee_name", + "department", + "designation", + "column_break_3", + "posting_date", + "status", + "company", + "gratuity_rule", + "section_break_5", + "pay_via_salary_slip", + "payroll_date", + "salary_component", + "payable_account", + "expense_account", + "mode_of_payment", + "cost_center", + "column_break_15", + "current_work_experience", + "amount", + "paid_amount", + "amended_from" + ], + "fields": [ + { + "fieldname": "employee", + "fieldtype": "Link", + "in_global_search": 1, + "in_list_view": 1, + "label": "Employee", + "options": "Employee", + "reqd": 1, + "search_index": 1 + }, + { + "fetch_from": "employee.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "read_only": 1, + "reqd": 1 + }, + { + "default": "1", + "fieldname": "pay_via_salary_slip", + "fieldtype": "Check", + "label": "Pay via Salary Slip" + }, + { + "fieldname": "posting_date", + "fieldtype": "Date", + "label": "Posting date", + "reqd": 1 + }, + { + "depends_on": "eval: doc.pay_via_salary_slip == 1", + "fieldname": "salary_component", + "fieldtype": "Link", + "label": "Salary Component", + "mandatory_depends_on": "eval: doc.pay_via_salary_slip == 1", + "options": "Salary Component" + }, + { + "default": "0", + "fieldname": "current_work_experience", + "fieldtype": "Int", + "label": "Current Work Experience", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Total Amount", + "read_only": 1, + "reqd": 1 + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Draft\nUnpaid\nPaid", + "read_only": 1, + "reqd": 1 + }, + { + "depends_on": "eval: doc.pay_via_salary_slip == 0", + "fieldname": "expense_account", + "fieldtype": "Link", + "label": "Expense Account", + "mandatory_depends_on": "eval: doc.pay_via_salary_slip == 0", + "options": "Account" + }, + { + "depends_on": "eval: doc.pay_via_salary_slip == 0", + "fieldname": "mode_of_payment", + "fieldtype": "Link", + "label": "Mode of Payment", + "mandatory_depends_on": "eval: doc.pay_via_salary_slip == 0", + "options": "Mode of Payment" + }, + { + "fieldname": "gratuity_rule", + "fieldtype": "Link", + "label": "Gratuity Rule", + "options": "Gratuity Rule", + "reqd": 1 + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break", + "label": "Payment Configuration" + }, + { + "fetch_from": "employee.employee_name", + "fieldname": "employee_name", + "fieldtype": "Data", + "label": "Employee Name", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fetch_from": "employee.department", + "fieldname": "department", + "fieldtype": "Link", + "label": "Department", + "options": "Department", + "read_only": 1 + }, + { + "fetch_from": "employee.designation", + "fieldname": "designation", + "fieldtype": "Data", + "label": "Designation", + "read_only": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Gratuity", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_15", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.pay_via_salary_slip == 1", + "fieldname": "payroll_date", + "fieldtype": "Date", + "label": "Payroll Date", + "mandatory_depends_on": "eval: doc.pay_via_salary_slip == 1" + }, + { + "default": "0", + "depends_on": "eval:doc.pay_via_salary_slip == 0", + "fieldname": "paid_amount", + "fieldtype": "Currency", + "label": "Paid Amount", + "read_only": 1 + }, + { + "depends_on": "eval: doc.pay_via_salary_slip == 0", + "fieldname": "payable_account", + "fieldtype": "Link", + "label": "Payable Account", + "mandatory_depends_on": "eval: doc.pay_via_salary_slip == 0", + "options": "Account" + }, + { + "depends_on": "eval: doc.pay_via_salary_slip == 0", + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "mandatory_depends_on": "eval: doc.pay_via_salary_slip == 0", + "options": "Cost Center" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2020-11-02 18:21:11.971488", + "modified_by": "Administrator", + "module": "Payroll", + "name": "Gratuity", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/payroll/doctype/gratuity/gratuity.py b/erpnext/payroll/doctype/gratuity/gratuity.py new file mode 100644 index 00000000000..1acd6e342fd --- /dev/null +++ b/erpnext/payroll/doctype/gratuity/gratuity.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _, bold +from frappe.utils import flt, get_datetime, get_link_to_form +from erpnext.accounts.general_ledger import make_gl_entries +from erpnext.controllers.accounts_controller import AccountsController +from math import floor + +class Gratuity(AccountsController): + def validate(self): + data = calculate_work_experience_and_amount(self.employee, self.gratuity_rule) + self.current_work_experience = data["current_work_experience"] + self.amount = data["amount"] + if self.docstatus == 1: + self.status = "Unpaid" + + def on_submit(self): + if self.pay_via_salary_slip: + self.create_additional_salary() + else: + self.create_gl_entries() + + def on_cancel(self): + self.ignore_linked_doctypes = ['GL Entry'] + self.create_gl_entries(cancel=True) + + def create_gl_entries(self, cancel=False): + gl_entries = self.get_gl_entries() + make_gl_entries(gl_entries, cancel) + + def get_gl_entries(self): + gl_entry = [] + # payable entry + if self.amount: + gl_entry.append( + self.get_gl_dict({ + "account": self.payable_account, + "credit": self.amount, + "credit_in_account_currency": self.amount, + "against": self.expense_account, + "party_type": "Employee", + "party": self.employee, + "against_voucher_type": self.doctype, + "against_voucher": self.name, + "cost_center": self.cost_center + }, item=self) + ) + + # expense entries + gl_entry.append( + self.get_gl_dict({ + "account": self.expense_account, + "debit": self.amount, + "debit_in_account_currency": self.amount, + "against": self.payable_account, + "cost_center": self.cost_center + }, item=self) + ) + else: + frappe.throw(_("Total Amount can not be zero")) + + return gl_entry + + def create_additional_salary(self): + if self.pay_via_salary_slip: + additional_salary = frappe.new_doc('Additional Salary') + additional_salary.employee = self.employee + additional_salary.salary_component = self.salary_component + additional_salary.overwrite_salary_structure_amount = 0 + additional_salary.amount = self.amount + additional_salary.payroll_date = self.payroll_date + additional_salary.company = self.company + additional_salary.ref_doctype = self.doctype + additional_salary.ref_docname = self.name + additional_salary.submit() + + def set_total_advance_paid(self): + paid_amount = frappe.db.sql(""" + select ifnull(sum(debit_in_account_currency), 0) as paid_amount + from `tabGL Entry` + where against_voucher_type = 'Gratuity' + and against_voucher = %s + and party_type = 'Employee' + and party = %s + """, (self.name, self.employee), as_dict=1)[0].paid_amount + + if flt(paid_amount) > self.amount: + frappe.throw(_("Row {0}# Paid Amount cannot be greater than Total amount")) + + + self.db_set("paid_amount", paid_amount) + if self.amount == self.paid_amount: + self.db_set("status", "Paid") + + +@frappe.whitelist() +def calculate_work_experience_and_amount(employee, gratuity_rule): + current_work_experience = calculate_work_experience(employee, gratuity_rule) or 0 + gratuity_amount = calculate_gratuity_amount(employee, gratuity_rule, current_work_experience) or 0 + + return {'current_work_experience': current_work_experience, "amount": gratuity_amount} + +def calculate_work_experience(employee, gratuity_rule): + + total_working_days_per_year, minimum_year_for_gratuity = frappe.db.get_value("Gratuity Rule", gratuity_rule, ["total_working_days_per_year", "minimum_year_for_gratuity"]) + + date_of_joining, relieving_date = frappe.db.get_value('Employee', employee, ['date_of_joining', 'relieving_date']) + if not relieving_date: + frappe.throw(_("Please set Relieving Date for employee: {0}").format(bold(get_link_to_form("Employee", employee)))) + + method = frappe.db.get_value("Gratuity Rule", gratuity_rule, "work_experience_calculation_function") + employee_total_workings_days = calculate_employee_total_workings_days(employee, date_of_joining, relieving_date) + + current_work_experience = employee_total_workings_days/total_working_days_per_year or 1 + current_work_experience = get_work_experience_using_method(method, current_work_experience, minimum_year_for_gratuity, employee) + return current_work_experience + +def calculate_employee_total_workings_days(employee, date_of_joining, relieving_date ): + employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(date_of_joining)).days + + payroll_based_on = frappe.db.get_value("Payroll Settings", None, "payroll_based_on") or "Leave" + if payroll_based_on == "Leave": + total_lwp = get_non_working_days(employee, relieving_date, "On Leave") + employee_total_workings_days -= total_lwp + elif payroll_based_on == "Attendance": + total_absents = get_non_working_days(employee, relieving_date, "Absent") + employee_total_workings_days -= total_absents + + return employee_total_workings_days + +def get_work_experience_using_method(method, current_work_experience, minimum_year_for_gratuity, employee): + if method == "Round off Work Experience": + current_work_experience = round(current_work_experience) + else: + current_work_experience = floor(current_work_experience) + + if current_work_experience < minimum_year_for_gratuity: + frappe.throw(_("Employee: {0} have to complete minimum {1} years for gratuity").format(bold(employee), minimum_year_for_gratuity)) + return current_work_experience + +def get_non_working_days(employee, relieving_date, status): + + filters={ + "docstatus": 1, + "status": status, + "employee": employee, + "attendance_date": ("<=", get_datetime(relieving_date)) + } + + if status == "On Leave": + lwp_leave_types = frappe.get_list("Leave Type", filters = {"is_lwp":1}) + lwp_leave_types = [leave_type.name for leave_type in lwp_leave_types] + filters["leave_type"] = ("IN", lwp_leave_types) + + + record = frappe.get_all("Attendance", filters=filters, fields = ["COUNT(name) as total_lwp"]) + return record[0].total_lwp if len(record) else 0 + +def calculate_gratuity_amount(employee, gratuity_rule, experience): + applicable_earnings_component = get_applicable_components(gratuity_rule) + total_applicable_components_amount = get_total_applicable_component_amount(employee, applicable_earnings_component, gratuity_rule) + + calculate_gratuity_amount_based_on = frappe.db.get_value("Gratuity Rule", gratuity_rule, "calculate_gratuity_amount_based_on") + gratuity_amount = 0 + slabs = get_gratuity_rule_slabs(gratuity_rule) + slab_found = False + year_left = experience + + for slab in slabs: + if calculate_gratuity_amount_based_on == "Current Slab": + slab_found, gratuity_amount = calculate_amount_based_on_current_slab(slab.from_year, slab.to_year, + experience, total_applicable_components_amount, slab.fraction_of_applicable_earnings) + if slab_found: + break + + elif calculate_gratuity_amount_based_on == "Sum of all previous slabs": + if slab.to_year == 0 and slab.from_year == 0: + gratuity_amount += year_left * total_applicable_components_amount * slab.fraction_of_applicable_earnings + slab_found = True + break + + if experience > slab.to_year and experience > slab.from_year and slab.to_year !=0: + gratuity_amount += (slab.to_year - slab.from_year) * total_applicable_components_amount * slab.fraction_of_applicable_earnings + year_left -= (slab.to_year - slab.from_year) + slab_found = True + elif slab.from_year <= experience and (experience < slab.to_year or slab.to_year == 0): + gratuity_amount += year_left * total_applicable_components_amount * slab.fraction_of_applicable_earnings + slab_found = True + + if not slab_found: + frappe.throw(_("No Suitable Slab found for Calculation of gratuity amount in Gratuity Rule: {0}").format(bold(gratuity_rule))) + return gratuity_amount + +def get_applicable_components(gratuity_rule): + applicable_earnings_component = frappe.get_all("Gratuity Applicable Component", filters= {'parent': gratuity_rule}, fields=["salary_component"]) + if len(applicable_earnings_component) == 0: + frappe.throw(_("No Applicable Earnings Component found for Gratuity Rule: {0}").format(bold(get_link_to_form("Gratuity Rule",gratuity_rule)))) + applicable_earnings_component = [component.salary_component for component in applicable_earnings_component] + + return applicable_earnings_component + +def get_total_applicable_component_amount(employee, applicable_earnings_component, gratuity_rule): + sal_slip = get_last_salary_slip(employee) + if not sal_slip: + frappe.throw(_("No Salary Slip is found for Employee: {0}").format(bold(employee))) + component_and_amounts = frappe.get_list("Salary Detail", + filters={ + "docstatus": 1, + 'parent': sal_slip, + "parentfield": "earnings", + 'salary_component': ('in', applicable_earnings_component) + }, + fields=["amount"]) + total_applicable_components_amount = 0 + if not len(component_and_amounts): + frappe.throw(_("No Applicable Component is present in last month salary slip")) + for data in component_and_amounts: + total_applicable_components_amount += data.amount + return total_applicable_components_amount + +def calculate_amount_based_on_current_slab(from_year, to_year, experience, total_applicable_components_amount, fraction_of_applicable_earnings): + slab_found = False; gratuity_amount = 0 + if experience >= from_year and (to_year == 0 or experience < to_year): + gratuity_amount = total_applicable_components_amount * experience * fraction_of_applicable_earnings + if fraction_of_applicable_earnings: + slab_found = True + + return slab_found, gratuity_amount + +def get_gratuity_rule_slabs(gratuity_rule): + return frappe.get_all("Gratuity Rule Slab", filters= {'parent': gratuity_rule}, fields = ["*"], order_by="idx") + +def get_salary_structure(employee): + return frappe.get_list("Salary Structure Assignment", filters = { + "employee": employee, 'docstatus': 1 + }, + fields=["from_date", "salary_structure"], + order_by = "from_date desc")[0].salary_structure + +def get_last_salary_slip(employee): + return frappe.get_list("Salary Slip", filters = { + "employee": employee, 'docstatus': 1 + }, + order_by = "start_date desc")[0].name + diff --git a/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py new file mode 100644 index 00000000000..5b2489f22cd --- /dev/null +++ b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py @@ -0,0 +1,20 @@ +from __future__ import unicode_literals +from frappe import _ + +def get_data(): + return { + 'fieldname': 'reference_name', + 'non_standard_fieldnames': { + 'Additional Salary': 'ref_docname', + }, + 'transactions': [ + { + 'label': _('Payment'), + 'items': ['Payment Entry'] + }, + { + 'label': _('Additional Salary'), + 'items': ['Additional Salary'] + } + ] + } \ No newline at end of file diff --git a/erpnext/payroll/doctype/gratuity/test_gratuity.py b/erpnext/payroll/doctype/gratuity/test_gratuity.py new file mode 100644 index 00000000000..7daea2da474 --- /dev/null +++ b/erpnext/payroll/doctype/gratuity/test_gratuity.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_employee_salary_slip, make_earning_salary_component, \ + make_deduction_salary_component +from erpnext.payroll.doctype.gratuity.gratuity import get_last_salary_slip +from erpnext.regional.united_arab_emirates.setup import create_gratuity_rule +from erpnext.hr.doctype.expense_claim.test_expense_claim import get_payable_account +from frappe.utils import getdate, add_days, get_datetime, flt + +test_dependencies = ["Salary Component", "Salary Slip", "Account"] +class TestGratuity(unittest.TestCase): + @classmethod + def setUpClass(cls): + make_earning_salary_component(setup=True, test_tax=True, company_list=['_Test Company']) + make_deduction_salary_component(setup=True, test_tax=True, company_list=['_Test Company']) + + def setUp(self): + frappe.db.sql("DELETE FROM `tabGratuity`") + frappe.db.sql("DELETE FROM `tabAdditional Salary` WHERE ref_doctype = 'Gratuity'") + + def test_check_gratuity_amount_based_on_current_slab_and_additional_salary_creation(self): + employee, sal_slip = create_employee_and_get_last_salary_slip() + + rule = get_gratuity_rule("Rule Under Unlimited Contract on termination (UAE)") + + gratuity = create_gratuity(pay_via_salary_slip = 1, employee=employee, rule=rule.name) + + #work experience calculation + date_of_joining, relieving_date = frappe.db.get_value('Employee', employee, ['date_of_joining', 'relieving_date']) + employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(date_of_joining)).days + + experience = employee_total_workings_days/rule.total_working_days_per_year + gratuity.reload() + from math import floor + self.assertEqual(floor(experience), gratuity.current_work_experience) + + #amount Calculation + component_amount = frappe.get_list("Salary Detail", + filters={ + "docstatus": 1, + 'parent': sal_slip, + "parentfield": "earnings", + 'salary_component': "Basic Salary" + }, + fields=["amount"]) + + ''' 5 - 0 fraction is 1 ''' + + gratuity_amount = component_amount[0].amount * experience + gratuity.reload() + + self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2)) + + #additional salary creation (Pay via salary slip) + self.assertTrue(frappe.db.exists("Additional Salary", {"ref_docname": gratuity.name})) + + def test_check_gratuity_amount_based_on_all_previous_slabs(self): + employee, sal_slip = create_employee_and_get_last_salary_slip() + rule = get_gratuity_rule("Rule Under Limited Contract (UAE)") + set_mode_of_payment_account() + + gratuity = create_gratuity(expense_account = 'Payment Account - _TC', mode_of_payment='Cash', employee=employee) + + #work experience calculation + date_of_joining, relieving_date = frappe.db.get_value('Employee', employee, ['date_of_joining', 'relieving_date']) + employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(date_of_joining)).days + + experience = employee_total_workings_days/rule.total_working_days_per_year + + gratuity.reload() + + from math import floor + + self.assertEqual(floor(experience), gratuity.current_work_experience) + + #amount Calculation + component_amount = frappe.get_list("Salary Detail", + filters={ + "docstatus": 1, + 'parent': sal_slip, + "parentfield": "earnings", + 'salary_component': "Basic Salary" + }, + fields=["amount"]) + + ''' range | Fraction + 0-1 | 0 + 1-5 | 0.7 + 5-0 | 1 + ''' + + gratuity_amount = ((0 * 1) + (4 * 0.7) + (1 * 1)) * component_amount[0].amount + gratuity.reload() + + self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2)) + self.assertEqual(gratuity.status, "Unpaid") + + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + pay_entry = get_payment_entry("Gratuity", gratuity.name) + pay_entry.reference_no = "123467" + pay_entry.reference_date = getdate() + pay_entry.save() + pay_entry.submit() + gratuity.reload() + + self.assertEqual(gratuity.status, "Paid") + self.assertEqual(flt(gratuity.paid_amount,2), flt(gratuity.amount, 2)) + + def tearDown(self): + frappe.db.sql("DELETE FROM `tabGratuity`") + frappe.db.sql("DELETE FROM `tabAdditional Salary` WHERE ref_doctype = 'Gratuity'") + +def get_gratuity_rule(name): + rule = frappe.db.exists("Gratuity Rule", name) + if not rule: + create_gratuity_rule() + rule = frappe.get_doc("Gratuity Rule", name) + rule.applicable_earnings_component = [] + rule.append("applicable_earnings_component", { + "salary_component": "Basic Salary" + }) + rule.save() + rule.reload() + + return rule + +def create_gratuity(**args): + if args: + args = frappe._dict(args) + gratuity = frappe.new_doc("Gratuity") + gratuity.employee = args.employee + gratuity.posting_date = getdate() + gratuity.gratuity_rule = args.rule or "Rule Under Limited Contract (UAE)" + gratuity.pay_via_salary_slip = args.pay_via_salary_slip or 0 + if gratuity.pay_via_salary_slip: + gratuity.payroll_date = getdate() + gratuity.salary_component = "Performance Bonus" + else: + gratuity.expense_account = args.expense_account or 'Payment Account - _TC' + gratuity.payable_account = args.payable_account or get_payable_account("_Test Company") + gratuity.mode_of_payment = args.mode_of_payment or 'Cash' + + gratuity.save() + gratuity.submit() + + return gratuity + +def set_mode_of_payment_account(): + if not frappe.db.exists("Account", "Payment Account - _TC"): + mode_of_payment = create_account() + + mode_of_payment = frappe.get_doc("Mode of Payment", "Cash") + + mode_of_payment.accounts = [] + mode_of_payment.append("accounts", { + "company": "_Test Company", + "default_account": "_Test Bank - _TC" + }) + mode_of_payment.save() + +def create_account(): + return frappe.get_doc({ + "doctype": "Account", + "company": "_Test Company", + "account_name": "Payment Account", + "root_type": "Asset", + "report_type": "Balance Sheet", + "currency": "INR", + "parent_account": "Bank Accounts - _TC", + "account_type": "Bank", + }).insert(ignore_permissions=True) + +def create_employee_and_get_last_salary_slip(): + employee = make_employee("test_employee@salary.com", company='_Test Company') + frappe.db.set_value("Employee", employee, "relieving_date", getdate()) + frappe.db.set_value("Employee", employee, "date_of_joining", add_days(getdate(), - (6*365))) + if not frappe.db.exists("Salary Slip", {"employee":employee}): + salary_slip = make_employee_salary_slip("test_employee@salary.com", "Monthly") + salary_slip.submit() + salary_slip = salary_slip.name + else: + salary_slip = get_last_salary_slip(employee) + + if not frappe.db.get_value("Employee", "test_employee@salary.com", "holiday_list"): + from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list + make_holiday_list() + frappe.db.set_value("Company", '_Test Company', "default_holiday_list", "Salary Slip Test Holiday List") + + return employee, salary_slip diff --git a/erpnext/payroll/doctype/gratuity_applicable_component/__init__.py b/erpnext/payroll/doctype/gratuity_applicable_component/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/payroll/doctype/gratuity_applicable_component/gratuity_applicable_component.json b/erpnext/payroll/doctype/gratuity_applicable_component/gratuity_applicable_component.json new file mode 100644 index 00000000000..eea0e852b17 --- /dev/null +++ b/erpnext/payroll/doctype/gratuity_applicable_component/gratuity_applicable_component.json @@ -0,0 +1,32 @@ +{ + "actions": [], + "creation": "2020-08-05 19:00:28.097265", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "salary_component" + ], + "fields": [ + { + "fieldname": "salary_component", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Salary Component ", + "options": "Salary Component", + "reqd": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2020-08-05 20:17:13.855035", + "modified_by": "Administrator", + "module": "Payroll", + "name": "Gratuity Applicable Component", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/payroll/doctype/gratuity_applicable_component/gratuity_applicable_component.py b/erpnext/payroll/doctype/gratuity_applicable_component/gratuity_applicable_component.py new file mode 100644 index 00000000000..23e4340b04f --- /dev/null +++ b/erpnext/payroll/doctype/gratuity_applicable_component/gratuity_applicable_component.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class GratuityApplicableComponent(Document): + pass diff --git a/erpnext/payroll/doctype/gratuity_rule/__init__.py b/erpnext/payroll/doctype/gratuity_rule/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.js b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.js new file mode 100644 index 00000000000..ee6c5df7371 --- /dev/null +++ b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.js @@ -0,0 +1,40 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Gratuity Rule', { + // refresh: function(frm) { + + // } +}); + +frappe.ui.form.on('Gratuity Rule Slab', { + + /* + Slabs should be in order like + + from | to | fraction + 0 | 4 | 0.5 + 4 | 6 | 0.7 + + So, on row addition setting current_row.from = previous row.to. + On to_year insert we have to check that it is not less than from_year + + Wrong order may lead to Wrong Calculation + */ + + gratuity_rule_slabs_add(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + let array_idx = row.idx - 1; + if (array_idx > 0) { + row.from_year = cur_frm.doc.gratuity_rule_slabs[array_idx - 1].to_year; + frm.refresh(); + } + }, + + to_year(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.to_year <= row.from_year && row.to_year === 0) { + frappe.throw(__("To(Year) year can not be less than From(year) ")); + } + } +}); \ No newline at end of file diff --git a/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.json b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.json new file mode 100644 index 00000000000..84cdcf50386 --- /dev/null +++ b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.json @@ -0,0 +1,114 @@ +{ + "actions": [], + "autoname": "Prompt", + "creation": "2020-08-05 19:00:36.103500", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "applicable_earnings_component", + "work_experience_calculation_function", + "total_working_days_per_year", + "column_break_3", + "disable", + "calculate_gratuity_amount_based_on", + "minimum_year_for_gratuity", + "gratuity_rules_section", + "gratuity_rule_slabs" + ], + "fields": [ + { + "default": "0", + "fieldname": "disable", + "fieldtype": "Check", + "label": "Disable" + }, + { + "fieldname": "calculate_gratuity_amount_based_on", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Calculate Gratuity Amount Based On", + "options": "Current Slab\nSum of all previous slabs", + "reqd": 1 + }, + { + "description": "Salary components should be part of the Salary Structure.", + "fieldname": "applicable_earnings_component", + "fieldtype": "Table MultiSelect", + "label": "Applicable Earnings Component", + "options": "Gratuity Applicable Component", + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "gratuity_rules_section", + "fieldtype": "Section Break", + "label": "Gratuity Rules" + }, + { + "description": "Leave From and To 0 for no upper and lower limit.", + "fieldname": "gratuity_rule_slabs", + "fieldtype": "Table", + "label": "Current Work Experience", + "options": "Gratuity Rule Slab", + "reqd": 1 + }, + { + "default": "Round off Work Experience", + "fieldname": "work_experience_calculation_function", + "fieldtype": "Select", + "label": "Work Experience Calculation method", + "options": "Round off Work Experience\nTake Exact Completed Years" + }, + { + "default": "365", + "fieldname": "total_working_days_per_year", + "fieldtype": "Int", + "label": "Total working Days Per Year" + }, + { + "fieldname": "minimum_year_for_gratuity", + "fieldtype": "Int", + "label": "Minimum Year for Gratuity" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-12-03 17:08:27.891535", + "modified_by": "Administrator", + "module": "Payroll", + "name": "Gratuity Rule", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.py b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.py new file mode 100644 index 00000000000..29a6ebe1a6a --- /dev/null +++ b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document +from frappe import _ + +class GratuityRule(Document): + + def validate(self): + for current_slab in self.gratuity_rule_slabs: + if (current_slab.from_year > current_slab.to_year) and current_slab.to_year != 0: + frappe(_("Row {0}: From (Year) can not be greater than To (Year)").format(current_slab.idx)) + + if current_slab.to_year == 0 and current_slab.from_year == 0 and len(self.gratuity_rule_slabs) > 1: + frappe.throw(_("You can not define multiple slabs if you have a slab with no lower and upper limits.")) + +def get_gratuity_rule(name, slabs, **args): + args = frappe._dict(args) + + rule = frappe.new_doc("Gratuity Rule") + rule.name = name + rule.calculate_gratuity_amount_based_on = args.calculate_gratuity_amount_based_on or "Current Slab" + rule.work_experience_calculation_method = args.work_experience_calculation_method or "Take Exact Completed Years" + rule.minimum_year_for_gratuity = 1 + + + for slab in slabs: + slab = frappe._dict(slab) + rule.append("gratuity_rule_slabs", slab) + return rule diff --git a/erpnext/payroll/doctype/gratuity_rule/gratuity_rule_dashboard.py b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule_dashboard.py new file mode 100644 index 00000000000..0d70163495a --- /dev/null +++ b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule_dashboard.py @@ -0,0 +1,13 @@ +from __future__ import unicode_literals +from frappe import _ + +def get_data(): + return { + 'fieldname': 'gratuity_rule', + 'transactions': [ + { + 'label': _('Gratuity'), + 'items': ['Gratuity'] + } + ] + } \ No newline at end of file diff --git a/erpnext/payroll/doctype/gratuity_rule/test_gratuity_rule.py b/erpnext/payroll/doctype/gratuity_rule/test_gratuity_rule.py new file mode 100644 index 00000000000..1f5dc4e571e --- /dev/null +++ b/erpnext/payroll/doctype/gratuity_rule/test_gratuity_rule.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestGratuityRule(unittest.TestCase): + pass diff --git a/erpnext/payroll/doctype/gratuity_rule_slab/__init__.py b/erpnext/payroll/doctype/gratuity_rule_slab/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/payroll/doctype/gratuity_rule_slab/gratuity_rule_slab.json b/erpnext/payroll/doctype/gratuity_rule_slab/gratuity_rule_slab.json new file mode 100644 index 00000000000..bc37b0f51ed --- /dev/null +++ b/erpnext/payroll/doctype/gratuity_rule_slab/gratuity_rule_slab.json @@ -0,0 +1,50 @@ +{ + "actions": [], + "creation": "2020-08-05 19:12:49.423500", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "from_year", + "to_year", + "fraction_of_applicable_earnings" + ], + "fields": [ + { + "fieldname": "fraction_of_applicable_earnings", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Fraction of Applicable Earnings ", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "from_year", + "fieldtype": "Int", + "in_list_view": 1, + "label": "From(Year)", + "read_only": 1, + "reqd": 1 + }, + { + "default": "0", + "fieldname": "to_year", + "fieldtype": "Int", + "in_list_view": 1, + "label": "To(Year)", + "reqd": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2020-08-17 14:09:56.781712", + "modified_by": "Administrator", + "module": "Payroll", + "name": "Gratuity Rule Slab", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_transaction_settings_item/bank_statement_transaction_settings_item.py b/erpnext/payroll/doctype/gratuity_rule_slab/gratuity_rule_slab.py similarity index 56% rename from erpnext/accounts/doctype/bank_statement_transaction_settings_item/bank_statement_transaction_settings_item.py rename to erpnext/payroll/doctype/gratuity_rule_slab/gratuity_rule_slab.py index bf0a590d484..fa468e77beb 100644 --- a/erpnext/accounts/doctype/bank_statement_transaction_settings_item/bank_statement_transaction_settings_item.py +++ b/erpnext/payroll/doctype/gratuity_rule_slab/gratuity_rule_slab.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2017, sathishpy@gmail.com and contributors +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt from __future__ import unicode_literals -import frappe +# import frappe from frappe.model.document import Document -class BankStatementTransactionSettingsItem(Document): +class GratuityRuleSlab(Document): pass diff --git a/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.js b/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.js index 73a54eb8dd9..7d780d3b040 100644 --- a/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.js +++ b/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.js @@ -2,5 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on('Income Tax Slab', { - + currency: function(frm) { + frm.refresh_fields(); + } }); diff --git a/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.json b/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.json index 6337d5a6d3e..9fa261dea2d 100644 --- a/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.json +++ b/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.json @@ -9,8 +9,9 @@ "effective_from", "company", "column_break_3", - "allow_tax_exemption", + "currency", "standard_tax_exemption_amount", + "allow_tax_exemption", "disabled", "amended_from", "taxable_salary_slabs_section", @@ -70,7 +71,7 @@ "fieldname": "standard_tax_exemption_amount", "fieldtype": "Currency", "label": "Standard Tax Exemption Amount", - "options": "Company:company:default_currency" + "options": "currency" }, { "fieldname": "company", @@ -90,11 +91,20 @@ "fieldtype": "Table", "label": "Other Taxes and Charges", "options": "Income Tax Slab Other Charges" + }, + { + "default": "Company:company:default_currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-06-22 20:27:13.425084", + "modified": "2020-10-19 13:54:24.728075", "modified_by": "Administrator", "module": "Payroll", "name": "Income Tax Slab", diff --git a/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.py b/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.py index 253f023f68b..81e364778ca 100644 --- a/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.py +++ b/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.py @@ -3,8 +3,11 @@ # For license information, please see license.txt from __future__ import unicode_literals -# import frappe +#import frappe +import erpnext from frappe.model.document import Document class IncomeTaxSlab(Document): - pass + def validate(self): + if self.company: + self.currency = erpnext.get_company_currency(self.company) diff --git a/erpnext/payroll/doctype/income_tax_slab_other_charges/income_tax_slab_other_charges.json b/erpnext/payroll/doctype/income_tax_slab_other_charges/income_tax_slab_other_charges.json index 7f21204591a..0dba3382504 100644 --- a/erpnext/payroll/doctype/income_tax_slab_other_charges/income_tax_slab_other_charges.json +++ b/erpnext/payroll/doctype/income_tax_slab_other_charges/income_tax_slab_other_charges.json @@ -45,7 +45,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Min Taxable Income", - "options": "Company:company:default_currency" + "options": "currency" }, { "fieldname": "column_break_7", @@ -57,12 +57,12 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Max Taxable Income", - "options": "Company:company:default_currency" + "options": "currency" } ], "istable": 1, "links": [], - "modified": "2020-06-22 23:33:17.931912", + "modified": "2020-10-19 13:45:12.850090", "modified_by": "Administrator", "module": "Payroll", "name": "Income Tax Slab Other Charges", diff --git a/erpnext/payroll/doctype/payroll_employee_detail/payroll_employee_detail.json b/erpnext/payroll/doctype/payroll_employee_detail/payroll_employee_detail.json index bb68e1814a7..09c7eb9a456 100644 --- a/erpnext/payroll/doctype/payroll_employee_detail/payroll_employee_detail.json +++ b/erpnext/payroll/doctype/payroll_employee_detail/payroll_employee_detail.json @@ -17,8 +17,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Employee", - "options": "Employee", - "read_only": 1 + "options": "Employee" }, { "fetch_from": "employee.employee_name", @@ -52,7 +51,7 @@ ], "istable": 1, "links": [], - "modified": "2020-06-22 23:25:13.779032", + "modified": "2020-12-17 15:43:29.542977", "modified_by": "Administrator", "module": "Payroll", "name": "Payroll Employee Detail", diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js index 1abc869c539..395e56fa92e 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js @@ -3,6 +3,8 @@ var in_progress = false; +frappe.provide("erpnext.accounts.dimensions"); + frappe.ui.form.on('Payroll Entry', { onload: function (frm) { if (!frm.doc.posting_date) { @@ -10,7 +12,13 @@ frappe.ui.form.on('Payroll Entry', { } frm.toggle_reqd(['payroll_frequency'], !frm.doc.salary_slip_based_on_timesheet); - frm.set_query("department", function() { + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); + frm.events.department_filters(frm); + frm.events.payroll_payable_account_filters(frm); + }, + + department_filters: function (frm) { + frm.set_query("department", function () { return { "filters": { "company": frm.doc.company, @@ -19,20 +27,32 @@ frappe.ui.form.on('Payroll Entry', { }); }, - refresh: function(frm) { + payroll_payable_account_filters: function (frm) { + frm.set_query("payroll_payable_account", function () { + return { + filters: { + "company": frm.doc.company, + "root_type": "Liability", + "is_group": 0, + } + }; + }); + }, + + refresh: function (frm) { if (frm.doc.docstatus == 0) { - if(!frm.is_new()) { + if (!frm.is_new()) { frm.page.clear_primary_action(); frm.add_custom_button(__("Get Employees"), - function() { + function () { frm.events.get_employee_details(frm); } ).toggleClass('btn-primary', !(frm.doc.employees || []).length); } - if ((frm.doc.employees || []).length) { + if ((frm.doc.employees || []).length && !frappe.model.has_workflow(frm.doctype)) { frm.page.clear_primary_action(); frm.page.set_primary_action(__('Create Salary Slips'), () => { - frm.save('Submit').then(()=>{ + frm.save('Submit').then(() => { frm.page.clear_primary_action(); frm.refresh(); frm.events.refresh(frm); @@ -51,48 +71,48 @@ frappe.ui.form.on('Payroll Entry', { doc: frm.doc, method: 'fill_employee_details', }).then(r => { - if (r.docs && r.docs[0].employees){ + if (r.docs && r.docs[0].employees) { frm.employees = r.docs[0].employees; frm.dirty(); frm.save(); frm.refresh(); - if(r.docs[0].validate_attendance){ + if (r.docs[0].validate_attendance) { render_employee_attendance(frm, r.message); } } - }) + }); }, - create_salary_slips: function(frm) { + create_salary_slips: function (frm) { frm.call({ doc: frm.doc, method: "create_salary_slips", - callback: function(r) { + callback: function () { frm.refresh(); frm.toolbar.refresh(); } - }) + }); }, - add_context_buttons: function(frm) { - if(frm.doc.salary_slips_submitted || (frm.doc.__onload && frm.doc.__onload.submitted_ss)) { + add_context_buttons: function (frm) { + if (frm.doc.salary_slips_submitted || (frm.doc.__onload && frm.doc.__onload.submitted_ss)) { frm.events.add_bank_entry_button(frm); - } else if(frm.doc.salary_slips_created) { - frm.add_custom_button(__("Submit Salary Slip"), function() { + } else if (frm.doc.salary_slips_created) { + frm.add_custom_button(__("Submit Salary Slip"), function () { submit_salary_slip(frm); }).addClass("btn-primary"); } }, - add_bank_entry_button: function(frm) { + add_bank_entry_button: function (frm) { frappe.call({ method: 'erpnext.payroll.doctype.payroll_entry.payroll_entry.payroll_entry_has_bank_entries', args: { 'name': frm.doc.name }, - callback: function(r) { + callback: function (r) { if (r.message && !r.message.submitted) { - frm.add_custom_button("Make Bank Entry", function() { + frm.add_custom_button("Make Bank Entry", function () { make_bank_entry(frm); }).addClass("btn-primary"); } @@ -112,31 +132,76 @@ frappe.ui.form.on('Payroll Entry', { "company": frm.doc.company } }; - }), - frm.set_query("cost_center", function () { + }); + }, + + payroll_frequency: function (frm) { + frm.trigger("set_start_end_dates").then( ()=> { + frm.events.clear_employee_table(frm); + frm.events.get_employee_with_salary_slip_and_set_query(frm); + }); + }, + + employee_filters: function (frm, emp_list) { + frm.set_query('employee', 'employees', () => { return { filters: { - "is_group": 0, - company: frm.doc.company - } - }; - }), - frm.set_query("project", function () { - return { - filters: { - company: frm.doc.company + name: ["not in", emp_list] } }; }); }, - payroll_frequency: function (frm) { - frm.trigger("set_start_end_dates"); - frm.events.clear_employee_table(frm); + get_employee_with_salary_slip_and_set_query: function (frm) { + frappe.db.get_list('Salary Slip', { + filters: { + start_date: frm.doc.start_date, + end_date: frm.doc.end_date, + docstatus: 1, + }, + fields: ['employee'] + }).then((emp) => { + var emp_list = []; + emp.forEach((employee_data) => { + emp_list.push(Object.values(employee_data)[0]); + }); + frm.events.employee_filters(frm, emp_list); + }); }, company: function (frm) { frm.events.clear_employee_table(frm); + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, + + currency: function (frm) { + var company_currency; + if (!frm.doc.company) { + company_currency = erpnext.get_currency(frappe.defaults.get_default("Company")); + } else { + company_currency = erpnext.get_currency(frm.doc.company); + } + if (frm.doc.currency) { + if (company_currency != frm.doc.currency) { + frappe.call({ + method: "erpnext.setup.utils.get_exchange_rate", + args: { + from_currency: frm.doc.currency, + to_currency: company_currency, + }, + callback: function (r) { + frm.set_value("exchange_rate", flt(r.message)); + frm.set_df_property('exchange_rate', 'hidden', 0); + frm.set_df_property("exchange_rate", "description", "1 " + frm.doc.currency + + " = [?] " + company_currency); + } + }); + } else { + frm.set_value("exchange_rate", 1.0); + frm.set_df_property('exchange_rate', 'hidden', 1); + frm.set_df_property("exchange_rate", "description", ""); + } + } }, department: function (frm) { @@ -152,9 +217,9 @@ frappe.ui.form.on('Payroll Entry', { }, start_date: function (frm) { - if(!in_progress && frm.doc.start_date){ + if (!in_progress && frm.doc.start_date) { frm.trigger("set_end_date"); - }else{ + } else { // reset flag in_progress = false; } @@ -188,7 +253,7 @@ frappe.ui.form.on('Payroll Entry', { } }, - set_end_date: function(frm){ + set_end_date: function (frm) { frappe.call({ method: 'erpnext.payroll.doctype.payroll_entry.payroll_entry.get_end_date', args: { @@ -203,19 +268,19 @@ frappe.ui.form.on('Payroll Entry', { }); }, - validate_attendance: function(frm){ - if(frm.doc.validate_attendance && frm.doc.employees){ + validate_attendance: function (frm) { + if (frm.doc.validate_attendance && frm.doc.employees) { frappe.call({ method: 'validate_employee_attendance', args: {}, - callback: function(r) { + callback: function (r) { render_employee_attendance(frm, r.message); }, doc: frm.doc, freeze: true, freeze_message: __('Validating Employee Attendance...') }); - }else{ + } else { frm.fields_dict.attendance_detail_html.html(""); } }, @@ -230,18 +295,20 @@ frappe.ui.form.on('Payroll Entry', { const submit_salary_slip = function (frm) { frappe.confirm(__('This will submit Salary Slips and create accrual Journal Entry. Do you want to proceed?'), - function() { + function () { frappe.call({ method: 'submit_salary_slips', args: {}, - callback: function() {frm.events.refresh(frm);}, + callback: function () { + frm.events.refresh(frm); + }, doc: frm.doc, freeze: true, freeze_message: __('Submitting Salary Slips and creating Journal Entry...') }); }, - function() { - if(frappe.dom.freeze_count) { + function () { + if (frappe.dom.freeze_count) { frappe.dom.unfreeze(); frm.events.refresh(frm); } @@ -255,9 +322,11 @@ let make_bank_entry = function (frm) { return frappe.call({ doc: cur_frm.doc, method: "make_payment_entry", - callback: function() { + callback: function () { frappe.set_route( - 'List', 'Journal Entry', {"Journal Entry Account.reference_name": frm.doc.name} + 'List', 'Journal Entry', { + "Journal Entry Account.reference_name": frm.doc.name + } ); }, freeze: true, @@ -269,11 +338,18 @@ let make_bank_entry = function (frm) { } }; - -let render_employee_attendance = function(frm, data) { +let render_employee_attendance = function (frm, data) { frm.fields_dict.attendance_detail_html.html( frappe.render_template('employees_to_mark_attendance', { data: data }) ); -} +}; + +frappe.ui.form.on('Payroll Employee Detail', { + employee: function(frm) { + if (!frm.doc.payroll_frequency) { + frappe.throw(__("Please set a Payroll Frequency")); + } + } +}); diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.json b/erpnext/payroll/doctype/payroll_entry/payroll_entry.json index 31a899699d7..0444134aa4d 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.json +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.json @@ -11,8 +11,11 @@ "column_break0", "posting_date", "payroll_frequency", - "column_break1", "company", + "column_break1", + "currency", + "exchange_rate", + "payroll_payable_account", "section_break_8", "branch", "department", @@ -126,8 +129,7 @@ "fieldname": "employees", "fieldtype": "Table", "label": "Employee Details", - "options": "Payroll Employee Detail", - "read_only": 1 + "options": "Payroll Employee Detail" }, { "fieldname": "section_break_13", @@ -257,12 +259,37 @@ { "fieldname": "column_break_33", "fieldtype": "Column Break" + }, + { + "depends_on": "company", + "fieldname": "currency", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Currency", + "options": "Currency", + "reqd": 1 + }, + { + "depends_on": "company", + "fieldname": "exchange_rate", + "fieldtype": "Float", + "label": "Exchange Rate", + "precision": "9", + "reqd": 1 + }, + { + "depends_on": "company", + "fieldname": "payroll_payable_account", + "fieldtype": "Link", + "label": "Payroll Payable Account", + "options": "Account", + "reqd": 1 } ], "icon": "fa fa-cog", "is_submittable": 1, "links": [], - "modified": "2020-06-22 20:06:06.953904", + "modified": "2020-12-17 15:13:17.766210", "modified_by": "Administrator", "module": "Payroll", "name": "Payroll Entry", diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 30ea432678c..6bcd4e0c006 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -3,10 +3,10 @@ # For license information, please see license.txt from __future__ import unicode_literals -import frappe +import frappe, erpnext from frappe.model.document import Document from dateutil.relativedelta import relativedelta -from frappe.utils import cint, flt, nowdate, add_days, getdate, fmt_money, add_to_date, DATE_FORMAT, date_diff +from frappe.utils import cint, flt, add_days, getdate, add_to_date, DATE_FORMAT, date_diff, comma_and from frappe import _ from erpnext.accounts.utils import get_fiscal_year from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee @@ -19,16 +19,29 @@ class PayrollEntry(Document): # check if salary slips were manually submitted entries = frappe.db.count("Salary Slip", {'payroll_entry': self.name, 'docstatus': 1}, ['name']) if cint(entries) == len(self.employees): - self.set_onload("submitted_ss", True) + self.set_onload("submitted_ss", True) + + def validate(self): + self.number_of_employees = len(self.employees) def on_submit(self): self.create_salary_slips() def before_submit(self): + self.validate_employee_details() if self.validate_attendance: if self.validate_employee_attendance(): frappe.throw(_("Cannot Submit, Employees left to mark attendance")) + def validate_employee_details(self): + emp_with_sal_slip = [] + for employee_details in self.employees: + if frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": self.start_date, "end_date": self.end_date, "docstatus": 1}): + emp_with_sal_slip.append(employee_details.employee) + + if len(emp_with_sal_slip): + frappe.throw(_("Salary Slip already exists for {0} ").format(comma_and(emp_with_sal_slip))) + def on_cancel(self): frappe.delete_doc("Salary Slip", frappe.db.sql_list("""select name from `tabSalary Slip` where payroll_entry=%s """, (self.name))) @@ -51,13 +64,15 @@ class PayrollEntry(Document): where docstatus = 1 and is_active = 'Yes' - and company = %(company)s and + and company = %(company)s + and currency = %(currency)s and ifnull(salary_slip_based_on_timesheet,0) = %(salary_slip_based_on_timesheet)s {condition}""".format(condition=condition), - {"company": self.company, "salary_slip_based_on_timesheet":self.salary_slip_based_on_timesheet}) + {"company": self.company, "currency": self.currency, "salary_slip_based_on_timesheet":self.salary_slip_based_on_timesheet}) if sal_struct: cond += "and t2.salary_structure IN %(sal_struct)s " + cond += "and t2.payroll_payable_account = %(payroll_payable_account)s " cond += "and %(from_date)s >= t2.from_date" emp_list = frappe.db.sql(""" select @@ -68,19 +83,40 @@ class PayrollEntry(Document): t1.name = t2.employee and t2.docstatus = 1 %s order by t2.from_date desc - """ % cond, {"sal_struct": tuple(sal_struct), "from_date": self.end_date}, as_dict=True) + """ % cond, {"sal_struct": tuple(sal_struct), "from_date": self.end_date, "payroll_payable_account": self.payroll_payable_account}, as_dict=True) + + emp_list = self.remove_payrolled_employees(emp_list) return emp_list + def remove_payrolled_employees(self, emp_list): + for employee_details in emp_list: + if frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": self.start_date, "end_date": self.end_date, "docstatus": 1}): + emp_list.remove(employee_details) + + return emp_list + def fill_employee_details(self): self.set('employees', []) employees = self.get_emp_list() if not employees: - frappe.throw(_("No employees for the mentioned criteria")) + error_msg = _("No employees found for the mentioned criteria:
    Company: {0}
    Currency: {1}
    Payroll Payable Account: {2}").format( + frappe.bold(self.company), frappe.bold(self.currency), frappe.bold(self.payroll_payable_account)) + if self.branch: + error_msg += "
    " + _("Branch: {0}").format(frappe.bold(self.branch)) + if self.department: + error_msg += "
    " + _("Department: {0}").format(frappe.bold(self.department)) + if self.designation: + error_msg += "
    " + _("Designation: {0}").format(frappe.bold(self.designation)) + if self.start_date: + error_msg += "
    " + _("Start date: {0}").format(frappe.bold(self.start_date)) + if self.end_date: + error_msg += "
    " + _("End date: {0}").format(frappe.bold(self.end_date)) + frappe.throw(error_msg, title=_("No employees found")) for d in employees: self.append('employees', d) - self.number_of_employees = len(employees) + self.number_of_employees = len(self.employees) if self.validate_attendance: return self.validate_employee_attendance() @@ -112,8 +148,8 @@ class PayrollEntry(Document): """ self.check_permission('write') self.created = 1 - emp_list = [d.employee for d in self.get_emp_list()] - if emp_list: + employees = [emp.employee for emp in self.employees] + if employees: args = frappe._dict({ "salary_slip_based_on_timesheet": self.salary_slip_based_on_timesheet, "payroll_frequency": self.payroll_frequency, @@ -123,12 +159,14 @@ class PayrollEntry(Document): "posting_date": self.posting_date, "deduct_tax_for_unclaimed_employee_benefits": self.deduct_tax_for_unclaimed_employee_benefits, "deduct_tax_for_unsubmitted_tax_exemption_proof": self.deduct_tax_for_unsubmitted_tax_exemption_proof, - "payroll_entry": self.name + "payroll_entry": self.name, + "exchange_rate": self.exchange_rate, + "currency": self.currency }) - if len(emp_list) > 30: - frappe.enqueue(create_salary_slips_for_employees, timeout=600, employees=emp_list, args=args) + if len(employees) > 30: + frappe.enqueue(create_salary_slips_for_employees, timeout=600, employees=employees, args=args) else: - create_salary_slips_for_employees(emp_list, args, publish_progress=False) + create_salary_slips_for_employees(employees, args, publish_progress=False) # since this method is called via frm.call this doc needs to be updated manually self.reload() @@ -160,10 +198,10 @@ class PayrollEntry(Document): def get_salary_component_account(self, salary_component): account = frappe.db.get_value("Salary Component Account", - {"parent": salary_component, "company": self.company}, "default_account") + {"parent": salary_component, "company": self.company}, "account") if not account: - frappe.throw(_("Please set default account in Salary Component {0}") + frappe.throw(_("Please set account in Salary Component {0}") .format(salary_component)) return account @@ -203,21 +241,11 @@ class PayrollEntry(Document): account_dict[(account, key[1])] = account_dict.get((account, key[1]), 0) + amount return account_dict - def get_default_payroll_payable_account(self): - payroll_payable_account = frappe.get_cached_value('Company', - {"company_name": self.company}, "default_payroll_payable_account") - - if not payroll_payable_account: - frappe.throw(_("Please set Default Payroll Payable Account in Company {0}") - .format(self.company)) - - return payroll_payable_account - def make_accrual_jv_entry(self): self.check_permission('write') earnings = self.get_salary_component_total(component_type = "earnings") or {} deductions = self.get_salary_component_total(component_type = "deductions") or {} - default_payroll_payable_account = self.get_default_payroll_payable_account() + payroll_payable_account = self.payroll_payable_account jv_name = "" precision = frappe.get_precision("Journal Entry Account", "debit_in_account_currency") @@ -230,14 +258,19 @@ class PayrollEntry(Document): journal_entry.posting_date = self.posting_date accounts = [] + currencies = [] payable_amount = 0 + multi_currency = 0 + company_currency = erpnext.get_company_currency(self.company) # Earnings for acc_cc, amount in earnings.items(): + exchange_rate, amt = self.get_amount_and_exchange_rate_for_journal_entry(acc_cc[0], amount, company_currency, currencies) payable_amount += flt(amount, precision) accounts.append({ "account": acc_cc[0], - "debit_in_account_currency": flt(amount, precision), + "debit_in_account_currency": flt(amt, precision), + "exchange_rate": flt(exchange_rate), "party_type": '', "cost_center": acc_cc[1] or self.cost_center, "project": self.project @@ -245,25 +278,32 @@ class PayrollEntry(Document): # Deductions for acc_cc, amount in deductions.items(): + exchange_rate, amt = self.get_amount_and_exchange_rate_for_journal_entry(acc_cc[0], amount, company_currency, currencies) payable_amount -= flt(amount, precision) accounts.append({ "account": acc_cc[0], - "credit_in_account_currency": flt(amount, precision), + "credit_in_account_currency": flt(amt, precision), + "exchange_rate": flt(exchange_rate), "cost_center": acc_cc[1] or self.cost_center, "party_type": '', "project": self.project }) # Payable amount + exchange_rate, payable_amt = self.get_amount_and_exchange_rate_for_journal_entry(payroll_payable_account, payable_amount, company_currency, currencies) accounts.append({ - "account": default_payroll_payable_account, - "credit_in_account_currency": flt(payable_amount, precision), + "account": payroll_payable_account, + "credit_in_account_currency": flt(payable_amt, precision), + "exchange_rate": flt(exchange_rate), "party_type": '', "cost_center": self.cost_center }) journal_entry.set("accounts", accounts) - journal_entry.title = default_payroll_payable_account + if len(currencies) > 1: + multi_currency = 1 + journal_entry.multi_currency = multi_currency + journal_entry.title = payroll_payable_account journal_entry.save() try: @@ -271,10 +311,24 @@ class PayrollEntry(Document): jv_name = journal_entry.name self.update_salary_slip_status(jv_name = jv_name) except Exception as e: - frappe.msgprint(e) + if type(e) in (str, list, tuple): + frappe.msgprint(e) + raise return jv_name + def get_amount_and_exchange_rate_for_journal_entry(self, account, amount, company_currency, currencies): + conversion_rate = 1 + exchange_rate = self.exchange_rate + account_currency = frappe.db.get_value('Account', account, 'account_currency') + if account_currency not in currencies: + currencies.append(account_currency) + if account_currency == company_currency: + conversion_rate = self.exchange_rate + exchange_rate = 1 + amount = flt(amount) * flt(conversion_rate) + return exchange_rate, amount + def make_payment_entry(self): self.check_permission('write') @@ -303,31 +357,43 @@ class PayrollEntry(Document): self.create_journal_entry(salary_slip_total, "salary") def create_journal_entry(self, je_payment_amount, user_remark): - default_payroll_payable_account = self.get_default_payroll_payable_account() + payroll_payable_account = self.payroll_payable_account precision = frappe.get_precision("Journal Entry Account", "debit_in_account_currency") + accounts = [] + currencies = [] + multi_currency = 0 + company_currency = erpnext.get_company_currency(self.company) + + exchange_rate, amount = self.get_amount_and_exchange_rate_for_journal_entry(self.payment_account, je_payment_amount, company_currency, currencies) + accounts.append({ + "account": self.payment_account, + "bank_account": self.bank_account, + "credit_in_account_currency": flt(amount, precision), + "exchange_rate": flt(exchange_rate), + }) + + exchange_rate, amount = self.get_amount_and_exchange_rate_for_journal_entry(payroll_payable_account, je_payment_amount, company_currency, currencies) + accounts.append({ + "account": payroll_payable_account, + "debit_in_account_currency": flt(amount, precision), + "exchange_rate": flt(exchange_rate), + "reference_type": self.doctype, + "reference_name": self.name + }) + + if len(currencies) > 1: + multi_currency = 1 + journal_entry = frappe.new_doc('Journal Entry') journal_entry.voucher_type = 'Bank Entry' journal_entry.user_remark = _('Payment of {0} from {1} to {2}')\ .format(user_remark, self.start_date, self.end_date) journal_entry.company = self.company journal_entry.posting_date = self.posting_date + journal_entry.multi_currency = multi_currency - payment_amount = flt(je_payment_amount, precision) - - journal_entry.set("accounts", [ - { - "account": self.payment_account, - "bank_account": self.bank_account, - "credit_in_account_currency": payment_amount - }, - { - "account": default_payroll_payable_account, - "debit_in_account_currency": payment_amount, - "reference_type": self.doctype, - "reference_name": self.name - } - ]) + journal_entry.set("accounts", accounts) journal_entry.save(ignore_permissions = True) def update_salary_slip_status(self, jv_name = None): @@ -344,9 +410,13 @@ class PayrollEntry(Document): employees_to_mark_attendance = [] days_in_payroll, days_holiday, days_attendance_marked = 0, 0, 0 for employee_detail in self.employees: - days_holiday = self.get_count_holidays_of_employee(employee_detail.employee) - days_attendance_marked = self.get_count_employee_attendance(employee_detail.employee) - days_in_payroll = date_diff(self.end_date, self.start_date) + 1 + employee_joining_date = frappe.db.get_value("Employee", employee_detail.employee, 'date_of_joining') + start_date = self.start_date + if employee_joining_date > getdate(self.start_date): + start_date = employee_joining_date + days_holiday = self.get_count_holidays_of_employee(employee_detail.employee, start_date) + days_attendance_marked = self.get_count_employee_attendance(employee_detail.employee, start_date) + days_in_payroll = date_diff(self.end_date, start_date) + 1 if days_in_payroll > days_holiday + days_attendance_marked: employees_to_mark_attendance.append({ "employee": employee_detail.employee, @@ -354,22 +424,25 @@ class PayrollEntry(Document): }) return employees_to_mark_attendance - def get_count_holidays_of_employee(self, employee): + def get_count_holidays_of_employee(self, employee, start_date): holiday_list = get_holiday_list_for_employee(employee) holidays = 0 if holiday_list: days = frappe.db.sql("""select count(*) from tabHoliday where parent=%s and holiday_date between %s and %s""", (holiday_list, - self.start_date, self.end_date)) + start_date, self.end_date)) if days and days[0][0]: holidays = days[0][0] return holidays - def get_count_employee_attendance(self, employee): + def get_count_employee_attendance(self, employee, start_date): marked_days = 0 - attendances = frappe.db.sql("""select count(*) from tabAttendance where - employee=%s and docstatus=1 and attendance_date between %s and %s""", - (employee, self.start_date, self.end_date)) + attendances = frappe.get_all("Attendance", + fields = ["count(*)"], + filters = { + "employee": employee, + "attendance_date": ('between', [start_date, self.end_date]) + }, as_list=1) if attendances and attendances[0][0]: marked_days = attendances[0][0] return marked_days @@ -489,6 +562,21 @@ def create_salary_slips_for_employees(employees, args, publish_progress=True): if publish_progress: frappe.publish_progress(count*100/len(set(employees) - set(salary_slips_exists_for)), title = _("Creating Salary Slips...")) + else: + salary_slip_name = frappe.db.sql( + '''SELECT + name + FROM `tabSalary Slip` + WHERE company=%s + AND start_date >= %s + AND end_date <= %s + AND employee = %s + ''', (args.company, args.start_date, args.end_date, emp), as_dict=True) + + salary_slip_doc = frappe.get_doc('Salary Slip', salary_slip_name[0].name) + salary_slip_doc.exchange_rate = args.exchange_rate + salary_slip_doc.set_totals() + salary_slip_doc.db_update() payroll_entry = frappe.get_doc("Payroll Entry", args.payroll_entry) payroll_entry.db_set("salary_slips_created", 1) diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py index b0f225d909d..84c381489ca 100644 --- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py @@ -10,9 +10,9 @@ from frappe.utils import add_months from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates, get_end_date from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.payroll.doctype.salary_slip.test_salary_slip import get_salary_component_account, \ - make_earning_salary_component, make_deduction_salary_component, create_account -from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure -from erpnext.loan_management.doctype.loan.test_loan import create_loan, make_loan_disbursement_entry + make_earning_salary_component, make_deduction_salary_component, create_account, make_employee_salary_slip +from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure, create_salary_structure_assignment +from erpnext.loan_management.doctype.loan.test_loan import create_loan, make_loan_disbursement_entry, create_loan_type, create_loan_accounts from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import process_loan_interest_accrual_for_term_loans class TestPayrollEntry(unittest.TestCase): @@ -22,7 +22,7 @@ class TestPayrollEntry(unittest.TestCase): frappe.db.sql("delete from `tab%s`" % dt) make_earning_salary_component(setup=True, company_list=["_Test Company"]) - make_deduction_salary_component(setup=True, company_list=["_Test Company"]) + make_deduction_salary_component(setup=True, test_tax=False, company_list=["_Test Company"]) frappe.db.set_value("Payroll Settings", None, "email_salary_slip_to_employee", 0) @@ -34,10 +34,47 @@ class TestPayrollEntry(unittest.TestCase): get_salary_component_account(data.name) employee = frappe.db.get_value("Employee", {'company': company}) - make_salary_structure("_Test Salary Structure", "Monthly", employee, company=company) + company_doc = frappe.get_doc('Company', company) + make_salary_structure("_Test Salary Structure", "Monthly", employee, company=company, currency=company_doc.default_currency) dates = get_start_end_dates('Monthly', nowdate()) if not frappe.db.get_value("Salary Slip", {"start_date": dates.start_date, "end_date": dates.end_date}): - make_payroll_entry(start_date=dates.start_date, end_date=dates.end_date) + make_payroll_entry(start_date=dates.start_date, end_date=dates.end_date, payable_account=company_doc.default_payroll_payable_account, + currency=company_doc.default_currency) + + def test_multi_currency_payroll_entry(self): # pylint: disable=no-self-use + company = erpnext.get_default_company() + employee = make_employee("test_muti_currency_employee@payroll.com", company=company) + for data in frappe.get_all('Salary Component', fields = ["name"]): + if not frappe.db.get_value('Salary Component Account', + {'parent': data.name, 'company': company}, 'name'): + get_salary_component_account(data.name) + + company_doc = frappe.get_doc('Company', company) + salary_structure = make_salary_structure("_Test Multi Currency Salary Structure", "Monthly", company=company, currency='USD') + create_salary_structure_assignment(employee, salary_structure.name, company=company) + frappe.db.sql("""delete from `tabSalary Slip` where employee=%s""",(frappe.db.get_value("Employee", {"user_id": "test_muti_currency_employee@payroll.com"}))) + salary_slip = get_salary_slip("test_muti_currency_employee@payroll.com", "Monthly", "_Test Multi Currency Salary Structure") + dates = get_start_end_dates('Monthly', nowdate()) + payroll_entry = make_payroll_entry(start_date=dates.start_date, end_date=dates.end_date, + payable_account=company_doc.default_payroll_payable_account, currency='USD', exchange_rate=70) + payroll_entry.make_payment_entry() + + salary_slip.load_from_db() + + payroll_je = salary_slip.journal_entry + payroll_je_doc = frappe.get_doc('Journal Entry', payroll_je) + + self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_debit) + self.assertEqual(salary_slip.base_gross_pay, payroll_je_doc.total_credit) + + payment_entry = frappe.db.sql(''' + Select ifnull(sum(je.total_debit),0) as total_debit, ifnull(sum(je.total_credit),0) as total_credit from `tabJournal Entry` je, `tabJournal Entry Account` jea + Where je.name = jea.parent + And jea.reference_name = %s + ''', (payroll_entry.name), as_dict=1) + + self.assertEqual(salary_slip.base_net_pay, payment_entry[0].total_debit) + self.assertEqual(salary_slip.base_net_pay, payment_entry[0].total_credit) def test_payroll_entry_with_employee_cost_center(self): # pylint: disable=no-self-use for data in frappe.get_all('Salary Component', fields = ["name"]): @@ -52,24 +89,32 @@ class TestPayrollEntry(unittest.TestCase): "company": "_Test Company" }).insert() + frappe.db.sql("""delete from `tabEmployee` where employee_name='test_employee1@example.com' """) + frappe.db.sql("""delete from `tabEmployee` where employee_name='test_employee2@example.com' """) + frappe.db.sql("""delete from `tabSalary Structure` where name='_Test Salary Structure 1' """) + frappe.db.sql("""delete from `tabSalary Structure` where name='_Test Salary Structure 2' """) + employee1 = make_employee("test_employee1@example.com", payroll_cost_center="_Test Cost Center - _TC", department="cc - _TC", company="_Test Company") employee2 = make_employee("test_employee2@example.com", payroll_cost_center="_Test Cost Center 2 - _TC", department="cc - _TC", company="_Test Company") - make_salary_structure("_Test Salary Structure 1", "Monthly", employee1, company="_Test Company") - make_salary_structure("_Test Salary Structure 2", "Monthly", employee2, company="_Test Company") - if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"): - create_account(account_name="_Test Payroll Payable", - company="_Test Company", parent_account="Current Liabilities - _TC") - frappe.db.set_value("Company", "_Test Company", "default_payroll_payable_account", - "_Test Payroll Payable - _TC") + create_account(account_name="_Test Payroll Payable", + company="_Test Company", parent_account="Current Liabilities - _TC") + + if not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") or \ + frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC": + frappe.db.set_value("Company", "_Test Company", "default_payroll_payable_account", + "_Test Payroll Payable - _TC") + currency=frappe.db.get_value("Company", "_Test Company", "default_currency") + make_salary_structure("_Test Salary Structure 1", "Monthly", employee1, company="_Test Company", currency=currency, test_tax=False) + make_salary_structure("_Test Salary Structure 2", "Monthly", employee2, company="_Test Company", currency=currency, test_tax=False) dates = get_start_end_dates('Monthly', nowdate()) if not frappe.db.get_value("Salary Slip", {"start_date": dates.start_date, "end_date": dates.end_date}): - pe = make_payroll_entry(start_date=dates.start_date, end_date=dates.end_date, - department="cc - _TC", company="_Test Company", payment_account="Cash - _TC", cost_center="Main - _TC") + pe = make_payroll_entry(start_date=dates.start_date, end_date=dates.end_date, payable_account="_Test Payroll Payable - _TC", + currency=frappe.db.get_value("Company", "_Test Company", "default_currency"), department="cc - _TC", company="_Test Company", payment_account="Cash - _TC", cost_center="Main - _TC") je = frappe.db.get_value("Salary Slip", {"payroll_entry": pe.name}, "journal_entry") je_entries = frappe.db.sql(""" select account, cost_center, debit, credit @@ -121,20 +166,28 @@ class TestPayrollEntry(unittest.TestCase): employee_doc.save() salary_structure = "Test Salary Structure for Loan" - make_salary_structure(salary_structure, "Monthly", employee=employee_doc.name, company="_Test Company") + make_salary_structure(salary_structure, "Monthly", employee=employee_doc.name, company="_Test Company", currency=company_doc.default_currency) + + if not frappe.db.exists("Loan Type", "Car Loan"): + create_loan_accounts() + create_loan_type("Car Loan", 500000, 8.4, + is_term_loan=1, + mode_of_payment='Cash', + payment_account='Payment Account - _TC', + loan_account='Loan Account - _TC', + interest_income_account='Interest Income Account - _TC', + penalty_income_account='Penalty Income Account - _TC') loan = create_loan(applicant, "Car Loan", 280000, "Repay Over Number of Periods", 20, posting_date=add_months(nowdate(), -1)) loan.repay_from_salary = 1 loan.submit() make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date=add_months(nowdate(), -1)) - process_loan_interest_accrual_for_term_loans(posting_date=nowdate()) - dates = get_start_end_dates('Monthly', nowdate()) - make_payroll_entry(company="_Test Company", start_date=dates.start_date, - end_date=dates.end_date, branch=branch, cost_center="Main - _TC", payment_account="Cash - _TC") + make_payroll_entry(company="_Test Company", start_date=dates.start_date, payable_account=company_doc.default_payroll_payable_account, + currency=company_doc.default_currency, end_date=dates.end_date, branch=branch, cost_center="Main - _TC", payment_account="Cash - _TC") name = frappe.db.get_value('Salary Slip', {'posting_date': nowdate(), 'employee': applicant}, 'name') @@ -165,6 +218,9 @@ def make_payroll_entry(**args): payroll_entry.payroll_frequency = "Monthly" payroll_entry.branch = args.branch or None payroll_entry.department = args.department or None + payroll_entry.payroll_payable_account = args.payable_account + payroll_entry.currency = args.currency + payroll_entry.exchange_rate = args.exchange_rate or 1 if args.cost_center: payroll_entry.cost_center = args.cost_center @@ -212,3 +268,11 @@ def make_holiday(holiday_list_name): }).insert() return holiday_list_name + +def get_salary_slip(user, period, salary_structure): + salary_slip = make_employee_salary_slip(user, period, salary_structure) + salary_slip.exchange_rate = 70 + salary_slip.calculate_net_pay() + salary_slip.db_update() + + return salary_slip diff --git a/erpnext/payroll/doctype/payroll_entry/test_set_salary_components.js b/erpnext/payroll/doctype/payroll_entry/test_set_salary_components.js index 8ff55151f6f..092cbd89748 100644 --- a/erpnext/payroll/doctype/payroll_entry/test_set_salary_components.js +++ b/erpnext/payroll/doctype/payroll_entry/test_set_salary_components.js @@ -9,45 +9,45 @@ QUnit.test("test: Set Salary Components", function (assert) { () => { var row = frappe.model.add_child(cur_frm.doc, "Salary Component Account", "accounts"); row.company = 'For Testing'; - row.default_account = 'Salary - FT'; + row.account = 'Salary - FT'; }, () => cur_frm.save(), () => frappe.timeout(2), - () => assert.equal(cur_frm.doc.accounts[0].default_account, 'Salary - FT'), + () => assert.equal(cur_frm.doc.accounts[0].account, 'Salary - FT'), () => frappe.set_route('Form', 'Salary Component', 'Basic'), () => { var row = frappe.model.add_child(cur_frm.doc, "Salary Component Account", "accounts"); row.company = 'For Testing'; - row.default_account = 'Salary - FT'; + row.account = 'Salary - FT'; }, () => cur_frm.save(), () => frappe.timeout(2), - () => assert.equal(cur_frm.doc.accounts[0].default_account, 'Salary - FT'), + () => assert.equal(cur_frm.doc.accounts[0].account, 'Salary - FT'), () => frappe.set_route('Form', 'Salary Component', 'Income Tax'), () => { var row = frappe.model.add_child(cur_frm.doc, "Salary Component Account", "accounts"); row.company = 'For Testing'; - row.default_account = 'Salary - FT'; + row.account = 'Salary - FT'; }, () => cur_frm.save(), () => frappe.timeout(2), - () => assert.equal(cur_frm.doc.accounts[0].default_account, 'Salary - FT'), + () => assert.equal(cur_frm.doc.accounts[0].account, 'Salary - FT'), () => frappe.set_route('Form', 'Salary Component', 'Arrear'), () => { var row = frappe.model.add_child(cur_frm.doc, "Salary Component Account", "accounts"); row.company = 'For Testing'; - row.default_account = 'Salary - FT'; + row.account = 'Salary - FT'; }, () => cur_frm.save(), () => frappe.timeout(2), - () => assert.equal(cur_frm.doc.accounts[0].default_account, 'Salary - FT'), + () => assert.equal(cur_frm.doc.accounts[0].account, 'Salary - FT'), () => frappe.set_route('Form', 'Company', 'For Testing'), () => cur_frm.set_value('default_payroll_payable_account', 'Payroll Payable - FT'), diff --git a/erpnext/payroll/doctype/payroll_period/payroll_period.py b/erpnext/payroll/doctype/payroll_period/payroll_period.py index d7893d06572..ef3a6cc0061 100644 --- a/erpnext/payroll/doctype/payroll_period/payroll_period.py +++ b/erpnext/payroll/doctype/payroll_period/payroll_period.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import date_diff, getdate, formatdate, cint, month_diff, flt +from frappe.utils import date_diff, getdate, formatdate, cint, month_diff, flt, add_months from frappe.model.document import Document from erpnext.hr.utils import get_holidays_for_employee @@ -41,7 +41,7 @@ class PayrollPeriod(Document): if overlap_doc: msg = _("A {0} exists between {1} and {2} (").format(self.doctype, formatdate(self.start_date), formatdate(self.end_date)) \ - + """ {1}""".format(self.doctype, overlap_doc[0].name) \ + + """ {1}""".format(self.doctype, overlap_doc[0].name) \ + _(") for {0}").format(self.company) frappe.throw(msg) @@ -88,6 +88,8 @@ def get_period_factor(employee, start_date, end_date, payroll_frequency, payroll period_start = joining_date if relieving_date and getdate(relieving_date) < getdate(period_end): period_end = relieving_date + if month_diff(period_end, start_date) > 1: + start_date = add_months(start_date, - (month_diff(period_end, start_date)+1)) total_sub_periods, remaining_sub_periods = 0.0, 0.0 diff --git a/erpnext/payroll/doctype/payroll_settings/payroll_settings.json b/erpnext/payroll/doctype/payroll_settings/payroll_settings.json index c47caa1227b..54377e94b30 100644 --- a/erpnext/payroll/doctype/payroll_settings/payroll_settings.json +++ b/erpnext/payroll/doctype/payroll_settings/payroll_settings.json @@ -15,6 +15,7 @@ "daily_wages_fraction_for_half_day", "email_salary_slip_to_employee", "encrypt_salary_slips_in_emails", + "show_leave_balances_in_salary_slip", "password_policy" ], "fields": [ @@ -23,58 +24,44 @@ "fieldname": "payroll_based_on", "fieldtype": "Select", "label": "Calculate Payroll Working Days Based On", - "options": "Leave\nAttendance", - "show_days": 1, - "show_seconds": 1 + "options": "Leave\nAttendance" }, { "fieldname": "max_working_hours_against_timesheet", "fieldtype": "Float", - "label": "Max working hours against Timesheet", - "show_days": 1, - "show_seconds": 1 + "label": "Max working hours against Timesheet" }, { "default": "0", "description": "If checked, Total no. of Working Days will include holidays, and this will reduce the value of Salary Per Day", "fieldname": "include_holidays_in_total_working_days", "fieldtype": "Check", - "label": "Include holidays in Total no. of Working Days", - "show_days": 1, - "show_seconds": 1 + "label": "Include holidays in Total no. of Working Days" }, { "default": "0", "description": "If checked, hides and disables Rounded Total field in Salary Slips", "fieldname": "disable_rounded_total", "fieldtype": "Check", - "label": "Disable Rounded Total", - "show_days": 1, - "show_seconds": 1 + "label": "Disable Rounded Total" }, { "fieldname": "column_break_11", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "default": "0.5", "description": "The fraction of daily wages to be paid for half-day attendance", "fieldname": "daily_wages_fraction_for_half_day", "fieldtype": "Float", - "label": "Fraction of Daily Salary for Half Day", - "show_days": 1, - "show_seconds": 1 + "label": "Fraction of Daily Salary for Half Day" }, { "default": "1", "description": "Emails salary slip to employee based on preferred email selected in Employee", "fieldname": "email_salary_slip_to_employee", "fieldtype": "Check", - "label": "Email Salary Slip to Employee", - "show_days": 1, - "show_seconds": 1 + "label": "Email Salary Slip to Employee" }, { "default": "0", @@ -82,9 +69,7 @@ "description": "The salary slip emailed to the employee will be password protected, the password will be generated based on the password policy.", "fieldname": "encrypt_salary_slips_in_emails", "fieldtype": "Check", - "label": "Encrypt Salary Slips in Emails", - "show_days": 1, - "show_seconds": 1 + "label": "Encrypt Salary Slips in Emails" }, { "depends_on": "eval: doc.encrypt_salary_slips_in_emails == 1", @@ -92,24 +77,27 @@ "fieldname": "password_policy", "fieldtype": "Data", "in_list_view": 1, - "label": "Password Policy", - "show_days": 1, - "show_seconds": 1 + "label": "Password Policy" }, { "depends_on": "eval:doc.payroll_based_on == 'Attendance'", "fieldname": "consider_unmarked_attendance_as", "fieldtype": "Select", "label": "Consider Unmarked Attendance As", - "options": "Present\nAbsent", - "show_days": 1, - "show_seconds": 1 + "options": "Present\nAbsent" + }, + { + "default": "0", + "fieldname": "show_leave_balances_in_salary_slip", + "fieldtype": "Check", + "label": "Show Leave Balances in Salary Slip" } ], "icon": "fa fa-cog", + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-06-22 17:00:58.408030", + "modified": "2021-03-03 17:49:59.579723", "modified_by": "Administrator", "module": "Payroll", "name": "Payroll Settings", @@ -126,5 +114,6 @@ } ], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/payroll/doctype/retention_bonus/retention_bonus.js b/erpnext/payroll/doctype/retention_bonus/retention_bonus.js index 64e726db857..f8bb40a9cb8 100644 --- a/erpnext/payroll/doctype/retention_bonus/retention_bonus.js +++ b/erpnext/payroll/doctype/retention_bonus/retention_bonus.js @@ -4,9 +4,13 @@ frappe.ui.form.on('Retention Bonus', { setup: function(frm) { frm.set_query("employee", function() { + if (!frm.doc.company) { + frappe.msgprint(__("Please Select Company First")); + } return { filters: { - "status": "Active" + "status": "Active", + "company": frm.doc.company } }; }); @@ -18,5 +22,22 @@ frappe.ui.form.on('Retention Bonus', { } }; }); + }, + + employee: function(frm) { + if (frm.doc.employee) { + frappe.call({ + method: "erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment.get_employee_currency", + args: { + employee: frm.doc.employee, + }, + callback: function(r) { + if (r.message) { + frm.set_value('currency', r.message); + frm.refresh_fields(); + } + } + }); + } } }); diff --git a/erpnext/payroll/doctype/retention_bonus/retention_bonus.json b/erpnext/payroll/doctype/retention_bonus/retention_bonus.json index da884c2f289..66472300788 100644 --- a/erpnext/payroll/doctype/retention_bonus/retention_bonus.json +++ b/erpnext/payroll/doctype/retention_bonus/retention_bonus.json @@ -17,7 +17,8 @@ "column_break_6", "employee_name", "department", - "date_of_joining" + "date_of_joining", + "currency" ], "fields": [ { @@ -46,6 +47,7 @@ "fieldname": "bonus_amount", "fieldtype": "Currency", "label": "Bonus Amount", + "options": "currency", "reqd": 1 }, { @@ -89,11 +91,22 @@ "label": "Salary Component", "options": "Salary Component", "reqd": 1 + }, + { + "default": "Company:company:default_currency", + "depends_on": "eval:(doc.docstatus==1 || doc.employee)", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "read_only": 1, + "reqd": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-06-22 22:42:05.251951", + "modified": "2020-10-20 17:27:47.003134", "modified_by": "Administrator", "module": "Payroll", "name": "Retention Bonus", @@ -151,7 +164,6 @@ "share": 1 } ], - "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 diff --git a/erpnext/payroll/doctype/salary_component/salary_component.js b/erpnext/payroll/doctype/salary_component/salary_component.js index c455eb3303b..dbf75140ac1 100644 --- a/erpnext/payroll/doctype/salary_component/salary_component.js +++ b/erpnext/payroll/doctype/salary_component/salary_component.js @@ -3,7 +3,7 @@ frappe.ui.form.on('Salary Component', { setup: function(frm) { - frm.set_query("default_account", "accounts", function(doc, cdt, cdn) { + frm.set_query("account", "accounts", function(doc, cdt, cdn) { var d = locals[cdt][cdn]; return { filters: { diff --git a/erpnext/payroll/doctype/salary_component/salary_component.json b/erpnext/payroll/doctype/salary_component/salary_component.json index 225b0482937..c97e45cd53a 100644 --- a/erpnext/payroll/doctype/salary_component/salary_component.json +++ b/erpnext/payroll/doctype/salary_component/salary_component.json @@ -217,7 +217,7 @@ "fieldname": "help", "fieldtype": "HTML", "label": "Help", - "options": "

    Help

    \n\n

    Notes:

    \n\n
      \n
    1. Use field base for using base salary of the Employee
    2. \n
    3. Use Salary Component abbreviations in conditions and formulas. BS = Basic Salary
    4. \n
    5. Use field name for employee details in conditions and formulas. Employment Type = employment_typeBranch = branch
    6. \n
    7. Use field name from Salary Slip in conditions and formulas. Payment Days = payment_daysLeave without pay = leave_without_pay
    8. \n
    9. Direct Amount can also be entered based on Condtion. See example 3
    \n\n

    Examples

    \n
      \n
    1. Calculating Basic Salary based on base\n
      Condition: base < 10000
      \n
      Formula: base * .2
    2. \n
    3. Calculating HRA based on Basic SalaryBS \n
      Condition: BS > 2000
      \n
      Formula: BS * .1
    4. \n
    5. Calculating TDS based on Employment Typeemployment_type \n
      Condition: employment_type==\"Intern\"
      \n
      Amount: 1000
    6. \n
    " + "options": "

    Help

    \n\n

    Notes:

    \n\n
      \n
    1. Use field base for using base salary of the Employee
    2. \n
    3. Use Salary Component abbreviations in conditions and formulas. BS = Basic Salary
    4. \n
    5. Use field name for employee details in conditions and formulas. Employment Type = employment_typeBranch = branch
    6. \n
    7. Use field name from Salary Slip in conditions and formulas. Payment Days = payment_daysLeave without pay = leave_without_pay
    8. \n
    9. Direct Amount can also be entered based on Condition. See example 3
    \n\n

    Examples

    \n
      \n
    1. Calculating Basic Salary based on base\n
      Condition: base < 10000
      \n
      Formula: base * .2
    2. \n
    3. Calculating HRA based on Basic SalaryBS \n
      Condition: BS > 2000
      \n
      Formula: BS * .1
    4. \n
    5. Calculating TDS based on Employment Typeemployment_type \n
      Condition: employment_type==\"Intern\"
      \n
      Amount: 1000
    6. \n
    " }, { "default": "0", @@ -238,14 +238,13 @@ "depends_on": "eval:doc.type == \"Deduction\"", "fieldname": "is_income_tax_component", "fieldtype": "Check", - "label": "Is Income Tax Component", - "show_days": 1, - "show_seconds": 1 + "label": "Is Income Tax Component" } ], "icon": "fa fa-flag", + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-06-22 15:39:20.826565", + "modified": "2020-10-07 20:38:33.795853", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Component", diff --git a/erpnext/payroll/doctype/salary_detail/salary_detail.json b/erpnext/payroll/doctype/salary_detail/salary_detail.json index cc87caeae1a..393f647cc88 100644 --- a/erpnext/payroll/doctype/salary_detail/salary_detail.json +++ b/erpnext/payroll/doctype/salary_detail/salary_detail.json @@ -9,6 +9,7 @@ "abbr", "column_break_3", "amount", + "year_to_date", "section_break_5", "additional_salary", "statistical_component", @@ -117,7 +118,7 @@ "depends_on": "eval:doc.is_flexible_benefit != 1", "fieldname": "section_break_2", "fieldtype": "Section Break", - "label": "Condtion and formula" + "label": "Condition and formula" }, { "allow_on_submit": 1, @@ -147,7 +148,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Amount", - "options": "Company:company:default_currency" + "options": "currency" }, { "default": "0", @@ -160,7 +161,7 @@ "fieldname": "default_amount", "fieldtype": "Currency", "label": "Default Amount", - "options": "Company:company:default_currency", + "options": "currency", "print_hide": 1 }, { @@ -169,6 +170,7 @@ "hidden": 1, "label": "Additional Amount", "no_copy": 1, + "options": "currency", "print_hide": 1, "read_only": 1 }, @@ -177,6 +179,7 @@ "fieldname": "tax_on_flexible_benefit", "fieldtype": "Currency", "label": "Tax on flexible benefit", + "options": "currency", "read_only": 1 }, { @@ -184,6 +187,7 @@ "fieldname": "tax_on_additional_salary", "fieldtype": "Currency", "label": "Tax on additional salary", + "options": "currency", "read_only": 1 }, { @@ -206,38 +210,36 @@ "collapsible": 1, "fieldname": "section_break_5", "fieldtype": "Section Break", - "label": "Component properties and references ", - "show_days": 1, - "show_seconds": 1 + "label": "Component properties and references " }, { "fieldname": "column_break_11", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "fieldname": "section_break_19", - "fieldtype": "Section Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Section Break" }, { "fieldname": "column_break_18", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "fieldname": "column_break_24", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" + }, + { + "description": "Total salary booked against this component for this employee from the beginning of the year (payroll period or fiscal year) up to the current salary slip's end date.", + "fieldname": "year_to_date", + "fieldtype": "Currency", + "label": "Year To Date", + "options": "currency", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2020-07-01 12:13:41.956495", + "modified": "2021-01-14 13:39:15.847158", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Detail", diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.js b/erpnext/payroll/doctype/salary_slip/salary_slip.js index 7b69dbe8d6d..d5278393a15 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.js +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.js @@ -13,12 +13,12 @@ frappe.ui.form.on("Salary Slip", { ]; }); - frm.fields_dict["timesheets"].grid.get_field("time_sheet").get_query = function(){ + frm.fields_dict["timesheets"].grid.get_field("time_sheet").get_query = function() { return { filters: { employee: frm.doc.employee } - } + }; }; frm.set_query("salary_component", "earnings", function() { @@ -26,7 +26,7 @@ frappe.ui.form.on("Salary Slip", { filters: { type: "earning" } - } + }; }); frm.set_query("salary_component", "deductions", function() { @@ -34,18 +34,18 @@ frappe.ui.form.on("Salary Slip", { filters: { type: "deduction" } - } + }; }); frm.set_query("employee", function() { - return{ + return { query: "erpnext.controllers.queries.employee_query" - } + }; }); }, - start_date: function(frm){ - if(frm.doc.start_date){ + start_date: function(frm) { + if (frm.doc.start_date) { frm.trigger("set_end_date"); } }, @@ -54,7 +54,7 @@ frappe.ui.form.on("Salary Slip", { frm.events.get_emp_and_working_day_details(frm); }, - set_end_date: function(frm){ + set_end_date: function(frm) { frappe.call({ method: 'erpnext.payroll.doctype.payroll_entry.payroll_entry.get_end_date', args: { @@ -66,22 +66,95 @@ frappe.ui.form.on("Salary Slip", { frm.set_value('end_date', r.message.end_date); } } - }) + }); }, company: function(frm) { var company = locals[':Company'][frm.doc.company]; - if(!frm.doc.letter_head && company.default_letter_head) { + if (!frm.doc.letter_head && company.default_letter_head) { frm.set_value('letter_head', company.default_letter_head); } }, + currency: function(frm) { + frm.trigger("set_dynamic_labels"); + }, + + set_dynamic_labels: function(frm) { + var company_currency = frm.doc.company? erpnext.get_currency(frm.doc.company): frappe.defaults.get_default("currency"); + if (frm.doc.employee && frm.doc.currency) { + frappe.run_serially([ + () => frm.events.set_exchange_rate(frm, company_currency), + () => frm.events.change_form_labels(frm, company_currency), + () => frm.events.change_grid_labels(frm), + () => frm.refresh_fields() + ]); + } + }, + + set_exchange_rate: function(frm, company_currency) { + if (frm.doc.currency) { + var from_currency = frm.doc.currency; + if (from_currency != company_currency) { + frm.events.hide_loan_section(frm); + frappe.call({ + method: "erpnext.setup.utils.get_exchange_rate", + args: { + from_currency: from_currency, + to_currency: company_currency, + }, + callback: function(r) { + frm.set_value("exchange_rate", flt(r.message)); + frm.set_df_property("exchange_rate", "hidden", 0); + frm.set_df_property("exchange_rate", "description", "1 " + frm.doc.currency + + " = [?] " + company_currency); + } + }); + } else { + frm.set_value("exchange_rate", 1.0); + frm.set_df_property("exchange_rate", "hidden", 1); + frm.set_df_property("exchange_rate", "description", ""); + } + } + }, + + exchange_rate: function(frm) { + set_totals(frm); + }, + + hide_loan_section: function(frm) { + frm.set_df_property('section_break_43', 'hidden', 1); + }, + + change_form_labels: function(frm, company_currency) { + frm.set_currency_labels(["base_hour_rate", "base_gross_pay", "base_total_deduction", + "base_net_pay", "base_rounded_total", "base_total_in_words", "base_year_to_date", "base_month_to_date"], + company_currency); + + frm.set_currency_labels(["hour_rate", "gross_pay", "total_deduction", "net_pay", "rounded_total", "total_in_words", "year_to_date", "month_to_date"], + frm.doc.currency); + + // toggle fields + frm.toggle_display(["exchange_rate", "base_hour_rate", "base_gross_pay", "base_total_deduction", + "base_net_pay", "base_rounded_total", "base_total_in_words", "base_year_to_date", "base_month_to_date"], + frm.doc.currency != company_currency); + }, + + change_grid_labels: function(frm) { + let fields = ["amount", "year_to_date", "default_amount", "additional_amount", "tax_on_flexible_benefit", + "tax_on_additional_salary"]; + + frm.set_currency_labels(fields, frm.doc.currency, "earnings"); + frm.set_currency_labels(fields, frm.doc.currency, "deductions"); + }, + refresh: function(frm) { - frm.trigger("toggle_fields") + frm.trigger("toggle_fields"); var salary_detail_fields = ["formula", "abbr", "statistical_component", "variable_based_on_taxable_salary"]; - cur_frm.fields_dict['earnings'].grid.set_column_disp(salary_detail_fields,false); - cur_frm.fields_dict['deductions'].grid.set_column_disp(salary_detail_fields,false); + frm.fields_dict['earnings'].grid.set_column_disp(salary_detail_fields, false); + frm.fields_dict['deductions'].grid.set_column_disp(salary_detail_fields, false); + frm.trigger("set_dynamic_labels"); }, salary_slip_based_on_timesheet: function(frm) { @@ -98,12 +171,12 @@ frappe.ui.form.on("Salary Slip", { frm.events.get_emp_and_working_day_details(frm); }, - leave_without_pay: function(frm){ + leave_without_pay: function(frm) { if (frm.doc.employee && frm.doc.start_date && frm.doc.end_date) { return frappe.call({ method: 'process_salary_based_on_working_days', doc: frm.doc, - callback: function(r, rt) { + callback: function() { frm.refresh(); } }); @@ -118,51 +191,96 @@ frappe.ui.form.on("Salary Slip", { }, get_emp_and_working_day_details: function(frm) { - return frappe.call({ - method: 'get_emp_and_working_day_details', - doc: frm.doc, - callback: function(r, rt) { - frm.refresh(); - if (r.message){ - frm.fields_dict.absent_days.set_description("Unmarked Days is treated as "+ r.message +". You can can change this in " + frappe.utils.get_form_link("Payroll Settings", "Payroll Settings", true)); + if (frm.doc.employee) { + return frappe.call({ + method: 'get_emp_and_working_day_details', + doc: frm.doc, + callback: function(r) { + if (r.message[1] !== "Leave" && r.message[0]) { + frm.fields_dict.absent_days.set_description(__("Unmarked Days is treated as {0}. You can can change this in {1}", [r.message, frappe.utils.get_form_link("Payroll Settings", "Payroll Settings", true)])); + } + frm.refresh(); } - } - }); + }); + } } }); frappe.ui.form.on('Salary Slip Timesheet', { - time_sheet: function(frm, dt, dn) { - total_work_hours(frm, dt, dn); + time_sheet: function(frm) { + set_totals(frm); }, - timesheets_remove: function(frm, dt, dn) { - total_work_hours(frm, dt, dn); + timesheets_remove: function(frm) { + set_totals(frm); } }); -// calculate total working hours, earnings based on hourly wages and totals -var total_work_hours = function(frm, dt, dn) { - var total_working_hours = 0.0; - $.each(frm.doc["timesheets"] || [], function(i, timesheet) { - total_working_hours += timesheet.working_hours; - }); - frm.set_value('total_working_hours', total_working_hours); +var set_totals = function(frm) { + if (frm.doc.docstatus === 0) { + if (frm.doc.earnings || frm.doc.deductions) { + frappe.call({ + method: "set_totals", + doc: frm.doc, + callback: function() { + frm.refresh_fields(); + } + }); + } + } +}; - var wages_amount = frm.doc.total_working_hours * frm.doc.hour_rate; +frappe.ui.form.on('Salary Detail', { + amount: function(frm) { + set_totals(frm); + }, - frappe.db.get_value('Salary Structure', {'name': frm.doc.salary_structure}, 'salary_component', (r) => { - var gross_pay = 0.0; - $.each(frm.doc["earnings"], function(i, earning) { - if (earning.salary_component == r.salary_component) { - earning.amount = wages_amount; - frm.refresh_fields('earnings'); - } - gross_pay += earning.amount; - }); - frm.set_value('gross_pay', gross_pay); + earnings_remove: function(frm) { + set_totals(frm); + }, - frm.doc.net_pay = flt(frm.doc.gross_pay) - flt(frm.doc.total_deduction); - frm.doc.rounded_total = Math.round(frm.doc.net_pay); - refresh_many(['net_pay', 'rounded_total']); - }); -} + deductions_remove: function(frm) { + set_totals(frm); + }, + + salary_component: function(frm, cdt, cdn) { + var child = locals[cdt][cdn]; + if (child.salary_component) { + frappe.call({ + method: "frappe.client.get", + args: { + doctype: "Salary Component", + name: child.salary_component + }, + callback: function(data) { + if (data.message) { + var result = data.message; + frappe.model.set_value(cdt, cdn, 'condition', result.condition); + frappe.model.set_value(cdt, cdn, 'amount_based_on_formula', result.amount_based_on_formula); + if (result.amount_based_on_formula === 1) { + frappe.model.set_value(cdt, cdn, 'formula', result.formula); + } else { + frappe.model.set_value(cdt, cdn, 'amount', result.amount); + } + frappe.model.set_value(cdt, cdn, 'statistical_component', result.statistical_component); + frappe.model.set_value(cdt, cdn, 'depends_on_payment_days', result.depends_on_payment_days); + frappe.model.set_value(cdt, cdn, 'do_not_include_in_total', result.do_not_include_in_total); + frappe.model.set_value(cdt, cdn, 'variable_based_on_taxable_salary', result.variable_based_on_taxable_salary); + frappe.model.set_value(cdt, cdn, 'is_tax_applicable', result.is_tax_applicable); + frappe.model.set_value(cdt, cdn, 'is_flexible_benefit', result.is_flexible_benefit); + refresh_field("earnings"); + refresh_field("deductions"); + } + } + }); + } + }, + + amount_based_on_formula: function(frm, cdt, cdn) { + var child = locals[cdt][cdn]; + if (child.amount_based_on_formula === 1) { + frappe.model.set_value(cdt, cdn, 'amount', null); + } else { + frappe.model.set_value(cdt, cdn, 'formula', null); + } + } +}); diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.json b/erpnext/payroll/doctype/salary_slip/salary_slip.json index 619c45fa4a1..66883682625 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.json +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.json @@ -18,6 +18,8 @@ "journal_entry", "payroll_entry", "company", + "currency", + "exchange_rate", "letter_head", "section_break_10", "start_date", @@ -38,6 +40,7 @@ "column_break_20", "total_working_hours", "hour_rate", + "base_hour_rate", "section_break_26", "bank_name", "bank_account_no", @@ -52,8 +55,10 @@ "deductions", "totals", "gross_pay", + "base_gross_pay", "column_break_25", "total_deduction", + "base_total_deduction", "loan_repayment", "loans", "section_break_43", @@ -63,10 +68,21 @@ "total_loan_repayment", "net_pay_info", "net_pay", + "base_net_pay", + "year_to_date", + "base_year_to_date", "column_break_53", "rounded_total", + "base_rounded_total", + "month_to_date", + "base_month_to_date", "section_break_55", "total_in_words", + "column_break_69", + "base_total_in_words", + "leave_details_section", + "leave_details", + "section_break_75", "amended_from" ], "fields": [ @@ -205,9 +221,13 @@ { "fieldname": "salary_structure", "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, "label": "Salary Structure", "options": "Salary Structure", - "read_only": 1 + "read_only": 1, + "reqd": 1, + "search_index": 1 }, { "depends_on": "eval:(!doc.salary_slip_based_on_timesheet)", @@ -265,7 +285,7 @@ "fieldname": "hour_rate", "fieldtype": "Currency", "label": "Hour Rate", - "options": "Company:company:default_currency", + "options": "currency", "print_hide_if_no_value": 1 }, { @@ -347,24 +367,13 @@ "fieldname": "gross_pay", "fieldtype": "Currency", "label": "Gross Pay", - "oldfieldname": "gross_pay", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency", + "options": "currency", "read_only": 1 }, { "fieldname": "column_break_25", "fieldtype": "Column Break" }, - { - "fieldname": "total_deduction", - "fieldtype": "Currency", - "label": "Total Deduction", - "oldfieldname": "total_deduction", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency", - "read_only": 1 - }, { "depends_on": "total_loan_repayment", "fieldname": "loan_repayment", @@ -379,6 +388,7 @@ "print_hide": 1 }, { + "depends_on": "eval:doc.docstatus != 0", "fieldname": "section_break_43", "fieldtype": "Section Break" }, @@ -416,13 +426,10 @@ "label": "net pay info" }, { - "description": "Gross Pay - Total Deduction - Loan Repayment", "fieldname": "net_pay", "fieldtype": "Currency", "label": "Net Pay", - "oldfieldname": "net_pay", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency", + "options": "currency", "read_only": 1 }, { @@ -434,22 +441,13 @@ "fieldname": "rounded_total", "fieldtype": "Currency", "label": "Rounded Total", - "options": "Company:company:default_currency", + "options": "currency", "read_only": 1 }, { "fieldname": "section_break_55", "fieldtype": "Section Break" }, - { - "description": "Net Pay (in words) will be visible once you save the Salary Slip.", - "fieldname": "total_in_words", - "fieldtype": "Data", - "label": "Total in words", - "oldfieldname": "net_pay_in_words", - "oldfieldtype": "Data", - "read_only": 1 - }, { "fieldname": "amended_from", "fieldtype": "Link", @@ -500,13 +498,141 @@ { "fieldname": "column_break_18", "fieldtype": "Column Break" + }, + { + "default": "Company:company:default_currency", + "depends_on": "eval:(doc.docstatus==1 || doc.salary_structure)", + "fetch_from": "salary_structure.currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "total_deduction", + "fieldtype": "Currency", + "label": "Total Deduction", + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "total_in_words", + "fieldtype": "Data", + "label": "Total in words", + "length": 240, + "read_only": 1 + }, + { + "fieldname": "section_break_75", + "fieldtype": "Section Break" + }, + { + "fieldname": "base_hour_rate", + "fieldtype": "Currency", + "label": "Hour Rate (Company Currency)", + "options": "Company:company:default_currency", + "print_hide_if_no_value": 1 + }, + { + "fieldname": "base_gross_pay", + "fieldtype": "Currency", + "label": "Gross Pay (Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "default": "1.0", + "fieldname": "exchange_rate", + "fieldtype": "Float", + "hidden": 1, + "label": "Exchange Rate", + "print_hide": 1, + "reqd": 1 + }, + { + "fieldname": "base_total_deduction", + "fieldtype": "Currency", + "label": "Total Deduction (Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "base_net_pay", + "fieldtype": "Currency", + "label": "Net Pay (Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "bold": 1, + "fieldname": "base_rounded_total", + "fieldtype": "Currency", + "label": "Rounded Total (Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "base_total_in_words", + "fieldtype": "Data", + "label": "Total in words (Company Currency)", + "length": 240, + "read_only": 1 + }, + { + "fieldname": "column_break_69", + "fieldtype": "Column Break" + }, + { + "description": "Total salary booked for this employee from the beginning of the year (payroll period or fiscal year) up to the current salary slip's end date.", + "fieldname": "year_to_date", + "fieldtype": "Currency", + "label": "Year To Date", + "options": "currency", + "read_only": 1 + }, + { + "description": "Total salary booked for this employee from the beginning of the month up to the current salary slip's end date.", + "fieldname": "month_to_date", + "fieldtype": "Currency", + "label": "Month To Date", + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "base_year_to_date", + "fieldtype": "Currency", + "label": "Year To Date(Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "base_month_to_date", + "fieldtype": "Currency", + "label": "Month To Date(Company Currency)", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "leave_details_section", + "fieldtype": "Section Break", + "label": "Leave Details" + }, + { + "fieldname": "leave_details", + "fieldtype": "Table", + "label": "Leave Details", + "options": "Salary Slip Leave", + "read_only": 1 } ], "icon": "fa fa-file-text", "idx": 9, "is_submittable": 1, "links": [], - "modified": "2020-08-11 17:37:54.274384", + "modified": "2021-02-19 11:48:05.383945", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Slip", diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index 4ccf56435dd..a04a6358078 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe, erpnext import datetime, math -from frappe.utils import add_days, cint, cstr, flt, getdate, rounded, date_diff, money_in_words, formatdate +from frappe.utils import add_days, cint, cstr, flt, getdate, rounded, date_diff, money_in_words, formatdate, get_first_day from frappe.model.naming import make_autoname from frappe import msgprint, _ @@ -13,11 +13,13 @@ from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_start_end_da from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from erpnext.utilities.transaction_base import TransactionBase from frappe.utils.background_jobs import enqueue -from erpnext.payroll.doctype.additional_salary.additional_salary import get_additional_salary_component +from erpnext.payroll.doctype.additional_salary.additional_salary import get_additional_salaries from erpnext.payroll.doctype.payroll_period.payroll_period import get_period_factor, get_payroll_period from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import get_benefit_component_amount from erpnext.payroll.doctype.employee_benefit_claim.employee_benefit_claim import get_benefit_claim_amount, get_last_payroll_period_benefits from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts, create_repayment_entry +from erpnext.accounts.utils import get_fiscal_year +from six import iteritems class SalarySlip(TransactionBase): def __init__(self, *args, **kwargs): @@ -49,10 +51,10 @@ class SalarySlip(TransactionBase): self.get_working_days_details(lwp = self.leave_without_pay) self.calculate_net_pay() - - company_currency = erpnext.get_company_currency(self.company) - total = self.net_pay if self.is_rounding_total_disabled() else self.rounded_total - self.total_in_words = money_in_words(total, company_currency) + self.compute_year_to_date() + self.compute_month_to_date() + self.compute_component_wise_year_to_date() + self.add_leave_balances() if frappe.db.get_single_value("Payroll Settings", "max_working_hours_against_timesheet"): max_working_hours = frappe.db.get_single_value("Payroll Settings", "max_working_hours_against_timesheet") @@ -60,6 +62,14 @@ class SalarySlip(TransactionBase): frappe.msgprint(_("Total working hours should not be greater than max working hours {0}"). format(max_working_hours), alert=True) + def set_net_total_in_words(self): + doc_currency = self.currency + company_currency = erpnext.get_company_currency(self.company) + total = self.net_pay if self.is_rounding_total_disabled() else self.rounded_total + base_total = self.base_net_pay if self.is_rounding_total_disabled() else self.base_rounded_total + self.total_in_words = money_in_words(total, doc_currency) + self.base_total_in_words = money_in_words(base_total, company_currency) + def on_submit(self): if self.net_pay < 0: frappe.throw(_("Net Pay cannot be less than 0")) @@ -70,9 +80,26 @@ class SalarySlip(TransactionBase): if (frappe.db.get_single_value("Payroll Settings", "email_salary_slip_to_employee")) and not frappe.flags.via_payroll_entry: self.email_salary_slip() + self.update_payment_status_for_gratuity() + + def update_payment_status_for_gratuity(self): + add_salary = frappe.db.get_all("Additional Salary", + filters = { + "payroll_date": ("BETWEEN", [self.start_date, self.end_date]), + "employee": self.employee, + "ref_doctype": "Gratuity", + "docstatus": 1, + }, fields = ["ref_docname", "name"], limit=1) + + if len(add_salary): + status = "Paid" if self.docstatus == 1 else "Unpaid" + if add_salary[0].name in [data.additional_salary for data in self.earnings]: + frappe.db.set_value("Gratuity", add_salary.ref_docname, "status", status) + def on_cancel(self): self.set_status() self.update_status() + self.update_payment_status_for_gratuity() self.cancel_loan_repayment_entry() def on_trash(self): @@ -136,8 +163,8 @@ class SalarySlip(TransactionBase): self.salary_slip_based_on_timesheet = self._salary_structure_doc.salary_slip_based_on_timesheet or 0 self.set_time_sheet() self.pull_sal_struct() - consider_unmarked_attendance_as = frappe.db.get_value("Payroll Settings", None, "consider_unmarked_attendance_as") or "Present" - return consider_unmarked_attendance_as + ps = frappe.db.get_value("Payroll Settings", None, ["payroll_based_on","consider_unmarked_attendance_as"], as_dict=1) + return [ps.payroll_based_on, ps.consider_unmarked_attendance_as] def set_time_sheet(self): if self.salary_slip_based_on_timesheet: @@ -182,6 +209,7 @@ class SalarySlip(TransactionBase): if self.salary_slip_based_on_timesheet: self.salary_structure = self._salary_structure_doc.name self.hour_rate = self._salary_structure_doc.hour_rate + self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate) self.total_working_hours = sum([d.working_hours or 0.0 for d in self.timesheets]) or 0.0 wages_amount = self.hour_rate * self.total_working_hours @@ -210,10 +238,10 @@ class SalarySlip(TransactionBase): frappe.throw(_("Please set Payroll based on in Payroll settings")) if payroll_based_on == "Attendance": - actual_lwp, absent = self.calculate_lwp_and_absent_days_based_on_attendance(holidays) + actual_lwp, absent = self.calculate_lwp_ppl_and_absent_days_based_on_attendance(holidays) self.absent_days = absent else: - actual_lwp = self.calculate_lwp_based_on_leave_application(holidays, working_days) + actual_lwp = self.calculate_lwp_or_ppl_based_on_leave_application(holidays, working_days) if not lwp: lwp = actual_lwp @@ -240,7 +268,6 @@ class SalarySlip(TransactionBase): self.absent_days += unmarked_days #will be treated as absent self.payment_days -= unmarked_days if include_holidays_in_total_working_days: - self.absent_days -= len(holidays) for holiday in holidays: if not frappe.db.exists("Attendance", {"employee": self.employee, "attendance_date": holiday, "docstatus": 1 }): self.payment_days += 1 @@ -301,7 +328,7 @@ class SalarySlip(TransactionBase): return holidays - def calculate_lwp_based_on_leave_application(self, holidays, working_days): + def calculate_lwp_or_ppl_based_on_leave_application(self, holidays, working_days): lwp = 0 holidays = "','".join(holidays) daily_wages_fraction_for_half_day = \ @@ -312,10 +339,12 @@ class SalarySlip(TransactionBase): leave = frappe.db.sql(""" SELECT t1.name, CASE WHEN (t1.half_day_date = %(dt)s or t1.to_date = t1.from_date) - THEN t1.half_day else 0 END + THEN t1.half_day else 0 END, + t2.is_ppl, + t2.fraction_of_daily_salary_per_leave FROM `tabLeave Application` t1, `tabLeave Type` t2 WHERE t2.name = t1.leave_type - AND t2.is_lwp = 1 + AND (t2.is_lwp = 1 or t2.is_ppl = 1) AND t1.docstatus = 1 AND t1.employee = %(employee)s AND ifnull(t1.salary_slip, '') = '' @@ -328,19 +357,35 @@ class SalarySlip(TransactionBase): """.format(holidays), {"employee": self.employee, "dt": dt}) if leave: + equivalent_lwp_count = 0 is_half_day_leave = cint(leave[0][1]) - lwp += (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1 + is_partially_paid_leave = cint(leave[0][2]) + fraction_of_daily_salary_per_leave = flt(leave[0][3]) + + equivalent_lwp_count = (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1 + + if is_partially_paid_leave: + equivalent_lwp_count *= fraction_of_daily_salary_per_leave if fraction_of_daily_salary_per_leave else 1 + + lwp += equivalent_lwp_count return lwp - def calculate_lwp_and_absent_days_based_on_attendance(self, holidays): + def calculate_lwp_ppl_and_absent_days_based_on_attendance(self, holidays): lwp = 0 absent = 0 daily_wages_fraction_for_half_day = \ flt(frappe.db.get_value("Payroll Settings", None, "daily_wages_fraction_for_half_day")) or 0.5 - lwp_leave_types = dict(frappe.get_all("Leave Type", {"is_lwp": 1}, ["name", "include_holiday"], as_list=1)) + leave_types = frappe.get_all("Leave Type", + or_filters=[["is_ppl", "=", 1], ["is_lwp", "=", 1]], + fields =["name", "is_lwp", "is_ppl", "fraction_of_daily_salary_per_leave", "include_holiday"]) + + leave_type_map = {} + for leave_type in leave_types: + leave_type_map[leave_type.name] = leave_type + attendances = frappe.db.sql(''' SELECT attendance_date, status, leave_type FROM `tabAttendance` @@ -352,21 +397,30 @@ class SalarySlip(TransactionBase): ''', values=(self.employee, self.start_date, self.end_date), as_dict=1) for d in attendances: - if d.status in ('Half Day', 'On Leave') and d.leave_type and d.leave_type not in lwp_leave_types: + if d.status in ('Half Day', 'On Leave') and d.leave_type and d.leave_type not in leave_type_map.keys(): continue if formatdate(d.attendance_date, "yyyy-mm-dd") in holidays: if d.status == "Absent" or \ - (d.leave_type and d.leave_type in lwp_leave_types and not lwp_leave_types[d.leave_type]): + (d.leave_type and d.leave_type in leave_type_map.keys() and not leave_type_map[d.leave_type]['include_holiday']): continue + if d.leave_type: + fraction_of_daily_salary_per_leave = leave_type_map[d.leave_type]["fraction_of_daily_salary_per_leave"] + if d.status == "Half Day": - lwp += (1 - daily_wages_fraction_for_half_day) - elif d.status == "On Leave" and d.leave_type in lwp_leave_types: - lwp += 1 + equivalent_lwp = (1 - daily_wages_fraction_for_half_day) + + if d.leave_type in leave_type_map.keys() and leave_type_map[d.leave_type]["is_ppl"]: + equivalent_lwp *= fraction_of_daily_salary_per_leave if fraction_of_daily_salary_per_leave else 1 + lwp += equivalent_lwp + elif d.status == "On Leave" and d.leave_type and d.leave_type in leave_type_map.keys(): + equivalent_lwp = 1 + if leave_type_map[d.leave_type]["is_ppl"]: + equivalent_lwp *= fraction_of_daily_salary_per_leave if fraction_of_daily_salary_per_leave else 1 + lwp += equivalent_lwp elif d.status == "Absent": absent += 1 - return lwp, absent def add_earning_for_hourly_wages(self, doc, salary_component, amount): @@ -390,16 +444,26 @@ class SalarySlip(TransactionBase): def calculate_net_pay(self): if self.salary_structure: self.calculate_component_amounts("earnings") - self.gross_pay = self.get_component_totals("earnings") + self.gross_pay = self.get_component_totals("earnings", depends_on_payment_days=1) + self.base_gross_pay = flt(flt(self.gross_pay) * flt(self.exchange_rate), self.precision('base_gross_pay')) if self.salary_structure: self.calculate_component_amounts("deductions") - self.total_deduction = self.get_component_totals("deductions") self.set_loan_repayment() + self.set_component_amounts_based_on_payment_days() + self.set_net_pay() + def set_net_pay(self): + self.total_deduction = self.get_component_totals("deductions") + self.base_total_deduction = flt(flt(self.total_deduction) * flt(self.exchange_rate), self.precision('base_total_deduction')) self.net_pay = flt(self.gross_pay) - (flt(self.total_deduction) + flt(self.total_loan_repayment)) self.rounded_total = rounded(self.net_pay) + self.base_net_pay = flt(flt(self.net_pay) * flt(self.exchange_rate), self.precision('base_net_pay')) + self.base_rounded_total = flt(rounded(self.base_net_pay), self.precision('base_net_pay')) + if self.hour_rate: + self.base_hour_rate = flt(flt(self.hour_rate) * flt(self.exchange_rate), self.precision('base_hour_rate')) + self.set_net_total_in_words() def calculate_component_amounts(self, component_type): if not getattr(self, '_salary_structure_doc', None): @@ -414,8 +478,6 @@ class SalarySlip(TransactionBase): else: self.add_tax_components(payroll_period) - self.set_component_amounts_based_on_payment_days(component_type) - def add_structure_components(self, component_type): data = self.get_data_for_eval() for struct_row in self._salary_structure_doc.get(component_type): @@ -461,7 +523,8 @@ class SalarySlip(TransactionBase): return amount except NameError as err: - frappe.throw(_("Name error: {0}").format(err)) + frappe.throw(_("{0}
    This error can be due to missing or deleted field.").format(err), + title=_("Name error")) except SyntaxError as err: frappe.throw(_("Syntax error in formula or condition: {0}").format(err)) except Exception as e: @@ -495,15 +558,16 @@ class SalarySlip(TransactionBase): self.update_component_row(frappe._dict(last_benefit.struct_row), amount, "earnings") def add_additional_salary_components(self, component_type): - salary_components_details, additional_salary_details = get_additional_salary_component(self.employee, + additional_salaries = get_additional_salaries(self.employee, self.start_date, self.end_date, component_type) - if salary_components_details and additional_salary_details: - for additional_salary in additional_salary_details: - additional_salary =frappe._dict(additional_salary) - amount = additional_salary.amount - overwrite = additional_salary.overwrite - self.update_component_row(frappe._dict(salary_components_details[additional_salary.component]), amount, - component_type, overwrite=overwrite, additional_salary=additional_salary.name) + + for additional_salary in additional_salaries: + self.update_component_row( + get_salary_component_data(additional_salary.component), + additional_salary.amount, + component_type, + additional_salary + ) def add_tax_components(self, payroll_period): # Calculate variable_based_on_taxable_salary after all components updated in salary slip @@ -520,46 +584,59 @@ class SalarySlip(TransactionBase): for d in tax_components: tax_amount = self.calculate_variable_based_on_taxable_salary(d, payroll_period) - tax_row = self.get_salary_slip_row(d) + tax_row = get_salary_component_data(d) self.update_component_row(tax_row, tax_amount, "deductions") - def update_component_row(self, struct_row, amount, key, overwrite=1, additional_salary = ''): + def update_component_row(self, component_data, amount, component_type, additional_salary=None): component_row = None - for d in self.get(key): - if d.salary_component == struct_row.salary_component: + for d in self.get(component_type): + if d.salary_component != component_data.salary_component: + continue + + if ( + not d.additional_salary + and (not additional_salary or additional_salary.overwrite) + or additional_salary + and additional_salary.name == d.additional_salary + ): component_row = d - if not component_row or (struct_row.get("is_additional_component") and not overwrite): - if amount: - self.append(key, { - 'amount': amount, - 'default_amount': amount if not struct_row.get("is_additional_component") else 0, - 'depends_on_payment_days' : struct_row.depends_on_payment_days, - 'salary_component' : struct_row.salary_component, - 'abbr' : struct_row.abbr, - 'additional_salary': additional_salary, - 'do_not_include_in_total' : struct_row.do_not_include_in_total, - 'is_tax_applicable': struct_row.is_tax_applicable, - 'is_flexible_benefit': struct_row.is_flexible_benefit, - 'variable_based_on_taxable_salary': struct_row.variable_based_on_taxable_salary, - 'deduct_full_tax_on_selected_payroll_date': struct_row.deduct_full_tax_on_selected_payroll_date, - 'additional_amount': amount if struct_row.get("is_additional_component") else 0, - 'exempted_from_income_tax': struct_row.exempted_from_income_tax - }) + break + + if additional_salary and additional_salary.overwrite: + # Additional Salary with overwrite checked, remove default rows of same component + self.set(component_type, [ + d for d in self.get(component_type) + if d.salary_component != component_data.salary_component + or d.additional_salary and additional_salary.name != d.additional_salary + or d == component_row + ]) + + if not component_row: + if not amount: + return + + component_row = self.append(component_type) + for attr in ( + 'depends_on_payment_days', 'salary_component', 'abbr' + 'do_not_include_in_total', 'is_tax_applicable', + 'is_flexible_benefit', 'variable_based_on_taxable_salary', + 'exempted_from_income_tax' + ): + component_row.set(attr, component_data.get(attr)) + + if additional_salary: + component_row.default_amount = 0 + component_row.additional_amount = amount + component_row.additional_salary = additional_salary.name + component_row.deduct_full_tax_on_selected_payroll_date = \ + additional_salary.deduct_full_tax_on_selected_payroll_date else: - if struct_row.get("is_additional_component"): - if overwrite: - component_row.additional_amount = amount - component_row.get("default_amount", 0) - component_row.additional_salary = additional_salary - else: - component_row.additional_amount = amount + component_row.default_amount = amount + component_row.additional_amount = 0 + component_row.deduct_full_tax_on_selected_payroll_date = \ + component_data.deduct_full_tax_on_selected_payroll_date - if not overwrite and component_row.default_amount: - amount += component_row.default_amount - else: - component_row.default_amount = amount - - component_row.amount = amount - component_row.deduct_full_tax_on_selected_payroll_date = struct_row.deduct_full_tax_on_selected_payroll_date + component_row.amount = amount def calculate_variable_based_on_taxable_salary(self, tax_component, payroll_period): if not payroll_period: @@ -772,7 +849,7 @@ class SalarySlip(TransactionBase): cint(row.depends_on_payment_days) and cint(self.total_working_days) and (not self.salary_slip_based_on_timesheet or getdate(self.start_date) < joining_date or - getdate(self.end_date) > relieving_date + (relieving_date and getdate(self.end_date) > relieving_date) )): additional_amount = flt((flt(row.additional_amount) * flt(self.payment_days) / cint(self.total_working_days)), row.precision("additional_amount")) @@ -885,35 +962,29 @@ class SalarySlip(TransactionBase): if condition: return frappe.safe_eval(condition, self.whitelisted_globals, data) except NameError as err: - frappe.throw(_("Name error: {0}").format(err)) + frappe.throw(_("{0}
    This error can be due to missing or deleted field.").format(err), + title=_("Name error")) except SyntaxError as err: frappe.throw(_("Syntax error in condition: {0}").format(err)) except Exception as e: frappe.throw(_("Error in formula or condition: {0}").format(e)) raise - def get_salary_slip_row(self, salary_component): - component = frappe.get_doc("Salary Component", salary_component) - # Data for update_component_row - struct_row = frappe._dict() - struct_row['depends_on_payment_days'] = component.depends_on_payment_days - struct_row['salary_component'] = component.name - struct_row['abbr'] = component.salary_component_abbr - struct_row['do_not_include_in_total'] = component.do_not_include_in_total - struct_row['is_tax_applicable'] = component.is_tax_applicable - struct_row['is_flexible_benefit'] = component.is_flexible_benefit - struct_row['variable_based_on_taxable_salary'] = component.variable_based_on_taxable_salary - return struct_row + def get_component_totals(self, component_type, depends_on_payment_days=0): + joining_date, relieving_date = frappe.get_cached_value("Employee", self.employee, + ["date_of_joining", "relieving_date"]) - def get_component_totals(self, component_type): total = 0.0 for d in self.get(component_type): if not d.do_not_include_in_total: - d.amount = flt(d.amount, d.precision("amount")) - total += d.amount + if depends_on_payment_days: + amount = self.get_amount_based_on_payment_days(d, joining_date, relieving_date)[0] + else: + amount = flt(d.amount, d.precision("amount")) + total += amount return total - def set_component_amounts_based_on_payment_days(self, component_type): + def set_component_amounts_based_on_payment_days(self): joining_date, relieving_date = frappe.get_cached_value("Employee", self.employee, ["date_of_joining", "relieving_date"]) @@ -923,8 +994,9 @@ class SalarySlip(TransactionBase): if not joining_date: frappe.throw(_("Please set the Date Of Joining for employee {0}").format(frappe.bold(self.employee_name))) - for d in self.get(component_type): - d.amount = self.get_amount_based_on_payment_days(d, joining_date, relieving_date)[0] + for component_type in ("earnings", "deductions"): + for d in self.get(component_type): + d.amount = flt(self.get_amount_based_on_payment_days(d, joining_date, relieving_date)[0], d.precision("amount")) def set_loan_repayment(self): self.total_loan_repayment = 0 @@ -950,9 +1022,9 @@ class SalarySlip(TransactionBase): amounts = calculate_amounts(payment.loan, self.posting_date, "Regular Payment") total_amount = amounts['interest_amount'] + amounts['payable_principal_amount'] if payment.total_payment > total_amount: - frappe.throw(_("""Row {0}: Paid amount {1} is greater than pending accrued amount {2} - against loan {3}""").format(payment.idx, frappe.bold(payment.total_payment), - frappe.bold(total_amount), frappe.bold(payment.loan))) + frappe.throw(_("""Row {0}: Paid amount {1} is greater than pending accrued amount {2} against loan {3}""") + .format(payment.idx, frappe.bold(payment.total_payment), + frappe.bold(total_amount), frappe.bold(payment.loan))) self.total_interest_amount += payment.interest_amount self.total_principal_amount += payment.principal_amount @@ -960,7 +1032,6 @@ class SalarySlip(TransactionBase): self.total_loan_repayment += payment.total_payment def get_loan_details(self): - return frappe.get_all("Loan", fields=["name", "interest_income_account", "loan_account", "loan_type"], filters = { @@ -1047,6 +1118,139 @@ class SalarySlip(TransactionBase): self.get_working_days_details(lwp=self.leave_without_pay) self.calculate_net_pay() + def set_totals(self): + self.gross_pay = 0.0 + if self.salary_slip_based_on_timesheet == 1: + self.calculate_total_for_salary_slip_based_on_timesheet() + else: + self.total_deduction = 0.0 + if hasattr(self, "earnings"): + for earning in self.earnings: + self.gross_pay += flt(earning.amount, earning.precision("amount")) + if hasattr(self, "deductions"): + for deduction in self.deductions: + self.total_deduction += flt(deduction.amount, deduction.precision("amount")) + self.net_pay = flt(self.gross_pay) - flt(self.total_deduction) - flt(self.total_loan_repayment) + self.set_base_totals() + + def set_base_totals(self): + self.base_gross_pay = flt(self.gross_pay) * flt(self.exchange_rate) + self.base_total_deduction = flt(self.total_deduction) * flt(self.exchange_rate) + self.rounded_total = rounded(self.net_pay) + self.base_net_pay = flt(self.net_pay) * flt(self.exchange_rate) + self.base_rounded_total = rounded(self.base_net_pay) + self.set_net_total_in_words() + + #calculate total working hours, earnings based on hourly wages and totals + def calculate_total_for_salary_slip_based_on_timesheet(self): + if self.timesheets: + self.total_working_hours = 0 + for timesheet in self.timesheets: + if timesheet.working_hours: + self.total_working_hours += timesheet.working_hours + + wages_amount = self.total_working_hours * self.hour_rate + self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate) + salary_component = frappe.db.get_value('Salary Structure', {'name': self.salary_structure}, 'salary_component') + if self.earnings: + for i, earning in enumerate(self.earnings): + if earning.salary_component == salary_component: + self.earnings[i].amount = wages_amount + self.gross_pay += self.earnings[i].amount + self.net_pay = flt(self.gross_pay) - flt(self.total_deduction) + + def compute_year_to_date(self): + year_to_date = 0 + period_start_date, period_end_date = self.get_year_to_date_period() + + salary_slip_sum = frappe.get_list('Salary Slip', + fields = ['sum(net_pay) as sum'], + filters = {'employee_name' : self.employee_name, + 'start_date' : ['>=', period_start_date], + 'end_date' : ['<', period_end_date], + 'name': ['!=', self.name], + 'docstatus': 1 + }) + + year_to_date = flt(salary_slip_sum[0].sum) if salary_slip_sum else 0.0 + + year_to_date += self.net_pay + self.year_to_date = year_to_date + + def compute_month_to_date(self): + month_to_date = 0 + first_day_of_the_month = get_first_day(self.start_date) + salary_slip_sum = frappe.get_list('Salary Slip', + fields = ['sum(net_pay) as sum'], + filters = {'employee_name' : self.employee_name, + 'start_date' : ['>=', first_day_of_the_month], + 'end_date' : ['<', self.start_date], + 'name': ['!=', self.name], + 'docstatus': 1 + }) + + month_to_date = flt(salary_slip_sum[0].sum) if salary_slip_sum else 0.0 + + month_to_date += self.net_pay + self.month_to_date = month_to_date + + def compute_component_wise_year_to_date(self): + period_start_date, period_end_date = self.get_year_to_date_period() + + for key in ('earnings', 'deductions'): + for component in self.get(key): + year_to_date = 0 + component_sum = frappe.db.sql(""" + SELECT sum(detail.amount) as sum + FROM `tabSalary Detail` as detail + INNER JOIN `tabSalary Slip` as salary_slip + ON detail.parent = salary_slip.name + WHERE + salary_slip.employee_name = %(employee_name)s + AND detail.salary_component = %(component)s + AND salary_slip.start_date >= %(period_start_date)s + AND salary_slip.end_date < %(period_end_date)s + AND salary_slip.name != %(docname)s + AND salary_slip.docstatus = 1""", + {'employee_name': self.employee_name, 'component': component.salary_component, 'period_start_date': period_start_date, + 'period_end_date': period_end_date, 'docname': self.name} + ) + + year_to_date = flt(component_sum[0][0]) if component_sum else 0.0 + year_to_date += component.amount + component.year_to_date = year_to_date + + def get_year_to_date_period(self): + payroll_period = get_payroll_period(self.start_date, self.end_date, self.company) + + if payroll_period: + period_start_date = payroll_period.start_date + period_end_date = payroll_period.end_date + else: + # get dates based on fiscal year if no payroll period exists + fiscal_year = get_fiscal_year(date=self.start_date, company=self.company, as_dict=1) + period_start_date = fiscal_year.year_start_date + period_end_date = fiscal_year.year_end_date + + return period_start_date, period_end_date + + def add_leave_balances(self): + self.set('leave_details', []) + + if frappe.db.get_single_value('Payroll Settings', 'show_leave_balances_in_salary_slip'): + from erpnext.hr.doctype.leave_application.leave_application import get_leave_details + leave_details = get_leave_details(self.employee, self.end_date) + + for leave_type, leave_values in iteritems(leave_details['leave_allocation']): + self.append('leave_details', { + 'leave_type': leave_type, + 'total_allocated_leaves': flt(leave_values.get('total_leaves')), + 'expired_leaves': flt(leave_values.get('expired_leaves')), + 'used_leaves': flt(leave_values.get('leaves_taken')), + 'pending_leaves': flt(leave_values.get('pending_leaves')), + 'available_leaves': flt(leave_values.get('remaining_leaves')) + }) + def unlink_ref_doc_from_salary_slip(ref_no): linked_ss = frappe.db.sql_list("""select name from `tabSalary Slip` where journal_entry=%s and docstatus < 2""", (ref_no)) @@ -1058,3 +1262,19 @@ def unlink_ref_doc_from_salary_slip(ref_no): def generate_password_for_pdf(policy_template, employee): employee = frappe.get_doc("Employee", employee) return policy_template.format(**employee.as_dict()) + +def get_salary_component_data(component): + return frappe.get_value( + "Salary Component", + component, + [ + "name as salary_component", + "depends_on_payment_days", + "salary_component_abbr as abbr", + "do_not_include_in_total", + "is_tax_applicable", + "is_flexible_benefit", + "variable_based_on_taxable_salary", + ], + as_dict=1, + ) diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 37cd89a7349..76726956531 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -9,16 +9,19 @@ import calendar import random from erpnext.accounts.utils import get_fiscal_year from frappe.utils.make_random import get_random -from frappe.utils import getdate, nowdate, add_days, add_months, flt, get_first_day, get_last_day +from frappe.utils import getdate, nowdate, add_days, add_months, flt, get_first_day, get_last_day, cstr from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_month_details from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation +from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration \ import create_payroll_period, create_exemption_category class TestSalarySlip(unittest.TestCase): def setUp(self): setup_test() + def tearDown(self): frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 0) frappe.set_user("Administrator") @@ -31,7 +34,7 @@ class TestSalarySlip(unittest.TestCase): frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance") frappe.db.set_value("Payroll Settings", None, "daily_wages_fraction_for_half_day", 0.75) - emp_id = make_employee("test_for_attendance@salary.com") + emp_id = make_employee("test_payment_days_based_on_attendance@salary.com") frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"}) frappe.db.set_value("Leave Type", "Leave Without Pay", "include_holiday", 0) @@ -53,7 +56,7 @@ class TestSalarySlip(unittest.TestCase): mark_attendance(emp_id, add_days(first_sunday, 4), 'On Leave', leave_type='Casual Leave', ignore_validate=True) # invalid lwp mark_attendance(emp_id, add_days(first_sunday, 7), 'On Leave', leave_type='Leave Without Pay', ignore_validate=True) # invalid lwp - ss = make_employee_salary_slip("test_for_attendance@salary.com", "Monthly") + ss = make_employee_salary_slip("test_payment_days_based_on_attendance@salary.com", "Monthly", "Test Payment Based On Attendence") self.assertEqual(ss.leave_without_pay, 1.25) self.assertEqual(ss.absent_days, 1) @@ -76,7 +79,7 @@ class TestSalarySlip(unittest.TestCase): # Payroll based on attendance frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave") - emp_id = make_employee("test_for_attendance@salary.com") + emp_id = make_employee("test_payment_days_based_on_leave_application@salary.com") frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"}) frappe.db.set_value("Leave Type", "Leave Without Pay", "include_holiday", 0) @@ -93,31 +96,40 @@ class TestSalarySlip(unittest.TestCase): make_leave_application(emp_id, first_sunday, add_days(first_sunday, 3), "Leave Without Pay") - ss = make_employee_salary_slip("test_for_attendance@salary.com", "Monthly") + leave_type_ppl = create_leave_type(leave_type_name="Test Partially Paid Leave", is_ppl = 1) + leave_type_ppl.save() - self.assertEqual(ss.leave_without_pay, 3) + alloc = create_leave_allocation( + employee = emp_id, from_date = add_days(first_sunday, 4), + to_date = add_days(first_sunday, 10), new_leaves_allocated = 3, + leave_type = "Test Partially Paid Leave") + alloc.save() + alloc.submit() + + #two day leave ppl with fraction_of_daily_salary_per_leave = 0.5 equivalent to single day lwp + make_leave_application(emp_id, add_days(first_sunday, 4), add_days(first_sunday, 5), "Test Partially Paid Leave") + + ss = make_employee_salary_slip("test_payment_days_based_on_leave_application@salary.com", "Monthly", "Test Payment Based On Leave Application") + + + self.assertEqual(ss.leave_without_pay, 4) days_in_month = no_of_days[0] no_of_holidays = no_of_days[1] - self.assertEqual(ss.payment_days, days_in_month - no_of_holidays - 3) - - #Gross pay calculation based on attendances - gross_pay = 78000 - ((78000 / (days_in_month - no_of_holidays)) * flt(ss.leave_without_pay)) - - self.assertEqual(flt(ss.gross_pay, 2), flt(gross_pay, 2)) + self.assertEqual(ss.payment_days, days_in_month - no_of_holidays - 4) frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave") def test_salary_slip_with_holidays_included(self): no_of_days = self.get_no_of_days() frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 1) - make_employee("test_employee@salary.com") + make_employee("test_salary_slip_with_holidays_included@salary.com") frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "relieving_date", None) + {"employee_name":"test_salary_slip_with_holidays_included@salary.com"}, "name"), "relieving_date", None) frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "status", "Active") - ss = make_employee_salary_slip("test_employee@salary.com", "Monthly") + {"employee_name":"test_salary_slip_with_holidays_included@salary.com"}, "name"), "status", "Active") + ss = make_employee_salary_slip("test_salary_slip_with_holidays_included@salary.com", "Monthly", "Test Salary Slip With Holidays Included") self.assertEqual(ss.total_working_days, no_of_days[0]) self.assertEqual(ss.payment_days, no_of_days[0]) @@ -128,12 +140,12 @@ class TestSalarySlip(unittest.TestCase): def test_salary_slip_with_holidays_excluded(self): no_of_days = self.get_no_of_days() frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 0) - make_employee("test_employee@salary.com") + make_employee("test_salary_slip_with_holidays_excluded@salary.com") frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "relieving_date", None) + {"employee_name":"test_salary_slip_with_holidays_excluded@salary.com"}, "name"), "relieving_date", None) frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "status", "Active") - ss = make_employee_salary_slip("test_employee@salary.com", "Monthly") + {"employee_name":"test_salary_slip_with_holidays_excluded@salary.com"}, "name"), "status", "Active") + ss = make_employee_salary_slip("test_salary_slip_with_holidays_excluded@salary.com", "Monthly", "Test Salary Slip With Holidays Excluded") self.assertEqual(ss.total_working_days, no_of_days[0] - no_of_days[1]) self.assertEqual(ss.payment_days, no_of_days[0] - no_of_days[1]) @@ -148,7 +160,7 @@ class TestSalarySlip(unittest.TestCase): frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 1) # set joinng date in the same month - make_employee("test_employee@salary.com") + make_employee("test_payment_days@salary.com") if getdate(nowdate()).day >= 15: relieving_date = getdate(add_days(nowdate(),-10)) date_of_joining = getdate(add_days(nowdate(),-10)) @@ -163,39 +175,39 @@ class TestSalarySlip(unittest.TestCase): relieving_date = getdate(nowdate()) frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "date_of_joining", date_of_joining) + {"employee_name":"test_payment_days@salary.com"}, "name"), "date_of_joining", date_of_joining) frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "relieving_date", None) + {"employee_name":"test_payment_days@salary.com"}, "name"), "relieving_date", None) frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "status", "Active") + {"employee_name":"test_payment_days@salary.com"}, "name"), "status", "Active") - ss = make_employee_salary_slip("test_employee@salary.com", "Monthly") + ss = make_employee_salary_slip("test_payment_days@salary.com", "Monthly", "Test Payment Days") self.assertEqual(ss.total_working_days, no_of_days[0]) self.assertEqual(ss.payment_days, (no_of_days[0] - getdate(date_of_joining).day + 1)) # set relieving date in the same month frappe.db.set_value("Employee",frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "date_of_joining", (add_days(nowdate(),-60))) + {"employee_name":"test_payment_days@salary.com"}, "name"), "date_of_joining", (add_days(nowdate(),-60))) frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "relieving_date", relieving_date) + {"employee_name":"test_payment_days@salary.com"}, "name"), "relieving_date", relieving_date) frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "status", "Left") + {"employee_name":"test_payment_days@salary.com"}, "name"), "status", "Left") ss.save() self.assertEqual(ss.total_working_days, no_of_days[0]) self.assertEqual(ss.payment_days, getdate(relieving_date).day) frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "relieving_date", None) + {"employee_name":"test_payment_days@salary.com"}, "name"), "relieving_date", None) frappe.db.set_value("Employee", frappe.get_value("Employee", - {"employee_name":"test_employee@salary.com"}, "name"), "status", "Active") + {"employee_name":"test_payment_days@salary.com"}, "name"), "status", "Active") def test_employee_salary_slip_read_permission(self): - make_employee("test_employee@salary.com") + make_employee("test_employee_salary_slip_read_permission@salary.com") - salary_slip_test_employee = make_employee_salary_slip("test_employee@salary.com", "Monthly") - frappe.set_user("test_employee@salary.com") + salary_slip_test_employee = make_employee_salary_slip("test_employee_salary_slip_read_permission@salary.com", "Monthly", "Test Employee Salary Slip Read Permission") + frappe.set_user("test_employee_salary_slip_read_permission@salary.com") self.assertTrue(salary_slip_test_employee.has_permission("read")) def test_email_salary_slip(self): @@ -203,8 +215,8 @@ class TestSalarySlip(unittest.TestCase): frappe.db.set_value("Payroll Settings", None, "email_salary_slip_to_employee", 1) - make_employee("test_employee@salary.com") - ss = make_employee_salary_slip("test_employee@salary.com", "Monthly") + make_employee("test_email_salary_slip@salary.com") + ss = make_employee_salary_slip("test_email_salary_slip@salary.com", "Monthly", "Test Salary Slip Email") ss.company = "_Test Company" ss.save() ss.submit() @@ -215,8 +227,9 @@ class TestSalarySlip(unittest.TestCase): def test_loan_repayment_salary_slip(self): from erpnext.loan_management.doctype.loan.test_loan import create_loan_type, create_loan, make_loan_disbursement_entry, create_loan_accounts from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import process_loan_interest_accrual_for_term_loans + from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure - applicant = make_employee("test_loanemployee@salary.com", company="_Test Company") + applicant = make_employee("test_loan_repayment_salary_slip@salary.com", company="_Test Company") create_loan_accounts() @@ -228,6 +241,12 @@ class TestSalarySlip(unittest.TestCase): interest_income_account='Interest Income Account - _TC', penalty_income_account='Penalty Income Account - _TC') + payroll_period = create_payroll_period(name="_Test Payroll Period 1", company="_Test Company") + + make_salary_structure("Test Loan Repayment Salary Structure", "Monthly", employee=applicant, currency='INR', + payroll_period=payroll_period) + + frappe.db.sql("delete from tabLoan") loan = create_loan(applicant, "Car Loan", 11000, "Repay Over Number of Periods", 20, posting_date=add_months(nowdate(), -1)) loan.repay_from_salary = 1 loan.submit() @@ -236,7 +255,7 @@ class TestSalarySlip(unittest.TestCase): process_loan_interest_accrual_for_term_loans(posting_date=nowdate()) - ss = make_employee_salary_slip("test_loanemployee@salary.com", "Monthly") + ss = make_employee_salary_slip("test_loan_repayment_salary_slip@salary.com", "Monthly", "Test Loan Repayment Salary Structure") ss.submit() self.assertEqual(ss.total_loan_repayment, 592) @@ -249,7 +268,7 @@ class TestSalarySlip(unittest.TestCase): for payroll_frequency in ["Monthly", "Bimonthly", "Fortnightly", "Weekly", "Daily"]: make_employee(payroll_frequency + "_test_employee@salary.com") - ss = make_employee_salary_slip(payroll_frequency + "_test_employee@salary.com", payroll_frequency) + ss = make_employee_salary_slip(payroll_frequency + "_test_employee@salary.com", payroll_frequency, payroll_frequency + "_Test Payroll Frequency") if payroll_frequency == "Monthly": self.assertEqual(ss.end_date, m['month_end_date']) elif payroll_frequency == "Bimonthly": @@ -264,6 +283,77 @@ class TestSalarySlip(unittest.TestCase): elif payroll_frequency == "Daily": self.assertEqual(ss.end_date, nowdate()) + def test_multi_currency_salary_slip(self): + from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + applicant = make_employee("test_multi_currency_salary_slip@salary.com", company="_Test Company") + frappe.db.sql("""delete from `tabSalary Structure` where name='Test Multi Currency Salary Slip'""") + salary_structure = make_salary_structure("Test Multi Currency Salary Slip", "Monthly", employee=applicant, company="_Test Company", currency='USD') + salary_slip = make_salary_slip(salary_structure.name, employee = applicant) + salary_slip.exchange_rate = 70 + salary_slip.calculate_net_pay() + + self.assertEqual(salary_slip.gross_pay, 78000) + self.assertEqual(salary_slip.base_gross_pay, 78000*70) + + def test_year_to_date_computation(self): + from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + + applicant = make_employee("test_ytd@salary.com", company="_Test Company") + + payroll_period = create_payroll_period(name="_Test Payroll Period 1", company="_Test Company") + + create_tax_slab(payroll_period, allow_tax_exemption=True, currency="INR", effective_date=getdate("2019-04-01"), + company="_Test Company") + + salary_structure = make_salary_structure("Monthly Salary Structure Test for Salary Slip YTD", + "Monthly", employee=applicant, company="_Test Company", currency="INR", payroll_period=payroll_period) + + # clear salary slip for this employee + frappe.db.sql("DELETE FROM `tabSalary Slip` where employee_name = 'test_ytd@salary.com'") + + create_salary_slips_for_payroll_period(applicant, salary_structure.name, + payroll_period, deduct_random=False) + + salary_slips = frappe.get_all('Salary Slip', fields=['year_to_date', 'net_pay'], filters={'employee_name': + 'test_ytd@salary.com'}, order_by = 'posting_date') + + year_to_date = 0 + for slip in salary_slips: + year_to_date += flt(slip.net_pay) + self.assertEqual(slip.year_to_date, year_to_date) + + def test_component_wise_year_to_date_computation(self): + from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + + applicant = make_employee("test_ytd@salary.com", company="_Test Company") + + payroll_period = create_payroll_period(name="_Test Payroll Period 1", company="_Test Company") + + create_tax_slab(payroll_period, allow_tax_exemption=True, currency="INR", effective_date=getdate("2019-04-01"), + company="_Test Company") + + salary_structure = make_salary_structure("Monthly Salary Structure Test for Salary Slip YTD", + "Monthly", employee=applicant, company="_Test Company", currency="INR", payroll_period=payroll_period) + + # clear salary slip for this employee + frappe.db.sql("DELETE FROM `tabSalary Slip` where employee_name = 'test_ytd@salary.com'") + + create_salary_slips_for_payroll_period(applicant, salary_structure.name, + payroll_period, deduct_random=False, num=3) + + salary_slips = frappe.get_all("Salary Slip", fields=["name"], filters={"employee_name": + "test_ytd@salary.com"}, order_by = "posting_date") + + year_to_date = dict() + for slip in salary_slips: + doc = frappe.get_doc("Salary Slip", slip.name) + for entry in doc.get("earnings"): + if not year_to_date.get(entry.salary_component): + year_to_date[entry.salary_component] = 0 + + year_to_date[entry.salary_component] += entry.amount + self.assertEqual(year_to_date[entry.salary_component], entry.year_to_date) + def test_tax_for_payroll_period(self): data = {} # test the impact of tax exemption declaration, tax exemption proof submission @@ -271,7 +361,6 @@ class TestSalarySlip(unittest.TestCase): # as per assigned salary structure 40500 in monthly salary so 236000*5/100/12 frappe.db.sql("""delete from `tabPayroll Period`""") frappe.db.sql("""delete from `tabSalary Component`""") - frappe.db.sql("""delete from `tabAdditional Salary`""") payroll_period = create_payroll_period() @@ -347,8 +436,7 @@ class TestSalarySlip(unittest.TestCase): # create additional salary of 150000 frappe.db.sql("""delete from `tabSalary Slip` where employee=%s""", (employee)) - data["additional-1"] = create_additional_salary(employee, payroll_period, 50000) - data["additional-2"] = create_additional_salary(employee, payroll_period, 100000) + data["additional-1"] = create_additional_salary(employee, payroll_period, 150000) data["deducted_dates"] = create_salary_slips_for_payroll_period(employee, salary_structure.name, payroll_period) @@ -385,16 +473,18 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None): salary_structure = payroll_frequency + " Salary Structure Test for Salary Slip" employee = frappe.db.get_value("Employee", {"user_id": user}) - salary_structure_doc = make_salary_structure(salary_structure, payroll_frequency, employee) - salary_slip = frappe.db.get_value("Salary Slip", {"employee": frappe.db.get_value("Employee", {"user_id": user})}) + salary_structure_doc = make_salary_structure(salary_structure, payroll_frequency, employee=employee) + salary_slip_name = frappe.db.get_value("Salary Slip", {"employee": frappe.db.get_value("Employee", {"user_id": user})}) - if not salary_slip: + if not salary_slip_name: salary_slip = make_salary_slip(salary_structure_doc.name, employee = employee) salary_slip.employee_name = frappe.get_value("Employee", {"name":frappe.db.get_value("Employee", {"user_id": user})}, "employee_name") salary_slip.payroll_frequency = payroll_frequency salary_slip.posting_date = nowdate() salary_slip.insert() + else: + salary_slip = frappe.get_doc('Salary Slip', salary_slip_name) return salary_slip @@ -435,7 +525,7 @@ def get_salary_component_account(sal_comp, company_list=None): sal_comp.append("accounts", { "company": d, - "default_account": create_account(account_name, d, parent_account) + "account": create_account(account_name, d, parent_account) }) sal_comp.save() @@ -527,14 +617,6 @@ def make_deduction_salary_component(setup=False, test_tax=False, company_list=No "amount": 200, "exempted_from_income_tax": 1 - }, - { - "salary_component": 'TDS', - "abbr":'T', - "type": "Deduction", - "depends_on_payment_days": 0, - "variable_based_on_taxable_salary": 1, - "round_to_the_nearest_integer": 1 } ] if not test_tax: @@ -545,6 +627,15 @@ def make_deduction_salary_component(setup=False, test_tax=False, company_list=No "type": "Deduction", "round_to_the_nearest_integer": 1 }) + else: + data.append({ + "salary_component": 'TDS', + "abbr":'T', + "type": "Deduction", + "depends_on_payment_days": 0, + "variable_based_on_taxable_salary": 1, + "round_to_the_nearest_integer": 1 + }) if setup or test_tax: make_salary_component(data, test_tax, company_list) @@ -562,7 +653,8 @@ def create_exemption_declaration(employee, payroll_period): "doctype": "Employee Tax Exemption Declaration", "employee": employee, "payroll_period": payroll_period, - "company": erpnext.get_default_company() + "company": erpnext.get_default_company(), + "currency": erpnext.get_default_currency() }) declaration.append("declarations", { "exemption_sub_category": "_Test Sub Category", @@ -577,7 +669,8 @@ def create_proof_submission(employee, payroll_period, amount): "doctype": "Employee Tax Exemption Proof Submission", "employee": employee, "payroll_period": payroll_period.name, - "submission_date": submission_date + "submission_date": submission_date, + "currency": erpnext.get_default_currency() }) proof_submission.append("tax_exemption_proofs", { "exemption_sub_category": "_Test Sub Category", @@ -594,13 +687,18 @@ def create_benefit_claim(employee, payroll_period, amount, component): "employee": employee, "claimed_amount": amount, "claim_date": claim_date, - "earning_component": component + "earning_component": component, + "currency": erpnext.get_default_currency() }).submit() return claim_date -def create_tax_slab(payroll_period, effective_date = None, allow_tax_exemption = False, dont_submit = False): - if frappe.db.exists("Income Tax Slab", "Tax Slab: " + payroll_period.name): - return +def create_tax_slab(payroll_period, effective_date = None, allow_tax_exemption = False, dont_submit = False, currency=None, + company=None): + if not currency: + currency = erpnext.get_default_currency() + + if company: + currency = erpnext.get_company_currency(company) slabs = [ { @@ -620,30 +718,38 @@ def create_tax_slab(payroll_period, effective_date = None, allow_tax_exemption = } ] - income_tax_slab = frappe.new_doc("Income Tax Slab") - income_tax_slab.name = "Tax Slab: " + payroll_period.name - income_tax_slab.effective_from = effective_date or add_days(payroll_period.start_date, -2) + income_tax_slab_name = frappe.db.get_value("Income Tax Slab", {"currency": currency}) + if not income_tax_slab_name: + income_tax_slab = frappe.new_doc("Income Tax Slab") + income_tax_slab.name = "Tax Slab: " + payroll_period.name + " " + cstr(currency) + income_tax_slab.effective_from = effective_date or add_days(payroll_period.start_date, -2) + income_tax_slab.company = company or '' + income_tax_slab.currency = currency - if allow_tax_exemption: - income_tax_slab.allow_tax_exemption = 1 - income_tax_slab.standard_tax_exemption_amount = 50000 + if allow_tax_exemption: + income_tax_slab.allow_tax_exemption = 1 + income_tax_slab.standard_tax_exemption_amount = 50000 - for item in slabs: - income_tax_slab.append("slabs", item) + for item in slabs: + income_tax_slab.append("slabs", item) - income_tax_slab.append("other_taxes_and_charges", { - "description": "cess", - "percent": 4 - }) + income_tax_slab.append("other_taxes_and_charges", { + "description": "cess", + "percent": 4 + }) - income_tax_slab.save() - if not dont_submit: - income_tax_slab.submit() + income_tax_slab.save() + if not dont_submit: + income_tax_slab.submit() -def create_salary_slips_for_payroll_period(employee, salary_structure, payroll_period, deduct_random=True): + return income_tax_slab.name + else: + return income_tax_slab_name + +def create_salary_slips_for_payroll_period(employee, salary_structure, payroll_period, deduct_random=True, num=12): deducted_dates = [] i = 0 - while i < 12: + while i < num: slip = frappe.get_doc({"doctype": "Salary Slip", "employee": employee, "salary_structure": salary_structure, "frequency": "Monthly"}) if i == 0: @@ -673,7 +779,8 @@ def create_additional_salary(employee, payroll_period, amount): "salary_component": "Performance Bonus", "payroll_date": salary_date, "amount": amount, - "type": "Earning" + "type": "Earning", + "currency": erpnext.get_default_currency() }).submit() return salary_date diff --git a/erpnext/payroll/doctype/salary_slip_leave/__init__.py b/erpnext/payroll/doctype/salary_slip_leave/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json b/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json new file mode 100644 index 00000000000..7ac453b3c3d --- /dev/null +++ b/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json @@ -0,0 +1,78 @@ +{ + "actions": [], + "creation": "2021-02-19 11:45:18.173417", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "leave_type", + "total_allocated_leaves", + "expired_leaves", + "used_leaves", + "pending_leaves", + "available_leaves" + ], + "fields": [ + { + "fieldname": "leave_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Leave Type", + "no_copy": 1, + "options": "Leave Type", + "read_only": 1 + }, + { + "fieldname": "total_allocated_leaves", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Total Allocated Leave", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "expired_leaves", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Expired Leave", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "used_leaves", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Used Leave", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "pending_leaves", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Pending Leave", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "available_leaves", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Available Leave", + "no_copy": 1, + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-02-19 10:47:48.546724", + "modified_by": "Administrator", + "module": "Payroll", + "name": "Salary Slip Leave", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.py b/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.py new file mode 100644 index 00000000000..7a92bf18f76 --- /dev/null +++ b/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class SalarySlipLeave(Document): + pass diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.js b/erpnext/payroll/doctype/salary_structure/salary_structure.js index ad93a2fa4bf..6aa13873633 100755 --- a/erpnext/payroll/doctype/salary_structure/salary_structure.js +++ b/erpnext/payroll/doctype/salary_structure/salary_structure.js @@ -41,20 +41,6 @@ frappe.ui.form.on('Salary Structure', { frm.toggle_reqd(['payroll_frequency'], !frm.doc.salary_slip_based_on_timesheet) - frm.set_query("salary_component", "earnings", function() { - return { - filters: { - type: "earning" - } - } - }); - frm.set_query("salary_component", "deductions", function() { - return { - filters: { - type: "deduction" - } - } - }); frm.set_query("payment_account", function () { var account_types = ["Bank", "Cash"]; return { @@ -65,9 +51,48 @@ frappe.ui.form.on('Salary Structure', { } }; }); + frm.trigger('set_earning_deduction_component'); + }, + + set_earning_deduction_component: function(frm) { + if(!frm.doc.company) return; + frm.set_query("salary_component", "earnings", function() { + return { + filters: {type: "earning", company: frm.doc.company} + }; + }); + frm.set_query("salary_component", "deductions", function() { + return { + filters: {type: "deduction", company: frm.doc.company} + }; + }); + }, + + company: function(frm) { + frm.trigger('set_earning_deduction_component'); + }, + + currency: function(frm) { + calculate_totals(frm.doc); + frm.trigger("set_dynamic_labels") + frm.refresh() + }, + + set_dynamic_labels: function(frm) { + frm.set_currency_labels(["net_pay","hour_rate", "leave_encashment_amount_per_day", "max_benefits", "total_earning", + "total_deduction"], frm.doc.currency); + + frm.set_currency_labels(["amount", "additional_amount", "tax_on_flexible_benefit", "tax_on_additional_salary"], + frm.doc.currency, "earnings"); + + frm.set_currency_labels(["amount", "additional_amount", "tax_on_flexible_benefit", "tax_on_additional_salary"], + frm.doc.currency, "deductions"); + + frm.refresh_fields(); }, refresh: function(frm) { + frm.trigger("set_dynamic_labels") frm.trigger("toggle_fields"); frm.fields_dict['earnings'].grid.set_column_disp("default_amount", false); frm.fields_dict['deductions'].grid.set_column_disp("default_amount", false); @@ -93,6 +118,7 @@ frappe.ui.form.on('Salary Structure', { fields_read_only.forEach(function(field) { frappe.meta.get_docfield("Salary Detail", field, frm.doc.name).read_only = 1; }); + frm.trigger('set_earning_deduction_component'); }, assign_to_employees:function (frm) { @@ -101,10 +127,12 @@ frappe.ui.form.on('Salary Structure', { fields: [ {fieldname: "sec_break", fieldtype: "Section Break", label: __("Filter Employees By (Optional)")}, {fieldname: "company", fieldtype: "Link", options: "Company", label: __("Company"), default: frm.doc.company, read_only:1}, + {fieldname: "currency", fieldtype: "Link", options: "Currency", label: __("Currency"), default: frm.doc.currency, read_only:1}, {fieldname: "grade", fieldtype: "Link", options: "Employee Grade", label: __("Employee Grade")}, {fieldname:'department', fieldtype:'Link', options: 'Department', label: __('Department')}, {fieldname:'designation', fieldtype:'Link', options: 'Designation', label: __('Designation')}, - {fieldname:"employee", fieldtype: "Link", options: "Employee", label: __("Employee")}, + {fieldname:"employee", fieldtype: "Link", options: "Employee", label: __("Employee")}, + {fieldname:"payroll_payable_account", fieldtype: "Link", options: "Account", filters: {"company": frm.doc.company, "root_type": "Liability", "is_group": 0, "account_currency": frm.doc.currency}, label: __("Payroll Payable Account")}, {fieldname:'base_variable', fieldtype:'Section Break'}, {fieldname:'from_date', fieldtype:'Date', label: __('From Date'), "reqd": 1}, {fieldname:'income_tax_slab', fieldtype:'Link', label: __('Income Tax Slab'), options: 'Income Tax Slab'}, @@ -114,6 +142,8 @@ frappe.ui.form.on('Salary Structure', { ], primary_action: function() { var data = d.get_values(); + delete data.company + delete data.currency frappe.call({ doc: frm.doc, method: "assign_salary_structure", diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.json b/erpnext/payroll/doctype/salary_structure/salary_structure.json index 5f94929f0b5..de56fc8457e 100644 --- a/erpnext/payroll/doctype/salary_structure/salary_structure.json +++ b/erpnext/payroll/doctype/salary_structure/salary_structure.json @@ -13,6 +13,7 @@ "column_break1", "is_active", "payroll_frequency", + "currency", "is_default", "time_sheet_earning_detail", "salary_slip_based_on_timesheet", @@ -26,9 +27,9 @@ "deductions", "conditions_and_formula_variable_and_example", "net_pay_detail", - "column_break2", "total_earning", "total_deduction", + "column_break2", "net_pay", "account", "mode_of_payment", @@ -43,23 +44,17 @@ "label": "Company", "options": "Company", "remember_last_selected_value": 1, - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "letter_head", "fieldtype": "Link", "label": "Letter Head", - "options": "Letter Head", - "show_days": 1, - "show_seconds": 1 + "options": "Letter Head" }, { "fieldname": "column_break1", "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1, "width": "50%" }, { @@ -72,9 +67,7 @@ "oldfieldname": "is_active", "oldfieldtype": "Select", "options": "\nYes\nNo", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "default": "Monthly", @@ -82,9 +75,7 @@ "fieldname": "payroll_frequency", "fieldtype": "Select", "label": "Payroll Frequency", - "options": "\nMonthly\nFortnightly\nBimonthly\nWeekly\nDaily", - "show_days": 1, - "show_seconds": 1 + "options": "\nMonthly\nFortnightly\nBimonthly\nWeekly\nDaily" }, { "default": "No", @@ -95,62 +86,46 @@ "no_copy": 1, "options": "Yes\nNo", "print_hide": 1, - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "fieldname": "time_sheet_earning_detail", - "fieldtype": "Section Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Section Break" }, { "default": "0", "fieldname": "salary_slip_based_on_timesheet", "fieldtype": "Check", - "label": "Salary Slip Based on Timesheet", - "show_days": 1, - "show_seconds": 1 + "label": "Salary Slip Based on Timesheet" }, { "fieldname": "column_break_17", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "description": "Salary Component for timesheet based payroll.", "fieldname": "salary_component", "fieldtype": "Link", "label": "Salary Component", - "options": "Salary Component", - "show_days": 1, - "show_seconds": 1 + "options": "Salary Component" }, { "fieldname": "hour_rate", "fieldtype": "Currency", "label": "Hour Rate", - "options": "Company:company:default_currency", - "show_days": 1, - "show_seconds": 1 + "options": "currency" }, { "fieldname": "leave_encashment_amount_per_day", "fieldtype": "Currency", "label": "Leave Encashment Amount Per Day", - "options": "Company:company:default_currency", - "show_days": 1, - "show_seconds": 1 + "options": "currency" }, { "fieldname": "max_benefits", "fieldtype": "Currency", "label": "Max Benefits (Amount)", - "options": "Company:company:default_currency", - "show_days": 1, - "show_seconds": 1 + "options": "currency" }, { "description": "Salary breakup based on Earning and Deduction.", @@ -158,9 +133,7 @@ "fieldtype": "Section Break", "oldfieldname": "earning_deduction", "oldfieldtype": "Section Break", - "precision": "2", - "show_days": 1, - "show_seconds": 1 + "precision": "2" }, { "fieldname": "earnings", @@ -168,9 +141,7 @@ "label": "Earnings", "oldfieldname": "earning_details", "oldfieldtype": "Table", - "options": "Salary Detail", - "show_days": 1, - "show_seconds": 1 + "options": "Salary Detail" }, { "fieldname": "deductions", @@ -178,22 +149,16 @@ "label": "Deductions", "oldfieldname": "deduction_details", "oldfieldtype": "Table", - "options": "Salary Detail", - "show_days": 1, - "show_seconds": 1 + "options": "Salary Detail" }, { "fieldname": "net_pay_detail", "fieldtype": "Section Break", - "options": "Simple", - "show_days": 1, - "show_seconds": 1 + "options": "Simple" }, { "fieldname": "column_break2", "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1, "width": "50%" }, { @@ -201,63 +166,45 @@ "fieldtype": "Currency", "hidden": 1, "label": "Total Earning", - "oldfieldname": "total_earning", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "options": "currency", + "read_only": 1 }, { "fieldname": "total_deduction", "fieldtype": "Currency", "hidden": 1, "label": "Total Deduction", - "oldfieldname": "total_deduction", - "oldfieldtype": "Currency", - "options": "Company:company:default_currency", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "options": "currency", + "read_only": 1 }, { "fieldname": "net_pay", "fieldtype": "Currency", "hidden": 1, "label": "Net Pay", - "options": "Company:company:default_currency", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "options": "currency", + "read_only": 1 }, { "fieldname": "account", "fieldtype": "Section Break", - "label": "Account", - "show_days": 1, - "show_seconds": 1 + "label": "Account" }, { "fieldname": "mode_of_payment", "fieldtype": "Link", "label": "Mode of Payment", - "options": "Mode of Payment", - "show_days": 1, - "show_seconds": 1 + "options": "Mode of Payment" }, { "fieldname": "column_break_28", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" }, { "fieldname": "payment_account", "fieldtype": "Link", "label": "Payment Account", - "options": "Account", - "show_days": 1, - "show_seconds": 1 + "options": "Account" }, { "fieldname": "amended_from", @@ -266,23 +213,26 @@ "no_copy": 1, "options": "Salary Structure", "print_hide": 1, - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "fieldname": "conditions_and_formula_variable_and_example", "fieldtype": "HTML", - "label": "Conditions and Formula variable and example", - "show_days": 1, - "show_seconds": 1 + "label": "Conditions and Formula variable and example" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "reqd": 1 } ], "icon": "fa fa-file-text", "idx": 1, "is_submittable": 1, "links": [], - "modified": "2020-06-22 17:07:26.129355", + "modified": "2020-09-30 11:30:32.190798", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Structure", diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.py b/erpnext/payroll/doctype/salary_structure/salary_structure.py index ffc16d73c25..17120815504 100644 --- a/erpnext/payroll/doctype/salary_structure/salary_structure.py +++ b/erpnext/payroll/doctype/salary_structure/salary_structure.py @@ -2,7 +2,7 @@ # License: GNU General Public License v3. See license.txt from __future__ import unicode_literals -import frappe +import frappe, erpnext from frappe.utils import flt, cint, cstr from frappe import _ @@ -88,24 +88,26 @@ class SalaryStructure(Document): return employees @frappe.whitelist() - def assign_salary_structure(self, company=None, grade=None, department=None, designation=None,employee=None, - from_date=None, base=None, variable=None, income_tax_slab=None): - employees = self.get_employees(company= company, grade= grade,department= department,designation= designation,name=employee) + def assign_salary_structure(self, grade=None, department=None, designation=None,employee=None, + payroll_payable_account=None, from_date=None, base=None, variable=None, income_tax_slab=None): + employees = self.get_employees(company= self.company, grade= grade,department= department,designation= designation,name=employee) if employees: if len(employees) > 20: frappe.enqueue(assign_salary_structure_for_employees, timeout=600, - employees=employees, salary_structure=self,from_date=from_date, - base=base, variable=variable, income_tax_slab=income_tax_slab) + employees=employees, salary_structure=self, + payroll_payable_account=payroll_payable_account, + from_date=from_date, base=base, variable=variable, income_tax_slab=income_tax_slab) else: - assign_salary_structure_for_employees(employees, self, from_date=from_date, - base=base, variable=variable, income_tax_slab=income_tax_slab) + assign_salary_structure_for_employees(employees, self, + payroll_payable_account=payroll_payable_account, + from_date=from_date, base=base, variable=variable, income_tax_slab=income_tax_slab) else: frappe.msgprint(_("No Employee Found")) -def assign_salary_structure_for_employees(employees, salary_structure, from_date=None, base=None, variable=None, income_tax_slab=None): +def assign_salary_structure_for_employees(employees, salary_structure, payroll_payable_account=None, from_date=None, base=None, variable=None, income_tax_slab=None): salary_structures_assignments = [] existing_assignments_for = get_existing_assignments(employees, salary_structure, from_date) count=0 @@ -115,7 +117,7 @@ def assign_salary_structure_for_employees(employees, salary_structure, from_date count +=1 salary_structures_assignment = create_salary_structures_assignment(employee, - salary_structure, from_date, base, variable, income_tax_slab) + salary_structure, payroll_payable_account, from_date, base, variable, income_tax_slab) salary_structures_assignments.append(salary_structures_assignment) frappe.publish_progress(count*100/len(set(employees) - set(existing_assignments_for)), title = _("Assigning Structures...")) @@ -123,11 +125,22 @@ def assign_salary_structure_for_employees(employees, salary_structure, from_date frappe.msgprint(_("Structures have been assigned successfully")) -def create_salary_structures_assignment(employee, salary_structure, from_date, base, variable, income_tax_slab=None): +def create_salary_structures_assignment(employee, salary_structure, payroll_payable_account, from_date, base, variable, income_tax_slab=None): + if not payroll_payable_account: + payroll_payable_account = frappe.db.get_value('Company', salary_structure.company, 'default_payroll_payable_account') + if not payroll_payable_account: + frappe.throw(_('Please set "Default Payroll Payable Account" in Company Defaults')) + payroll_payable_account_currency = frappe.db.get_value('Account', payroll_payable_account, 'account_currency') + company_curency = erpnext.get_company_currency(salary_structure.company) + if payroll_payable_account_currency != salary_structure.currency and payroll_payable_account_currency != company_curency: + frappe.throw(_("Invalid Payroll Payable Account. The account currency must be {0} or {1}").format(salary_structure.currency, company_curency)) + assignment = frappe.new_doc("Salary Structure Assignment") assignment.employee = employee assignment.salary_structure = salary_structure.name assignment.company = salary_structure.company + assignment.currency = salary_structure.currency + assignment.payroll_payable_account = payroll_payable_account assignment.from_date = from_date assignment.base = base assignment.variable = variable @@ -170,7 +183,8 @@ def make_salary_slip(source_name, target_doc = None, employee = None, as_print = "doctype": "Salary Slip", "field_map": { "total_earning": "gross_pay", - "name": "salary_structure" + "name": "salary_structure", + "currency": "currency" } } }, target_doc, postprocess, ignore_child_tables=True, ignore_permissions=ignore_permissions) @@ -188,7 +202,8 @@ def get_employees(salary_structure): filters={'salary_structure': salary_structure, 'docstatus': 1}, fields=['employee']) if not employees: - frappe.throw(_("There's no Employee with Salary Structure: {0}. \ - Assign {1} to an Employee to preview Salary Slip").format(salary_structure, salary_structure)) + frappe.throw(_("There's no Employee with Salary Structure: {0}. Assign {1} to an Employee to preview Salary Slip").format( + salary_structure, salary_structure)) return list(set([d.employee for d in employees])) + diff --git a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py index e04fda81202..f2fb558a14b 100644 --- a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py +++ b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py @@ -94,7 +94,8 @@ class TestSalaryStructure(unittest.TestCase): self.assertFalse(("\n" in row.formula) or ("\n" in row.condition)) def test_salary_structures_assignment(self): - salary_structure = make_salary_structure("Salary Structure Sample", "Monthly") + company_currency = erpnext.get_default_currency() + salary_structure = make_salary_structure("Salary Structure Sample", "Monthly", currency=company_currency) employee = "test_assign_stucture@salary.com" employee_doc_name = make_employee(employee) # clear the already assigned stuctures @@ -107,8 +108,13 @@ class TestSalaryStructure(unittest.TestCase): self.assertEqual(salary_structure_assignment.base, 5000) self.assertEqual(salary_structure_assignment.variable, 200) + def test_multi_currency_salary_structure(self): + make_employee("test_muti_currency_employee@salary.com") + sal_struct = make_salary_structure("Salary Structure Multi Currency", "Monthly", currency='USD') + self.assertEqual(sal_struct.currency, 'USD') + def make_salary_structure(salary_structure, payroll_frequency, employee=None, dont_submit=False, other_details=None, - test_tax=False, company=None): + test_tax=False, company=None, currency=erpnext.get_default_currency(), payroll_period=None): if test_tax: frappe.db.sql("""delete from `tabSalary Structure` where name=%s""",(salary_structure)) @@ -120,7 +126,8 @@ def make_salary_structure(salary_structure, payroll_frequency, employee=None, do "earnings": make_earning_salary_component(test_tax=test_tax, company_list=["_Test Company"]), "deductions": make_deduction_salary_component(test_tax=test_tax, company_list=["_Test Company"]), "payroll_frequency": payroll_frequency, - "payment_account": get_random("Account") + "payment_account": get_random("Account", filters={'account_currency': currency}), + "currency": currency } if other_details and isinstance(other_details, dict): details.update(other_details) @@ -134,16 +141,24 @@ def make_salary_structure(salary_structure, payroll_frequency, employee=None, do if employee and not frappe.db.get_value("Salary Structure Assignment", {'employee':employee, 'docstatus': 1}) and salary_structure_doc.docstatus==1: - create_salary_structure_assignment(employee, salary_structure, company=company) + create_salary_structure_assignment(employee, salary_structure, company=company, currency=currency, + payroll_period=payroll_period) return salary_structure_doc -def create_salary_structure_assignment(employee, salary_structure, from_date=None, company=None): +def create_salary_structure_assignment(employee, salary_structure, from_date=None, company=None, currency=erpnext.get_default_currency(), + payroll_period=None): + if frappe.db.exists("Salary Structure Assignment", {"employee": employee}): frappe.db.sql("""delete from `tabSalary Structure Assignment` where employee=%s""",(employee)) - payroll_period = create_payroll_period() - create_tax_slab(payroll_period, allow_tax_exemption=True) + if not payroll_period: + payroll_period = create_payroll_period() + + income_tax_slab = frappe.db.get_value("Income Tax Slab", {"currency": currency}) + + if not income_tax_slab: + income_tax_slab = create_tax_slab(payroll_period, allow_tax_exemption=True, currency=currency) salary_structure_assignment = frappe.new_doc("Salary Structure Assignment") salary_structure_assignment.employee = employee @@ -151,8 +166,15 @@ def create_salary_structure_assignment(employee, salary_structure, from_date=Non salary_structure_assignment.variable = 5000 salary_structure_assignment.from_date = from_date or add_days(nowdate(), -1) salary_structure_assignment.salary_structure = salary_structure + salary_structure_assignment.currency = currency + salary_structure_assignment.payroll_payable_account = get_payable_account(company) salary_structure_assignment.company = company or erpnext.get_default_company() salary_structure_assignment.save(ignore_permissions=True) - salary_structure_assignment.income_tax_slab = "Tax Slab: _Test Payroll Period" + salary_structure_assignment.income_tax_slab = income_tax_slab salary_structure_assignment.submit() return salary_structure_assignment + +def get_payable_account(company=None): + if not company: + company = erpnext.get_default_company() + return frappe.db.get_value("Company", company, "default_payroll_payable_account") \ No newline at end of file diff --git a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.js b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.js index 818e853154d..6cd897e95d1 100644 --- a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.js +++ b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.js @@ -6,9 +6,6 @@ frappe.ui.form.on('Salary Structure Assignment', { frm.set_query("employee", function() { return { query: "erpnext.controllers.queries.employee_query", - filters: { - company: frm.doc.company - } } }); frm.set_query("salary_structure", function() { @@ -26,11 +23,25 @@ frappe.ui.form.on('Salary Structure Assignment', { filters: { company: frm.doc.company, docstatus: 1, - disabled: 0 + disabled: 0, + currency: frm.doc.currency + } + }; + }); + + frm.set_query("payroll_payable_account", function() { + var company_currency = erpnext.get_currency(frm.doc.company); + return { + filters: { + "company": frm.doc.company, + "root_type": "Liability", + "is_group": 0, + "account_currency": ["in", [frm.doc.currency, company_currency]], } } }); }, + employee: function(frm) { if(frm.doc.employee){ frappe.call({ @@ -52,5 +63,13 @@ frappe.ui.form.on('Salary Structure Assignment', { else{ frm.set_value("company", null); } + }, + + company: function(frm) { + if (frm.doc.company) { + frappe.db.get_value("Company", frm.doc.company, "default_payroll_payable_account", (r) => { + frm.set_value("payroll_payable_account", r.default_payroll_payable_account); + }); + } } }); diff --git a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json index c84e034c727..92bb347661e 100644 --- a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json +++ b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json @@ -11,11 +11,13 @@ "employee_name", "department", "company", + "payroll_payable_account", "column_break_6", "designation", "salary_structure", "from_date", "income_tax_slab", + "currency", "section_break_7", "base", "column_break_9", @@ -94,7 +96,7 @@ "fieldname": "base", "fieldtype": "Currency", "label": "Base", - "options": "Company:company:default_currency" + "options": "currency" }, { "fieldname": "column_break_9", @@ -104,7 +106,7 @@ "fieldname": "variable", "fieldtype": "Currency", "label": "Variable", - "options": "Company:company:default_currency" + "options": "currency" }, { "fieldname": "amended_from", @@ -116,15 +118,35 @@ "read_only": 1 }, { + "depends_on": "salary_structure", "fieldname": "income_tax_slab", "fieldtype": "Link", "label": "Income Tax Slab", "options": "Income Tax Slab" + }, + { + "default": "Company:company:default_currency", + "depends_on": "eval:(doc.docstatus==1 || doc.salary_structure)", + "fetch_from": "salary_structure.currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "read_only": 1, + "reqd": 1 + }, + { + "depends_on": "employee", + "fieldname": "payroll_payable_account", + "fieldtype": "Link", + "label": "Payroll Payable Account", + "options": "Account" } ], "is_submittable": 1, "links": [], - "modified": "2020-06-22 19:58:09.964692", + "modified": "2020-11-30 18:07:48.251311", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Structure Assignment", diff --git a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py index 668e0ec4717..a0c3013061d 100644 --- a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -13,6 +13,8 @@ class DuplicateAssignment(frappe.ValidationError): pass class SalaryStructureAssignment(Document): def validate(self): self.validate_dates() + self.validate_income_tax_slab() + self.set_payroll_payable_account() def validate_dates(self): joining_date, relieving_date = frappe.db.get_value("Employee", self.employee, @@ -31,6 +33,24 @@ class SalaryStructureAssignment(Document): frappe.throw(_("From Date {0} cannot be after employee's relieving Date {1}") .format(self.from_date, relieving_date)) + def validate_income_tax_slab(self): + if not self.income_tax_slab: + return + + income_tax_slab_currency = frappe.db.get_value('Income Tax Slab', self.income_tax_slab, 'currency') + if self.currency != income_tax_slab_currency: + frappe.throw(_("Currency of selected Income Tax Slab should be {0} instead of {1}").format(self.currency, income_tax_slab_currency)) + + def set_payroll_payable_account(self): + if not self.payroll_payable_account: + payroll_payable_account = frappe.db.get_value('Company', self.company, 'default_payroll_payable_account') + if not payroll_payable_account: + payroll_payable_account = frappe.db.get_value( + "Account", { + "account_name": _("Payroll Payable"), "company": self.company, "account_currency": frappe.db.get_value( + "Company", self.company, "default_currency"), "is_group": 0}) + self.payroll_payable_account = payroll_payable_account + def get_assigned_salary_structure(employee, on_date): if not employee or not on_date: return None @@ -43,3 +63,10 @@ def get_assigned_salary_structure(employee, on_date): 'on_date': on_date, }) return salary_structure[0][0] if salary_structure else None + +@frappe.whitelist() +def get_employee_currency(employee): + employee_currency = frappe.db.get_value('Salary Structure Assignment', {'employee': employee}, 'currency') + if not employee_currency: + frappe.throw(_("There is no Salary Structure assigned to {0}. First assign a Salary Stucture.").format(employee)) + return employee_currency \ No newline at end of file diff --git a/erpnext/payroll/doctype/taxable_salary_slab/taxable_salary_slab.json b/erpnext/payroll/doctype/taxable_salary_slab/taxable_salary_slab.json index 94eda4c043a..65d3824f3aa 100644 --- a/erpnext/payroll/doctype/taxable_salary_slab/taxable_salary_slab.json +++ b/erpnext/payroll/doctype/taxable_salary_slab/taxable_salary_slab.json @@ -19,13 +19,15 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "From Amount", + "options": "currency", "reqd": 1 }, { "fieldname": "to_amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "To Amount" + "label": "To Amount", + "options": "currency" }, { "default": "0", @@ -53,7 +55,7 @@ ], "istable": 1, "links": [], - "modified": "2020-06-22 18:16:07.596493", + "modified": "2020-10-19 13:44:39.549337", "modified_by": "Administrator", "module": "Payroll", "name": "Taxable Salary Slab", diff --git a/erpnext/payroll/print_format/__init__.py b/erpnext/payroll/print_format/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/payroll/print_format/salary_slip_with_year_to_date/__init__.py b/erpnext/payroll/print_format/salary_slip_with_year_to_date/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/payroll/print_format/salary_slip_with_year_to_date/salary_slip_with_year_to_date.json b/erpnext/payroll/print_format/salary_slip_with_year_to_date/salary_slip_with_year_to_date.json new file mode 100644 index 00000000000..71ba37f6ed2 --- /dev/null +++ b/erpnext/payroll/print_format/salary_slip_with_year_to_date/salary_slip_with_year_to_date.json @@ -0,0 +1,25 @@ +{ + "absolute_value": 0, + "align_labels_right": 0, + "creation": "2021-01-14 09:56:42.393623", + "custom_format": 0, + "default_print_language": "en", + "disabled": 0, + "doc_type": "Salary Slip", + "docstatus": 0, + "doctype": "Print Format", + "font": "Default", + "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"

    {{doc.name}}

    \\n
    \\n
    \\n
    \"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"employee\", \"print_hide\": 0, \"label\": \"Employee\"}, {\"fieldname\": \"company\", \"print_hide\": 0, \"label\": \"Company\"}, {\"fieldname\": \"employee_name\", \"print_hide\": 0, \"label\": \"Employee Name\"}, {\"fieldname\": \"department\", \"print_hide\": 0, \"label\": \"Department\"}, {\"fieldname\": \"designation\", \"print_hide\": 0, \"label\": \"Designation\"}, {\"fieldname\": \"branch\", \"print_hide\": 0, \"label\": \"Branch\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"start_date\", \"print_hide\": 0, \"label\": \"Start Date\"}, {\"fieldname\": \"end_date\", \"print_hide\": 0, \"label\": \"End Date\"}, {\"fieldname\": \"total_working_days\", \"print_hide\": 0, \"label\": \"Working Days\"}, {\"fieldname\": \"leave_without_pay\", \"print_hide\": 0, \"label\": \"Leave Without Pay\"}, {\"fieldname\": \"payment_days\", \"print_hide\": 0, \"label\": \"Payment Days\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"earnings\", \"print_hide\": 0, \"label\": \"Earnings\", \"visible_columns\": [{\"fieldname\": \"salary_component\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"year_to_date\", \"print_width\": \"\", \"print_hide\": 0}]}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"deductions\", \"print_hide\": 0, \"label\": \"Deductions\", \"visible_columns\": [{\"fieldname\": \"salary_component\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"year_to_date\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"depends_on_payment_days\", \"print_width\": \"\", \"print_hide\": 0}]}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"gross_pay\", \"print_hide\": 0, \"label\": \"Gross Pay\"}, {\"fieldname\": \"total_deduction\", \"print_hide\": 0, \"label\": \"Total Deduction\"}, {\"fieldname\": \"net_pay\", \"print_hide\": 0, \"label\": \"Net Pay\"}, {\"fieldname\": \"rounded_total\", \"print_hide\": 0, \"label\": \"Rounded Total\"}, {\"fieldname\": \"total_in_words\", \"print_hide\": 0, \"label\": \"Total in words\"}, {\"fieldtype\": \"Section Break\", \"label\": \"net pay info\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"year_to_date\", \"print_hide\": 0, \"label\": \"Year To Date\"}, {\"fieldname\": \"month_to_date\", \"print_hide\": 0, \"label\": \"Month To Date\"}]", + "idx": 0, + "line_breaks": 0, + "modified": "2021-01-14 10:03:45.283725", + "modified_by": "Administrator", + "module": "Payroll", + "name": "Salary Slip with Year to Date", + "owner": "Administrator", + "print_format_builder": 0, + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 0, + "standard": "Yes" +} \ No newline at end of file diff --git a/erpnext/payroll/report/bank_remittance/bank_remittance.py b/erpnext/payroll/report/bank_remittance/bank_remittance.py index 4b052bf5c4b..500543ceb02 100644 --- a/erpnext/payroll/report/bank_remittance/bank_remittance.py +++ b/erpnext/payroll/report/bank_remittance/bank_remittance.py @@ -47,33 +47,39 @@ def execute(filters=None): "fieldtype": "Int", "fieldname": "employee_account_no", "width": 50 - }, - { + } + ] + + if frappe.db.has_column('Employee', 'ifsc_code'): + columns.append({ "label": _("IFSC Code"), "fieldtype": "Data", "fieldname": "bank_code", "width": 100 - }, - { - "label": _("Currency"), - "fieldtype": "Data", - "fieldname": "currency", - "width": 50 - }, - { - "label": _("Net Salary Amount"), - "fieldtype": "Currency", - "options": "currency", - "fieldname": "amount", - "width": 100 - } - ] + }) + + columns += [{ + "label": _("Currency"), + "fieldtype": "Data", + "fieldname": "currency", + "width": 50 + }, + { + "label": _("Net Salary Amount"), + "fieldtype": "Currency", + "options": "currency", + "fieldname": "amount", + "width": 100 + }] + data = [] accounts = get_bank_accounts() payroll_entries = get_payroll_entries(accounts, filters) salary_slips = get_salary_slips(payroll_entries) - get_emp_bank_ifsc_code(salary_slips) + + if frappe.db.has_column('Employee', 'ifsc_code'): + get_emp_bank_ifsc_code(salary_slips) for salary in salary_slips: if salary.bank_name and salary.bank_account_no and salary.debit_acc_no and salary.status in ["Submitted", "Paid"]: diff --git a/erpnext/payroll/report/salary_register/salary_register.js b/erpnext/payroll/report/salary_register/salary_register.js index 885e3d13c7f..eb4acb91a73 100644 --- a/erpnext/payroll/report/salary_register/salary_register.js +++ b/erpnext/payroll/report/salary_register/salary_register.js @@ -8,34 +8,48 @@ frappe.query_reports["Salary Register"] = { "label": __("From"), "fieldtype": "Date", "default": frappe.datetime.add_months(frappe.datetime.get_today(),-1), - "reqd": 1 + "reqd": 1, + "width": "100px" }, { "fieldname":"to_date", "label": __("To"), "fieldtype": "Date", "default": frappe.datetime.get_today(), - "reqd": 1 + "reqd": 1, + "width": "100px" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "options": "Currency", + "label": __("Currency"), + "default": erpnext.get_currency(frappe.defaults.get_default("Company")), + "width": "50px" }, { "fieldname":"employee", "label": __("Employee"), "fieldtype": "Link", - "options": "Employee" + "options": "Employee", + "width": "100px" }, { "fieldname":"company", "label": __("Company"), "fieldtype": "Link", "options": "Company", - "default": frappe.defaults.get_user_default("Company") + "default": frappe.defaults.get_user_default("Company"), + "width": "100px", + "reqd": 1 }, { "fieldname":"docstatus", "label":__("Document Status"), "fieldtype":"Select", "options":["Draft", "Submitted", "Cancelled"], - "default":"Submitted" + "default": "Submitted", + "width": "100px" } ] } diff --git a/erpnext/payroll/report/salary_register/salary_register.py b/erpnext/payroll/report/salary_register/salary_register.py index 87010855fdb..a1b1a8c56b5 100644 --- a/erpnext/payroll/report/salary_register/salary_register.py +++ b/erpnext/payroll/report/salary_register/salary_register.py @@ -2,18 +2,22 @@ # License: GNU General Public License v3. See license.txt from __future__ import unicode_literals -import frappe +import frappe, erpnext from frappe.utils import flt from frappe import _ def execute(filters=None): if not filters: filters = {} - salary_slips = get_salary_slips(filters) + currency = None + if filters.get('currency'): + currency = filters.get('currency') + company_currency = erpnext.get_company_currency(filters.get("company")) + salary_slips = get_salary_slips(filters, company_currency) if not salary_slips: return [], [] columns, earning_types, ded_types = get_columns(salary_slips) - ss_earning_map = get_ss_earning_map(salary_slips) - ss_ded_map = get_ss_ded_map(salary_slips) + ss_earning_map = get_ss_earning_map(salary_slips, currency, company_currency) + ss_ded_map = get_ss_ded_map(salary_slips,currency, company_currency) doj_map = get_employee_doj_map() data = [] @@ -21,24 +25,30 @@ def execute(filters=None): row = [ss.name, ss.employee, ss.employee_name, doj_map.get(ss.employee), ss.branch, ss.department, ss.designation, ss.company, ss.start_date, ss.end_date, ss.leave_without_pay, ss.payment_days] - if not ss.branch == None:columns[3] = columns[3].replace('-1','120') - if not ss.department == None: columns[4] = columns[4].replace('-1','120') - if not ss.designation == None: columns[5] = columns[5].replace('-1','120') - if not ss.leave_without_pay == None: columns[9] = columns[9].replace('-1','130') + if ss.branch is not None: columns[3] = columns[3].replace('-1','120') + if ss.department is not None: columns[4] = columns[4].replace('-1','120') + if ss.designation is not None: columns[5] = columns[5].replace('-1','120') + if ss.leave_without_pay is not None: columns[9] = columns[9].replace('-1','130') for e in earning_types: row.append(ss_earning_map.get(ss.name, {}).get(e)) - row += [ss.gross_pay] + if currency == company_currency: + row += [flt(ss.gross_pay) * flt(ss.exchange_rate)] + else: + row += [ss.gross_pay] for d in ded_types: row.append(ss_ded_map.get(ss.name, {}).get(d)) row.append(ss.total_loan_repayment) - row += [ss.total_deduction, ss.net_pay] - + if currency == company_currency: + row += [flt(ss.total_deduction) * flt(ss.exchange_rate), flt(ss.net_pay) * flt(ss.exchange_rate)] + else: + row += [ss.total_deduction, ss.net_pay] + row.append(currency or company_currency) data.append(row) return columns, data @@ -46,10 +56,19 @@ def execute(filters=None): def get_columns(salary_slips): """ columns = [ - _("Salary Slip ID") + ":Link/Salary Slip:150",_("Employee") + ":Link/Employee:120", _("Employee Name") + "::140", - _("Date of Joining") + "::80", _("Branch") + ":Link/Branch:120", _("Department") + ":Link/Department:120", - _("Designation") + ":Link/Designation:120", _("Company") + ":Link/Company:120", _("Start Date") + "::80", - _("End Date") + "::80", _("Leave Without Pay") + ":Float:130", _("Payment Days") + ":Float:120" + _("Salary Slip ID") + ":Link/Salary Slip:150", + _("Employee") + ":Link/Employee:120", + _("Employee Name") + "::140", + _("Date of Joining") + "::80", + _("Branch") + ":Link/Branch:120", + _("Department") + ":Link/Department:120", + _("Designation") + ":Link/Designation:120", + _("Company") + ":Link/Company:120", + _("Start Date") + "::80", + _("End Date") + "::80", + _("Leave Without Pay") + ":Float:130", + _("Payment Days") + ":Float:120", + _("Currency") + ":Link/Currency:80" ] """ columns = [ @@ -73,15 +92,15 @@ def get_columns(salary_slips): return columns, salary_components[_("Earning")], salary_components[_("Deduction")] -def get_salary_slips(filters): +def get_salary_slips(filters, company_currency): filters.update({"from_date": filters.get("from_date"), "to_date":filters.get("to_date")}) - conditions, filters = get_conditions(filters) + conditions, filters = get_conditions(filters, company_currency) salary_slips = frappe.db.sql("""select * from `tabSalary Slip` where %s order by employee""" % conditions, filters, as_dict=1) return salary_slips or [] -def get_conditions(filters): +def get_conditions(filters, company_currency): conditions = "" doc_status = {"Draft": 0, "Submitted": 1, "Cancelled": 2} @@ -92,6 +111,8 @@ def get_conditions(filters): if filters.get("to_date"): conditions += " and end_date <= %(to_date)s" if filters.get("company"): conditions += " and company = %(company)s" if filters.get("employee"): conditions += " and employee = %(employee)s" + if filters.get("currency") and filters.get("currency") != company_currency: + conditions += " and currency = %(currency)s" return conditions, filters @@ -103,26 +124,32 @@ def get_employee_doj_map(): FROM `tabEmployee` """)) -def get_ss_earning_map(salary_slips): - ss_earnings = frappe.db.sql("""select parent, salary_component, amount - from `tabSalary Detail` where parent in (%s)""" % +def get_ss_earning_map(salary_slips, currency, company_currency): + ss_earnings = frappe.db.sql("""select sd.parent, sd.salary_component, sd.amount, ss.exchange_rate, ss.name + from `tabSalary Detail` sd, `tabSalary Slip` ss where sd.parent=ss.name and sd.parent in (%s)""" % (', '.join(['%s']*len(salary_slips))), tuple([d.name for d in salary_slips]), as_dict=1) ss_earning_map = {} for d in ss_earnings: ss_earning_map.setdefault(d.parent, frappe._dict()).setdefault(d.salary_component, []) - ss_earning_map[d.parent][d.salary_component] = flt(d.amount) + if currency == company_currency: + ss_earning_map[d.parent][d.salary_component] = flt(d.amount) * flt(d.exchange_rate if d.exchange_rate else 1) + else: + ss_earning_map[d.parent][d.salary_component] = flt(d.amount) return ss_earning_map -def get_ss_ded_map(salary_slips): - ss_deductions = frappe.db.sql("""select parent, salary_component, amount - from `tabSalary Detail` where parent in (%s)""" % +def get_ss_ded_map(salary_slips, currency, company_currency): + ss_deductions = frappe.db.sql("""select sd.parent, sd.salary_component, sd.amount, ss.exchange_rate, ss.name + from `tabSalary Detail` sd, `tabSalary Slip` ss where sd.parent=ss.name and sd.parent in (%s)""" % (', '.join(['%s']*len(salary_slips))), tuple([d.name for d in salary_slips]), as_dict=1) ss_ded_map = {} for d in ss_deductions: ss_ded_map.setdefault(d.parent, frappe._dict()).setdefault(d.salary_component, []) - ss_ded_map[d.parent][d.salary_component] = flt(d.amount) + if currency == company_currency: + ss_ded_map[d.parent][d.salary_component] = flt(d.amount) * flt(d.exchange_rate if d.exchange_rate else 1) + else: + ss_ded_map[d.parent][d.salary_component] = flt(d.amount) return ss_ded_map diff --git a/erpnext/payroll/workspace/payroll/payroll.json b/erpnext/payroll/workspace/payroll/payroll.json new file mode 100644 index 00000000000..814973063da --- /dev/null +++ b/erpnext/payroll/workspace/payroll/payroll.json @@ -0,0 +1,333 @@ +{ + "category": "Modules", + "charts": [ + { + "chart_name": "Outgoing Salary", + "label": "Outgoing Salary" + } + ], + "creation": "2020-05-27 19:54:23.405607", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "money-coins-1", + "idx": 0, + "is_standard": 1, + "label": "Payroll", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Payroll", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Salary Component", + "link_to": "Salary Component", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Salary Structure", + "link_to": "Salary Structure", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Salary Structure Assignment", + "link_to": "Salary Structure Assignment", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Payroll Entry", + "link_to": "Payroll Entry", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Salary Slip", + "link_to": "Salary Slip", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Taxation", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Payroll Period", + "link_to": "Payroll Period", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Income Tax Slab", + "link_to": "Income Tax Slab", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Other Income", + "link_to": "Employee Other Income", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Tax Exemption Declaration", + "link_to": "Employee Tax Exemption Declaration", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Tax Exemption Proof Submission", + "link_to": "Employee Tax Exemption Proof Submission", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Tax Exemption Category", + "link_to": "Employee Tax Exemption Category", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Tax Exemption Sub Category", + "link_to": "Employee Tax Exemption Sub Category", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Compensations", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Additional Salary", + "link_to": "Additional Salary", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Retention Bonus", + "link_to": "Retention Bonus", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Incentive", + "link_to": "Employee Incentive", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Benefit Application", + "link_to": "Employee Benefit Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Benefit Claim", + "link_to": "Employee Benefit Claim", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Salary Slip", + "hidden": 0, + "is_query_report": 1, + "label": "Salary Register", + "link_to": "Salary Register", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Salary Slip", + "hidden": 0, + "is_query_report": 1, + "label": "Salary Payments Based On Payment Mode", + "link_to": "Salary Payments Based On Payment Mode", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Salary Slip", + "hidden": 0, + "is_query_report": 1, + "label": "Salary Payments via ECS", + "link_to": "Salary Payments via ECS", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Salary Slip", + "hidden": 0, + "is_query_report": 1, + "label": "Income Tax Deductions", + "link_to": "Income Tax Deductions", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Salary Slip", + "hidden": 0, + "is_query_report": 1, + "label": "Professional Tax Deductions", + "link_to": "Professional Tax Deductions", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Salary Slip", + "hidden": 0, + "is_query_report": 1, + "label": "Provident Fund Deductions", + "link_to": "Provident Fund Deductions", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Payroll Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Bank Remittance", + "link_to": "Bank Remittance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:37.205628", + "modified_by": "Administrator", + "module": "Payroll", + "name": "Payroll", + "onboarding": "Payroll", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "label": "Salary Structure", + "link_to": "Salary Structure", + "type": "DocType" + }, + { + "label": "Payroll Entry", + "link_to": "Payroll Entry", + "type": "DocType" + }, + { + "color": "", + "format": "{} Pending", + "label": "Salary Slip", + "link_to": "Salary Slip", + "stats_filter": "{\"status\": \"Draft\"}", + "type": "DocType" + }, + { + "label": "Income Tax Slab", + "link_to": "Income Tax Slab", + "type": "DocType" + }, + { + "label": "Salary Register", + "link_to": "Salary Register", + "type": "Report" + }, + { + "label": "Dashboard", + "link_to": "Payroll", + "type": "Dashboard" + } + ] +} \ No newline at end of file diff --git a/erpnext/portal/doctype/products_settings/products_settings.py b/erpnext/portal/doctype/products_settings/products_settings.py index ae7dc680208..9a708924ae0 100644 --- a/erpnext/portal/doctype/products_settings/products_settings.py +++ b/erpnext/portal/doctype/products_settings/products_settings.py @@ -17,6 +17,7 @@ class ProductsSettings(Document): self.validate_field_filters() self.validate_attribute_filters() + frappe.clear_document_cache("Product Settings", "Product Settings") def validate_field_filters(self): if not (self.enable_field_filters and self.filter_fields): return diff --git a/erpnext/portal/product_configurator/test_product_configurator.py b/erpnext/portal/product_configurator/test_product_configurator.py index 97042dba92c..3521e7e8bf0 100644 --- a/erpnext/portal/product_configurator/test_product_configurator.py +++ b/erpnext/portal/product_configurator/test_product_configurator.py @@ -10,8 +10,38 @@ from erpnext.stock.doctype.item.test_item import make_item_variant test_dependencies = ["Item"] class TestProductConfigurator(unittest.TestCase): - def setUp(self): - self.create_variant_item() + @classmethod + def setUpClass(cls): + cls.create_variant_item() + + @classmethod + def create_variant_item(cls): + if not frappe.db.exists('Item', '_Test Variant Item - 2XL'): + frappe.get_doc({ + "description": "_Test Variant Item - 2XL", + "item_code": "_Test Variant Item - 2XL", + "item_name": "_Test Variant Item - 2XL", + "doctype": "Item", + "is_stock_item": 1, + "variant_of": "_Test Variant Item", + "item_group": "_Test Item Group", + "stock_uom": "_Test UOM", + "item_defaults": [{ + "company": "_Test Company", + "default_warehouse": "_Test Warehouse - _TC", + "expense_account": "_Test Account Cost for Goods Sold - _TC", + "buying_cost_center": "_Test Cost Center - _TC", + "selling_cost_center": "_Test Cost Center - _TC", + "income_account": "Sales - _TC" + }], + "attributes": [ + { + "attribute": "Test Size", + "attribute_value": "2XL" + } + ], + "show_variant_in_website": 1 + }).insert() def test_product_list(self): template_items = frappe.get_all('Item', {'show_in_website': 1}) @@ -46,39 +76,6 @@ class TestProductConfigurator(unittest.TestCase): def test_get_products_for_website(self): items = get_products_for_website(attribute_filters={ - 'Test Size': ['Medium'] + 'Test Size': ['2XL'] }) self.assertEqual(len(items), 1) - - - def create_variant_item(self): - if not frappe.db.exists('Item', '_Test Variant Item 1'): - frappe.get_doc({ - "description": "_Test Variant Item 12", - "doctype": "Item", - "is_stock_item": 1, - "variant_of": "_Test Variant Item", - "item_code": "_Test Variant Item 1", - "item_group": "_Test Item Group", - "item_name": "_Test Variant Item 1", - "stock_uom": "_Test UOM", - "item_defaults": [{ - "company": "_Test Company", - "default_warehouse": "_Test Warehouse - _TC", - "expense_account": "_Test Account Cost for Goods Sold - _TC", - "buying_cost_center": "_Test Cost Center - _TC", - "selling_cost_center": "_Test Cost Center - _TC", - "income_account": "Sales - _TC" - }], - "attributes": [ - { - "attribute": "Test Size", - "attribute_value": "Medium" - } - ], - "show_variant_in_website": 1 - }).insert() - - - def tearDown(self): - frappe.db.rollback() \ No newline at end of file diff --git a/erpnext/portal/product_configurator/utils.py b/erpnext/portal/product_configurator/utils.py index 9ba4cdc5145..d77eb2c3966 100644 --- a/erpnext/portal/product_configurator/utils.py +++ b/erpnext/portal/product_configurator/utils.py @@ -1,6 +1,7 @@ import frappe from frappe.utils import cint from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager +from erpnext.shopping_cart.product_info import get_product_info_for_website def get_field_filter_data(): product_settings = get_product_settings() @@ -297,7 +298,7 @@ def get_items_by_fields(field_filters): def get_items(filters=None, search=None): - start = frappe.form_dict.start or 0 + start = frappe.form_dict.get('start', 0) products_settings = get_product_settings() page_length = products_settings.products_per_page @@ -356,10 +357,10 @@ def get_items(filters=None, search=None): results = frappe.db.sql(''' SELECT - `tabItem`.`name`, `tabItem`.`item_name`, + `tabItem`.`name`, `tabItem`.`item_name`, `tabItem`.`item_code`, `tabItem`.`website_image`, `tabItem`.`image`, `tabItem`.`web_long_description`, `tabItem`.`description`, - `tabItem`.`route` + `tabItem`.`route`, `tabItem`.`item_group` FROM `tabItem` {left_join} @@ -384,6 +385,9 @@ def get_items(filters=None, search=None): for r in results: r.description = r.web_long_description or r.description r.image = r.website_image or r.image + product_info = get_product_info_for_website(r.item_code, skip_quotation_creation=True).get('product_info') + if product_info: + r.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None return results diff --git a/erpnext/projects/desk_page/projects/projects.json b/erpnext/projects/desk_page/projects/projects.json deleted file mode 100644 index e24cf3081cb..00000000000 --- a/erpnext/projects/desk_page/projects/projects.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Projects", - "links": "[\n {\n \"description\": \"Project master.\",\n \"label\": \"Project\",\n \"name\": \"Project\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Project activity / task.\",\n \"label\": \"Task\",\n \"name\": \"Task\",\n \"onboard\": 1,\n \"route\": \"#List/Task\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Make project from a template.\",\n \"label\": \"Project Template\",\n \"name\": \"Project Template\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Define Project type.\",\n \"label\": \"Project Type\",\n \"name\": \"Project Type\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Project\"\n ],\n \"description\": \"Project Update.\",\n \"label\": \"Project Update\",\n \"name\": \"Project Update\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Time Tracking", - "links": "[\n {\n \"description\": \"Timesheet for tasks.\",\n \"label\": \"Timesheet\",\n \"name\": \"Timesheet\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Types of activities for Time Logs\",\n \"label\": \"Activity Type\",\n \"name\": \"Activity Type\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Activity Type\"\n ],\n \"description\": \"Cost of various activities\",\n \"label\": \"Activity Cost\",\n \"name\": \"Activity Cost\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Reports", - "links": "[\n {\n \"dependencies\": [\n \"Timesheet\"\n ],\n \"doctype\": \"Timesheet\",\n \"is_query_report\": true,\n \"label\": \"Daily Timesheet Summary\",\n \"name\": \"Daily Timesheet Summary\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Project\"\n ],\n \"doctype\": \"Project\",\n \"is_query_report\": true,\n \"label\": \"Project wise Stock Tracking\",\n \"name\": \"Project wise Stock Tracking\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Project\"\n ],\n \"doctype\": \"Project\",\n \"is_query_report\": true,\n \"label\": \"Project Billing Summary\",\n \"name\": \"Project Billing Summary\",\n \"type\": \"report\"\n }\n]" - } - ], - "category": "Modules", - "charts": [ - { - "chart_name": "Project Summary", - "label": "Open Projects" - } - ], - "creation": "2020-03-02 15:46:04.874669", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Projects", - "modified": "2020-05-28 13:38:19.934937", - "modified_by": "Administrator", - "module": "Projects", - "name": "Projects", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "color": "#cef6d1", - "format": "{} Assigned", - "label": "Task", - "link_to": "Task", - "stats_filter": "{\n \"_assign\": [\"like\", '%' + frappe.session.user + '%'],\n \"status\": \"Open\"\n}", - "type": "DocType" - }, - { - "color": "#ffe8cd", - "format": "{} Open", - "label": "Project", - "link_to": "Project", - "stats_filter": "{\n \"status\": \"Open\"\n}", - "type": "DocType" - }, - { - "label": "Timesheet", - "link_to": "Timesheet", - "type": "DocType" - }, - { - "label": "Project Billing Summary", - "link_to": "Project Billing Summary", - "type": "Report" - }, - { - "label": "Dashboard", - "link_to": "Project", - "type": "Dashboard" - } - ] -} \ No newline at end of file diff --git a/erpnext/projects/doctype/project/project.js b/erpnext/projects/doctype/project/project.js index 3570a0f2be4..077011ace07 100644 --- a/erpnext/projects/doctype/project/project.js +++ b/erpnext/projects/doctype/project/project.js @@ -75,24 +75,27 @@ frappe.ui.form.on("Project", { frm.add_custom_button(__('Cancelled'), () => { frm.events.set_status(frm, 'Cancelled'); }, __('Set Status')); - } - if (frappe.model.can_read("Task")) { - frm.add_custom_button(__("Gantt Chart"), function () { - frappe.route_options = { - "project": frm.doc.name - }; - frappe.set_route("List", "Task", "Gantt"); - }); - frm.add_custom_button(__("Kanban Board"), () => { - frappe.call('erpnext.projects.doctype.project.project.create_kanban_board_if_not_exists', { - project: frm.doc.project_name - }).then(() => { - frappe.set_route('List', 'Task', 'Kanban', frm.doc.project_name); + if (frappe.model.can_read("Task")) { + frm.add_custom_button(__("Gantt Chart"), function () { + frappe.route_options = { + "project": frm.doc.name + }; + frappe.set_route("List", "Task", "Gantt"); }); - }); + + frm.add_custom_button(__("Kanban Board"), () => { + frappe.call('erpnext.projects.doctype.project.project.create_kanban_board_if_not_exists', { + project: frm.doc.project_name + }).then(() => { + frappe.set_route('List', 'Task', 'Kanban', frm.doc.project_name); + }); + }); + } } + + }, create_duplicate: function(frm) { @@ -135,4 +138,4 @@ function open_form(frm, doctype, child_doctype, parentfield) { frappe.ui.form.make_quick_entry(doctype, null, null, new_doc); }); -} \ No newline at end of file +} diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json index f3cecd9059b..3cdfcb212f5 100644 --- a/erpnext/projects/doctype/project/project.json +++ b/erpnext/projects/doctype/project/project.json @@ -2,12 +2,13 @@ "actions": [], "allow_import": 1, "allow_rename": 1, - "autoname": "field:project_name", + "autoname": "naming_series:", "creation": "2013-03-07 11:55:07", "doctype": "DocType", "document_type": "Setup", "engine": "InnoDB", "field_order": [ + "naming_series", "project_name", "status", "project_type", @@ -440,13 +441,24 @@ "fieldtype": "Text", "label": "Message", "mandatory_depends_on": "collect_progress" + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "no_copy": 1, + "options": "PROJ-.####", + "print_hide": 1, + "reqd": 1, + "set_only_once": 1 } ], "icon": "fa fa-puzzle-piece", "idx": 29, + "index_web_pages_for_search": 1, "links": [], "max_attachments": 4, - "modified": "2020-04-08 22:11:14.552615", + "modified": "2020-09-02 11:54:01.223620", "modified_by": "Administrator", "module": "Projects", "name": "Project", @@ -488,5 +500,6 @@ "sort_field": "modified", "sort_order": "DESC", "timeline_field": "customer", + "title_field": "project_name", "track_seen": 1 } diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 5bbd29c4c42..f9e1359b450 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -13,6 +13,7 @@ from frappe.desk.reportview import get_match_cond from erpnext.hr.doctype.daily_work_summary.daily_work_summary import get_users_email from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday from frappe.model.document import Document +from erpnext.education.doctype.student_attendance.student_attendance import get_holiday_list class Project(Document): def get_feed(self): @@ -26,7 +27,7 @@ class Project(Document): self.update_costing() - def before_print(self): + def before_print(self, settings=None): self.onload() @@ -54,17 +55,70 @@ class Project(Document): self.project_type = template.project_type # create tasks from template + project_tasks = [] + tmp_task_details = [] for task in template.tasks: - frappe.get_doc(dict( - doctype = 'Task', - subject = task.subject, - project = self.name, - status = 'Open', - exp_start_date = add_days(self.expected_start_date, task.start), - exp_end_date = add_days(self.expected_start_date, task.start + task.duration), - description = task.description, - task_weight = task.task_weight - )).insert() + template_task_details = frappe.get_doc("Task", task.task) + tmp_task_details.append(template_task_details) + task = self.create_task_from_template(template_task_details) + project_tasks.append(task) + self.dependency_mapping(tmp_task_details, project_tasks) + + def create_task_from_template(self, task_details): + return frappe.get_doc(dict( + doctype = 'Task', + subject = task_details.subject, + project = self.name, + status = 'Open', + exp_start_date = self.calculate_start_date(task_details), + exp_end_date = self.calculate_end_date(task_details), + description = task_details.description, + task_weight = task_details.task_weight, + type = task_details.type, + issue = task_details.issue, + is_group = task_details.is_group + )).insert() + + def calculate_start_date(self, task_details): + self.start_date = add_days(self.expected_start_date, task_details.start) + self.start_date = self.update_if_holiday(self.start_date) + return self.start_date + + def calculate_end_date(self, task_details): + self.end_date = add_days(self.start_date, task_details.duration) + return self.update_if_holiday(self.end_date) + + def update_if_holiday(self, date): + holiday_list = self.holiday_list or get_holiday_list(self.company) + while is_holiday(holiday_list, date): + date = add_days(date, 1) + return date + + def dependency_mapping(self, template_tasks, project_tasks): + for template_task in template_tasks: + project_task = list(filter(lambda x: x.subject == template_task.subject, project_tasks))[0] + project_task = frappe.get_doc("Task", project_task.name) + self.check_depends_on_value(template_task, project_task, project_tasks) + self.check_for_parent_tasks(template_task, project_task, project_tasks) + + def check_depends_on_value(self, template_task, project_task, project_tasks): + if template_task.get("depends_on") and not project_task.get("depends_on"): + for child_task in template_task.get("depends_on"): + child_task_subject = frappe.db.get_value("Task", child_task.task, "subject") + corresponding_project_task = list(filter(lambda x: x.subject == child_task_subject, project_tasks)) + if len(corresponding_project_task): + project_task.append("depends_on",{ + "task": corresponding_project_task[0].name + }) + project_task.save() + + def check_for_parent_tasks(self, template_task, project_task, project_tasks): + if template_task.get("parent_task") and not project_task.get("parent_task"): + parent_task_subject = frappe.db.get_value("Task", template_task.get("parent_task"), "subject") + corresponding_project_task = list(filter(lambda x: x.subject == parent_task_subject, project_tasks)) + if len(corresponding_project_task): + project_task.parent_task = corresponding_project_task[0].name + project_task.save() def is_row_updated(self, row, existing_task_data, fields): if self.get("__islocal") or not existing_task_data: return True diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index 0c4f6f1bdfe..15a2873aded 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -7,60 +7,131 @@ import frappe, unittest test_records = frappe.get_test_records('Project') test_ignore = ["Sales Order"] -from erpnext.projects.doctype.project_template.test_project_template import get_project_template, make_project_template -from erpnext.projects.doctype.project.project import set_project_status - -from frappe.utils import getdate +from erpnext.projects.doctype.project_template.test_project_template import make_project_template +from erpnext.projects.doctype.task.test_task import create_task +from frappe.utils import getdate, nowdate, add_days class TestProject(unittest.TestCase): - def test_project_with_template(self): - frappe.db.sql('delete from tabTask where project = "Test Project with Template"') - frappe.delete_doc('Project', 'Test Project with Template') + def test_project_with_template_having_no_parent_and_depend_tasks(self): + project_name = "Test Project with Template - No Parent and Dependend Tasks" + frappe.db.sql(""" delete from tabTask where project = %s """, project_name) + frappe.delete_doc('Project', project_name) - project = get_project('Test Project with Template') + task1 = task_exists("Test Template Task with No Parent and Dependency") + if not task1: + task1 = create_task(subject="Test Template Task with No Parent and Dependency", is_template=1, begin=5, duration=3) - tasks = frappe.get_all('Task', '*', dict(project=project.name), order_by='creation asc') + template = make_project_template("Test Project Template - No Parent and Dependend Tasks", [task1]) + project = get_project(project_name, template) + tasks = frappe.get_all('Task', ['subject','exp_end_date','depends_on_tasks'], dict(project=project.name), order_by='creation asc') - task1 = tasks[0] - self.assertEqual(task1.subject, 'Task 1') - self.assertEqual(task1.description, 'Task 1 description') - self.assertEqual(getdate(task1.exp_start_date), getdate('2019-01-01')) - self.assertEqual(getdate(task1.exp_end_date), getdate('2019-01-04')) + self.assertEqual(tasks[0].subject, 'Test Template Task with No Parent and Dependency') + self.assertEqual(getdate(tasks[0].exp_end_date), calculate_end_date(project, 5, 3)) + self.assertEqual(len(tasks), 1) - self.assertEqual(len(tasks), 4) - task4 = tasks[3] - self.assertEqual(task4.subject, 'Task 4') - self.assertEqual(getdate(task4.exp_end_date), getdate('2019-01-06')) + def test_project_template_having_parent_child_tasks(self): + project_name = "Test Project with Template - Tasks with Parent-Child Relation" + frappe.db.sql(""" delete from tabTask where project = %s """, project_name) + frappe.delete_doc('Project', project_name) -def get_project(name): - template = get_project_template() + task1 = task_exists("Test Template Task Parent") + if not task1: + task1 = create_task(subject="Test Template Task Parent", is_group=1, is_template=1, begin=1, duration=4) + + task2 = task_exists("Test Template Task Child 1") + if not task2: + task2 = create_task(subject="Test Template Task Child 1", parent_task=task1.name, is_template=1, begin=1, duration=3) + + task3 = task_exists("Test Template Task Child 2") + if not task3: + task3 = create_task(subject="Test Template Task Child 2", parent_task=task1.name, is_template=1, begin=2, duration=3) + + template = make_project_template("Test Project Template - Tasks with Parent-Child Relation", [task1, task2, task3]) + project = get_project(project_name, template) + tasks = frappe.get_all('Task', ['subject','exp_end_date','depends_on_tasks', 'name', 'parent_task'], dict(project=project.name), order_by='creation asc') + + self.assertEqual(tasks[0].subject, 'Test Template Task Parent') + self.assertEqual(getdate(tasks[0].exp_end_date), calculate_end_date(project, 1, 4)) + + self.assertEqual(tasks[1].subject, 'Test Template Task Child 1') + self.assertEqual(getdate(tasks[1].exp_end_date), calculate_end_date(project, 1, 3)) + self.assertEqual(tasks[1].parent_task, tasks[0].name) + + self.assertEqual(tasks[2].subject, 'Test Template Task Child 2') + self.assertEqual(getdate(tasks[2].exp_end_date), calculate_end_date(project, 2, 3)) + self.assertEqual(tasks[2].parent_task, tasks[0].name) + + self.assertEqual(len(tasks), 3) + + def test_project_template_having_dependent_tasks(self): + project_name = "Test Project with Template - Dependent Tasks" + frappe.db.sql(""" delete from tabTask where project = %s """, project_name) + frappe.delete_doc('Project', project_name) + + task1 = task_exists("Test Template Task for Dependency") + if not task1: + task1 = create_task(subject="Test Template Task for Dependency", is_template=1, begin=3, duration=1) + + task2 = task_exists("Test Template Task with Dependency") + if not task2: + task2 = create_task(subject="Test Template Task with Dependency", depends_on=task1.name, is_template=1, begin=2, duration=2) + + template = make_project_template("Test Project with Template - Dependent Tasks", [task1, task2]) + project = get_project(project_name, template) + tasks = frappe.get_all('Task', ['subject','exp_end_date','depends_on_tasks', 'name'], dict(project=project.name), order_by='creation asc') + + self.assertEqual(tasks[1].subject, 'Test Template Task with Dependency') + self.assertEqual(getdate(tasks[1].exp_end_date), calculate_end_date(project, 2, 2)) + self.assertTrue(tasks[1].depends_on_tasks.find(tasks[0].name) >= 0 ) + + self.assertEqual(tasks[0].subject, 'Test Template Task for Dependency') + self.assertEqual(getdate(tasks[0].exp_end_date), calculate_end_date(project, 3, 1) ) + + self.assertEqual(len(tasks), 2) + +def get_project(name, template): project = frappe.get_doc(dict( doctype = 'Project', project_name = name, status = 'Open', project_template = template.name, - expected_start_date = '2019-01-01' + expected_start_date = nowdate(), + company="_Test Company" )).insert() return project def make_project(args): args = frappe._dict(args) - if args.project_template_name: - template = make_project_template(args.project_template_name) - else: - template = get_project_template() + + if args.project_name and frappe.db.exists("Project", {"project_name": args.project_name}): + return frappe.get_doc("Project", {"project_name": args.project_name}) project = frappe.get_doc(dict( doctype = 'Project', project_name = args.project_name, status = 'Open', - project_template = template.name, expected_start_date = args.start_date )) - if not frappe.db.exists("Project", args.project_name): - project.insert() + if args.project_template_name: + template = make_project_template(args.project_template_name) + project.project_template = template.name - return project \ No newline at end of file + project.insert() + + return project + +def task_exists(subject): + result = frappe.db.get_list("Task", filters={"subject": subject},fields=["name"]) + if not len(result): + return False + return frappe.get_doc("Task", result[0].name) + +def calculate_end_date(project, start, duration): + start = add_days(project.expected_start_date, start) + start = project.update_if_holiday(start) + end = add_days(start, duration) + end = project.update_if_holiday(end) + return getdate(end) diff --git a/erpnext/projects/doctype/project_template/project_template.js b/erpnext/projects/doctype/project_template/project_template.js index d7a876dfbd3..3d3c15c6e05 100644 --- a/erpnext/projects/doctype/project_template/project_template.js +++ b/erpnext/projects/doctype/project_template/project_template.js @@ -5,4 +5,23 @@ frappe.ui.form.on('Project Template', { // refresh: function(frm) { // } + setup: function (frm) { + frm.set_query("task", "tasks", function () { + return { + filters: { + "is_template": 1 + } + }; + }); + } +}); + +frappe.ui.form.on('Project Template Task', { + task: function (frm, cdt, cdn) { + var row = locals[cdt][cdn]; + frappe.db.get_value("Task", row.task, "subject", (value) => { + row.subject = value.subject; + refresh_field("tasks"); + }); + } }); diff --git a/erpnext/projects/doctype/project_template/project_template.py b/erpnext/projects/doctype/project_template/project_template.py index ac78135fc42..aace40240c4 100644 --- a/erpnext/projects/doctype/project_template/project_template.py +++ b/erpnext/projects/doctype/project_template/project_template.py @@ -3,8 +3,28 @@ # For license information, please see license.txt from __future__ import unicode_literals -# import frappe +import frappe from frappe.model.document import Document +from frappe import _ +from frappe.utils import get_link_to_form class ProjectTemplate(Document): - pass + + def validate(self): + self.validate_dependencies() + + def validate_dependencies(self): + for task in self.tasks: + task_details = frappe.get_doc("Task", task.task) + if task_details.depends_on: + for dependency_task in task_details.depends_on: + if not self.check_dependent_task_presence(dependency_task.task): + task_details_format = get_link_to_form("Task",task_details.name) + dependency_task_format = get_link_to_form("Task", dependency_task.task) + frappe.throw(_("Task {0} depends on Task {1}. Please add Task {1} to the Tasks list.").format(frappe.bold(task_details_format), frappe.bold(dependency_task_format))) + + def check_dependent_task_presence(self, task): + for task_details in self.tasks: + if task_details.task == task: + return True + return False diff --git a/erpnext/projects/doctype/project_template/test_project_template.py b/erpnext/projects/doctype/project_template/test_project_template.py index 2c5831a5dc9..95663cdcbbb 100644 --- a/erpnext/projects/doctype/project_template/test_project_template.py +++ b/erpnext/projects/doctype/project_template/test_project_template.py @@ -5,44 +5,25 @@ from __future__ import unicode_literals import frappe import unittest +from erpnext.projects.doctype.task.test_task import create_task class TestProjectTemplate(unittest.TestCase): pass -def get_project_template(): - if not frappe.db.exists('Project Template', 'Test Project Template'): - frappe.get_doc(dict( - doctype = 'Project Template', - name = 'Test Project Template', - tasks = [ - dict(subject='Task 1', description='Task 1 description', - start=0, duration=3), - dict(subject='Task 2', description='Task 2 description', - start=0, duration=2), - dict(subject='Task 3', description='Task 3 description', - start=2, duration=4), - dict(subject='Task 4', description='Task 4 description', - start=3, duration=2), - ] - )).insert() - - return frappe.get_doc('Project Template', 'Test Project Template') - def make_project_template(project_template_name, project_tasks=[]): if not frappe.db.exists('Project Template', project_template_name): - frappe.get_doc(dict( - doctype = 'Project Template', - name = project_template_name, - tasks = project_tasks or [ - dict(subject='Task 1', description='Task 1 description', - start=0, duration=3), - dict(subject='Task 2', description='Task 2 description', - start=0, duration=2), - dict(subject='Task 3', description='Task 3 description', - start=2, duration=4), - dict(subject='Task 4', description='Task 4 description', - start=3, duration=2), + project_tasks = project_tasks or [ + create_task(subject="_Test Template Task 1", is_template=1, begin=0, duration=3), + create_task(subject="_Test Template Task 2", is_template=1, begin=0, duration=2), ] - )).insert() + doc = frappe.get_doc(dict( + doctype = 'Project Template', + name = project_template_name + )) + for task in project_tasks: + doc.append("tasks",{ + "task": task.name + }) + doc.insert() return frappe.get_doc('Project Template', project_template_name) \ No newline at end of file diff --git a/erpnext/projects/doctype/project_template_task/project_template_task.json b/erpnext/projects/doctype/project_template_task/project_template_task.json index 8644d897bb0..16caaa20ae4 100644 --- a/erpnext/projects/doctype/project_template_task/project_template_task.json +++ b/erpnext/projects/doctype/project_template_task/project_template_task.json @@ -1,203 +1,42 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, + "actions": [], "creation": "2019-02-18 17:24:41.830096", - "custom": 0, - "docstatus": 0, "doctype": "DocType", - "document_type": "", "editable_grid": 1, "engine": "InnoDB", + "field_order": [ + "task", + "subject" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "columns": 2, + "fieldname": "task", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Task", + "options": "Task", + "reqd": 1 + }, + { + "columns": 6, + "fetch_from": "task.subject", "fieldname": "subject", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, + "fieldtype": "Read Only", "in_list_view": 1, - "in_standard_filter": 0, - "label": "Subject", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "start", - "fieldtype": "Int", - "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": "Begin On (Days)", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "duration", - "fieldtype": "Int", - "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": "Duration (Days)", - "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_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "task_weight", - "fieldtype": "Float", - "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": "Task Weight", - "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_in_quick_entry": 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": 1, - "in_standard_filter": 0, - "label": "Description", - "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 + "label": "Subject" } ], - "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": "2019-02-18 18:30:22.688966", + "links": [], + "modified": "2021-02-24 15:18:49.095071", "modified_by": "Administrator", "module": "Projects", "name": "Project Template 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": 1, - "track_seen": 0, - "track_views": 0 + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/projects/doctype/task/task.js b/erpnext/projects/doctype/task/task.js index 8c6a9cf8d7c..002ddb2f409 100644 --- a/erpnext/projects/doctype/task/task.js +++ b/erpnext/projects/doctype/task/task.js @@ -49,7 +49,10 @@ frappe.ui.form.on("Task", { }, callback: function (r) { if (r.message.length > 0) { - frappe.msgprint(__(`Cannot convert it to non-group. The following child Tasks exist: ${r.message.join(", ")}.`)); + let message = __('Cannot convert Task to non-group because the following child Tasks exist: {0}.', + [r.message.join(", ")] + ); + frappe.msgprint(message); frm.reload_doc(); } } diff --git a/erpnext/projects/doctype/task/task.json b/erpnext/projects/doctype/task/task.json index 27f1a71a528..160cc5812f7 100644 --- a/erpnext/projects/doctype/task/task.json +++ b/erpnext/projects/doctype/task/task.json @@ -12,6 +12,7 @@ "issue", "type", "is_group", + "is_template", "column_break0", "status", "priority", @@ -22,9 +23,11 @@ "sb_timeline", "exp_start_date", "expected_time", + "start", "column_break_11", "exp_end_date", "progress", + "duration", "is_milestone", "sb_details", "description", @@ -112,7 +115,7 @@ "no_copy": 1, "oldfieldname": "status", "oldfieldtype": "Select", - "options": "Open\nWorking\nPending Review\nOverdue\nCompleted\nCancelled" + "options": "Open\nWorking\nPending Review\nOverdue\nTemplate\nCompleted\nCancelled" }, { "fieldname": "priority", @@ -360,6 +363,24 @@ "label": "Completed By", "no_copy": 1, "options": "User" + }, + { + "default": "0", + "fieldname": "is_template", + "fieldtype": "Check", + "label": "Is Template" + }, + { + "depends_on": "is_template", + "fieldname": "start", + "fieldtype": "Int", + "label": "Begin On (Days)" + }, + { + "depends_on": "is_template", + "fieldname": "duration", + "fieldtype": "Int", + "label": "Duration (Days)" } ], "icon": "fa fa-check", @@ -367,7 +388,7 @@ "is_tree": 1, "links": [], "max_attachments": 5, - "modified": "2020-07-03 12:36:04.960457", + "modified": "2020-12-28 11:32:58.714991", "modified_by": "Administrator", "module": "Projects", "name": "Task", diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index fb84094ffe6..855ff5f83e8 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -30,10 +30,12 @@ class Task(NestedSet): def validate(self): self.validate_dates() + self.validate_parent_expected_end_date() self.validate_parent_project_dates() self.validate_progress() self.validate_status() self.update_depends_on() + self.validate_dependencies_for_template_task() def validate_dates(self): if self.exp_start_date and self.exp_end_date and getdate(self.exp_start_date) > getdate(self.exp_end_date): @@ -44,6 +46,12 @@ class Task(NestedSet): frappe.throw(_("{0} can not be greater than {1}").format(frappe.bold("Actual Start Date"), \ frappe.bold("Actual End Date"))) + def validate_parent_expected_end_date(self): + if self.parent_task: + parent_exp_end_date = frappe.db.get_value("Task", self.parent_task, "exp_end_date") + if parent_exp_end_date and getdate(self.get("exp_end_date")) > getdate(parent_exp_end_date): + frappe.throw(_("Expected End Date should be less than or equal to parent task's Expected End Date {0}.").format(getdate(parent_exp_end_date))) + def validate_parent_project_dates(self): if not self.project or frappe.flags.in_test: return @@ -55,6 +63,8 @@ class Task(NestedSet): validate_project_dates(getdate(expected_end_date), self, "act_start_date", "act_end_date", "Actual") def validate_status(self): + if self.is_template and self.status != "Template": + self.status = "Template" if self.status!=self.get_db_value("status") and self.status == "Completed": for d in self.depends_on: if frappe.db.get_value("Task", d.task, "status") not in ("Completed", "Cancelled"): @@ -72,10 +82,28 @@ class Task(NestedSet): if self.status == 'Completed': self.progress = 100 + def validate_dependencies_for_template_task(self): + if self.is_template: + self.validate_parent_template_task() + self.validate_depends_on_tasks() + + def validate_parent_template_task(self): + if self.parent_task: + if not frappe.db.get_value("Task", self.parent_task, "is_template"): + parent_task_format = """{0}""".format(self.parent_task) + frappe.throw(_("Parent Task {0} is not a Template Task").format(parent_task_format)) + + def validate_depends_on_tasks(self): + if self.depends_on: + for task in self.depends_on: + if not frappe.db.get_value("Task", task.task, "is_template"): + dependent_task_format = """{0}""".format(task.task) + frappe.throw(_("Dependent Task {0} is not a Template Task").format(dependent_task_format)) + def update_depends_on(self): depends_on_tasks = self.depends_on_tasks or "" for d in self.depends_on: - if d.task and not d.task in depends_on_tasks: + if d.task and d.task not in depends_on_tasks: depends_on_tasks += d.task + "," self.depends_on_tasks = depends_on_tasks @@ -161,7 +189,7 @@ class Task(NestedSet): def populate_depends_on(self): if self.parent_task: parent = frappe.get_doc('Task', self.parent_task) - if not self.name in [row.task for row in parent.depends_on]: + if self.name not in [row.task for row in parent.depends_on]: parent.append("depends_on", { "doctype": "Task Depends On", "task": self.name, @@ -196,17 +224,24 @@ def check_if_child_exists(name): @frappe.validate_and_sanitize_search_inputs def get_project(doctype, txt, searchfield, start, page_len, filters): from erpnext.controllers.queries import get_match_cond - return frappe.db.sql(""" select name from `tabProject` - where %(key)s like %(txt)s - %(mcond)s - order by name - limit %(start)s, %(page_len)s""" % { - 'key': searchfield, - 'txt': frappe.db.escape('%' + txt + '%'), - 'mcond':get_match_cond(doctype), - 'start': start, - 'page_len': page_len - }) + meta = frappe.get_meta(doctype) + searchfields = meta.get_search_fields() + search_columns = ", " + ", ".join(searchfields) if searchfields else '' + search_cond = " or " + " or ".join([field + " like %(txt)s" for field in searchfields]) + + return frappe.db.sql(""" select name {search_columns} from `tabProject` + where %(key)s like %(txt)s + %(mcond)s + {search_condition} + order by name + limit %(start)s, %(page_len)s""".format(search_columns = search_columns, + search_condition=search_cond), { + 'key': searchfield, + 'txt': '%' + txt + '%', + 'mcond':get_match_cond(doctype), + 'start': start, + 'page_len': page_len + }) @frappe.whitelist() diff --git a/erpnext/projects/doctype/task/task_list.js b/erpnext/projects/doctype/task/task_list.js index 941fe975468..98d2bbc81a4 100644 --- a/erpnext/projects/doctype/task/task_list.js +++ b/erpnext/projects/doctype/task/task_list.js @@ -20,13 +20,14 @@ frappe.listview_settings['Task'] = { "Pending Review": "orange", "Working": "orange", "Completed": "green", - "Cancelled": "dark grey" + "Cancelled": "dark grey", + "Template": "blue" } return [__(doc.status), colors[doc.status], "status,=," + doc.status]; }, gantt_custom_popup_html: function(ganttobj, task) { var html = `
    ${ganttobj.name}
    `; + href="/app/task/${ganttobj.id}""> ${ganttobj.name} `; if(task.project) html += `

    Project: ${task.project}

    `; html += `

    Progress: ${ganttobj.progress}

    `; diff --git a/erpnext/projects/doctype/task/test_task.py b/erpnext/projects/doctype/task/test_task.py index 47a28fd1114..0fad5e88074 100644 --- a/erpnext/projects/doctype/task/test_task.py +++ b/erpnext/projects/doctype/task/test_task.py @@ -30,14 +30,16 @@ class TestTask(unittest.TestCase): }) def test_reschedule_dependent_task(self): + project = frappe.get_value("Project", {"project_name": "_Test Project"}) + task1 = create_task("_Test Task 1", nowdate(), add_days(nowdate(), 10)) task2 = create_task("_Test Task 2", add_days(nowdate(), 11), add_days(nowdate(), 15), task1.name) - task2.get("depends_on")[0].project = "_Test Project" + task2.get("depends_on")[0].project = project task2.save() task3 = create_task("_Test Task 3", add_days(nowdate(), 11), add_days(nowdate(), 15), task2.name) - task3.get("depends_on")[0].project = "_Test Project" + task3.get("depends_on")[0].project = project task3.save() task1.update({ @@ -97,14 +99,19 @@ class TestTask(unittest.TestCase): self.assertEqual(frappe.db.get_value("Task", task.name, "status"), "Overdue") -def create_task(subject, start=None, end=None, depends_on=None, project=None, save=True): +def create_task(subject, start=None, end=None, depends_on=None, project=None, parent_task=None, is_group=0, is_template=0, begin=0, duration=0, save=True): if not frappe.db.exists("Task", subject): task = frappe.new_doc('Task') task.status = "Open" task.subject = subject task.exp_start_date = start or nowdate() task.exp_end_date = end or nowdate() - task.project = project or "_Test Project" + task.project = project or None if is_template else frappe.get_value("Project", {"project_name": "_Test Project"}) + task.is_template = is_template + task.start = begin + task.duration = duration + task.is_group = is_group + task.parent_task = parent_task if save: task.save() else: @@ -116,5 +123,4 @@ def create_task(subject, start=None, end=None, depends_on=None, project=None, sa }) if save: task.save() - return task diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index a5ce44dcf24..f7c764e1bd2 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -13,9 +13,18 @@ from erpnext.projects.doctype.timesheet.timesheet import make_salary_slip, make_ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.payroll.doctype.salary_structure.test_salary_structure \ import make_salary_structure, create_salary_structure_assignment +from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( + make_earning_salary_component, + make_deduction_salary_component +) from erpnext.hr.doctype.employee.test_employee import make_employee class TestTimesheet(unittest.TestCase): + @classmethod + def setUpClass(cls): + make_earning_salary_component(setup=True, company_list=['_Test Company']) + make_deduction_salary_component(setup=True, company_list=['_Test Company']) + def setUp(self): for dt in ["Salary Slip", "Salary Structure", "Salary Structure Assignment", "Timesheet"]: frappe.db.sql("delete from `tab%s`" % dt) @@ -49,7 +58,7 @@ class TestTimesheet(unittest.TestCase): self.assertEqual(timesheet.total_billable_amount, 0) def test_salary_slip_from_timesheet(self): - emp = make_employee("test_employee_6@salary.com") + emp = make_employee("test_employee_6@salary.com", company="_Test Company") salary_structure = make_salary_structure_for_timesheet(emp) timesheet = make_timesheet(emp, simulate = True, billable=1) @@ -89,10 +98,11 @@ class TestTimesheet(unittest.TestCase): def test_timesheet_billing_based_on_project(self): emp = make_employee("test_employee_6@salary.com") + project = frappe.get_value("Project", {"project_name": "_Test Project"}) - timesheet = make_timesheet(emp, simulate=True, billable=1, project = '_Test Project', company='_Test Company') + timesheet = make_timesheet(emp, simulate=True, billable=1, project=project, company='_Test Company') sales_invoice = create_sales_invoice(do_not_save=True) - sales_invoice.project = '_Test Project' + sales_invoice.project = project sales_invoice.submit() ts = frappe.get_doc('Timesheet', timesheet.name) diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js index 607c3fd9748..b123af5d188 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.js +++ b/erpnext/projects/doctype/timesheet/timesheet.js @@ -133,6 +133,11 @@ frappe.ui.form.on("Timesheet", { frm: frm }); }, + + parent_project: function(frm) { + set_project_in_timelog(frm); + }, + }); frappe.ui.form.on("Timesheet Detail", { @@ -162,7 +167,11 @@ frappe.ui.form.on("Timesheet Detail", { frappe.model.set_value(cdt, cdn, "hours", hours); }, - time_logs_add: function(frm) { + time_logs_add: function(frm, cdt, cdn) { + if(frm.doc.parent_project) { + frappe.model.set_value(cdt, cdn, 'project', frm.doc.parent_project); + } + var $trigger_again = $('.form-grid').find('.grid-row').find('.btn-open-row'); $trigger_again.on('click', () => { $('.form-grid') @@ -297,3 +306,11 @@ const set_employee_and_company = function(frm) { } }); }; + +function set_project_in_timelog(frm) { + if(frm.doc.parent_project) { + $.each(frm.doc.time_logs || [], function(i, item) { + frappe.model.set_value(item.doctype, item.name, "project", frm.doc.parent_project); + }); + } +} \ No newline at end of file diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json index c29c11b7465..b28682184ef 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.json +++ b/erpnext/projects/doctype/timesheet/timesheet.json @@ -1,1133 +1,352 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 1, - "allow_rename": 0, - "autoname": "naming_series:", - "beta": 0, - "creation": "2013-02-28 17:57:33", - "custom": 0, - "description": "", - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, + "actions": [], + "allow_import": 1, + "autoname": "naming_series:", + "creation": "2013-02-28 17:57:33", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "naming_series", + "company", + "sales_invoice", + "column_break_3", + "salary_slip", + "status", + "parent_project", + "employee_detail", + "employee", + "employee_name", + "department", + "column_break_9", + "user", + "start_date", + "end_date", + "section_break_5", + "time_logs", + "working_hours", + "total_hours", + "billing_details", + "total_billable_hours", + "total_billed_hours", + "total_costing_amount", + "column_break_10", + "total_billable_amount", + "total_billed_amount", + "per_billed", + "section_break_18", + "note", + "amended_from" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "{employee_name}", - "fieldname": "title", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Title", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "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_on_submit": 1, + "default": "{employee_name}", + "fieldname": "title", + "fieldtype": "Data", + "hidden": 1, + "label": "Title", + "no_copy": 1, + "print_hide": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "", - "fieldname": "naming_series", - "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": "Series", - "length": 0, - "no_copy": 0, - "options": "TS-.YYYY.-", - "permlevel": 0, - "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": 1, - "translatable": 0, - "unique": 0 - }, + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "options": "TS-.YYYY.-", + "reqd": 1, + "set_only_once": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "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": "Company", - "length": 0, - "no_copy": 0, - "options": "Company", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "remember_last_selected_value": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "description": "", - "fieldname": "sales_invoice", - "fieldtype": "Link", - "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": "Sales Invoice", - "length": 0, - "no_copy": 1, - "options": "Sales Invoice", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "sales_invoice", + "fieldtype": "Link", + "label": "Sales Invoice", + "no_copy": 1, + "options": "Sales Invoice", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_3", - "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, - "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": "column_break_3", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "fieldname": "salary_slip", - "fieldtype": "Link", - "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": "Salary Slip", - "length": 0, - "no_copy": 1, - "options": "Salary Slip", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "salary_slip", + "fieldtype": "Link", + "label": "Salary Slip", + "no_copy": 1, + "options": "Salary Slip", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Draft", - "fieldname": "status", - "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": 1, - "label": "Status", - "length": 0, - "no_copy": 1, - "options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Status", + "no_copy": 1, + "options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": "", - "columns": 0, - "depends_on": "eval:!doc.work_order || doc.docstatus == 1", - "fieldname": "employee_detail", - "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, - "label": "Employee Detail", - "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 - }, + "depends_on": "eval:!doc.work_order || doc.docstatus == 1", + "fieldname": "employee_detail", + "fieldtype": "Section Break", + "label": "Employee Detail" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "description": "", - "fieldname": "employee", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "Employee", - "length": 0, - "no_copy": 0, - "options": "Employee", - "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": "employee", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Employee", + "options": "Employee" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "employee", - "fieldname": "employee_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Employee Name", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "employee", + "fieldname": "employee_name", + "fieldtype": "Data", + "in_global_search": 1, + "label": "Employee Name", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_from": "employee.department", - "fieldname": "department", - "fieldtype": "Link", - "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": "Department", - "length": 0, - "no_copy": 0, - "options": "Department", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fetch_from": "employee.department", + "fieldname": "department", + "fieldtype": "Link", + "label": "Department", + "options": "Department", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_9", - "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 - }, + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "user", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "User", - "length": 0, - "no_copy": 0, - "options": "User", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "user", + "fieldtype": "Link", + "in_global_search": 1, + "label": "User", + "options": "User", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "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": 1, - "in_standard_filter": 0, - "label": "Start Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "start_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Start Date", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "end_date", + "fieldtype": "Date", + "label": "End Date", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_5", - "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, - "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": "section_break_5", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "time_logs", - "fieldtype": "Table", - "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": "Time Sheets", - "length": 0, - "no_copy": 0, - "options": "Timesheet Detail", - "permlevel": 0, - "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": "time_logs", + "fieldtype": "Table", + "label": "Time Sheets", + "options": "Timesheet Detail", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "working_hours", - "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 - }, + "fieldname": "working_hours", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "0", - "description": "", - "fieldname": "total_hours", - "fieldtype": "Float", - "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": "Total Working Hours", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "default": "0", + "fieldname": "total_hours", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Total Working Hours", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 1, - "columns": 0, - "fieldname": "billing_details", - "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, - "label": "Billing Details", - "length": 0, - "no_copy": 0, - "permlevel": 1, - "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 - }, + "collapsible": 1, + "fieldname": "billing_details", + "fieldtype": "Section Break", + "label": "Billing Details", + "permlevel": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "total_billable_hours", - "fieldtype": "Float", - "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": "Total Billable Hours", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "fieldname": "total_billable_hours", + "fieldtype": "Float", + "label": "Total Billable Hours", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "total_billed_hours", - "fieldtype": "Float", - "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": "Total Billed Hours", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "fieldname": "total_billed_hours", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Total Billed Hours", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "total_costing_amount", - "fieldtype": "Currency", - "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": "Total Costing Amount", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "fieldname": "total_costing_amount", + "fieldtype": "Currency", + "label": "Total Costing Amount", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 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 - }, + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "0", - "depends_on": "", - "description": "", - "fieldname": "total_billable_amount", - "fieldtype": "Currency", - "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": "Total Billable Amount", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "default": "0", + "fieldname": "total_billable_amount", + "fieldtype": "Currency", + "label": "Total Billable Amount", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "total_billed_amount", - "fieldtype": "Currency", - "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": "Total Billed Amount", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "fieldname": "total_billed_amount", + "fieldtype": "Currency", + "label": "Total Billed Amount", + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "per_billed", - "fieldtype": "Percent", - "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": "% Amount Billed", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "fieldname": "per_billed", + "fieldtype": "Percent", + "label": "% Amount Billed", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_18", - "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 - }, + "fieldname": "section_break_18", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "note", - "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": "Note", - "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 - }, + "fieldname": "note", + "fieldtype": "Text Editor", + "label": "Note" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "amended_from", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 1, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Amended From", - "length": 0, - "no_copy": 1, - "options": "Timesheet", - "permlevel": 0, - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "amended_from", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Amended From", + "no_copy": 1, + "options": "Timesheet", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "parent_project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-clock-o", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 1, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2019-03-05 21:54:02.654690", - "modified_by": "Administrator", - "module": "Projects", - "name": "Timesheet", - "owner": "Administrator", + ], + "icon": "fa fa-clock-o", + "idx": 1, + "is_submittable": 1, + "links": [], + "modified": "2021-01-08 20:51:14.590080", + "modified_by": "Administrator", + "module": "Projects", + "name": "Timesheet", + "owner": "Administrator", "permissions": [ { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Projects User", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Projects User", + "share": 1, + "submit": 1, "write": 1 - }, + }, { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "HR User", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "submit": 1, "write": 1 - }, + }, { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Manufacturing User", - "set_user_permissions": 0, - "share": 1, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing User", + "share": 1, + "submit": 1, "write": 1 - }, + }, { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Employee", - "set_user_permissions": 0, - "share": 0, - "submit": 0, + "create": 1, + "read": 1, + "role": "Employee", "write": 1 - }, + }, { - "amend": 1, - "cancel": 1, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 1, - "role": "Accounts User", - "set_user_permissions": 0, - "share": 0, - "submit": 1, + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "submit": 1, "write": 1 - }, + }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 1, - "print": 0, - "read": 1, - "report": 0, - "role": "Accounts User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, + "permlevel": 1, + "read": 1, + "role": "Accounts User", "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_order": "ASC", - "title_field": "title", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "sort_field": "modified", + "sort_order": "ASC", + "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 9e807f728ec..ed02f79c2dd 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -204,14 +204,16 @@ class Timesheet(Document): ts_detail.billing_rate = 0.0 @frappe.whitelist() -def get_projectwise_timesheet_data(project, parent=None): - cond = '' +def get_projectwise_timesheet_data(project, parent=None, from_time=None, to_time=None): + condition = '' if parent: - cond = "and parent = %(parent)s" + condition = "AND parent = %(parent)s" + if from_time and to_time: + condition += "AND from_time BETWEEN %(from_time)s AND %(to_time)s" return frappe.db.sql("""select name, parent, billing_hours, billing_amount as billing_amt from `tabTimesheet Detail` where parenttype = 'Timesheet' and docstatus=1 and project = %(project)s {0} and billable = 1 - and sales_invoice is null""".format(cond), {'project': project, 'parent': parent}, as_dict=1) + and sales_invoice is null""".format(condition), {'project': project, 'parent': parent, 'from_time': from_time, 'to_time': to_time}, as_dict=1) @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs @@ -288,7 +290,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None): def make_salary_slip(source_name, target_doc=None): target = frappe.new_doc("Salary Slip") set_missing_values(source_name, target) - target.run_method("get_emp_and_leave_details") + target.run_method("get_emp_and_working_day_details") return target diff --git a/erpnext/projects/report/billing_summary.py b/erpnext/projects/report/billing_summary.py index b808268d1b8..6c3c05f3b68 100644 --- a/erpnext/projects/report/billing_summary.py +++ b/erpnext/projects/report/billing_summary.py @@ -136,6 +136,7 @@ def get_timesheet_details(filters, timesheet_list): return timesheet_details_map def get_billable_and_total_duration(activity, start_time, end_time): + precision = frappe.get_precision("Timesheet Detail", "hours") activity_duration = time_diff_in_hours(end_time, start_time) billing_duration = 0.0 if activity.billable: @@ -143,4 +144,4 @@ def get_billable_and_total_duration(activity, start_time, end_time): if activity_duration != activity.billing_hours: billing_duration = activity_duration * activity.billing_hours / activity.hours - return flt(activity_duration, 2), flt(billing_duration, 2) \ No newline at end of file + return flt(activity_duration, precision), flt(billing_duration, precision) \ No newline at end of file diff --git a/erpnext/projects/workspace/projects/projects.json b/erpnext/projects/workspace/projects/projects.json new file mode 100644 index 00000000000..dbbd7e1458e --- /dev/null +++ b/erpnext/projects/workspace/projects/projects.json @@ -0,0 +1,193 @@ +{ + "category": "Modules", + "charts": [ + { + "chart_name": "Project Summary", + "label": "Open Projects" + } + ], + "creation": "2020-03-02 15:46:04.874669", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "project", + "idx": 0, + "is_standard": 1, + "label": "Projects", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Projects", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Project", + "link_to": "Project", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Task", + "link_to": "Task", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Project Template", + "link_to": "Project Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Project Type", + "link_to": "Project Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Project", + "hidden": 0, + "is_query_report": 0, + "label": "Project Update", + "link_to": "Project Update", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Time Tracking", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Timesheet", + "link_to": "Timesheet", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Activity Type", + "link_to": "Activity Type", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Activity Type", + "hidden": 0, + "is_query_report": 0, + "label": "Activity Cost", + "link_to": "Activity Cost", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Timesheet", + "hidden": 0, + "is_query_report": 1, + "label": "Daily Timesheet Summary", + "link_to": "Daily Timesheet Summary", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Project", + "hidden": 0, + "is_query_report": 1, + "label": "Project wise Stock Tracking", + "link_to": "Project wise Stock Tracking", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Project", + "hidden": 0, + "is_query_report": 1, + "label": "Project Billing Summary", + "link_to": "Project Billing Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:37.856224", + "modified_by": "Administrator", + "module": "Projects", + "name": "Projects", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "color": "Blue", + "format": "{} Assigned", + "label": "Task", + "link_to": "Task", + "stats_filter": "{\n \"_assign\": [\"like\", '%' + frappe.session.user + '%'],\n \"status\": \"Open\"\n}", + "type": "DocType" + }, + { + "color": "Blue", + "format": "{} Open", + "label": "Project", + "link_to": "Project", + "stats_filter": "{\n \"status\": \"Open\"\n}", + "type": "DocType" + }, + { + "label": "Timesheet", + "link_to": "Timesheet", + "type": "DocType" + }, + { + "label": "Project Billing Summary", + "link_to": "Project Billing Summary", + "type": "Report" + }, + { + "label": "Dashboard", + "link_to": "Project", + "type": "Dashboard" + } + ] +} \ No newline at end of file diff --git a/erpnext/public/build.json b/erpnext/public/build.json index 2695502269a..7a3cb838a99 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -2,7 +2,8 @@ "css/erpnext.css": [ "public/less/erpnext.less", "public/less/hub.less", - "public/less/call_popup.less" + "public/scss/call_popup.scss", + "public/scss/point-of-sale.scss" ], "css/marketplace.css": [ "public/less/hub.less" @@ -12,7 +13,8 @@ "public/js/shopping_cart.js" ], "css/erpnext-web.css": [ - "public/scss/website.scss" + "public/scss/website.scss", + "public/scss/shopping_cart.scss" ], "js/marketplace.min.js": [ "public/js/hub/marketplace.js" @@ -27,16 +29,6 @@ "public/js/payment/payments.js", "public/js/controllers/taxes_and_totals.js", "public/js/controllers/transaction.js", - "public/js/pos/pos.html", - "public/js/pos/pos_bill_item.html", - "public/js/pos/pos_bill_item_new.html", - "public/js/pos/pos_selected_item.html", - "public/js/pos/pos_item.html", - "public/js/pos/pos_tax_row.html", - "public/js/pos/customer_toolbar.html", - "public/js/pos/pos_invoice_list.html", - "public/js/payment/pos_payment.html", - "public/js/payment/payment_details.html", "public/js/templates/item_selector.html", "public/js/templates/employees_to_mark_attendance.html", "public/js/utils/item_selector.js", @@ -49,11 +41,30 @@ "public/js/education/assessment_result_tool.html", "public/js/hub/hub_factory.js", "public/js/call_popup/call_popup.js", - "public/js/utils/dimension_tree_filter.js" + "public/js/utils/dimension_tree_filter.js", + "public/js/telephony.js", + "public/js/templates/call_link.html" ], "js/item-dashboard.min.js": [ "stock/dashboard/item_dashboard.html", "stock/dashboard/item_dashboard_list.html", - "stock/dashboard/item_dashboard.js" + "stock/dashboard/item_dashboard.js", + "stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html", + "stock/page/warehouse_capacity_summary/warehouse_capacity_summary_header.html" + ], + "js/point-of-sale.min.js": [ + "selling/page/point_of_sale/pos_item_selector.js", + "selling/page/point_of_sale/pos_item_cart.js", + "selling/page/point_of_sale/pos_item_details.js", + "selling/page/point_of_sale/pos_number_pad.js", + "selling/page/point_of_sale/pos_payment.js", + "selling/page/point_of_sale/pos_past_order_list.js", + "selling/page/point_of_sale/pos_past_order_summary.js", + "selling/page/point_of_sale/pos_controller.js" + ], + "js/bank-reconciliation-tool.min.js": [ + "public/js/bank_reconciliation_tool/data_table_manager.js", + "public/js/bank_reconciliation_tool/number_card.js", + "public/js/bank_reconciliation_tool/dialog_manager.js" ] } diff --git a/erpnext/public/css/pos.css b/erpnext/public/css/pos.css deleted file mode 100644 index e80e3ed126d..00000000000 --- a/erpnext/public/css/pos.css +++ /dev/null @@ -1,216 +0,0 @@ -[data-route="point-of-sale"] .layout-main-section { border: none; font-size: 12px; } -[data-route="point-of-sale"] .layout-main-section-wrapper { margin-bottom: 0; } -[data-route="point-of-sale"] .pos-items-wrapper { max-height: calc(100vh - 210px); } -:root { --border-color: #d1d8dd; --text-color: #8d99a6; --primary: #5e64ff; } -[data-route="point-of-sale"] .flex { display: flex; } -[data-route="point-of-sale"] .grid { display: grid; } -[data-route="point-of-sale"] .absolute { position: absolute; } -[data-route="point-of-sale"] .relative { position: relative; } -[data-route="point-of-sale"] .abs-center { top: 50%; left: 50%; transform: translate(-50%, -50%); } -[data-route="point-of-sale"] .inline { display: inline; } -[data-route="point-of-sale"] .float-right { float: right; } -[data-route="point-of-sale"] .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } -[data-route="point-of-sale"] .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } -[data-route="point-of-sale"] .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } -[data-route="point-of-sale"] .grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } -[data-route="point-of-sale"] .grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); } -[data-route="point-of-sale"] .grid-cols-10 { grid-template-columns: repeat(10, minmax(0, 1fr)); } -[data-route="point-of-sale"] .gap-2 { grid-gap: 0.5rem; gap: 0.5rem; } -[data-route="point-of-sale"] .gap-4 { grid-gap: 1rem; gap: 1rem; } -[data-route="point-of-sale"] .gap-6 { grid-gap: 1.25rem; gap: 1.25rem; } -[data-route="point-of-sale"] .gap-8 { grid-gap: 1.5rem; gap: 1.5rem; } -[data-route="point-of-sale"] .row-gap-2 { grid-row-gap: 0.5rem; row-gap: 0.5rem; } -[data-route="point-of-sale"] .col-gap-4 { grid-column-gap: 1rem; column-gap: 1rem; } -[data-route="point-of-sale"] .col-span-2 { grid-column: span 2 / span 2; } -[data-route="point-of-sale"] .col-span-3 { grid-column: span 3 / span 3; } -[data-route="point-of-sale"] .col-span-4 { grid-column: span 4 / span 4; } -[data-route="point-of-sale"] .col-span-6 { grid-column: span 6 / span 6; } -[data-route="point-of-sale"] .col-span-10 { grid-column: span 10 / span 10; } -[data-route="point-of-sale"] .row-span-2 { grid-row: span 2 / span 2; } -[data-route="point-of-sale"] .grid-auto-row { grid-auto-rows: 5.5rem; } -[data-route="point-of-sale"] .d-none { display: none; } -[data-route="point-of-sale"] .flex-wrap { flex-wrap: wrap; } -[data-route="point-of-sale"] .flex-row { flex-direction: row; } -[data-route="point-of-sale"] .flex-col { flex-direction: column; } -[data-route="point-of-sale"] .flex-row-rev { flex-direction: row-reverse; } -[data-route="point-of-sale"] .flex-col-rev { flex-direction: column-reverse; } -[data-route="point-of-sale"] .flex-1 { flex: 1 1 0%; } -[data-route="point-of-sale"] .items-center { align-items: center; } -[data-route="point-of-sale"] .items-end { align-items: flex-end; } -[data-route="point-of-sale"] .f-grow-1 { flex-grow: 1; } -[data-route="point-of-sale"] .f-grow-2 { flex-grow: 2; } -[data-route="point-of-sale"] .f-grow-3 { flex-grow: 3; } -[data-route="point-of-sale"] .f-grow-4 { flex-grow: 4; } -[data-route="point-of-sale"] .f-shrink-0 { flex-shrink: 0; } -[data-route="point-of-sale"] .f-shrink-1 { flex-shrink: 1; } -[data-route="point-of-sale"] .f-shrink-2 { flex-shrink: 2; } -[data-route="point-of-sale"] .f-shrink-3 { flex-shrink: 3; } -[data-route="point-of-sale"] .shadow { box-shadow: 0 0px 3px 0 rgba(0, 0, 0, 0.2), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } -[data-route="point-of-sale"] .shadow-sm { box-shadow: 0 0.5px 3px 0 rgba(0, 0, 0, 0.125); } -[data-route="point-of-sale"] .shadow-inner { box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.1); } -[data-route="point-of-sale"] .rounded { border-radius: 0.3rem; } -[data-route="point-of-sale"] .rounded-b { border-bottom-left-radius: 0.3rem; border-bottom-right-radius: 0.3rem; } -[data-route="point-of-sale"] .p-8 { padding: 2rem; } -[data-route="point-of-sale"] .p-16 { padding: 4rem; } -[data-route="point-of-sale"] .p-32 { padding: 8rem; } -[data-route="point-of-sale"] .p-6 { padding: 1.5rem; } -[data-route="point-of-sale"] .p-4 { padding: 1rem; } -[data-route="point-of-sale"] .p-3 { padding: 0.75rem; } -[data-route="point-of-sale"] .p-2 { padding: 0.5rem; } -[data-route="point-of-sale"] .m-8 { margin: 2rem; } -[data-route="point-of-sale"] .p-1 { padding: 0.25rem; } -[data-route="point-of-sale"] .pr-0 { padding-right: 0rem; } -[data-route="point-of-sale"] .pl-0 { padding-left: 0rem; } -[data-route="point-of-sale"] .pt-0 { padding-top: 0rem; } -[data-route="point-of-sale"] .pb-0 { padding-bottom: 0rem; } -[data-route="point-of-sale"] .mr-0 { margin-right: 0rem; } -[data-route="point-of-sale"] .ml-0 { margin-left: 0rem; } -[data-route="point-of-sale"] .mt-0 { margin-top: 0rem; } -[data-route="point-of-sale"] .mb-0 { margin-bottom: 0rem; } -[data-route="point-of-sale"] .pr-2 { padding-right: 0.5rem; } -[data-route="point-of-sale"] .pl-2 { padding-left: 0.5rem; } -[data-route="point-of-sale"] .pt-2 { padding-top: 0.5rem; } -[data-route="point-of-sale"] .pb-2 { padding-bottom: 0.5rem; } -[data-route="point-of-sale"] .pr-3 { padding-right: 0.75rem; } -[data-route="point-of-sale"] .pl-3 { padding-left: 0.75rem; } -[data-route="point-of-sale"] .pt-3 { padding-top: 0.75rem; } -[data-route="point-of-sale"] .pb-3 { padding-bottom: 0.75rem; } -[data-route="point-of-sale"] .pr-4 { padding-right: 1rem; } -[data-route="point-of-sale"] .pl-4 { padding-left: 1rem; } -[data-route="point-of-sale"] .pt-4 { padding-top: 1rem; } -[data-route="point-of-sale"] .pb-4 { padding-bottom: 1rem; } -[data-route="point-of-sale"] .mr-4 { margin-right: 1rem; } -[data-route="point-of-sale"] .ml-4 { margin-left: 1rem; } -[data-route="point-of-sale"] .mt-4 { margin-top: 1rem; } -[data-route="point-of-sale"] .mb-4 { margin-bottom: 1rem; } -[data-route="point-of-sale"] .mr-2 { margin-right: 0.5rem; } -[data-route="point-of-sale"] .ml-2 { margin-left: 0.5rem; } -[data-route="point-of-sale"] .mt-2 { margin-top: 0.5rem; } -[data-route="point-of-sale"] .mb-2 { margin-bottom: 0.5rem; } -[data-route="point-of-sale"] .mr-1 { margin-right: 0.25rem; } -[data-route="point-of-sale"] .ml-1 { margin-left: 0.25rem; } -[data-route="point-of-sale"] .mt-1 { margin-top: 0.25rem; } -[data-route="point-of-sale"] .mb-1 { margin-bottom: 0.25rem; } -[data-route="point-of-sale"] .mr-auto { margin-right: auto; } -[data-route="point-of-sale"] .ml-auto { margin-left: auto; } -[data-route="point-of-sale"] .mt-auto { margin-top: auto; } -[data-route="point-of-sale"] .mb-auto { margin-bottom: auto; } -[data-route="point-of-sale"] .pr-6 { padding-right: 1.5rem; } -[data-route="point-of-sale"] .pl-6 { padding-left: 1.5rem; } -[data-route="point-of-sale"] .pt-6 { padding-top: 1.5rem; } -[data-route="point-of-sale"] .pb-6 { padding-bottom: 1.5rem; } -[data-route="point-of-sale"] .mr-6 { margin-right: 1.5rem; } -[data-route="point-of-sale"] .ml-6 { margin-left: 1.5rem; } -[data-route="point-of-sale"] .mt-6 { margin-top: 1.5rem; } -[data-route="point-of-sale"] .mb-6 { margin-bottom: 1.5rem; } -[data-route="point-of-sale"] .mr-8 { margin-right: 2rem; } -[data-route="point-of-sale"] .ml-8 { margin-left: 2rem; } -[data-route="point-of-sale"] .mt-8 { margin-top: 2rem; } -[data-route="point-of-sale"] .mb-8 { margin-bottom: 2rem; } -[data-route="point-of-sale"] .pr-8 { padding-right: 2rem; } -[data-route="point-of-sale"] .pl-8 { padding-left: 2rem; } -[data-route="point-of-sale"] .pt-8 { padding-top: 2rem; } -[data-route="point-of-sale"] .pb-8 { padding-bottom: 2rem; } -[data-route="point-of-sale"] .pr-16 { padding-right: 4rem; } -[data-route="point-of-sale"] .pl-16 { padding-left: 4rem; } -[data-route="point-of-sale"] .pt-16 { padding-top: 4rem; } -[data-route="point-of-sale"] .pb-16 { padding-bottom: 4rem; } -[data-route="point-of-sale"] .w-full { width: 100%; } -[data-route="point-of-sale"] .h-full { height: 100%; } -[data-route="point-of-sale"] .w-quarter { width: 25%; } -[data-route="point-of-sale"] .w-half { width: 50%; } -[data-route="point-of-sale"] .w-66 { width: 66.66%; } -[data-route="point-of-sale"] .w-33 { width: 33.33%; } -[data-route="point-of-sale"] .w-60 { width: 60%; } -[data-route="point-of-sale"] .w-40 { width: 40%; } -[data-route="point-of-sale"] .w-fit { width: fit-content; } -[data-route="point-of-sale"] .w-6 { width: 2rem; } -[data-route="point-of-sale"] .h-6 { min-height: 2rem; height: 2rem; } -[data-route="point-of-sale"] .w-8 { width: 2.5rem; } -[data-route="point-of-sale"] .h-8 { min-height: 2.5rem; height: 2.5rem; } -[data-route="point-of-sale"] .w-10 { width: 3rem; } -[data-route="point-of-sale"] .h-10 { min-height:3rem; height: 3rem; } -[data-route="point-of-sale"] .h-12 { min-height: 3.3rem; height: 3.3rem; } -[data-route="point-of-sale"] .w-12 { width: 3.3rem; } -[data-route="point-of-sale"] .h-14 { min-height: 4.2rem; height: 4.2rem; } -[data-route="point-of-sale"] .h-16 { min-height: 4.6rem; height: 4.6rem; } -[data-route="point-of-sale"] .h-18 { min-height: 5rem; height: 5rem; } -[data-route="point-of-sale"] .w-18 { width: 5.4rem; } -[data-route="point-of-sale"] .w-24 { width: 7.2rem; } -[data-route="point-of-sale"] .w-26 { width: 8.4rem; } -[data-route="point-of-sale"] .h-24 { min-height: 7.2rem; height: 7.2rem; } -[data-route="point-of-sale"] .h-32 { min-height: 9.6rem; height: 9.6rem; } -[data-route="point-of-sale"] .w-46 { width: 15rem; } -[data-route="point-of-sale"] .h-46 { min-height:15rem; height: 15rem; } -[data-route="point-of-sale"] .h-100 { height: 100vh; } -[data-route="point-of-sale"] .mx-h-70 { max-height: 67rem; } -[data-route="point-of-sale"] .border-grey-300 { border-color: #e2e8f0; } -[data-route="point-of-sale"] .border-grey { border: 1px solid #d1d8dd; } -[data-route="point-of-sale"] .border-white { border: 1px solid #fff; } -[data-route="point-of-sale"] .border-b-grey { border-bottom: 1px solid #d1d8dd; } -[data-route="point-of-sale"] .border-t-grey { border-top: 1px solid #d1d8dd; } -[data-route="point-of-sale"] .border-r-grey { border-right: 1px solid #d1d8dd; } -[data-route="point-of-sale"] .text-dark-grey { color: #5f5f5f; } -[data-route="point-of-sale"] .text-grey { color: #8d99a6; } -[data-route="point-of-sale"] .text-grey-100 { color: #d1d8dd; } -[data-route="point-of-sale"] .text-grey-200 { color: #a0aec0; } -[data-route="point-of-sale"] .bg-green-200 { background-color: #c6f6d5; } -[data-route="point-of-sale"] .text-bold { font-weight: bold; } -[data-route="point-of-sale"] .italic { font-style: italic; } -[data-route="point-of-sale"] .font-weight-450 { font-weight: 450; } -[data-route="point-of-sale"] .justify-around { justify-content: space-around; } -[data-route="point-of-sale"] .justify-between { justify-content: space-between; } -[data-route="point-of-sale"] .justify-center { justify-content: center; } -[data-route="point-of-sale"] .justify-end { justify-content: flex-end; } -[data-route="point-of-sale"] .bg-white { background-color: white; } -[data-route="point-of-sale"] .bg-light-grey { background-color: #f0f4f7; } -[data-route="point-of-sale"] .bg-grey-100 { background-color: #f7fafc; } -[data-route="point-of-sale"] .bg-grey-200 { background-color: #edf2f7; } -[data-route="point-of-sale"] .bg-grey { background-color: #f4f5f6; } -[data-route="point-of-sale"] .text-center { text-align: center; } -[data-route="point-of-sale"] .text-right { text-align: right; } -[data-route="point-of-sale"] .text-sm { font-size: 1rem; } -[data-route="point-of-sale"] .text-md-0 { font-size: 1.25rem; } -[data-route="point-of-sale"] .text-md { font-size: 1.4rem; } -[data-route="point-of-sale"] .text-lg { font-size: 1.6rem; } -[data-route="point-of-sale"] .text-xl { font-size: 2.2rem; } -[data-route="point-of-sale"] .text-2xl { font-size: 2.8rem; } -[data-route="point-of-sale"] .text-2-5xl { font-size: 3rem; } -[data-route="point-of-sale"] .text-3xl { font-size: 3.8rem; } -[data-route="point-of-sale"] .text-6xl { font-size: 4.8rem; } -[data-route="point-of-sale"] .line-through { text-decoration: line-through; } -[data-route="point-of-sale"] .text-primary { color: #5e64ff; } -[data-route="point-of-sale"] .text-white { color: #fff; } -[data-route="point-of-sale"] .text-green-500 { color: #48bb78; } -[data-route="point-of-sale"] .bg-primary { background-color: #5e64ff; } -[data-route="point-of-sale"] .border-primary { border-color: #5e64ff; } -[data-route="point-of-sale"] .text-danger { color: #e53e3e; } -[data-route="point-of-sale"] .scroll-x { overflow-x: scroll;overflow-y: hidden; } -[data-route="point-of-sale"] .scroll-y { overflow-y: scroll;overflow-x: hidden; } -[data-route="point-of-sale"] .overflow-hidden { overflow: hidden; } -[data-route="point-of-sale"] .whitespace-nowrap { white-space: nowrap; } -[data-route="point-of-sale"] .sticky { position: sticky; top: -1px; } -[data-route="point-of-sale"] .bg-white { background-color: #fff; } -[data-route="point-of-sale"] .bg-selected { background-color: #fffdf4; } -[data-route="point-of-sale"] .border-dashed { border-width:1px; border-style: dashed; } -[data-route="point-of-sale"] .z-100 { z-index: 100; } - -[data-route="point-of-sale"] .frappe-control { margin: 0 !important; width: 100%; } -[data-route="point-of-sale"] .form-control { font-size: 12px; } -[data-route="point-of-sale"] .form-group { margin: 0 !important; } -[data-route="point-of-sale"] .pointer { cursor: pointer; } -[data-route="point-of-sale"] .no-select { user-select: none; } -[data-route="point-of-sale"] .item-wrapper:hover { transform: scale(1.02, 1.02); } -[data-route="point-of-sale"] .hover-underline:hover { text-decoration: underline; } -[data-route="point-of-sale"] .item-wrapper { transition: scale 0.2s ease-in-out; } -[data-route="point-of-sale"] .cart-items-section .cart-item-wrapper:not(:first-child) { border-top: none; } -[data-route="point-of-sale"] .customer-transactions .invoice-wrapper:not(:first-child) { border-top: none; } - -[data-route="point-of-sale"] .payment-summary-wrapper:last-child { border-bottom: none; } -[data-route="point-of-sale"] .item-summary-wrapper:last-child { border-bottom: none; } -[data-route="point-of-sale"] .total-summary-wrapper:last-child { border-bottom: none; } -[data-route="point-of-sale"] .invoices-container .invoice-wrapper:last-child { border-bottom: none; } -[data-route="point-of-sale"] .summary-btns:last-child { margin-right: 0px; } -[data-route="point-of-sale"] ::-webkit-scrollbar { width: 1px } - -[data-route="point-of-sale"] .indicator.grey::before { background-color: #8d99a6; } \ No newline at end of file diff --git a/erpnext/public/images/erp-icon.svg b/erpnext/public/images/erp-icon.svg deleted file mode 100644 index 6bec40cc62e..00000000000 --- a/erpnext/public/images/erp-icon.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - erpnext-logo - Created with Sketch. - - - - - \ No newline at end of file diff --git a/erpnext/public/images/erpnext-12.svg b/erpnext/public/images/erpnext-12.svg deleted file mode 100644 index fcc8e46fdd5..00000000000 --- a/erpnext/public/images/erpnext-12.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - version-12 - Created with Sketch. - - - - - - - - - - - - - - - - - 12 - - - - \ No newline at end of file diff --git a/erpnext/public/images/erpnext-favicon.svg b/erpnext/public/images/erpnext-favicon.svg new file mode 100644 index 00000000000..a3ac3bb2ce2 --- /dev/null +++ b/erpnext/public/images/erpnext-favicon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/erpnext/public/images/erpnext-footer.png b/erpnext/public/images/erpnext-footer.png deleted file mode 100644 index ffff7756b77..00000000000 Binary files a/erpnext/public/images/erpnext-footer.png and /dev/null differ diff --git a/erpnext/public/images/erpnext-logo.png b/erpnext/public/images/erpnext-logo.png index 115faaa6a8f..3090727d8ff 100644 Binary files a/erpnext/public/images/erpnext-logo.png and b/erpnext/public/images/erpnext-logo.png differ diff --git a/erpnext/public/images/erpnext-logo.svg b/erpnext/public/images/erpnext-logo.svg new file mode 100644 index 00000000000..a3ac3bb2ce2 --- /dev/null +++ b/erpnext/public/images/erpnext-logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/erpnext/public/images/favicon.png b/erpnext/public/images/favicon.png deleted file mode 100644 index b6948856f88..00000000000 Binary files a/erpnext/public/images/favicon.png and /dev/null differ diff --git a/erpnext/public/images/splash.png b/erpnext/public/images/splash.png deleted file mode 100644 index 8e5d055c660..00000000000 Binary files a/erpnext/public/images/splash.png and /dev/null differ diff --git a/erpnext/public/images/ui-states/cart-empty-state.png b/erpnext/public/images/ui-states/cart-empty-state.png new file mode 100644 index 00000000000..e1ead0e175d Binary files /dev/null and b/erpnext/public/images/ui-states/cart-empty-state.png differ diff --git a/erpnext/public/js/address.js b/erpnext/public/js/address.js new file mode 100644 index 00000000000..57f7163bbb2 --- /dev/null +++ b/erpnext/public/js/address.js @@ -0,0 +1,25 @@ +// Copyright (c) 2016, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Address", { + is_your_company_address: function(frm) { + frm.clear_table('links'); + if(frm.doc.is_your_company_address) { + frm.add_child('links', { + link_doctype: 'Company', + link_name: frappe.defaults.get_user_default('Company') + }); + frm.set_query('link_doctype', 'links', () => { + return { + filters: { + name: 'Company' + } + }; + }); + frm.refresh_field('links'); + } + else { + frm.trigger('refresh'); + } + } +}); diff --git a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js new file mode 100644 index 00000000000..5bb58faf2fc --- /dev/null +++ b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js @@ -0,0 +1,220 @@ +frappe.provide("erpnext.accounts.bank_reconciliation"); + +erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager { + constructor(opts) { + Object.assign(this, opts); + this.dialog_manager = new erpnext.accounts.bank_reconciliation.DialogManager( + this.company, + this.bank_account + ); + this.make_dt(); + } + + make_dt() { + var me = this; + frappe.call({ + method: + "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions", + args: { + bank_account: this.bank_account, + }, + callback: function (response) { + me.format_data(response.message); + me.get_dt_columns(); + me.get_datatable(); + me.set_listeners(); + }, + }); + } + + get_dt_columns() { + this.columns = [ + { + name: "Date", + editable: false, + width: 100, + }, + + { + name: "Party Type", + editable: false, + width: 95, + }, + { + name: "Party", + editable: false, + width: 100, + }, + { + name: "Description", + editable: false, + width: 350, + }, + { + name: "Deposit", + editable: false, + width: 100, + format: (value) => + "" + + format_currency(value, this.currency) + + "", + }, + { + name: "Withdrawal", + editable: false, + width: 100, + format: (value) => + "" + + format_currency(value, this.currency) + + "", + }, + { + name: "Unallocated Amount", + editable: false, + width: 100, + format: (value) => + "" + + format_currency(value, this.currency) + + "", + }, + { + name: "Reference Number", + editable: false, + width: 140, + }, + { + name: "Actions", + editable: false, + sortable: false, + focusable: false, + dropdown: false, + width: 80, + }, + ]; + } + + format_data(transactions) { + this.transactions = []; + if (transactions[0]) { + this.currency = transactions[0]["currency"]; + } + this.transaction_dt_map = {}; + let length; + transactions.forEach((row) => { + length = this.transactions.push(this.format_row(row)); + this.transaction_dt_map[row["name"]] = length - 1; + }); + } + + format_row(row) { + return [ + row["date"], + row["party_type"], + row["party"], + row["description"], + row["deposit"], + row["withdrawal"], + row["unallocated_amount"], + row["reference_number"], + ` + {% for s in students %} - @@ -29,7 +29,7 @@ + + """.format(item_link, frappe.bold(entry[1])) + + msg += """ +
    {% if(s.assessment_details) { %} - {{s.assessment_details[c.assessment_criteria][1]}} + {{s.assessment_details[c.assessment_criteria][1]}} {% } %} - + diff --git a/erpnext/public/js/financial_statements.js b/erpnext/public/js/financial_statements.js index 459c01b269b..b2f7afe53f3 100644 --- a/erpnext/public/js/financial_statements.js +++ b/erpnext/public/js/financial_statements.js @@ -57,18 +57,22 @@ erpnext.financial_statements = { }); }); - report.page.add_inner_button(__("Balance Sheet"), function() { + const views_menu = report.page.add_custom_button_group(__('Financial Statements')); + + report.page.add_custom_menu_item(views_menu, __("Balance Sheet"), function() { var filters = report.get_values(); frappe.set_route('query-report', 'Balance Sheet', {company: filters.company}); - }, __('Financial Statements')); - report.page.add_inner_button(__("Profit and Loss"), function() { + }); + + report.page.add_custom_menu_item(views_menu, __("Profit and Loss"), function() { var filters = report.get_values(); frappe.set_route('query-report', 'Profit and Loss Statement', {company: filters.company}); - }, __('Financial Statements')); - report.page.add_inner_button(__("Cash Flow Statement"), function() { + }); + + report.page.add_custom_menu_item(views_menu, __("Cash Flow Statement"), function() { var filters = report.get_values(); frappe.set_route('query-report', 'Cash Flow', {company: filters.company}); - }, __('Financial Statements')); + }); } }; diff --git a/erpnext/public/js/help_links.js b/erpnext/public/js/help_links.js index 66ff46405d1..e78992302f1 100644 --- a/erpnext/public/js/help_links.js +++ b/erpnext/public/js/help_links.js @@ -1,526 +1,1051 @@ -frappe.provide('frappe.help.help_links'); +frappe.provide("frappe.help.help_links"); -const docsUrl = 'https://erpnext.com/docs/'; +const docsUrl = "https://erpnext.com/docs/"; -frappe.help.help_links['Form/Rename Tool'] = [ - { label: 'Bulk Rename', url: docsUrl + 'user/manual/en/setting-up/data/bulk-rename' }, -] +frappe.help.help_links["Form/Rename Tool"] = [ + { + label: "Bulk Rename", + url: docsUrl + "user/manual/en/setting-up/data/bulk-rename", + }, +]; //Setup -frappe.help.help_links['List/User'] = [ - { label: 'New User', url: docsUrl + 'user/manual/en/setting-up/users-and-permissions/adding-users' }, - { label: 'Rename User', url: docsUrl + 'user/manual/en/setting-up/articles/rename-user' }, -] +frappe.help.help_links["List/User"] = [ + { + label: "New User", + url: + docsUrl + + "user/manual/en/setting-up/users-and-permissions/adding-users", + }, + { + label: "Rename User", + url: docsUrl + "user/manual/en/setting-up/articles/rename-user", + }, +]; -frappe.help.help_links['permission-manager'] = [ - { label: 'Role Permissions Manager', url: docsUrl + 'user/manual/en/setting-up/users-and-permissions/role-based-permissions' }, - { label: 'Managing Perm Level in Permissions Manager', url: docsUrl + 'user/manual/en/setting-up/articles/managing-perm-level' }, - { label: 'User Permissions', url: docsUrl + 'user/manual/en/setting-up/users-and-permissions/user-permissions' }, - { label: 'Sharing', url: docsUrl + 'user/manual/en/setting-up/users-and-permissions/sharing' }, - { label: 'Password', url: docsUrl + 'user/manual/en/setting-up/articles/change-password' }, -] +frappe.help.help_links["permission-manager"] = [ + { + label: "Role Permissions Manager", + url: + docsUrl + + "user/manual/en/setting-up/users-and-permissions/role-based-permissions", + }, + { + label: "Managing Perm Level in Permissions Manager", + url: docsUrl + "user/manual/en/setting-up/articles/managing-perm-level", + }, + { + label: "User Permissions", + url: + docsUrl + + "user/manual/en/setting-up/users-and-permissions/user-permissions", + }, + { + label: "Sharing", + url: + docsUrl + "user/manual/en/setting-up/users-and-permissions/sharing", + }, + { + label: "Password", + url: docsUrl + "user/manual/en/setting-up/articles/change-password", + }, +]; -frappe.help.help_links['Form/System Settings'] = [ - { label: 'Naming Series', url: docsUrl + 'user/manual/en/setting-up/settings/system-settings' }, -] +frappe.help.help_links["Form/System Settings"] = [ + { + label: "Naming Series", + url: docsUrl + "user/manual/en/setting-up/settings/system-settings", + }, +]; -frappe.help.help_links['data-import-tool'] = [ - { label: 'Importing and Exporting Data', url: docsUrl + 'user/manual/en/setting-up/data/data-import-tool' }, - { label: 'Overwriting Data from Data Import Tool', url: docsUrl + 'user/manual/en/setting-up/articles/overwriting-data-from-data-import-tool' }, -] +frappe.help.help_links["data-import-tool"] = [ + { + label: "Importing and Exporting Data", + url: docsUrl + "user/manual/en/setting-up/data/data-import-tool", + }, + { + label: "Overwriting Data from Data Import Tool", + url: + docsUrl + + "user/manual/en/setting-up/articles/overwriting-data-from-data-import-tool", + }, +]; -frappe.help.help_links['module_setup'] = [ - { label: 'Role Permissions Manager', url: docsUrl + 'user/manual/en/setting-up/users-and-permissions/role-based-permissions' }, -] +frappe.help.help_links["module_setup"] = [ + { + label: "Role Permissions Manager", + url: + docsUrl + + "user/manual/en/setting-up/users-and-permissions/role-based-permissions", + }, +]; -frappe.help.help_links['Form/Naming Series'] = [ - { label: 'Naming Series', url: docsUrl + 'user/manual/en/setting-up/settings/naming-series' }, - { label: 'Setting the Current Value for Naming Series', url: docsUrl + 'user/manual/en/setting-up/articles/naming-series-current-value' }, -] +frappe.help.help_links["Form/Naming Series"] = [ + { + label: "Naming Series", + url: docsUrl + "user/manual/en/setting-up/settings/naming-series", + }, + { + label: "Setting the Current Value for Naming Series", + url: + docsUrl + + "user/manual/en/setting-up/articles/naming-series-current-value", + }, +]; -frappe.help.help_links['Form/Global Defaults'] = [ - { label: 'Global Settings', url: docsUrl + 'user/manual/en/setting-up/settings/global-defaults' }, -] +frappe.help.help_links["Form/Global Defaults"] = [ + { + label: "Global Settings", + url: docsUrl + "user/manual/en/setting-up/settings/global-defaults", + }, +]; -frappe.help.help_links['Form/Email Digest'] = [ - { label: 'Email Digest', url: docsUrl + 'user/manual/en/setting-up/email/email-digest' }, -] +frappe.help.help_links["Form/Email Digest"] = [ + { + label: "Email Digest", + url: docsUrl + "user/manual/en/setting-up/email/email-digest", + }, +]; -frappe.help.help_links['List/Print Heading'] = [ - { label: 'Print Heading', url: docsUrl + 'user/manual/en/setting-up/print/print-headings' }, -] +frappe.help.help_links["List/Print Heading"] = [ + { + label: "Print Heading", + url: docsUrl + "user/manual/en/setting-up/print/print-headings", + }, +]; -frappe.help.help_links['List/Letter Head'] = [ - { label: 'Letter Head', url: docsUrl + 'user/manual/en/setting-up/print/letter-head' }, -] +frappe.help.help_links["List/Letter Head"] = [ + { + label: "Letter Head", + url: docsUrl + "user/manual/en/setting-up/print/letter-head", + }, +]; -frappe.help.help_links['List/Address Template'] = [ - { label: 'Address Template', url: docsUrl + 'user/manual/en/setting-up/print/address-template' }, -] +frappe.help.help_links["List/Address Template"] = [ + { + label: "Address Template", + url: docsUrl + "user/manual/en/setting-up/print/address-template", + }, +]; -frappe.help.help_links['List/Terms and Conditions'] = [ - { label: 'Terms and Conditions', url: docsUrl + 'user/manual/en/setting-up/print/terms-and-conditions' }, -] +frappe.help.help_links["List/Terms and Conditions"] = [ + { + label: "Terms and Conditions", + url: docsUrl + "user/manual/en/setting-up/print/terms-and-conditions", + }, +]; -frappe.help.help_links['List/Cheque Print Template'] = [ - { label: 'Cheque Print Template', url: docsUrl + 'user/manual/en/setting-up/print/cheque-print-template' }, -] +frappe.help.help_links["List/Cheque Print Template"] = [ + { + label: "Cheque Print Template", + url: docsUrl + "user/manual/en/setting-up/print/cheque-print-template", + }, +]; -frappe.help.help_links['List/Email Account'] = [ - { label: 'Email Account', url: docsUrl + 'user/manual/en/setting-up/email/email-account' }, -] +frappe.help.help_links["List/Email Account"] = [ + { + label: "Email Account", + url: docsUrl + "user/manual/en/setting-up/email/email-account", + }, +]; -frappe.help.help_links['List/Notification'] = [ - { label: 'Notification', url: docsUrl + 'user/manual/en/setting-up/email/notifications' }, -] +frappe.help.help_links["List/Notification"] = [ + { + label: "Notification", + url: docsUrl + "user/manual/en/setting-up/email/notifications", + }, +]; -frappe.help.help_links['Form/Notification'] = [ - { label: 'Notification', url: docsUrl + 'user/manual/en/setting-up/email/notifications' }, -] +frappe.help.help_links["Form/Notification"] = [ + { + label: "Notification", + url: docsUrl + "user/manual/en/setting-up/email/notifications", + }, +]; -frappe.help.help_links['List/Email Digest'] = [ - { label: 'Email Digest', url: docsUrl + 'user/manual/en/setting-up/email/email-digest' }, -] +frappe.help.help_links["List/Email Digest"] = [ + { + label: "Email Digest", + url: docsUrl + "user/manual/en/setting-up/email/email-digest", + }, +]; -frappe.help.help_links['List/Auto Email Report'] = [ - { label: 'Auto Email Reports', url: docsUrl + 'user/manual/en/setting-up/email/email-reports' }, -] +frappe.help.help_links["List/Auto Email Report"] = [ + { + label: "Auto Email Reports", + url: docsUrl + "user/manual/en/setting-up/email/email-reports", + }, +]; -frappe.help.help_links['Form/Print Settings'] = [ - { label: 'Print Settings', url: docsUrl + 'user/manual/en/setting-up/print/print-settings' }, -] +frappe.help.help_links["Form/Print Settings"] = [ + { + label: "Print Settings", + url: docsUrl + "user/manual/en/setting-up/print/print-settings", + }, +]; -frappe.help.help_links['print-format-builder'] = [ - { label: 'Print Format Builder', url: docsUrl + 'user/manual/en/setting-up/print/print-settings' }, -] +frappe.help.help_links["print-format-builder"] = [ + { + label: "Print Format Builder", + url: docsUrl + "user/manual/en/setting-up/print/print-settings", + }, +]; -frappe.help.help_links['List/Print Heading'] = [ - { label: 'Print Heading', url: docsUrl + 'user/manual/en/setting-up/print/print-headings' }, -] +frappe.help.help_links["List/Print Heading"] = [ + { + label: "Print Heading", + url: docsUrl + "user/manual/en/setting-up/print/print-headings", + }, +]; //setup-integrations -frappe.help.help_links['Form/PayPal Settings'] = [ - { label: 'PayPal Settings', url: docsUrl + 'user/manual/en/setting-up/integrations/paypal-integration' }, -] +frappe.help.help_links["Form/PayPal Settings"] = [ + { + label: "PayPal Settings", + url: + docsUrl + + "user/manual/en/setting-up/integrations/paypal-integration", + }, +]; -frappe.help.help_links['Form/Razorpay Settings'] = [ - { label: 'Razorpay Settings', url: docsUrl + 'user/manual/en/setting-up/integrations/razorpay-integration' }, -] +frappe.help.help_links["Form/Razorpay Settings"] = [ + { + label: "Razorpay Settings", + url: + docsUrl + + "user/manual/en/setting-up/integrations/razorpay-integration", + }, +]; -frappe.help.help_links['Form/Dropbox Settings'] = [ - { label: 'Dropbox Settings', url: docsUrl + 'user/manual/en/setting-up/integrations/dropbox-backup' }, -] +frappe.help.help_links["Form/Dropbox Settings"] = [ + { + label: "Dropbox Settings", + url: docsUrl + "user/manual/en/setting-up/integrations/dropbox-backup", + }, +]; -frappe.help.help_links['Form/LDAP Settings'] = [ - { label: 'LDAP Settings', url: docsUrl + 'user/manual/en/setting-up/integrations/ldap-integration' }, -] +frappe.help.help_links["Form/LDAP Settings"] = [ + { + label: "LDAP Settings", + url: + docsUrl + "user/manual/en/setting-up/integrations/ldap-integration", + }, +]; -frappe.help.help_links['Form/Stripe Settings'] = [ - { label: 'Stripe Settings', url: docsUrl + 'user/manual/en/setting-up/integrations/stripe-integration' }, -] +frappe.help.help_links["Form/Stripe Settings"] = [ + { + label: "Stripe Settings", + url: + docsUrl + + "user/manual/en/setting-up/integrations/stripe-integration", + }, +]; //Sales -frappe.help.help_links['Form/Quotation'] = [ - { label: 'Quotation', url: docsUrl + 'user/manual/en/selling/quotation' }, - { label: 'Applying Discount', url: docsUrl + 'user/manual/en/selling/articles/applying-discount' }, - { label: 'Sales Person', url: docsUrl + 'user/manual/en/selling/articles/sales-persons-in-the-sales-transactions' }, - { label: 'Applying Margin', url: docsUrl + 'user/manual/en/selling/articles/adding-margin' }, -] +frappe.help.help_links["Form/Quotation"] = [ + { label: "Quotation", url: docsUrl + "user/manual/en/selling/quotation" }, + { + label: "Applying Discount", + url: docsUrl + "user/manual/en/selling/articles/applying-discount", + }, + { + label: "Sales Person", + url: + docsUrl + + "user/manual/en/selling/articles/sales-persons-in-the-sales-transactions", + }, + { + label: "Applying Margin", + url: docsUrl + "user/manual/en/selling/articles/adding-margin", + }, +]; -frappe.help.help_links['List/Customer'] = [ - { label: 'Customer', url: docsUrl + 'user/manual/en/CRM/customer' }, - { label: 'Credit Limit', url: docsUrl + 'user/manual/en/accounts/credit-limit' }, -] +frappe.help.help_links["List/Customer"] = [ + { label: "Customer", url: docsUrl + "user/manual/en/CRM/customer" }, + { + label: "Credit Limit", + url: docsUrl + "user/manual/en/accounts/credit-limit", + }, +]; -frappe.help.help_links['Form/Customer'] = [ - { label: 'Customer', url: docsUrl + 'user/manual/en/CRM/customer' }, - { label: 'Credit Limit', url: docsUrl + 'user/manual/en/accounts/credit-limit' }, -] +frappe.help.help_links["Form/Customer"] = [ + { label: "Customer", url: docsUrl + "user/manual/en/CRM/customer" }, + { + label: "Credit Limit", + url: docsUrl + "user/manual/en/accounts/credit-limit", + }, +]; -frappe.help.help_links['List/Sales Taxes and Charges Template'] = [ - { label: 'Setting Up Taxes', url: docsUrl + 'user/manual/en/setting-up/setting-up-taxes' }, -] +frappe.help.help_links["List/Sales Taxes and Charges Template"] = [ + { + label: "Setting Up Taxes", + url: docsUrl + "user/manual/en/setting-up/setting-up-taxes", + }, +]; -frappe.help.help_links['Form/Sales Taxes and Charges Template'] = [ - { label: 'Setting Up Taxes', url: docsUrl + 'user/manual/en/setting-up/setting-up-taxes' }, -] +frappe.help.help_links["Form/Sales Taxes and Charges Template"] = [ + { + label: "Setting Up Taxes", + url: docsUrl + "user/manual/en/setting-up/setting-up-taxes", + }, +]; -frappe.help.help_links['List/Sales Order'] = [ - { label: 'Sales Order', url: docsUrl + 'user/manual/en/selling/sales-order' }, - { label: 'Recurring Sales Order', url: docsUrl + 'user/manual/en/accounts/recurring-orders-and-invoices' }, - { label: 'Applying Discount', url: docsUrl + 'user/manual/en/selling/articles/applying-discount' }, -] +frappe.help.help_links["List/Sales Order"] = [ + { + label: "Sales Order", + url: docsUrl + "user/manual/en/selling/sales-order", + }, + { + label: "Recurring Sales Order", + url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + }, + { + label: "Applying Discount", + url: docsUrl + "user/manual/en/selling/articles/applying-discount", + }, +]; -frappe.help.help_links['Form/Sales Order'] = [ - { label: 'Sales Order', url: docsUrl + 'user/manual/en/selling/sales-order' }, - { label: 'Recurring Sales Order', url: docsUrl + 'user/manual/en/accounts/recurring-orders-and-invoices' }, - { label: 'Applying Discount', url: docsUrl + 'user/manual/en/selling/articles/applying-discount' }, - { label: 'Drop Shipping', url: docsUrl + 'user/manual/en/selling/articles/drop-shipping' }, - { label: 'Sales Person', url: docsUrl + 'user/manual/en/selling/articles/sales-persons-in-the-sales-transactions' }, - { label: 'Close Sales Order', url: docsUrl + 'user/manual/en/selling/articles/close-sales-order' }, - { label: 'Applying Margin', url: docsUrl + 'user/manual/en/selling/articles/adding-margin' }, -] +frappe.help.help_links["Form/Sales Order"] = [ + { + label: "Sales Order", + url: docsUrl + "user/manual/en/selling/sales-order", + }, + { + label: "Recurring Sales Order", + url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + }, + { + label: "Applying Discount", + url: docsUrl + "user/manual/en/selling/articles/applying-discount", + }, + { + label: "Drop Shipping", + url: docsUrl + "user/manual/en/selling/articles/drop-shipping", + }, + { + label: "Sales Person", + url: + docsUrl + + "user/manual/en/selling/articles/sales-persons-in-the-sales-transactions", + }, + { + label: "Close Sales Order", + url: docsUrl + "user/manual/en/selling/articles/close-sales-order", + }, + { + label: "Applying Margin", + url: docsUrl + "user/manual/en/selling/articles/adding-margin", + }, +]; -frappe.help.help_links['Form/Product Bundle'] = [ - { label: 'Product Bundle', url: docsUrl + 'user/manual/en/selling/setup/product-bundle' }, -] +frappe.help.help_links["Form/Product Bundle"] = [ + { + label: "Product Bundle", + url: docsUrl + "user/manual/en/selling/setup/product-bundle", + }, +]; -frappe.help.help_links['Form/Selling Settings'] = [ - { label: 'Selling Settings', url: docsUrl + 'user/manual/en/selling/setup/selling-settings' }, -] +frappe.help.help_links["Form/Selling Settings"] = [ + { + label: "Selling Settings", + url: docsUrl + "user/manual/en/selling/setup/selling-settings", + }, +]; //Buying -frappe.help.help_links['List/Supplier'] = [ - { label: 'Supplier', url: docsUrl + 'user/manual/en/buying/supplier' }, -] +frappe.help.help_links["List/Supplier"] = [ + { label: "Supplier", url: docsUrl + "user/manual/en/buying/supplier" }, +]; -frappe.help.help_links['Form/Supplier'] = [ - { label: 'Supplier', url: docsUrl + 'user/manual/en/buying/supplier' }, -] +frappe.help.help_links["Form/Supplier"] = [ + { label: "Supplier", url: docsUrl + "user/manual/en/buying/supplier" }, +]; -frappe.help.help_links['Form/Request for Quotation'] = [ - { label: 'Request for Quotation', url: docsUrl + 'user/manual/en/buying/request-for-quotation' }, - { label: 'RFQ Video', url: docsUrl + 'user/videos/learn/request-for-quotation.html' }, -] +frappe.help.help_links["Form/Request for Quotation"] = [ + { + label: "Request for Quotation", + url: docsUrl + "user/manual/en/buying/request-for-quotation", + }, + { + label: "RFQ Video", + url: docsUrl + "user/videos/learn/request-for-quotation.html", + }, +]; -frappe.help.help_links['Form/Supplier Quotation'] = [ - { label: 'Supplier Quotation', url: docsUrl + 'user/manual/en/buying/supplier-quotation' }, -] +frappe.help.help_links["Form/Supplier Quotation"] = [ + { + label: "Supplier Quotation", + url: docsUrl + "user/manual/en/buying/supplier-quotation", + }, +]; -frappe.help.help_links['Form/Buying Settings'] = [ - { label: 'Buying Settings', url: docsUrl + 'user/manual/en/buying/setup/buying-settings' }, -] +frappe.help.help_links["Form/Buying Settings"] = [ + { + label: "Buying Settings", + url: docsUrl + "user/manual/en/buying/setup/buying-settings", + }, +]; -frappe.help.help_links['List/Purchase Order'] = [ - { label: 'Purchase Order', url: docsUrl + 'user/manual/en/buying/purchase-order' }, - { label: 'Recurring Purchase Order', url: docsUrl + 'user/manual/en/accounts/recurring-orders-and-invoices' }, -] +frappe.help.help_links["List/Purchase Order"] = [ + { + label: "Purchase Order", + url: docsUrl + "user/manual/en/buying/purchase-order", + }, + { + label: "Recurring Purchase Order", + url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + }, +]; -frappe.help.help_links['Form/Purchase Order'] = [ - { label: 'Purchase Order', url: docsUrl + 'user/manual/en/buying/purchase-order' }, - { label: 'Item UoM', url: docsUrl + 'user/manual/en/buying/articles/purchasing-in-different-unit' }, - { label: 'Supplier Item Code', url: docsUrl + 'user/manual/en/buying/articles/maintaining-suppliers-part-no-in-item' }, - { label: 'Recurring Purchase Order', url: docsUrl + 'user/manual/en/accounts/recurring-orders-and-invoices' }, - { label: 'Subcontracting', url: docsUrl + 'user/manual/en/manufacturing/subcontracting' }, -] +frappe.help.help_links["Form/Purchase Order"] = [ + { + label: "Purchase Order", + url: docsUrl + "user/manual/en/buying/purchase-order", + }, + { + label: "Item UoM", + url: + docsUrl + + "user/manual/en/buying/articles/purchasing-in-different-unit", + }, + { + label: "Supplier Item Code", + url: + docsUrl + + "user/manual/en/buying/articles/maintaining-suppliers-part-no-in-item", + }, + { + label: "Recurring Purchase Order", + url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + }, + { + label: "Subcontracting", + url: docsUrl + "user/manual/en/manufacturing/subcontracting", + }, +]; -frappe.help.help_links['List/Purchase Taxes and Charges Template'] = [ - { label: 'Setting Up Taxes', url: docsUrl + 'user/manual/en/setting-up/setting-up-taxes' }, -] +frappe.help.help_links["List/Purchase Taxes and Charges Template"] = [ + { + label: "Setting Up Taxes", + url: docsUrl + "user/manual/en/setting-up/setting-up-taxes", + }, +]; -frappe.help.help_links['List/POS Profile'] = [ - { label: 'POS Profile', url: docsUrl + 'user/manual/en/setting-up/pos-setting' }, -] +frappe.help.help_links["List/POS Profile"] = [ + { + label: "POS Profile", + url: docsUrl + "user/manual/en/setting-up/pos-setting", + }, +]; -frappe.help.help_links['List/Price List'] = [ - { label: 'Price List', url: docsUrl + 'user/manual/en/setting-up/price-lists' }, -] +frappe.help.help_links["List/Price List"] = [ + { + label: "Price List", + url: docsUrl + "user/manual/en/setting-up/price-lists", + }, +]; -frappe.help.help_links['List/Authorization Rule'] = [ - { label: 'Authorization Rule', url: docsUrl + 'user/manual/en/setting-up/authorization-rule' }, -] +frappe.help.help_links["List/Authorization Rule"] = [ + { + label: "Authorization Rule", + url: docsUrl + "user/manual/en/setting-up/authorization-rule", + }, +]; -frappe.help.help_links['Form/SMS Settings'] = [ - { label: 'SMS Settings', url: docsUrl + 'user/manual/en/setting-up/sms-setting' }, -] +frappe.help.help_links["Form/SMS Settings"] = [ + { + label: "SMS Settings", + url: docsUrl + "user/manual/en/setting-up/sms-setting", + }, +]; -frappe.help.help_links['List/Stock Reconciliation'] = [ - { label: 'Stock Reconciliation', url: docsUrl + 'user/manual/en/setting-up/stock-reconciliation-for-non-serialized-item' }, -] +frappe.help.help_links["List/Stock Reconciliation"] = [ + { + label: "Stock Reconciliation", + url: + docsUrl + + "user/manual/en/setting-up/stock-reconciliation-for-non-serialized-item", + }, +]; -frappe.help.help_links['Tree/Territory'] = [ - { label: 'Territory', url: docsUrl + 'user/manual/en/setting-up/territory' }, -] +frappe.help.help_links["Tree/Territory"] = [ + { + label: "Territory", + url: docsUrl + "user/manual/en/setting-up/territory", + }, +]; -frappe.help.help_links['Form/Dropbox Backup'] = [ - { label: 'Dropbox Backup', url: docsUrl + 'user/manual/en/setting-up/third-party-backups' }, - { label: 'Setting Up Dropbox Backup', url: docsUrl + 'user/manual/en/setting-up/articles/setting-up-dropbox-backups' }, -] +frappe.help.help_links["Form/Dropbox Backup"] = [ + { + label: "Dropbox Backup", + url: docsUrl + "user/manual/en/setting-up/third-party-backups", + }, + { + label: "Setting Up Dropbox Backup", + url: + docsUrl + + "user/manual/en/setting-up/articles/setting-up-dropbox-backups", + }, +]; -frappe.help.help_links['List/Workflow'] = [ - { label: 'Workflow', url: docsUrl + 'user/manual/en/setting-up/workflows' }, -] +frappe.help.help_links["List/Workflow"] = [ + { label: "Workflow", url: docsUrl + "user/manual/en/setting-up/workflows" }, +]; -frappe.help.help_links['List/Company'] = [ - { label: 'Company', url: docsUrl + 'user/manual/en/setting-up/company-setup' }, - { label: 'Managing Multiple Companies', url: docsUrl + 'user/manual/en/setting-up/articles/managing-multiple-companies' }, - { label: 'Delete All Related Transactions for a Company', url: docsUrl + 'user/manual/en/setting-up/articles/delete-a-company-and-all-related-transactions' }, -] +frappe.help.help_links["List/Company"] = [ + { + label: "Company", + url: docsUrl + "user/manual/en/setting-up/company-setup", + }, + { + label: "Managing Multiple Companies", + url: + docsUrl + + "user/manual/en/setting-up/articles/managing-multiple-companies", + }, + { + label: "Delete All Related Transactions for a Company", + url: + docsUrl + + "user/manual/en/setting-up/articles/delete-a-company-and-all-related-transactions", + }, +]; //Accounts -frappe.help.help_links['modules/Accounts'] = [ - { label: 'Introduction to Accounts', url: docsUrl + 'user/manual/en/accounts/' }, - { label: 'Chart of Accounts', url: docsUrl + 'user/manual/en/accounts/chart-of-accounts.html' }, - { label: 'Multi Currency Accounting', url: docsUrl + 'user/manual/en/accounts/multi-currency-accounting' }, -] +frappe.help.help_links["modules/Accounts"] = [ + { + label: "Introduction to Accounts", + url: docsUrl + "user/manual/en/accounts/", + }, + { + label: "Chart of Accounts", + url: docsUrl + "user/manual/en/accounts/chart-of-accounts.html", + }, + { + label: "Multi Currency Accounting", + url: docsUrl + "user/manual/en/accounts/multi-currency-accounting", + }, +]; -frappe.help.help_links['Tree/Account'] = [ - { label: 'Chart of Accounts', url: docsUrl + 'user/manual/en/accounts/chart-of-accounts' }, - { label: 'Managing Tree Mastes', url: docsUrl + 'user/manual/en/setting-up/articles/managing-tree-structure-masters' }, -] +frappe.help.help_links["Tree/Account"] = [ + { + label: "Chart of Accounts", + url: docsUrl + "user/manual/en/accounts/chart-of-accounts", + }, + { + label: "Managing Tree Mastes", + url: + docsUrl + + "user/manual/en/setting-up/articles/managing-tree-structure-masters", + }, +]; -frappe.help.help_links['Form/Sales Invoice'] = [ - { label: 'Sales Invoice', url: docsUrl + 'user/manual/en/accounts/sales-invoice' }, - { label: 'Accounts Opening Balance', url: docsUrl + 'user/manual/en/accounts/opening-accounts' }, - { label: 'Sales Return', url: docsUrl + 'user/manual/en/stock/sales-return' }, - { label: 'Recurring Sales Invoice', url: docsUrl + 'user/manual/en/accounts/recurring-orders-and-invoices' }, -] +frappe.help.help_links["Form/Sales Invoice"] = [ + { + label: "Sales Invoice", + url: docsUrl + "user/manual/en/accounts/sales-invoice", + }, + { + label: "Accounts Opening Balance", + url: docsUrl + "user/manual/en/accounts/opening-accounts", + }, + { + label: "Sales Return", + url: docsUrl + "user/manual/en/stock/sales-return", + }, + { + label: "Recurring Sales Invoice", + url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + }, +]; -frappe.help.help_links['List/Sales Invoice'] = [ - { label: 'Sales Invoice', url: docsUrl + 'user/manual/en/accounts/sales-invoice' }, - { label: 'Accounts Opening Balance', url: docsUrl + 'user/manual/en/accounts/opening-accounts' }, - { label: 'Sales Return', url: docsUrl + 'user/manual/en/stock/sales-return' }, - { label: 'Recurring Sales Invoice', url: docsUrl + 'user/manual/en/accounts/recurring-orders-and-invoices' }, -] +frappe.help.help_links["List/Sales Invoice"] = [ + { + label: "Sales Invoice", + url: docsUrl + "user/manual/en/accounts/sales-invoice", + }, + { + label: "Accounts Opening Balance", + url: docsUrl + "user/manual/en/accounts/opening-accounts", + }, + { + label: "Sales Return", + url: docsUrl + "user/manual/en/stock/sales-return", + }, + { + label: "Recurring Sales Invoice", + url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + }, +]; -frappe.help.help_links['pos'] = [ - { label: 'Point of Sale Invoice', url: docsUrl + 'user/manual/en/accounts/point-of-sale-pos-invoice' }, -] +frappe.help.help_links["pos"] = [ + { + label: "Point of Sale Invoice", + url: docsUrl + "user/manual/en/accounts/point-of-sale-pos-invoice", + }, +]; -frappe.help.help_links['List/POS Profile'] = [ - { label: 'Point of Sale Profile', url: docsUrl + 'user/manual/en/setting-up/pos-setting' }, -] +frappe.help.help_links["List/POS Profile"] = [ + { + label: "Point of Sale Profile", + url: docsUrl + "user/manual/en/setting-up/pos-setting", + }, +]; -frappe.help.help_links['List/Purchase Invoice'] = [ - { label: 'Purchase Invoice', url: docsUrl + 'user/manual/en/accounts/purchase-invoice' }, - { label: 'Accounts Opening Balance', url: docsUrl + 'user/manual/en/accounts/opening-accounts' }, - { label: 'Recurring Purchase Invoice', url: docsUrl + 'user/manual/en/accounts/recurring-orders-and-invoices' }, -] +frappe.help.help_links["List/Purchase Invoice"] = [ + { + label: "Purchase Invoice", + url: docsUrl + "user/manual/en/accounts/purchase-invoice", + }, + { + label: "Accounts Opening Balance", + url: docsUrl + "user/manual/en/accounts/opening-accounts", + }, + { + label: "Recurring Purchase Invoice", + url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + }, +]; -frappe.help.help_links['List/Journal Entry'] = [ - { label: 'Journal Entry', url: docsUrl + 'user/manual/en/accounts/journal-entry' }, - { label: 'Advance Payment Entry', url: docsUrl + 'user/manual/en/accounts/advance-payment-entry' }, - { label: 'Accounts Opening Balance', url: docsUrl + 'user/manual/en/accounts/opening-accounts' }, -] +frappe.help.help_links["List/Journal Entry"] = [ + { + label: "Journal Entry", + url: docsUrl + "user/manual/en/accounts/journal-entry", + }, + { + label: "Advance Payment Entry", + url: docsUrl + "user/manual/en/accounts/advance-payment-entry", + }, + { + label: "Accounts Opening Balance", + url: docsUrl + "user/manual/en/accounts/opening-accounts", + }, +]; -frappe.help.help_links['List/Payment Entry'] = [ - { label: 'Payment Entry', url: docsUrl + 'user/manual/en/accounts/payment-entry' }, -] +frappe.help.help_links["List/Payment Entry"] = [ + { + label: "Payment Entry", + url: docsUrl + "user/manual/en/accounts/payment-entry", + }, +]; -frappe.help.help_links['List/Payment Request'] = [ - { label: 'Payment Request', url: docsUrl + 'user/manual/en/accounts/payment-request' }, -] +frappe.help.help_links["List/Payment Request"] = [ + { + label: "Payment Request", + url: docsUrl + "user/manual/en/accounts/payment-request", + }, +]; -frappe.help.help_links['List/Asset'] = [ - { label: 'Managing Fixed Assets', url: docsUrl + 'user/manual/en/accounts/managing-fixed-assets' }, -] +frappe.help.help_links["List/Asset"] = [ + { + label: "Managing Fixed Assets", + url: docsUrl + "user/manual/en/accounts/managing-fixed-assets", + }, +]; -frappe.help.help_links['List/Asset Category'] = [ - { label: 'Asset Category', url: docsUrl + 'user/manual/en/accounts/managing-fixed-assets' }, -] +frappe.help.help_links["List/Asset Category"] = [ + { + label: "Asset Category", + url: docsUrl + "user/manual/en/accounts/managing-fixed-assets", + }, +]; -frappe.help.help_links['Tree/Cost Center'] = [ - { label: 'Budgeting', url: docsUrl + 'user/manual/en/accounts/budgeting' }, -] +frappe.help.help_links["Tree/Cost Center"] = [ + { label: "Budgeting", url: docsUrl + "user/manual/en/accounts/budgeting" }, +]; -frappe.help.help_links['List/Item'] = [ - { label: 'Item', url: docsUrl + 'user/manual/en/stock/item' }, - { label: 'Item Price', url: docsUrl + 'user/manual/en/stock/item/item-price' }, - { label: 'Barcode', url: docsUrl + 'user/manual/en/stock/articles/track-items-using-barcode' }, - { label: 'Item Wise Taxation', url: docsUrl + 'user/manual/en/accounts/item-wise-taxation' }, - { label: 'Managing Fixed Assets', url: docsUrl + 'user/manual/en/accounts/managing-fixed-assets' }, - { label: 'Item Codification', url: docsUrl + 'user/manual/en/stock/item/item-codification' }, - { label: 'Item Variants', url: docsUrl + 'user/manual/en/stock/item/item-variants' }, - { label: 'Item Valuation', url: docsUrl + 'user/manual/en/stock/item/item-valuation-fifo-and-moving-average' }, -] +frappe.help.help_links["List/Item"] = [ + { label: "Item", url: docsUrl + "user/manual/en/stock/item" }, + { + label: "Item Price", + url: docsUrl + "user/manual/en/stock/item/item-price", + }, + { + label: "Barcode", + url: + docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + }, + { + label: "Item Wise Taxation", + url: docsUrl + "user/manual/en/accounts/item-wise-taxation", + }, + { + label: "Managing Fixed Assets", + url: docsUrl + "user/manual/en/accounts/managing-fixed-assets", + }, + { + label: "Item Codification", + url: docsUrl + "user/manual/en/stock/item/item-codification", + }, + { + label: "Item Variants", + url: docsUrl + "user/manual/en/stock/item/item-variants", + }, + { + label: "Item Valuation", + url: + docsUrl + + "user/manual/en/stock/item/item-valuation-fifo-and-moving-average", + }, +]; -frappe.help.help_links['Form/Item'] = [ - { label: 'Item', url: docsUrl + 'user/manual/en/stock/item' }, - { label: 'Item Price', url: docsUrl + 'user/manual/en/stock/item/item-price' }, - { label: 'Barcode', url: docsUrl + 'user/manual/en/stock/articles/track-items-using-barcode' }, - { label: 'Item Wise Taxation', url: docsUrl + 'user/manual/en/accounts/item-wise-taxation' }, - { label: 'Managing Fixed Assets', url: docsUrl + 'user/manual/en/accounts/managing-fixed-assets' }, - { label: 'Item Codification', url: docsUrl + 'user/manual/en/stock/item/item-codification' }, - { label: 'Item Variants', url: docsUrl + 'user/manual/en/stock/item/item-variants' }, - { label: 'Item Valuation', url: docsUrl + 'user/manual/en/stock/item/item-valuation-fifo-and-moving-average' }, -] +frappe.help.help_links["Form/Item"] = [ + { label: "Item", url: docsUrl + "user/manual/en/stock/item" }, + { + label: "Item Price", + url: docsUrl + "user/manual/en/stock/item/item-price", + }, + { + label: "Barcode", + url: + docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + }, + { + label: "Item Wise Taxation", + url: docsUrl + "user/manual/en/accounts/item-wise-taxation", + }, + { + label: "Managing Fixed Assets", + url: docsUrl + "user/manual/en/accounts/managing-fixed-assets", + }, + { + label: "Item Codification", + url: docsUrl + "user/manual/en/stock/item/item-codification", + }, + { + label: "Item Variants", + url: docsUrl + "user/manual/en/stock/item/item-variants", + }, + { + label: "Item Valuation", + url: + docsUrl + + "user/manual/en/stock/item/item-valuation-fifo-and-moving-average", + }, +]; -frappe.help.help_links['List/Purchase Receipt'] = [ - { label: 'Purchase Receipt', url: docsUrl + 'user/manual/en/stock/purchase-receipt' }, - { label: 'Barcode', url: docsUrl + 'user/manual/en/stock/articles/track-items-using-barcode' }, -] +frappe.help.help_links["List/Purchase Receipt"] = [ + { + label: "Purchase Receipt", + url: docsUrl + "user/manual/en/stock/purchase-receipt", + }, + { + label: "Barcode", + url: + docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + }, +]; -frappe.help.help_links['List/Delivery Note'] = [ - { label: 'Delivery Note', url: docsUrl + 'user/manual/en/stock/delivery-note' }, - { label: 'Barcode', url: docsUrl + 'user/manual/en/stock/articles/track-items-using-barcode' }, - { label: 'Sales Return', url: docsUrl + 'user/manual/en/stock/sales-return' }, -] +frappe.help.help_links["List/Delivery Note"] = [ + { + label: "Delivery Note", + url: docsUrl + "user/manual/en/stock/delivery-note", + }, + { + label: "Barcode", + url: + docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + }, + { + label: "Sales Return", + url: docsUrl + "user/manual/en/stock/sales-return", + }, +]; -frappe.help.help_links['Form/Delivery Note'] = [ - { label: 'Delivery Note', url: docsUrl + 'user/manual/en/stock/delivery-note' }, - { label: 'Sales Return', url: docsUrl + 'user/manual/en/stock/sales-return' }, - { label: 'Barcode', url: docsUrl + 'user/manual/en/stock/articles/track-items-using-barcode' }, - { label: 'Subcontracting', url: docsUrl + 'user/manual/en/manufacturing/subcontracting' }, -] +frappe.help.help_links["Form/Delivery Note"] = [ + { + label: "Delivery Note", + url: docsUrl + "user/manual/en/stock/delivery-note", + }, + { + label: "Sales Return", + url: docsUrl + "user/manual/en/stock/sales-return", + }, + { + label: "Barcode", + url: + docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", + }, + { + label: "Subcontracting", + url: docsUrl + "user/manual/en/manufacturing/subcontracting", + }, +]; -frappe.help.help_links['List/Installation Note'] = [ - { label: 'Installation Note', url: docsUrl + 'user/manual/en/stock/installation-note' }, -] +frappe.help.help_links["List/Installation Note"] = [ + { + label: "Installation Note", + url: docsUrl + "user/manual/en/stock/installation-note", + }, +]; +frappe.help.help_links["Tree"] = [ + { + label: "Managing Tree Structure Masters", + url: + docsUrl + + "user/manual/en/setting-up/articles/managing-tree-structure-masters", + }, +]; -frappe.help.help_links['Tree'] = [ - { label: 'Managing Tree Structure Masters', url: docsUrl + 'user/manual/en/setting-up/articles/managing-tree-structure-masters' }, -] - -frappe.help.help_links['List/Budget'] = [ - { label: 'Budgeting', url: docsUrl + 'user/manual/en/accounts/budgeting' }, -] +frappe.help.help_links["List/Budget"] = [ + { label: "Budgeting", url: docsUrl + "user/manual/en/accounts/budgeting" }, +]; //Stock -frappe.help.help_links['List/Material Request'] = [ - { label: 'Material Request', url: docsUrl + 'user/manual/en/stock/material-request' }, - { label: 'Auto-creation of Material Request', url: docsUrl + 'user/manual/en/stock/articles/auto-creation-of-material-request' }, -] +frappe.help.help_links["List/Material Request"] = [ + { + label: "Material Request", + url: docsUrl + "user/manual/en/stock/material-request", + }, + { + label: "Auto-creation of Material Request", + url: + docsUrl + + "user/manual/en/stock/articles/auto-creation-of-material-request", + }, +]; -frappe.help.help_links['Form/Material Request'] = [ - { label: 'Material Request', url: docsUrl + 'user/manual/en/stock/material-request' }, - { label: 'Auto-creation of Material Request', url: docsUrl + 'user/manual/en/stock/articles/auto-creation-of-material-request' }, -] +frappe.help.help_links["Form/Material Request"] = [ + { + label: "Material Request", + url: docsUrl + "user/manual/en/stock/material-request", + }, + { + label: "Auto-creation of Material Request", + url: + docsUrl + + "user/manual/en/stock/articles/auto-creation-of-material-request", + }, +]; -frappe.help.help_links['Form/Stock Entry'] = [ - { label: 'Stock Entry', url: docsUrl + 'user/manual/en/stock/stock-entry' }, - { label: 'Stock Entry Types', url: docsUrl + 'user/manual/en/stock/articles/stock-entry-purpose' }, - { label: 'Repack Entry', url: docsUrl + 'user/manual/en/stock/articles/repack-entry' }, - { label: 'Opening Stock', url: docsUrl + 'user/manual/en/stock/opening-stock' }, - { label: 'Subcontracting', url: docsUrl + 'user/manual/en/manufacturing/subcontracting' }, -] +frappe.help.help_links["Form/Stock Entry"] = [ + { label: "Stock Entry", url: docsUrl + "user/manual/en/stock/stock-entry" }, + { + label: "Stock Entry Types", + url: docsUrl + "user/manual/en/stock/articles/stock-entry-purpose", + }, + { + label: "Repack Entry", + url: docsUrl + "user/manual/en/stock/articles/repack-entry", + }, + { + label: "Opening Stock", + url: docsUrl + "user/manual/en/stock/opening-stock", + }, + { + label: "Subcontracting", + url: docsUrl + "user/manual/en/manufacturing/subcontracting", + }, +]; -frappe.help.help_links['List/Stock Entry'] = [ - { label: 'Stock Entry', url: docsUrl + 'user/manual/en/stock/stock-entry' }, -] +frappe.help.help_links["List/Stock Entry"] = [ + { label: "Stock Entry", url: docsUrl + "user/manual/en/stock/stock-entry" }, +]; -frappe.help.help_links['Tree/Warehouse'] = [ - { label: 'Warehouse', url: docsUrl + 'user/manual/en/stock/warehouse' }, -] +frappe.help.help_links["Tree/Warehouse"] = [ + { label: "Warehouse", url: docsUrl + "user/manual/en/stock/warehouse" }, +]; -frappe.help.help_links['List/Serial No'] = [ - { label: 'Serial No', url: docsUrl + 'user/manual/en/stock/serial-no' }, -] +frappe.help.help_links["List/Serial No"] = [ + { label: "Serial No", url: docsUrl + "user/manual/en/stock/serial-no" }, +]; -frappe.help.help_links['Form/Serial No'] = [ - { label: 'Serial No', url: docsUrl + 'user/manual/en/stock/serial-no' }, -] +frappe.help.help_links["Form/Serial No"] = [ + { label: "Serial No", url: docsUrl + "user/manual/en/stock/serial-no" }, +]; -frappe.help.help_links['Form/Batch'] = [ - { label: 'Batch', url: docsUrl + 'user/manual/en/stock/batch' }, -] +frappe.help.help_links["Form/Batch"] = [ + { label: "Batch", url: docsUrl + "user/manual/en/stock/batch" }, +]; -frappe.help.help_links['Form/Packing Slip'] = [ - { label: 'Packing Slip', url: docsUrl + 'user/manual/en/stock/tools/packing-slip' }, -] +frappe.help.help_links["Form/Packing Slip"] = [ + { + label: "Packing Slip", + url: docsUrl + "user/manual/en/stock/tools/packing-slip", + }, +]; -frappe.help.help_links['Form/Quality Inspection'] = [ - { label: 'Quality Inspection', url: docsUrl + 'user/manual/en/stock/tools/quality-inspection' }, -] +frappe.help.help_links["Form/Quality Inspection"] = [ + { + label: "Quality Inspection", + url: docsUrl + "user/manual/en/stock/tools/quality-inspection", + }, +]; -frappe.help.help_links['Form/Landed Cost Voucher'] = [ - { label: 'Landed Cost Voucher', url: docsUrl + 'user/manual/en/stock/tools/landed-cost-voucher' }, -] +frappe.help.help_links["Form/Landed Cost Voucher"] = [ + { + label: "Landed Cost Voucher", + url: docsUrl + "user/manual/en/stock/tools/landed-cost-voucher", + }, +]; -frappe.help.help_links['Tree/Item Group'] = [ - { label: 'Item Group', url: docsUrl + 'user/manual/en/stock/setup/item-group' }, -] +frappe.help.help_links["Tree/Item Group"] = [ + { + label: "Item Group", + url: docsUrl + "user/manual/en/stock/setup/item-group", + }, +]; -frappe.help.help_links['Form/Item Attribute'] = [ - { label: 'Item Attribute', url: docsUrl + 'user/manual/en/stock/setup/item-attribute' }, -] +frappe.help.help_links["Form/Item Attribute"] = [ + { + label: "Item Attribute", + url: docsUrl + "user/manual/en/stock/setup/item-attribute", + }, +]; -frappe.help.help_links['Form/UOM'] = [ - { label: 'Fractions in UOM', url: docsUrl + 'user/manual/en/stock/articles/managing-fractions-in-uom' }, -] +frappe.help.help_links["Form/UOM"] = [ + { + label: "Fractions in UOM", + url: + docsUrl + "user/manual/en/stock/articles/managing-fractions-in-uom", + }, +]; -frappe.help.help_links['Form/Stock Reconciliation'] = [ - { label: 'Opening Stock Entry', url: docsUrl + 'user/manual/en/stock/opening-stock' }, -] +frappe.help.help_links["Form/Stock Reconciliation"] = [ + { + label: "Opening Stock Entry", + url: docsUrl + "user/manual/en/stock/opening-stock", + }, +]; //CRM -frappe.help.help_links['Form/Lead'] = [ - { label: 'Lead', url: docsUrl + 'user/manual/en/CRM/lead' }, -] +frappe.help.help_links["Form/Lead"] = [ + { label: "Lead", url: docsUrl + "user/manual/en/CRM/lead" }, +]; -frappe.help.help_links['Form/Opportunity'] = [ - { label: 'Opportunity', url: docsUrl + 'user/manual/en/CRM/opportunity' }, -] +frappe.help.help_links["Form/Opportunity"] = [ + { label: "Opportunity", url: docsUrl + "user/manual/en/CRM/opportunity" }, +]; -frappe.help.help_links['Form/Address'] = [ - { label: 'Address', url: docsUrl + 'user/manual/en/CRM/address' }, -] +frappe.help.help_links["Form/Address"] = [ + { label: "Address", url: docsUrl + "user/manual/en/CRM/address" }, +]; -frappe.help.help_links['Form/Contact'] = [ - { label: 'Contact', url: docsUrl + 'user/manual/en/CRM/contact' }, -] +frappe.help.help_links["Form/Contact"] = [ + { label: "Contact", url: docsUrl + "user/manual/en/CRM/contact" }, +]; -frappe.help.help_links['Form/Newsletter'] = [ - { label: 'Newsletter', url: docsUrl + 'user/manual/en/CRM/newsletter' }, -] +frappe.help.help_links["Form/Newsletter"] = [ + { label: "Newsletter", url: docsUrl + "user/manual/en/CRM/newsletter" }, +]; -frappe.help.help_links['Form/Campaign'] = [ - { label: 'Campaign', url: docsUrl + 'user/manual/en/CRM/setup/campaign' }, -] +frappe.help.help_links["Form/Campaign"] = [ + { label: "Campaign", url: docsUrl + "user/manual/en/CRM/setup/campaign" }, +]; -frappe.help.help_links['Tree/Sales Person'] = [ - { label: 'Sales Person', url: docsUrl + 'user/manual/en/CRM/setup/sales-person' }, -] +frappe.help.help_links["Tree/Sales Person"] = [ + { + label: "Sales Person", + url: docsUrl + "user/manual/en/CRM/setup/sales-person", + }, +]; -frappe.help.help_links['Form/Sales Person'] = [ - { label: 'Sales Person Target', url: docsUrl + 'user/manual/en/selling/setup/sales-person-target-allocation' }, -] +frappe.help.help_links["Form/Sales Person"] = [ + { + label: "Sales Person Target", + url: + docsUrl + + "user/manual/en/selling/setup/sales-person-target-allocation", + }, +]; //Support -frappe.help.help_links['List/Feedback Trigger'] = [ - { label: 'Feedback Trigger', url: docsUrl + 'user/manual/en/setting-up/feedback/setting-up-feedback' }, -] +frappe.help.help_links["List/Feedback Trigger"] = [ + { + label: "Feedback Trigger", + url: docsUrl + "user/manual/en/setting-up/feedback/setting-up-feedback", + }, +]; -frappe.help.help_links['List/Feedback Request'] = [ - { label: 'Feedback Request', url: docsUrl + 'user/manual/en/setting-up/feedback/submit-feedback' }, -] +frappe.help.help_links["List/Feedback Request"] = [ + { + label: "Feedback Request", + url: docsUrl + "user/manual/en/setting-up/feedback/submit-feedback", + }, +]; -frappe.help.help_links['List/Feedback Request'] = [ - { label: 'Feedback Request', url: docsUrl + 'user/manual/en/setting-up/feedback/submit-feedback' }, -] +frappe.help.help_links["List/Feedback Request"] = [ + { + label: "Feedback Request", + url: docsUrl + "user/manual/en/setting-up/feedback/submit-feedback", + }, +]; //Manufacturing -frappe.help.help_links['Form/BOM'] = [ - { label: 'Bill of Material', url: docsUrl + 'user/manual/en/manufacturing/bill-of-materials' }, - { label: 'Nested BOM Structure', url: docsUrl + 'user/manual/en/manufacturing/articles/nested-bom-structure' }, -] +frappe.help.help_links["Form/BOM"] = [ + { + label: "Bill of Material", + url: docsUrl + "user/manual/en/manufacturing/bill-of-materials", + }, + { + label: "Nested BOM Structure", + url: + docsUrl + + "user/manual/en/manufacturing/articles/nested-bom-structure", + }, +]; -frappe.help.help_links['Form/Work Order'] = [ - { label: 'Work Order', url: docsUrl + 'user/manual/en/manufacturing/work-order' }, -] +frappe.help.help_links["Form/Work Order"] = [ + { + label: "Work Order", + url: docsUrl + "user/manual/en/manufacturing/work-order", + }, +]; -frappe.help.help_links['Form/Workstation'] = [ - { label: 'Workstation', url: docsUrl + 'user/manual/en/manufacturing/workstation' }, -] +frappe.help.help_links["Form/Workstation"] = [ + { + label: "Workstation", + url: docsUrl + "user/manual/en/manufacturing/workstation", + }, +]; -frappe.help.help_links['Form/Operation'] = [ - { label: 'Operation', url: docsUrl + 'user/manual/en/manufacturing/operation' }, -] +frappe.help.help_links["Form/Operation"] = [ + { + label: "Operation", + url: docsUrl + "user/manual/en/manufacturing/operation", + }, +]; -frappe.help.help_links['Form/BOM Update Tool'] = [ - { label: 'BOM Update Tool', url: docsUrl + 'user/manual/en/manufacturing/tools/bom-update-tool' }, -] +frappe.help.help_links["Form/BOM Update Tool"] = [ + { + label: "BOM Update Tool", + url: docsUrl + "user/manual/en/manufacturing/tools/bom-update-tool", + }, +]; //Customize -frappe.help.help_links['Form/Customize Form'] = [ - { label: 'Custom Field', url: docsUrl + 'user/manual/en/customize-erpnext/custom-field' }, - { label: 'Customize Field', url: docsUrl + 'user/manual/en/customize-erpnext/customize-form' }, -] +frappe.help.help_links["Form/Customize Form"] = [ + { + label: "Custom Field", + url: docsUrl + "user/manual/en/customize-erpnext/custom-field", + }, + { + label: "Customize Field", + url: docsUrl + "user/manual/en/customize-erpnext/customize-form", + }, +]; -frappe.help.help_links['Form/Custom Field'] = [ - { label: 'Custom Field', url: docsUrl + 'user/manual/en/customize-erpnext/custom-field' }, -] +frappe.help.help_links["Form/Custom Field"] = [ + { + label: "Custom Field", + url: docsUrl + "user/manual/en/customize-erpnext/custom-field", + }, +]; -frappe.help.help_links['Form/Custom Field'] = [ - { label: 'Custom Field', url: docsUrl + 'user/manual/en/customize-erpnext/custom-field' }, -] +frappe.help.help_links["Form/Custom Field"] = [ + { + label: "Custom Field", + url: docsUrl + "user/manual/en/customize-erpnext/custom-field", + }, +]; diff --git a/erpnext/public/js/hub/pages/Category.vue b/erpnext/public/js/hub/pages/Category.vue index 057fe8bc617..16d06018ff0 100644 --- a/erpnext/public/js/hub/pages/Category.vue +++ b/erpnext/public/js/hub/pages/Category.vue @@ -32,7 +32,7 @@ export default { item_id_fieldname: 'name', // Constants - empty_state_message: __(`No items in this category yet.`), + empty_state_message: __('No items in this category yet.'), search_value: '', diff --git a/erpnext/public/js/hub/pages/FeaturedItems.vue b/erpnext/public/js/hub/pages/FeaturedItems.vue index ab9990a3230..63ae7e99bbd 100644 --- a/erpnext/public/js/hub/pages/FeaturedItems.vue +++ b/erpnext/public/js/hub/pages/FeaturedItems.vue @@ -33,10 +33,8 @@ export default { // Constants page_title: __('Your Featured Items'), - empty_state_message: __(`No featured items yet. Got to your - - Published Items - and feature upto 8 items that you want to highlight to your customers.`) + empty_state_message: __('No featured items yet. Got to your {0} and feature up to eight items that you want to highlight to your customers.', + [`${__("Published Items")}`]) }; }, created() { @@ -71,9 +69,9 @@ export default { const item_name = this.items.filter(item => item.hub_item_name === hub_item_name); - alert = frappe.show_alert(__(`${item_name} removed. - Undo`), - grace_period/1000, + alert_message = __('{0} removed. {1}', [item_name, + `${__('Undo')}`]); + alert = frappe.show_alert(alert_message, grace_period / 1000, { 'undo-remove': undo_remove.bind(this) } diff --git a/erpnext/public/js/hub/pages/Item.vue b/erpnext/public/js/hub/pages/Item.vue index 51ade42cbae..93002a7b27a 100644 --- a/erpnext/public/js/hub/pages/Item.vue +++ b/erpnext/public/js/hub/pages/Item.vue @@ -113,12 +113,12 @@ export default { let stats = __('No views yet'); if (this.item.view_count) { - const views_message = __(`${this.item.view_count} Views`); + const views_message = __('{0} Views', [this.item.view_count]); const rating_html = get_rating_html(this.item.average_rating); const rating_count = this.item.no_of_ratings > 0 - ? `${this.item.no_of_ratings} reviews` + ? __('{0} reviews', [this.item.no_of_ratings]) : __('No reviews yet'); stats = [views_message, rating_html, rating_count]; @@ -310,7 +310,7 @@ export default { return this.get_item_details(); }) .then(() => { - frappe.show_alert(__(`${this.item.item_name} Updated`)); + frappe.show_alert(__('{0} Updated', [this.item.item_name])); }); }, @@ -337,7 +337,7 @@ export default { }, unpublish_item() { - frappe.confirm(__(`Unpublish {0}?`, [this.item.item_name]), () => { + frappe.confirm(__('Unpublish {0}?', [this.item.item_name]), () => { frappe .call('erpnext.hub_node.api.unpublish_item', { item_code: this.item.item_code, diff --git a/erpnext/public/js/hub/pages/NotFound.vue b/erpnext/public/js/hub/pages/NotFound.vue index 246d31bc681..8901b97802d 100644 --- a/erpnext/public/js/hub/pages/NotFound.vue +++ b/erpnext/public/js/hub/pages/NotFound.vue @@ -27,7 +27,7 @@ export default { }, // Constants - empty_state_message: __(`Sorry! I could not find what you were looking for.`) + empty_state_message: __('Sorry! We could not find what you were looking for.') }; }, } diff --git a/erpnext/public/js/hub/pages/Publish.vue b/erpnext/public/js/hub/pages/Publish.vue index 735f2b92eca..96fa0aae4e5 100644 --- a/erpnext/public/js/hub/pages/Publish.vue +++ b/erpnext/public/js/hub/pages/Publish.vue @@ -75,14 +75,11 @@ export default { // TODO: multiline translations don't work page_title: __('Publish Items'), search_placeholder: __('Search Items ...'), - empty_state_message: __(`No Items selected yet. Browse and click on items below to publish.`), - valid_items_instruction: __(`Only items with an image and description can be published. Please update them if an item in your inventory does not appear.`), + empty_state_message: __('No Items selected yet. Browse and click on items below to publish.'), + valid_items_instruction: __('Only items with an image and description can be published. Please update them if an item in your inventory does not appear.'), last_sync_message: (hub.settings.last_sync_datetime) - ? __(`Last sync was - - ${comment_when(hub.settings.last_sync_datetime)}. - - See your Published Items.`) + ? __('Last sync was {0}.', [`${comment_when(hub.settings.last_sync_datetime)}`]) + + ` ${__('See your Published Items.')}` : '' }; }, @@ -147,11 +144,9 @@ export default { }, add_last_sync_message() { - this.last_sync_message = __(`Last sync was - - ${comment_when(hub.settings.last_sync_datetime)}. - - See your Published Items.`); + this.last_sync_message = __('Last sync was {0}.', + [`${comment_when(hub.settings.last_sync_datetime)}`] + ) + `${__('See your Published Items')}.`; }, clear_last_sync_message() { diff --git a/erpnext/public/js/hub/pages/SavedItems.vue b/erpnext/public/js/hub/pages/SavedItems.vue index c29675acd30..7007ddcf8e7 100644 --- a/erpnext/public/js/hub/pages/SavedItems.vue +++ b/erpnext/public/js/hub/pages/SavedItems.vue @@ -29,7 +29,7 @@ export default { // Constants page_title: __('Saved Items'), - empty_state_message: __(`You haven't saved any items yet.`) + empty_state_message: __('You have not saved any items yet.') }; }, created() { @@ -64,8 +64,13 @@ export default { const item_name = this.items.filter(item => item.hub_item_name === hub_item_name); - alert = frappe.show_alert(__(`${item_name} removed. - Undo`), + alert = frappe.show_alert(` + + ${__('{0} removed.', [item_name], 'A specific Item has been removed.')} + + ${__('Undo', None, 'Undo removal of item.')} + + `, grace_period/1000, { 'undo-remove': undo_remove.bind(this) diff --git a/erpnext/public/js/hub/pages/Search.vue b/erpnext/public/js/hub/pages/Search.vue index 103284289bb..c10841e9848 100644 --- a/erpnext/public/js/hub/pages/Search.vue +++ b/erpnext/public/js/hub/pages/Search.vue @@ -42,7 +42,10 @@ export default { computed: { page_title() { return this.items.length - ? __(`Results for "${this.search_value}" ${this.category !== 'All'? `in category ${this.category}` : ''}`) + ? __('Results for "{0}" {1}', [ + this.search_value, + this.category !== 'All' ? __('in category {0}', [this.category]) : '' + ]) : __('No Items found.'); } }, diff --git a/erpnext/public/js/hub/pages/Seller.vue b/erpnext/public/js/hub/pages/Seller.vue index e339eaa3e5b..c0903c64c37 100644 --- a/erpnext/public/js/hub/pages/Seller.vue +++ b/erpnext/public/js/hub/pages/Seller.vue @@ -136,7 +136,7 @@ export default { this.init = false; this.profile = data.profile; this.items = data.items; - this.item_container_heading = data.is_featured_item? "Features Items":"Popular Items"; + this.item_container_heading = data.is_featured_item ? __('Featured Items') : __('Popular Items'); this.hub_seller = this.items[0].hub_seller; this.recent_seller_reviews = data.recent_seller_reviews; this.seller_product_view_stats = data.seller_product_view_stats; @@ -147,7 +147,7 @@ export default { this.country = __(profile.country); this.site_name = __(profile.site_name); - this.joined_when = __(`Joined ${comment_when(profile.creation)}`); + this.joined_when = __('Joined {0}', [comment_when(profile.creation)]); this.image = profile.logo; this.sections = [ diff --git a/erpnext/public/js/payment/payment_details.html b/erpnext/public/js/payment/payment_details.html deleted file mode 100644 index 3e6394483eb..00000000000 --- a/erpnext/public/js/payment/payment_details.html +++ /dev/null @@ -1,11 +0,0 @@ -
    -
    {{mode_of_payment}}
    -
    -
    - - - - -
    -
    -
    \ No newline at end of file diff --git a/erpnext/public/js/payment/pos_payment.html b/erpnext/public/js/payment/pos_payment.html deleted file mode 100644 index cb6971b46b5..00000000000 --- a/erpnext/public/js/payment/pos_payment.html +++ /dev/null @@ -1,42 +0,0 @@ -
    -
    -

    {{ __("Total Amount") }}: {%= format_currency(grand_total, currency) %}

    -
    -
    -
    -

    {{ __("Paid") }}

    -
    -
    -

    {{ __("Outstanding") }}

    {%= format_currency(outstanding_amount, currency) %}

    -
    -
    -

    {{ __("Change") }} -

    -
    -
    -

    {{ __("Write off") }} -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - {% for(var i=0; i<3; i++) { %} -
    - {% for(var j=i*3; j<(i+1)*3; j++) { %} - - {% } %} -
    - {% } %} -
    - - - -
    -
    -
    -
    diff --git a/erpnext/public/js/pos/clusterize.js b/erpnext/public/js/pos/clusterize.js deleted file mode 100644 index 075c9ca4ae6..00000000000 --- a/erpnext/public/js/pos/clusterize.js +++ /dev/null @@ -1,330 +0,0 @@ -/* eslint-disable */ -/*! Clusterize.js - v0.17.6 - 2017-03-05 -* http://NeXTs.github.com/Clusterize.js/ -* Copyright (c) 2015 Denis Lukov; Licensed GPLv3 */ - -;(function(name, definition) { - if (typeof module != 'undefined') module.exports = definition(); - else if (typeof define == 'function' && typeof define.amd == 'object') define(definition); - else this[name] = definition(); -}('Clusterize', function() { - "use strict" - - // detect ie9 and lower - // https://gist.github.com/padolsey/527683#comment-786682 - var ie = (function(){ - for( var v = 3, - el = document.createElement('b'), - all = el.all || []; - el.innerHTML = '', - all[0]; - ){} - return v > 4 ? v : document.documentMode; - }()), - is_mac = navigator.platform.toLowerCase().indexOf('mac') + 1; - var Clusterize = function(data) { - if( ! (this instanceof Clusterize)) - return new Clusterize(data); - var self = this; - - var defaults = { - rows_in_block: 50, - blocks_in_cluster: 4, - tag: null, - show_no_data_row: true, - no_data_class: 'clusterize-no-data', - no_data_text: 'No data', - keep_parity: true, - callbacks: {} - } - - // public parameters - self.options = {}; - var options = ['rows_in_block', 'blocks_in_cluster', 'show_no_data_row', 'no_data_class', 'no_data_text', 'keep_parity', 'tag', 'callbacks']; - for(var i = 0, option; option = options[i]; i++) { - self.options[option] = typeof data[option] != 'undefined' && data[option] != null - ? data[option] - : defaults[option]; - } - - var elems = ['scroll', 'content']; - for(var i = 0, elem; elem = elems[i]; i++) { - self[elem + '_elem'] = data[elem + 'Id'] - ? document.getElementById(data[elem + 'Id']) - : data[elem + 'Elem']; - if( ! self[elem + '_elem']) - throw new Error("Error! Could not find " + elem + " element"); - } - - // tabindex forces the browser to keep focus on the scrolling list, fixes #11 - if( ! self.content_elem.hasAttribute('tabindex')) - self.content_elem.setAttribute('tabindex', 0); - - // private parameters - var rows = isArray(data.rows) - ? data.rows - : self.fetchMarkup(), - cache = {}, - scroll_top = self.scroll_elem.scrollTop; - - // append initial data - self.insertToDOM(rows, cache); - - // restore the scroll position - self.scroll_elem.scrollTop = scroll_top; - - // adding scroll handler - var last_cluster = false, - scroll_debounce = 0, - pointer_events_set = false, - scrollEv = function() { - // fixes scrolling issue on Mac #3 - if (is_mac) { - if( ! pointer_events_set) self.content_elem.style.pointerEvents = 'none'; - pointer_events_set = true; - clearTimeout(scroll_debounce); - scroll_debounce = setTimeout(function () { - self.content_elem.style.pointerEvents = 'auto'; - pointer_events_set = false; - }, 50); - } - if (last_cluster != (last_cluster = self.getClusterNum())) - self.insertToDOM(rows, cache); - if (self.options.callbacks.scrollingProgress) - self.options.callbacks.scrollingProgress(self.getScrollProgress()); - }, - resize_debounce = 0, - resizeEv = function() { - clearTimeout(resize_debounce); - resize_debounce = setTimeout(self.refresh, 100); - } - on('scroll', self.scroll_elem, scrollEv); - on('resize', window, resizeEv); - - // public methods - self.destroy = function(clean) { - off('scroll', self.scroll_elem, scrollEv); - off('resize', window, resizeEv); - self.html((clean ? self.generateEmptyRow() : rows).join('')); - } - self.refresh = function(force) { - if(self.getRowsHeight(rows) || force) self.update(rows); - } - self.update = function(new_rows) { - rows = isArray(new_rows) - ? new_rows - : []; - var scroll_top = self.scroll_elem.scrollTop; - // fixes #39 - if(rows.length * self.options.item_height < scroll_top) { - self.scroll_elem.scrollTop = 0; - last_cluster = 0; - } - self.insertToDOM(rows, cache); - self.scroll_elem.scrollTop = scroll_top; - } - self.clear = function() { - self.update([]); - } - self.getRowsAmount = function() { - return rows.length; - } - self.getScrollProgress = function() { - return this.options.scroll_top / (rows.length * this.options.item_height) * 100 || 0; - } - - var add = function(where, _new_rows) { - var new_rows = isArray(_new_rows) - ? _new_rows - : []; - if( ! new_rows.length) return; - rows = where == 'append' - ? rows.concat(new_rows) - : new_rows.concat(rows); - self.insertToDOM(rows, cache); - } - self.append = function(rows) { - add('append', rows); - } - self.prepend = function(rows) { - add('prepend', rows); - } - } - - Clusterize.prototype = { - constructor: Clusterize, - // fetch existing markup - fetchMarkup: function() { - var rows = [], rows_nodes = this.getChildNodes(this.content_elem); - while (rows_nodes.length) { - rows.push(rows_nodes.shift().outerHTML); - } - return rows; - }, - // get tag name, content tag name, tag height, calc cluster height - exploreEnvironment: function(rows, cache) { - var opts = this.options; - opts.content_tag = this.content_elem.tagName.toLowerCase(); - if( ! rows.length) return; - if(ie && ie <= 9 && ! opts.tag) opts.tag = rows[0].match(/<([^>\s/]*)/)[1].toLowerCase(); - if(this.content_elem.children.length <= 1) cache.data = this.html(rows[0] + rows[0] + rows[0]); - if( ! opts.tag) opts.tag = this.content_elem.children[0].tagName.toLowerCase(); - this.getRowsHeight(rows); - }, - getRowsHeight: function(rows) { - var opts = this.options, - prev_item_height = opts.item_height; - opts.cluster_height = 0; - if( ! rows.length) return; - var nodes = this.content_elem.children; - var node = nodes[Math.floor(nodes.length / 2)]; - opts.item_height = node.offsetHeight; - // consider table's border-spacing - if(opts.tag == 'tr' && getStyle('borderCollapse', this.content_elem) != 'collapse') - opts.item_height += parseInt(getStyle('borderSpacing', this.content_elem), 10) || 0; - // consider margins (and margins collapsing) - if(opts.tag != 'tr') { - var marginTop = parseInt(getStyle('marginTop', node), 10) || 0; - var marginBottom = parseInt(getStyle('marginBottom', node), 10) || 0; - opts.item_height += Math.max(marginTop, marginBottom); - } - opts.block_height = opts.item_height * opts.rows_in_block; - opts.rows_in_cluster = opts.blocks_in_cluster * opts.rows_in_block; - opts.cluster_height = opts.blocks_in_cluster * opts.block_height; - return prev_item_height != opts.item_height; - }, - // get current cluster number - getClusterNum: function () { - this.options.scroll_top = this.scroll_elem.scrollTop; - return Math.floor(this.options.scroll_top / (this.options.cluster_height - this.options.block_height)) || 0; - }, - // generate empty row if no data provided - generateEmptyRow: function() { - var opts = this.options; - if( ! opts.tag || ! opts.show_no_data_row) return []; - var empty_row = document.createElement(opts.tag), - no_data_content = document.createTextNode(opts.no_data_text), td; - empty_row.className = opts.no_data_class; - if(opts.tag == 'tr') { - td = document.createElement('td'); - // fixes #53 - td.colSpan = 100; - td.appendChild(no_data_content); - } - empty_row.appendChild(td || no_data_content); - return [empty_row.outerHTML]; - }, - // generate cluster for current scroll position - generate: function (rows, cluster_num) { - var opts = this.options, - rows_len = rows.length; - if (rows_len < opts.rows_in_block) { - return { - top_offset: 0, - bottom_offset: 0, - rows_above: 0, - rows: rows_len ? rows : this.generateEmptyRow() - } - } - var items_start = Math.max((opts.rows_in_cluster - opts.rows_in_block) * cluster_num, 0), - items_end = items_start + opts.rows_in_cluster, - top_offset = Math.max(items_start * opts.item_height, 0), - bottom_offset = Math.max((rows_len - items_end) * opts.item_height, 0), - this_cluster_rows = [], - rows_above = items_start; - if(top_offset < 1) { - rows_above++; - } - for (var i = items_start; i < items_end; i++) { - rows[i] && this_cluster_rows.push(rows[i]); - } - return { - top_offset: top_offset, - bottom_offset: bottom_offset, - rows_above: rows_above, - rows: this_cluster_rows - } - }, - renderExtraTag: function(class_name, height) { - var tag = document.createElement(this.options.tag), - clusterize_prefix = 'clusterize-'; - tag.className = [clusterize_prefix + 'extra-row', clusterize_prefix + class_name].join(' '); - height && (tag.style.height = height + 'px'); - return tag.outerHTML; - }, - // if necessary verify data changed and insert to DOM - insertToDOM: function(rows, cache) { - // explore row's height - if( ! this.options.cluster_height) { - this.exploreEnvironment(rows, cache); - } - var data = this.generate(rows, this.getClusterNum()), - this_cluster_rows = data.rows.join(''), - this_cluster_content_changed = this.checkChanges('data', this_cluster_rows, cache), - top_offset_changed = this.checkChanges('top', data.top_offset, cache), - only_bottom_offset_changed = this.checkChanges('bottom', data.bottom_offset, cache), - callbacks = this.options.callbacks, - layout = []; - - if(this_cluster_content_changed || top_offset_changed) { - if(data.top_offset) { - this.options.keep_parity && layout.push(this.renderExtraTag('keep-parity')); - layout.push(this.renderExtraTag('top-space', data.top_offset)); - } - layout.push(this_cluster_rows); - data.bottom_offset && layout.push(this.renderExtraTag('bottom-space', data.bottom_offset)); - callbacks.clusterWillChange && callbacks.clusterWillChange(); - this.html(layout.join('')); - this.options.content_tag == 'ol' && this.content_elem.setAttribute('start', data.rows_above); - callbacks.clusterChanged && callbacks.clusterChanged(); - } else if(only_bottom_offset_changed) { - this.content_elem.lastChild.style.height = data.bottom_offset + 'px'; - } - }, - // unfortunately ie <= 9 does not allow to use innerHTML for table elements, so make a workaround - html: function(data) { - var content_elem = this.content_elem; - if(ie && ie <= 9 && this.options.tag == 'tr') { - var div = document.createElement('div'), last; - div.innerHTML = '' + data + '
    '; - while((last = content_elem.lastChild)) { - content_elem.removeChild(last); - } - var rows_nodes = this.getChildNodes(div.firstChild.firstChild); - while (rows_nodes.length) { - content_elem.appendChild(rows_nodes.shift()); - } - } else { - content_elem.innerHTML = data; - } - }, - getChildNodes: function(tag) { - var child_nodes = tag.children, nodes = []; - for (var i = 0, ii = child_nodes.length; i < ii; i++) { - nodes.push(child_nodes[i]); - } - return nodes; - }, - checkChanges: function(type, value, cache) { - var changed = value != cache[type]; - cache[type] = value; - return changed; - } - } - - // support functions - function on(evt, element, fnc) { - return element.addEventListener ? element.addEventListener(evt, fnc, false) : element.attachEvent("on" + evt, fnc); - } - function off(evt, element, fnc) { - return element.removeEventListener ? element.removeEventListener(evt, fnc, false) : element.detachEvent("on" + evt, fnc); - } - function isArray(arr) { - return Object.prototype.toString.call(arr) === '[object Array]'; - } - function getStyle(prop, elem) { - return window.getComputedStyle ? window.getComputedStyle(elem)[prop] : elem.currentStyle[prop]; - } - - return Clusterize; -})); \ No newline at end of file diff --git a/erpnext/public/js/pos/customer_toolbar.html b/erpnext/public/js/pos/customer_toolbar.html deleted file mode 100644 index 3ba5ccbc673..00000000000 --- a/erpnext/public/js/pos/customer_toolbar.html +++ /dev/null @@ -1,16 +0,0 @@ -
    -
    - - - - - -
    - - {% if (allow_delete) { %} - {% } %} -
    \ No newline at end of file diff --git a/erpnext/public/js/pos/pos.html b/erpnext/public/js/pos/pos.html deleted file mode 100644 index 89e2940c896..00000000000 --- a/erpnext/public/js/pos/pos.html +++ /dev/null @@ -1,136 +0,0 @@ -
    -
    -
    -
    {{ __("Item Cart") }}
    -
    -
    -
    - - - {{ __("Item Name")}} - - {{ __("Quantity") }} - {{ __("Discount") }} - {{ __("Rate") }} -
    -
    -
    - - -

    {{ __("Tap items to add them here") }}

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    {%= __("Net Total") %}
    -
    -
    -
    -
    -
    {%= __("Taxes") %}
    -
    -
    -
    - {% if(allow_user_to_edit_discount) { %} -
    -
    -
    {%= __("Discount") %}
    -
    -
    - % - -
    -
    - {%= get_currency_symbol(currency) %} - -
    -
    -
    - {% } %} -
    -
    - - - -
    -
    {%= __("Grand Total") %}
    -
    -
    -
    -
    - - - -
    -
    {%= __("Qty Total") %}
    -
    -
    -
    -
    -
    - -
    - -
    -
    -
    -
    {{ __("Customers in Queue") }}
    -
    -
    {{ __("Customer") }}
    -
    {{ __("Status") }}
    -
    {{ __("Amount") }}
    -
    {{ __("Grand Total") }}
    -
    -
    -
    - - -

    {{ __("No Customers yet!") }}

    -
    -
    -
    -
    -
    -
    {{ __("Stock Items") }}
    -
    - -
    - -
    -
    - -
    -
    -
    - -
    -
    -
    -
    diff --git a/erpnext/public/js/pos/pos_bill_item.html b/erpnext/public/js/pos/pos_bill_item.html deleted file mode 100644 index 21868a6caed..00000000000 --- a/erpnext/public/js/pos/pos_bill_item.html +++ /dev/null @@ -1,34 +0,0 @@ -
    -
    {%= item_code || "" %}{%= __(item_name) || "" %}
    -
    -
    -
    -
    -
    - -
    - {% if(actual_qty != null) { %} -
    - {%= __("In Stock: ") %} {%= actual_qty || 0.0 %} -
    - {% } %} -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    - {% if(enabled) { %} - - {% } else { %} -
    {%= format_currency(rate) %}
    - {% } %} -
    -

    {%= amount %}

    -
    -
    diff --git a/erpnext/public/js/pos/pos_bill_item_new.html b/erpnext/public/js/pos/pos_bill_item_new.html deleted file mode 100644 index cb626cefcea..00000000000 --- a/erpnext/public/js/pos/pos_bill_item_new.html +++ /dev/null @@ -1,9 +0,0 @@ -
    - -
    {%= qty %}
    -
    {%= discount_percentage %}
    -
    {%= format_currency(rate) %}
    -
    diff --git a/erpnext/public/js/pos/pos_invoice_list.html b/erpnext/public/js/pos/pos_invoice_list.html deleted file mode 100644 index 13aa52055ac..00000000000 --- a/erpnext/public/js/pos/pos_invoice_list.html +++ /dev/null @@ -1,9 +0,0 @@ -
    - -
    {{ data.status }}
    -
    {%= paid_amount %}
    -
    {%= grand_total %}
    -
    diff --git a/erpnext/public/js/pos/pos_item.html b/erpnext/public/js/pos/pos_item.html deleted file mode 100755 index 52f3cf698ae..00000000000 --- a/erpnext/public/js/pos/pos_item.html +++ /dev/null @@ -1,32 +0,0 @@ - \ No newline at end of file diff --git a/erpnext/public/js/pos/pos_selected_item.html b/erpnext/public/js/pos/pos_selected_item.html deleted file mode 100644 index 03c73411a48..00000000000 --- a/erpnext/public/js/pos/pos_selected_item.html +++ /dev/null @@ -1,22 +0,0 @@ -
    -
    -
    {{ __("Quantity") }}:
    - -
    -
    -
    {{ __("Price List Rate") }}:
    - -
    -
    -
    {{ __("Discount") }}: %
    - -
    -
    -
    {{ __("Price") }}:
    - -
    -
    -
    {{ __("Amount") }}:
    - -
    -
    \ No newline at end of file diff --git a/erpnext/public/js/pos/pos_tax_row.html b/erpnext/public/js/pos/pos_tax_row.html deleted file mode 100644 index 3752a89bbdc..00000000000 --- a/erpnext/public/js/pos/pos_tax_row.html +++ /dev/null @@ -1,4 +0,0 @@ -
    -
    {%= description %}
    -
    {%= tax_amount %}
    -
    diff --git a/erpnext/public/js/queries.js b/erpnext/public/js/queries.js index 560a5617da5..b635adcd443 100644 --- a/erpnext/public/js/queries.js +++ b/erpnext/public/js/queries.js @@ -115,7 +115,26 @@ $.extend(erpnext.queries, { ["Warehouse", "is_group", "=",0] ] - } + }; + }, + + get_filtered_dimensions: function(doc, child_fields, dimension, company) { + let account = ''; + + child_fields.forEach((field) => { + if (!account) { + account = doc[field]; + } + }); + + return { + query: "erpnext.controllers.queries.get_filtered_dimensions", + filters: { + 'dimension': dimension, + 'account': account, + 'company': company + } + }; } }); diff --git a/erpnext/public/js/salary_slip_deductions_report_filters.js b/erpnext/public/js/salary_slip_deductions_report_filters.js index 2b30e650753..1ca36600c3b 100644 --- a/erpnext/public/js/salary_slip_deductions_report_filters.js +++ b/erpnext/public/js/salary_slip_deductions_report_filters.js @@ -45,7 +45,7 @@ erpnext.salary_slip_deductions_report_filters = { }, { fieldname: "branch", - label: __("Barnch"), + label: __("Branch"), fieldtype: "Link", options: "Branch", } @@ -63,4 +63,4 @@ erpnext.salary_slip_deductions_report_filters = { } }); } -} \ No newline at end of file +} diff --git a/erpnext/public/js/setup_wizard.js b/erpnext/public/js/setup_wizard.js index 9beba6adf8d..ef03b01698c 100644 --- a/erpnext/public/js/setup_wizard.js +++ b/erpnext/public/js/setup_wizard.js @@ -127,11 +127,9 @@ erpnext.setup.slides_settings = [ options: "", fieldtype: 'Select' }, { fieldname: 'view_coa', label: __('View Chart of Accounts'), fieldtype: 'Button' }, - - { fieldtype: "Section Break", label: __('Financial Year') }, - { fieldname: 'fy_start_date', label: __('Start Date'), fieldtype: 'Date', reqd: 1 }, - { fieldtype: "Column Break" }, - { fieldname: 'fy_end_date', label: __('End Date'), fieldtype: 'Date', reqd: 1 }, + { fieldname: 'fy_start_date', label: __('Financial Year Begins On'), fieldtype: 'Date', reqd: 1 }, + // end date should be hidden (auto calculated) + { fieldname: 'fy_end_date', label: __('End Date'), fieldtype: 'Date', reqd: 1, hidden: 1 }, ], onload: function (slide) { @@ -161,7 +159,10 @@ erpnext.setup.slides_settings = [ if(r.message){ exist = r.message; me.get_field("bank_account").set_value(""); - frappe.msgprint(__(`Account ${me.values.bank_account} already exists, enter a different name for your bank account`)); + let message = __('Account {0} already exists. Please enter a different name for your bank account.', + [me.values.bank_account] + ); + frappe.msgprint(message); } } }); @@ -309,7 +310,6 @@ erpnext.setup.fiscal_years = { "Hong Kong": ["04-01", "03-31"], "India": ["04-01", "03-31"], "Iran": ["06-23", "06-22"], - "Italy": ["07-01", "06-30"], "Myanmar": ["04-01", "03-31"], "New Zealand": ["04-01", "03-31"], "Pakistan": ["07-01", "06-30"], diff --git a/erpnext/public/js/telephony.js b/erpnext/public/js/telephony.js new file mode 100644 index 00000000000..9548d6c5f36 --- /dev/null +++ b/erpnext/public/js/telephony.js @@ -0,0 +1,33 @@ +frappe.ui.form.ControlData = frappe.ui.form.ControlData.extend( { + make_input() { + this._super(); + if (this.df.options == 'Phone') { + this.setup_phone(); + } + if (this.frm && this.frm.fields_dict) { + Object.values(this.frm.fields_dict).forEach(function(field) { + if (field.df.read_only === 1 && field.df.options === 'Phone' + && field.disp_area.style[0] != 'display' && !field.has_icon) { + field.setup_phone(); + field.has_icon = true; + } + }); + } + }, + setup_phone() { + if (frappe.phone_call.handler) { + let control = this.df.read_only ? '.control-value' : '.control-input'; + this.$wrapper.find(control) + .append(` + + + ${frappe.utils.icon('call')} + + `) + .find('.phone-btn') + .click(() => { + frappe.phone_call.handler(this.get_value(), this.frm); + }); + } + } +}); diff --git a/erpnext/public/js/templates/call_link.html b/erpnext/public/js/templates/call_link.html new file mode 100644 index 00000000000..071078c776e --- /dev/null +++ b/erpnext/public/js/templates/call_link.html @@ -0,0 +1,42 @@ +
    + + + +
    + {% if (type === "Incoming") { %} + Incoming call from {{ from }}, received by {{ to }} + {% } else { %} + Outgoing Call made by {{ from }} to {{ to }} + {% } %} +
    + {% if (summary) { %} + {{ summary }} + {% } else { %} + {{ __("No Summary") }} + {% } %} +
    + {% if (recording_url) { %} +
    + +
    + {% } %} +
    +
    diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 9ed500932f3..e5b50d86eda 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -194,15 +194,21 @@ $.extend(erpnext.utils, { add_dimensions: function(report_name, index) { let filters = frappe.query_reports[report_name].filters; - erpnext.dimension_filters.forEach((dimension) => { - let found = filters.some(el => el.fieldname === dimension['fieldname']); + frappe.call({ + method: "erpnext.accounts.doctype.accounting_dimension.accounting_dimension.get_dimensions", + callback: function(r) { + let accounting_dimensions = r.message[0]; + accounting_dimensions.forEach((dimension) => { + let found = filters.some(el => el.fieldname === dimension['fieldname']); - if (!found) { - filters.splice(index, 0 ,{ - "fieldname": dimension["fieldname"], - "label": __(dimension["label"]), - "fieldtype": "Link", - "options": dimension["document_type"] + if (!found) { + filters.splice(index, 0, { + "fieldname": dimension["fieldname"], + "label": __(dimension["label"]), + "fieldtype": "Link", + "options": dimension["document_type"] + }); + } }); } }); @@ -452,6 +458,9 @@ erpnext.utils.update_child_items = function(opts) { const frm = opts.frm; const cannot_add_row = (typeof opts.cannot_add_row === 'undefined') ? true : opts.cannot_add_row; const child_docname = (typeof opts.cannot_add_row === 'undefined') ? "items" : opts.child_docname; + 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 = []; const fields = [{ fieldtype:'Data', @@ -499,14 +508,17 @@ erpnext.utils.update_child_items = function(opts) { default: 0, read_only: 0, in_list_view: 1, - label: __('Qty') + label: __('Qty'), + precision: get_precision("qty") }, { fieldtype:'Currency', fieldname:"rate", + options: "currency", default: 0, read_only: 0, in_list_view: 1, - label: __('Rate') + label: __('Rate'), + precision: get_precision("rate") }]; if (frm.doc.doctype == 'Sales Order' || frm.doc.doctype == 'Purchase Order' ) { @@ -521,7 +533,8 @@ erpnext.utils.update_child_items = function(opts) { fieldtype: 'Float', fieldname: "conversion_factor", in_list_view: 1, - label: __("Conversion Factor") + label: __("Conversion Factor"), + precision: get_precision('conversion_factor') }) } @@ -533,7 +546,7 @@ erpnext.utils.update_child_items = function(opts) { fieldtype: "Table", label: "Items", cannot_add_rows: cannot_add_row, - in_place_edit: true, + in_place_edit: false, reqd: 1, data: this.data, get_data: () => { @@ -582,21 +595,7 @@ erpnext.utils.update_child_items = function(opts) { } erpnext.utils.map_current_doc = function(opts) { - let query_args = {}; - if (opts.get_query_filters) { - query_args.filters = opts.get_query_filters; - } - - if (opts.get_query_method) { - query_args.query = opts.get_query_method; - } - - if (query_args.filters || query_args.query) { - opts.get_query = () => { - return query_args; - } - } - var _map = function() { + function _map() { if($.isArray(cur_frm.doc.items) && cur_frm.doc.items.length > 0) { // remove first item row if empty if(!cur_frm.doc.items[0].item_code) { @@ -670,8 +669,22 @@ erpnext.utils.map_current_doc = function(opts) { } }); } - if(opts.source_doctype) { - var d = new frappe.ui.form.MultiSelectDialog({ + + let query_args = {}; + if (opts.get_query_filters) { + query_args.filters = opts.get_query_filters; + } + + if (opts.get_query_method) { + query_args.query = opts.get_query_method; + } + + if (query_args.filters || query_args.query) { + opts.get_query = () => query_args; + } + + if (opts.source_doctype) { + const d = new frappe.ui.form.MultiSelectDialog({ doctype: opts.source_doctype, target: opts.target, date_field: opts.date_field || undefined, @@ -690,16 +703,24 @@ erpnext.utils.map_current_doc = function(opts) { _map(); }, }); - } else if(opts.source_name) { + + return d; + } + + if (opts.source_name) { opts.source_name = [opts.source_name]; _map(); } } frappe.form.link_formatters['Item'] = function(value, doc) { - if(doc && doc.item_name && doc.item_name !== value) { - return value? value + ': ' + doc.item_name: doc.item_name; + if (doc && value && doc.item_name && doc.item_name !== value) { + return value + ': ' + doc.item_name; + } else if (!value && doc.doctype && doc.item_name) { + // format blank value in child table + return doc.item_name; } else { + // if value is blank in report view or item code and name are the same, return as is return value; } } diff --git a/erpnext/public/js/utils/dimension_tree_filter.js b/erpnext/public/js/utils/dimension_tree_filter.js index b6720c05cb2..96e181788e3 100644 --- a/erpnext/public/js/utils/dimension_tree_filter.js +++ b/erpnext/public/js/utils/dimension_tree_filter.js @@ -1,54 +1,83 @@ -frappe.provide('frappe.ui.form'); +frappe.provide('erpnext.accounts'); -let default_dimensions = {}; +erpnext.accounts.dimensions = { + setup_dimension_filters(frm, doctype) { + this.accounting_dimensions = []; + this.default_dimensions = {}; + this.fetch_custom_dimensions(frm, doctype); + }, -let doctypes_with_dimensions = ["GL Entry", "Sales Invoice", "Purchase Invoice", "Payment Entry", "Asset", - "Expense Claim", "Stock Entry", "Budget", "Payroll Entry", "Delivery Note", "Shipping Rule", "Loyalty Program", - "Fee Schedule", "Fee Structure", "Stock Reconciliation", "Travel Request", "Fees", "POS Profile", "Opening Invoice Creation Tool", - "Subscription", "Purchase Order", "Journal Entry", "Material Request", "Purchase Receipt", "Landed Cost Item", "Asset"]; + fetch_custom_dimensions(frm, doctype) { + let me = this; + frappe.call({ + method: "erpnext.accounts.doctype.accounting_dimension.accounting_dimension.get_dimensions", + args: { + 'with_cost_center_and_project': true + }, + callback: function(r) { + me.accounting_dimensions = r.message[0]; + me.default_dimensions = r.message[1]; + me.setup_filters(frm, doctype); + } + }); + }, -let child_docs = ["Sales Invoice Item", "Purchase Invoice Item", "Purchase Order Item", "Journal Entry Account", - "Material Request Item", "Delivery Note Item", "Purchase Receipt Item", "Stock Entry Detail", "Payment Entry Deduction", - "Landed Cost Item", "Asset Value Adjustment", "Opening Invoice Creation Tool Item", "Subscription Plan"]; - -frappe.call({ - method: "erpnext.accounts.doctype.accounting_dimension.accounting_dimension.get_dimension_filters", - callback: function(r) { - erpnext.dimension_filters = r.message[0]; - default_dimensions = r.message[1]; - } -}); - -doctypes_with_dimensions.forEach((doctype) => { - frappe.ui.form.on(doctype, { - onload: function(frm) { - erpnext.dimension_filters.forEach((dimension) => { + setup_filters(frm, doctype) { + if (this.accounting_dimensions) { + this.accounting_dimensions.forEach((dimension) => { frappe.model.with_doctype(dimension['document_type'], () => { - if(frappe.meta.has_field(dimension['document_type'], 'is_group')) { - frm.set_query(dimension['fieldname'], { - "is_group": 0 - }); - } + let parent_fields = []; + frappe.meta.get_docfields(doctype).forEach((df) => { + if (df.fieldtype === 'Link' && df.options === 'Account') { + parent_fields.push(df.fieldname); + } else if (df.fieldtype === 'Table') { + this.setup_child_filters(frm, df.options, df.fieldname, dimension['fieldname']); + } + + if (frappe.meta.has_field(doctype, dimension['fieldname'])) { + this.setup_account_filters(frm, dimension['fieldname'], parent_fields); + } + }); }); }); - }, + } + }, - company: function(frm) { - if(frm.doc.company && (Object.keys(default_dimensions || {}).length > 0) - && default_dimensions[frm.doc.company]) { - frm.trigger('update_dimension'); - } - }, + setup_child_filters(frm, doctype, parentfield, dimension) { + let fields = []; - update_dimension: function(frm) { - erpnext.dimension_filters.forEach((dimension) => { - if(frm.is_new()) { - if(frm.doc.company && Object.keys(default_dimensions || {}).length > 0 - && default_dimensions[frm.doc.company]) { + if (frappe.meta.has_field(doctype, dimension)) { + frappe.model.with_doctype(doctype, () => { + frappe.meta.get_docfields(doctype).forEach((df) => { + if (df.fieldtype === 'Link' && df.options === 'Account') { + fields.push(df.fieldname); + } + }); - let default_dimension = default_dimensions[frm.doc.company][dimension['fieldname']]; + frm.set_query(dimension, parentfield, function(doc, cdt, cdn) { + let row = locals[cdt][cdn]; + return erpnext.queries.get_filtered_dimensions(row, fields, dimension, doc.company); + }); + }); + } + }, - if(default_dimension) { + setup_account_filters(frm, dimension, fields) { + frm.set_query(dimension, function(doc) { + return erpnext.queries.get_filtered_dimensions(doc, fields, dimension, doc.company); + }); + }, + + update_dimension(frm, doctype) { + if (this.accounting_dimensions) { + this.accounting_dimensions.forEach((dimension) => { + if (frm.is_new()) { + if (frm.doc.company && Object.keys(this.default_dimensions || {}).length > 0 + && this.default_dimensions[frm.doc.company]) { + + let default_dimension = this.default_dimensions[frm.doc.company][dimension['fieldname']]; + + if (default_dimension) { if (frappe.meta.has_field(doctype, dimension['fieldname'])) { frm.set_value(dimension['fieldname'], default_dimension); } @@ -61,23 +90,14 @@ doctypes_with_dimensions.forEach((doctype) => { } }); } - }); -}); + }, -child_docs.forEach((doctype) => { - frappe.ui.form.on(doctype, { - items_add: function(frm, cdt, cdn) { - erpnext.dimension_filters.forEach((dimension) => { - var row = frappe.get_doc(cdt, cdn); - frm.script_manager.copy_from_first_row("items", row, [dimension['fieldname']]); - }); - }, - - accounts_add: function(frm, cdt, cdn) { - erpnext.dimension_filters.forEach((dimension) => { - var row = frappe.get_doc(cdt, cdn); - frm.script_manager.copy_from_first_row("accounts", row, [dimension['fieldname']]); + copy_dimension_from_first_row(frm, cdt, cdn, fieldname) { + if (frappe.meta.has_field(frm.doctype, fieldname) && this.accounting_dimensions) { + this.accounting_dimensions.forEach((dimension) => { + let row = frappe.get_doc(cdt, cdn); + frm.script_manager.copy_from_first_row(fieldname, row, [dimension['fieldname']]); }); } - }); -}); \ No newline at end of file + } +}; \ No newline at end of file diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 44e75aee36a..808dd5add05 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -276,8 +276,14 @@ erpnext.utils.validate_mandatory = function(frm, label, value, trigger_on) { erpnext.utils.get_shipping_address = function(frm, callback){ if (frm.doc.company) { + if (!(frm.doc.inter_com_order_reference || frm.doc.internal_invoice_reference || + frm.doc.internal_order_reference)) { + if (callback) { + return callback(); + } + } frappe.call({ - method: "frappe.contacts.doctype.address.address.get_shipping_address", + method: "erpnext.accounts.custom.address.get_shipping_address", args: { company: frm.doc.company, address: frm.doc.shipping_address diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index d9f6e1d4336..d49a8138fb5 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -75,7 +75,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ fieldtype:'Float', read_only: me.has_batch && !me.has_serial_no, label: __(me.has_batch && !me.has_serial_no ? 'Total Qty' : 'Qty'), - default: 0 + default: flt(me.item.stock_qty), }, { fieldname: 'auto_fetch_button', @@ -91,7 +91,8 @@ erpnext.SerialNoBatchSelector = Class.extend({ qty: qty, item_code: me.item_code, warehouse: typeof me.warehouse_details.name == "string" ? me.warehouse_details.name : '', - batch_no: me.item.batch_no || null + batch_no: me.item.batch_no || null, + posting_date: me.frm.doc.posting_date || me.frm.doc.transaction_date } }); @@ -100,11 +101,12 @@ erpnext.SerialNoBatchSelector = Class.extend({ let records_length = auto_fetched_serial_numbers.length; if (!records_length) { const warehouse = me.dialog.fields_dict.warehouse.get_value().bold(); - frappe.msgprint(__(`Serial numbers unavailable for Item ${me.item.item_code.bold()} - under warehouse ${warehouse}. Please try changing warehouse.`)); + frappe.msgprint( + __('Serial numbers unavailable for Item {0} under warehouse {1}. Please try changing warehouse.', [me.item.item_code.bold(), warehouse]) + ); } if (records_length < qty) { - frappe.msgprint(__(`Fetched only ${records_length} available serial numbers.`)); + frappe.msgprint(__('Fetched only {0} available serial numbers.', [records_length])); } let serial_no_list_field = this.dialog.fields_dict.serial_no; numbers = auto_fetched_serial_numbers.join('\n'); @@ -138,6 +140,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ () => me.update_batch_serial_no_items(), () => { refresh_field("items"); + refresh_field("packed_items"); if (me.callback) { return me.callback(me.item); } @@ -152,7 +155,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ if (this.item.serial_no) { this.dialog.fields_dict.serial_no.set_value(this.item.serial_no); } - + if (this.has_batch && !this.has_serial_no && d.batch_no) { this.frm.doc.items.forEach(data => { if(data.item_code == d.item_code) { @@ -189,15 +192,12 @@ erpnext.SerialNoBatchSelector = Class.extend({ } if(this.has_batch && !this.has_serial_no) { if(values.batches.length === 0 || !values.batches) { - frappe.throw(__("Please select batches for batched item " - + values.item_code)); - return false; + frappe.throw(__("Please select batches for batched item {0}", [values.item_code])); } values.batches.map((batch, i) => { if(!batch.selected_qty || batch.selected_qty === 0 ) { if (!this.show_dialog) { - frappe.throw(__("Please select quantity on row " + (i+1))); - return false; + frappe.throw(__("Please select quantity on row {0}", [i+1])); } } }); @@ -206,9 +206,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ } else { let serial_nos = values.serial_no || ''; if (!serial_nos || !serial_nos.replace(/\s/g, '').length) { - frappe.throw(__("Please enter serial numbers for serialized item " - + values.item_code)); - return false; + frappe.throw(__("Please enter serial numbers for serialized item {0}", [values.item_code])); } return true; } @@ -234,7 +232,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ this.map_row_values(row, batch, 'batch_no', 'selected_qty', this.values.warehouse); }); - } + } }, update_serial_no_item() { @@ -253,7 +251,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ filters: { 'name': ["in", selected_serial_nos]}, fields: ["batch_no", "name"] }).then((data) => { - // data = [{batch_no: 'batch-1', name: "SR-001"}, + // data = [{batch_no: 'batch-1', name: "SR-001"}, // {batch_no: 'batch-2', name: "SR-003"}, {batch_no: 'batch-2', name: "SR-004"}] const batch_serial_map = data.reduce((acc, d) => { if (!acc[d['batch_no']]) acc[d['batch_no']] = []; @@ -301,6 +299,8 @@ erpnext.SerialNoBatchSelector = Class.extend({ } else { row.warehouse = values.warehouse || warehouse; } + + this.frm.dirty(); }, update_total_qty: function() { @@ -355,8 +355,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ }); if (selected_batches.includes(val)) { this.set_value(""); - frappe.throw(__(`Batch ${val} already selected.`)); - return; + frappe.throw(__('Batch {0} already selected.', [val])); } if (me.warehouse_details.name) { @@ -375,8 +374,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ } else { this.set_value(""); - frappe.throw(__(`Please select a warehouse to get available - quantities`)); + frappe.throw(__('Please select a warehouse to get available quantities')); } // e.stopImmediatePropagation(); } @@ -411,8 +409,7 @@ erpnext.SerialNoBatchSelector = Class.extend({ parseFloat(available_qty) < parseFloat(selected_qty)) { this.set_value('0'); - frappe.throw(__(`For transfer from source, selected quantity cannot be - greater than available quantity`)); + frappe.throw(__('For transfer from source, selected quantity cannot be greater than available quantity')); } else { this.grid.refresh(); } @@ -451,20 +448,12 @@ erpnext.SerialNoBatchSelector = Class.extend({ frappe.call({ method: "erpnext.stock.doctype.serial_no.serial_no.get_pos_reserved_serial_nos", args: { - item_code: me.item_code, - warehouse: typeof me.warehouse_details.name == "string" ? me.warehouse_details.name : '' + filters: { + item_code: me.item_code, + warehouse: typeof me.warehouse_details.name == "string" ? me.warehouse_details.name : '', + } } }).then((data) => { - if (!data.message[1].length) { - this.showing_reserved_serial_nos_error = true; - const warehouse = me.dialog.fields_dict.warehouse.get_value().bold(); - const d = frappe.msgprint(__(`Serial numbers unavailable for Item ${me.item.item_code.bold()} - under warehouse ${warehouse}. Please try changing warehouse.`)); - d.get_close_btn().on('click', () => { - this.showing_reserved_serial_nos_error = false; - d.hide(); - }); - } serial_no_filters['name'] = ["not in", data.message[0]] }) } diff --git a/erpnext/public/less/call_popup.less b/erpnext/public/less/call_popup.less deleted file mode 100644 index 32e85ce16d9..00000000000 --- a/erpnext/public/less/call_popup.less +++ /dev/null @@ -1,9 +0,0 @@ -.call-popup { - a:hover { - text-decoration: underline; - } - .for-description { - max-height: 250px; - overflow: scroll; - } -} \ No newline at end of file diff --git a/erpnext/public/less/erpnext.less b/erpnext/public/less/erpnext.less index 8685837d33d..4076ebec1fd 100644 --- a/erpnext/public/less/erpnext.less +++ b/erpnext/public/less/erpnext.less @@ -39,8 +39,9 @@ .dashboard-list-item { background-color: inherit; - padding: 5px 0px; - border-bottom: 1px solid @border-color; + border-bottom: 1px solid var(--border-color); + font-size: var(--text-md); + color: var(--text-color); } #page-stock-balance .dashboard-list-item { @@ -446,20 +447,6 @@ body[data-route="pos"] { } -// Leaderboard - -.leaderboard { - .result { - border-top: 1px solid #d1d8dd; - } - .list-item { - padding-left: 45px; - } - .list-item_content { - padding-right: 45px; - } -} - // Healthcare .exercise-card { diff --git a/erpnext/public/less/hub.less b/erpnext/public/less/hub.less index 8cb7a9c1ce5..29deada8a41 100644 --- a/erpnext/public/less/hub.less +++ b/erpnext/public/less/hub.less @@ -32,7 +32,12 @@ body[data-route*="marketplace"] { } .hub-image-loading, .hub-image-broken { - .img-background(); + content: " "; + position: absolute; + left: 0; + height: 100%; + width: 100%; + background-color: var(--bg-light-gray); display: flex; align-items: center; justify-content: center; diff --git a/erpnext/public/scss/call_popup.scss b/erpnext/public/scss/call_popup.scss new file mode 100644 index 00000000000..95e31828c18 --- /dev/null +++ b/erpnext/public/scss/call_popup.scss @@ -0,0 +1,21 @@ +.call-popup { + a:hover { + text-decoration: underline; + } + .for-description { + max-height: 250px; + overflow: scroll; + } +} + +audio { + height: 40px; + width: 100%; + max-width: 500px; + background-color: var(--control-bg); + border-radius: var(--border-radius-sm); + &-webkit-media-controls-panel { + background: var(--control-bg); + } + outline: none; +} diff --git a/erpnext/public/scss/point-of-sale.scss b/erpnext/public/scss/point-of-sale.scss new file mode 100644 index 00000000000..0bb8e68b698 --- /dev/null +++ b/erpnext/public/scss/point-of-sale.scss @@ -0,0 +1,1110 @@ +.point-of-sale-app { + display: grid; + grid-template-columns: repeat(10, minmax(0, 1fr)); + gap: var(--margin-md); + + section { + min-height: 45rem; + height: calc(100vh - 200px); + max-height: calc(100vh - 200px); + } + + .frappe-control { + margin: 0 !important; + width: 100%; + } + + .form-group { + margin-bottom: 0px !important; + } + + .pointer-no-select { + cursor: pointer; + user-select: none; + } + + .nowrap { + overflow: hidden; + white-space: nowrap; + } + + .image { + height: 100% !important; + object-fit: cover; + } + + .abbr { + background-color: var(--gray-50); + font-size: var(--text-3xl); + } + + .label { + display: flex; + align-items: center; + font-weight: 700; + font-size: var(--text-lg); + } + + .pos-card { + background-color: var(--fg-color); + box-shadow: var(--shadow-base); + border-radius: var(--border-radius-md); + } + + .seperator { + margin-left: var(--margin-sm); + margin-right: var(--margin-sm); + border-bottom: 1px solid var(--gray-300); + } + + .primary-action { + @extend .pointer-no-select; + display: flex; + align-items: center; + justify-content: center; + padding: var(--padding-sm); + margin-top: var(--margin-sm); + border-radius: var(--border-radius-md); + font-size: var(--text-lg); + font-weight: 700; + } + + .highlighted-numpad-btn { + box-shadow: inset 0 0px 4px 0px rgba(0, 0, 0, 0.15) !important; + font-weight: 700; + background-color: var(--gray-50); + } + + > .items-selector { + @extend .pos-card; + grid-column: span 6 / span 6; + display: flex; + flex-direction: column; + overflow: hidden; + + > .filter-section { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + background-color: var(--fg-color); + padding: var(--padding-lg); + padding-bottom: var(--padding-sm); + align-items: center; + + > .label { + @extend .label; + grid-column: span 4 / span 4; + padding-bottom: var(--padding-xs); + } + + > .search-field { + grid-column: span 5 / span 5; + display: flex; + align-items: center; + margin: 0px var(--margin-sm); + } + + > .item-group-field { + grid-column: span 3 / span 3; + display: flex; + align-items: center; + } + } + + > .items-container { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--margin-lg); + padding: var(--padding-lg); + padding-top: var(--padding-xs); + overflow-y: scroll; + overflow-x: hidden; + + &:after { + content: ""; + display: block; + height: 1px; + } + + > .item-wrapper { + @extend .pointer-no-select; + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-base); + + &:hover { + transform: scale(1.02, 1.02); + } + + .item-display { + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-md); + margin: var(--margin-sm); + margin-bottom: 0px; + min-height: 8rem; + height: 8rem; + color: var(--gray-500); + + > img { + @extend .image; + } + } + + > .item-detail { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 3.5rem; + height: 3.5rem; + padding-left: var(--padding-sm); + padding-right: var(--padding-sm); + + > .item-name { + @extend .nowrap; + display: flex; + align-items: center; + font-size: var(--text-md); + } + + > .item-rate { + font-weight: 700; + } + } + + } + } + } + + > .customer-cart-container { + grid-column: span 4 / span 4; + display: flex; + flex-direction: column; + + > .customer-section { + @extend .pos-card; + display: flex; + flex-direction: column; + padding: var(--padding-md) var(--padding-lg); + overflow: visible; + + > .customer-field { + display: flex; + align-items: center; + padding-top: var(--padding-xs); + } + + > .customer-details { + display: flex; + flex-direction: column; + background-color: var(--fg-color); + + > .header { + display: flex; + margin-bottom: var(--margin-md); + justify-content: space-between; + padding-top: var(--padding-md); + + > .label { + @extend .label; + } + + > .close-details-btn { + display: flex; + align-items: center; + cursor: pointer; + } + } + + > .customer-display { + display: flex; + align-items: center; + cursor: pointer; + + > .customer-image { + display: flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + color: var(--gray-500); + margin-right: var(--margin-md); + + > img { + @extend .image; + border-radius: 50%; + } + } + + > .customer-abbr { + @extend .abbr; + font-size: var(--text-2xl); + } + + > .customer-name-desc { + @extend .nowrap; + display: flex; + flex-direction: column; + margin-right: auto; + + >.customer-name { + font-weight: 700; + font-size: var(--text-lg); + } + + >.customer-desc { + color: var(--gray-600); + font-weight: 500; + font-size: var(--text-sm); + } + } + + > .reset-customer-btn { + display: flex; + align-items: center; + cursor: pointer; + } + + } + + > .customer-fields-container { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: var(--margin-md); + column-gap: var(--padding-sm); + row-gap: var(--padding-xs); + } + + > .transactions-label { + @extend .label; + margin-top: var(--margin-md); + margin-bottom: var(--margin-sm); + } + } + + > .customer-transactions { + height: 100%; + overflow-x: hidden; + overflow-y: scroll; + margin-right: -12px; + padding-right: 12px; + margin-left: -10px; + + > .no-transactions-placeholder { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--gray-50); + border-radius: var(--border-radius-md); + } + } + } + + > .cart-container { + @extend .pos-card; + display: flex; + flex-direction: column; + align-items: center; + margin-top: var(--margin-md); + position: relative; + height: 100%; + + > .abs-cart-container { + position: absolute; + display: flex; + flex-direction: column; + padding: var(--padding-lg); + width: 100%; + height: 100%; + + > .cart-label { + @extend .label; + padding-bottom: var(--padding-md); + } + + > .cart-header { + display: flex; + width: 100%; + font-size: var(--text-md); + padding-bottom: var(--padding-md); + + > .name-header { + flex: 1 1 0%; + } + + > .qty-header { + margin-right: var(--margin-lg); + text-align: center; + } + + > .rate-amount-header { + text-align: right; + margin-right: var(--margin-sm); + } + } + + .no-item-wrapper { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--gray-50); + border-radius: var(--border-radius-md); + font-size: var(--text-md); + font-weight: 500; + width: 100%; + height: 100%; + } + + > .cart-items-section { + display: flex; + flex-direction: column; + flex: 1 1 0%; + overflow-y: scroll; + + > .cart-item-wrapper { + @extend .pointer-no-select; + display: flex; + align-items: center; + padding: var(--padding-sm); + border-radius: var(--border-radius-md); + + &:hover { + background-color: var(--gray-50); + } + + > .item-image { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--border-radius-md); + color: var(--gray-500); + margin-right: var(--margin-md); + + > img { + @extend .image; + } + } + + > .item-abbr { + @extend .abbr; + font-size: var(--text-lg); + } + + + > .item-name-desc { + @extend .nowrap; + display: flex; + flex-direction: column; + flex: 1 1 0%; + flex-shrink: 1; + + > .item-name { + font-weight: 700; + } + + > .item-desc { + font-size: var(--text-sm); + color: var(--gray-600); + font-weight: 500; + } + } + + > .item-qty-rate { + display: flex; + flex-shrink: 0; + text-align: right; + margin-left: var(--margin-md); + + > .item-qty { + display: flex; + align-items: center; + margin-right: var(--margin-lg); + font-weight: 700; + } + + > .item-rate-amount { + display: flex; + flex-direction: column; + flex-shrink: 0; + text-align: right; + + > .item-rate { + font-weight: 700; + } + + > .item-amount { + font-size: var(--text-md); + font-weight: 600; + } + } + } + + } + } + + > .cart-totals-section { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 100%; + margin-top: var(--margin-md); + + > .add-discount-wrapper { + @extend .pointer-no-select; + display: none; + align-items: center; + border-radius: var(--border-radius-md); + border: 1px dashed var(--gray-500); + padding: var(--padding-sm) var(--padding-md); + margin-bottom: var(--margin-sm); + + > .add-discount-field { + width: 100%; + } + + .discount-icon { + margin-right: var(--margin-sm); + } + + .edit-discount-btn { + display: flex; + align-items: center; + font-weight: 500; + color: var(--dark-green-500); + } + } + + > .net-total-container { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--padding-sm) 0px; + font-weight: 500; + font-size: var(--text-md); + } + + > .taxes-container { + display: none; + flex-direction: column; + font-weight: 500; + font-size: var(--text-md); + + > .tax-row { + display: flex; + justify-content: space-between; + line-height: var(--text-3xl); + } + } + + > .grand-total-container { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--padding-sm) 0px; + font-weight: 700; + font-size: var(--text-lg); + } + + > .checkout-btn { + @extend .primary-action; + background-color: var(--blue-200); + color: white; + } + + > .edit-cart-btn { + @extend .primary-action; + display: none; + background-color: var(--gray-300); + font-weight: 500; + transition: all 0.15s ease-in-out; + + &:hover { + background-color: var(--gray-600); + color: white; + font-weight: 700; + } + } + } + + > .numpad-section { + display: none; + flex-direction: column; + flex-shrink: 0; + margin-top: var(--margin-sm); + padding: var(--padding-sm); + padding-bottom: 0px; + width: 100%; + + > .numpad-totals { + display: flex; + justify-content: space-between; + margin-bottom: var(--margin-md); + font-size: var(--text-md); + font-weight: 700; + } + + > .numpad-container { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--margin-md); + margin-bottom: var(--margin-md); + + > .numpad-btn { + @extend .pointer-no-select; + border-radius: var(--border-radius-md); + display: flex; + align-items: center; + justify-content: center; + padding: var(--padding-md); + box-shadow: var(--shadow-sm); + } + + > .col-span-2 { + grid-column: span 2 / span 2; + } + + > .remove-btn { + font-weight: 700; + color: var(--red-500); + } + } + + > .checkout-btn { + @extend .primary-action; + margin: 0px; + margin-bottom: var(--margin-sm); + background-color: var(--blue-200); + color: white; + } + } + } + } + } + + .invoice-wrapper { + @extend .pointer-no-select; + display: flex; + justify-content: space-between; + border-radius: var(--border-radius-md); + padding: var(--padding-sm); + + &:hover { + background-color: var(--gray-50); + } + + > .invoice-name-date { + display: flex; + flex-direction: column; + justify-content: space-around; + + > .invoice-name { + @extend .nowrap; + font-size: var(--text-md); + font-weight: 700; + } + + > .invoice-date { + @extend .nowrap; + font-size: var(--text-sm); + display: flex; + align-items: center; + } + } + + > .invoice-total-status { + display: flex; + flex-direction: column; + font-weight: 500; + font-size: var(--text-sm); + margin-left: var(--margin-md); + + > .invoice-total { + margin-bottom: var(--margin-xs); + font-size: var(--text-base); + font-weight: 700; + text-align: right; + } + + > .invoice-status { + display: flex; + align-items: center; + justify-content: right; + } + } + } + + > .item-details-container { + @extend .pos-card; + grid-column: span 4 / span 4; + display: none; + flex-direction: column; + padding: var(--padding-lg); + padding-top: var(--padding-md); + + > .item-details-header { + display: flex; + justify-content: space-between; + margin-bottom: var(--margin-md); + + > .close-btn { + @extend .pointer-no-select; + } + } + + > .item-display { + display: flex; + + > .item-name-desc-price { + flex: 1 1 0%; + display: flex; + flex-direction: column; + justify-content: flex-end; + margin-right: var(--margin-md); + + > .item-name { + font-size: var(--text-3xl); + font-weight: 600; + } + + > .item-desc { + font-size: var(--text-md); + font-weight: 500; + } + + > .item-price { + font-size: var(--text-3xl); + font-weight: 700; + } + } + + > .item-image { + display: flex; + align-items: center; + justify-content: center; + width: 11rem; + height: 11rem; + border-radius: var(--border-radius-md); + margin-left: var(--margin-md); + color: var(--gray-500); + + > img { + @extend .image; + } + + > .item-abbr { + @extend .abbr; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-md); + font-size: var(--text-3xl); + width: 100%; + height: 100%; + } + } + } + + > .discount-section { + display: flex; + align-items: center; + margin-bottom: var(--margin-sm); + + > .item-rate { + font-weight: 500; + margin-right: var(--margin-sm); + text-decoration: line-through; + } + + > .item-discount { + padding: 3px var(--padding-sm); + border-radius: var(--border-radius-sm); + background-color: var(--green-100); + color: var(--dark-green-500); + font-size: var(--text-sm); + font-weight: 700; + } + } + + > .form-container { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: var(--padding-md); + + > .auto-fetch-btn { + @extend .pointer-no-select; + margin: var(--margin-xs); + } + } + } + + > .payment-container { + @extend .pos-card; + grid-column: span 6 / span 6; + display: none; + flex-direction: column; + padding: var(--padding-lg); + + .border-primary { + border: 1px solid var(--blue-500); + } + + .submit-order-btn { + @extend .primary-action; + background-color: var(--blue-500); + color: white; + } + + .section-label { + @extend .label; + @extend .pointer-no-select; + margin-bottom: var(--margin-md); + } + + > .payment-modes { + display: flex; + padding-bottom: var(--padding-sm); + margin-bottom: var(--margin-xs); + overflow-x: scroll; + overflow-y: hidden; + + > .payment-mode-wrapper { + min-width: 40%; + padding: var(--padding-xs); + + > .mode-of-payment { + @extend .pos-card; + @extend .pointer-no-select; + padding: var(--padding-md) var(--padding-lg); + + > .pay-amount { + display: inline; + float: right; + font-weight: 700; + } + + > .mode-of-payment-control { + display: none; + align-items: center; + margin-top: var(--margin-sm); + margin-bottom: var(--margin-xs); + } + + > .loyalty-amount-name { + display: none; + float: right; + font-weight: 700; + } + + > .cash-shortcuts { + display: none; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--margin-sm); + font-size: var(--text-sm); + text-align: center; + + > .shortcut { + @extend .pointer-no-select; + border-radius: var(--border-radius-sm); + background-color: var(--gray-100); + font-weight: 500; + padding: var(--padding-xs) var(--padding-sm); + transition: all 0.15s ease-in-out; + + &:hover { + background-color: var(--gray-300); + } + } + } + } + } + } + + > .fields-numpad-container { + display: flex; + flex: 1; + + > .fields-section { + flex: 1; + } + + > .number-pad { + flex: 1; + display: flex; + justify-content: flex-end; + align-items: flex-end; + + .numpad-container { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--margin-md); + margin-bottom: var(--margin-md); + + > .numpad-btn { + @extend .pointer-no-select; + border-radius: var(--border-radius-md); + display: flex; + align-items: center; + justify-content: center; + padding: var(--padding-md); + box-shadow: var(--shadow-sm); + } + } + } + } + + > .totals-section { + display: flex; + margin-top: auto; + margin-bottom: var(--margin-sm); + justify-content: center; + flex-direction: column; + + > .totals { + display: flex; + background-color: var(--gray-100); + justify-content: center; + padding: var(--padding-md); + border-radius: var(--border-radius-md); + + > .col { + flex-grow: 1; + text-align: center; + + > .total-label { + font-size: var(--text-md); + font-weight: 500; + color: var(--gray-600); + } + + > .value { + font-size: var(--text-2xl); + font-weight: 700; + } + } + + > .seperator-y { + margin-left: var(--margin-sm); + margin-right: var(--margin-sm); + border-right: 1px solid var(--gray-300); + } + } + + > .number-pad { + display: none; + } + } + } + + > .past-order-list { + @extend .pos-card; + grid-column: span 4 / span 4; + display: none; + flex-direction: column; + overflow: hidden; + + > .filter-section { + display: flex; + flex-direction: column; + background-color: var(--fg-color); + padding: var(--padding-lg); + + > .search-field { + width: 100%; + display: flex; + align-items: center; + margin-top: var(--margin-md); + margin-bottom: var(--margin-xs); + } + + > .status-field { + width: 100%; + display: flex; + align-items: center; + } + } + + > .invoices-container { + padding: var(--padding-lg); + padding-top: 0px; + overflow-x: hidden; + overflow-y: scroll; + } + } + + > .past-order-summary { + display: none; + grid-column: span 6 / span 6; + flex-direction: column; + align-items: center; + justify-content: center; + + > .no-summary-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: var(--gray-50); + font-weight: 500; + border-radius: var(--border-radius-md); + } + + > .invoice-summary-wrapper { + @extend .pos-card; + display: none; + position: relative; + width: 31rem; + height: 100%; + + > .abs-container { + position: absolute; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: var(--padding-lg); + + > .upper-section { + display: flex; + justify-content: space-between; + width: 100%; + margin-bottom: var(--margin-md); + + > .left-section { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + padding-right: var(--padding-sm); + + > .customer-name { + font-size: var(--text-2xl); + font-weight: 700; + } + + > .customer-email { + font-size: var(--text-md); + font-weight: 500; + color: var(--gray-600); + } + + > .cashier { + font-size: var(--text-md); + font-weight: 500; + color: var(--gray-600); + margin-top: auto; + } + } + + > .right-section { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + + > .paid-amount { + font-size: var(--text-2xl); + font-weight: 700; + } + + > .invoice-name { + font-size: var(--text-md); + font-weight: 500; + color: var(--gray-600); + margin-bottom: var(--margin-sm); + } + } + } + + > .summary-container { + display: flex; + flex-direction: column; + border-radius: var(--border-radius-md); + background-color: var(--gray-50); + margin: var(--margin-md) 0px; + + > .summary-row-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--padding-sm) var(--padding-md); + } + + > .taxes-wrapper { + display: flex; + flex-direction: column; + padding: 0px var(--padding-md); + + > .tax-row { + display: flex; + justify-content: space-between; + font-size: var(--text-md); + line-height: var(--text-3xl); + } + } + + > .item-row-wrapper { + display: flex; + align-items: center; + padding: var(--padding-sm) var(--padding-md); + + > .item-name { + @extend .nowrap; + font-weight: 500; + margin-right: var(--margin-md); + } + + > .item-qty { + font-weight: 500; + margin-left: auto; + } + + > .item-rate-disc { + display: flex; + text-align: right; + margin-left: var(--margin-md); + justify-content: flex-end; + + > .item-disc { + color: var(--dark-green-500); + } + + > .item-rate { + font-weight: 500; + margin-left: var(--margin-md); + } + } + } + + > .grand-total { + font-weight: 700; + } + + > .payments { + font-weight: 700; + } + } + + + > .summary-btns { + display: flex; + justify-content: space-between; + + > .summary-btn { + flex: 1; + margin: 0px var(--margin-xs); + } + + > .new-btn { + background-color: var(--blue-500); + color:white; + font-weight: 500; + } + } + } + } + } +} \ No newline at end of file diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss new file mode 100644 index 00000000000..159a8a47cd3 --- /dev/null +++ b/erpnext/public/scss/shopping_cart.scss @@ -0,0 +1,494 @@ +@import "frappe/public/scss/desk/variables"; +@import "frappe/public/scss/common/mixins"; + +body.product-page { + background: var(--gray-50); +} + + +.item-breadcrumbs { + .breadcrumb-container { + ol.breadcrumb { + background-color: var(--gray-50) !important; + } + + a { + color: var(--gray-900); + } + } +} + +.carousel-control { + height: 42px; + width: 42px; + display: flex; + align-items: center; + justify-content: center; + background: white; + box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.08), 0px 1px 2px 1px rgba(0, 0, 0, 0.06); + border-radius: 100px; +} + +.carousel-control-prev, +.carousel-control-next { + opacity: 1; +} + +.carousel-body { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.carousel-content { + max-width: 400px; +} + +.card { + border: none; +} + +.product-category-section { + .card:hover { + box-shadow: 0px 16px 45px 6px rgba(0, 0, 0, 0.08), 0px 8px 10px -10px rgba(0, 0, 0, 0.04); + } + + .card-grid { + display: grid; + grid-gap: 15px; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + } +} + +.item-card-group-section { + .card { + height: 360px; + align-items: center; + justify-content: center; + + &:hover { + box-shadow: 0px 16px 60px rgba(0, 0, 0, 0.08), 0px 8px 30px -20px rgba(0, 0, 0, 0.04); + transition: box-shadow 400ms; + } + } + + // .card-body { + // text-align: center; + // } + + // .featured-item { + // .card-body { + // text-align: left; + // } + // } + .card-img-container { + height: 210px; + width: 100%; + } + + .card-img { + max-height: 210px; + object-fit: contain; + margin-top: 1.25rem; + } + + .no-image { + @include flex(flex, center, center, null); + height: 200px; + margin: 0 auto; + margin-top: var(--margin-xl); + background: var(--gray-100); + width: 80%; + border-radius: var(--border-radius); + font-size: 2rem; + color: var(--gray-500); + } + + .product-title { + font-size: 14px; + color: var(--gray-800); + font-weight: 500; + } + + .product-description { + font-size: 12px; + color: var(--text-color); + margin: 20px 0; + display: -webkit-box; + -webkit-line-clamp: 6; + -webkit-box-orient: vertical; + + p { + margin-bottom: 0.5rem; + } + } + + .product-category { + font-size: 13px; + color: var(--text-muted); + margin: var(--margin-sm) 0; + } + + .product-price { + font-size: 18px; + font-weight: 600; + color: var(--text-color); + margin: var(--margin-sm) 0; + } + + .item-card { + padding: var(--padding-sm); + } +} + +[data-doctype="Item Group"], +#page-all-products { + .page-header { + font-size: 20px; + font-weight: 700; + color: var(--text-color); + } + + .filters-section { + .title-section { + border-bottom: 1px solid var(--table-border-color); + } + + .filter-title { + font-weight: 500; + } + + .clear-filters { + font-size: 13px; + } + + .filter-label { + font-size: 11px; + font-weight: 600; + color: var(--gray-700); + text-transform: uppercase; + } + + .filter-block { + border-bottom: 1px solid var(--table-border-color); + } + + .checkbox { + .label-area { + font-size: 13px; + color: var(--gray-800); + } + } + } +} + +.product-container { + @include card($padding: var(--padding-md)); + min-height: 70vh; + + .product-details { + max-width: 40%; + margin-left: -30px; + + .btn-add-to-cart { + font-size: var(--text-base); + } + } + + .product-title { + font-size: 24px; + font-weight: 600; + color: var(--text-color); + } + + .product-code { + color: var(--text-muted); + font-size: 13px; + } + + .product-description { + font-size: 13px; + color: var(--gray-800); + } + + .product-image { + border-color: var(--table-border-color) !important; + padding: 15px; + + @include media-breakpoint-between(xs, md) { + height: 300px; + width: 300px; + } + + @include media-breakpoint-up(lg) { + height: 350px; + width: 350px; + } + + img { + object-fit: contain; + } + } + + .item-slideshow { + @include media-breakpoint-between(xs, md) { + max-height: 320px; + } + + @include media-breakpoint-up(lg) { + max-height: 430px; + } + + overflow: scroll; + } + + .item-slideshow-image { + height: 4rem; + width: 6rem; + object-fit: contain; + padding: 0.5rem; + border: 1px solid var(--table-border-color); + border-radius: 4px; + cursor: pointer; + + &:hover, &.active { + border-color: $primary; + } + } + + .item-cart { + .product-price { + font-size: 20px; + color: var(--text-color); + font-weight: 600; + + .formatted-price { + color: var(--text-muted); + font-size: var(--text-base); + } + } + + .no-stock { + font-size: var(--text-base); + } + } +} + +.item-configurator-dialog { + .modal-header { + padding: var(--padding-md) var(--padding-xl); + } + + .modal-body { + padding: 0 var(--padding-xl); + padding-bottom: var(--padding-xl); + + .status-area { + .alert { + padding: var(--padding-xs) var(--padding-sm); + font-size: var(--text-sm); + } + } + + .form-layout { + max-height: 50vh; + overflow-y: auto; + } + + .section-body { + .form-column { + .form-group { + .control-label { + font-size: var(--text-md); + color: var(--gray-700); + } + + .help-box { + margin-top: 2px; + font-size: var(--text-sm); + } + } + } + } + } +} + +.item-group-slideshow { + .item-group-description { + // max-width: 900px; + } + + .carousel-inner.rounded-carousel { + border-radius: $card-border-radius; + } +} + +.cart-icon { + .cart-badge { + position: relative; + top: -10px; + left: -12px; + background: var(--red-600); + width: 16px; + align-items: center; + height: 16px; + font-size: 10px; + border-radius: 50%; + } +} + + +#page-cart { + .shopping-cart-header { + font-weight: bold; + } + + .cart-container { + color: var(--text-color); + + .frappe-card { + display: flex; + flex-direction: column; + justify-content: space-between; + } + + .cart-items-header { + font-weight: 600; + } + + .cart-table { + th, tr, td { + border-color: var(--border-color); + border-width: 1px; + } + + th { + font-weight: normal; + font-size: 13px; + color: var(--text-muted); + padding: var(--padding-sm) 0; + } + + td { + padding: var(--padding-sm) 0; + color: var(--text-color); + } + + .cart-items { + .item-title { + font-size: var(--text-base); + font-weight: 500; + color: var(--text-color); + } + + .item-subtitle { + color: var(--text-muted); + font-size: var(--text-md); + } + + .item-subtotal { + font-size: var(--text-base); + font-weight: 500; + } + + .item-rate { + font-size: var(--text-md); + color: var(--text-muted); + } + + textarea { + width: 40%; + } + } + + .cart-tax-items { + .item-grand-total { + font-size: 16px; + font-weight: 600; + color: var(--text-color); + } + } + } + + .cart-addresses { + hr { + border-color: var(--border-color); + } + } + + .number-spinner { + width: 75%; + .cart-btn { + border: none; + background: var(--gray-100); + box-shadow: none; + height: 28px; + align-items: center; + display: flex; + } + + .cart-qty { + height: 28px; + font-size: var(--text-md); + } + } + + .place-order-container { + .btn-place-order { + width: 62%; + } + } + } +} + +.cart-empty.frappe-card { + min-height: 76vh; + @include flex(flex, center, center, column); + + .cart-empty-message { + font-size: 18px; + color: var(--text-color); + font-weight: bold; + } +} + +.address-card { + .card-title { + font-size: var(--text-base); + font-weight: 500; + } + + .card-body { + max-width: 80%; + } + + .card-text { + font-size: var(--text-md); + color: var(--gray-700); + } + + .card-link { + font-size: var(--text-md); + + svg use { + stroke: var(--blue-500); + } + } + + .btn-change-address { + color: var(--blue-500); + box-shadow: none; + border: 1px solid var(--blue-500); + } +} + +.modal .address-card { + .card-body { + padding: var(--padding-sm); + border-radius: var(--border-radius); + border: 1px solid var(--dark-border-color); + } +} + diff --git a/erpnext/public/scss/website.scss b/erpnext/public/scss/website.scss index 617e916724d..56b717c4240 100644 --- a/erpnext/public/scss/website.scss +++ b/erpnext/public/scss/website.scss @@ -1,29 +1,10 @@ -@import "frappe/public/scss/variables"; - -.product-image img { - min-height: 20rem; - max-height: 30rem; -} +@import "frappe/public/scss/website/variables"; .filter-options { max-height: 300px; overflow: auto; } -.item-slideshow-image { - height: 3rem; - width: 3rem; - object-fit: contain; - padding: 0.5rem; - border: 1px solid $border-color; - border-radius: 4px; - cursor: pointer; - - &:hover, &.active { - border-color: $primary; - } -} - .address-card { cursor: pointer; position: relative; @@ -43,10 +24,10 @@ .check { display: inline-flex; - padding: 0.25rem; - background: $primary; - color: white; - border-radius: 50%; + padding: 0.25rem; + background: $primary; + color: white; + border-radius: 50%; font-size: 12px; width: 24px; height: 24px; diff --git a/erpnext/quality_management/desk_page/quality/quality.json b/erpnext/quality_management/desk_page/quality/quality.json deleted file mode 100644 index 5ee70008dd8..00000000000 --- a/erpnext/quality_management/desk_page/quality/quality.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Goal and Procedure", - "links": "[\n {\n \"description\": \"Quality Goal.\",\n \"label\": \"Quality Goal\",\n \"name\": \"Quality Goal\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Quality Procedure.\",\n \"label\": \"Quality Procedure\",\n \"name\": \"Quality Procedure\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tree of Quality Procedures.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Tree of Procedures\",\n \"name\": \"Quality Procedure\",\n \"route\": \"#Tree/Quality Procedure\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Feedback", - "links": "[\n {\n \"description\": \"Quality Feedback\",\n \"label\": \"Quality Feedback\",\n \"name\": \"Quality Feedback\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Quality Feedback Template\",\n \"label\": \"Quality Feedback Template\",\n \"name\": \"Quality Feedback Template\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Meeting", - "links": "[\n {\n \"description\": \"Quality Meeting\",\n \"label\": \"Quality Meeting\",\n \"name\": \"Quality Meeting\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Review and Action", - "links": "[\n {\n \"description\": \"Quality Review\",\n \"label\": \"Quality Review\",\n \"name\": \"Quality Review\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Quality Action\",\n \"label\": \"Quality Action\",\n \"name\": \"Quality Action\",\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Modules", - "charts": [], - "creation": "2020-03-02 15:49:28.632014", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "icon": "", - "idx": 0, - "is_standard": 1, - "label": "Quality", - "modified": "2020-04-01 11:28:51.095012", - "modified_by": "Administrator", - "module": "Quality Management", - "name": "Quality", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "label": "Quality Goal", - "link_to": "Quality Goal", - "type": "DocType" - }, - { - "label": "Quality Procedure", - "link_to": "Quality Procedure", - "type": "DocType" - }, - { - "label": "Quality Inspection", - "link_to": "Quality Inspection", - "type": "DocType" - } - ] -} \ No newline at end of file diff --git a/erpnext/quality_management/doctype/non_conformance/__init__.py b/erpnext/quality_management/doctype/non_conformance/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/quality_management/doctype/non_conformance/non_conformance.js b/erpnext/quality_management/doctype/non_conformance/non_conformance.js new file mode 100644 index 00000000000..e7f5eee623e --- /dev/null +++ b/erpnext/quality_management/doctype/non_conformance/non_conformance.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Non Conformance', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/quality_management/doctype/non_conformance/non_conformance.json b/erpnext/quality_management/doctype/non_conformance/non_conformance.json new file mode 100644 index 00000000000..8dfe2d6859d --- /dev/null +++ b/erpnext/quality_management/doctype/non_conformance/non_conformance.json @@ -0,0 +1,118 @@ +{ + "actions": [], + "autoname": "format:QA-NC-{#####}", + "creation": "2020-10-21 14:49:50.350136", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "subject", + "procedure", + "process_owner", + "full_name", + "column_break_4", + "status", + "section_break_4", + "details", + "corrective_action", + "preventive_action" + ], + "fields": [ + { + "fieldname": "subject", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Subject", + "reqd": 1 + }, + { + "fieldname": "procedure", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Procedure", + "options": "Quality Procedure", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Open\nResolved\nCancelled", + "reqd": 1 + }, + { + "fieldname": "section_break_4", + "fieldtype": "Section Break" + }, + { + "fieldname": "details", + "fieldtype": "Text Editor", + "label": "Details" + }, + { + "fetch_from": "procedure.process_owner", + "fieldname": "process_owner", + "fieldtype": "Data", + "label": "Process Owner", + "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fetch_from": "process_owner.full_name", + "fieldname": "full_name", + "fieldtype": "Data", + "hidden": 1, + "label": "Full Name" + }, + { + "fieldname": "corrective_action", + "fieldtype": "Text Editor", + "label": "Corrective Action" + }, + { + "fieldname": "preventive_action", + "fieldtype": "Text Editor", + "label": "Preventive Action" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-02-26 15:27:47.247814", + "modified_by": "Administrator", + "module": "Quality Management", + "name": "Non Conformance", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Employee", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/erpnext/quality_management/doctype/non_conformance/non_conformance.py b/erpnext/quality_management/doctype/non_conformance/non_conformance.py new file mode 100644 index 00000000000..d4e8cc7a716 --- /dev/null +++ b/erpnext/quality_management/doctype/non_conformance/non_conformance.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class NonConformance(Document): + pass diff --git a/erpnext/quality_management/doctype/non_conformance/test_non_conformance.py b/erpnext/quality_management/doctype/non_conformance/test_non_conformance.py new file mode 100644 index 00000000000..54f8b58cfb0 --- /dev/null +++ b/erpnext/quality_management/doctype/non_conformance/test_non_conformance.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestNonConformance(unittest.TestCase): + pass diff --git a/erpnext/quality_management/doctype/quality_action/quality_action.js b/erpnext/quality_management/doctype/quality_action/quality_action.js index 70782477f06..e216a7539c8 100644 --- a/erpnext/quality_management/doctype/quality_action/quality_action.js +++ b/erpnext/quality_management/doctype/quality_action/quality_action.js @@ -2,32 +2,5 @@ // For license information, please see license.txt frappe.ui.form.on('Quality Action', { - onload: function(frm) { - frm.set_value("date", frappe.datetime.get_today()); - frm.refresh(); - }, - document_name: function(frm){ - frappe.call({ - "method": "frappe.client.get", - args: { - doctype: frm.doc.document_type, - name: frm.doc.document_name - }, - callback: function(data){ - frm.fields_dict.resolutions.grid.remove_all(); - let objectives = []; - if(frm.doc.document_type === "Quality Review"){ - for(let i in data.message.reviews) objectives.push(data.message.reviews[i].review); - } else { - for(let j in data.message.parameters) objectives.push(data.message.parameters[j].feedback); - } - for (var objective in objectives){ - frm.add_child("resolutions"); - frm.fields_dict.resolutions.get_value()[objective].problem = objectives[objective]; - } - frm.refresh(); - } - }); - }, }); \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_action/quality_action.json b/erpnext/quality_management/doctype/quality_action/quality_action.json index 8835b479cc3..0cc2a98cd24 100644 --- a/erpnext/quality_management/doctype/quality_action/quality_action.json +++ b/erpnext/quality_management/doctype/quality_action/quality_action.json @@ -1,32 +1,34 @@ { - "autoname": "format:ACTN-{#####}", + "actions": [], + "autoname": "format:QA-ACT-{#####}", "creation": "2018-10-02 11:40:43.666100", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "corrective_preventive", - "document_type", - "goal", + "review", + "feedback", + "status", "cb_00", "date", - "document_name", + "goal", "procedure", - "status", "sb_00", "resolutions" ], "fields": [ { - "depends_on": "eval:doc.type == 'Quality Review'", "fetch_from": "review.goal", "fieldname": "goal", "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, "label": "Goal", - "options": "Quality Goal", - "read_only": 1 + "options": "Quality Goal" }, { + "default": "Today", "fieldname": "date", "fieldtype": "Date", "in_list_view": 1, @@ -34,34 +36,20 @@ "read_only": 1 }, { - "depends_on": "eval:doc.type == 'Quality Review'", "fieldname": "procedure", "fieldtype": "Link", "label": "Procedure", - "options": "Quality Procedure", - "read_only": 1 + "options": "Quality Procedure" }, { "default": "Open", "fieldname": "status", "fieldtype": "Select", "in_list_view": 1, + "in_standard_filter": 1, "label": "Status", - "options": "Open\nClosed" - }, - { - "fieldname": "document_name", - "fieldtype": "Dynamic Link", - "label": "Document Name", - "options": "document_type" - }, - { - "fieldname": "document_type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Document Type", - "options": "Quality Review\nQuality Feedback", - "reqd": 1 + "options": "Open\nCompleted", + "read_only": 1 }, { "default": "Corrective", @@ -86,9 +74,24 @@ "fieldtype": "Table", "label": "Resolutions", "options": "Quality Action Resolution" + }, + { + "fieldname": "review", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Review", + "options": "Quality Review" + }, + { + "fieldname": "feedback", + "fieldtype": "Link", + "label": "Feedback", + "options": "Quality Feedback" } ], - "modified": "2019-05-28 13:10:44.092497", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-10-27 16:21:59.533937", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Action", diff --git a/erpnext/quality_management/doctype/quality_action/quality_action.py b/erpnext/quality_management/doctype/quality_action/quality_action.py index 88d4bd844a6..d6fa5051ee6 100644 --- a/erpnext/quality_management/doctype/quality_action/quality_action.py +++ b/erpnext/quality_management/doctype/quality_action/quality_action.py @@ -7,4 +7,5 @@ import frappe from frappe.model.document import Document class QualityAction(Document): - pass \ No newline at end of file + def validate(self): + self.status = 'Open' if any([d.status=='Open' for d in self.resolutions]) else 'Completed' \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_action/test_quality_action.js b/erpnext/quality_management/doctype/quality_action/test_quality_action.js deleted file mode 100644 index 34a8c868894..00000000000 --- a/erpnext/quality_management/doctype/quality_action/test_quality_action.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Quality Action", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially([ - // insert a new Quality Actions - () => frappe.tests.make('Quality Actions', [ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/erpnext/quality_management/doctype/quality_action/test_quality_action.py b/erpnext/quality_management/doctype/quality_action/test_quality_action.py index 51178d62258..24b97ca3a09 100644 --- a/erpnext/quality_management/doctype/quality_action/test_quality_action.py +++ b/erpnext/quality_management/doctype/quality_action/test_quality_action.py @@ -5,42 +5,7 @@ from __future__ import unicode_literals import frappe import unittest -from erpnext.quality_management.doctype.quality_procedure.test_quality_procedure import create_procedure -from erpnext.quality_management.doctype.quality_goal.test_quality_goal import create_unit -from erpnext.quality_management.doctype.quality_goal.test_quality_goal import create_goal -from erpnext.quality_management.doctype.quality_review.test_quality_review import create_review class TestQualityAction(unittest.TestCase): - - def test_quality_action(self): - create_procedure() - create_unit() - create_goal() - create_review() - test_create_action = create_action() - test_get_action = get_action() - - self.assertEquals(test_create_action, test_get_action) - -def create_action(): - review = frappe.db.exists("Quality Review", {"goal": "GOAL-_Test Quality Goal"}) - action = frappe.get_doc({ - "doctype": "Quality Action", - "action": "Corrective", - "document_type": "Quality Review", - "document_name": review, - "date": frappe.utils.nowdate(), - "goal": "GOAL-_Test Quality Goal", - "procedure": "PRC-_Test Quality Procedure" - }) - action_exist = frappe.db.exists("Quality Action", {"review": review}) - - if not action_exist: - action.insert() - return action.name - else: - return action_exist - -def get_action(): - review = frappe.db.exists("Quality Review", {"goal": "GOAL-_Test Quality Goal"}) - return frappe.db.exists("Quality Action", {"document_name": review}) \ No newline at end of file + # quality action has no code + pass \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_action_resolution/quality_action_resolution.json b/erpnext/quality_management/doctype/quality_action_resolution/quality_action_resolution.json index a4e6aed86a0..993274b5496 100644 --- a/erpnext/quality_management/doctype/quality_action_resolution/quality_action_resolution.json +++ b/erpnext/quality_management/doctype/quality_action_resolution/quality_action_resolution.json @@ -1,33 +1,54 @@ { + "actions": [], "creation": "2019-05-26 20:36:44.337186", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "problem", - "sb_00", - "resolution" + "resolution", + "status", + "responsible", + "completion_by" ], "fields": [ { "fieldname": "problem", "fieldtype": "Long Text", "in_list_view": 1, - "label": "Review" - }, - { - "fieldname": "sb_00", - "fieldtype": "Section Break" + "label": "Problem" }, { "fieldname": "resolution", "fieldtype": "Text Editor", "in_list_view": 1, "label": "Resolution" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Open\nCompleted" + }, + { + "fieldname": "responsible", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Responsible", + "options": "User" + }, + { + "fieldname": "completion_by", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Completion By" } ], + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2019-05-28 13:09:50.435323", + "links": [], + "modified": "2020-10-21 12:59:25.566682", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Action Resolution", diff --git a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.js b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.js index dac6ac40a71..6fb326776ed 100644 --- a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.js +++ b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.js @@ -2,31 +2,9 @@ // For license information, please see license.txt frappe.ui.form.on('Quality Feedback', { - refresh: function(frm) { - frm.set_value("date", frappe.datetime.get_today()); - }, - template: function(frm) { if (frm.doc.template) { - frappe.call({ - "method": "frappe.client.get", - args: { - doctype: "Quality Feedback Template", - name: frm.doc.template - }, - callback: function(data) { - if (data && data.message) { - frm.fields_dict.parameters.grid.remove_all(); - - // fetch parameters from template and autofill - for (let template_parameter of data.message.parameters) { - let row = frm.add_child("parameters"); - row.parameter = template_parameter.parameter; - } - frm.refresh(); - } - } - }); + frm.call('set_parameters'); } } }); diff --git a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json index ab9084fa79b..f3bd0ddb2ed 100644 --- a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json +++ b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json @@ -1,16 +1,15 @@ { "actions": [], - "autoname": "format:FDBK-{#####}", + "autoname": "format:QA-FB-{#####}", "creation": "2019-05-26 21:23:05.308379", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "document_type", "template", "cb_00", + "document_type", "document_name", - "date", "sb_00", "parameters" ], @@ -18,6 +17,7 @@ { "fieldname": "template", "fieldtype": "Link", + "in_list_view": 1, "label": "Template", "options": "Quality Feedback Template", "reqd": 1 @@ -26,13 +26,6 @@ "fieldname": "cb_00", "fieldtype": "Column Break" }, - { - "fieldname": "date", - "fieldtype": "Date", - "in_list_view": 1, - "label": "Date", - "read_only": 1 - }, { "fieldname": "sb_00", "fieldtype": "Section Break" @@ -47,6 +40,7 @@ { "fieldname": "document_type", "fieldtype": "Select", + "in_list_view": 1, "label": "Type", "options": "User\nCustomer", "reqd": 1 @@ -54,13 +48,20 @@ { "fieldname": "document_name", "fieldtype": "Dynamic Link", + "in_list_view": 1, "label": "Feedback By", "options": "document_type", "reqd": 1 } ], - "links": [], - "modified": "2020-07-03 15:50:58.589302", + "links": [ + { + "group": "Actions", + "link_doctype": "Quality Action", + "link_fieldname": "feedback" + } + ], + "modified": "2020-10-27 16:20:10.918544", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Feedback", diff --git a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.py b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.py index 98941810047..bf82cc080a0 100644 --- a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.py +++ b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.py @@ -7,4 +7,17 @@ import frappe from frappe.model.document import Document class QualityFeedback(Document): - pass \ No newline at end of file + def set_parameters(self): + if self.template and not getattr(self, 'parameters', []): + for d in frappe.get_doc('Quality Feedback Template', self.template).parameters: + self.append('parameters', dict( + parameter = d.parameter, + rating = 1 + )) + + def validate(self): + if not self.document_name: + self.document_type ='User' + self.document_name = frappe.session.user + self.set_parameters() + diff --git a/erpnext/quality_management/doctype/quality_feedback/test_quality_feedback.py b/erpnext/quality_management/doctype/quality_feedback/test_quality_feedback.py index 3be1eb2791a..5a8bd5ce30c 100644 --- a/erpnext/quality_management/doctype/quality_feedback/test_quality_feedback.py +++ b/erpnext/quality_management/doctype/quality_feedback/test_quality_feedback.py @@ -5,49 +5,27 @@ from __future__ import unicode_literals import frappe import unittest -from erpnext.quality_management.doctype.quality_feedback_template.test_quality_feedback_template import create_template + class TestQualityFeedback(unittest.TestCase): - def test_quality_feedback(self): - create_template() - test_create_feedback = create_feedback() - test_get_feedback = get_feedback() + template = frappe.get_doc(dict( + doctype = 'Quality Feedback Template', + template = 'Test Template', + parameters = [ + dict(parameter='Test Parameter 1'), + dict(parameter='Test Parameter 2') + ] + )).insert() - self.assertEqual(test_create_feedback, test_get_feedback) + feedback = frappe.get_doc(dict( + doctype = 'Quality Feedback', + template = template.name, + document_type = 'User', + document_name = frappe.session.user + )).insert() -def create_feedback(): - create_customer() + self.assertEqual(template.parameters[0].parameter, feedback.parameters[0].parameter) - feedabck = frappe.get_doc({ - "doctype": "Quality Feedback", - "template": "TMPL-_Test Feedback Template", - "document_type": "Customer", - "document_name": "Quality Feedback Customer", - "date": frappe.utils.nowdate(), - "parameters": [ - { - "parameter": "Test Parameter", - "rating": 3, - "feedback": "Test Feedback" - } - ] - }) - - feedback_exists = frappe.db.exists("Quality Feedback", {"template": "TMPL-_Test Feedback Template"}) - - if not feedback_exists: - feedabck.insert() - return feedabck.name - else: - return feedback_exists - -def get_feedback(): - return frappe.db.exists("Quality Feedback", {"template": "TMPL-_Test Feedback Template"}) - -def create_customer(): - if not frappe.db.exists("Customer", {"customer_name": "Quality Feedback Customer"}): - customer = frappe.get_doc({ - "doctype": "Customer", - "customer_name": "Quality Feedback Customer" - }).insert(ignore_permissions=True) \ No newline at end of file + feedback.delete() + template.delete() diff --git a/erpnext/quality_management/doctype/quality_feedback_parameter/quality_feedback_parameter.json b/erpnext/quality_management/doctype/quality_feedback_parameter/quality_feedback_parameter.json index 5bd8920a327..ce5d4cfeee0 100644 --- a/erpnext/quality_management/doctype/quality_feedback_parameter/quality_feedback_parameter.json +++ b/erpnext/quality_management/doctype/quality_feedback_parameter/quality_feedback_parameter.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2019-05-26 21:25:01.715807", "doctype": "DocType", "editable_grid": 1, @@ -39,12 +40,13 @@ "fieldname": "feedback", "fieldtype": "Text Editor", "in_list_view": 1, - "label": "Feedback", - "reqd": 1 + "label": "Feedback" } ], + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2019-07-13 19:58:08.966141", + "links": [], + "modified": "2020-10-27 17:28:12.033145", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Feedback Parameter", diff --git a/erpnext/quality_management/doctype/quality_feedback_template/quality_feedback_template.json b/erpnext/quality_management/doctype/quality_feedback_template/quality_feedback_template.json index bdc9dbab492..169647046dc 100644 --- a/erpnext/quality_management/doctype/quality_feedback_template/quality_feedback_template.json +++ b/erpnext/quality_management/doctype/quality_feedback_template/quality_feedback_template.json @@ -1,13 +1,12 @@ { "actions": [], - "autoname": "format:TMPL-{template}", + "autoname": "field:template", "creation": "2019-05-26 21:17:24.283061", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "template", - "cb_00", "sb_00", "parameters" ], @@ -16,12 +15,9 @@ "fieldname": "template", "fieldtype": "Data", "in_list_view": 1, - "label": "Template", - "reqd": 1 - }, - { - "fieldname": "cb_00", - "fieldtype": "Column Break" + "label": "Template Name", + "reqd": 1, + "unique": 1 }, { "fieldname": "sb_00", @@ -35,8 +31,14 @@ "reqd": 1 } ], - "links": [], - "modified": "2020-07-03 16:06:03.749415", + "links": [ + { + "group": "Records", + "link_doctype": "Quality Feedback", + "link_fieldname": "template" + } + ], + "modified": "2020-10-27 16:18:53.579688", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Feedback Template", diff --git a/erpnext/quality_management/doctype/quality_feedback_template/test_quality_feedback_template.py b/erpnext/quality_management/doctype/quality_feedback_template/test_quality_feedback_template.py index 36dbe137a54..b3eed103836 100644 --- a/erpnext/quality_management/doctype/quality_feedback_template/test_quality_feedback_template.py +++ b/erpnext/quality_management/doctype/quality_feedback_template/test_quality_feedback_template.py @@ -7,31 +7,4 @@ import frappe import unittest class TestQualityFeedbackTemplate(unittest.TestCase): - - def test_quality_feedback_template(self): - test_create_template = create_template() - test_get_template = get_template() - - self.assertEqual(test_create_template, test_get_template) - -def create_template(): - template = frappe.get_doc({ - "doctype": "Quality Feedback Template", - "template": "_Test Feedback Template", - "parameters": [ - { - "parameter": "Test Parameter" - } - ] - }) - - template_exists = frappe.db.exists("Quality Feedback Template", {"template": "_Test Feedback Template"}) - - if not template_exists: - template.insert() - return template.name - else: - return template_exists - -def get_template(): - return frappe.db.exists("Quality Feedback Template", {"template": "_Test Feedback Template"}) \ No newline at end of file + pass \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_goal/quality_goal.js b/erpnext/quality_management/doctype/quality_goal/quality_goal.js index ff58c5ad210..40cb4d92464 100644 --- a/erpnext/quality_management/doctype/quality_goal/quality_goal.js +++ b/erpnext/quality_management/doctype/quality_goal/quality_goal.js @@ -2,7 +2,6 @@ // For license information, please see license.txt frappe.ui.form.on('Quality Goal', { - refresh: function(frm) { - frm.doc.created_by = frappe.session.user; - } + // refresh: function(frm) { + // } }); diff --git a/erpnext/quality_management/doctype/quality_goal/quality_goal.json b/erpnext/quality_management/doctype/quality_goal/quality_goal.json index c32610948e3..26802550dca 100644 --- a/erpnext/quality_management/doctype/quality_goal/quality_goal.json +++ b/erpnext/quality_management/doctype/quality_goal/quality_goal.json @@ -1,5 +1,6 @@ { - "autoname": "format:GOAL-{goal}", + "actions": [], + "autoname": "field:goal", "creation": "2018-10-02 12:17:41.727541", "doctype": "DocType", "editable_grid": 1, @@ -7,27 +8,14 @@ "field_order": [ "goal", "frequency", - "created_by", "cb_00", "procedure", "weekday", - "quarter", "date", - "sb_00", - "revision", - "cb_01", - "revised_on", "sb_01", "objectives" ], "fields": [ - { - "fieldname": "created_by", - "fieldtype": "Link", - "label": "Created By", - "options": "User", - "read_only": 1 - }, { "default": "None", "fieldname": "frequency", @@ -50,20 +38,6 @@ "label": "Date", "options": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n30" }, - { - "default": "0", - "fieldname": "revision", - "fieldtype": "Int", - "label": "Revision", - "read_only": 1 - }, - { - "fieldname": "revised_on", - "fieldtype": "Date", - "in_list_view": 1, - "label": "Revised On", - "read_only": 1 - }, { "depends_on": "eval:doc.frequency == 'Weekly';", "fieldname": "weekday", @@ -75,15 +49,6 @@ "fieldname": "cb_00", "fieldtype": "Column Break" }, - { - "fieldname": "sb_00", - "fieldtype": "Section Break", - "label": "Revision and Revised On" - }, - { - "fieldname": "cb_01", - "fieldtype": "Column Break" - }, { "fieldname": "sb_01", "fieldtype": "Section Break", @@ -101,18 +66,17 @@ "label": "Goal", "reqd": 1, "unique": 1 - }, - { - "default": "January-April-July-October", - "depends_on": "eval:doc.frequency == 'Quarterly';", - "fieldname": "quarter", - "fieldtype": "Select", - "label": "Quarter", - "options": "January-April-July-October", - "read_only": 1 } ], - "modified": "2019-05-28 14:49:12.768863", + "index_web_pages_for_search": 1, + "links": [ + { + "group": "Review", + "link_doctype": "Quality Review", + "link_fieldname": "goal" + } + ], + "modified": "2020-10-27 15:57:59.368605", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Goal", diff --git a/erpnext/quality_management/doctype/quality_goal/quality_goal.py b/erpnext/quality_management/doctype/quality_goal/quality_goal.py index 4ae015e4a19..f3fe986d539 100644 --- a/erpnext/quality_management/doctype/quality_goal/quality_goal.py +++ b/erpnext/quality_management/doctype/quality_goal/quality_goal.py @@ -8,7 +8,5 @@ import frappe from frappe.model.document import Document class QualityGoal(Document): - def validate(self): - self.revision += 1 - self.revised_on = frappe.utils.today() + pass \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_goal/quality_goal_dashboard.py b/erpnext/quality_management/doctype/quality_goal/quality_goal_dashboard.py deleted file mode 100644 index 22af3c0f74b..00000000000 --- a/erpnext/quality_management/doctype/quality_goal/quality_goal_dashboard.py +++ /dev/null @@ -1,12 +0,0 @@ -from frappe import _ - -def get_data(): - return { - 'fieldname': 'goal', - 'transactions': [ - { - 'label': _('Review'), - 'items': ['Quality Review'] - } - ] - } \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_goal/test_quality_goal.js b/erpnext/quality_management/doctype/quality_goal/test_quality_goal.js deleted file mode 100644 index f8afe548a46..00000000000 --- a/erpnext/quality_management/doctype/quality_goal/test_quality_goal.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Quality Goal", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially([ - // insert a new Quality Goal - () => frappe.tests.make('Quality Goal', [ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/erpnext/quality_management/doctype/quality_goal/test_quality_goal.py b/erpnext/quality_management/doctype/quality_goal/test_quality_goal.py index d77187aaa19..f61d6e581d7 100644 --- a/erpnext/quality_management/doctype/quality_goal/test_quality_goal.py +++ b/erpnext/quality_management/doctype/quality_goal/test_quality_goal.py @@ -8,44 +8,18 @@ import unittest from erpnext.quality_management.doctype.quality_procedure.test_quality_procedure import create_procedure class TestQualityGoal(unittest.TestCase): - def test_quality_goal(self): - create_procedure() - create_unit() - test_create_goal = create_goal() - test_get_goal = get_goal() + # no code, just a basic sanity check + goal = get_quality_goal() + self.assertTrue(goal) + goal.delete() - self.assertEquals(test_create_goal, test_get_goal) - -def create_goal(): - goal = frappe.get_doc({ - "doctype": "Quality Goal", - "goal": "_Test Quality Goal", - "procedure": "PRC-_Test Quality Procedure", - "objectives": [ - { - "objective": "_Test Quality Objective", - "target": "4", - "uom": "_Test UOM" - } +def get_quality_goal(): + return frappe.get_doc(dict( + doctype = 'Quality Goal', + goal = 'Test Quality Module', + frequency = 'Daily', + objectives = [ + dict(objective = 'Check test cases', target='100', uom='Percent') ] - }) - goal_exist = frappe.db.exists("Quality Goal", {"goal": goal.goal}) - if not goal_exist: - goal.insert() - return goal.name - else: - return goal_exist - -def get_goal(): - goal = frappe.db.exists("Quality Goal", "GOAL-_Test Quality Goal") - return goal - -def create_unit(): - unit = frappe.get_doc({ - "doctype": "UOM", - "uom_name": "_Test UOM", - }) - unit_exist = frappe.db.exists("UOM", unit.uom_name) - if not unit_exist: - unit.insert() + )).insert() \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_meeting/quality_meeting.js b/erpnext/quality_management/doctype/quality_meeting/quality_meeting.js index 32c7c33fd53..eb7a8c32d73 100644 --- a/erpnext/quality_management/doctype/quality_meeting/quality_meeting.js +++ b/erpnext/quality_management/doctype/quality_meeting/quality_meeting.js @@ -2,8 +2,5 @@ // For license information, please see license.txt frappe.ui.form.on('Quality Meeting', { - onload: function(frm){ - frm.set_value("date", frappe.datetime.get_today()); - frm.refresh(); - } + }); diff --git a/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json b/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json index 7691fe35870..e2125c3933a 100644 --- a/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json +++ b/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json @@ -1,28 +1,19 @@ { "actions": [], - "autoname": "naming_series:", + "autoname": "format:QA-MEET-{YY}-{MM}-{DD}", "creation": "2018-10-15 16:25:41.548432", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "naming_series", - "date", - "cb_00", "status", + "cb_00", "sb_00", "agenda", "sb_01", "minutes" ], "fields": [ - { - "fieldname": "date", - "fieldtype": "Date", - "in_list_view": 1, - "label": "Date", - "read_only": 1 - }, { "default": "Open", "fieldname": "status", @@ -42,8 +33,7 @@ }, { "fieldname": "sb_00", - "fieldtype": "Section Break", - "label": "Agenda" + "fieldtype": "Section Break" }, { "fieldname": "agenda", @@ -53,18 +43,12 @@ }, { "fieldname": "sb_01", - "fieldtype": "Section Break", - "label": "Minutes" - }, - { - "fieldname": "naming_series", - "fieldtype": "Select", - "label": "Naming Series", - "options": "MTNG-.YYYY.-.MM.-.DD.-" + "fieldtype": "Section Break" } ], + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-05-19 13:18:59.821740", + "modified": "2021-02-27 16:36:45.657883", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Meeting", @@ -99,4 +83,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/quality_management/doctype/quality_meeting/test_quality_meeting.js b/erpnext/quality_management/doctype/quality_meeting/test_quality_meeting.js deleted file mode 100644 index 196cc85ccb9..00000000000 --- a/erpnext/quality_management/doctype/quality_meeting/test_quality_meeting.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Quality Meeting", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially([ - // insert a new Quality Meeting - () => frappe.tests.make('Quality Meeting', [ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/erpnext/quality_management/doctype/quality_meeting/test_quality_meeting.py b/erpnext/quality_management/doctype/quality_meeting/test_quality_meeting.py index e61b5dfc57c..754bccb06e0 100644 --- a/erpnext/quality_management/doctype/quality_meeting/test_quality_meeting.py +++ b/erpnext/quality_management/doctype/quality_meeting/test_quality_meeting.py @@ -5,41 +5,7 @@ from __future__ import unicode_literals import frappe import unittest -from erpnext.quality_management.doctype.quality_review.test_quality_review import create_review class TestQualityMeeting(unittest.TestCase): - def test_quality_meeting(self): - create_review() - test_create_meeting = create_meeting() - test_get_meeting = get_meeting() - self.assertEquals(test_create_meeting, test_get_meeting) - -def create_meeting(): - meeting = frappe.get_doc({ - "doctype": "Quality Meeting", - "status": "Open", - "date": frappe.utils.nowdate(), - "agenda": [ - { - "agenda": "Test Agenda" - } - ], - "minutes": [ - { - "document_type": "Quality Review", - "document_name": frappe.db.exists("Quality Review", {"goal": "GOAL-_Test Quality Goal"}), - "minute": "Test Minute" - } - ] - }) - meeting_exist = frappe.db.exists("Quality Meeting", {"date": frappe.utils.nowdate(), "status": "Open"}) - - if not meeting_exist: - meeting.insert() - return meeting.name - else: - return meeting_exist - -def get_meeting(): - meeting = frappe.db.exists("Quality Meeting", {"date": frappe.utils.nowdate(), "status": "Open"}) - return meeting \ No newline at end of file + # nothing to test + pass \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.js b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.js index cf2644e0053..ac876229ecb 100644 --- a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.js +++ b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.js @@ -10,5 +10,13 @@ frappe.ui.form.on('Quality Procedure', { } }; }); + + frm.set_query('parent_quality_procedure', function(){ + return { + filters: { + is_group: 1 + } + }; + }); } }); \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json index b3c0d948909..f588f9aea1a 100644 --- a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json +++ b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json @@ -1,19 +1,22 @@ { "actions": [], "allow_rename": 1, - "autoname": "format:PRC-{quality_procedure_name}", + "autoname": "field:quality_procedure_name", "creation": "2018-10-06 00:06:29.756804", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "quality_procedure_name", + "process_owner", + "process_owner_full_name", + "section_break_3", + "processes", + "sb_00", "parent_quality_procedure", "is_group", - "sb_00", - "processes", - "lft", "rgt", + "lft", "old_parent" ], "fields": [ @@ -21,8 +24,7 @@ "fieldname": "parent_quality_procedure", "fieldtype": "Link", "label": "Parent Procedure", - "options": "Quality Procedure", - "read_only": 1 + "options": "Quality Procedure" }, { "default": "0", @@ -35,14 +37,14 @@ "fieldname": "lft", "fieldtype": "Int", "hidden": 1, - "label": "Lft", + "label": "Left Index", "read_only": 1 }, { "fieldname": "rgt", "fieldtype": "Int", "hidden": 1, - "label": "Rgt", + "label": "Right Index", "read_only": 1 }, { @@ -55,7 +57,7 @@ { "fieldname": "sb_00", "fieldtype": "Section Break", - "label": "Processes" + "label": "Parent" }, { "fieldname": "processes", @@ -68,12 +70,52 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Quality Procedure", - "reqd": 1 + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "process_owner", + "fieldtype": "Link", + "label": "Process Owner", + "options": "User" + }, + { + "fieldname": "section_break_3", + "fieldtype": "Section Break" + }, + { + "fetch_from": "process_owner.full_name", + "fieldname": "process_owner_full_name", + "fieldtype": "Data", + "hidden": 1, + "label": "Process Owner Full Name", + "print_hide": 1 } ], "is_tree": 1, - "links": [], - "modified": "2020-06-17 17:25:03.434953", + "links": [ + { + "group": "Reviews", + "link_doctype": "Quality Review", + "link_fieldname": "procedure" + }, + { + "group": "Goals", + "link_doctype": "Quality Goal", + "link_fieldname": "procedure" + }, + { + "group": "Actions", + "link_doctype": "Quality Action", + "link_fieldname": "procedure" + }, + { + "group": "Actions", + "link_doctype": "Non Conformance", + "link_fieldname": "procedure" + } + ], + "modified": "2020-10-26 15:25:39.316088", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Procedure", diff --git a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.py b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.py index 1952e578673..53f4e6c70fe 100644 --- a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.py +++ b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import frappe -from frappe.utils.nestedset import NestedSet +from frappe.utils.nestedset import NestedSet, rebuild_tree from frappe import _ class QualityProcedure(NestedSet): @@ -14,67 +14,58 @@ class QualityProcedure(NestedSet): self.check_for_incorrect_child() def on_update(self): + NestedSet.on_update(self) self.set_parent() def after_insert(self): self.set_parent() - #if Child is Added through Tree View. + + # add child to parent if missing if self.parent_quality_procedure: - parent_quality_procedure = frappe.get_doc("Quality Procedure", self.parent_quality_procedure) - parent_quality_procedure.append("processes", {"procedure": self.name}) - parent_quality_procedure.save() + parent = frappe.get_doc("Quality Procedure", self.parent_quality_procedure) + if not [d for d in parent.processes if d.procedure == self.name]: + parent.append("processes", {"procedure": self.name, "process_description": self.name}) + parent.save() def on_trash(self): - if self.parent_quality_procedure: - doc = frappe.get_doc("Quality Procedure", self.parent_quality_procedure) - for process in doc.processes: - if process.procedure == self.name: - doc.processes.remove(process) - doc.save(ignore_permissions=True) - - flag_is_group = 0 - doc.load_from_db() - - for process in doc.processes: - flag_is_group = 1 if process.procedure else 0 - - doc.is_group = 0 if flag_is_group == 0 else 1 - doc.save(ignore_permissions=True) + # clear from child table (sub procedures) + frappe.db.sql('''update `tabQuality Procedure Process` + set `procedure`='' where `procedure`=%s''', self.name) + NestedSet.on_trash(self, allow_root_deletion=True) def set_parent(self): for process in self.processes: # Set parent for only those children who don't have a parent - parent_quality_procedure = frappe.db.get_value("Quality Procedure", process.procedure, "parent_quality_procedure") - if not parent_quality_procedure and process.procedure: + has_parent = frappe.db.get_value("Quality Procedure", process.procedure, "parent_quality_procedure") + if not has_parent and process.procedure: frappe.db.set_value(self.doctype, process.procedure, "parent_quality_procedure", self.name) def check_for_incorrect_child(self): for process in self.processes: if process.procedure: + self.is_group = 1 # Check if any child process belongs to another parent. parent_quality_procedure = frappe.db.get_value("Quality Procedure", process.procedure, "parent_quality_procedure") if parent_quality_procedure and parent_quality_procedure != self.name: - frappe.throw(_("{0} already has a Parent Procedure {1}.".format(frappe.bold(process.procedure), frappe.bold(parent_quality_procedure))), + frappe.throw(_("{0} already has a Parent Procedure {1}.").format(frappe.bold(process.procedure), frappe.bold(parent_quality_procedure)), title=_("Invalid Child Procedure")) - self.is_group = 1 @frappe.whitelist() def get_children(doctype, parent=None, parent_quality_procedure=None, is_root=False): if parent is None or parent == "All Quality Procedures": parent = "" - return frappe.db.sql(""" - select - name as value, - is_group as expandable - from - `tab{doctype}` - where - ifnull(parent_quality_procedure, "")={parent} - """.format( - doctype = doctype, - parent=frappe.db.escape(parent) - ), as_dict=1) + if parent: + parent_procedure = frappe.get_doc('Quality Procedure', parent) + # return the list in order + return [dict( + value=d.procedure, + expandable=frappe.db.get_value('Quality Procedure', d.procedure, 'is_group')) + for d in parent_procedure.processes if d.procedure + ] + else: + return frappe.get_all(doctype, fields=['name as value', 'is_group as expandable'], + filters = dict(parent_quality_procedure = parent), order_by='name asc') @frappe.whitelist() def add_node(): @@ -86,4 +77,4 @@ def add_node(): if args.parent_quality_procedure == 'All Quality Procedures': args.parent_quality_procedure = None - frappe.get_doc(args).insert() \ No newline at end of file + return frappe.get_doc(args).insert() \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_procedure/quality_procedure_dashboard.py b/erpnext/quality_management/doctype/quality_procedure/quality_procedure_dashboard.py deleted file mode 100644 index 407028bb822..00000000000 --- a/erpnext/quality_management/doctype/quality_procedure/quality_procedure_dashboard.py +++ /dev/null @@ -1,20 +0,0 @@ -from frappe import _ - -def get_data(): - return { - 'fieldname': 'procedure', - 'transactions': [ - { - 'label': _('Goal'), - 'items': ['Quality Goal'] - }, - { - 'label': _('Review'), - 'items': ['Quality Review'] - }, - { - 'label': _('Action'), - 'items': ['Quality Action'] - } - ], - } \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_procedure/quality_procedure_tree.js b/erpnext/quality_management/doctype/quality_procedure/quality_procedure_tree.js index ef48ab6c6e2..eeb4cf617c3 100644 --- a/erpnext/quality_management/doctype/quality_procedure/quality_procedure_tree.js +++ b/erpnext/quality_management/doctype/quality_procedure/quality_procedure_tree.js @@ -15,7 +15,7 @@ frappe.treeview_settings["Quality Procedure"] = { } }, ], - breadcrumb: "Setup", + breadcrumb: "Quality Management", disable_add_node: true, root_label: "All Quality Procedures", get_tree_root: false, diff --git a/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.js b/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.js deleted file mode 100644 index 0a187ebfb72..00000000000 --- a/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Quality Procedure", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially([ - // insert a new Quality Procedure - () => frappe.tests.make('Quality Procedure', [ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.py b/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.py index 3289bb5a371..36bdf26acf5 100644 --- a/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.py +++ b/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.py @@ -6,54 +6,45 @@ from __future__ import unicode_literals import frappe import unittest -class TestQualityProcedure(unittest.TestCase): - def test_quality_procedure(self): - test_create_procedure = create_procedure() - test_create_nested_procedure = create_nested_procedure() - test_get_procedure, test_get_nested_procedure = get_procedure() +from .quality_procedure import add_node - self.assertEquals(test_create_procedure, test_get_procedure.get("name")) - self.assertEquals(test_create_nested_procedure, test_get_nested_procedure.get("name")) +class TestQualityProcedure(unittest.TestCase): + def test_add_node(self): + try: + procedure = frappe.get_doc(dict( + doctype = 'Quality Procedure', + quality_procedure_name = 'Test Procedure 1', + processes = [ + dict(process_description = 'Test Step 1') + ] + )).insert() + + frappe.form_dict = dict(doctype = 'Quality Procedure', quality_procedure_name = 'Test Child 1', + parent_quality_procedure = procedure.name, cmd='test', is_root='false') + node = add_node() + + procedure.reload() + + self.assertEqual(procedure.is_group, 1) + + # child row created + self.assertTrue([d for d in procedure.processes if d.procedure == node.name]) + + node.delete() + procedure.reload() + + # child unset + self.assertFalse([d for d in procedure.processes if d.name == node.name]) + + finally: + procedure.delete() def create_procedure(): - procedure = frappe.get_doc({ - "doctype": "Quality Procedure", - "quality_procedure_name": "_Test Quality Procedure", - "processes": [ - { - "process_description": "_Test Quality Procedure Table", - } + return frappe.get_doc(dict( + doctype = 'Quality Procedure', + quality_procedure_name = 'Test Procedure 1', + is_group = 1, + processes = [ + dict(process_description = 'Test Step 1') ] - }) - - procedure_exist = frappe.db.exists("Quality Procedure", "PRC-_Test Quality Procedure") - - if not procedure_exist: - procedure.insert() - return procedure.name - else: - return procedure_exist - -def create_nested_procedure(): - nested_procedure = frappe.get_doc({ - "doctype": "Quality Procedure", - "quality_procedure_name": "_Test Nested Quality Procedure", - "processes": [ - { - "procedure": "PRC-_Test Quality Procedure" - } - ] - }) - - nested_procedure_exist = frappe.db.exists("Quality Procedure", "PRC-_Test Nested Quality Procedure") - - if not nested_procedure_exist: - nested_procedure.insert() - return nested_procedure.name - else: - return nested_procedure_exist - -def get_procedure(): - procedure = frappe.get_doc("Quality Procedure", "PRC-_Test Quality Procedure") - nested_procedure = frappe.get_doc("Quality Procedure", "PRC-_Test Nested Quality Procedure") - return {"name": procedure.name}, {"name": nested_procedure.name, "parent_quality_procedure": nested_procedure.parent_quality_procedure} \ No newline at end of file + )).insert() \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_procedure_process/quality_procedure_process.json b/erpnext/quality_management/doctype/quality_procedure_process/quality_procedure_process.json index 3925dbb8aca..aeca6ff47a2 100644 --- a/erpnext/quality_management/doctype/quality_procedure_process/quality_procedure_process.json +++ b/erpnext/quality_management/doctype/quality_procedure_process/quality_procedure_process.json @@ -10,6 +10,7 @@ ], "fields": [ { + "columns": 8, "fieldname": "process_description", "fieldtype": "Text Editor", "in_list_view": 1, @@ -20,13 +21,14 @@ "fieldname": "procedure", "fieldtype": "Link", "in_list_view": 1, - "label": "Child Procedure", + "label": "Sub Procedure", "options": "Quality Procedure" } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-06-17 15:44:38.937915", + "modified": "2020-10-27 13:55:11.252945", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Procedure Process", diff --git a/erpnext/quality_management/doctype/quality_review/quality_review.js b/erpnext/quality_management/doctype/quality_review/quality_review.js index b6245818f5c..67371bfc5c6 100644 --- a/erpnext/quality_management/doctype/quality_review/quality_review.js +++ b/erpnext/quality_management/doctype/quality_review/quality_review.js @@ -2,9 +2,6 @@ // For license information, please see license.txt frappe.ui.form.on('Quality Review', { - onload: function(frm){ - frm.set_value("date", frappe.datetime.get_today()); - }, goal: function(frm) { frappe.call({ "method": "frappe.client.get", diff --git a/erpnext/quality_management/doctype/quality_review/quality_review.json b/erpnext/quality_management/doctype/quality_review/quality_review.json index 76714ced81b..31ad3413627 100644 --- a/erpnext/quality_management/doctype/quality_review/quality_review.json +++ b/erpnext/quality_management/doctype/quality_review/quality_review.json @@ -1,6 +1,6 @@ { "actions": [], - "autoname": "format:REV-{#####}", + "autoname": "format:QA-REV-{#####}", "creation": "2018-10-02 11:45:16.301955", "doctype": "DocType", "editable_grid": 1, @@ -18,6 +18,7 @@ ], "fields": [ { + "default": "Today", "fieldname": "date", "fieldtype": "Date", "in_list_view": 1, @@ -50,7 +51,7 @@ "collapsible": 1, "fieldname": "sb_01", "fieldtype": "Section Break", - "label": "Additional Information" + "label": "Notes" }, { "fieldname": "reviews", @@ -63,7 +64,8 @@ "fieldname": "status", "fieldtype": "Select", "label": "Status", - "options": "Open\nClosed" + "options": "Open\nPassed\nFailed", + "read_only": 1 }, { "fieldname": "goal", @@ -74,8 +76,15 @@ "reqd": 1 } ], - "links": [], - "modified": "2020-02-01 10:59:38.933115", + "index_web_pages_for_search": 1, + "links": [ + { + "group": "Review", + "link_doctype": "Quality Action", + "link_fieldname": "review" + } + ], + "modified": "2020-10-21 12:56:47.046172", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Review", @@ -120,5 +129,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "title_field": "goal", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_review/quality_review.py b/erpnext/quality_management/doctype/quality_review/quality_review.py index 2bc8867ef7c..e3a8b073f0f 100644 --- a/erpnext/quality_management/doctype/quality_review/quality_review.py +++ b/erpnext/quality_management/doctype/quality_review/quality_review.py @@ -7,7 +7,26 @@ import frappe from frappe.model.document import Document class QualityReview(Document): - pass + def validate(self): + # fetch targets from goal + if not self.reviews: + for d in frappe.get_doc('Quality Goal', self.goal).objectives: + self.append('reviews', dict( + objective = d.objective, + target = d.target, + uom = d.uom + )) + + self.set_status() + + def set_status(self): + # if any child item is failed, fail the parent + if not len(self.reviews or []) or any([d.status=='Open' for d in self.reviews]): + self.status = 'Open' + elif any([d.status=='Failed' for d in self.reviews]): + self.status = 'Failed' + else: + self.status = 'Passed' def review(): day = frappe.utils.getdate().day @@ -24,7 +43,7 @@ def review(): elif goal.frequency == 'Monthly' and goal.date == str(day): create_review(goal.name) - elif goal.frequency == 'Quarterly' and goal.data == str(day) and get_quarter(month): + elif goal.frequency == 'Quarterly' and day==1 and get_quarter(month): create_review(goal.name) def create_review(goal): @@ -36,15 +55,6 @@ def create_review(goal): "date": frappe.utils.getdate() }) - for objective in goal.objectives: - review.append("reviews", - { - "objective": objective.objective, - "target": objective.target, - "uom": objective.uom - } - ) - review.insert(ignore_permissions=True) def get_quarter(month): diff --git a/erpnext/quality_management/doctype/quality_review/test_quality_review.js b/erpnext/quality_management/doctype/quality_review/test_quality_review.js deleted file mode 100644 index cf910b27afa..00000000000 --- a/erpnext/quality_management/doctype/quality_review/test_quality_review.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Performance Monitoring", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially([ - // insert a new Performance Monitoring - () => frappe.tests.make('Performance Monitoring', [ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/erpnext/quality_management/doctype/quality_review/test_quality_review.py b/erpnext/quality_management/doctype/quality_review/test_quality_review.py index 8add6db9c9d..a7d92da8ace 100644 --- a/erpnext/quality_management/doctype/quality_review/test_quality_review.py +++ b/erpnext/quality_management/doctype/quality_review/test_quality_review.py @@ -5,42 +5,18 @@ from __future__ import unicode_literals import frappe import unittest -from erpnext.quality_management.doctype.quality_procedure.test_quality_procedure import create_procedure -from erpnext.quality_management.doctype.quality_goal.test_quality_goal import create_unit -from erpnext.quality_management.doctype.quality_goal.test_quality_goal import create_goal + +from ..quality_goal.test_quality_goal import get_quality_goal +from .quality_review import review class TestQualityReview(unittest.TestCase): + def test_review_creation(self): + quality_goal = get_quality_goal() + review() - def test_quality_review(self): - create_procedure() - create_unit() - create_goal() - test_create_review = create_review() - test_get_review = get_review() - self.assertEquals(test_create_review, test_get_review) + # check if review exists + quality_review = frappe.get_doc('Quality Review', dict(goal = quality_goal.name)) + self.assertEqual(quality_goal.objectives[0].target, quality_review.reviews[0].target) + quality_review.delete() -def create_review(): - review = frappe.get_doc({ - "doctype": "Quality Review", - "goal": "GOAL-_Test Quality Goal", - "procedure": "PRC-_Test Quality Procedure", - "date": frappe.utils.nowdate(), - "reviews": [ - { - "objective": "_Test Quality Objective", - "target": "100", - "uom": "_Test UOM", - "review": "Test Review" - } - ] - }) - review_exist = frappe.db.exists("Quality Review", {"goal": "GOAL-_Test Quality Goal"}) - if not review_exist: - review.insert(ignore_permissions=True) - return review.name - else: - return review_exist - -def get_review(): - review = frappe.db.exists("Quality Review", {"goal": "GOAL-_Test Quality Goal"}) - return review \ No newline at end of file + quality_goal.delete() \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_review_objective/quality_review_objective.json b/erpnext/quality_management/doctype/quality_review_objective/quality_review_objective.json index 91f7bc07c73..3a750c21d6e 100644 --- a/erpnext/quality_management/doctype/quality_review_objective/quality_review_objective.json +++ b/erpnext/quality_management/doctype/quality_review_objective/quality_review_objective.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2019-05-26 15:17:44.796958", "doctype": "DocType", "editable_grid": 1, @@ -9,10 +10,12 @@ "target", "uom", "sb_00", + "status", "review" ], "fields": [ { + "columns": 3, "fieldname": "objective", "fieldtype": "Text", "in_list_view": 1, @@ -20,6 +23,7 @@ "read_only": 1 }, { + "columns": 2, "fieldname": "target", "fieldtype": "Data", "in_list_view": 1, @@ -27,6 +31,7 @@ "read_only": 1 }, { + "columns": 1, "fetch_from": "target_unit", "fieldname": "uom", "fieldtype": "Link", @@ -49,10 +54,20 @@ { "fieldname": "cb_00", "fieldtype": "Column Break" + }, + { + "columns": 2, + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Open\nPassed\nFailed" } ], + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2019-05-26 16:14:12.586128", + "links": [], + "modified": "2020-10-27 16:28:20.908637", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Review Objective", diff --git a/erpnext/quality_management/workspace/quality/quality.json b/erpnext/quality_management/workspace/quality/quality.json new file mode 100644 index 00000000000..e5fef435505 --- /dev/null +++ b/erpnext/quality_management/workspace/quality/quality.json @@ -0,0 +1,190 @@ +{ + "category": "Modules", + "charts": [], + "creation": "2020-03-02 15:49:28.632014", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "quality", + "idx": 0, + "is_standard": 1, + "label": "Quality", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Goal and Procedure", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quality Goal", + "link_to": "Quality Goal", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quality Procedure", + "link_to": "Quality Procedure", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Tree of Procedures", + "link_to": "Quality Procedure", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Feedback", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quality Feedback", + "link_to": "Quality Feedback", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quality Feedback Template", + "link_to": "Quality Feedback Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Meeting", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quality Meeting", + "link_to": "Quality Meeting", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Review and Action", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Non Conformance", + "link_to": "Non Conformance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quality Review", + "link_to": "Quality Review", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quality Action", + "link_to": "Quality Action", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:35.120213", + "modified_by": "Administrator", + "module": "Quality Management", + "name": "Quality", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "color": "Grey", + "label": "Quality Goal", + "link_to": "Quality Goal", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "Tree", + "label": "Quality Procedure", + "link_to": "Quality Procedure", + "type": "DocType" + }, + { + "color": "Grey", + "label": "Quality Inspection", + "link_to": "Quality Inspection", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "", + "format": "{} Open", + "label": "Quality Review", + "link_to": "Quality Review", + "stats_filter": "{\"status\": \"Open\"}", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "", + "format": "{} Open", + "label": "Quality Action", + "link_to": "Quality Action", + "stats_filter": "{\"status\": \"Open\"}", + "type": "DocType" + }, + { + "color": "Grey", + "doc_view": "", + "format": "{} Open", + "label": "Non Conformance", + "link_to": "Non Conformance", + "stats_filter": "{\"status\": \"Open\"}", + "type": "DocType" + } + ] +} \ No newline at end of file diff --git a/erpnext/regional/address_template/templates/luxembourg.html b/erpnext/regional/address_template/templates/luxembourg.html new file mode 100644 index 00000000000..75075e7f1a4 --- /dev/null +++ b/erpnext/regional/address_template/templates/luxembourg.html @@ -0,0 +1,4 @@ +{% if address_line1 %}{{ address_line1 }}
    {% endif -%} +{% if address_line2 %}{{ address_line2 }}
    {% endif -%} +{% if pincode %}L-{{ pincode }}{% endif -%}{% if city %} {{ city }}{% endif %}
    +{% if country %}{{ country | upper }}{% endif %} diff --git a/erpnext/regional/doctype/datev_settings/datev_settings.js b/erpnext/regional/doctype/datev_settings/datev_settings.js index 69747b0b89e..f04705929fc 100644 --- a/erpnext/regional/doctype/datev_settings/datev_settings.js +++ b/erpnext/regional/doctype/datev_settings/datev_settings.js @@ -2,7 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on('DATEV Settings', { - // refresh: function(frm) { - - // } + refresh: function(frm) { + frm.add_custom_button('Show Report', () => frappe.set_route('query-report', 'DATEV'), "fa fa-table"); + } }); diff --git a/erpnext/regional/doctype/datev_settings/datev_settings.json b/erpnext/regional/doctype/datev_settings/datev_settings.json index 39486dfc123..f60de4c8af4 100644 --- a/erpnext/regional/doctype/datev_settings/datev_settings.json +++ b/erpnext/regional/doctype/datev_settings/datev_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "field:client", "creation": "2019-08-13 23:56:34.259906", "doctype": "DocType", @@ -6,12 +7,14 @@ "engine": "InnoDB", "field_order": [ "client", - "column_break_2", "client_number", - "section_break_4", + "column_break_2", + "consultant_number", "consultant", + "section_break_4", + "account_number_length", "column_break_6", - "consultant_number" + "temporary_against_account_number" ], "fields": [ { @@ -28,8 +31,8 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Client ID", - "reqd": 1, - "length": 5 + "length": 5, + "reqd": 1 }, { "fieldname": "consultant", @@ -43,8 +46,8 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Consultant ID", - "reqd": 1, - "length": 7 + "length": 7, + "reqd": 1 }, { "fieldname": "column_break_2", @@ -57,9 +60,24 @@ { "fieldname": "column_break_6", "fieldtype": "Column Break" + }, + { + "default": "4", + "fieldname": "account_number_length", + "fieldtype": "Int", + "label": "Account Number Length", + "reqd": 1 + }, + { + "allow_in_quick_entry": 1, + "fieldname": "temporary_against_account_number", + "fieldtype": "Data", + "label": "Temporary Against Account Number", + "reqd": 1 } ], - "modified": "2019-08-14 00:03:26.616460", + "links": [], + "modified": "2020-11-19 19:00:09.088816", "modified_by": "Administrator", "module": "Regional", "name": "DATEV Settings", @@ -104,4 +122,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/regional/doctype/e_invoice_request_log/__init__.py b/erpnext/regional/doctype/e_invoice_request_log/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js new file mode 100644 index 00000000000..7b7ba964e5e --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('E Invoice Request Log', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json new file mode 100644 index 00000000000..3034370feac --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.json @@ -0,0 +1,102 @@ +{ + "actions": [], + "autoname": "EINV-REQ-.#####", + "creation": "2020-12-08 12:54:08.175992", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "url", + "headers", + "response", + "column_break_7", + "timestamp", + "reference_invoice", + "data" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User" + }, + { + "fieldname": "reference_invoice", + "fieldtype": "Data", + "label": "Reference Invoice" + }, + { + "fieldname": "headers", + "fieldtype": "Code", + "label": "Headers", + "options": "JSON" + }, + { + "fieldname": "data", + "fieldtype": "Code", + "label": "Data", + "options": "JSON" + }, + { + "default": "Now", + "fieldname": "timestamp", + "fieldtype": "Datetime", + "label": "Timestamp" + }, + { + "fieldname": "response", + "fieldtype": "Code", + "label": "Response", + "options": "JSON" + }, + { + "fieldname": "url", + "fieldtype": "Data", + "label": "URL" + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-01-13 12:06:57.253111", + "modified_by": "Administrator", + "module": "Regional", + "name": "E Invoice Request Log", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py new file mode 100644 index 00000000000..9150bdd9260 --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_request_log/e_invoice_request_log.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class EInvoiceRequestLog(Document): + pass diff --git a/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py b/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py new file mode 100644 index 00000000000..c84e9a249bd --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_request_log/test_e_invoice_request_log.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestEInvoiceRequestLog(unittest.TestCase): + pass diff --git a/erpnext/regional/doctype/e_invoice_settings/__init__.py b/erpnext/regional/doctype/e_invoice_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js new file mode 100644 index 00000000000..cc2d9f06d2d --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.js @@ -0,0 +1,11 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('E Invoice Settings', { + refresh(frm) { + const docs_link = 'https://docs.erpnext.com/docs/user/manual/en/regional/india/setup-e-invoicing'; + frm.dashboard.set_headline( + __("Read {0} for more information on E Invoicing features.", [`documentation`]) + ); + } +}); diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json new file mode 100644 index 00000000000..db8bda75bfd --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "creation": "2020-09-24 16:23:16.235722", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enable", + "section_break_2", + "sandbox_mode", + "credentials", + "auth_token", + "token_expiry" + ], + "fields": [ + { + "default": "0", + "fieldname": "enable", + "fieldtype": "Check", + "label": "Enable" + }, + { + "depends_on": "enable", + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "fieldname": "auth_token", + "fieldtype": "Data", + "hidden": 1, + "read_only": 1 + }, + { + "fieldname": "token_expiry", + "fieldtype": "Datetime", + "hidden": 1, + "read_only": 1 + }, + { + "fieldname": "credentials", + "fieldtype": "Table", + "label": "Credentials", + "mandatory_depends_on": "enable", + "options": "E Invoice User" + }, + { + "default": "0", + "fieldname": "sandbox_mode", + "fieldtype": "Check", + "label": "Sandbox Mode" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2021-01-13 12:04:49.449199", + "modified_by": "Administrator", + "module": "Regional", + "name": "E Invoice Settings", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py new file mode 100644 index 00000000000..c24ad886ea1 --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_settings/e_invoice_settings.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt +from __future__ import unicode_literals + +import frappe +from frappe import _ +from frappe.model.document import Document + +class EInvoiceSettings(Document): + def validate(self): + if self.enable and not self.credentials: + frappe.throw(_('You must add atleast one credentials to be able to use E Invoicing.')) + diff --git a/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py b/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py new file mode 100644 index 00000000000..a11ce63ee6c --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_settings/test_e_invoice_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestEInvoiceSettings(unittest.TestCase): + pass diff --git a/erpnext/regional/doctype/e_invoice_user/__init__.py b/erpnext/regional/doctype/e_invoice_user/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json new file mode 100644 index 00000000000..dd9d99773a3 --- /dev/null +++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.json @@ -0,0 +1,48 @@ +{ + "actions": [], + "creation": "2020-12-22 15:02:46.229474", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gstin", + "username", + "password" + ], + "fields": [ + { + "fieldname": "gstin", + "fieldtype": "Data", + "in_list_view": 1, + "label": "GSTIN", + "reqd": 1 + }, + { + "fieldname": "username", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Username", + "reqd": 1 + }, + { + "fieldname": "password", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Password", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-12-22 15:10:53.466205", + "modified_by": "Administrator", + "module": "Regional", + "name": "E Invoice User", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/bank_statement_settings_item/bank_statement_settings_item.py b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.py similarity index 58% rename from erpnext/accounts/doctype/bank_statement_settings_item/bank_statement_settings_item.py rename to erpnext/regional/doctype/e_invoice_user/e_invoice_user.py index 9438e9a63f0..056c54f069d 100644 --- a/erpnext/accounts/doctype/bank_statement_settings_item/bank_statement_settings_item.py +++ b/erpnext/regional/doctype/e_invoice_user/e_invoice_user.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2018, sathishpy@gmail.com and contributors +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt from __future__ import unicode_literals -import frappe +# import frappe from frappe.model.document import Document -class BankStatementSettingsItem(Document): +class EInvoiceUser(Document): pass diff --git a/erpnext/regional/doctype/gst_settings/gst_settings.json b/erpnext/regional/doctype/gst_settings/gst_settings.json index 98c33ad33bb..95b930c4c86 100644 --- a/erpnext/regional/doctype/gst_settings/gst_settings.json +++ b/erpnext/regional/doctype/gst_settings/gst_settings.json @@ -1,222 +1,86 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2017-06-27 15:09:01.318003", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2017-06-27 15:09:01.318003", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gst_summary", + "column_break_2", + "round_off_gst_values", + "gstin_email_sent_on", + "section_break_4", + "gst_accounts", + "b2c_limit" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "gst_summary", - "fieldtype": "HTML", - "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": "GST Summary", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "gst_summary", + "fieldtype": "HTML", + "label": "GST Summary", + "show_days": 1, + "show_seconds": 1 + }, { - "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, - "unique": 0 - }, + "fieldname": "column_break_2", + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "gstin_email_sent_on", - "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": "GSTIN Email Sent On", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "gstin_email_sent_on", + "fieldtype": "Date", + "label": "GSTIN Email Sent On", + "read_only": 1, + "show_days": 1, + "show_seconds": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_4", - "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, - "unique": 0 - }, + "fieldname": "section_break_4", + "fieldtype": "Section Break", + "show_days": 1, + "show_seconds": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "gst_accounts", - "fieldtype": "Table", - "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": "GST Accounts", - "length": 0, - "no_copy": 0, - "options": "GST Account", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "gst_accounts", + "fieldtype": "Table", + "label": "GST Accounts", + "options": "GST Account", + "show_days": 1, + "show_seconds": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "250000", - "description": "Set Invoice Value for B2C. B2CL and B2CS calculated based on this invoice value.", - "fieldname": "b2c_limit", - "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": "B2C Limit", - "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, - "unique": 0 + "default": "250000", + "description": "Set Invoice Value for B2C. B2CL and B2CS calculated based on this invoice value.", + "fieldname": "b2c_limit", + "fieldtype": "Data", + "in_list_view": 1, + "label": "B2C Limit", + "reqd": 1, + "show_days": 1, + "show_seconds": 1 + }, + { + "default": "0", + "description": "Enabling this option will round off individual GST components in all the Invoices", + "fieldname": "round_off_gst_values", + "fieldtype": "Check", + "label": "Round Off GST Values", + "show_days": 1, + "show_seconds": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 1, - "istable": 0, - "max_attachments": 0, - "modified": "2018-02-14 08:14:15.375181", - "modified_by": "Administrator", - "module": "Regional", - "name": "GST Settings", - "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": 1, - "track_seen": 0 -} \ No newline at end of file + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2021-01-28 17:19:47.969260", + "modified_by": "Administrator", + "module": "Regional", + "name": "GST Settings", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 + } \ No newline at end of file diff --git a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py index 787d557e805..a49996d107e 100644 --- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py @@ -192,19 +192,20 @@ class GSTR3BReport(Document): for d in self.report_dict["itc_elg"]["itc_avl"]: itc_type = itc_type_map.get(d["ty"]) - gst_category = ["Registered Regular"] if d["ty"] == 'ISRC': - reverse_charge = "Y" + reverse_charge = ["Y"] itc_type = 'All Other ITC' gst_category = ['Unregistered', 'Overseas'] else: - reverse_charge = "N" + gst_category = ['Unregistered', 'Overseas', 'Registered Regular'] + reverse_charge = ["N", "Y"] for account_head in self.account_heads: for category in gst_category: - for key in [['iamt', 'igst_account'], ['camt', 'cgst_account'], ['samt', 'sgst_account'], ['csamt', 'cess_account']]: - d[key[0]] += flt(itc_details.get((category, itc_type, reverse_charge, account_head.get(key[1])), {}).get("amount"), 2) + for charge_type in reverse_charge: + for key in [['iamt', 'igst_account'], ['camt', 'cgst_account'], ['samt', 'sgst_account'], ['csamt', 'cess_account']]: + d[key[0]] += flt(itc_details.get((category, itc_type, charge_type, account_head.get(key[1])), {}).get("amount"), 2) for key in ['iamt', 'camt', 'samt', 'csamt']: net_itc[key] += flt(d[key], 2) @@ -264,7 +265,8 @@ class GSTR3BReport(Document): def get_itc_details(self): itc_amount = frappe.db.sql(""" - select s.gst_category, sum(t.tax_amount_after_discount_amount) as tax_amount, t.account_head, s.eligibility_for_itc, s.reverse_charge + select s.gst_category, sum(t.base_tax_amount_after_discount_amount) as tax_amount, + t.account_head, s.eligibility_for_itc, s.reverse_charge from `tabPurchase Invoice` s , `tabPurchase Taxes and Charges` t where s.docstatus = 1 and t.parent = s.name and month(s.posting_date) = %s and year(s.posting_date) = %s and s.company = %s @@ -347,13 +349,12 @@ class GSTR3BReport(Document): return inter_state_supply_details def get_inward_nil_exempt(self, state): - inward_nil_exempt = frappe.db.sql(""" select p.place_of_supply, sum(i.base_amount) as base_amount, i.is_nil_exempt, i.is_non_gst from `tabPurchase Invoice` p , `tabPurchase Invoice Item` i where p.docstatus = 1 and p.name = i.parent - and i.is_nil_exempt = 1 or i.is_non_gst = 1 and + and (i.is_nil_exempt = 1 or i.is_non_gst = 1) and month(p.posting_date) = %s and year(p.posting_date) = %s and p.company = %s and p.company_gstin = %s - group by p.place_of_supply """, (self.month_no, self.year, self.company, self.gst_details.get("gstin")), as_dict=1) + group by p.place_of_supply, i.is_nil_exempt, i.is_non_gst""", (self.month_no, self.year, self.company, self.gst_details.get("gstin")), as_dict=1) inward_nil_exempt_details = { "gst": { @@ -387,7 +388,7 @@ class GSTR3BReport(Document): tax_template = 'Purchase Taxes and Charges' tax_amounts = frappe.db.sql(""" - select s.gst_category, sum(t.tax_amount_after_discount_amount) as tax_amount, t.account_head + select s.gst_category, sum(t.base_tax_amount_after_discount_amount) as tax_amount, t.account_head from `tab{doctype}` s , `tab{template}` t where s.docstatus = 1 and t.parent = s.name and s.reverse_charge = %s and month(s.posting_date) = %s and year(s.posting_date) = %s and s.company = %s diff --git a/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py index 8174da20cb7..023b4ed22bc 100644 --- a/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py @@ -14,8 +14,20 @@ import json test_dependencies = ["Territory", "Customer Group", "Supplier Group", "Item"] class TestGSTR3BReport(unittest.TestCase): - def test_gstr_3b_report(self): + def setUp(self): + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company GST'") + frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company GST'") + frappe.db.sql("delete from `tabGSTR 3B Report` where company='_Test Company GST'") + + make_company() + make_item("Milk", properties = {"is_nil_exempt": 1, "standard_rate": 0.000000}) + set_account_heads() + make_customers() + make_suppliers() + + def test_gstr_3b_report(self): month_number_mapping = { 1: "January", 2: "February", @@ -31,17 +43,6 @@ class TestGSTR3BReport(unittest.TestCase): 12: "December" } - frappe.set_user("Administrator") - - frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company GST'") - frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company GST'") - frappe.db.sql("delete from `tabGSTR 3B Report` where company='_Test Company GST'") - - make_company() - make_item("Milk", properties = {"is_nil_exempt": 1, "standard_rate": 0.000000}) - set_account_heads() - make_customers() - make_suppliers() make_sales_invoice() create_purchase_invoices() @@ -67,6 +68,42 @@ class TestGSTR3BReport(unittest.TestCase): self.assertEqual(output["itc_elg"]["itc_avl"][4]["samt"], 22.50) self.assertEqual(output["itc_elg"]["itc_avl"][4]["camt"], 22.50) + def test_gst_rounding(self): + gst_settings = frappe.get_doc('GST Settings') + gst_settings.round_off_gst_values = 1 + gst_settings.save() + + current_country = frappe.flags.country + frappe.flags.country = 'India' + + si = create_sales_invoice(company="_Test Company GST", + customer = '_Test GST Customer', + currency = 'INR', + warehouse = 'Finished Goods - _GST', + debit_to = 'Debtors - _GST', + income_account = 'Sales - _GST', + expense_account = 'Cost of Goods Sold - _GST', + cost_center = 'Main - _GST', + rate=216, + do_not_save=1 + ) + + si.append("taxes", { + "charge_type": "On Net Total", + "account_head": "IGST - _GST", + "cost_center": "Main - _GST", + "description": "IGST @ 18.0", + "rate": 18 + }) + + si.save() + # Check for 39 instead of 38.88 + self.assertEqual(si.taxes[0].base_tax_amount_after_discount_amount, 39) + + frappe.flags.country = current_country + gst_settings.round_off_gst_values = 1 + gst_settings.save() + def make_sales_invoice(): si = create_sales_invoice(company="_Test Company GST", customer = '_Test GST Customer', @@ -145,7 +182,6 @@ def make_sales_invoice(): si3.submit() def create_purchase_invoices(): - pi = make_purchase_invoice( company="_Test Company GST", supplier = '_Test Registered Supplier', @@ -193,7 +229,6 @@ def create_purchase_invoices(): pi1.submit() def make_suppliers(): - if not frappe.db.exists("Supplier", "_Test Registered Supplier"): frappe.get_doc({ "supplier_group": "_Test Supplier Group", @@ -257,7 +292,6 @@ def make_suppliers(): address.save() def make_customers(): - if not frappe.db.exists("Customer", "_Test GST Customer"): frappe.get_doc({ "customer_group": "_Test Customer Group", @@ -354,9 +388,9 @@ def make_customers(): address.save() def make_company(): - if frappe.db.exists("Company", "_Test Company GST"): return + company = frappe.new_doc("Company") company.company_name = "_Test Company GST" company.abbr = "_GST" @@ -388,7 +422,6 @@ def make_company(): address.save() def set_account_heads(): - gst_settings = frappe.get_doc("GST Settings") gst_account = frappe.get_all( diff --git a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py index e8a8ed87505..ad60db05595 100644 --- a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py +++ b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py @@ -5,12 +5,16 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import getdate +from frappe.utils import getdate, get_link_to_form from frappe.model.document import Document from erpnext.accounts.utils import get_fiscal_year class LowerDeductionCertificate(Document): def validate(self): + self.validate_dates() + self.validate_supplier_against_section_code() + + def validate_dates(self): if getdate(self.valid_upto) < getdate(self.valid_from): frappe.throw(_("Valid Upto date cannot be before Valid From date")) @@ -24,3 +28,20 @@ class LowerDeductionCertificate(Document): <= fiscal_year.year_end_date): frappe.throw(_("Valid Upto date not in Fiscal Year {0}").format(frappe.bold(self.fiscal_year))) + def validate_supplier_against_section_code(self): + duplicate_certificate = frappe.db.get_value('Lower Deduction Certificate', {'supplier': self.supplier, 'section_code': self.section_code}, ['name', 'valid_from', 'valid_upto'], as_dict=True) + if duplicate_certificate and self.are_dates_overlapping(duplicate_certificate): + certificate_link = get_link_to_form('Lower Deduction Certificate', duplicate_certificate.name) + frappe.throw(_("There is already a valid Lower Deduction Certificate {0} for Supplier {1} against Section Code {2} for this time period.") + .format(certificate_link, frappe.bold(self.supplier), frappe.bold(self.section_code))) + + def are_dates_overlapping(self,duplicate_certificate): + valid_from = duplicate_certificate.valid_from + valid_upto = duplicate_certificate.valid_upto + if valid_from <= getdate(self.valid_from) <= valid_upto: + return True + elif valid_from <= getdate(self.valid_upto) <= valid_upto: + return True + elif getdate(self.valid_from) <= valid_from and valid_upto <= getdate(self.valid_upto): + return True + return False \ No newline at end of file diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate/__init__.py b/erpnext/regional/doctype/tax_exemption_80g_certificate/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.js b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.js new file mode 100644 index 00000000000..54cde9c0cf4 --- /dev/null +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.js @@ -0,0 +1,67 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Tax Exemption 80G Certificate', { + refresh: function(frm) { + if (frm.doc.donor) { + frm.set_query('donation', function() { + return { + filters: { + docstatus: 1, + donor: frm.doc.donor + } + }; + }); + } + }, + + recipient: function(frm) { + if (frm.doc.recipient === 'Donor') { + frm.set_value({ + 'member': '', + 'member_name': '', + 'member_email': '', + 'member_pan_number': '', + 'fiscal_year': '', + 'total': 0, + 'payments': [] + }); + } else { + frm.set_value({ + 'donor': '', + 'donor_name': '', + 'donor_email': '', + 'donor_pan_number': '', + 'donation': '', + 'date_of_donation': '', + 'amount': 0, + 'mode_of_payment': '', + 'razorpay_payment_id': '' + }); + } + }, + + get_payments: function(frm) { + frm.call({ + doc: frm.doc, + method: 'get_payments', + freeze: true + }); + }, + + company: function(frm) { + if ((frm.doc.member || frm.doc.donor) && frm.doc.company) { + frm.call({ + doc: frm.doc, + method: 'set_company_address', + freeze: true + }); + } + }, + + donation: function(frm) { + if (frm.doc.recipient === 'Donor' && !frm.doc.donor) { + frappe.msgprint(__('Please select donor first')); + } + } +}); diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.json b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.json new file mode 100644 index 00000000000..9eee722f420 --- /dev/null +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.json @@ -0,0 +1,297 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2021-02-15 12:37:21.577042", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "recipient", + "member", + "member_name", + "member_email", + "member_pan_number", + "donor", + "donor_name", + "donor_email", + "donor_pan_number", + "column_break_4", + "date", + "fiscal_year", + "section_break_11", + "company", + "company_address", + "company_address_display", + "column_break_14", + "company_pan_number", + "company_80g_number", + "company_80g_wef", + "title", + "section_break_6", + "get_payments", + "payments", + "total", + "donation_details_section", + "donation", + "date_of_donation", + "amount", + "column_break_27", + "mode_of_payment", + "razorpay_payment_id" + ], + "fields": [ + { + "fieldname": "recipient", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Certificate Recipient", + "options": "Member\nDonor", + "reqd": 1 + }, + { + "depends_on": "eval:doc.recipient === \"Member\";", + "fieldname": "member", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Member", + "mandatory_depends_on": "eval:doc.recipient === \"Member\";", + "options": "Member" + }, + { + "depends_on": "eval:doc.recipient === \"Member\";", + "fetch_from": "member.member_name", + "fieldname": "member_name", + "fieldtype": "Data", + "label": "Member Name", + "read_only": 1 + }, + { + "depends_on": "eval:doc.recipient === \"Donor\";", + "fieldname": "donor", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Donor", + "mandatory_depends_on": "eval:doc.recipient === \"Donor\";", + "options": "Donor" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "date", + "fieldtype": "Date", + "label": "Date", + "reqd": 1 + }, + { + "depends_on": "eval:doc.recipient === \"Member\";", + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, + { + "fieldname": "payments", + "fieldtype": "Table", + "label": "Payments", + "options": "Tax Exemption 80G Certificate Detail" + }, + { + "fieldname": "total", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Total", + "read_only": 1 + }, + { + "depends_on": "eval:doc.recipient === \"Member\";", + "fieldname": "fiscal_year", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Fiscal Year", + "options": "Fiscal Year" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "get_payments", + "fieldtype": "Button", + "label": "Get Memberships" + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "NPO-80G-.YYYY.-" + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break", + "label": "Company Details" + }, + { + "fieldname": "company_address", + "fieldtype": "Link", + "label": "Company Address", + "options": "Address" + }, + { + "fieldname": "column_break_14", + "fieldtype": "Column Break" + }, + { + "fetch_from": "company.pan_details", + "fieldname": "company_pan_number", + "fieldtype": "Data", + "label": "PAN Number", + "read_only": 1 + }, + { + "fieldname": "company_address_display", + "fieldtype": "Small Text", + "hidden": 1, + "label": "Company Address Display", + "print_hide": 1, + "read_only": 1 + }, + { + "fetch_from": "company.company_80g_number", + "fieldname": "company_80g_number", + "fieldtype": "Data", + "label": "80G Number", + "read_only": 1 + }, + { + "fetch_from": "company.with_effect_from", + "fieldname": "company_80g_wef", + "fieldtype": "Date", + "label": "80G With Effect From", + "read_only": 1 + }, + { + "depends_on": "eval:doc.recipient === \"Donor\";", + "fieldname": "donation_details_section", + "fieldtype": "Section Break", + "label": "Donation Details" + }, + { + "fieldname": "donation", + "fieldtype": "Link", + "label": "Donation", + "mandatory_depends_on": "eval:doc.recipient === \"Donor\";", + "options": "Donation" + }, + { + "fetch_from": "donation.amount", + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "read_only": 1 + }, + { + "fetch_from": "donation.mode_of_payment", + "fieldname": "mode_of_payment", + "fieldtype": "Link", + "label": "Mode of Payment", + "options": "Mode of Payment", + "read_only": 1 + }, + { + "fetch_from": "donation.razorpay_payment_id", + "fieldname": "razorpay_payment_id", + "fieldtype": "Data", + "label": "RazorPay Payment ID", + "read_only": 1 + }, + { + "fetch_from": "donation.date", + "fieldname": "date_of_donation", + "fieldtype": "Date", + "label": "Date of Donation", + "read_only": 1 + }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.recipient === \"Donor\";", + "fetch_from": "donor.donor_name", + "fieldname": "donor_name", + "fieldtype": "Data", + "label": "Donor Name", + "read_only": 1 + }, + { + "depends_on": "eval:doc.recipient === \"Donor\";", + "fetch_from": "donor.email", + "fieldname": "donor_email", + "fieldtype": "Data", + "label": "Email", + "read_only": 1 + }, + { + "depends_on": "eval:doc.recipient === \"Member\";", + "fetch_from": "member.email_id", + "fieldname": "member_email", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Email", + "read_only": 1 + }, + { + "depends_on": "eval:doc.recipient === \"Member\";", + "fetch_from": "member.pan_number", + "fieldname": "member_pan_number", + "fieldtype": "Data", + "label": "PAN Details", + "read_only": 1 + }, + { + "depends_on": "eval:doc.recipient === \"Donor\";", + "fetch_from": "donor.pan_number", + "fieldname": "donor_pan_number", + "fieldtype": "Data", + "label": "PAN Details", + "read_only": 1 + }, + { + "fieldname": "title", + "fieldtype": "Data", + "hidden": 1, + "label": "Title", + "print_hide": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-02-22 00:03:34.215633", + "modified_by": "Administrator", + "module": "Regional", + "name": "Tax Exemption 80G Certificate", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "member, member_name", + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "title", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.py b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.py new file mode 100644 index 00000000000..41c7b231469 --- /dev/null +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate/tax_exemption_80g_certificate.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import getdate, flt, get_link_to_form +from erpnext.accounts.utils import get_fiscal_year +from frappe.contacts.doctype.address.address import get_company_address + +class TaxExemption80GCertificate(Document): + def validate(self): + self.validate_date() + self.validate_duplicates() + self.validate_company_details() + self.set_company_address() + self.calculate_total() + self.set_title() + + def validate_date(self): + if self.recipient == 'Member': + if getdate(self.date): + fiscal_year = get_fiscal_year(fiscal_year=self.fiscal_year, as_dict=True) + + if not (fiscal_year.year_start_date <= getdate(self.date) \ + <= fiscal_year.year_end_date): + frappe.throw(_('The Certificate Date is not in the Fiscal Year {0}').format(frappe.bold(self.fiscal_year))) + + def validate_duplicates(self): + if self.recipient == 'Donor': + certificate = frappe.db.exists(self.doctype, { + 'donation': self.donation, + 'name': ('!=', self.name) + }) + if certificate: + frappe.throw(_('An 80G Certificate {0} already exists for the donation {1}').format( + get_link_to_form(self.doctype, certificate), frappe.bold(self.donation) + ), title=_('Duplicate Certificate')) + + def validate_company_details(self): + fields = ['company_80g_number', 'with_effect_from', 'pan_details'] + company_details = frappe.db.get_value('Company', self.company, fields, as_dict=True) + if not company_details.company_80g_number: + frappe.throw(_('Please set the {0} for company {1}').format(frappe.bold('80G Number'), + get_link_to_form('Company', self.company))) + + if not company_details.pan_details: + frappe.throw(_('Please set the {0} for company {1}').format(frappe.bold('PAN Number'), + get_link_to_form('Company', self.company))) + + def set_company_address(self): + address = get_company_address(self.company) + self.company_address = address.company_address + self.company_address_display = address.company_address_display + + def calculate_total(self): + if self.recipient == 'Donor': + return + + total = 0 + for entry in self.payments: + total += flt(entry.amount) + self.total = total + + def set_title(self): + if self.recipient == 'Member': + self.title = self.member_name + else: + self.title = self.donor_name + + def get_payments(self): + if not self.member: + frappe.throw(_('Please select a Member first.')) + + fiscal_year = get_fiscal_year(fiscal_year=self.fiscal_year, as_dict=True) + + memberships = frappe.db.get_all('Membership', { + 'member': self.member, + 'from_date': ['between', (fiscal_year.year_start_date, fiscal_year.year_end_date)], + 'to_date': ['between', (fiscal_year.year_start_date, fiscal_year.year_end_date)], + 'membership_status': ('!=', 'Cancelled') + }, ['from_date', 'amount', 'name', 'invoice', 'payment_id'], order_by='from_date') + + if not memberships: + frappe.msgprint(_('No Membership Payments found against the Member {0}').format(self.member)) + + total = 0 + self.payments = [] + + for doc in memberships: + self.append('payments', { + 'date': doc.from_date, + 'amount': doc.amount, + 'invoice_id': doc.invoice, + 'razorpay_payment_id': doc.payment_id, + 'membership': doc.name + }) + total += flt(doc.amount) + + self.total = total diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate/test_tax_exemption_80g_certificate.py b/erpnext/regional/doctype/tax_exemption_80g_certificate/test_tax_exemption_80g_certificate.py new file mode 100644 index 00000000000..346ebbf6796 --- /dev/null +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate/test_tax_exemption_80g_certificate.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from frappe.utils import getdate +from erpnext.accounts.utils import get_fiscal_year +from erpnext.non_profit.doctype.donation.test_donation import create_donor, create_mode_of_payment, create_donor_type +from erpnext.non_profit.doctype.donation.donation import create_donation +from erpnext.non_profit.doctype.membership.test_membership import setup_membership, make_membership +from erpnext.non_profit.doctype.member.member import create_member + +class TestTaxExemption80GCertificate(unittest.TestCase): + def setUp(self): + frappe.db.sql('delete from `tabTax Exemption 80G Certificate`') + frappe.db.sql('delete from `tabMembership`') + create_donor_type() + settings = frappe.get_doc('Non Profit Settings') + settings.company = '_Test Company' + settings.donation_company = '_Test Company' + settings.default_donor_type = '_Test Donor' + settings.creation_user = 'Administrator' + settings.save() + + company = frappe.get_doc('Company', '_Test Company') + company.pan_details = 'BBBTI3374C' + company.company_80g_number = 'NQ.CIT(E)I2018-19/DEL-IE28615-27062018/10087' + company.with_effect_from = getdate() + company.save() + + def test_duplicate_donation_certificate(self): + donor = create_donor() + create_mode_of_payment() + payment = frappe._dict({ + 'amount': 100, + 'method': 'Debit Card', + 'id': 'pay_MeXAmsgeKOhq7O' + }) + donation = create_donation(donor, payment) + + args = frappe._dict({ + 'recipient': 'Donor', + 'donor': donor.name, + 'donation': donation.name + }) + certificate = create_80g_certificate(args) + certificate.insert() + + # check company details + self.assertEquals(certificate.company_pan_number, 'BBBTI3374C') + self.assertEquals(certificate.company_80g_number, 'NQ.CIT(E)I2018-19/DEL-IE28615-27062018/10087') + + # check donation details + self.assertEquals(certificate.amount, donation.amount) + + duplicate_certificate = create_80g_certificate(args) + # duplicate validation + self.assertRaises(frappe.ValidationError, duplicate_certificate.insert) + + def test_membership_80g_certificate(self): + plan = setup_membership() + + # make test member + member_doc = create_member(frappe._dict({ + 'fullname': "_Test_Member", + 'email': "_test_member_erpnext@example.com", + 'plan_id': plan.name + })) + member_doc.make_customer_and_link() + member = member_doc.name + + membership = make_membership(member, { "from_date": getdate() }) + invoice = membership.generate_invoice(save=True) + + args = frappe._dict({ + 'recipient': 'Member', + 'member': member, + 'fiscal_year': get_fiscal_year(getdate(), as_dict=True).get('name') + }) + certificate = create_80g_certificate(args) + certificate.get_payments() + certificate.insert() + + self.assertEquals(len(certificate.payments), 1) + self.assertEquals(certificate.payments[0].amount, membership.amount) + self.assertEquals(certificate.payments[0].invoice_id, invoice.name) + + +def create_80g_certificate(args): + certificate = frappe.get_doc({ + 'doctype': 'Tax Exemption 80G Certificate', + 'recipient': args.recipient, + 'date': getdate(), + 'company': '_Test Company' + }) + + certificate.update(args) + + return certificate \ No newline at end of file diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/__init__.py b/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.json b/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.json new file mode 100644 index 00000000000..dfa817dd271 --- /dev/null +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.json @@ -0,0 +1,66 @@ +{ + "actions": [], + "creation": "2021-02-15 12:43:52.754124", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "date", + "amount", + "invoice_id", + "column_break_4", + "razorpay_payment_id", + "membership" + ], + "fields": [ + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "reqd": 1 + }, + { + "fieldname": "invoice_id", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Invoice ID", + "options": "Sales Invoice", + "reqd": 1 + }, + { + "fieldname": "razorpay_payment_id", + "fieldtype": "Data", + "label": "Razorpay Payment ID" + }, + { + "fieldname": "membership", + "fieldtype": "Link", + "label": "Membership", + "options": "Membership" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-02-15 16:35:10.777587", + "modified_by": "Administrator", + "module": "Regional", + "name": "Tax Exemption 80G Certificate Detail", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.py b/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.py new file mode 100644 index 00000000000..bdad798d980 --- /dev/null +++ b/erpnext/regional/doctype/tax_exemption_80g_certificate_detail/tax_exemption_80g_certificate_detail.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class TaxExemption80GCertificateDetail(Document): + pass diff --git a/erpnext/regional/doctype/uae_vat_account/__init__.py b/erpnext/regional/doctype/uae_vat_account/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/uae_vat_account/uae_vat_account.json b/erpnext/regional/doctype/uae_vat_account/uae_vat_account.json new file mode 100644 index 00000000000..73a81692073 --- /dev/null +++ b/erpnext/regional/doctype/uae_vat_account/uae_vat_account.json @@ -0,0 +1,35 @@ +{ + "actions": [], + "autoname": "account", + "creation": "2020-09-28 11:30:45.472053", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "account" + ], + "fields": [ + { + "allow_in_quick_entry": 1, + "fieldname": "account", + "fieldtype": "Link", + "in_list_view": 1, + "in_preview": 1, + "label": "Account", + "options": "Account" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-09-28 12:02:56.444007", + "modified_by": "Administrator", + "module": "Regional", + "name": "UAE VAT Account", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/regional/doctype/uae_vat_account/uae_vat_account.py b/erpnext/regional/doctype/uae_vat_account/uae_vat_account.py new file mode 100644 index 00000000000..80d6b3a5f1f --- /dev/null +++ b/erpnext/regional/doctype/uae_vat_account/uae_vat_account.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class UAEVATAccount(Document): + pass diff --git a/erpnext/regional/doctype/uae_vat_settings/__init__.py b/erpnext/regional/doctype/uae_vat_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/doctype/uae_vat_settings/test_uae_vat_settings.py b/erpnext/regional/doctype/uae_vat_settings/test_uae_vat_settings.py new file mode 100644 index 00000000000..b88439f9b85 --- /dev/null +++ b/erpnext/regional/doctype/uae_vat_settings/test_uae_vat_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestUAEVATSettings(unittest.TestCase): + pass diff --git a/erpnext/regional/doctype/uae_vat_settings/uae_vat_settings.js b/erpnext/regional/doctype/uae_vat_settings/uae_vat_settings.js new file mode 100644 index 00000000000..07a93010b51 --- /dev/null +++ b/erpnext/regional/doctype/uae_vat_settings/uae_vat_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('UAE VAT Settings', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/regional/doctype/uae_vat_settings/uae_vat_settings.json b/erpnext/regional/doctype/uae_vat_settings/uae_vat_settings.json new file mode 100644 index 00000000000..1ff5680bfe9 --- /dev/null +++ b/erpnext/regional/doctype/uae_vat_settings/uae_vat_settings.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "autoname": "field:company", + "creation": "2020-09-25 12:48:51.463265", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "uae_vat_accounts" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "uae_vat_accounts", + "fieldtype": "Table", + "label": "UAE VAT Accounts", + "options": "UAE VAT Account", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-12-25 20:20:22.342426", + "modified_by": "Administrator", + "module": "Regional", + "name": "UAE VAT Settings", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/regional/doctype/uae_vat_settings/uae_vat_settings.py b/erpnext/regional/doctype/uae_vat_settings/uae_vat_settings.py new file mode 100644 index 00000000000..20dc604510b --- /dev/null +++ b/erpnext/regional/doctype/uae_vat_settings/uae_vat_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class UAEVATSettings(Document): + pass diff --git a/erpnext/regional/germany/utils/datev/datev_csv.py b/erpnext/regional/germany/utils/datev/datev_csv.py index aae734f8e22..f138a807bca 100644 --- a/erpnext/regional/germany/utils/datev/datev_csv.py +++ b/erpnext/regional/germany/utils/datev/datev_csv.py @@ -104,9 +104,9 @@ def get_header(filters, csv_class): # L = Tax client number (Mandantennummer) datev_settings.get('client_number', '00000'), # M = Start of the fiscal year (Wirtschaftsjahresbeginn) - frappe.utils.formatdate(frappe.defaults.get_user_default('year_start_date'), 'yyyyMMdd'), + frappe.utils.formatdate(filters.get('fiscal_year_start'), 'yyyyMMdd'), # N = Length of account numbers (Sachkontenlänge) - datev_settings.get('account_number_length', '4'), + str(filters.get('account_number_length', 4)), # O = Transaction batch start date (YYYYMMDD) frappe.utils.formatdate(filters.get('from_date'), 'yyyyMMdd') if csv_class.DATA_CATEGORY == DataCategory.TRANSACTIONS else '', # P = Transaction batch end date (YYYYMMDD) @@ -155,20 +155,22 @@ def get_header(filters, csv_class): return header -def download_csv_files_as_zip(csv_data_list): +def zip_and_download(zip_filename, csv_files): """ Put CSV files in a zip archive and send that to the client. Params: - csv_data_list -- list of dicts [{'file_name': 'EXTF_Buchunsstapel.zip', 'csv_data': get_datev_csv()}] + zip_filename Name of the zip file + csv_files list of dicts [{'file_name': 'my_file.csv', 'csv_data': 'comma,separated,values'}] """ zip_buffer = BytesIO() - datev_zip = zipfile.ZipFile(zip_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) - for csv_file in csv_data_list: - datev_zip.writestr(csv_file.get('file_name'), csv_file.get('csv_data')) - datev_zip.close() + zip_file = zipfile.ZipFile(zip_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) + for csv_file in csv_files: + zip_file.writestr(csv_file.get('file_name'), csv_file.get('csv_data')) + + zip_file.close() frappe.response['filecontent'] = zip_buffer.getvalue() - frappe.response['filename'] = 'DATEV.zip' + frappe.response['filename'] = zip_filename frappe.response['type'] = 'binary' diff --git a/erpnext/regional/india/__init__.py b/erpnext/regional/india/__init__.py index d6221a80aa2..378b735e078 100644 --- a/erpnext/regional/india/__init__.py +++ b/erpnext/regional/india/__init__.py @@ -20,6 +20,7 @@ states = [ 'Jharkhand', 'Karnataka', 'Kerala', + 'Ladakh', 'Lakshadweep Islands', 'Madhya Pradesh', 'Maharashtra', @@ -59,6 +60,7 @@ state_numbers = { "Jharkhand": "20", "Karnataka": "29", "Kerala": "32", + "Ladakh": "38", "Lakshadweep Islands": "31", "Madhya Pradesh": "23", "Maharashtra": "27", @@ -80,4 +82,4 @@ state_numbers = { "West Bengal": "19", } -number_state_mapping = {v: k for k, v in iteritems(state_numbers)} \ No newline at end of file +number_state_mapping = {v: k for k, v in iteritems(state_numbers)} diff --git a/erpnext/regional/india/e_invoice/__init__.py b/erpnext/regional/india/e_invoice/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/india/e_invoice/einv_item_template.json b/erpnext/regional/india/e_invoice/einv_item_template.json new file mode 100644 index 00000000000..78e56518dff --- /dev/null +++ b/erpnext/regional/india/e_invoice/einv_item_template.json @@ -0,0 +1,31 @@ +{{ + "SlNo": "{item.sr_no}", + "PrdDesc": "{item.description}", + "IsServc": "{item.is_service_item}", + "HsnCd": "{item.gst_hsn_code}", + "Barcde": "{item.barcode}", + "Unit": "{item.uom}", + "Qty": "{item.qty}", + "FreeQty": "{item.free_qty}", + "UnitPrice": "{item.unit_rate}", + "TotAmt": "{item.gross_amount}", + "Discount": "{item.discount_amount}", + "AssAmt": "{item.taxable_value}", + "PrdSlNo": "{item.serial_no}", + "GstRt": "{item.tax_rate}", + "IgstAmt": "{item.igst_amount}", + "CgstAmt": "{item.cgst_amount}", + "SgstAmt": "{item.sgst_amount}", + "CesRt": "{item.cess_rate}", + "CesAmt": "{item.cess_amount}", + "CesNonAdvlAmt": "{item.cess_nadv_amount}", + "StateCesRt": "{item.state_cess_rate}", + "StateCesAmt": "{item.state_cess_amount}", + "StateCesNonAdvlAmt": "{item.state_cess_nadv_amount}", + "OthChrg": "{item.other_charges}", + "TotItemVal": "{item.total_value}", + "BchDtls": {{ + "Nm": "{item.batch_no}", + "ExpDt": "{item.batch_expiry_date}" + }} +}} \ No newline at end of file diff --git a/erpnext/regional/india/e_invoice/einv_template.json b/erpnext/regional/india/e_invoice/einv_template.json new file mode 100644 index 00000000000..60f490d6166 --- /dev/null +++ b/erpnext/regional/india/e_invoice/einv_template.json @@ -0,0 +1,110 @@ +{{ + "Version": "1.1", + "TranDtls": {{ + "TaxSch": "{transaction_details.tax_scheme}", + "SupTyp": "{transaction_details.supply_type}", + "RegRev": "{transaction_details.reverse_charge}", + "EcmGstin": "{transaction_details.ecom_gstin}", + "IgstOnIntra": "{transaction_details.igst_on_intra}" + }}, + "DocDtls": {{ + "Typ": "{doc_details.invoice_type}", + "No": "{doc_details.invoice_name}", + "Dt": "{doc_details.invoice_date}" + }}, + "SellerDtls": {{ + "Gstin": "{seller_details.gstin}", + "LglNm": "{seller_details.legal_name}", + "TrdNm": "{seller_details.trade_name}", + "Loc": "{seller_details.location}", + "Pin": "{seller_details.pincode}", + "Stcd": "{seller_details.state_code}", + "Addr1": "{seller_details.address_line1}", + "Addr2": "{seller_details.address_line2}", + "Ph": "{seller_details.phone}", + "Em": "{seller_details.email}" + }}, + "BuyerDtls": {{ + "Gstin": "{buyer_details.gstin}", + "LglNm": "{buyer_details.legal_name}", + "TrdNm": "{buyer_details.trade_name}", + "Addr1": "{buyer_details.address_line1}", + "Addr2": "{buyer_details.address_line2}", + "Loc": "{buyer_details.location}", + "Pin": "{buyer_details.pincode}", + "Stcd": "{buyer_details.state_code}", + "Ph": "{buyer_details.phone}", + "Em": "{buyer_details.email}", + "Pos": "{buyer_details.place_of_supply}" + }}, + "DispDtls": {{ + "Nm": "{dispatch_details.company_name}", + "Addr1": "{dispatch_details.address_line1}", + "Addr2": "{dispatch_details.address_line2}", + "Loc": "{dispatch_details.location}", + "Pin": "{dispatch_details.pincode}", + "Stcd": "{dispatch_details.state_code}" + }}, + "ShipDtls": {{ + "Gstin": "{shipping_details.gstin}", + "LglNm": "{shipping_details.legal_name}", + "TrdNm": "{shipping_details.trader_name}", + "Addr1": "{shipping_details.address_line1}", + "Addr2": "{shipping_details.address_line2}", + "Loc": "{shipping_details.location}", + "Pin": "{shipping_details.pincode}", + "Stcd": "{shipping_details.state_code}" + }}, + "ItemList": [ + {item_list} + ], + "ValDtls": {{ + "AssVal": "{invoice_value_details.base_total}", + "CgstVal": "{invoice_value_details.total_cgst_amt}", + "SgstVal": "{invoice_value_details.total_sgst_amt}", + "IgstVal": "{invoice_value_details.total_igst_amt}", + "CesVal": "{invoice_value_details.total_cess_amt}", + "Discount": "{invoice_value_details.invoice_discount_amt}", + "RndOffAmt": "{invoice_value_details.round_off}", + "OthChrg": "{invoice_value_details.total_other_charges}", + "TotInvVal": "{invoice_value_details.base_grand_total}", + "TotInvValFc": "{invoice_value_details.grand_total}" + }}, + "PayDtls": {{ + "Nm": "{payment_details.payee_name}", + "AccDet": "{payment_details.account_no}", + "Mode": "{payment_details.mode_of_payment}", + "FinInsBr": "{payment_details.ifsc_code}", + "PayTerm": "{payment_details.terms}", + "PaidAmt": "{payment_details.paid_amount}", + "PaymtDue": "{payment_details.outstanding_amount}" + }}, + "RefDtls": {{ + "DocPerdDtls": {{ + "InvStDt": "{period_details.start_date}", + "InvEndDt": "{period_details.end_date}" + }}, + "PrecDocDtls": [{{ + "InvNo": "{prev_doc_details.invoice_name}", + "InvDt": "{prev_doc_details.invoice_date}" + }}] + }}, + "ExpDtls": {{ + "ShipBNo": "{export_details.bill_no}", + "ShipBDt": "{export_details.bill_date}", + "Port": "{export_details.port}", + "ForCur": "{export_details.foreign_curr_code}", + "CntCode": "{export_details.country_code}", + "ExpDuty": "{export_details.export_duty}" + }}, + "EwbDtls": {{ + "TransId": "{eway_bill_details.gstin}", + "TransName": "{eway_bill_details.name}", + "TransMode": "{eway_bill_details.mode_of_transport}", + "Distance": "{eway_bill_details.distance}", + "TransDocNo": "{eway_bill_details.document_name}", + "TransDocDt": "{eway_bill_details.document_date}", + "VehNo": "{eway_bill_details.vehicle_no}", + "VehType": "{eway_bill_details.vehicle_type}" + }} +}} \ No newline at end of file diff --git a/erpnext/regional/india/e_invoice/einv_validation.json b/erpnext/regional/india/e_invoice/einv_validation.json new file mode 100644 index 00000000000..86290cfe524 --- /dev/null +++ b/erpnext/regional/india/e_invoice/einv_validation.json @@ -0,0 +1,956 @@ +{ + "Version": { + "type": "string", + "minLength": 1, + "maxLength": 6, + "description": "Version of the schema" + }, + "Irn": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "description": "Invoice Reference Number" + }, + "TranDtls": { + "type": "object", + "properties": { + "TaxSch": { + "type": "string", + "minLength": 3, + "maxLength": 10, + "enum": ["GST"], + "description": "GST- Goods and Services Tax Scheme" + }, + "SupTyp": { + "type": "string", + "minLength": 3, + "maxLength": 10, + "enum": ["B2B", "SEZWP", "SEZWOP", "EXPWP", "EXPWOP", "DEXP"], + "description": "Type of Supply: B2B-Business to Business, SEZWP - SEZ with payment, SEZWOP - SEZ without payment, EXPWP - Export with Payment, EXPWOP - Export without payment,DEXP - Deemed Export" + }, + "RegRev": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "enum": ["Y", "N"], + "description": "Y- whether the tax liability is payable under reverse charge" + }, + "EcmGstin": { + "type": "string", + "minLength": 15, + "maxLength": 15, + "pattern": "([0-9]{2}[0-9A-Z]{13})", + "description": "E-Commerce GSTIN", + "validationMsg": "E-Commerce GSTIN is invalid" + }, + "IgstOnIntra": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "enum": ["Y", "N"], + "description": "Y- indicates the supply is intra state but chargeable to IGST" + } + }, + "required": ["TaxSch", "SupTyp"] + }, + "DocDtls": { + "type": "object", + "properties": { + "Typ": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "enum": ["INV", "CRN", "DBN"], + "description": "Document Type" + }, + "No": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "pattern": "^([A-Z1-9]{1}[A-Z0-9/-]{0,15})$", + "description": "Document Number", + "validationMsg": "Document Number should not be starting with 0, / and -" + }, + "Dt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Document Date" + } + }, + "required": ["Typ", "No", "Dt"] + }, + "SellerDtls": { + "type": "object", + "properties": { + "Gstin": { + "type": "string", + "minLength": 15, + "maxLength": 15, + "pattern": "([0-9]{2}[0-9A-Z]{13})", + "description": "Supplier GSTIN", + "validationMsg": "Company GSTIN is invalid" + }, + "LglNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Legal Name" + }, + "TrdNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Tradename" + }, + "Addr1": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Address Line 1" + }, + "Addr2": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Address Line 2" + }, + "Loc": { + "type": "string", + "minLength": 3, + "maxLength": 50, + "description": "Location" + }, + "Pin": { + "type": "number", + "minimum": 100000, + "maximum": 999999, + "description": "Pincode" + }, + "Stcd": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "Supplier State Code" + }, + "Ph": { + "type": "string", + "minLength": 6, + "maxLength": 12, + "description": "Phone" + }, + "Em": { + "type": "string", + "minLength": 6, + "maxLength": 100, + "description": "Email-Id" + } + }, + "required": ["Gstin", "LglNm", "Addr1", "Loc", "Pin", "Stcd"] + }, + "BuyerDtls": { + "type": "object", + "properties": { + "Gstin": { + "type": "string", + "minLength": 3, + "maxLength": 15, + "pattern": "^(([0-9]{2}[0-9A-Z]{13})|URP)$", + "description": "Buyer GSTIN", + "validationMsg": "Customer GSTIN is invalid" + }, + "LglNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Legal Name" + }, + "TrdNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Trade Name" + }, + "Pos": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "Place of Supply State code" + }, + "Addr1": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Address Line 1" + }, + "Addr2": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Address Line 2" + }, + "Loc": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Location" + }, + "Pin": { + "type": "number", + "minimum": 100000, + "maximum": 999999, + "description": "Pincode" + }, + "Stcd": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "Buyer State Code" + }, + "Ph": { + "type": "string", + "minLength": 6, + "maxLength": 12, + "description": "Phone" + }, + "Em": { + "type": "string", + "minLength": 6, + "maxLength": 100, + "description": "Email-Id" + } + }, + "required": ["Gstin", "LglNm", "Pos", "Addr1", "Loc", "Stcd"] + }, + "DispDtls": { + "type": "object", + "properties": { + "Nm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Dispatch Address Name" + }, + "Addr1": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Address Line 1" + }, + "Addr2": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Address Line 2" + }, + "Loc": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Location" + }, + "Pin": { + "type": "number", + "minimum": 100000, + "maximum": 999999, + "description": "Pincode" + }, + "Stcd": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "State Code" + } + }, + "required": ["Nm", "Addr1", "Loc", "Pin", "Stcd"] + }, + "ShipDtls": { + "type": "object", + "properties": { + "Gstin": { + "type": "string", + "maxLength": 15, + "minLength": 3, + "pattern": "^(([0-9]{2}[0-9A-Z]{13})|URP)$", + "description": "Shipping Address GSTIN", + "validationMsg": "Shipping Address GSTIN is invalid" + }, + "LglNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Legal Name" + }, + "TrdNm": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Trade Name" + }, + "Addr1": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Address Line 1" + }, + "Addr2": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Address Line 2" + }, + "Loc": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Location" + }, + "Pin": { + "type": "number", + "minimum": 100000, + "maximum": 999999, + "description": "Pincode" + }, + "Stcd": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "State Code" + } + }, + "required": ["LglNm", "Addr1", "Loc", "Pin", "Stcd"] + }, + "ItemList": { + "type": "Array", + "properties": { + "SlNo": { + "type": "string", + "minLength": 1, + "maxLength": 6, + "description": "Serial No. of Item" + }, + "PrdDesc": { + "type": "string", + "minLength": 3, + "maxLength": 300, + "description": "Item Name" + }, + "IsServc": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "enum": ["Y", "N"], + "description": "Is Service Item" + }, + "HsnCd": { + "type": "string", + "minLength": 4, + "maxLength": 8, + "description": "HSN Code" + }, + "Barcde": { + "type": "string", + "minLength": 3, + "maxLength": 30, + "description": "Barcode" + }, + "Qty": { + "type": "number", + "minimum": 0, + "maximum": 9999999999.999, + "description": "Quantity" + }, + "FreeQty": { + "type": "number", + "minimum": 0, + "maximum": 9999999999.999, + "description": "Free Quantity" + }, + "Unit": { + "type": "string", + "minLength": 3, + "maxLength": 8, + "description": "UOM" + }, + "UnitPrice": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.999, + "description": "Rate" + }, + "TotAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Gross Amount" + }, + "Discount": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Discount" + }, + "PreTaxVal": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Pre tax value" + }, + "AssAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Taxable Value" + }, + "GstRt": { + "type": "number", + "minimum": 0, + "maximum": 999.999, + "description": "GST Rate" + }, + "IgstAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "IGST Amount" + }, + "CgstAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "CGST Amount" + }, + "SgstAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "SGST Amount" + }, + "CesRt": { + "type": "number", + "minimum": 0, + "maximum": 999.999, + "description": "Cess Rate" + }, + "CesAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Cess Amount (Advalorem)" + }, + "CesNonAdvlAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Cess Amount (Non-Advalorem)" + }, + "StateCesRt": { + "type": "number", + "minimum": 0, + "maximum": 999.999, + "description": "State CESS Rate" + }, + "StateCesAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "State CESS Amount" + }, + "StateCesNonAdvlAmt": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "State CESS Amount (Non Advalorem)" + }, + "OthChrg": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Other Charges" + }, + "TotItemVal": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Total Item Value" + }, + "OrdLineRef": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "description": "Order line reference" + }, + "OrgCntry": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "description": "Origin Country" + }, + "PrdSlNo": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "description": "Serial number" + }, + "BchDtls": { + "type": "object", + "properties": { + "Nm": { + "type": "string", + "minLength": 3, + "maxLength": 20, + "description": "Batch number" + }, + "ExpDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Batch Expiry Date" + }, + "WrDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Warranty Date" + } + }, + "required": ["Nm"] + }, + "AttribDtls": { + "type": "Array", + "Attribute": { + "type": "object", + "properties": { + "Nm": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Attribute name of the item" + }, + "Val": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Attribute value of the item" + } + } + } + } + }, + "required": [ + "SlNo", + "IsServc", + "HsnCd", + "UnitPrice", + "TotAmt", + "AssAmt", + "GstRt", + "TotItemVal" + ] + }, + "ValDtls": { + "type": "object", + "properties": { + "AssVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total Assessable value of all items" + }, + "CgstVal": { + "type": "number", + "maximum": 99999999999999.99, + "minimum": 0, + "description": "Total CGST value of all items" + }, + "SgstVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total SGST value of all items" + }, + "IgstVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total IGST value of all items" + }, + "CesVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total CESS value of all items" + }, + "StCesVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Total State CESS value of all items" + }, + "Discount": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Invoice Discount" + }, + "OthChrg": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Other Charges" + }, + "RndOffAmt": { + "type": "number", + "minimum": -99.99, + "maximum": 99.99, + "description": "Rounded off Amount" + }, + "TotInvVal": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Final Invoice Value " + }, + "TotInvValFc": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Final Invoice value in Foreign Currency" + } + }, + "required": ["AssVal", "TotInvVal"] + }, + "PayDtls": { + "type": "object", + "properties": { + "Nm": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Payee Name" + }, + "AccDet": { + "type": "string", + "minLength": 1, + "maxLength": 18, + "description": "Bank Account Number of Payee" + }, + "Mode": { + "type": "string", + "minLength": 1, + "maxLength": 18, + "description": "Mode of Payment" + }, + "FinInsBr": { + "type": "string", + "minLength": 1, + "maxLength": 11, + "description": "Branch or IFSC code" + }, + "PayTerm": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Terms of Payment" + }, + "PayInstr": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Payment Instruction" + }, + "CrTrn": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Credit Transfer" + }, + "DirDr": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Direct Debit" + }, + "CrDay": { + "type": "number", + "minimum": 0, + "maximum": 9999, + "description": "Credit Days" + }, + "PaidAmt": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Advance Amount" + }, + "PaymtDue": { + "type": "number", + "minimum": 0, + "maximum": 99999999999999.99, + "description": "Outstanding Amount" + } + } + }, + "RefDtls": { + "type": "object", + "properties": { + "InvRm": { + "type": "string", + "maxLength": 100, + "minLength": 3, + "pattern": "^[0-9A-Za-z/-]{3,100}$", + "description": "Remarks/Note" + }, + "DocPerdDtls": { + "type": "object", + "properties": { + "InvStDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Invoice Period Start Date" + }, + "InvEndDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Invoice Period End Date" + } + }, + "required": ["InvStDt ", "InvEndDt "] + }, + "PrecDocDtls": { + "type": "object", + "properties": { + "InvNo": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "pattern": "^[1-9A-Z]{1}[0-9A-Z/-]{1,15}$", + "description": "Reference of Original Invoice" + }, + "InvDt": { + "type": "string", + "maxLength": 10, + "minLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Date of Orginal Invoice" + }, + "OthRefNo": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "description": "Other Reference" + } + } + }, + "required": ["InvNo", "InvDt"], + "ContrDtls": { + "type": "object", + "properties": { + "RecAdvRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Receipt Advice No." + }, + "RecAdvDt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Date of receipt advice" + }, + "TendRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Lot/Batch Reference No." + }, + "ContrRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Contract Reference Number" + }, + "ExtRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Any other reference" + }, + "ProjRefr": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^([0-9A-Za-z/-]){1,20}$", + "description": "Project Reference Number" + }, + "PORefr": { + "type": "string", + "minLength": 1, + "maxLength": 16, + "pattern": "^([0-9A-Za-z/-]){1,16}$", + "description": "PO Reference Number" + }, + "PORefDt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "PO Reference date" + } + } + } + } + }, + "AddlDocDtls": { + "type": "Array", + "properties": { + "Url": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Supporting document URL" + }, + "Docs": { + "type": "string", + "minLength": 3, + "maxLength": 1000, + "description": "Supporting document in Base64 Format" + }, + "Info": { + "type": "string", + "minLength": 3, + "maxLength": 1000, + "description": "Any additional information" + } + } + }, + + "ExpDtls": { + "type": "object", + "properties": { + "ShipBNo": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "description": "Shipping Bill No." + }, + "ShipBDt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Shipping Bill Date" + }, + "Port": { + "type": "string", + "minLength": 2, + "maxLength": 10, + "pattern": "^[0-9A-Za-z]{2,10}$", + "description": "Port Code. Refer the master" + }, + "RefClm": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "description": "Claiming Refund. Y/N" + }, + "ForCur": { + "type": "string", + "minLength": 3, + "maxLength": 16, + "description": "Additional Currency Code. Refer the master" + }, + "CntCode": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "description": "Country Code. Refer the master" + }, + "ExpDuty": { + "type": "number", + "minimum": 0, + "maximum": 999999999999.99, + "description": "Export Duty" + } + } + }, + "EwbDtls": { + "type": "object", + "properties": { + "TransId": { + "type": "string", + "minLength": 15, + "maxLength": 15, + "description": "Transporter GSTIN" + }, + "TransName": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Transporter Name" + }, + "TransMode": { + "type": "string", + "maxLength": 1, + "minLength": 1, + "enum": ["1", "2", "3", "4"], + "description": "Mode of Transport" + }, + "Distance": { + "type": "number", + "minimum": 1, + "maximum": 9999, + "description": "Distance" + }, + "TransDocNo": { + "type": "string", + "minLength": 1, + "maxLength": 15, + "pattern": "^([0-9A-Z/-]){1,15}$", + "description": "Tranport Document Number" + }, + "TransDocDt": { + "type": "string", + "minLength": 10, + "maxLength": 10, + "pattern": "[0-3][0-9]/[0-1][0-9]/[2][0][1-2][0-9]", + "description": "Transport Document Date" + }, + "VehNo": { + "type": "string", + "minLength": 4, + "maxLength": 20, + "description": "Vehicle Number" + }, + "VehType": { + "type": "string", + "minLength": 1, + "maxLength": 1, + "enum": ["O", "R"], + "description": "Vehicle Type" + } + }, + "required": ["Distance"] + }, + "required": [ + "Version", + "TranDtls", + "DocDtls", + "SellerDtls", + "BuyerDtls", + "ItemList", + "ValDtls" + ] +} diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js new file mode 100644 index 00000000000..7cd64f2fc07 --- /dev/null +++ b/erpnext/regional/india/e_invoice/einvoice.js @@ -0,0 +1,307 @@ +erpnext.setup_einvoice_actions = (doctype) => { + frappe.ui.form.on(doctype, { + async refresh(frm) { + const einvoicing_enabled = await frappe.db.get_single_value("E Invoice Settings", "enable"); + const supply_type = frm.doc.gst_category; + const valid_supply_type = ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'].includes(supply_type); + const company_transaction = frm.doc.billing_address_gstin == frm.doc.company_gstin; + + if (cint(einvoicing_enabled) == 0 || !valid_supply_type || company_transaction) return; + + const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, __unsaved } = frm.doc; + + const add_custom_button = (label, action) => { + if (!frm.custom_buttons[label]) { + frm.add_custom_button(label, action, __('E Invoicing')); + } + }; + + if (!irn && !__unsaved) { + const action = () => { + if (frm.doc.__unsaved) { + frappe.throw(__('Please save the document to generate IRN.')); + } + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.get_einvoice', + args: { doctype, docname: name }, + freeze: true, + callback: (res) => { + const einvoice = res.message; + show_einvoice_preview(frm, einvoice); + } + }); + }; + + add_custom_button(__("Generate IRN"), action); + } + + if (irn && !irn_cancelled && !ewaybill) { + const fields = [ + { + "label": "Reason", + "fieldname": "reason", + "fieldtype": "Select", + "reqd": 1, + "default": "1-Duplicate", + "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"] + }, + { + "label": "Remark", + "fieldname": "remark", + "fieldtype": "Data", + "reqd": 1 + } + ]; + const action = () => { + const d = new frappe.ui.Dialog({ + title: __("Cancel IRN"), + fields: fields, + primary_action: function() { + const data = d.get_values(); + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.cancel_irn', + args: { + doctype, + docname: name, + irn: irn, + reason: data.reason.split('-')[0], + remark: data.remark + }, + freeze: true, + callback: () => frm.reload_doc() || d.hide(), + error: () => d.hide() + }); + }, + primary_action_label: __('Submit') + }); + d.show(); + }; + add_custom_button(__("Cancel IRN"), action); + } + + if (irn && !irn_cancelled && !ewaybill) { + const action = () => { + const d = new frappe.ui.Dialog({ + title: __('Generate E-Way Bill'), + size: "large", + fields: get_ewaybill_fields(frm), + primary_action: function() { + const data = d.get_values(); + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.generate_eway_bill', + args: { + doctype, + docname: name, + irn, + ...data + }, + freeze: true, + callback: () => frm.reload_doc() || d.hide(), + error: () => d.hide() + }); + }, + primary_action_label: __('Submit') + }); + d.show(); + }; + + add_custom_button(__("Generate E-Way Bill"), action); + } + + if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) { + const fields = [ + { + "label": "Reason", + "fieldname": "reason", + "fieldtype": "Select", + "reqd": 1, + "default": "1-Duplicate", + "options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"] + }, + { + "label": "Remark", + "fieldname": "remark", + "fieldtype": "Data", + "reqd": 1 + } + ]; + const action = () => { + const d = new frappe.ui.Dialog({ + title: __('Cancel E-Way Bill'), + fields: fields, + primary_action: function() { + const data = d.get_values(); + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill', + args: { + doctype, + docname: name, + eway_bill: ewaybill, + reason: data.reason.split('-')[0], + remark: data.remark + }, + freeze: true, + callback: () => frm.reload_doc() || d.hide(), + error: () => d.hide() + }); + }, + primary_action_label: __('Submit') + }); + d.show(); + }; + add_custom_button(__("Cancel E-Way Bill"), action); + } + } + }); +}; + +const get_ewaybill_fields = (frm) => { + return [ + { + 'fieldname': 'transporter', + 'label': 'Transporter', + 'fieldtype': 'Link', + 'options': 'Supplier', + 'default': frm.doc.transporter + }, + { + 'fieldname': 'gst_transporter_id', + 'label': 'GST Transporter ID', + 'fieldtype': 'Data', + 'fetch_from': 'transporter.gst_transporter_id', + 'default': frm.doc.gst_transporter_id + }, + { + 'fieldname': 'driver', + 'label': 'Driver', + 'fieldtype': 'Link', + 'options': 'Driver', + 'default': frm.doc.driver + }, + { + 'fieldname': 'lr_no', + 'label': 'Transport Receipt No', + 'fieldtype': 'Data', + 'default': frm.doc.lr_no + }, + { + 'fieldname': 'vehicle_no', + 'label': 'Vehicle No', + 'fieldtype': 'Data', + 'default': frm.doc.vehicle_no + }, + { + 'fieldname': 'distance', + 'label': 'Distance (in km)', + 'fieldtype': 'Float', + 'default': frm.doc.distance + }, + { + 'fieldname': 'transporter_col_break', + 'fieldtype': 'Column Break', + }, + { + 'fieldname': 'transporter_name', + 'label': 'Transporter Name', + 'fieldtype': 'Data', + 'fetch_from': 'transporter.name', + 'read_only': 1, + 'default': frm.doc.transporter_name + }, + { + 'fieldname': 'mode_of_transport', + 'label': 'Mode of Transport', + 'fieldtype': 'Select', + 'options': `\nRoad\nAir\nRail\nShip`, + 'default': frm.doc.mode_of_transport + }, + { + 'fieldname': 'driver_name', + 'label': 'Driver Name', + 'fieldtype': 'Data', + 'fetch_from': 'driver.full_name', + 'read_only': 1, + 'default': frm.doc.driver_name + }, + { + 'fieldname': 'lr_date', + 'label': 'Transport Receipt Date', + 'fieldtype': 'Date', + 'default': frm.doc.lr_date + }, + { + 'fieldname': 'gst_vehicle_type', + 'label': 'GST Vehicle Type', + 'fieldtype': 'Select', + 'options': `Regular\nOver Dimensional Cargo (ODC)`, + 'depends_on': 'eval:(doc.mode_of_transport === "Road")', + 'default': frm.doc.gst_vehicle_type + } + ]; +}; + +const request_irn_generation = (frm) => { + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.generate_irn', + args: { doctype: frm.doc.doctype, docname: frm.doc.name }, + freeze: true, + callback: () => frm.reload_doc() + }); +}; + +const get_preview_dialog = (frm, action) => { + const dialog = new frappe.ui.Dialog({ + title: __("Preview"), + size: "large", + fields: [ + { + "label": "Preview", + "fieldname": "preview_html", + "fieldtype": "HTML" + } + ], + primary_action: () => action(frm) || dialog.hide(), + primary_action_label: __('Generate IRN') + }); + return dialog; +}; + +const show_einvoice_preview = (frm, einvoice) => { + const preview_dialog = get_preview_dialog(frm, request_irn_generation); + + // initialize e-invoice fields + einvoice["Irn"] = einvoice["AckNo"] = ''; einvoice["AckDt"] = frappe.datetime.nowdate(); + frm.doc.signed_einvoice = JSON.stringify(einvoice); + + // initialize preview wrapper + const $preview_wrapper = preview_dialog.get_field("preview_html").$wrapper; + $preview_wrapper.html( + `
    + +
    +
    ` + ); + + frappe.call({ + method: "frappe.www.printview.get_html_and_style", + args: { + doc: frm.doc, + print_format: "GST E-Invoice", + no_letterhead: 1 + }, + callback: function (r) { + if (!r.exc) { + $preview_wrapper.find(".print-format").html(r.message.html); + const style = ` + .print-format { box-shadow: 0px 0px 5px rgba(0,0,0,0.2); padding: 0.30in; min-height: 80vh; } + .print-preview { min-height: 0px; } + .modal-dialog { width: 720px; }`; + + frappe.dom.set_style(style, "custom-print-style"); + preview_dialog.show(); + } + } + }); +}; \ No newline at end of file diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py new file mode 100644 index 00000000000..96f7f1b224f --- /dev/null +++ b/erpnext/regional/india/e_invoice/utils.py @@ -0,0 +1,851 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import os +import re +import jwt +import sys +import json +import base64 +import frappe +import six +import traceback +import io +from frappe import _, bold +from pyqrcode import create as qrcreate +from frappe.integrations.utils import make_post_request, make_get_request +from erpnext.regional.india.utils import get_gst_accounts, get_place_of_supply +from frappe.utils.data import cstr, cint, format_date, flt, time_diff_in_seconds, now_datetime, add_to_date, get_link_to_form + +def validate_einvoice_fields(doc): + einvoicing_enabled = cint(frappe.db.get_value('E Invoice Settings', 'E Invoice Settings', 'enable')) + invalid_doctype = doc.doctype != 'Sales Invoice' + invalid_supply_type = doc.get('gst_category') not in ['Registered Regular', 'SEZ', 'Overseas', 'Deemed Export'] + company_transaction = doc.get('billing_address_gstin') == doc.get('company_gstin') + no_taxes_applied = not doc.get('taxes') + + if not einvoicing_enabled or invalid_doctype or invalid_supply_type or company_transaction or no_taxes_applied: + return + + if doc.docstatus == 0 and doc._action == 'save': + if doc.irn: + frappe.throw(_('You cannot edit the invoice after generating IRN'), title=_('Edit Not Allowed')) + if len(doc.name) > 16: + raise_document_name_too_long_error() + + elif doc.docstatus == 1 and doc._action == 'submit' and not doc.irn: + frappe.throw(_('You must generate IRN before submitting the document.'), title=_('Missing IRN')) + + elif doc.irn and doc.docstatus == 2 and doc._action == 'cancel' and not doc.irn_cancelled: + frappe.throw(_('You must cancel IRN before cancelling the document.'), title=_('Cancel Not Allowed')) + +def raise_document_name_too_long_error(): + title = _('Document ID Too Long') + msg = _('As you have E-Invoicing enabled, to be able to generate IRN for this invoice, ') + msg += _('document id {} exceed 16 letters. ').format(bold(_('should not'))) + msg += '

    ' + msg += _('You must {} your {} in order to have document id of {} length 16. ').format( + bold(_('modify')), bold(_('naming series')), bold(_('maximum')) + ) + msg += _('Please account for ammended documents too. ') + frappe.throw(msg, title=title) + +def read_json(name): + file_path = os.path.join(os.path.dirname(__file__), '{name}.json'.format(name=name)) + with open(file_path, 'r') as f: + return cstr(f.read()) + +def get_transaction_details(invoice): + supply_type = '' + if invoice.gst_category == 'Registered Regular': supply_type = 'B2B' + elif invoice.gst_category == 'SEZ': supply_type = 'SEZWOP' + elif invoice.gst_category == 'Overseas': supply_type = 'EXPWOP' + elif invoice.gst_category == 'Deemed Export': supply_type = 'DEXP' + + if not supply_type: + rr, sez, overseas, export = bold('Registered Regular'), bold('SEZ'), bold('Overseas'), bold('Deemed Export') + frappe.throw(_('GST category should be one of {}, {}, {}, {}').format(rr, sez, overseas, export), + title=_('Invalid Supply Type')) + + return frappe._dict(dict( + tax_scheme='GST', + supply_type=supply_type, + reverse_charge=invoice.reverse_charge + )) + +def get_doc_details(invoice): + invoice_type = 'CRN' if invoice.is_return else 'INV' + + invoice_name = invoice.name + invoice_date = format_date(invoice.posting_date, 'dd/mm/yyyy') + + return frappe._dict(dict( + invoice_type=invoice_type, + invoice_name=invoice_name, + invoice_date=invoice_date + )) + +def get_party_details(address_name): + d = frappe.get_all('Address', filters={'name': address_name}, fields=['*'])[0] + + if (not d.gstin + or not d.city + or not d.pincode + or not d.address_title + or not d.address_line1 + or not d.gst_state_number): + + frappe.throw( + msg=_('Address lines, city, pincode, gstin is mandatory for address {}. Please set them and try again.').format( + get_link_to_form('Address', address_name) + ), + title=_('Missing Address Fields') + ) + + if d.gst_state_number == 97: + # according to einvoice standard + pincode = 999999 + + return frappe._dict(dict( + gstin=d.gstin, + legal_name=sanitize_for_json(d.address_title), + location=sanitize_for_json(d.city), + pincode=d.pincode, + state_code=d.gst_state_number, + address_line1=sanitize_for_json(d.address_line1), + address_line2=sanitize_for_json(d.address_line2) + )) + +def get_gstin_details(gstin): + if not hasattr(frappe.local, 'gstin_cache'): + frappe.local.gstin_cache = {} + + key = gstin + details = frappe.local.gstin_cache.get(key) + if details: + return details + + details = frappe.cache().hget('gstin_cache', key) + if details: + frappe.local.gstin_cache[key] = details + return details + + if not details: + return GSPConnector.get_gstin_details(gstin) + +def get_overseas_address_details(address_name): + address_title, address_line1, address_line2, city = frappe.db.get_value( + 'Address', address_name, ['address_title', 'address_line1', 'address_line2', 'city'] + ) + + if not address_title or not address_line1 or not city: + frappe.throw( + msg=_('Address lines and city is mandatory for address {}. Please set them and try again.').format( + get_link_to_form('Address', address_name) + ), + title=_('Missing Address Fields') + ) + + return frappe._dict(dict( + gstin='URP', + legal_name=sanitize_for_json(address_title), + location=city, + address_line1=sanitize_for_json(address_line1), + address_line2=sanitize_for_json(address_line2), + pincode=999999, state_code=96, place_of_supply=96 + )) + +def get_item_list(invoice): + item_list = [] + + for d in invoice.items: + einvoice_item_schema = read_json('einv_item_template') + item = frappe._dict({}) + item.update(d.as_dict()) + + item.sr_no = d.idx + item.description = sanitize_for_json(d.item_name) + + item.qty = abs(item.qty) + item.discount_amount = 0 + item.unit_rate = abs(item.base_net_amount / item.qty) + item.gross_amount = abs(item.base_net_amount) + item.taxable_value = abs(item.base_net_amount) + + item.batch_expiry_date = frappe.db.get_value('Batch', d.batch_no, 'expiry_date') if d.batch_no else None + item.batch_expiry_date = format_date(item.batch_expiry_date, 'dd/mm/yyyy') if item.batch_expiry_date else None + item.is_service_item = 'N' if frappe.db.get_value('Item', d.item_code, 'is_stock_item') else 'Y' + item.serial_no = "" + + item = update_item_taxes(invoice, item) + + item.total_value = abs( + item.taxable_value + item.igst_amount + item.sgst_amount + + item.cgst_amount + item.cess_amount + item.cess_nadv_amount + item.other_charges + ) + einv_item = einvoice_item_schema.format(item=item) + item_list.append(einv_item) + + return ', '.join(item_list) + +def update_item_taxes(invoice, item): + gst_accounts = get_gst_accounts(invoice.company) + gst_accounts_list = [d for accounts in gst_accounts.values() for d in accounts if d] + + for attr in [ + 'tax_rate', 'cess_rate', 'cess_nadv_amount', + 'cgst_amount', 'sgst_amount', 'igst_amount', + 'cess_amount', 'cess_nadv_amount', 'other_charges' + ]: + item[attr] = 0 + + for t in invoice.taxes: + is_applicable = t.tax_amount and t.account_head in gst_accounts_list + if is_applicable: + # this contains item wise tax rate & tax amount (incl. discount) + item_tax_detail = json.loads(t.item_wise_tax_detail).get(item.item_code) + + item_tax_rate = item_tax_detail[0] + # item tax amount excluding discount amount + item_tax_amount = (item_tax_rate / 100) * item.base_net_amount + + if t.account_head in gst_accounts.cess_account: + item_tax_amount_after_discount = item_tax_detail[1] + if t.charge_type == 'On Item Quantity': + item.cess_nadv_amount += abs(item_tax_amount_after_discount) + else: + item.cess_rate += item_tax_rate + item.cess_amount += abs(item_tax_amount_after_discount) + + for tax_type in ['igst', 'cgst', 'sgst']: + if t.account_head in gst_accounts[f'{tax_type}_account']: + item.tax_rate += item_tax_rate + item[f'{tax_type}_amount'] += abs(item_tax_amount) + + return item + +def get_invoice_value_details(invoice): + invoice_value_details = frappe._dict(dict()) + + if invoice.apply_discount_on == 'Net Total' and invoice.discount_amount: + invoice_value_details.base_total = abs(invoice.base_total) + invoice_value_details.invoice_discount_amt = abs(invoice.base_discount_amount) + else: + invoice_value_details.base_total = abs(invoice.base_net_total) + # since tax already considers discount amount + invoice_value_details.invoice_discount_amt = 0 + + invoice_value_details.round_off = invoice.base_rounding_adjustment + invoice_value_details.base_grand_total = abs(invoice.base_rounded_total) or abs(invoice.base_grand_total) + invoice_value_details.grand_total = abs(invoice.rounded_total) or abs(invoice.grand_total) + + invoice_value_details = update_invoice_taxes(invoice, invoice_value_details) + + return invoice_value_details + +def update_invoice_taxes(invoice, invoice_value_details): + gst_accounts = get_gst_accounts(invoice.company) + gst_accounts_list = [d for accounts in gst_accounts.values() for d in accounts if d] + + invoice_value_details.total_cgst_amt = 0 + invoice_value_details.total_sgst_amt = 0 + invoice_value_details.total_igst_amt = 0 + invoice_value_details.total_cess_amt = 0 + invoice_value_details.total_other_charges = 0 + for t in invoice.taxes: + if t.account_head in gst_accounts_list: + if t.account_head in gst_accounts.cess_account: + # using after discount amt since item also uses after discount amt for cess calc + invoice_value_details.total_cess_amt += abs(t.base_tax_amount_after_discount_amount) + + for tax_type in ['igst', 'cgst', 'sgst']: + if t.account_head in gst_accounts[f'{tax_type}_account']: + invoice_value_details[f'total_{tax_type}_amt'] += abs(t.base_tax_amount_after_discount_amount) + else: + invoice_value_details.total_other_charges += abs(t.base_tax_amount_after_discount_amount) + + return invoice_value_details + +def get_payment_details(invoice): + payee_name = invoice.company + mode_of_payment = ', '.join([d.mode_of_payment for d in invoice.payments]) + paid_amount = invoice.base_paid_amount + outstanding_amount = invoice.outstanding_amount + + return frappe._dict(dict( + payee_name=payee_name, mode_of_payment=mode_of_payment, + paid_amount=paid_amount, outstanding_amount=outstanding_amount + )) + +def get_return_doc_reference(invoice): + invoice_date = frappe.db.get_value('Sales Invoice', invoice.return_against, 'posting_date') + return frappe._dict(dict( + invoice_name=invoice.return_against, invoice_date=format_date(invoice_date, 'dd/mm/yyyy') + )) + +def get_eway_bill_details(invoice): + if invoice.is_return: + frappe.throw(_('E-Way Bill cannot be generated for Credit Notes & Debit Notes'), title=_('E Invoice Validation Failed')) + + mode_of_transport = { '': '', 'Road': '1', 'Air': '2', 'Rail': '3', 'Ship': '4' } + vehicle_type = { 'Regular': 'R', 'Over Dimensional Cargo (ODC)': 'O' } + + return frappe._dict(dict( + gstin=invoice.gst_transporter_id, + name=invoice.transporter_name, + mode_of_transport=mode_of_transport[invoice.mode_of_transport], + distance=invoice.distance or 0, + document_name=invoice.lr_no, + document_date=format_date(invoice.lr_date, 'dd/mm/yyyy'), + vehicle_no=invoice.vehicle_no, + vehicle_type=vehicle_type[invoice.gst_vehicle_type] + )) + +def validate_mandatory_fields(invoice): + if not invoice.company_address: + frappe.throw(_('Company Address is mandatory to fetch company GSTIN details.'), title=_('Missing Fields')) + if not invoice.customer_address: + frappe.throw(_('Customer Address is mandatory to fetch customer GSTIN details.'), title=_('Missing Fields')) + if not frappe.db.get_value('Address', invoice.company_address, 'gstin'): + frappe.throw( + _('GSTIN is mandatory to fetch company GSTIN details. Please enter GSTIN in selected company address.'), + title=_('Missing Fields') + ) + if invoice.gst_category != 'Overseas' and not frappe.db.get_value('Address', invoice.customer_address, 'gstin'): + frappe.throw( + _('GSTIN is mandatory to fetch customer GSTIN details. Please enter GSTIN in selected customer address.'), + title=_('Missing Fields') + ) + +def make_einvoice(invoice): + validate_mandatory_fields(invoice) + + schema = read_json('einv_template') + + transaction_details = get_transaction_details(invoice) + item_list = get_item_list(invoice) + doc_details = get_doc_details(invoice) + invoice_value_details = get_invoice_value_details(invoice) + seller_details = get_party_details(invoice.company_address) + + if invoice.gst_category == 'Overseas': + buyer_details = get_overseas_address_details(invoice.customer_address) + else: + buyer_details = get_party_details(invoice.customer_address) + place_of_supply = get_place_of_supply(invoice, invoice.doctype) or sanitize_for_json(invoice.billing_address_gstin) + place_of_supply = place_of_supply[:2] + buyer_details.update(dict(place_of_supply=place_of_supply)) + + shipping_details = payment_details = prev_doc_details = eway_bill_details = frappe._dict({}) + if invoice.shipping_address_name and invoice.customer_address != invoice.shipping_address_name: + if invoice.gst_category == 'Overseas': + shipping_details = get_overseas_address_details(invoice.shipping_address_name) + else: + shipping_details = get_party_details(invoice.shipping_address_name) + + if invoice.is_pos and invoice.base_paid_amount: + payment_details = get_payment_details(invoice) + + if invoice.is_return and invoice.return_against: + prev_doc_details = get_return_doc_reference(invoice) + + if invoice.transporter: + eway_bill_details = get_eway_bill_details(invoice) + + # not yet implemented + dispatch_details = period_details = export_details = frappe._dict({}) + + einvoice = schema.format( + transaction_details=transaction_details, doc_details=doc_details, dispatch_details=dispatch_details, + seller_details=seller_details, buyer_details=buyer_details, shipping_details=shipping_details, + item_list=item_list, invoice_value_details=invoice_value_details, payment_details=payment_details, + period_details=period_details, prev_doc_details=prev_doc_details, + export_details=export_details, eway_bill_details=eway_bill_details + ) + einvoice = safe_json_load(einvoice) + + validations = json.loads(read_json('einv_validation')) + errors = validate_einvoice(validations, einvoice) + if errors: + message = "\n".join([ + "E Invoice: ", json.dumps(einvoice, indent=4), + "-" * 50, + "Errors: ", json.dumps(errors, indent=4) + ]) + frappe.log_error(title="E Invoice Validation Failed", message=message) + frappe.throw(errors, title=_('E Invoice Validation Failed'), as_list=1) + + return einvoice + +def safe_json_load(json_string): + JSONDecodeError = ValueError if six.PY2 else json.JSONDecodeError + + try: + return json.loads(json_string) + except JSONDecodeError as e: + # print a snippet of 40 characters around the location where error occured + pos = e.pos + start, end = max(0, pos-20), min(len(json_string)-1, pos+20) + snippet = json_string[start:end] + frappe.throw(_("Error in input data. Please check for any special characters near following input:
    {}").format(snippet)) + +def validate_einvoice(validations, einvoice, errors=[]): + for fieldname, field_validation in validations.items(): + value = einvoice.get(fieldname, None) + if not value or value == "None": + # remove keys with empty values + einvoice.pop(fieldname, None) + continue + + value_type = field_validation.get("type").lower() + if value_type in ['object', 'array']: + child_validations = field_validation.get('properties') + + if isinstance(value, list): + for d in value: + validate_einvoice(child_validations, d, errors) + if not d: + # remove empty dicts + einvoice.pop(fieldname, None) + else: + validate_einvoice(child_validations, value, errors) + if not value: + # remove empty dicts + einvoice.pop(fieldname, None) + continue + + # convert to int or str + if value_type == 'string': + einvoice[fieldname] = str(value) + elif value_type == 'number': + is_integer = '.' not in str(field_validation.get('maximum')) + precision = 3 if '.999' in str(field_validation.get('maximum')) else 2 + einvoice[fieldname] = flt(value, precision) if not is_integer else cint(value) + value = einvoice[fieldname] + + max_length = field_validation.get('maxLength') + minimum = flt(field_validation.get('minimum')) + maximum = flt(field_validation.get('maximum')) + pattern_str = field_validation.get('pattern') + pattern = re.compile(pattern_str or '') + + label = field_validation.get('description') or fieldname + + if value_type == 'string' and len(value) > max_length: + errors.append(_('{} should not exceed {} characters').format(label, max_length)) + if value_type == 'number' and (value > maximum or value < minimum): + errors.append(_('{} {} should be between {} and {}').format(label, value, minimum, maximum)) + if pattern_str and not pattern.match(value): + errors.append(field_validation.get('validationMsg')) + + return errors + +class RequestFailed(Exception): pass + +class GSPConnector(): + def __init__(self, doctype=None, docname=None): + self.e_invoice_settings = frappe.get_cached_doc('E Invoice Settings') + sandbox_mode = self.e_invoice_settings.sandbox_mode + + self.invoice = frappe.get_cached_doc(doctype, docname) if doctype and docname else None + self.credentials = self.get_credentials() + + # authenticate url is same for sandbox & live + self.authenticate_url = 'https://gsp.adaequare.com/gsp/authenticate?grant_type=token' + self.base_url = 'https://gsp.adaequare.com' if not sandbox_mode else 'https://gsp.adaequare.com/test' + + self.cancel_irn_url = self.base_url + '/enriched/ei/api/invoice/cancel' + self.irn_details_url = self.base_url + '/enriched/ei/api/invoice/irn' + self.generate_irn_url = self.base_url + '/enriched/ei/api/invoice' + self.gstin_details_url = self.base_url + '/enriched/ei/api/master/gstin' + self.cancel_ewaybill_url = self.base_url + '/enriched/ewb/ewayapi?action=CANEWB' + self.generate_ewaybill_url = self.base_url + '/enriched/ei/api/ewaybill' + + def get_credentials(self): + if self.invoice: + gstin = self.get_seller_gstin() + if not self.e_invoice_settings.enable: + frappe.throw(_("E-Invoicing is disabled. Please enable it from {} to generate e-invoices.").format(get_link_to_form("E Invoice Settings", "E Invoice Settings"))) + credentials = next(d for d in self.e_invoice_settings.credentials if d.gstin == gstin) + else: + credentials = self.e_invoice_settings.credentials[0] if self.e_invoice_settings.credentials else None + return credentials + + def get_seller_gstin(self): + gstin = self.invoice.company_gstin or frappe.db.get_value('Address', self.invoice.company_address, 'gstin') + if not gstin: + frappe.throw(_('Cannot retrieve Company GSTIN. Please select company address with valid GSTIN.')) + return gstin + + def get_auth_token(self): + if time_diff_in_seconds(self.e_invoice_settings.token_expiry, now_datetime()) < 150.0: + self.fetch_auth_token() + + return self.e_invoice_settings.auth_token + + def make_request(self, request_type, url, headers=None, data=None): + if request_type == 'post': + res = make_post_request(url, headers=headers, data=data) + else: + res = make_get_request(url, headers=headers, data=data) + + self.log_request(url, headers, data, res) + return res + + def log_request(self, url, headers, data, res): + headers.update({ 'password': self.credentials.password }) + request_log = frappe.get_doc({ + "doctype": "E Invoice Request Log", + "user": frappe.session.user, + "reference_invoice": self.invoice.name if self.invoice else None, + "url": url, + "headers": json.dumps(headers, indent=4) if headers else None, + "data": json.dumps(data, indent=4) if isinstance(data, dict) else data, + "response": json.dumps(res, indent=4) if res else None + }) + request_log.save(ignore_permissions=True) + frappe.db.commit() + + def fetch_auth_token(self): + headers = { + 'gspappid': frappe.conf.einvoice_client_id, + 'gspappsecret': frappe.conf.einvoice_client_secret + } + res = {} + try: + res = self.make_request('post', self.authenticate_url, headers) + self.e_invoice_settings.auth_token = "{} {}".format(res.get('token_type'), res.get('access_token')) + self.e_invoice_settings.token_expiry = add_to_date(None, seconds=res.get('expires_in')) + self.e_invoice_settings.save(ignore_permissions=True) + self.e_invoice_settings.reload() + + except Exception: + self.log_error(res) + self.raise_error(True) + + def get_headers(self): + return { + 'content-type': 'application/json', + 'user_name': self.credentials.username, + 'password': self.credentials.get_password(), + 'gstin': self.credentials.gstin, + 'authorization': self.get_auth_token(), + 'requestid': str(base64.b64encode(os.urandom(18))), + } + + def fetch_gstin_details(self, gstin): + headers = self.get_headers() + + try: + params = '?gstin={gstin}'.format(gstin=gstin) + res = self.make_request('get', self.gstin_details_url + params, headers) + if res.get('success'): + return res.get('result') + else: + self.log_error(res) + raise RequestFailed + + except RequestFailed: + self.raise_error() + + except Exception: + self.log_error() + self.raise_error(True) + + @staticmethod + def get_gstin_details(gstin): + '''fetch and cache GSTIN details''' + if not hasattr(frappe.local, 'gstin_cache'): + frappe.local.gstin_cache = {} + + key = gstin + gsp_connector = GSPConnector() + details = gsp_connector.fetch_gstin_details(gstin) + + frappe.local.gstin_cache[key] = details + frappe.cache().hset('gstin_cache', key, details) + return details + + def generate_irn(self): + headers = self.get_headers() + einvoice = make_einvoice(self.invoice) + data = json.dumps(einvoice, indent=4) + + try: + res = self.make_request('post', self.generate_irn_url, headers, data) + if res.get('success'): + self.set_einvoice_data(res.get('result')) + + elif '2150' in res.get('message'): + # IRN already generated but not updated in invoice + # Extract the IRN from the response description and fetch irn details + irn = res.get('result')[0].get('Desc').get('Irn') + irn_details = self.get_irn_details(irn) + if irn_details: + self.set_einvoice_data(irn_details) + else: + raise RequestFailed('IRN has already been generated for the invoice but cannot fetch details for the it. \ + Contact ERPNext support to resolve the issue.') + + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.raise_error(errors=errors) + + except Exception: + self.log_error(data) + self.raise_error(True) + + def get_irn_details(self, irn): + headers = self.get_headers() + + try: + params = '?irn={irn}'.format(irn=irn) + res = self.make_request('get', self.irn_details_url + params, headers) + if res.get('success'): + return res.get('result') + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.raise_error(errors=errors) + + except Exception: + self.log_error() + self.raise_error(True) + + def cancel_irn(self, irn, reason, remark): + headers = self.get_headers() + data = json.dumps({ + 'Irn': irn, + 'Cnlrsn': reason, + 'Cnlrem': remark + }, indent=4) + + try: + res = self.make_request('post', self.cancel_irn_url, headers, data) + if res.get('success'): + self.invoice.irn_cancelled = 1 + self.invoice.flags.updater_reference = { + 'doctype': self.invoice.doctype, + 'docname': self.invoice.name, + 'label': _('IRN Cancelled - {}').format(remark) + } + self.update_invoice() + + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.raise_error(errors=errors) + + except Exception: + self.log_error(data) + self.raise_error(True) + + def generate_eway_bill(self, **kwargs): + args = frappe._dict(kwargs) + + headers = self.get_headers() + eway_bill_details = get_eway_bill_details(args) + data = json.dumps({ + 'Irn': args.irn, + 'Distance': cint(eway_bill_details.distance), + 'TransMode': eway_bill_details.mode_of_transport, + 'TransId': eway_bill_details.gstin, + 'TransName': eway_bill_details.transporter, + 'TrnDocDt': eway_bill_details.document_date, + 'TrnDocNo': eway_bill_details.document_name, + 'VehNo': eway_bill_details.vehicle_no, + 'VehType': eway_bill_details.vehicle_type + }, indent=4) + + try: + res = self.make_request('post', self.generate_ewaybill_url, headers, data) + if res.get('success'): + self.invoice.ewaybill = res.get('result').get('EwbNo') + self.invoice.eway_bill_cancelled = 0 + self.invoice.update(args) + self.invoice.flags.updater_reference = { + 'doctype': self.invoice.doctype, + 'docname': self.invoice.name, + 'label': _('E-Way Bill Generated') + } + self.update_invoice() + + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.raise_error(errors=errors) + + except Exception: + self.log_error(data) + self.raise_error(True) + + def cancel_eway_bill(self, eway_bill, reason, remark): + headers = self.get_headers() + data = json.dumps({ + 'ewbNo': eway_bill, + 'cancelRsnCode': reason, + 'cancelRmrk': remark + }, indent=4) + headers["username"] = headers["user_name"] + del headers["user_name"] + try: + res = self.make_request('post', self.cancel_ewaybill_url, headers, data) + if res.get('success'): + self.invoice.ewaybill = '' + self.invoice.eway_bill_cancelled = 1 + self.invoice.flags.updater_reference = { + 'doctype': self.invoice.doctype, + 'docname': self.invoice.name, + 'label': _('E-Way Bill Cancelled - {}').format(remark) + } + self.update_invoice() + + else: + raise RequestFailed + + except RequestFailed: + errors = self.sanitize_error_message(res.get('message')) + self.raise_error(errors=errors) + + except Exception: + self.log_error(data) + self.raise_error(True) + + def sanitize_error_message(self, message): + ''' + On validation errors, response message looks something like this: + message = '2174 : For inter-state transaction, CGST and SGST amounts are not applicable; only IGST amount is applicable, + 3095 : Supplier GSTIN is inactive' + we search for string between ':' to extract the error messages + errors = [ + ': For inter-state transaction, CGST and SGST amounts are not applicable; only IGST amount is applicable, 3095 ', + ': Test' + ] + then we trim down the message by looping over errors + ''' + errors = re.findall(': [^:]+', message) + for idx, e in enumerate(errors): + # remove colons + errors[idx] = errors[idx].replace(':', '').strip() + # if not last + if idx != len(errors) - 1: + # remove last 7 chars eg: ', 3095 ' + errors[idx] = errors[idx][:-6] + + return errors + + def log_error(self, data={}): + if not isinstance(data, dict): + data = json.loads(data) + + seperator = "--" * 50 + err_tb = traceback.format_exc() + err_msg = str(sys.exc_info()[1]) + data = json.dumps(data, indent=4) + + message = "\n".join([ + "Error", err_msg, seperator, + "Data:", data, seperator, + "Exception:", err_tb + ]) + frappe.log_error(title=_('E Invoice Request Failed'), message=message) + + def raise_error(self, raise_exception=False, errors=[]): + title = _('E Invoice Request Failed') + if errors: + frappe.throw(errors, title=title, as_list=1) + else: + link_to_error_list = 'Error Log' + frappe.msgprint( + _('An error occurred while making e-invoicing request. Please check {} for more information.').format(link_to_error_list), + title=title, + raise_exception=raise_exception, + indicator='red' + ) + + def set_einvoice_data(self, res): + enc_signed_invoice = res.get('SignedInvoice') + dec_signed_invoice = jwt.decode(enc_signed_invoice, verify=False)['data'] + + self.invoice.irn = res.get('Irn') + self.invoice.ewaybill = res.get('EwbNo') + self.invoice.signed_einvoice = dec_signed_invoice + self.invoice.signed_qr_code = res.get('SignedQRCode') + + self.attach_qrcode_image() + + self.invoice.flags.updater_reference = { + 'doctype': self.invoice.doctype, + 'docname': self.invoice.name, + 'label': _('IRN Generated') + } + self.update_invoice() + + def attach_qrcode_image(self): + qrcode = self.invoice.signed_qr_code + doctype = self.invoice.doctype + docname = self.invoice.name + filename = 'QRCode_{}.png'.format(docname).replace(os.path.sep, "__") + + qr_image = io.BytesIO() + url = qrcreate(qrcode, error='L') + url.png(qr_image, scale=2, quiet_zone=1) + _file = frappe.get_doc({ + "doctype": "File", + "file_name": filename, + "attached_to_doctype": doctype, + "attached_to_name": docname, + "attached_to_field": "qrcode_image", + "is_private": 1, + "content": qr_image.getvalue()}) + _file.save() + frappe.db.commit() + self.invoice.qrcode_image = _file.file_url + + def update_invoice(self): + self.invoice.flags.ignore_validate_update_after_submit = True + self.invoice.flags.ignore_validate = True + self.invoice.save() + + +def sanitize_for_json(string): + """Escape JSON specific characters from a string.""" + + # json.dumps adds double-quotes to the string. Indexing to remove them. + return json.dumps(string)[1:-1] + +@frappe.whitelist() +def get_einvoice(doctype, docname): + invoice = frappe.get_doc(doctype, docname) + return make_einvoice(invoice) + +@frappe.whitelist() +def generate_irn(doctype, docname): + gsp_connector = GSPConnector(doctype, docname) + gsp_connector.generate_irn() + +@frappe.whitelist() +def cancel_irn(doctype, docname, irn, reason, remark): + gsp_connector = GSPConnector(doctype, docname) + gsp_connector.cancel_irn(irn, reason, remark) + +@frappe.whitelist() +def generate_eway_bill(doctype, docname, **kwargs): + gsp_connector = GSPConnector(doctype, docname) + gsp_connector.generate_eway_bill(**kwargs) + +@frappe.whitelist() +def cancel_eway_bill(doctype, docname, eway_bill, reason, remark): + gsp_connector = GSPConnector(doctype, docname) + gsp_connector.cancel_eway_bill(eway_bill, reason, remark) diff --git a/erpnext/regional/india/gst_state_code_data.json b/erpnext/regional/india/gst_state_code_data.json index ff88e0f9d6c..8481c279728 100644 --- a/erpnext/regional/india/gst_state_code_data.json +++ b/erpnext/regional/india/gst_state_code_data.json @@ -168,5 +168,10 @@ "state_number": "37", "state_code": "AD", "state_name": "Andhra Pradesh (New)" + }, + { + "state_number": "38", + "state_code": "LA", + "state_name": "Ladakh" } ] diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index cbcd6e3203a..ee49aae0501 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -7,7 +7,7 @@ import frappe, os, json from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.permissions import add_permission, update_permission_property from erpnext.regional.india import states -from erpnext.accounts.utils import get_fiscal_year +from erpnext.accounts.utils import get_fiscal_year, FiscalYearError from frappe.utils import today def setup(company=None, patch=True): @@ -21,6 +21,7 @@ def setup_company_independent_fixtures(): add_permissions() add_custom_roles_for_reports() frappe.enqueue('erpnext.regional.india.setup.add_hsn_sac_codes', now=frappe.flags.in_test) + create_gratuity_rule() add_print_formats() def add_hsn_sac_codes(): @@ -87,7 +88,7 @@ def add_custom_roles_for_reports(): )).insert() def add_permissions(): - for doctype in ('GST HSN Code', 'GST Settings', 'GSTR 3B Report', 'Lower Deduction Certificate'): + for doctype in ('GST HSN Code', 'GST Settings', 'GSTR 3B Report', 'Lower Deduction Certificate', 'E Invoice Settings'): add_permission(doctype, 'All', 0) for role in ('Accounts Manager', 'Accounts User', 'System Manager'): add_permission(doctype, role, 0) @@ -103,9 +104,11 @@ def add_permissions(): def add_print_formats(): frappe.reload_doc("regional", "print_format", "gst_tax_invoice") frappe.reload_doc("accounts", "print_format", "gst_pos_invoice") + frappe.reload_doc("accounts", "print_format", "GST E-Invoice") - frappe.db.sql(""" update `tabPrint Format` set disabled = 0 where - name in('GST POS Invoice', 'GST Tax Invoice') """) + frappe.db.set_value("Print Format", "GST POS Invoice", "disabled", 0) + frappe.db.set_value("Print Format", "GST Tax Invoice", "disabled", 0) + frappe.db.set_value("Print Format", "GST E-Invoice", "disabled", 0) def make_custom_fields(update=True): hsn_sac_field = dict(fieldname='gst_hsn_code', label='HSN/SAC', @@ -351,7 +354,6 @@ def make_custom_fields(update=True): 'label': 'Mode of Transport', 'fieldtype': 'Select', 'options': '\nRoad\nAir\nRail\nShip', - 'default': 'Road', 'insert_after': 'transporter_name', 'print_hide': 1, 'translatable': 0 @@ -388,13 +390,34 @@ def make_custom_fields(update=True): 'fieldname': 'ewaybill', 'label': 'E-Way Bill No.', 'fieldtype': 'Data', - 'depends_on': 'eval:(doc.docstatus === 1)', + 'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)', 'allow_on_submit': 1, 'insert_after': 'tax_id', 'translatable': 0 } ] + si_einvoice_fields = [ + dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1, + depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'), + + dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='irn', no_copy=1, print_hide=1), + + dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1), + + dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), + + dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1, + depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'), + + dict(fieldname='signed_einvoice', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='signed_qr_code', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1), + + dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, no_copy=1, print_hide=1, read_only=1) + ] + custom_fields = { 'Address': [ dict(fieldname='gstin', label='Party GSTIN', fieldtype='Data', @@ -407,7 +430,7 @@ def make_custom_fields(update=True): 'Purchase Invoice': purchase_invoice_gst_category + invoice_gst_fields + purchase_invoice_itc_fields + purchase_invoice_gst_fields, 'Purchase Order': purchase_invoice_gst_fields, 'Purchase Receipt': purchase_invoice_gst_fields, - 'Sales Invoice': sales_invoice_gst_category + invoice_gst_fields + sales_invoice_shipping_fields + sales_invoice_gst_fields + si_ewaybill_fields, + 'Sales Invoice': sales_invoice_gst_category + invoice_gst_fields + sales_invoice_shipping_fields + sales_invoice_gst_fields + si_ewaybill_fields + si_einvoice_fields, 'Delivery Note': sales_invoice_gst_fields + ewaybill_fields + sales_invoice_shipping_fields, 'Sales Order': sales_invoice_gst_fields, 'Tax Category': inter_state_gst_field, @@ -477,6 +500,14 @@ def make_custom_fields(update=True): fieldtype='Link', options='Salary Component', insert_after='basic_component'), dict(fieldname='arrear_component', label='Arrear Component', fieldtype='Link', options='Salary Component', insert_after='hra_component'), + dict(fieldname='non_profit_section', label='Non Profit Settings', + fieldtype='Section Break', insert_after='asset_received_but_not_billed', collapsible=1), + dict(fieldname='company_80g_number', label='80G Number', + fieldtype='Data', insert_after='non_profit_section'), + dict(fieldname='with_effect_from', label='80G With Effect From', + fieldtype='Date', insert_after='company_80g_number'), + dict(fieldname='pan_details', label='PAN Number', + fieldtype='Data', insert_after='with_effect_from') ], 'Employee Tax Exemption Declaration':[ dict(fieldname='hra_section', label='HRA Exemption', @@ -559,7 +590,15 @@ def make_custom_fields(update=True): 'options': '\nWith Payment of Tax\nWithout Payment of Tax' } ], - "Member": [ + 'Member': [ + { + 'fieldname': 'pan_number', + 'label': 'PAN Details', + 'fieldtype': 'Data', + 'insert_after': 'email_id' + } + ], + 'Donor': [ { 'fieldname': 'pan_number', 'label': 'PAN Details', @@ -608,13 +647,18 @@ def set_salary_components(docs): def set_tax_withholding_category(company): accounts = [] + fiscal_year = None abbr = frappe.get_value("Company", company, "abbr") tds_account = frappe.get_value("Account", 'TDS Payable - {0}'.format(abbr), 'name') if company and tds_account: accounts = [dict(company=company, account=tds_account)] - fiscal_year = get_fiscal_year(today(), company=company)[0] + try: + fiscal_year = get_fiscal_year(today(), verbose=0, company=company)[0] + except FiscalYearError: + pass + docs = get_tds_details(accounts, fiscal_year) for d in docs: @@ -629,11 +673,14 @@ def set_tax_withholding_category(company): if accounts: doc.append("accounts", accounts[0]) - # if fiscal year don't match with any of the already entered data, append rate row - fy_exist = [k for k in doc.get('rates') if k.get('fiscal_year')==fiscal_year] - if not fy_exist: - doc.append("rates", d.get('rates')[0]) + if fiscal_year: + # if fiscal year don't match with any of the already entered data, append rate row + fy_exist = [k for k in doc.get('rates') if k.get('fiscal_year')==fiscal_year] + if not fy_exist: + doc.append("rates", d.get('rates')[0]) + doc.flags.ignore_permissions = True + doc.flags.ignore_mandatory = True doc.save() def set_tds_account(docs, company): @@ -793,4 +840,24 @@ def get_tds_details(accounts, fiscal_year): doctype="Tax Withholding Category", accounts=accounts, rates=[{"fiscal_year": fiscal_year, "tax_withholding_rate": 20, "single_threshold": 2500, "cumulative_threshold": 0}]) - ] \ No newline at end of file + ] + +def create_gratuity_rule(): + + # Standard Indain Gratuity Rule + if not frappe.db.exists("Gratuity Rule", "Indian Standard Gratuity Rule"): + rule = frappe.new_doc("Gratuity Rule") + rule.name = "Indian Standard Gratuity Rule" + rule.calculate_gratuity_amount_based_on = "Current Slab" + rule.work_experience_calculation_method = "Round Off Work Experience" + rule.minimum_year_for_gratuity = 5 + + fraction = 15/26 + rule.append("gratuity_rule_slabs", { + "from_year": 0, + "to_year":0, + "fraction_of_applicable_earnings": fraction + }) + + rule.flags.ignore_mandatory = True + rule.save() \ No newline at end of file diff --git a/erpnext/regional/india/taxes.js b/erpnext/regional/india/taxes.js index 3b6a28f52c0..d3b7ea3b1a0 100644 --- a/erpnext/regional/india/taxes.js +++ b/erpnext/regional/india/taxes.js @@ -12,6 +12,9 @@ erpnext.setup_auto_gst_taxation = (doctype) => { tax_category: function(frm) { frm.trigger('get_tax_template'); }, + customer_address: function(frm) { + frm.trigger('get_tax_template'); + }, get_tax_template: function(frm) { if (!frm.doc.company) return; @@ -19,6 +22,7 @@ erpnext.setup_auto_gst_taxation = (doctype) => { 'shipping_address': frm.doc.shipping_address || '', 'shipping_address_name': frm.doc.shipping_address_name || '', 'customer_address': frm.doc.customer_address || '', + 'supplier_address': frm.doc.supplier_address, 'customer': frm.doc.customer, 'supplier': frm.doc.supplier, 'supplier_gstin': frm.doc.supplier_gstin, @@ -31,19 +35,18 @@ erpnext.setup_auto_gst_taxation = (doctype) => { args: { party_details: JSON.stringify(party_details), doctype: frm.doc.doctype, - company: frm.doc.company, - return_taxes: 1 + company: frm.doc.company }, + debounce: 2000, callback: function(r) { if(r.message) { frm.set_value('taxes_and_charges', r.message.taxes_and_charges); - } else if (frm.doc.is_internal_supplier || frm.doc.is_internal_customer) { - frm.set_value('taxes_and_charges', ''); - frm.set_value('taxes', []); + frm.set_value('taxes', r.message.taxes); + frm.set_value('place_of_supply', r.message.place_of_supply); } } }); } }); -}; +} diff --git a/erpnext/regional/india/test_utils.py b/erpnext/regional/india/test_utils.py new file mode 100644 index 00000000000..7ce27f6cf5a --- /dev/null +++ b/erpnext/regional/india/test_utils.py @@ -0,0 +1,38 @@ +from __future__ import unicode_literals + +import unittest +import frappe +from unittest.mock import patch +from erpnext.regional.india.utils import validate_document_name + + +class TestIndiaUtils(unittest.TestCase): + @patch("frappe.get_cached_value") + def test_validate_document_name(self, mock_get_cached): + mock_get_cached.return_value = "India" # mock country + posting_date = "2021-05-01" + + invalid_names = [ "SI$1231", "012345678901234567", "SI 2020 05", + "SI.2020.0001", "PI2021 - 001" ] + for name in invalid_names: + doc = frappe._dict(name=name, posting_date=posting_date) + self.assertRaises(frappe.ValidationError, validate_document_name, doc) + + valid_names = [ "012345678901236", "SI/2020/0001", "SI/2020-0001", + "2020-PI-0001", "PI2020-0001" ] + for name in valid_names: + doc = frappe._dict(name=name, posting_date=posting_date) + try: + validate_document_name(doc) + except frappe.ValidationError: + self.fail("Valid name {} throwing error".format(name)) + + @patch("frappe.get_cached_value") + def test_validate_document_name_not_india(self, mock_get_cached): + mock_get_cached.return_value = "Not India" + doc = frappe._dict(name="SI$123", posting_date="2021-05-01") + + try: + validate_document_name(doc) + except frappe.ValidationError: + self.fail("Regional validation related to India are being applied to other countries") diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 69e47a43c41..3637de438cd 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import frappe, re, json from frappe import _ import erpnext -from frappe.utils import cstr, flt, date_diff, nowdate, round_based_on_smallest_currency_fraction, money_in_words +from frappe.utils import cstr, flt, date_diff, nowdate, round_based_on_smallest_currency_fraction, money_in_words, getdate from erpnext.regional.india import states, state_numbers from erpnext.controllers.taxes_and_totals import get_itemised_tax, get_itemised_taxable_amount from erpnext.controllers.accounts_controller import get_taxes_and_charges @@ -12,6 +12,14 @@ from erpnext.regional.india import number_state_mapping from six import string_types from erpnext.accounts.general_ledger import make_gl_entries from erpnext.accounts.utils import get_account_currency +from frappe.model.utils import get_fetch_values + + +GST_INVOICE_NUMBER_FORMAT = re.compile(r"^[a-zA-Z0-9\-/]+$") #alphanumeric and - / +GSTIN_FORMAT = re.compile("^[0-9]{2}[A-Z]{4}[0-9A-Z]{1}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}[1-9A-Z]{1}[0-9A-Z]{1}$") +GSTIN_UIN_FORMAT = re.compile("^[0-9]{4}[A-Z]{3}[0-9]{5}[0-9A-Z]{3}") +PAN_NUMBER_FORMAT = re.compile("[A-Z]{5}[0-9]{4}[A-Z]{1}") + def validate_gstin_for_india(doc, method): if hasattr(doc, 'gst_state') and doc.gst_state: @@ -36,21 +44,36 @@ def validate_gstin_for_india(doc, method): frappe.throw(_("Invalid GSTIN! A GSTIN must have 15 characters.")) if gst_category and gst_category == 'UIN Holders': - p = re.compile("^[0-9]{4}[A-Z]{3}[0-9]{5}[0-9A-Z]{3}") - if not p.match(doc.gstin): + if not GSTIN_UIN_FORMAT.match(doc.gstin): frappe.throw(_("Invalid GSTIN! The input you've entered doesn't match the GSTIN format for UIN Holders or Non-Resident OIDAR Service Providers")) else: - p = re.compile("^[0-9]{2}[A-Z]{4}[0-9A-Z]{1}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}[1-9A-Z]{1}[0-9A-Z]{1}$") - if not p.match(doc.gstin): + if not GSTIN_FORMAT.match(doc.gstin): frappe.throw(_("Invalid GSTIN! The input you've entered doesn't match the format of GSTIN.")) validate_gstin_check_digit(doc.gstin) set_gst_state_and_state_number(doc) + if not doc.gst_state: + frappe.throw(_("Please Enter GST state")) + if doc.gst_state_number != doc.gstin[:2]: frappe.throw(_("Invalid GSTIN! First 2 digits of GSTIN should match with State number {0}.") .format(doc.gst_state_number)) +def validate_pan_for_india(doc, method): + if doc.get('country') != 'India' or not doc.pan: + return + + if not PAN_NUMBER_FORMAT.match(doc.pan): + frappe.throw(_("Invalid PAN No. The input you've entered doesn't match the format of PAN.")) + +def validate_tax_category(doc, method): + if doc.get('gst_state') and frappe.db.get_value('Tax Category', {'gst_state': doc.gst_state, 'is_inter_state': doc.is_inter_state}): + if doc.is_inter_state: + frappe.throw(_("Inter State tax category for GST State {0} already exists").format(doc.gst_state)) + else: + frappe.throw(_("Intra State tax category for GST State {0} already exists").format(doc.gst_state)) + def update_gst_category(doc, method): for link in doc.links: if link.link_doctype in ['Customer', 'Supplier']: @@ -85,8 +108,7 @@ def validate_gstin_check_digit(gstin, label='GSTIN'): total += digit factor = 2 if factor == 1 else 1 if gstin[-1] != code_point_chars[((mod - (total % mod)) % mod)]: - frappe.throw(_("""Invalid {0}! The check digit validation has failed. - Please ensure you've typed the {0} correctly.""".format(label))) + frappe.throw(_("""Invalid {0}! The check digit validation has failed. Please ensure you've typed the {0} correctly.""").format(label)) def get_itemised_tax_breakup_header(item_doctype, tax_accounts): if frappe.get_meta(item_doctype).has_field('gst_hsn_code'): @@ -130,6 +152,20 @@ def get_itemised_tax_breakup_data(doc, account_wise=False): def set_place_of_supply(doc, method=None): doc.place_of_supply = get_place_of_supply(doc, doc.doctype) +def validate_document_name(doc, method=None): + """Validate GST invoice number requirements.""" + country = frappe.get_cached_value("Company", doc.company, "country") + + # Date was chosen as start of next FY to avoid irritating current users. + if country != "India" or getdate(doc.posting_date) < getdate("2021-04-01"): + return + + if len(doc.name) > 16: + frappe.throw(_("Maximum length of document number should be 16 characters as per GST rules. Please change the naming series.")) + + if not GST_INVOICE_NUMBER_FORMAT.match(doc.name): + frappe.throw(_("Document name should only contain alphanumeric values, dash(-) and slash(/) characters as per GST rules. Please change the naming series.")) + # don't remove this function it is used in tests def test_method(): '''test function''' @@ -139,7 +175,7 @@ def get_place_of_supply(party_details, doctype): if not frappe.get_meta('Address').has_field('gst_state'): return if doctype in ("Sales Invoice", "Delivery Note", "Sales Order"): - address_name = party_details.shipping_address_name or party_details.customer_address + address_name = party_details.customer_address or party_details.shipping_address_name elif doctype in ("Purchase Invoice", "Purchase Order", "Purchase Receipt"): address_name = party_details.shipping_address or party_details.supplier_address @@ -150,17 +186,19 @@ def get_place_of_supply(party_details, doctype): return cstr(address.gst_state_number) + "-" + cstr(address.gst_state) @frappe.whitelist() -def get_regional_address_details(party_details, doctype, company, return_taxes=None): +def get_regional_address_details(party_details, doctype, company): if isinstance(party_details, string_types): party_details = json.loads(party_details) party_details = frappe._dict(party_details) + update_party_details(party_details, doctype) + party_details.place_of_supply = get_place_of_supply(party_details, doctype) if is_internal_transfer(party_details, doctype): party_details.taxes_and_charges = '' - party_details.taxes = '' - return + party_details.taxes = [] + return party_details if doctype in ("Sales Invoice", "Delivery Note", "Sales Order"): master_doctype = "Sales Taxes and Charges Template" @@ -168,26 +206,26 @@ def get_regional_address_details(party_details, doctype, company, return_taxes=N get_tax_template_for_sez(party_details, master_doctype, company, 'Customer') get_tax_template_based_on_category(master_doctype, company, party_details) - if party_details.get('taxes_and_charges') and return_taxes: + if party_details.get('taxes_and_charges'): return party_details if not party_details.company_gstin: - return + return party_details elif doctype in ("Purchase Invoice", "Purchase Order", "Purchase Receipt"): master_doctype = "Purchase Taxes and Charges Template" get_tax_template_for_sez(party_details, master_doctype, company, 'Supplier') get_tax_template_based_on_category(master_doctype, company, party_details) - if party_details.get('taxes_and_charges') and return_taxes: + if party_details.get('taxes_and_charges'): return party_details if not party_details.supplier_gstin: - return + return party_details - if not party_details.place_of_supply: return + if not party_details.place_of_supply: return party_details - if not party_details.company_gstin: return + if not party_details.company_gstin: return party_details if ((doctype in ("Sales Invoice", "Delivery Note", "Sales Order") and party_details.company_gstin and party_details.company_gstin[:2] != party_details.place_of_supply[:2]) or (doctype in ("Purchase Invoice", @@ -197,12 +235,16 @@ def get_regional_address_details(party_details, doctype, company, return_taxes=N default_tax = get_tax_template(master_doctype, company, 0, party_details.company_gstin[:2]) if not default_tax: - return + return party_details party_details["taxes_and_charges"] = default_tax party_details.taxes = get_taxes_and_charges(master_doctype, default_tax) - if return_taxes: - return party_details + return party_details + +def update_party_details(party_details, doctype): + for address_field in ['shipping_address', 'company_address', 'supplier_address', 'shipping_address_name', 'customer_address']: + if party_details.get(address_field): + party_details.update(get_fetch_values(doctype, address_field, party_details.get(address_field))) def is_internal_transfer(party_details, doctype): if doctype in ("Sales Invoice", "Delivery Note", "Sales Order"): @@ -236,7 +278,7 @@ def get_tax_template(master_doctype, company, is_inter_state, state_code): if tax_category.gst_state == number_state_mapping[state_code] or \ (not default_tax and not tax_category.gst_state): default_tax = frappe.db.get_value(master_doctype, - {'disabled': 0, 'tax_category': tax_category.name}, 'name') + {'company': company, 'disabled': 0, 'tax_category': tax_category.name}, 'name') return default_tax def get_tax_template_for_sez(party_details, master_doctype, company, party_type): @@ -517,6 +559,9 @@ def get_address_details(data, doc, company_address, billing_address): data.actualToStateCode = data.toStateCode shipping_address = billing_address + if doc.gst_category == 'SEZ': + data.toStateCode = 99 + return data def get_item_list(data, doc): @@ -674,25 +719,12 @@ def update_grand_total_for_rcm(doc, method): if country != 'India': return - if not doc.total_taxes_and_charges: + gst_tax, base_gst_tax = get_gst_tax_amount(doc) + + if not base_gst_tax: return if doc.reverse_charge == 'Y': - gst_accounts = get_gst_accounts(doc.company) - gst_account_list = gst_accounts.get('cgst_account') + gst_accounts.get('sgst_account') \ - + gst_accounts.get('igst_account') - - base_gst_tax = 0 - gst_tax = 0 - - for tax in doc.get('taxes'): - if tax.category not in ("Total", "Valuation and Total"): - continue - - if flt(tax.base_tax_amount_after_discount_amount) and tax.account_head in gst_account_list: - base_gst_tax += tax.base_tax_amount_after_discount_amount - gst_tax += tax.tax_amount_after_discount_amount - doc.taxes_and_charges_added -= gst_tax doc.total_taxes_and_charges -= gst_tax doc.base_taxes_and_charges_added -= base_gst_tax @@ -726,6 +758,11 @@ def make_regional_gl_entries(gl_entries, doc): if country != 'India': return gl_entries + gst_tax, base_gst_tax = get_gst_tax_amount(doc) + + if not base_gst_tax: + return gl_entries + if doc.reverse_charge == 'Y': gst_accounts = get_gst_accounts(doc.company) gst_account_list = gst_accounts.get('cgst_account') + gst_accounts.get('sgst_account') \ @@ -752,4 +789,46 @@ def make_regional_gl_entries(gl_entries, doc): }, account_currency, item=tax) ) - return gl_entries \ No newline at end of file + return gl_entries + +def get_gst_tax_amount(doc): + gst_accounts = get_gst_accounts(doc.company) + gst_account_list = gst_accounts.get('cgst_account', []) + gst_accounts.get('sgst_account', []) \ + + gst_accounts.get('igst_account', []) + + base_gst_tax = 0 + gst_tax = 0 + + for tax in doc.get('taxes'): + if tax.category not in ("Total", "Valuation and Total"): + continue + + if flt(tax.base_tax_amount_after_discount_amount) and tax.account_head in gst_account_list: + base_gst_tax += tax.base_tax_amount_after_discount_amount + gst_tax += tax.tax_amount_after_discount_amount + + return gst_tax, base_gst_tax + +@frappe.whitelist() +def get_regional_round_off_accounts(company, account_list): + country = frappe.get_cached_value('Company', company, 'country') + + if country != 'India': + return + + if isinstance(account_list, string_types): + account_list = json.loads(account_list) + + if not frappe.db.get_single_value('GST Settings', 'round_off_gst_values'): + return + + gst_accounts = get_gst_accounts(company) + + gst_account_list = [] + for account in ['cgst_account', 'sgst_account', 'igst_account']: + if account in gst_accounts: + gst_account_list += gst_accounts.get(account) + + account_list.extend(gst_account_list) + + return account_list diff --git a/erpnext/regional/italy/sales_invoice.js b/erpnext/regional/italy/sales_invoice.js index 586a52937b5..b54ac538126 100644 --- a/erpnext/regional/italy/sales_invoice.js +++ b/erpnext/regional/italy/sales_invoice.js @@ -11,15 +11,10 @@ erpnext.setup_e_invoice_button = (doctype) => { callback: function(r) { frm.reload_doc(); if(r.message) { - var w = window.open( - frappe.urllib.get_full_url( - "/api/method/erpnext.regional.italy.utils.download_e_invoice_file?" - + "file_name=" + r.message - ) - ) - if (!w) { - frappe.msgprint(__("Please enable pop-ups")); return; - } + open_url_post(frappe.request.url, { + cmd: 'frappe.core.doctype.file.file.download_file', + file_url: r.message + }); } } }); diff --git a/erpnext/regional/italy/setup.py b/erpnext/regional/italy/setup.py index 6ab73413df2..a1f5bb98367 100644 --- a/erpnext/regional/italy/setup.py +++ b/erpnext/regional/italy/setup.py @@ -127,12 +127,9 @@ def make_custom_fields(update=True): options="\n".join(map(lambda x: frappe.safe_decode(x, encoding='utf-8'), vat_collectability_options)), fetch_from="company.vat_collectability"), dict(fieldname='sb_e_invoicing_reference', label='E-Invoicing', - fieldtype='Section Break', insert_after='pos_total_qty', print_hide=1), - dict(fieldname='company_tax_id', label='Company Tax ID', - fieldtype='Data', insert_after='sb_e_invoicing_reference', print_hide=1, read_only=1, - fetch_from="company.tax_id"), + fieldtype='Section Break', insert_after='against_income_account', print_hide=1), dict(fieldname='company_fiscal_code', label='Company Fiscal Code', - fieldtype='Data', insert_after='company_tax_id', print_hide=1, read_only=1, + fieldtype='Data', insert_after='sb_e_invoicing_reference', print_hide=1, read_only=1, fetch_from="company.fiscal_code"), dict(fieldname='company_fiscal_regime', label='Company Fiscal Regime', fieldtype='Data', insert_after='company_fiscal_code', print_hide=1, read_only=1, @@ -189,9 +186,7 @@ def make_custom_fields(update=True): def setup_report(): report_name = 'Electronic Invoice Register' - - frappe.db.sql(""" update `tabReport` set disabled = 0 where - name = %s """, report_name) + frappe.db.set_value("Report", report_name, "disabled", 0) if not frappe.db.get_value('Custom Role', dict(report=report_name)): frappe.get_doc(dict( @@ -219,4 +214,4 @@ def add_permissions(): update_permission_property(doctype, 'Accounts Manager', 0, 'delete', 1) add_permission(doctype, 'Accounts Manager', 1) update_permission_property(doctype, 'Accounts Manager', 1, 'write', 1) - update_permission_property(doctype, 'Accounts Manager', 1, 'create', 1) \ No newline at end of file + update_permission_property(doctype, 'Accounts Manager', 1, 'create', 1) diff --git a/erpnext/regional/italy/utils.py b/erpnext/regional/italy/utils.py index 6842fb2a619..08573cddcda 100644 --- a/erpnext/regional/italy/utils.py +++ b/erpnext/regional/italy/utils.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals -import frappe, json, os +import io +import json +import frappe from frappe.utils import flt, cstr from erpnext.controllers.taxes_and_totals import get_itemised_tax from frappe import _ @@ -28,20 +30,22 @@ def update_itemised_tax_data(doc): @frappe.whitelist() def export_invoices(filters=None): - saved_xmls = [] + frappe.has_permission('Sales Invoice', throw=True) - invoices = frappe.get_all("Sales Invoice", filters=get_conditions(filters), fields=["*"]) + invoices = frappe.get_all( + "Sales Invoice", + filters=get_conditions(filters), + fields=["name", "company_tax_id"] + ) - for invoice in invoices: - attachments = get_e_invoice_attachments(invoice) - saved_xmls += [attachment.file_name for attachment in attachments] + attachments = get_e_invoice_attachments(invoices) - zip_filename = "{0}-einvoices.zip".format(frappe.utils.get_datetime().strftime("%Y%m%d_%H%M%S")) + zip_filename = "{0}-einvoices.zip".format( + frappe.utils.get_datetime().strftime("%Y%m%d_%H%M%S")) - download_zip(saved_xmls, zip_filename) + download_zip(attachments, zip_filename) -@frappe.whitelist() def prepare_invoice(invoice, progressive_number): #set company information company = frappe.get_doc("Company", invoice.company) @@ -98,7 +102,7 @@ def prepare_invoice(invoice, progressive_number): def get_conditions(filters): filters = json.loads(filters) - conditions = {"docstatus": 1} + conditions = {"docstatus": 1, "company_tax_id": ("!=", "")} if filters.get("company"): conditions["company"] = filters["company"] if filters.get("customer"): conditions["customer"] = filters["customer"] @@ -111,23 +115,22 @@ def get_conditions(filters): return conditions -#TODO: Use function from frappe once PR #6853 is merged. + def download_zip(files, output_filename): - from zipfile import ZipFile + import zipfile - input_files = [frappe.get_site_path('private', 'files', filename) for filename in files] - output_path = frappe.get_site_path('private', 'files', output_filename) + zip_stream = io.BytesIO() + with zipfile.ZipFile(zip_stream, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for file in files: + file_path = frappe.utils.get_files_path( + file.file_name, is_private=file.is_private) - with ZipFile(output_path, 'w') as output_zip: - for input_file in input_files: - output_zip.write(input_file, arcname=os.path.basename(input_file)) - - with open(output_path, 'rb') as fileobj: - filedata = fileobj.read() + zip_file.write(file_path, arcname=file.file_name) frappe.local.response.filename = output_filename - frappe.local.response.filecontent = filedata + frappe.local.response.filecontent = zip_stream.getvalue() frappe.local.response.type = "download" + zip_stream.close() def get_invoice_summary(items, taxes): summary_data = frappe._dict() @@ -307,23 +310,12 @@ def prepare_and_attach_invoice(doc, replace=False): @frappe.whitelist() def generate_single_invoice(docname): doc = frappe.get_doc("Sales Invoice", docname) - + frappe.has_permission("Sales Invoice", doc=doc, throw=True) e_invoice = prepare_and_attach_invoice(doc, True) + return e_invoice.file_url - return e_invoice.file_name - -@frappe.whitelist() -def download_e_invoice_file(file_name): - content = None - with open(frappe.get_site_path('private', 'files', file_name), "r") as f: - content = f.read() - - frappe.local.response.filename = file_name - frappe.local.response.filecontent = content - frappe.local.response.type = "download" - -#Delete e-invoice attachment on cancel. +# Delete e-invoice attachment on cancel. def sales_invoice_on_cancel(doc, method): if get_company_country(doc.company) not in ['Italy', 'Italia', 'Italian Republic', 'Repubblica Italiana']: @@ -335,16 +327,38 @@ def sales_invoice_on_cancel(doc, method): def get_company_country(company): return frappe.get_cached_value('Company', company, 'country') -def get_e_invoice_attachments(invoice): - if not invoice.company_tax_id: - return [] +def get_e_invoice_attachments(invoices): + if not isinstance(invoices, list): + if not invoices.company_tax_id: + return + + invoices = [invoices] + + tax_id_map = { + invoice.name: ( + invoice.company_tax_id + if invoice.company_tax_id.startswith("IT") + else "IT" + invoice.company_tax_id + ) for invoice in invoices + } + + attachments = frappe.get_all( + "File", + fields=("name", "file_name", "attached_to_name", "is_private"), + filters= { + "attached_to_name": ('in', tax_id_map), + "attached_to_doctype": 'Sales Invoice' + } + ) out = [] - attachments = get_attachments(invoice.doctype, invoice.name) - company_tax_id = invoice.company_tax_id if invoice.company_tax_id.startswith("IT") else "IT" + invoice.company_tax_id - for attachment in attachments: - if attachment.file_name and attachment.file_name.startswith(company_tax_id) and attachment.file_name.endswith(".xml"): + if ( + attachment.file_name + and attachment.file_name.endswith(".xml") + and attachment.file_name.startswith( + tax_id_map.get(attachment.attached_to_name)) + ): out.append(attachment) return out diff --git a/erpnext/regional/print_format/80g_certificate_for_donation/80g_certificate_for_donation.json b/erpnext/regional/print_format/80g_certificate_for_donation/80g_certificate_for_donation.json new file mode 100644 index 00000000000..a8da0bd2097 --- /dev/null +++ b/erpnext/regional/print_format/80g_certificate_for_donation/80g_certificate_for_donation.json @@ -0,0 +1,26 @@ +{ + "absolute_value": 0, + "align_labels_right": 0, + "creation": "2021-02-22 00:17:33.878581", + "css": ".details {\n font-size: 15px;\n font-family: Tahoma, sans-serif;;\n line-height: 150%;\n}\n\n.certificate-footer {\n font-size: 15px;\n font-family: Tahoma, sans-serif;\n line-height: 140%;\n margin-top: 120px;\n}\n\n.company-address {\n color: #666666;\n font-size: 15px;\n font-family: Tahoma, sans-serif;;\n}", + "custom_format": 1, + "default_print_language": "en", + "disabled": 0, + "doc_type": "Tax Exemption 80G Certificate", + "docstatus": 0, + "doctype": "Print Format", + "font": "Default", + "html": "{% if letter_head and not no_letterhead -%}\n
    {{ letter_head }}
    \n{%- endif %}\n\n
    \n

    {{ doc.company }} 80G Donor Certificate

    \n
    \n

    \n\n
    \n

    {{ _(\"Certificate No. : \") }} {{ doc.name }}

    \n

    \n \t{{ _(\"Date\") }} : {{ doc.get_formatted(\"date\") }}
    \n

    \n

    \n \n
    \n\n This is to confirm that the {{ doc.company }} received an amount of {{doc.get_formatted(\"amount\")}}\n from {{ doc.donor_name }}\n {% if doc.pan_number -%}\n bearing PAN Number {{ doc.member_pan_number }}\n {%- endif %}\n\n via the Mode of Payment {{doc.mode_of_payment}}\n\n {% if doc.razorpay_payment_id -%}\n bearing RazorPay Payment ID {{ doc.razorpay_payment_id }}\n {%- endif %}\n\n on {{ doc.get_formatted(\"date_of_donation\") }}\n

    \n \n

    \n We thank you for your contribution towards the corpus of the {{ doc.company }} and helping support our work.\n

    \n\n
    \n
    \n\n

    \n

    {{doc.company_address_display }}

    \n\n", + "idx": 0, + "line_breaks": 0, + "modified": "2021-02-22 00:20:08.516600", + "modified_by": "Administrator", + "module": "Regional", + "name": "80G Certificate for Donation", + "owner": "Administrator", + "print_format_builder": 0, + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 0, + "standard": "Yes" +} \ No newline at end of file diff --git a/erpnext/regional/print_format/80g_certificate_for_donation/__init__.py b/erpnext/regional/print_format/80g_certificate_for_donation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/print_format/80g_certificate_for_membership/80g_certificate_for_membership.json b/erpnext/regional/print_format/80g_certificate_for_membership/80g_certificate_for_membership.json new file mode 100644 index 00000000000..f1b15aab298 --- /dev/null +++ b/erpnext/regional/print_format/80g_certificate_for_membership/80g_certificate_for_membership.json @@ -0,0 +1,26 @@ +{ + "absolute_value": 0, + "align_labels_right": 0, + "creation": "2021-02-15 16:53:55.026611", + "css": ".details {\n font-size: 15px;\n font-family: Tahoma, sans-serif;;\n line-height: 150%;\n}\n\n.certificate-footer {\n font-size: 15px;\n font-family: Tahoma, sans-serif;\n line-height: 140%;\n margin-top: 120px;\n}\n\n.company-address {\n color: #666666;\n font-size: 15px;\n font-family: Tahoma, sans-serif;;\n}", + "custom_format": 1, + "default_print_language": "en", + "disabled": 0, + "doc_type": "Tax Exemption 80G Certificate", + "docstatus": 0, + "doctype": "Print Format", + "font": "Default", + "html": "{% if letter_head and not no_letterhead -%}\n
    {{ letter_head }}
    \n{%- endif %}\n\n
    \n

    {{ doc.company }} Members 80G Donor Certificate

    \n

    Financial Cycle {{ doc.fiscal_year }}

    \n
    \n

    \n\n
    \n

    {{ _(\"Certificate No. : \") }} {{ doc.name }}

    \n

    \n \t{{ _(\"Date\") }} : {{ doc.get_formatted(\"date\") }}
    \n

    \n

    \n \n
    \n This is to confirm that the {{ doc.company }} received a total amount of {{doc.get_formatted(\"total\")}}\n from {{ doc.member_name }}\n {% if doc.pan_number -%}\n bearing PAN Number {{ doc.member_pan_number }}\n {%- endif %}\n as per the payment details given below:\n \n

    \n \n \t\n \t\t\n \t\t\t\n \t\t\t\n \t\t\t\n \t\t\n \t\n \t\n \t\t{%- for payment in doc.payments -%}\n \t\t\n \t\t\t\n \t\t\t\n \t\t\t\n \t\t\n \t\t{%- endfor -%}\n \t\n
    {{ _(\"Date\") }}{{ _(\"Amount\") }}{{ _(\"Invoice ID\") }}
    {{ payment.date }} {{ payment.get_formatted(\"amount\") }}{{ payment.invoice_id }}
    \n \n
    \n \n

    \n We thank you for your contribution towards the corpus of the {{ doc.company }} and helping support our work.\n

    \n\n
    \n
    \n\n

    \n

    {{doc.company_address_display }}

    \n\n", + "idx": 0, + "line_breaks": 0, + "modified": "2021-02-21 23:29:00.778973", + "modified_by": "Administrator", + "module": "Regional", + "name": "80G Certificate for Membership", + "owner": "Administrator", + "print_format_builder": 0, + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 0, + "standard": "Yes" +} \ No newline at end of file diff --git a/erpnext/regional/print_format/80g_certificate_for_membership/__init__.py b/erpnext/regional/print_format/80g_certificate_for_membership/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/print_format/irs_1099_form/irs_1099_form.json b/erpnext/regional/print_format/irs_1099_form/irs_1099_form.json index ce8c44a9a19..e59700f5a5e 100644 --- a/erpnext/regional/print_format/irs_1099_form/irs_1099_form.json +++ b/erpnext/regional/print_format/irs_1099_form/irs_1099_form.json @@ -1,23 +1,26 @@ -[ - { - "align_labels_right": 0, - "css": "", - "custom_format": 1, - "default_print_language": "en", - "disabled": 0, - "doc_type": "Supplier", - "docstatus": 0, - "doctype": "Print Format", - "font": "Default", - "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"
    \\t\\t\\t\\t

    TAX Invoice
    {{ doc.name }}\\t\\t\\t\\t

    \"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"customer_name\", \"label\": \"Customer Name\"}, {\"print_hide\": 0, \"fieldname\": \"customer_name_in_arabic\", \"label\": \"Customer Name in Arabic\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"posting_date\", \"label\": \"Date\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Address\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"company\", \"label\": \"Company\"}, {\"print_hide\": 0, \"fieldname\": \"company_trn\", \"label\": \"Company TRN\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"company_address_display\", \"label\": \"Company Address\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"visible_columns\": [{\"print_hide\": 0, \"fieldname\": \"item_code\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"description\", \"print_width\": \"200px\"}, {\"print_hide\": 0, \"fieldname\": \"uom\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"tax_code\", \"print_width\": \"\"}], \"print_hide\": 0, \"fieldname\": \"items\", \"label\": \"Items\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"total\", \"label\": \"Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"visible_columns\": [{\"print_hide\": 0, \"fieldname\": \"charge_type\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"row_id\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"account_head\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"cost_center\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"description\", \"print_width\": \"300px\"}, {\"print_hide\": 0, \"fieldname\": \"rate\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"tax_amount\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"total\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"tax_amount_after_discount_amount\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"base_tax_amount\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"base_total\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"base_tax_amount_after_discount_amount\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"item_wise_tax_detail\", \"print_width\": \"\"}], \"print_hide\": 0, \"fieldname\": \"taxes\", \"label\": \"Sales Taxes and Charges\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"grand_total\", \"label\": \"Grand Total\"}, {\"print_hide\": 0, \"fieldname\": \"rounded_total\", \"label\": \"Rounded Total\"}, {\"print_hide\": 0, \"fieldname\": \"in_words\", \"align\": \"left\", \"label\": \"In Words\"}]", - "html": "
    \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n
    PAYER'S name, street address, city or town, state or province, country, ZIP
    or foreign postal code, and telephone no.
    \n\t{{company if company else \"\"}}
    \n\t{{payer_street_address if payer_street_address else \"\"}}\n
    1 RentsOMB No. 1545-0115
    2018
    Form 1099-MISC
    Miscellaneous Income
    2 Royalties
    3 Other Income
    \n\t{{payments if payments else \"\"}}\n\t
    4 Federal Income tax withheldCopy A
    For
    Internal Revenue
    Service Center

    File with Form 1096
    PAYER'S TIN
    \n\t{{company_tin if company_tin else \"\"}}\n\t
    RECIPIENT'S TIN

    \n {{tax_id if tax_id else \"None\"}}\n
    Fishing boat proceeds6 Medical and health care payments
    RECIPIENT'S name
    \n {{supplier if supplier else \"\"}}\n
    7 Nonemployee compensation
    \n\t
    Substitute payments in lieu of dividends or interestFor Privacy Act
    and Paperwork
    Reduction Act
    Notice, see the
    2018 General
    Instructions for
    Certain
    Information
    Returns.
    Street address (including apt. no.)
    \n\t{{recipient_street_address if recipient_street_address else \"\"}}\n\t
    $___________$___________
    9 Payer made direct sales of
    $5,000 or more of consumer products
    to a buyer
    (recipient) for resale
    10 Crop insurance proceeds
    City or town, state or province, country, and ZIP or foreign postal code
    \n\t{{recipient_city_state if recipient_city_state else \"\"}}\n
    $___________
    1112
    Account number (see instructions)FACTA filing
    requirement
    2nd TIN not.13 Excess golden parachute payments
    $___________
    14 Gross proceeds paid to an
    attorney
    $___________
    15a Section 409A deferrals15b Section 409 income16 State tax withheld17 State/Payer's state no.18 State income
    $$$$
    Form 1099-MISC Cat. No. 14425J www.irs.gov/Form1099MISC Department of the Treasury - Internal Revenue Service
    \n
    \n
    \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n {{supplier if supplier else \"\"}}\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n
    PAYER'S name, street address, city or town, state or province, country, ZIP
    or foreign postal code, and telephone no.
    \n {{company if company else \"\"}}
    \n \t{{payer_street_address if payer_street_address else \"\"}}
    1 RentsOMB No. 1545-0115
    2018
    Form 1099-MISC
    Miscellaneous Income
    2 Royalties
    3 Other Income
    \n\t{{payments if payments else \"\"}}\n\t
    4 Federal Income tax withheldCopy 1
    For State Tax
    Department
    PAYER'S TIN
    \n\t{{company_tin if company_tin else \"\"}}\n\t
    RECIPIENT'S TIN
    \n\t{{tax_id if tax_id else \"\"}}\n\t
    Fishing boat proceeds6 Medical and health care payments
    RECIPIENT'S name7 Nonemployee compensation
    \n\t
    Substitute payments in lieu of dividends or interest
    Street address (including apt. no.)
    \n\t{{recipient_street_address if recipient_street_address else \"\"}}\n\t
    $___________$___________
    9 Payer made direct sales of
    $5,000 or more of consumer products
    to a buyer
    (recipient) for resale
    10 Crop insurance proceeds
    City or town, state or province, country, and ZIP or foreign postal code
    \n\t{{recipient_city_state if recipient_city_state else \"\"}}\n\t
    $___________
    1112
    Account number (see instructions)FACTA filing
    requirement
    2nd TIN not.13 Excess golden parachute payments
    $___________
    14 Gross proceeds paid to an
    attorney
    $___________
    15a Section 409A deferrals15b Section 409 income16 State tax withheld17 State/Payer's state no.18 State income
    $$$$
    Form 1099-MISC Cat. No. 14425J www.irs.gov/Form1099MISC Department of the Treasury - Internal Revenue Service
    \n
    \n", - "line_breaks": 0, - "modified": "2018-10-08 14:56:56.912851", - "module": "Regional", - "name": "IRS 1099 Form", - "print_format_builder": 1, - "print_format_type": "Server", - "show_section_headings": 0, - "standard": "No" - } -] +{ + "align_labels_right": 0, + "creation": "2020-11-09 16:01:26.096002", + "css": "", + "custom_format": 1, + "default_print_language": "en", + "disabled": 0, + "doc_type": "Supplier", + "docstatus": 0, + "doctype": "Print Format", + "font": "Default", + "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"
    \\t\\t\\t\\t

    TAX Invoice
    {{ doc.name }}\\t\\t\\t\\t

    \"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"customer_name\", \"label\": \"Customer Name\"}, {\"print_hide\": 0, \"fieldname\": \"customer_name_in_arabic\", \"label\": \"Customer Name in Arabic\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"posting_date\", \"label\": \"Date\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Address\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"company\", \"label\": \"Company\"}, {\"print_hide\": 0, \"fieldname\": \"company_trn\", \"label\": \"Company TRN\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"company_address_display\", \"label\": \"Company Address\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"visible_columns\": [{\"print_hide\": 0, \"fieldname\": \"item_code\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"description\", \"print_width\": \"200px\"}, {\"print_hide\": 0, \"fieldname\": \"uom\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"tax_code\", \"print_width\": \"\"}], \"print_hide\": 0, \"fieldname\": \"items\", \"label\": \"Items\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"total\", \"label\": \"Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"visible_columns\": [{\"print_hide\": 0, \"fieldname\": \"charge_type\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"row_id\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"account_head\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"cost_center\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"description\", \"print_width\": \"300px\"}, {\"print_hide\": 0, \"fieldname\": \"rate\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"tax_amount\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"total\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"tax_amount_after_discount_amount\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"base_tax_amount\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"base_total\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"base_tax_amount_after_discount_amount\", \"print_width\": \"\"}, {\"print_hide\": 0, \"fieldname\": \"item_wise_tax_detail\", \"print_width\": \"\"}], \"print_hide\": 0, \"fieldname\": \"taxes\", \"label\": \"Sales Taxes and Charges\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"print_hide\": 0, \"fieldname\": \"grand_total\", \"label\": \"Grand Total\"}, {\"print_hide\": 0, \"fieldname\": \"rounded_total\", \"label\": \"Rounded Total\"}, {\"print_hide\": 0, \"fieldname\": \"in_words\", \"align\": \"left\", \"label\": \"In Words\"}]", + "html": "
    \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n
    PAYER'S name, street address,\n city or town, state or province, country, ZIP
    or foreign postal code, and telephone no.
    \n {{ company or \"\" }}
    \n {{ payer_street_address or \"\" }}\n
    1 RentsOMB No. 1545-0115
    \n {{ fiscal_year[:2] }}\n {{ fiscal_year[-2:] }}
    Form 1099-MISC\n
    Miscellaneous Income
    2 Royalties
    3 Other Income
    {{ payments or \"\" }}
    4 Federal Income tax withheldCopy A
    For
    Internal Revenue
    Service\n Center

    File with Form 1096
    PAYER'S TIN
    {{ company_tin or \"\" }}
    RECIPIENT'S TIN

    {{ tax_id or \"None\" }}
    Fishing boat proceeds6 Medical and health care payments
    RECIPIENT'S name
    {{ supplier or \"\" }}
    7 Nonemployee compensation
    \n
    Substitute payments in lieu of dividends or interestFor Privacy Act
    and Paperwork
    Reduction Act
    Notice, see\n the
    2018 General
    Instructions for
    Certain
    Information
    Returns.
    Street address (including apt. no.)
    \n {{ recipient_street_address or \"\" }}\n
    $___________$___________
    9 Payer made direct sales of
    $5,000 or more of consumer\n products
    to a buyer
    (recipient) for resale
    10 Crop insurance proceeds
    City or town, state or province, country, and ZIP or\n foreign postal code
    \n {{ recipient_city_state or \"\" }}\n
    $___________
    1112
    Account number (see instructions)FACTA filing
    requirement
    2nd TIN not.13 Excess golden parachute payments
    $___________
    14 Gross proceeds paid to an
    attorney
    $___________
    15a Section 409A deferrals15b Section 409 income16 State tax withheld17 State/Payer's state no.18 State income
    $$$$
    Form 1099-MISC Cat. No. 14425J www.irs.gov/Form1099MISC Department of the\n Treasury - Internal Revenue Service
    \n
    \n
    \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n {{ supplier or \"\" }}\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n
    PAYER'S name, street address,\n city or town, state or province, country, ZIP
    or foreign postal code, and telephone no.
    \n {{ company or \"\"}}\n {{ payer_street_address or \"\" }}\n
    1 RentsOMB No. 1545-0115
    \n {{ fiscal_year[:2] }}\n {{ fiscal_year[-2:] }}
    Form 1099-MISC\n
    Miscellaneous Income
    2 Royalties
    3 Other Income
    \n {{ payments or \"\" }}\n
    4 Federal Income tax withheldCopy 1
    For State Tax
    Department
    PAYER'S TIN
    \n {{ company_tin or \"\" }}\n
    RECIPIENT'S TIN
    \n {{ tax_id or \"\" }}\n
    Fishing boat proceeds6 Medical and health care payments
    RECIPIENT'S name7 Nonemployee compensation
    \n
    Substitute payments in lieu of dividends or interest
    Street address (including apt. no.)
    \n {{ recipient_street_address or \"\" }}\n
    $___________$___________
    9 Payer made direct sales of
    $5,000 or more of consumer\n products
    to a buyer
    (recipient) for resale
    10 Crop insurance proceeds
    City or town, state or province, country, and ZIP or\n foreign postal code
    \n {{ recipient_city_state or \"\" }}\n
    $___________
    1112
    Account number (see instructions)FACTA filing
    requirement
    2nd TIN not.13 Excess golden parachute payments
    $___________
    14 Gross proceeds paid to an
    attorney
    $___________
    15a Section 409A deferrals15b Section 409 income16 State tax withheld17 State/Payer's state no.18 State income
    $$$$
    Form 1099-MISC Cat. No. 14425J www.irs.gov/Form1099MISC Department of the\n Treasury - Internal Revenue Service
    \n
    \n", + "idx": 0, + "line_breaks": 0, + "modified": "2021-01-19 07:25:16.333666", + "modified_by": "Administrator", + "module": "Regional", + "name": "IRS 1099 Form", + "owner": "Administrator", + "print_format_builder": 1, + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 0, + "standard": "No" +} \ No newline at end of file diff --git a/erpnext/regional/report/datev/datev.js b/erpnext/regional/report/datev/datev.js index 55f12cf3738..4124e3df190 100644 --- a/erpnext/regional/report/datev/datev.js +++ b/erpnext/regional/report/datev/datev.js @@ -11,14 +11,14 @@ frappe.query_reports["DATEV"] = { { "fieldname": "from_date", "label": __("From Date"), - "default": frappe.datetime.month_start(), + "default": moment().subtract(1, 'month').startOf('month').format(), "fieldtype": "Date", "reqd": 1 }, { "fieldname": "to_date", "label": __("To Date"), - "default": frappe.datetime.now_date(), + "default": moment().subtract(1, 'month').endOf('month').format(), "fieldtype": "Date", "reqd": 1 }, @@ -30,9 +30,23 @@ frappe.query_reports["DATEV"] = { } ], onload: function(query_report) { + let company = frappe.query_report.get_filter_value('company'); + frappe.db.exists('DATEV Settings', company).then((settings_exist) => { + if (!settings_exist) { + frappe.confirm(__('DATEV Settings for your Company are missing. Would you like to create them now?'), + () => frappe.new_doc('DATEV Settings', {'company': company}) + ); + } + }); + query_report.page.add_menu_item(__("Download DATEV File"), () => { const filters = JSON.stringify(query_report.get_values()); window.open(`/api/method/erpnext.regional.report.datev.datev.download_datev_csv?filters=${filters}`); }); + + query_report.page.add_menu_item(__("Change DATEV Settings"), () => { + let company = frappe.query_report.get_filter_value('company'); // read company from filters again – it might have changed by now. + frappe.set_route('Form', 'DATEV Settings', company); + }); } }; diff --git a/erpnext/regional/report/datev/datev.py b/erpnext/regional/report/datev/datev.py index dd818e6054d..cbc94789874 100644 --- a/erpnext/regional/report/datev/datev.py +++ b/erpnext/regional/report/datev/datev.py @@ -11,9 +11,11 @@ from __future__ import unicode_literals import json import frappe -from frappe import _ from six import string_types -from erpnext.regional.germany.utils.datev.datev_csv import download_csv_files_as_zip, get_datev_csv + +from frappe import _ +from erpnext.accounts.utils import get_fiscal_year +from erpnext.regional.germany.utils.datev.datev_csv import zip_and_download, get_datev_csv from erpnext.regional.germany.utils.datev.datev_constants import Transactions, DebtorsCreditors, AccountNames COLUMNS = [ @@ -92,25 +94,46 @@ COLUMNS = [ def execute(filters=None): """Entry point for frappe.""" - validate(filters) - return COLUMNS, get_transactions(filters, as_dict=0) + data = [] + if filters and validate(filters): + fn = 'temporary_against_account_number' + filters[fn] = frappe.get_value('DATEV Settings', filters.get('company'), fn) + data = get_transactions(filters, as_dict=0) + + return COLUMNS, data def validate(filters): """Make sure all mandatory filters and settings are present.""" - if not filters.get('company'): + company = filters.get('company') + if not company: frappe.throw(_('Company is a mandatory filter.')) - if not filters.get('from_date'): + from_date = filters.get('from_date') + if not from_date: frappe.throw(_('From Date is a mandatory filter.')) - if not filters.get('to_date'): + to_date = filters.get('to_date') + if not to_date: frappe.throw(_('To Date is a mandatory filter.')) - try: - frappe.get_doc('DATEV Settings', filters.get('company')) - except frappe.DoesNotExistError: - frappe.throw(_('Please create DATEV Settings for Company {}.').format(filters.get('company'))) + validate_fiscal_year(from_date, to_date, company) + + if not frappe.db.exists('DATEV Settings', filters.get('company')): + frappe.log_error(_('Please create {} for Company {}.').format( + '{}'.format(_('DATEV Settings')), + frappe.bold(filters.get('company')) + )) + return False + + return True + + +def validate_fiscal_year(from_date, to_date, company): + from_fiscal_year = get_fiscal_year(date=from_date, company=company) + to_fiscal_year = get_fiscal_year(date=to_date, company=company) + if from_fiscal_year != to_fiscal_year: + frappe.throw(_('Dates {} and {} are not in the same fiscal year.').format(from_date, to_date)) def get_transactions(filters, as_dict=1): @@ -135,11 +158,11 @@ def get_transactions(filters, as_dict=1): case gl.debit when 0 then 'H' else 'S' end as 'Soll/Haben-Kennzeichen', /* account number or, if empty, party account number */ - coalesce(acc.account_number, acc_pa.account_number) as 'Konto', + acc.account_number as 'Konto', /* against number or, if empty, party against number */ - coalesce(acc_against.account_number, acc_against_pa.account_number) as 'Gegenkonto (ohne BU-Schlüssel)', - + %(temporary_against_account_number)s as 'Gegenkonto (ohne BU-Schlüssel)', + gl.posting_date as 'Belegdatum', gl.voucher_no as 'Belegfeld 1', LEFT(gl.remarks, 60) as 'Buchungstext', @@ -150,27 +173,10 @@ def get_transactions(filters, as_dict=1): FROM `tabGL Entry` gl - /* Statistisches Konto (Debitoren/Kreditoren) */ - left join `tabParty Account` pa - on gl.against = pa.parent - and gl.company = pa.company - /* Kontonummer */ left join `tabAccount` acc on gl.account = acc.name - /* Gegenkonto-Nummer */ - left join `tabAccount` acc_against - on gl.against = acc_against.name - - /* Statistische Kontonummer */ - left join `tabAccount` acc_pa - on pa.account = acc_pa.name - - /* Statistische Gegenkonto-Nummer */ - left join `tabAccount` acc_against_pa - on pa.account = acc_against_pa.name - WHERE gl.company = %(company)s AND DATE(gl.posting_date) >= %(from_date)s AND DATE(gl.posting_date) <= %(to_date)s @@ -317,17 +323,26 @@ def download_datev_csv(filters): filters = json.loads(filters) validate(filters) + company = filters.get('company') + + fiscal_year = get_fiscal_year(date=filters.get('from_date'), company=company) + filters['fiscal_year_start'] = fiscal_year[1] # set chart of accounts used - coa = frappe.get_value('Company', filters.get('company'), 'chart_of_accounts') + coa = frappe.get_value('Company', company, 'chart_of_accounts') filters['skr'] = '04' if 'SKR04' in coa else ('03' if 'SKR03' in coa else '') + datev_settings = frappe.get_doc('DATEV Settings', company) + filters['account_number_length'] = datev_settings.account_number_length + filters['temporary_against_account_number'] = datev_settings.temporary_against_account_number + transactions = get_transactions(filters) account_names = get_account_names(filters) customers = get_customers(filters) suppliers = get_suppliers(filters) - download_csv_files_as_zip([ + zip_name = '{} DATEV.zip'.format(frappe.utils.datetime.date.today()) + zip_and_download(zip_name, [ { 'file_name': 'EXTF_Buchungsstapel.csv', 'csv_data': get_datev_csv(transactions, filters, csv_class=Transactions) diff --git a/erpnext/regional/report/datev/test_datev.py b/erpnext/regional/report/datev/test_datev.py index 9529923a73b..59b878e94a5 100644 --- a/erpnext/regional/report/datev/test_datev.py +++ b/erpnext/regional/report/datev/test_datev.py @@ -126,7 +126,8 @@ def make_datev_settings(company): "doctype": "DATEV Settings", "client": company.name, "client_number": "12345", - "consultant_number": "67890" + "consultant_number": "67890", + "temporary_against_account_number": "9999" }).insert() @@ -137,7 +138,8 @@ class TestDatev(TestCase): self.filters = { "company": self.company.name, "from_date": today(), - "to_date": today() + "to_date": today(), + "temporary_against_account_number": "9999" } make_datev_settings(self.company) diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index 282efe47901..62faa30e3fc 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -151,6 +151,7 @@ class Gstr1Report(object): {select_columns} from `tab{doctype}` where docstatus = 1 {where_conditions} + and is_opening = 'No' order by posting_date desc """.format(select_columns=self.select_columns, doctype=self.doctype, where_conditions=conditions), self.filters, as_dict=1) @@ -235,6 +236,7 @@ class Gstr1Report(object): self.cgst_sgst_invoices = [] unidentified_gst_accounts = [] + unidentified_gst_accounts_invoice = [] for parent, account, item_wise_tax_detail, tax_amount in self.tax_details: if account in self.gst_accounts.cess_account: self.invoice_cess.setdefault(parent, tax_amount) @@ -250,19 +252,21 @@ class Gstr1Report(object): if not (cgst_or_sgst or account in self.gst_accounts.igst_account): if "gst" in account.lower() and account not in unidentified_gst_accounts: unidentified_gst_accounts.append(account) + unidentified_gst_accounts_invoice.append(parent) continue for item_code, tax_amounts in item_wise_tax_detail.items(): tax_rate = tax_amounts[0] - if cgst_or_sgst: - tax_rate *= 2 - if parent not in self.cgst_sgst_invoices: - self.cgst_sgst_invoices.append(parent) + if tax_rate: + if cgst_or_sgst: + tax_rate *= 2 + if parent not in self.cgst_sgst_invoices: + self.cgst_sgst_invoices.append(parent) - rate_based_dict = self.items_based_on_tax_rate\ - .setdefault(parent, {}).setdefault(tax_rate, []) - if item_code not in rate_based_dict: - rate_based_dict.append(item_code) + rate_based_dict = self.items_based_on_tax_rate\ + .setdefault(parent, {}).setdefault(tax_rate, []) + if item_code not in rate_based_dict: + rate_based_dict.append(item_code) except ValueError: continue if unidentified_gst_accounts: @@ -271,7 +275,7 @@ class Gstr1Report(object): # Build itemised tax for export invoices where tax table is blank for invoice, items in iteritems(self.invoice_items): - if invoice not in self.items_based_on_tax_rate \ + if invoice not in self.items_based_on_tax_rate and invoice not in unidentified_gst_accounts_invoice \ and frappe.db.get_value(self.doctype, invoice, "export_type") == "Without Payment of Tax": self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys()) diff --git a/erpnext/regional/report/irs_1099/irs_1099.js b/erpnext/regional/report/irs_1099/irs_1099.js index 2d74652cfe2..070ff43f78c 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.js +++ b/erpnext/regional/report/irs_1099/irs_1099.js @@ -4,7 +4,7 @@ frappe.query_reports["IRS 1099"] = { "filters": [ { - "fieldname":"company", + "fieldname": "company", "label": __("Company"), "fieldtype": "Link", "options": "Company", @@ -13,7 +13,7 @@ frappe.query_reports["IRS 1099"] = { "width": 80, }, { - "fieldname":"fiscal_year", + "fieldname": "fiscal_year", "label": __("Fiscal Year"), "fieldtype": "Link", "options": "Fiscal Year", @@ -22,7 +22,7 @@ frappe.query_reports["IRS 1099"] = { "width": 80, }, { - "fieldname":"supplier_group", + "fieldname": "supplier_group", "label": __("Supplier Group"), "fieldtype": "Link", "options": "Supplier Group", @@ -32,16 +32,16 @@ frappe.query_reports["IRS 1099"] = { }, ], - onload: function(query_report) { + onload: function (query_report) { query_report.page.add_inner_button(__("Print IRS 1099 Forms"), () => { build_1099_print(query_report); }); } }; -function build_1099_print(query_report){ +function build_1099_print(query_report) { let filters = JSON.stringify(query_report.get_values()); let w = window.open('/api/method/erpnext.regional.report.irs_1099.irs_1099.irs_1099_print?' + - '&filters=' + encodeURIComponent(filters)); + '&filters=' + encodeURIComponent(filters)); // w.print(); } diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py index d3509e500f8..4e57ff7ea37 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.py +++ b/erpnext/regional/report/irs_1099/irs_1099.py @@ -1,32 +1,41 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -from __future__ import unicode_literals -import frappe import json -from frappe import _, _dict -from frappe.utils import nowdate -from frappe.utils.data import fmt_money -from erpnext.accounts.utils import get_fiscal_year + from PyPDF2 import PdfFileWriter + +import frappe +from erpnext.accounts.utils import get_fiscal_year +from frappe import _ +from frappe.utils import cstr, nowdate +from frappe.utils.data import fmt_money +from frappe.utils.jinja import render_template from frappe.utils.pdf import get_pdf from frappe.utils.print_format import read_multi_pdf -from frappe.utils.jinja import render_template + +IRS_1099_FORMS_FILE_EXTENSION = ".pdf" def execute(filters=None): - filters = filters if isinstance(filters, _dict) else _dict(filters) - + filters = filters if isinstance(filters, frappe._dict) else frappe._dict(filters) if not filters: filters.setdefault('fiscal_year', get_fiscal_year(nowdate())[0]) filters.setdefault('company', frappe.db.get_default("company")) - region = frappe.db.get_value("Company", fieldname = ["country"], filters = { "name": filters.company }) + region = frappe.db.get_value("Company", + filters={"name": filters.company}, + fieldname=["country"]) + if region != 'United States': - return [],[] + return [], [] data = [] columns = get_columns() + conditions = "" + if filters.supplier_group: + conditions += "AND s.supplier_group = %s" %frappe.db.escape(filters.get("supplier_group")) + data = frappe.db.sql(""" SELECT s.supplier_group as "supplier_group", @@ -34,20 +43,25 @@ def execute(filters=None): s.tax_id as "tax_id", SUM(gl.debit_in_account_currency) AS "payments" FROM - `tabGL Entry` gl INNER JOIN `tabSupplier` s + `tabGL Entry` gl + INNER JOIN `tabSupplier` s WHERE s.name = gl.party - AND s.irs_1099 = 1 - AND gl.fiscal_year = %(fiscal_year)s - AND gl.party_type = "Supplier" - + AND s.irs_1099 = 1 + AND gl.fiscal_year = %(fiscal_year)s + AND gl.party_type = "Supplier" + AND gl.company = %(company)s + {conditions} + GROUP BY gl.party ORDER BY - gl.party DESC""", {"fiscal_year": filters.fiscal_year, - "supplier_group": filters.supplier_group, - "company": filters.company}, as_dict=True) + gl.party DESC""".format(conditions=conditions), { + "fiscal_year": filters.fiscal_year, + "company": filters.company + }, as_dict=True) + return columns, data @@ -71,14 +85,13 @@ def get_columns(): "fieldname": "tax_id", "label": _("Tax ID"), "fieldtype": "Data", - "width": 120 + "width": 200 }, { - "fieldname": "payments", "label": _("Total Payments"), "fieldtype": "Currency", - "width": 120 + "width": 200 } ] @@ -88,23 +101,32 @@ def irs_1099_print(filters): if not filters: frappe._dict({ "company": frappe.db.get_default("Company"), - "fiscal_year": frappe.db.get_default("fiscal_year")}) + "fiscal_year": frappe.db.get_default("Fiscal Year") + }) else: filters = frappe._dict(json.loads(filters)) + + fiscal_year_doc = get_fiscal_year(fiscal_year=filters.fiscal_year, as_dict=True) + fiscal_year = cstr(fiscal_year_doc.year_start_date.year) + company_address = get_payer_address_html(filters.company) company_tin = frappe.db.get_value("Company", filters.company, "tax_id") + columns, data = execute(filters) template = frappe.get_doc("Print Format", "IRS 1099 Form").html output = PdfFileWriter() + for row in data: + row["fiscal_year"] = fiscal_year row["company"] = filters.company row["company_tin"] = company_tin row["payer_street_address"] = company_address - row["recipient_street_address"], row["recipient_city_state"] = get_street_address_html("Supplier", row.supplier) + row["recipient_street_address"], row["recipient_city_state"] = get_street_address_html( + "Supplier", row.supplier) row["payments"] = fmt_money(row["payments"], precision=0, currency="USD") - frappe._dict(row) pdf = get_pdf(render_template(template, row), output=output if output else None) - frappe.local.response.filename = filters.fiscal_year + " " + filters.company + " IRS 1099 Forms" + + frappe.local.response.filename = f"{filters.fiscal_year} {filters.company} IRS 1099 Forms{IRS_1099_FORMS_FILE_EXTENSION}" frappe.local.response.filecontent = read_multi_pdf(output) frappe.local.response.type = "download" @@ -120,36 +142,45 @@ def get_payer_address_html(company): ORDER BY address_type="Postal" DESC, address_type="Billing" DESC LIMIT 1 - """, {"company": company}, as_dict=True) + """, {"company": company}, as_dict=True) + + address_display = "" if address_list: company_address = address_list[0]["name"] - return frappe.get_doc("Address", company_address).get_display() - else: - return "" + address_display = frappe.get_doc("Address", company_address).get_display() + + return address_display def get_street_address_html(party_type, party): address_list = frappe.db.sql(""" SELECT link.parent - FROM `tabDynamic Link` link, `tabAddress` address - WHERE link.parenttype = "Address" - AND link.link_name = %(party)s - ORDER BY address.address_type="Postal" DESC, + FROM + `tabDynamic Link` link, + `tabAddress` address + WHERE + link.parenttype = "Address" + AND link.link_name = %(party)s + ORDER BY + address.address_type="Postal" DESC, address.address_type="Billing" DESC LIMIT 1 - """, {"party": party}, as_dict=True) + """, {"party": party}, as_dict=True) + + street_address = city_state = "" if address_list: supplier_address = address_list[0]["parent"] doc = frappe.get_doc("Address", supplier_address) + if doc.address_line2: - street = doc.address_line1 + "
    \n" + doc.address_line2 + "
    \n" + street_address = doc.address_line1 + "
    \n" + doc.address_line2 + "
    \n" else: - street = doc.address_line1 + "
    \n" - city = doc.city + ", " if doc.city else "" - city = city + doc.state + " " if doc.state else city - city = city + doc.pincode if doc.pincode else city - city += "
    \n" - return street, city - else: - return "", "" + street_address = doc.address_line1 + "
    \n" + + city_state = doc.city + ", " if doc.city else "" + city_state = city_state + doc.state + " " if doc.state else city_state + city_state = city_state + doc.pincode if doc.pincode else city_state + city_state += "
    \n" + + return street_address, city_state diff --git a/erpnext/regional/report/uae_vat_201/__init__.py b/erpnext/regional/report/uae_vat_201/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py new file mode 100644 index 00000000000..daa69768c57 --- /dev/null +++ b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py @@ -0,0 +1,239 @@ +# coding=utf-8 +from __future__ import unicode_literals + +import erpnext +import frappe +from unittest import TestCase +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse_account +from erpnext.regional.report.uae_vat_201.uae_vat_201 import ( + get_total_emiratewise, + get_tourist_tax_return_total, + get_tourist_tax_return_tax, + get_zero_rated_total, + get_exempt_total, + get_standard_rated_expenses_total, + get_standard_rated_expenses_tax, +) + +test_dependencies = ["Territory", "Customer Group", "Supplier Group", "Item"] + +class TestUaeVat201(TestCase): + def setUp(self): + frappe.set_user("Administrator") + + frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company UAE VAT'") + frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company UAE VAT'") + + make_company("_Test Company UAE VAT", "_TCUV") + set_vat_accounts() + + make_customer() + + make_supplier() + + create_warehouse("_Test UAE VAT Supplier Warehouse", company="_Test Company UAE VAT") + + make_item("_Test UAE VAT Item", properties = {"is_zero_rated": 0, "is_exempt": 0}) + make_item("_Test UAE VAT Zero Rated Item", properties = {"is_zero_rated": 1, "is_exempt": 0}) + make_item("_Test UAE VAT Exempt Item", properties = {"is_zero_rated": 0, "is_exempt": 1}) + + make_sales_invoices() + + create_purchase_invoices() + + def test_uae_vat_201_report(self): + filters = {"company": "_Test Company UAE VAT"} + total_emiratewise = get_total_emiratewise(filters) + amounts_by_emirate = {} + for data in total_emiratewise: + emirate, amount, vat = data + amounts_by_emirate[emirate] = { + "raw_amount": amount, + "raw_vat_amount": vat, + } + self.assertEqual(amounts_by_emirate["Sharjah"]["raw_amount"],100) + self.assertEqual(amounts_by_emirate["Sharjah"]["raw_vat_amount"],5) + self.assertEqual(amounts_by_emirate["Dubai"]["raw_amount"],200) + self.assertEqual(amounts_by_emirate["Dubai"]["raw_vat_amount"],10) + self.assertEqual(get_tourist_tax_return_total(filters),100) + self.assertEqual(get_tourist_tax_return_tax(filters),2) + self.assertEqual(get_zero_rated_total(filters),100) + self.assertEqual(get_exempt_total(filters),100) + self.assertEqual(get_standard_rated_expenses_total(filters),250) + self.assertEqual(get_standard_rated_expenses_tax(filters),1) + +def make_company(company_name, abbr): + if not frappe.db.exists("Company", company_name): + company = frappe.get_doc({ + "doctype": "Company", + "company_name": company_name, + "abbr": abbr, + "default_currency": "AED", + "country": "United Arab Emirates", + "create_chart_of_accounts_based_on": "Standard Template", + }) + company.insert() + else: + company = frappe.get_doc("Company", company_name) + + company.create_default_warehouses() + + if not frappe.db.get_value("Cost Center", {"is_group": 0, "company": company.name}): + company.create_default_cost_center() + + company.save() + return company + +def set_vat_accounts(): + if not frappe.db.exists("UAE VAT Settings", "_Test Company UAE VAT"): + vat_accounts = frappe.get_all( + "Account", + fields=["name"], + filters = { + "company": "_Test Company UAE VAT", + "is_group": 0, + "account_type": "Tax" + } + ) + + uae_vat_accounts = [] + for account in vat_accounts: + uae_vat_accounts.append({ + "doctype": "UAE VAT Account", + "account": account.name + }) + + frappe.get_doc({ + "company": "_Test Company UAE VAT", + "uae_vat_accounts": uae_vat_accounts, + "doctype": "UAE VAT Settings", + }).insert() + +def make_customer(): + if not frappe.db.exists("Customer", "_Test UAE Customer"): + customer = frappe.get_doc({ + "doctype": "Customer", + "customer_name": "_Test UAE Customer", + "customer_type": "Company", + }) + customer.insert() + else: + customer = frappe.get_doc("Customer", "_Test UAE Customer") + +def make_supplier(): + if not frappe.db.exists("Supplier", "_Test UAE Supplier"): + frappe.get_doc({ + "supplier_group": "Local", + "supplier_name": "_Test UAE Supplier", + "supplier_type": "Individual", + "doctype": "Supplier", + }).insert() + +def create_warehouse(warehouse_name, properties=None, company=None): + if not company: + company = "_Test Company" + + warehouse_id = erpnext.encode_company_abbr(warehouse_name, company) + if not frappe.db.exists("Warehouse", warehouse_id): + warehouse = frappe.new_doc("Warehouse") + warehouse.warehouse_name = warehouse_name + warehouse.parent_warehouse = "All Warehouses - _TCUV" + warehouse.company = company + warehouse.account = get_warehouse_account(warehouse_name, company) + if properties: + warehouse.update(properties) + warehouse.save() + return warehouse.name + else: + return warehouse_id + +def make_item(item_code, properties=None): + if frappe.db.exists("Item", item_code): + return frappe.get_doc("Item", item_code) + + item = frappe.get_doc({ + "doctype": "Item", + "item_code": item_code, + "item_name": item_code, + "description": item_code, + "item_group": "Products" + }) + + if properties: + item.update(properties) + + item.insert() + + return item + +def make_sales_invoices(): + def make_sales_invoices_wrapper(emirate, item, tax = True, tourist_tax= False): + si = create_sales_invoice( + company="_Test Company UAE VAT", + customer = '_Test UAE Customer', + currency = 'AED', + warehouse = 'Finished Goods - _TCUV', + debit_to = 'Debtors - _TCUV', + income_account = 'Sales - _TCUV', + expense_account = 'Cost of Goods Sold - _TCUV', + cost_center = 'Main - _TCUV', + item = item, + do_not_save=1 + ) + si.vat_emirate = emirate + if tax: + si.append( + "taxes", { + "charge_type": "On Net Total", + "account_head": "VAT 5% - _TCUV", + "cost_center": "Main - _TCUV", + "description": "VAT 5% @ 5.0", + "rate": 5.0 + } + ) + if tourist_tax: + si.tourist_tax_return = 2 + si.submit() + + #Define Item Names + uae_item = "_Test UAE VAT Item" + uae_exempt_item = "_Test UAE VAT Exempt Item" + uae_zero_rated_item = "_Test UAE VAT Zero Rated Item" + + #Sales Invoice with standard rated expense in Dubai + make_sales_invoices_wrapper('Dubai', uae_item) + #Sales Invoice with standard rated expense in Sharjah + make_sales_invoices_wrapper('Sharjah', uae_item) + #Sales Invoice with Tourist Tax Return + make_sales_invoices_wrapper('Dubai', uae_item, True, True) + #Sales Invoice with Exempt Item + make_sales_invoices_wrapper('Sharjah', uae_exempt_item, False) + #Sales Invoice with Zero Rated Item + make_sales_invoices_wrapper('Sharjah', uae_zero_rated_item, False) + +def create_purchase_invoices(): + pi = make_purchase_invoice( + company="_Test Company UAE VAT", + supplier = '_Test UAE Supplier', + supplier_warehouse = '_Test UAE VAT Supplier Warehouse - _TCUV', + warehouse = '_Test UAE VAT Supplier Warehouse - _TCUV', + currency = 'AED', + cost_center = 'Main - _TCUV', + expense_account = 'Cost of Goods Sold - _TCUV', + item = "_Test UAE VAT Item", + do_not_save=1, + uom = "Nos" + ) + pi.append("taxes", { + "charge_type": "On Net Total", + "account_head": "VAT 5% - _TCUV", + "cost_center": "Main - _TCUV", + "description": "VAT 5% @ 5.0", + "rate": 5.0 + }) + + pi.recoverable_standard_rated_expenses = 1 + + pi.submit() diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.html b/erpnext/regional/report/uae_vat_201/uae_vat_201.html new file mode 100644 index 00000000000..d9b9968d90c --- /dev/null +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.html @@ -0,0 +1,77 @@ +{% + var report_columns = report.get_columns_for_print(); + report_columns = report_columns.filter(col => !col.hidden); +%} + + +

    {%= __(report.report_name) %}

    + +

    {%= __("VAT on Sales and All Other Outputs") %}

    + + + + + + + + {% for (let i=2; i{%= report_columns[i].label %} + {% } %} + + + + {% for (let j=1; j<12; j++) { %} + {% + var row = data[j]; + %} + + {% for (let i=0; i + {% const fieldname = report_columns[i].fieldname; %} + {% if (!is_null(row[fieldname])) { %} + {%= frappe.format(row[fieldname], report_columns[i], {}, row) %} + {% } %} + + {% } %} + + {% } %} + +
    {%= report_columns[0].label %}{%= report_columns[1].label %}
    + +

    {%= __("VAT on Expenses and All Other Inputs") %}

    + + + + + + + {% for (let i=2; i{%= report_columns[i].label %} + {% } %} + + + + {% for (let j=14; j + {% for (let i=0; i + {% const fieldname = report_columns[i].fieldname; %} + {% if (!is_null(row[fieldname])) { %} + {%= frappe.format(row[fieldname], report_columns[i], {}, row) %} + {% } %} + + {% } %} + + {% } %} + + +
    {%= report_columns[0].label %}{%= report_columns[1].label %}
    \ No newline at end of file diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.js b/erpnext/regional/report/uae_vat_201/uae_vat_201.js new file mode 100644 index 00000000000..59574247701 --- /dev/null +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.js @@ -0,0 +1,40 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["UAE VAT 201"] = { + "filters": [ + { + "fieldname": "company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "reqd": 1, + "default": frappe.defaults.get_user_default("Company") + }, + { + "fieldname": "from_date", + "label": __("From Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -3), + }, + { + "fieldname": "to_date", + "label": __("To Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.get_today() + }, + ], + "formatter": function(value, row, column, data, default_formatter) { + if (data + && (data.legend=='VAT on Sales and All Other Outputs' || data.legend=='VAT on Expenses and All Other Inputs') + && data.legend==value) { + value = $(`${value}`); + var $value = $(value).css("font-weight", "bold"); + value = $value.wrap("

    ").parent().html(); + } + return value; + }, +}; diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.json b/erpnext/regional/report/uae_vat_201/uae_vat_201.json new file mode 100644 index 00000000000..8a88bcd3e23 --- /dev/null +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.json @@ -0,0 +1,22 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2020-09-10 08:51:02.298482", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2020-09-10 08:51:02.298482", + "modified_by": "Administrator", + "module": "Regional", + "name": "UAE VAT 201", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "report_name": "UAE VAT 201", + "report_type": "Script Report", + "roles": [] +} \ No newline at end of file diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py new file mode 100644 index 00000000000..b0614238ba0 --- /dev/null +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -0,0 +1,339 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ + +def execute(filters=None): + columns = get_columns() + data, emirates, amounts_by_emirate = get_data(filters) + return columns, data + +def get_columns(): + """Creates a list of dictionaries that are used to generate column headers of the data table.""" + return [ + { + "fieldname": "no", + "label": _("No"), + "fieldtype": "Data", + "width": 50 + }, + { + "fieldname": "legend", + "label": _("Legend"), + "fieldtype": "Data", + "width": 300 + }, + { + "fieldname": "amount", + "label": _("Amount (AED)"), + "fieldtype": "Currency", + "width": 125, + }, + { + "fieldname": "vat_amount", + "label": _("VAT Amount (AED)"), + "fieldtype": "Currency", + "width": 150, + } + ] + +def get_data(filters = None): + """Returns the list of dictionaries. Each dictionary is a row in the datatable and chart data.""" + data = [] + emirates, amounts_by_emirate = append_vat_on_sales(data, filters) + append_vat_on_expenses(data, filters) + return data, emirates, amounts_by_emirate + +def append_vat_on_sales(data, filters): + """Appends Sales and All Other Outputs.""" + append_data(data, '', _('VAT on Sales and All Other Outputs'), '', '') + + emirates, amounts_by_emirate = standard_rated_expenses_emiratewise(data, filters) + + append_data(data, '2', + _('Tax Refunds provided to Tourists under the Tax Refunds for Tourists Scheme'), + frappe.format((-1) * get_tourist_tax_return_total(filters), 'Currency'), + frappe.format((-1) * get_tourist_tax_return_tax(filters), 'Currency')) + + append_data(data, '3', _('Supplies subject to the reverse charge provision'), + frappe.format(get_reverse_charge_total(filters), 'Currency'), + frappe.format(get_reverse_charge_tax(filters), 'Currency')) + + append_data(data, '4', _('Zero Rated'), + frappe.format(get_zero_rated_total(filters), 'Currency'), "-") + + append_data(data, '5', _('Exempt Supplies'), + frappe.format(get_exempt_total(filters), 'Currency'),"-") + + append_data(data, '', '', '', '') + + return emirates, amounts_by_emirate + +def standard_rated_expenses_emiratewise(data, filters): + """Append emiratewise standard rated expenses and vat.""" + total_emiratewise = get_total_emiratewise(filters) + emirates = get_emirates() + amounts_by_emirate = {} + for emirate, amount, vat in total_emiratewise: + amounts_by_emirate[emirate] = { + "legend": emirate, + "raw_amount": amount, + "raw_vat_amount": vat, + "amount": frappe.format(amount, 'Currency'), + "vat_amount": frappe.format(vat, 'Currency'), + } + amounts_by_emirate = append_emiratewise_expenses(data, emirates, amounts_by_emirate) + return emirates, amounts_by_emirate + +def append_emiratewise_expenses(data, emirates, amounts_by_emirate): + """Append emiratewise standard rated expenses and vat.""" + for no, emirate in enumerate(emirates, 97): + if emirate in amounts_by_emirate: + amounts_by_emirate[emirate]["no"] = _('1{0}').format(chr(no)) + amounts_by_emirate[emirate]["legend"] = _('Standard rated supplies in {0}').format(emirate) + data.append(amounts_by_emirate[emirate]) + else: + append_data(data, _('1{0}').format(chr(no)), + _('Standard rated supplies in {0}').format(emirate), + frappe.format(0, 'Currency'), frappe.format(0, 'Currency')) + return amounts_by_emirate + +def append_vat_on_expenses(data, filters): + """Appends Expenses and All Other Inputs.""" + append_data(data, '', _('VAT on Expenses and All Other Inputs'), '', '') + append_data(data, '9', _('Standard Rated Expenses'), + frappe.format(get_standard_rated_expenses_total(filters), 'Currency'), + frappe.format(get_standard_rated_expenses_tax(filters), 'Currency')) + append_data(data, '10', _('Supplies subject to the reverse charge provision'), + frappe.format(get_reverse_charge_recoverable_total(filters), 'Currency'), + frappe.format(get_reverse_charge_recoverable_tax(filters), 'Currency')) + +def append_data(data, no, legend, amount, vat_amount): + """Returns data with appended value.""" + data.append({"no": no, "legend":legend, "amount": amount, "vat_amount": vat_amount}) + +def get_total_emiratewise(filters): + """Returns Emiratewise Amount and Taxes.""" + conditions = get_conditions(filters) + try: + return frappe.db.sql(""" + select + s.vat_emirate as emirate, sum(i.base_amount) as total, sum(s.total_taxes_and_charges) + from + `tabSales Invoice Item` i inner join `tabSales Invoice` s + on + i.parent = s.name + where + s.docstatus = 1 and i.is_exempt != 1 and i.is_zero_rated != 1 + {where_conditions} + group by + s.vat_emirate; + """.format(where_conditions=conditions), filters) + except (IndexError, TypeError): + return 0 + +def get_emirates(): + """Returns a List of emirates in the order that they are to be displayed.""" + return [ + 'Abu Dhabi', + 'Dubai', + 'Sharjah', + 'Ajman', + 'Umm Al Quwain', + 'Ras Al Khaimah', + 'Fujairah' + ] + +def get_filters(filters): + """The conditions to be used to filter data to calculate the total sale.""" + query_filters = [] + if filters.get("company"): + query_filters.append(["company", '=', filters['company']]) + if filters.get("from_date"): + query_filters.append(["posting_date", '>=', filters['from_date']]) + if filters.get("from_date"): + query_filters.append(["posting_date", '<=', filters['to_date']]) + return query_filters + +def get_reverse_charge_total(filters): + """Returns the sum of the total of each Purchase invoice made.""" + query_filters = get_filters(filters) + query_filters.append(['reverse_charge', '=', 'Y']) + query_filters.append(['docstatus', '=', 1]) + try: + return frappe.db.get_all('Purchase Invoice', + filters = query_filters, + fields = ['sum(total)'], + as_list=True, + limit = 1 + )[0][0] or 0 + except (IndexError, TypeError): + return 0 + +def get_reverse_charge_tax(filters): + """Returns the sum of the tax of each Purchase invoice made.""" + conditions = get_conditions_join(filters) + return frappe.db.sql(""" + select sum(debit) from + `tabPurchase Invoice` p inner join `tabGL Entry` gl + on + gl.voucher_no = p.name + where + p.reverse_charge = "Y" + and p.docstatus = 1 + and gl.docstatus = 1 + and account in (select account from `tabUAE VAT Account` where parent=%(company)s) + {where_conditions} ; + """.format(where_conditions=conditions), filters)[0][0] or 0 + +def get_reverse_charge_recoverable_total(filters): + """Returns the sum of the total of each Purchase invoice made with recoverable reverse charge.""" + query_filters = get_filters(filters) + query_filters.append(['reverse_charge', '=', 'Y']) + query_filters.append(['recoverable_reverse_charge', '>', '0']) + query_filters.append(['docstatus', '=', 1]) + try: + return frappe.db.get_all('Purchase Invoice', + filters = query_filters, + fields = ['sum(total)'], + as_list=True, + limit = 1 + )[0][0] or 0 + except (IndexError, TypeError): + return 0 + +def get_reverse_charge_recoverable_tax(filters): + """Returns the sum of the tax of each Purchase invoice made.""" + conditions = get_conditions_join(filters) + return frappe.db.sql(""" + select + sum(debit * p.recoverable_reverse_charge / 100) + from + `tabPurchase Invoice` p inner join `tabGL Entry` gl + on + gl.voucher_no = p.name + where + p.reverse_charge = "Y" + and p.docstatus = 1 + and p.recoverable_reverse_charge > 0 + and gl.docstatus = 1 + and account in (select account from `tabUAE VAT Account` where parent=%(company)s) + {where_conditions} ; + """.format(where_conditions=conditions), filters)[0][0] or 0 + +def get_conditions_join(filters): + """The conditions to be used to filter data to calculate the total vat.""" + conditions = "" + for opts in (("company", " and p.company=%(company)s"), + ("from_date", " and p.posting_date>=%(from_date)s"), + ("to_date", " and p.posting_date<=%(to_date)s")): + if filters.get(opts[0]): + conditions += opts[1] + return conditions + +def get_standard_rated_expenses_total(filters): + """Returns the sum of the total of each Purchase invoice made with recoverable reverse charge.""" + query_filters = get_filters(filters) + query_filters.append(['recoverable_standard_rated_expenses', '>', 0]) + query_filters.append(['docstatus', '=', 1]) + try: + return frappe.db.get_all('Purchase Invoice', + filters = query_filters, + fields = ['sum(total)'], + as_list=True, + limit = 1 + )[0][0] or 0 + except (IndexError, TypeError): + return 0 + +def get_standard_rated_expenses_tax(filters): + """Returns the sum of the tax of each Purchase invoice made.""" + query_filters = get_filters(filters) + query_filters.append(['recoverable_standard_rated_expenses', '>', 0]) + query_filters.append(['docstatus', '=', 1]) + try: + return frappe.db.get_all('Purchase Invoice', + filters = query_filters, + fields = ['sum(recoverable_standard_rated_expenses)'], + as_list=True, + limit = 1 + )[0][0] or 0 + except (IndexError, TypeError): + return 0 + +def get_tourist_tax_return_total(filters): + """Returns the sum of the total of each Sales invoice with non zero tourist_tax_return.""" + query_filters = get_filters(filters) + query_filters.append(['tourist_tax_return', '>', 0]) + query_filters.append(['docstatus', '=', 1]) + try: + return frappe.db.get_all('Sales Invoice', + filters = query_filters, + fields = ['sum(total)'], + as_list=True, + limit = 1 + )[0][0] or 0 + except (IndexError, TypeError): + return 0 + +def get_tourist_tax_return_tax(filters): + """Returns the sum of the tax of each Sales invoice with non zero tourist_tax_return.""" + query_filters = get_filters(filters) + query_filters.append(['tourist_tax_return', '>', 0]) + query_filters.append(['docstatus', '=', 1]) + try: + return frappe.db.get_all('Sales Invoice', + filters = query_filters, + fields = ['sum(tourist_tax_return)'], + as_list=True, + limit = 1 + )[0][0] or 0 + except (IndexError, TypeError): + return 0 + +def get_zero_rated_total(filters): + """Returns the sum of each Sales Invoice Item Amount which is zero rated.""" + conditions = get_conditions(filters) + try: + return frappe.db.sql(""" + select + sum(i.base_amount) as total + from + `tabSales Invoice Item` i inner join `tabSales Invoice` s + on + i.parent = s.name + where + s.docstatus = 1 and i.is_zero_rated = 1 + {where_conditions} ; + """.format(where_conditions=conditions), filters)[0][0] or 0 + except (IndexError, TypeError): + return 0 + +def get_exempt_total(filters): + """Returns the sum of each Sales Invoice Item Amount which is Vat Exempt.""" + conditions = get_conditions(filters) + try: + return frappe.db.sql(""" + select + sum(i.base_amount) as total + from + `tabSales Invoice Item` i inner join `tabSales Invoice` s + on + i.parent = s.name + where + s.docstatus = 1 and i.is_exempt = 1 + {where_conditions} ; + """.format(where_conditions=conditions), filters)[0][0] or 0 + except (IndexError, TypeError): + return 0 +def get_conditions(filters): + """The conditions to be used to filter data to calculate the total sale.""" + conditions = "" + for opts in (("company", " and company=%(company)s"), + ("from_date", " and posting_date>=%(from_date)s"), + ("to_date", " and posting_date<=%(to_date)s")): + if filters.get(opts[0]): + conditions += opts[1] + return conditions diff --git a/erpnext/regional/united_arab_emirates/setup.py b/erpnext/regional/united_arab_emirates/setup.py index 250659e54da..68208ab31bf 100644 --- a/erpnext/regional/united_arab_emirates/setup.py +++ b/erpnext/regional/united_arab_emirates/setup.py @@ -5,24 +5,33 @@ from __future__ import unicode_literals import frappe, os, json from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from frappe.permissions import add_permission, update_permission_property from erpnext.setup.setup_wizard.operations.taxes_setup import create_sales_tax +from erpnext.payroll.doctype.gratuity_rule.gratuity_rule import get_gratuity_rule def setup(company=None, patch=True): make_custom_fields() add_print_formats() + add_custom_roles_for_reports() + add_permissions() + create_gratuity_rule() if company: create_sales_tax(company) def make_custom_fields(): + is_zero_rated = dict(fieldname='is_zero_rated', label='Is Zero Rated', + fieldtype='Check', fetch_from='item_code.is_zero_rated', insert_after='description', + print_hide=1) + is_exempt = dict(fieldname='is_exempt', label='Is Exempt', + fieldtype='Check', fetch_from='item_code.is_exempt', insert_after='is_zero_rated', + print_hide=1) + invoice_fields = [ dict(fieldname='vat_section', label='VAT Details', fieldtype='Section Break', insert_after='group_same_items', print_hide=1, collapsible=1), dict(fieldname='permit_no', label='Permit Number', fieldtype='Data', insert_after='vat_section', print_hide=1), - dict(fieldname='reverse_charge_applicable', label='Reverse Charge Applicable', - fieldtype='Select', insert_after='permit_no', print_hide=1, - options='Y\nN', default='N') ] purchase_invoice_fields = [ @@ -31,7 +40,16 @@ def make_custom_fields(): fetch_from='company.tax_id', print_hide=1), dict(fieldname='supplier_name_in_arabic', label='Supplier Name in Arabic', fieldtype='Read Only', insert_after='supplier_name', - fetch_from='supplier.supplier_name_in_arabic', print_hide=1) + fetch_from='supplier.supplier_name_in_arabic', print_hide=1), + dict(fieldname='recoverable_standard_rated_expenses', print_hide=1, default='0', + label='Recoverable Standard Rated Expenses (AED)', insert_after='permit_no', + fieldtype='Currency', ), + dict(fieldname='reverse_charge', label='Reverse Charge Applicable', + fieldtype='Select', insert_after='recoverable_standard_rated_expenses', print_hide=1, + options='Y\nN', default='N'), + dict(fieldname='recoverable_reverse_charge', label='Recoverable Reverse Charge (Percentage)', + insert_after='reverse_charge', fieldtype='Percent', print_hide=1, + depends_on="eval:doc.reverse_charge=='Y'", default='100.000'), ] sales_invoice_fields = [ @@ -41,6 +59,11 @@ def make_custom_fields(): dict(fieldname='customer_name_in_arabic', label='Customer Name in Arabic', fieldtype='Read Only', insert_after='customer_name', fetch_from='customer.customer_name_in_arabic', print_hide=1), + dict(fieldname='vat_emirate', label='VAT Emirate', insert_after='permit_no', fieldtype='Select', + options='\nAbu Dhabi\nAjman\nDubai\nFujairah\nRas Al Khaimah\nSharjah\nUmm Al Quwain', + fetch_from='company_address.emirate'), + dict(fieldname='tourist_tax_return', label='Tax Refund provided to Tourists (AED)', + insert_after='vat_emirate', fieldtype='Currency', print_hide=1, default='0'), ] invoice_item_fields = [ @@ -67,6 +90,12 @@ def make_custom_fields(): 'Item': [ dict(fieldname='tax_code', label='Tax Code', fieldtype='Data', insert_after='item_group'), + dict(fieldname='is_zero_rated', label='Is Zero Rated', + fieldtype='Check', insert_after='tax_code', + print_hide=1), + dict(fieldname='is_exempt', label='Is Exempt', + fieldtype='Check', insert_after='is_zero_rated', + print_hide=1) ], 'Customer': [ dict(fieldname='customer_name_in_arabic', label='Customer Name in Arabic', @@ -76,13 +105,19 @@ def make_custom_fields(): dict(fieldname='supplier_name_in_arabic', label='Supplier Name in Arabic', fieldtype='Data', insert_after='supplier_name'), ], + 'Address': [ + dict(fieldname='emirate', label='Emirate', fieldtype='Select', insert_after='state', + options='\nAbu Dhabi\nAjman\nDubai\nFujairah\nRas Al Khaimah\nSharjah\nUmm Al Quwain') + ], 'Purchase Invoice': purchase_invoice_fields + invoice_fields, 'Purchase Order': purchase_invoice_fields + invoice_fields, 'Purchase Receipt': purchase_invoice_fields + invoice_fields, 'Sales Invoice': sales_invoice_fields + invoice_fields, + 'POS Invoice': sales_invoice_fields + invoice_fields, 'Sales Order': sales_invoice_fields + invoice_fields, 'Delivery Note': sales_invoice_fields + invoice_fields, - 'Sales Invoice Item': invoice_item_fields + delivery_date_field, + 'Sales Invoice Item': invoice_item_fields + delivery_date_field + [is_zero_rated, is_exempt], + 'POS Invoice Item': invoice_item_fields + delivery_date_field + [is_zero_rated, is_exempt], 'Purchase Invoice Item': invoice_item_fields, 'Sales Order Item': invoice_item_fields, 'Delivery Note Item': invoice_item_fields, @@ -101,3 +136,115 @@ def add_print_formats(): frappe.db.sql(""" update `tabPrint Format` set disabled = 0 where name in('Simplified Tax Invoice', 'Detailed Tax Invoice', 'Tax Invoice') """) + +def add_custom_roles_for_reports(): + """Add Access Control to UAE VAT 201.""" + if not frappe.db.get_value('Custom Role', dict(report='UAE VAT 201')): + frappe.get_doc(dict( + doctype='Custom Role', + report='UAE VAT 201', + roles= [ + dict(role='Accounts User'), + dict(role='Accounts Manager'), + dict(role='Auditor') + ] + )).insert() + +def add_permissions(): + """Add Permissions for UAE VAT Settings and UAE VAT Account.""" + for doctype in ('UAE VAT Settings', 'UAE VAT Account'): + add_permission(doctype, 'All', 0) + for role in ('Accounts Manager', 'Accounts User', 'System Manager'): + add_permission(doctype, role, 0) + update_permission_property(doctype, role, 0, 'write', 1) + update_permission_property(doctype, role, 0, 'create', 1) + +def create_gratuity_rule(): + rule_1 = rule_2 = rule_3 = None + + # Rule Under Limited Contract + slabs = get_slab_for_limited_contract() + if not frappe.db.exists("Gratuity Rule", "Rule Under Limited Contract (UAE)"): + rule_1 = get_gratuity_rule("Rule Under Limited Contract (UAE)", slabs, calculate_gratuity_amount_based_on="Sum of all previous slabs") + + # Rule Under Unlimited Contract on termination + slabs = get_slab_for_unlimited_contract_on_termination() + if not frappe.db.exists("Gratuity Rule", "Rule Under Unlimited Contract on termination (UAE)"): + rule_2 = get_gratuity_rule("Rule Under Unlimited Contract on termination (UAE)", slabs) + + # Rule Under Unlimited Contract on resignation + slabs = get_slab_for_unlimited_contract_on_resignation() + if not frappe.db.exists("Gratuity Rule", "Rule Under Unlimited Contract on resignation (UAE)"): + rule_3 = get_gratuity_rule("Rule Under Unlimited Contract on resignation (UAE)", slabs) + + #for applicable salary component user need to set this by its own + if rule_1: + rule_1.flags.ignore_mandatory = True + rule_1.save() + if rule_2: + rule_2.flags.ignore_mandatory = True + rule_2.save() + if rule_3: + rule_3.flags.ignore_mandatory = True + rule_3.save() + + +def get_slab_for_limited_contract(): + return [{ + "from_year": 0, + "to_year":1, + "fraction_of_applicable_earnings": 0 + }, + { + "from_year": 1, + "to_year":5, + "fraction_of_applicable_earnings": 21/30 + }, + { + "from_year": 5, + "to_year":0, + "fraction_of_applicable_earnings": 1 + }] + +def get_slab_for_unlimited_contract_on_termination(): + return [{ + "from_year": 0, + "to_year":1, + "fraction_of_applicable_earnings": 0 + }, + { + "from_year": 1, + "to_year":5, + "fraction_of_applicable_earnings": 21/30 + }, + { + "from_year": 5, + "to_year":0, + "fraction_of_applicable_earnings": 1 + }] + +def get_slab_for_unlimited_contract_on_resignation(): + fraction_1 = 1/3 * 21/30 + fraction_2 = 2/3 * 21/30 + fraction_3 = 21/30 + + return [{ + "from_year": 0, + "to_year":1, + "fraction_of_applicable_earnings": 0 + }, + { + "from_year": 1, + "to_year":3, + "fraction_of_applicable_earnings": fraction_1 + }, + { + "from_year": 3, + "to_year":5, + "fraction_of_applicable_earnings": fraction_2 + }, + { + "from_year": 5, + "to_year":0, + "fraction_of_applicable_earnings": fraction_3 + }] diff --git a/erpnext/regional/united_arab_emirates/utils.py b/erpnext/regional/united_arab_emirates/utils.py index a0425f6b1c2..7d5fd6ecf86 100644 --- a/erpnext/regional/united_arab_emirates/utils.py +++ b/erpnext/regional/united_arab_emirates/utils.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals import frappe -from frappe.utils import flt +from frappe import _ +import erpnext +from frappe.utils import flt, round_based_on_smallest_currency_fraction, money_in_words from erpnext.controllers.taxes_and_totals import get_itemised_tax from six import iteritems @@ -26,4 +28,134 @@ def update_itemised_tax_data(doc): row.tax_rate = flt(tax_rate, row.precision("tax_rate")) row.tax_amount = flt((row.net_amount * tax_rate) / 100, row.precision("net_amount")) - row.total_amount = flt((row.net_amount + row.tax_amount), row.precision("total_amount")) \ No newline at end of file + row.total_amount = flt((row.net_amount + row.tax_amount), row.precision("total_amount")) + +def get_account_currency(account): + """Helper function to get account currency.""" + if not account: + return + def generator(): + account_currency, company = frappe.get_cached_value( + "Account", + account, + ["account_currency", + "company"] + ) + if not account_currency: + account_currency = frappe.get_cached_value('Company', company, "default_currency") + + return account_currency + + return frappe.local_cache("account_currency", account, generator) + +def get_tax_accounts(company): + """Get the list of tax accounts for a specific company.""" + tax_accounts_dict = frappe._dict() + tax_accounts_list = frappe.get_all("UAE VAT Account", + filters={"parent": company}, + fields=["Account"] + ) + + if not tax_accounts_list and not frappe.flags.in_test: + frappe.throw(_('Please set Vat Accounts for Company: "{0}" in UAE VAT Settings').format(company)) + for tax_account in tax_accounts_list: + for account, name in tax_account.items(): + tax_accounts_dict[name] = name + + return tax_accounts_dict + +def update_grand_total_for_rcm(doc, method): + """If the Reverse Charge is Applicable subtract the tax amount from the grand total and update in the form.""" + country = frappe.get_cached_value('Company', doc.company, 'country') + + if country != 'United Arab Emirates': + return + + if not doc.total_taxes_and_charges: + return + + if doc.reverse_charge == 'Y': + tax_accounts = get_tax_accounts(doc.company) + + base_vat_tax = 0 + vat_tax = 0 + + for tax in doc.get('taxes'): + if tax.category not in ("Total", "Valuation and Total"): + continue + + if flt(tax.base_tax_amount_after_discount_amount) and tax.account_head in tax_accounts: + base_vat_tax += tax.base_tax_amount_after_discount_amount + vat_tax += tax.tax_amount_after_discount_amount + + doc.taxes_and_charges_added -= vat_tax + doc.total_taxes_and_charges -= vat_tax + doc.base_taxes_and_charges_added -= base_vat_tax + doc.base_total_taxes_and_charges -= base_vat_tax + + update_totals(vat_tax, base_vat_tax, doc) + +def update_totals(vat_tax, base_vat_tax, doc): + """Update the grand total values in the form.""" + doc.base_grand_total -= base_vat_tax + doc.grand_total -= vat_tax + + if doc.meta.get_field("rounded_total"): + + if doc.is_rounded_total_disabled(): + doc.outstanding_amount = doc.grand_total + + else: + doc.rounded_total = round_based_on_smallest_currency_fraction(doc.grand_total, + doc.currency, doc.precision("rounded_total")) + doc.rounding_adjustment = flt(doc.rounded_total - doc.grand_total, + doc.precision("rounding_adjustment")) + doc.outstanding_amount = doc.rounded_total or doc.grand_total + + doc.in_words = money_in_words(doc.grand_total, doc.currency) + doc.base_in_words = money_in_words(doc.base_grand_total, erpnext.get_company_currency(doc.company)) + doc.set_payment_schedule() + +def make_regional_gl_entries(gl_entries, doc): + """Hooked to make_regional_gl_entries in Purchase Invoice.It appends the region specific general ledger entries to the list of GL Entries.""" + country = frappe.get_cached_value('Company', doc.company, 'country') + + if country != 'United Arab Emirates': + return gl_entries + + if doc.reverse_charge == 'Y': + tax_accounts = get_tax_accounts(doc.company) + for tax in doc.get('taxes'): + if tax.category not in ("Total", "Valuation and Total"): + continue + gl_entries = make_gl_entry(tax, gl_entries, doc, tax_accounts) + return gl_entries + +def make_gl_entry(tax, gl_entries, doc, tax_accounts): + dr_or_cr = "credit" if tax.add_deduct_tax == "Add" else "debit" + if flt(tax.base_tax_amount_after_discount_amount) and tax.account_head in tax_accounts: + account_currency = get_account_currency(tax.account_head) + + gl_entries.append(doc.get_gl_dict({ + "account": tax.account_head, + "cost_center": tax.cost_center, + "posting_date": doc.posting_date, + "against": doc.supplier, + dr_or_cr: tax.base_tax_amount_after_discount_amount, + dr_or_cr + "_in_account_currency": tax.base_tax_amount_after_discount_amount \ + if account_currency==doc.company_currency \ + else tax.tax_amount_after_discount_amount + }, account_currency, item=tax + )) + return gl_entries + + +def validate_returns(doc, method): + """Standard Rated expenses should not be set when Reverse Charge Applicable is set.""" + country = frappe.get_cached_value('Company', doc.company, 'country') + if country != 'United Arab Emirates': + return + if doc.reverse_charge == 'Y' and flt(doc.recoverable_standard_rated_expenses) != 0: + frappe.throw(_( + "Recoverable Standard Rated expenses should not be set when Reverse Charge Applicable is Y" + )) diff --git a/erpnext/regional/united_states/setup.py b/erpnext/regional/united_states/setup.py index 2b0ecafebc5..24ab1cf049f 100644 --- a/erpnext/regional/united_states/setup.py +++ b/erpnext/regional/united_states/setup.py @@ -36,5 +36,4 @@ def make_custom_fields(update=True): def add_print_formats(): frappe.reload_doc("regional", "print_format", "irs_1099_form") - frappe.db.sql(""" update `tabPrint Format` set disabled = 0 where - name in('IRS 1099 Form') """) + frappe.db.set_value("Print Format", "IRS 1099 Form", "disabled", 0) diff --git a/erpnext/regional/united_states/test_united_states.py b/erpnext/regional/united_states/test_united_states.py index ad95010a9ac..513570ed6df 100644 --- a/erpnext/regional/united_states/test_united_states.py +++ b/erpnext/regional/united_states/test_united_states.py @@ -26,7 +26,6 @@ class TestUnitedStates(unittest.TestCase): make_payment_entry_to_irs_1099_supplier() filters = frappe._dict({"fiscal_year": "_Test Fiscal Year 2016", "company": "_Test Company 1"}) columns, data = execute_1099_report(filters) - print(columns, data) expected_row = {'supplier': '_US 1099 Test Supplier', 'supplier_group': 'Services', 'payments': 100.0, diff --git a/erpnext/selling/desk_page/retail/retail.json b/erpnext/selling/desk_page/retail/retail.json deleted file mode 100644 index c4ddf26a901..00000000000 --- a/erpnext/selling/desk_page/retail/retail.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Settings & Configurations", - "links": "[\n {\n \"description\": \"Setup default values for POS Invoices\",\n \"label\": \"Point-of-Sale Profile\",\n \"name\": \"POS Profile\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"POS Settings\",\n \"name\": \"POS Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Loyalty Program", - "links": "[\n {\n \"description\": \"To make Customer based incentive schemes.\",\n \"label\": \"Loyalty Program\",\n \"name\": \"Loyalty Program\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"To view logs of Loyalty Points assigned to a Customer.\",\n \"label\": \"Loyalty Point Entry\",\n \"name\": \"Loyalty Point Entry\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Opening & Closing", - "links": "[\n {\n \"label\": \"POS Opening Entry\",\n \"name\": \"POS Opening Entry\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"POS Closing Entry\",\n \"name\": \"POS Closing Entry\",\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Domains", - "charts": [], - "creation": "2020-03-02 17:18:32.505616", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Retail", - "modified": "2020-09-09 11:46:28.297435", - "modified_by": "Administrator", - "module": "Selling", - "name": "Retail", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "restrict_to_domain": "Retail", - "shortcuts": [ - { - "doc_view": "", - "label": "Point Of Sale", - "link_to": "point-of-sale", - "type": "Page" - } - ] -} \ No newline at end of file diff --git a/erpnext/selling/desk_page/selling/selling.json b/erpnext/selling/desk_page/selling/selling.json deleted file mode 100644 index 4c09ee94e04..00000000000 --- a/erpnext/selling/desk_page/selling/selling.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Selling", - "links": "[\n {\n \"description\": \"Customer Database.\",\n \"label\": \"Customer\",\n \"name\": \"Customer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Customer\"\n ],\n \"description\": \"Quotes to Leads or Customers.\",\n \"label\": \"Quotation\",\n \"name\": \"Quotation\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Customer\"\n ],\n \"description\": \"Confirmed orders from Customers.\",\n \"label\": \"Sales Order\",\n \"name\": \"Sales Order\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Customer\"\n ],\n \"label\": \"Sales Invoice\",\n \"name\": \"Sales Invoice\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Customer\"\n ],\n \"description\": \"Blanket Orders from Costumers.\",\n \"label\": \"Blanket Order\",\n \"name\": \"Blanket Order\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\"\n ],\n \"description\": \"Manage Sales Partners.\",\n \"label\": \"Sales Partner\",\n \"name\": \"Sales Partner\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Customer\"\n ],\n \"description\": \"Manage Sales Person Tree.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Sales Person\",\n \"link\": \"Tree/Sales Person\",\n \"name\": \"Sales Person\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Items and Pricing", - "links": "[\n {\n \"description\": \"All Products or Services.\",\n \"label\": \"Item\",\n \"name\": \"Item\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\",\n \"Price List\"\n ],\n \"description\": \"Multiple Item prices.\",\n \"label\": \"Item Price\",\n \"name\": \"Item Price\",\n \"onboard\": 1,\n \"route\": \"#Report/Item Price\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Price List master.\",\n \"label\": \"Price List\",\n \"name\": \"Price List\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tree of Item Groups.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Item Group\",\n \"link\": \"Tree/Item Group\",\n \"name\": \"Item Group\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\"\n ],\n \"description\": \"Bundle items at time of sale.\",\n \"label\": \"Product Bundle\",\n \"name\": \"Product Bundle\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Rules for applying different promotional schemes.\",\n \"label\": \"Promotional Scheme\",\n \"name\": \"Promotional Scheme\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Item\"\n ],\n \"description\": \"Rules for applying pricing and discount.\",\n \"label\": \"Pricing Rule\",\n \"name\": \"Pricing Rule\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Rules for adding shipping costs.\",\n \"label\": \"Shipping Rule\",\n \"name\": \"Shipping Rule\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Define coupon codes.\",\n \"label\": \"Coupon Code\",\n \"name\": \"Coupon Code\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"description\": \"Default settings for selling transactions.\",\n \"label\": \"Selling Settings\",\n \"name\": \"Selling Settings\",\n \"settings\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Template of terms or contract.\",\n \"label\": \"Terms and Conditions Template\",\n \"name\": \"Terms and Conditions\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tax template for selling transactions.\",\n \"label\": \"Sales Taxes and Charges Template\",\n \"name\": \"Sales Taxes and Charges Template\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Track Leads by Lead Source.\",\n \"label\": \"Lead Source\",\n \"name\": \"Lead Source\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Manage Customer Group Tree.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Customer Group\",\n \"link\": \"Tree/Customer Group\",\n \"name\": \"Customer Group\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"All Contacts.\",\n \"label\": \"Contact\",\n \"name\": \"Contact\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"All Addresses.\",\n \"label\": \"Address\",\n \"name\": \"Address\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Manage Territory Tree.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Territory\",\n \"link\": \"Tree/Territory\",\n \"name\": \"Territory\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Sales campaigns.\",\n \"label\": \"Campaign\",\n \"name\": \"Campaign\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Key Reports", - "links": "[\n {\n \n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Analytics\",\n \"name\": \"Sales Analytics\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Order Analysis\",\n \"name\": \"Sales Order Analysis\",\n \"onboard\": 1,\n \"type\": \"report\"\n },\n {\n \"icon\": \"fa fa-bar-chart\",\n \"label\": \"Sales Funnel\",\n \"name\": \"sales-funnel\",\n \"onboard\": 1,\n \"type\": \"page\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Order Trends\",\n \"name\": \"Sales Order Trends\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Quotation\"\n ],\n \"doctype\": \"Quotation\",\n \"is_query_report\": true,\n \"label\": \"Quotation Trends\",\n \"name\": \"Quotation Trends\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Customer\"\n ],\n \"doctype\": \"Customer\",\n \"icon\": \"fa fa-bar-chart\",\n \"is_query_report\": true,\n \"label\": \"Customer Acquisition and Loyalty\",\n \"name\": \"Customer Acquisition and Loyalty\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Inactive Customers\",\n \"name\": \"Inactive Customers\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Sales Person-wise Transaction Summary\",\n \"name\": \"Sales Person-wise Transaction Summary\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Item\"\n ],\n \"doctype\": \"Item\",\n \"is_query_report\": true,\n \"label\": \"Item-wise Sales History\",\n \"name\": \"Item-wise Sales History\",\n \"type\": \"report\"\n }\n]" - }, - { - "hidden": 0, - "label": "Other Reports", - "links": "[\n {\n \"dependencies\": [\n \"Lead\"\n ],\n \"doctype\": \"Lead\",\n \"is_query_report\": true,\n \"label\": \"Lead Details\",\n \"name\": \"Lead Details\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Address\"\n ],\n \"doctype\": \"Address\",\n \"is_query_report\": true,\n \"label\": \"Customer Addresses And Contacts\",\n \"name\": \"Address And Contacts\",\n \"route_options\": {\n \"party_type\": \"Customer\"\n },\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Item\"\n ],\n \"doctype\": \"Item\",\n \"is_query_report\": true,\n \"label\": \"Available Stock for Packing Items\",\n \"name\": \"Available Stock for Packing Items\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Order\"\n ],\n \"doctype\": \"Sales Order\",\n \"is_query_report\": true,\n \"label\": \"Pending SO Items For Purchase Request\",\n \"name\": \"Pending SO Items For Purchase Request\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Delivery Note\"\n ],\n \"doctype\": \"Delivery Note\",\n \"is_query_report\": true,\n \"label\": \"Delivery Note Trends\",\n \"name\": \"Delivery Note Trends\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Sales Invoice\"\n ],\n \"doctype\": \"Sales Invoice\",\n \"is_query_report\": true,\n \"label\": \"Sales Invoice Trends\",\n \"name\": \"Sales Invoice Trends\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Customer\"\n ],\n \"doctype\": \"Customer\",\n \"is_query_report\": true,\n \"label\": \"Customer Credit Balance\",\n \"name\": \"Customer Credit Balance\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Customer\"\n ],\n \"doctype\": \"Customer\",\n \"is_query_report\": true,\n \"label\": \"Customers Without Any Sales Transactions\",\n \"name\": \"Customers Without Any Sales Transactions\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"Customer\"\n ],\n \"doctype\": \"Customer\",\n \"is_query_report\": true,\n \"label\": \"Sales Partners Commission\",\n \"name\": \"Sales Partners Commission\",\n \"type\": \"report\"\n }\n]" - } - ], - "category": "Modules", - "charts": [ - { - "chart_name": "Sales Order Trends", - "label": "Sales Order Trends" - } - ], - "charts_label": "Selling ", - "creation": "2020-01-28 11:49:12.092882", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 1, - "idx": 0, - "is_standard": 1, - "label": "Selling", - "modified": "2020-08-15 10:12:53.131621", - "modified_by": "Administrator", - "module": "Selling", - "name": "Selling", - "onboarding": "Selling", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "color": "#cef6d1", - "format": "{} Available", - "label": "Item", - "link_to": "Item", - "stats_filter": "{\n \"disabled\":0\n}", - "type": "DocType" - }, - { - "color": "#ffe8cd", - "format": "{} To Deliver", - "label": "Sales Order", - "link_to": "Sales Order", - "stats_filter": "{\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%'],\n \"status\":[\"in\", [\"To Deliver\", \"To Deliver and Bill\"]]\n}", - "type": "DocType" - }, - { - "color": "#cef6d1", - "format": "{} Open", - "label": "Sales Analytics", - "link_to": "Sales Analytics", - "stats_filter": "{ \"Status\": \"Open\" }", - "type": "Report" - }, - { - "label": "Sales Order Analysis", - "link_to": "Sales Order Analysis", - "type": "Report" - }, - { - "label": "Dashboard", - "link_to": "Selling", - "type": "Dashboard" - } - ], - "shortcuts_label": "Quick Access" -} \ No newline at end of file diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json index 557c7151d96..7d5e84df52f 100644 --- a/erpnext/selling/doctype/customer/customer.json +++ b/erpnext/selling/doctype/customer/customer.json @@ -16,6 +16,8 @@ "customer_name", "gender", "customer_type", + "pan", + "tax_withholding_category", "default_bank_account", "lead_name", "image", @@ -34,9 +36,8 @@ "companies", "currency_and_price_list", "default_currency", - "default_price_list", "column_break_14", - "language", + "default_price_list", "address_contacts", "address_html", "website", @@ -59,6 +60,7 @@ "column_break_45", "market_segment", "industry", + "language", "is_frozen", "column_break_38", "loyalty_program", @@ -479,13 +481,25 @@ "fieldname": "dn_required", "fieldtype": "Check", "label": "Allow Sales Invoice Creation Without Delivery Note" + }, + { + "fieldname": "pan", + "fieldtype": "Data", + "label": "PAN" + }, + { + "fieldname": "tax_withholding_category", + "fieldtype": "Link", + "label": "Tax Withholding Category", + "options": "Tax Withholding Category" } ], "icon": "fa fa-user", "idx": 363, "image_field": "image", + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-03-17 11:03:42.706907", + "modified": "2021-01-28 12:54:57.258959", "modified_by": "Administrator", "module": "Selling", "name": "Customer", diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 1f955fcd52e..c4525946088 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -58,6 +58,7 @@ class Customer(TransactionBase): self.set_loyalty_program() self.check_customer_group_change() self.validate_default_bank_account() + self.validate_internal_customer() # set loyalty program tier if frappe.db.exists('Customer', self.name): @@ -82,6 +83,14 @@ class Customer(TransactionBase): if not is_company_account: frappe.throw(_("{0} is not a company bank account").format(frappe.bold(self.default_bank_account))) + def validate_internal_customer(self): + internal_customer = frappe.db.get_value("Customer", + {"is_internal_customer": 1, "represents_company": self.represents_company, "name": ("!=", self.name)}, "name") + + if internal_customer: + frappe.throw(_("Internal Customer for company {0} already exists").format( + frappe.bold(self.represents_company))) + def on_update(self): self.validate_name_with_customer_group() self.create_primary_contact() @@ -117,7 +126,9 @@ class Customer(TransactionBase): '''If Customer created from Lead, update lead status to "Converted" update Customer link in Quotation, Opportunity''' if self.lead_name: - frappe.db.set_value('Lead', self.lead_name, 'status', 'Converted', update_modified=False) + lead = frappe.get_doc('Lead', self.lead_name) + lead.status = 'Converted' + lead.save() def create_lead_address_contact(self): if self.lead_name: @@ -132,7 +143,7 @@ class Customer(TransactionBase): address = frappe.get_doc('Address', address_name.get('name')) if not address.has_link('Customer', self.name): address.append('links', dict(link_doctype='Customer', link_name=self.name)) - address.save() + address.save(ignore_permissions=self.flags.ignore_permissions) lead = frappe.db.get_value("Lead", self.lead_name, ["organization_lead", "lead_name", "email_id", "phone", "mobile_no", "gender", "salutation"], as_dict=True) @@ -150,7 +161,7 @@ class Customer(TransactionBase): contact = frappe.get_doc('Contact', contact_name.get('name')) if not contact.has_link('Customer', self.name): contact.append('links', dict(link_doctype='Customer', link_name=self.name)) - contact.save() + contact.save(ignore_permissions=self.flags.ignore_permissions) else: lead.lead_name = lead.lead_name.lstrip().split(" ") @@ -398,7 +409,7 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False, # form a list of emails and names to show to the user credit_controller_users_formatted = [get_formatted_email(user).replace("<", "(").replace(">", ")") for user in credit_controller_users] if not credit_controller_users_formatted: - frappe.throw(_("Please contact your administrator to extend the credit limits for {0}.".format(customer))) + frappe.throw(_("Please contact your administrator to extend the credit limits for {0}.").format(customer)) message = """Please contact any of the following users to extend the credit limits for {0}:

    • {1}
    """.format(customer, '
  • '.join(credit_controller_users_formatted)) diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 87fdaa366f1..7761aa70fb2 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -54,7 +54,11 @@ class TestCustomer(unittest.TestCase): details = get_party_details("_Test Customer") for key, value in iteritems(to_check): - self.assertEqual(value, details.get(key)) + val = details.get(key) + if not val and not isinstance(val, list): + val = None + + self.assertEqual(value, val) def test_party_details_tax_category(self): from erpnext.accounts.party import get_party_details diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 12f32602f5a..5a0d9c90655 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -7,7 +7,7 @@ frappe.ui.form.on('Quotation', { setup: function(frm) { frm.custom_make_buttons = { - 'Sales Order': 'Make Sales Order' + 'Sales Order': 'Sales Order' }, frm.set_query("quotation_to", function() { @@ -116,7 +116,7 @@ erpnext.selling.QuotationController = erpnext.selling.SellingController.extend({ company: me.frm.doc.company } }) - }, __("Get items from"), "btn-default"); + }, __("Get Items From"), "btn-default"); } this.toggle_reqd_lead_customer(); diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index 5b85187ccb9..3eba62bc193 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_auto_repeat": 1, "allow_import": 1, "autoname": "naming_series:", "creation": "2013-05-24 19:29:08", @@ -932,7 +933,7 @@ "is_submittable": 1, "links": [], "max_attachments": 1, - "modified": "2020-07-26 17:46:19.951223", + "modified": "2020-10-30 13:58:59.212060", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 20ae19f5dbe..5da248c1b52 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -19,13 +19,12 @@ class Quotation(SellingController): self.indicator_color = 'blue' self.indicator_title = 'Submitted' if self.valid_till and getdate(self.valid_till) < getdate(nowdate()): - self.indicator_color = 'darkgrey' + self.indicator_color = 'gray' self.indicator_title = 'Expired' def validate(self): super(Quotation, self).validate() self.set_status() - self.update_opportunity() self.validate_uom_is_integer("stock_uom", "qty") self.validate_valid_till() self.set_customer_name() @@ -50,21 +49,20 @@ class Quotation(SellingController): lead_name, company_name = frappe.db.get_value("Lead", self.party_name, ["lead_name", "company_name"]) self.customer_name = company_name or lead_name - def update_opportunity(self): + def update_opportunity(self, status): for opportunity in list(set([d.prevdoc_docname for d in self.get("items")])): if opportunity: - self.update_opportunity_status(opportunity) + self.update_opportunity_status(status, opportunity) if self.opportunity: - self.update_opportunity_status() + self.update_opportunity_status(status) - def update_opportunity_status(self, opportunity=None): + def update_opportunity_status(self, status, opportunity=None): if not opportunity: opportunity = self.opportunity opp = frappe.get_doc("Opportunity", opportunity) - opp.status = None - opp.set_status(update=True) + opp.set_status(status=status, update=True) def declare_enquiry_lost(self, lost_reasons_list, detailed_reason=None): if not self.has_sales_order(): @@ -82,7 +80,7 @@ class Quotation(SellingController): else: frappe.throw(_("Invalid lost reason {0}, please create a new lost reason").format(frappe.bold(reason.get('lost_reason')))) - self.update_opportunity() + self.update_opportunity('Lost') self.update_lead() self.save() @@ -95,7 +93,7 @@ class Quotation(SellingController): self.company, self.base_grand_total, self) #update enquiry status - self.update_opportunity() + self.update_opportunity('Quotation') self.update_lead() def on_cancel(self): @@ -105,7 +103,7 @@ class Quotation(SellingController): #update enquiry status self.set_status(update=True) - self.update_opportunity() + self.update_opportunity('Open') self.update_lead() def print_other_charges(self,docname): diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js index f425acf180a..b631685bd19 100644 --- a/erpnext/selling/doctype/quotation/quotation_list.js +++ b/erpnext/selling/doctype/quotation/quotation_list.js @@ -20,9 +20,9 @@ frappe.listview_settings['Quotation'] = { } else if(doc.status==="Ordered") { return [__("Ordered"), "green", "status,=,Ordered"]; } else if(doc.status==="Lost") { - return [__("Lost"), "darkgrey", "status,=,Lost"]; + return [__("Lost"), "gray", "status,=,Lost"]; } else if(doc.status==="Expired") { - return [__("Expired"), "darkgrey", "status,=,Expired"]; + return [__("Expired"), "gray", "status,=,Expired"]; } } }; diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index b4c3d79f31c..f0143f34a41 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -108,6 +108,10 @@ class TestQuotation(unittest.TestCase): sales_order.transaction_date = nowdate() sales_order.insert() + # Remove any unknown taxes if applied + sales_order.set('taxes', []) + sales_order.save() + self.assertEqual(sales_order.payment_schedule[0].payment_amount, 8906.00) self.assertEqual(sales_order.payment_schedule[0].due_date, getdate(quotation.transaction_date)) self.assertEqual(sales_order.payment_schedule[1].payment_amount, 8906.00) diff --git a/erpnext/selling/doctype/quotation/tests/test_quotation.js b/erpnext/selling/doctype/quotation/tests/test_quotation.js index d69d799d0de..ad942fe4976 100644 --- a/erpnext/selling/doctype/quotation/tests/test_quotation.js +++ b/erpnext/selling/doctype/quotation/tests/test_quotation.js @@ -46,7 +46,7 @@ QUnit.test("test: quotation", function (assert) { assert.ok(cur_frm.doc.items[0].rate == 200, "Price Changed Manually"); assert.equal(cur_frm.doc.total, 1000, "New Total Calculated"); - // Check Terms and Condtions + // Check Terms and Conditions assert.ok(cur_frm.doc.tc_name == "Test Term 1", "Terms and Conditions Checked"); assert.ok(cur_frm.doc.payment_terms_template, "Payment Terms Template is correct"); diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 59ae7b23239..8b53902d32f 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -47,6 +47,7 @@ "base_amount", "base_net_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_43", "valuation_rate", @@ -634,12 +635,21 @@ "print_hide": 1, "read_only": 1, "report_hide": 1 + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "no_copy": 1, + "options": "currency", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-05-19 20:48:43.222229", + "modified": "2021-02-23 01:13:54.670763", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.py b/erpnext/selling/doctype/quotation_item/quotation_item.py index 966b542c41f..7384871ed44 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.py +++ b/erpnext/selling/doctype/quotation_item/quotation_item.py @@ -5,8 +5,6 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document -from erpnext.controllers.print_settings import print_settings_for_item_table class QuotationItem(Document): - def __setup__(self): - print_settings_for_item_table(self) + pass diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 705dcb8e03a..e3b41e66fbc 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -8,7 +8,7 @@ frappe.ui.form.on("Sales Order", { frm.custom_make_buttons = { 'Delivery Note': 'Delivery Note', 'Pick List': 'Pick List', - 'Sales Invoice': 'Invoice', + 'Sales Invoice': 'Sales Invoice', 'Material Request': 'Material Request', 'Purchase Order': 'Purchase Order', 'Project': 'Project', @@ -162,7 +162,7 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( // sales invoice if(flt(doc.per_billed, 6) < 100) { - this.frm.add_custom_button(__('Invoice'), () => me.make_sales_invoice(), __('Create')); + this.frm.add_custom_button(__('Sales Invoice'), () => me.make_sales_invoice(), __('Create')); } // material request @@ -171,8 +171,10 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( this.frm.add_custom_button(__('Request for Raw Materials'), () => this.make_raw_material_request(), __('Create')); } - // make purchase order + // Make Purchase Order + if (!this.frm.doc.is_internal_customer) { this.frm.add_custom_button(__('Purchase Order'), () => this.make_purchase_order(), __('Create')); + } // maintenance if(flt(doc.per_delivered, 2) < 100 && (order_is_maintenance || order_is_a_custom_sale)) { @@ -181,7 +183,7 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( } // project - if(flt(doc.per_delivered, 2) < 100 && (order_is_a_sale || order_is_a_custom_sale) && allow_delivery) { + if(flt(doc.per_delivered, 2) < 100) { this.frm.add_custom_button(__('Project'), () => this.make_project(), __('Create')); } @@ -193,16 +195,15 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( if (doc.docstatus === 1 && !doc.inter_company_order_reference) { let me = this; - frappe.model.with_doc("Customer", me.frm.doc.customer, () => { - let customer = frappe.model.get_doc("Customer", me.frm.doc.customer); - let internal = customer.is_internal_customer; - let disabled = customer.disabled; - if (internal === 1 && disabled === 0) { - me.frm.add_custom_button("Inter Company Order", function() { - me.make_inter_company_order(); - }, __('Create')); - } - }); + let internal = me.frm.doc.is_internal_customer; + if (internal) { + let button_label = (me.frm.doc.company === me.frm.doc.represents_company) ? "Internal Purchase Order" : + "Inter Company Purchase Order"; + + me.frm.add_custom_button(button_label, function() { + me.make_inter_company_order(); + }, __('Create')); + } } } // payment request @@ -236,7 +237,7 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( status: ["!=", "Lost"] } }) - }, __("Get items from")); + }, __("Get Items From")); } this.order_type(doc); @@ -326,9 +327,8 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( callback: function(r) { if(r.message) { frappe.msgprint({ - message: __('Work Orders Created: {0}', - [r.message.map(function(d) { - return repl('%(name)s', {name:d}) + message: __('Work Orders Created: {0}', [r.message.map(function(d) { + return repl('%(name)s', {name:d}) }).join(', ')]), indicator: 'green' }) @@ -437,7 +437,7 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( callback: function(r) { if(r.message) { frappe.msgprint(__('Material Request {0} submitted.', - ['' + r.message.name+ ''])); + ['' + r.message.name+ ''])); } d.hide(); me.frm.reload_doc(); @@ -514,7 +514,7 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( make_delivery_note: function() { frappe.model.open_mapped_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", - frm: me.frm + frm: this.frm }) }, @@ -554,19 +554,26 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( }, make_purchase_order: function(){ + let pending_items = this.frm.doc.items.some((item) =>{ + let pending_qty = flt(item.stock_qty) - flt(item.ordered_qty); + return pending_qty > 0; + }) + if(!pending_items){ + frappe.throw({message: __("Purchase Order already created for all Sales Order items"), title: __("Note")}); + } + var me = this; var dialog = new frappe.ui.Dialog({ - title: __("For Supplier"), + title: __("Select Items"), fields: [ - {"fieldtype": "Link", "label": __("Supplier"), "fieldname": "supplier", "options":"Supplier", - "description": __("Leave the field empty to make purchase orders for all suppliers"), - "get_query": function () { - return { - query:"erpnext.selling.doctype.sales_order.sales_order.get_supplier", - filters: {'parent': me.frm.doc.name} - } - }}, - {fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items', + { + "fieldtype": "Check", + "label": __("Against Default Supplier"), + "fieldname": "against_default_supplier", + "default": 0 + }, + { + fieldname: 'items_for_po', fieldtype: 'Table', label: 'Select Items', fields: [ { fieldtype:'Data', @@ -584,8 +591,8 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( }, { fieldtype:'Float', - fieldname:'qty', - label: __('Quantity'), + fieldname:'pending_qty', + label: __('Pending Qty'), read_only: 1, in_list_view:1 }, @@ -594,60 +601,97 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( read_only:1, fieldname:'uom', label: __('UOM'), + in_list_view:1, + }, + { + fieldtype:'Data', + fieldname:'supplier', + label: __('Supplier'), + read_only:1, in_list_view:1 - } - ], - data: cur_frm.doc.items, - get_data: function() { - return cur_frm.doc.items - } - }, - - {"fieldtype": "Button", "label": __('Create Purchase Order'), "fieldname": "make_purchase_order", "cssClass": "btn-primary"}, - ] - }); - - dialog.fields_dict.make_purchase_order.$input.click(function() { - var args = dialog.get_values(); - let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children() - if(selected_items.length == 0) { - frappe.throw({message: 'Please select Item form Table', title: __('Message'), indicator:'blue'}) - } - let selected_items_list = [] - for(let i in selected_items){ - selected_items_list.push(selected_items[i].item_code) - } - dialog.hide(); - return frappe.call({ - type: "GET", - method: "erpnext.selling.doctype.sales_order.sales_order.make_purchase_order", - args: { - "source_name": me.frm.doc.name, - "for_supplier": args.supplier, - "selected_items": selected_items_list - }, - freeze: true, - callback: function(r) { - if(!r.exc) { - // var args = dialog.get_values(); - if (args.supplier){ - var doc = frappe.model.sync(r.message); - frappe.set_route("Form", r.message.doctype, r.message.name); - } - else{ - frappe.route_options = { - "sales_order": me.frm.doc.name - } - frappe.set_route("List", "Purchase Order"); - } - } + }, + ] } - }) + ], + primary_action_label: 'Create Purchase Order', + primary_action (args) { + if (!args) return; + + let selected_items = dialog.fields_dict.items_for_po.grid.get_selected_children(); + if(selected_items.length == 0) { + frappe.throw({message: 'Please select Items from the Table', title: __('Items Required'), indicator:'blue'}) + } + + dialog.hide(); + + var method = args.against_default_supplier ? "make_purchase_order_for_default_supplier" : "make_purchase_order" + return frappe.call({ + method: "erpnext.selling.doctype.sales_order.sales_order." + method, + freeze: true, + freeze_message: __("Creating Purchase Order ..."), + args: { + "source_name": me.frm.doc.name, + "selected_items": selected_items + }, + freeze: true, + callback: function(r) { + if(!r.exc) { + if (!args.against_default_supplier) { + frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + else { + frappe.route_options = { + "sales_order": me.frm.doc.name + } + frappe.set_route("List", "Purchase Order"); + } + } + } + }) + } }); - dialog.get_field("items_for_po").grid.only_sortable() - dialog.get_field("items_for_po").refresh() + + dialog.fields_dict["against_default_supplier"].df.onchange = () => set_po_items_data(dialog); + + function set_po_items_data (dialog) { + var against_default_supplier = dialog.get_value("against_default_supplier"); + var items_for_po = dialog.get_value("items_for_po"); + + if (against_default_supplier) { + let items_with_supplier = items_for_po.filter((item) => item.supplier) + + dialog.fields_dict["items_for_po"].df.data = items_with_supplier; + dialog.get_field("items_for_po").refresh(); + } else { + let po_items = []; + me.frm.doc.items.forEach(d => { + let pending_qty = (flt(d.stock_qty) - flt(d.ordered_qty)) / flt(d.conversion_factor); + if (pending_qty > 0) { + po_items.push({ + "doctype": "Sales Order Item", + "name": d.name, + "item_name": d.item_name, + "item_code": d.item_code, + "pending_qty": pending_qty, + "uom": d.uom, + "supplier": d.supplier + }); + } + }); + + dialog.fields_dict["items_for_po"].df.data = po_items; + dialog.get_field("items_for_po").refresh(); + } + } + + set_po_items_data(dialog); + dialog.get_field("items_for_po").grid.only_sortable(); + dialog.get_field("items_for_po").refresh(); + dialog.wrapper.find('.grid-heading-row .grid-row-check').click(); dialog.show(); }, + hold_sales_order: function(){ var me = this; var d = new frappe.ui.Dialog({ diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index a68b7387b76..0a5c6651ba3 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_auto_repeat": 1, "allow_import": 1, "autoname": "naming_series:", "creation": "2013-06-18 12:39:59", @@ -106,6 +107,8 @@ "tc_name", "terms", "more_info", + "is_internal_customer", + "represents_company", "inter_company_order_reference", "project", "party_account_currency", @@ -1102,7 +1105,8 @@ "hide_days": 1, "hide_seconds": 1, "label": "Inter Company Order Reference", - "options": "Purchase Order" + "options": "Purchase Order", + "read_only": 1 }, { "description": "Track this Sales Order against any Project", @@ -1454,13 +1458,29 @@ "hide_seconds": 1, "label": "Skip Delivery Note", "print_hide": 1 + }, + { + "default": "0", + "fetch_from": "customer.is_internal_customer", + "fieldname": "is_internal_customer", + "fieldtype": "Check", + "label": "Is Internal Customer", + "read_only": 1 + }, + { + "fetch_from": "customer.represents_company", + "fieldname": "represents_company", + "fieldtype": "Link", + "label": "Represents Company", + "options": "Company", + "read_only": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2020-07-31 14:13:17.962015", + "modified": "2021-01-20 23:40:39.929296", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", @@ -1534,7 +1554,7 @@ "sort_field": "modified", "sort_order": "DESC", "timeline_field": "customer", - "title_field": "customer", + "title_field": "customer_name", "track_changes": 1, "track_seen": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index f88289871e9..e56129170c1 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe import json import frappe.utils -from frappe.utils import cstr, flt, getdate, cint, nowdate, add_days, get_link_to_form +from frappe.utils import cstr, flt, getdate, cint, nowdate, add_days, get_link_to_form, strip_html from frappe import _ from six import string_types from frappe.model.utils import get_fetch_values @@ -14,7 +14,6 @@ from erpnext.stock.stock_balance import update_bin_qty, get_reserved_qty from frappe.desk.notifications import clear_doctype_notifications from frappe.contacts.doctype.address.address import get_company_address from erpnext.controllers.selling_controller import SellingController -from frappe.automation.doctype.auto_repeat.auto_repeat import get_next_schedule_date from erpnext.selling.doctype.customer.customer import check_credit_limit from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults @@ -159,7 +158,6 @@ class SalesOrder(SellingController): frappe.throw(_("Quotation {0} is cancelled").format(quotation)) doc.set_status(update=True) - doc.update_opportunity() def validate_drop_ship(self): for d in self.get('items'): @@ -182,6 +180,7 @@ class SalesOrder(SellingController): update_coupon_code_count(self.coupon_code,'used') def on_cancel(self): + self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry') super(SalesOrder, self).on_cancel() # Cannot cancel closed SO @@ -418,8 +417,7 @@ class SalesOrder(SellingController): 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 = get_next_schedule_date(ref_doc_delivery_date, - auto_repeat_doc.frequency, auto_repeat_doc.start_date, cint(auto_repeat_doc.repeat_on_day)) + delivery_date = auto_repeat_doc.get_next_schedule_date(schedule_date=ref_doc_delivery_date) if delivery_date <= transaction_date: delivery_date_diff = frappe.utils.date_diff(ref_doc_delivery_date, red_doc_transaction_date) @@ -443,25 +441,19 @@ class SalesOrder(SellingController): for item in self.items: if item.ensure_delivery_based_on_produced_serial_no: if item.item_code in normal_items: - frappe.throw(_("Cannot ensure delivery by Serial No as \ - Item {0} is added with and without Ensure Delivery by \ - Serial No.").format(item.item_code)) + frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code)) if item.item_code not in reserved_items: if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"): - frappe.throw(_("Item {0} has no Serial No. Only serilialized items \ - can have delivery based on Serial No").format(item.item_code)) + frappe.throw(_("Item {0} has no Serial No. Only serilialized items can have delivery based on Serial No").format(item.item_code)) if not frappe.db.exists("BOM", {"item": item.item_code, "is_active": 1}): - frappe.throw(_("No active BOM found for item {0}. Delivery by \ - Serial No cannot be ensured").format(item.item_code)) + frappe.throw(_("No active BOM found for item {0}. Delivery by Serial No cannot be ensured").format(item.item_code)) reserved_items.append(item.item_code) else: normal_items.append(item.item_code) if not item.ensure_delivery_based_on_produced_serial_no and \ item.item_code in reserved_items: - frappe.throw(_("Cannot ensure delivery by Serial No as \ - Item {0} is added with and without Ensure Delivery by \ - Serial No.").format(item.item_code)) + frappe.throw(_("Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No.").format(item.item_code)) def get_list_context(context=None): from erpnext.controllers.website_list_for_contact import get_list_context @@ -785,7 +777,9 @@ def get_events(start, end, filters=None): return data @frappe.whitelist() -def make_purchase_order(source_name, for_supplier=None, selected_items=[], target_doc=None): +def make_purchase_order_for_default_supplier(source_name, selected_items=None, target_doc=None): + if not selected_items: return + if isinstance(selected_items, string_types): selected_items = json.loads(selected_items) @@ -822,102 +816,133 @@ def make_purchase_order(source_name, for_supplier=None, selected_items=[], targe def update_item(source, target, source_parent): target.schedule_date = source.delivery_date - target.qty = flt(source.qty) - flt(source.ordered_qty) - target.stock_qty = (flt(source.qty) - flt(source.ordered_qty)) * flt(source.conversion_factor) + target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor)) + target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty)) target.project = source_parent.project - suppliers =[] - if for_supplier: - suppliers.append(for_supplier) - else: - sales_order = frappe.get_doc("Sales Order", source_name) - for item in sales_order.items: - if item.supplier and item.supplier not in suppliers: - suppliers.append(item.supplier) + suppliers = [item.get('supplier') for item in selected_items if item.get('supplier') and item.get('supplier')] + suppliers = list(set(suppliers)) + + items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')] + items_to_map = list(set(items_to_map)) if not suppliers: frappe.throw(_("Please set a Supplier against the Items to be considered in the Purchase Order.")) for supplier in suppliers: - po =frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) - if len(po) == 0: - doc = get_mapped_doc("Sales Order", source_name, { - "Sales Order": { - "doctype": "Purchase Order", - "field_no_map": [ - "address_display", - "contact_display", - "contact_mobile", - "contact_email", - "contact_person", - "taxes_and_charges" - ], - "validation": { - "docstatus": ["=", 1] - } - }, - "Sales Order Item": { - "doctype": "Purchase Order Item", - "field_map": [ - ["name", "sales_order_item"], - ["parent", "sales_order"], - ["stock_uom", "stock_uom"], - ["uom", "uom"], - ["conversion_factor", "conversion_factor"], - ["delivery_date", "schedule_date"] - ], - "field_no_map": [ - "rate", - "price_list_rate", - "item_tax_template" - ], - "postprocess": update_item, - "condition": lambda doc: doc.ordered_qty < doc.qty and doc.supplier == supplier and doc.item_code in selected_items + doc = get_mapped_doc("Sales Order", source_name, { + "Sales Order": { + "doctype": "Purchase Order", + "field_no_map": [ + "address_display", + "contact_display", + "contact_mobile", + "contact_email", + "contact_person", + "taxes_and_charges", + "shipping_address", + "terms" + ], + "validation": { + "docstatus": ["=", 1] } - }, target_doc, set_missing_values) - if not for_supplier: - doc.insert() - else: - suppliers =[] - if suppliers: - if not for_supplier: - frappe.db.commit() - return doc - else: - frappe.msgprint(_("PO already created for all sales order items")) + }, + "Sales Order Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "sales_order_item"], + ["parent", "sales_order"], + ["stock_uom", "stock_uom"], + ["uom", "uom"], + ["conversion_factor", "conversion_factor"], + ["delivery_date", "schedule_date"] + ], + "field_no_map": [ + "rate", + "price_list_rate", + "item_tax_template", + "discount_percentage", + "discount_amount", + "pricing_rules" + ], + "postprocess": update_item, + "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.supplier == supplier and doc.item_code in items_to_map + } + }, target_doc, set_missing_values) + doc.insert() + frappe.db.commit() + return doc @frappe.whitelist() -@frappe.validate_and_sanitize_search_inputs -def get_supplier(doctype, txt, searchfield, start, page_len, filters): - supp_master_name = frappe.defaults.get_user_default("supp_master_name") - if supp_master_name == "Supplier Name": - fields = ["name", "supplier_group"] - else: - fields = ["name", "supplier_name", "supplier_group"] - fields = ", ".join(fields) +def make_purchase_order(source_name, selected_items=None, target_doc=None): + if not selected_items: return - return frappe.db.sql("""select {field} from `tabSupplier` - where docstatus < 2 - and ({key} like %(txt)s - or supplier_name like %(txt)s) - and name in (select supplier from `tabSales Order Item` where parent = %(parent)s) - and name not in (select supplier from `tabPurchase Order` po inner join `tabPurchase Order Item` poi - on po.name=poi.parent where po.docstatus<2 and poi.sales_order=%(parent)s) - order by - if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), - if(locate(%(_txt)s, supplier_name), locate(%(_txt)s, supplier_name), 99999), - name, supplier_name - limit %(start)s, %(page_len)s """.format(**{ - 'field': fields, - 'key': frappe.db.escape(searchfield) - }), { - 'txt': "%%%s%%" % txt, - '_txt': txt.replace("%", ""), - 'start': start, - 'page_len': page_len, - 'parent': filters.get('parent') - }) + if isinstance(selected_items, string_types): + selected_items = json.loads(selected_items) + + items_to_map = [item.get('item_code') for item in selected_items if item.get('item_code') and item.get('item_code')] + items_to_map = list(set(items_to_map)) + + def set_missing_values(source, target): + target.supplier = "" + target.apply_discount_on = "" + target.additional_discount_percentage = 0.0 + target.discount_amount = 0.0 + target.inter_company_order_reference = "" + target.customer = "" + target.customer_name = "" + target.run_method("set_missing_values") + target.run_method("calculate_taxes_and_totals") + + def update_item(source, target, source_parent): + target.schedule_date = source.delivery_date + target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor)) + target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty)) + target.project = source_parent.project + + # po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) + doc = get_mapped_doc("Sales Order", source_name, { + "Sales Order": { + "doctype": "Purchase Order", + "field_no_map": [ + "address_display", + "contact_display", + "contact_mobile", + "contact_email", + "contact_person", + "taxes_and_charges", + "shipping_address", + "terms" + ], + "validation": { + "docstatus": ["=", 1] + } + }, + "Sales Order Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "sales_order_item"], + ["parent", "sales_order"], + ["stock_uom", "stock_uom"], + ["uom", "uom"], + ["conversion_factor", "conversion_factor"], + ["delivery_date", "schedule_date"] + ], + "field_no_map": [ + "rate", + "price_list_rate", + "item_tax_template", + "discount_percentage", + "discount_amount", + "supplier", + "pricing_rules" + ], + "postprocess": update_item, + "condition": lambda doc: doc.ordered_qty < doc.stock_qty and doc.item_code in items_to_map + } + }, target_doc, set_missing_values) + return doc @frappe.whitelist() def make_work_orders(items, sales_order, company, project=None): @@ -989,20 +1014,24 @@ def make_raw_material_request(items, company, sales_order, project=None): doctype = 'Material Request', transaction_date = nowdate(), company = company, - requested_by = frappe.session.user, material_request_type = 'Purchase' )) for item in raw_materials: item_doc = frappe.get_cached_doc('Item', item.get('item_code')) + schedule_date = add_days(nowdate(), cint(item_doc.lead_time_days)) - material_request.append('items', { - 'item_code': item.get('item_code'), - 'qty': item.get('quantity'), - 'schedule_date': schedule_date, - 'warehouse': item.get('warehouse'), - 'sales_order': sales_order, - 'project': project + row = material_request.append('items', { + 'item_code': item.get('item_code'), + 'qty': item.get('quantity'), + 'schedule_date': schedule_date, + 'warehouse': item.get('warehouse'), + 'sales_order': sales_order, + 'project': project }) + + if not (strip_html(item.get("description")) and strip_html(item_doc.description)): + row.description = item_doc.item_name or item.get('item_code') + material_request.insert() material_request.flags.ignore_permissions = 1 material_request.run_method("set_missing_values") @@ -1058,4 +1087,4 @@ def update_produced_qty_in_so_item(sales_order, sales_order_item): if not total_produced_qty and frappe.flags.in_patch: return - frappe.db.set_value('Sales Order Item', sales_order_item, 'produced_qty', total_produced_qty) \ No newline at end of file + frappe.db.set_value('Sales Order Item', sales_order_item, 'produced_qty', total_produced_qty) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 735b071f443..0fdfb1b889e 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1,11 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt from __future__ import unicode_literals -import frappe import json -from frappe.utils import flt, add_days, nowdate -import frappe.permissions import unittest +import frappe +import frappe.permissions +from frappe.utils import flt, add_days, nowdate +from frappe.core.doctype.user_permission.test_user_permission import create_user from erpnext.selling.doctype.sales_order.sales_order \ import make_material_request, make_delivery_note, make_sales_invoice, WarehouseRequired from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry @@ -17,6 +18,18 @@ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_prod from erpnext.stock.doctype.item.test_item import make_item class TestSalesOrder(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.unlink_setting = int(frappe.db.get_value("Accounts Settings", "Accounts Settings", + "unlink_advance_payment_on_cancelation_of_order")) + + @classmethod + def tearDownClass(cls) -> None: + # reset config to previous state + frappe.db.set_value("Accounts Settings", "Accounts Settings", + "unlink_advance_payment_on_cancelation_of_order", cls.unlink_setting) + def tearDown(self): frappe.set_user("Administrator") @@ -89,6 +102,8 @@ class TestSalesOrder(unittest.TestCase): self.assertEqual(len(si.get("items")), 1) si.insert() + si.set('taxes', []) + si.save() self.assertEqual(si.payment_schedule[0].payment_amount, 500.0) self.assertEqual(si.payment_schedule[0].due_date, so.transaction_date) @@ -323,6 +338,9 @@ class TestSalesOrder(unittest.TestCase): create_dn_against_so(so.name, 4) make_sales_invoice(so.name) + prev_total = so.get("base_total") + prev_total_in_words = so.get("base_in_words") + first_item_of_so = so.get("items")[0] trans_item = json.dumps([ {'item_code' : first_item_of_so.item_code, 'rate' : first_item_of_so.rate, \ @@ -338,6 +356,12 @@ class TestSalesOrder(unittest.TestCase): self.assertEqual(so.get("items")[-1].amount, 1400) self.assertEqual(so.status, 'To Deliver and Bill') + updated_total = so.get("base_total") + updated_total_in_words = so.get("base_in_words") + + self.assertEqual(updated_total, prev_total+1400) + self.assertNotEqual(updated_total_in_words, prev_total_in_words) + def test_update_child_removing_item(self): so = make_sales_order(**{ "item_list": [{ @@ -402,13 +426,27 @@ class TestSalesOrder(unittest.TestCase): trans_item = json.dumps([{'item_code' : '_Test Item', 'rate' : 200, 'qty' : 2, 'docname': so.items[0].name}]) self.assertRaises(frappe.ValidationError, update_child_qty_rate,'Sales Order', trans_item, so.name) + def test_update_child_with_precision(self): + from frappe.model.meta import get_field_precision + from frappe.custom.doctype.property_setter.property_setter import make_property_setter + + precision = get_field_precision(frappe.get_meta("Sales Order Item").get_field("rate")) + + make_property_setter("Sales Order Item", "rate", "precision", 7, "Currency") + so = make_sales_order(item_code= "_Test Item", qty=4, rate=200.34664) + + trans_item = json.dumps([{'item_code' : '_Test Item', 'rate' : 200.34669, 'qty' : 4, 'docname': so.items[0].name}]) + update_child_qty_rate('Sales Order', trans_item, so.name) + + so.reload() + self.assertEqual(so.items[0].rate, 200.34669) + make_property_setter("Sales Order Item", "rate", "precision", precision, "Currency") + def test_update_child_perm(self): so = make_sales_order(item_code= "_Test Item", qty=4) - user = 'test@example.com' - test_user = frappe.get_doc('User', user) - test_user.add_roles("Accounts User") - frappe.set_user(user) + test_user = create_user("test_so_child_perms@example.com", "Accounts User") + frappe.set_user(test_user.name) # update qty trans_item = json.dumps([{'item_code' : '_Test Item', 'rate' : 200, 'qty' : 7, 'docname': so.items[0].name}]) @@ -417,9 +455,7 @@ class TestSalesOrder(unittest.TestCase): # add new item trans_item = json.dumps([{'item_code' : '_Test Item', 'rate' : 100, 'qty' : 2}]) self.assertRaises(frappe.ValidationError, update_child_qty_rate,'Sales Order', trans_item, so.name) - test_user.remove_roles("Accounts User") - frappe.set_user("Administrator") - + def test_update_child_qty_rate_with_workflow(self): from frappe.model.workflow import apply_workflow @@ -427,7 +463,6 @@ class TestSalesOrder(unittest.TestCase): so = make_sales_order(item_code= "_Test Item", qty=1, rate=150, do_not_submit=1) apply_workflow(so, 'Approve') - frappe.set_user("Administrator") user = 'test@example.com' test_user = frappe.get_doc('User', user) test_user.add_roles("Sales User", "Test Junior Approver") @@ -488,34 +523,121 @@ class TestSalesOrder(unittest.TestCase): so.reload() self.assertEqual(so.packed_items[0].qty, 8) - def test_warehouse_user(self): - frappe.permissions.add_user_permission("Warehouse", "_Test Warehouse 1 - _TC", "test@example.com") - frappe.permissions.add_user_permission("Warehouse", "_Test Warehouse 2 - _TC1", "test2@example.com") - frappe.permissions.add_user_permission("Company", "_Test Company 1", "test2@example.com") + def test_update_child_with_tax_template(self): + """ + Test Action: Create a SO with one item having its tax account head already in the SO. + Add the same item + new item with tax template via Update Items. + Expected result: First Item's tax row is updated. New tax row is added for second Item. + """ + if not frappe.db.exists("Item", "Test Item with Tax"): + make_item("Test Item with Tax", { + 'is_stock_item': 1, + }) - test_user = frappe.get_doc("User", "test@example.com") - test_user.add_roles("Sales User", "Stock User") - test_user.remove_roles("Sales Manager") + if not frappe.db.exists("Item Tax Template", {"title": 'Test Update Items Template'}): + frappe.get_doc({ + 'doctype': 'Item Tax Template', + 'title': 'Test Update Items Template', + 'company': '_Test Company', + 'taxes': [ + { + 'tax_type': "_Test Account Service Tax - _TC", + 'tax_rate': 10, + } + ] + }).insert() + + new_item_with_tax = frappe.get_doc("Item", "Test Item with Tax") + + new_item_with_tax.append("taxes", { + "item_tax_template": "Test Update Items Template - _TC", + "valid_from": nowdate() + }) + new_item_with_tax.save() + + tax_template = "_Test Account Excise Duty @ 10 - _TC" + item = "_Test Item Home Desktop 100" + if not frappe.db.exists("Item Tax", {"parent":item, "item_tax_template":tax_template}): + item_doc = frappe.get_doc("Item", item) + item_doc.append("taxes", { + "item_tax_template": tax_template, + "valid_from": nowdate() + }) + item_doc.save() + else: + # update valid from + frappe.db.sql("""UPDATE `tabItem Tax` set valid_from = CURDATE() + where parent = %(item)s and item_tax_template = %(tax)s""", + {"item": item, "tax": tax_template}) + + so = make_sales_order(item_code=item, qty=1, do_not_save=1) + + so.append("taxes", { + "account_head": "_Test Account Excise Duty - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "Excise Duty", + "doctype": "Sales Taxes and Charges", + "rate": 10 + }) + so.insert() + so.submit() + + self.assertEqual(so.taxes[0].tax_amount, 10) + self.assertEqual(so.taxes[0].total, 110) + + old_stock_settings_value = frappe.db.get_single_value("Stock Settings", "default_warehouse") + frappe.db.set_value("Stock Settings", None, "default_warehouse", "_Test Warehouse - _TC") + + items = json.dumps([ + {'item_code' : item, 'rate' : 100, 'qty' : 1, 'docname': so.items[0].name}, + {'item_code' : item, 'rate' : 200, 'qty' : 1}, # added item whose tax account head already exists in PO + {'item_code' : new_item_with_tax.name, 'rate' : 100, 'qty' : 1} # added item whose tax account head is missing in PO + ]) + update_child_qty_rate('Sales Order', items, so.name) + + so.reload() + self.assertEqual(so.taxes[0].tax_amount, 40) + self.assertEqual(so.taxes[0].total, 440) + self.assertEqual(so.taxes[1].account_head, "_Test Account Service Tax - _TC") + self.assertEqual(so.taxes[1].tax_amount, 40) + self.assertEqual(so.taxes[1].total, 480) + + # teardown + frappe.db.sql("""UPDATE `tabItem Tax` set valid_from = NULL + where parent = %(item)s and item_tax_template = %(tax)s""", {"item": item, "tax": tax_template}) + so.cancel() + so.delete() + new_item_with_tax.delete() + frappe.get_doc("Item Tax Template", "Test Update Items Template - _TC").delete() + frappe.db.set_value("Stock Settings", None, "default_warehouse", old_stock_settings_value) + + def test_warehouse_user(self): + test_user = create_user("test_so_warehouse_user@example.com", "Sales User", "Stock User") test_user_2 = frappe.get_doc("User", "test2@example.com") test_user_2.add_roles("Sales User", "Stock User") test_user_2.remove_roles("Sales Manager") - frappe.set_user("test@example.com") + frappe.permissions.add_user_permission("Warehouse", "_Test Warehouse 1 - _TC", test_user.name) + frappe.permissions.add_user_permission("Warehouse", "_Test Warehouse 2 - _TC1", test_user_2.name) + frappe.permissions.add_user_permission("Company", "_Test Company 1", test_user_2.name) - so = make_sales_order(company="_Test Company 1", + frappe.set_user(test_user.name) + + so = make_sales_order(company="_Test Company 1", customer="_Test Customer 1", warehouse="_Test Warehouse 2 - _TC1", do_not_save=True) so.conversion_rate = 0.02 so.plc_conversion_rate = 0.02 self.assertRaises(frappe.PermissionError, so.insert) - frappe.set_user("test2@example.com") + frappe.set_user(test_user_2.name) so.insert() frappe.set_user("Administrator") - frappe.permissions.remove_user_permission("Warehouse", "_Test Warehouse 1 - _TC", "test@example.com") - frappe.permissions.remove_user_permission("Warehouse", "_Test Warehouse 2 - _TC1", "test2@example.com") - frappe.permissions.remove_user_permission("Company", "_Test Company 1", "test2@example.com") + frappe.permissions.remove_user_permission("Warehouse", "_Test Warehouse 1 - _TC", test_user.name) + frappe.permissions.remove_user_permission("Warehouse", "_Test Warehouse 2 - _TC1", test_user_2.name) + frappe.permissions.remove_user_permission("Company", "_Test Company 1", test_user_2.name) def test_block_delivery_note_against_cancelled_sales_order(self): so = make_sales_order() @@ -581,12 +703,12 @@ class TestSalesOrder(unittest.TestCase): frappe.db.set_value("Stock Settings", None, "auto_insert_price_list_rate_if_missing", 1) def test_drop_shipping(self): - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order + from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order_for_default_supplier, \ + update_status as so_update_status from erpnext.buying.doctype.purchase_order.purchase_order import update_status - make_stock_entry(target="_Test Warehouse - _TC", qty=10, rate=100) + # make items po_item = make_item("_Test Item for Drop Shipping", {"is_stock_item": 1, "delivered_by_supplier": 1}) - dn_item = make_item("_Test Regular Item", {"is_stock_item": 1}) so_items = [ @@ -608,80 +730,114 @@ class TestSalesOrder(unittest.TestCase): ] if frappe.db.get_value("Item", "_Test Regular Item", "is_stock_item")==1: - make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=10, rate=100) + make_stock_entry(item="_Test Regular Item", target="_Test Warehouse - _TC", qty=2, rate=100) - #setuo existing qty from bin - bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, - fields=["ordered_qty", "reserved_qty"]) - - existing_ordered_qty = bin[0].ordered_qty if bin else 0.0 - existing_reserved_qty = bin[0].reserved_qty if bin else 0.0 - - bin = frappe.get_all("Bin", filters={"item_code": dn_item.item_code, - "warehouse": "_Test Warehouse - _TC"}, fields=["reserved_qty"]) - - existing_reserved_qty_for_dn_item = bin[0].reserved_qty if bin else 0.0 - - #create so, po and partial dn + #create so, po and dn so = make_sales_order(item_list=so_items, do_not_submit=True) so.submit() - po = make_purchase_order(so.name, '_Test Supplier', selected_items=[so_items[0]['item_code']]) + po = make_purchase_order_for_default_supplier(so.name, selected_items=[so_items[0]]) po.submit() - dn = create_dn_against_so(so.name, delivered_qty=1) + dn = create_dn_against_so(so.name, delivered_qty=2) self.assertEqual(so.customer, po.customer) self.assertEqual(po.items[0].sales_order, so.name) self.assertEqual(po.items[0].item_code, po_item.item_code) self.assertEqual(dn.items[0].item_code, dn_item.item_code) - - #test ordered_qty and reserved_qty - bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, - fields=["ordered_qty", "reserved_qty"]) - - ordered_qty = bin[0].ordered_qty if bin else 0.0 - reserved_qty = bin[0].reserved_qty if bin else 0.0 - - self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty) - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty) - - reserved_qty = frappe.db.get_value("Bin", - {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty") - - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item + 1) - #test po_item length self.assertEqual(len(po.items), 1) - #test per_delivered status + # test ordered_qty and reserved_qty for drop ship item + bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, + fields=["ordered_qty", "reserved_qty"]) + + ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0 + reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0 + + # drop ship PO should not impact bin, test the same + self.assertEqual(abs(flt(ordered_qty)), 0) + self.assertEqual(abs(flt(reserved_qty)), 0) + + # test per_delivered status update_status("Delivered", po.name) - self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 75.00) + self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 100.00) + po.load_from_db() - #test reserved qty after complete delivery - dn = create_dn_against_so(so.name, delivered_qty=1) - reserved_qty = frappe.db.get_value("Bin", - {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty") - - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item) - - #test after closing so + # test after closing so so.db_set('status', "Closed") so.update_reserved_qty() - bin = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, + # test ordered_qty and reserved_qty for drop ship item after closing so + bin_po_item = frappe.get_all("Bin", filters={"item_code": po_item.item_code, "warehouse": "_Test Warehouse - _TC"}, fields=["ordered_qty", "reserved_qty"]) - ordered_qty = bin[0].ordered_qty if bin else 0.0 - reserved_qty = bin[0].reserved_qty if bin else 0.0 + ordered_qty = bin_po_item[0].ordered_qty if bin_po_item else 0.0 + reserved_qty = bin_po_item[0].reserved_qty if bin_po_item else 0.0 - self.assertEqual(abs(flt(ordered_qty)), existing_ordered_qty) - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty) + self.assertEqual(abs(flt(ordered_qty)), 0) + self.assertEqual(abs(flt(reserved_qty)), 0) - reserved_qty = frappe.db.get_value("Bin", - {"item_code": dn_item.item_code, "warehouse": "_Test Warehouse - _TC"}, "reserved_qty") + # teardown + so_update_status("Draft", so.name) + dn.load_from_db() + dn.cancel() + po.cancel() + so.load_from_db() + so.cancel() - self.assertEqual(abs(flt(reserved_qty)), existing_reserved_qty_for_dn_item) + def test_drop_shipping_partial_order(self): + from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order_for_default_supplier, \ + update_status as so_update_status + + # make items + po_item1 = make_item("_Test Item for Drop Shipping 1", {"is_stock_item": 1, "delivered_by_supplier": 1}) + po_item2 = make_item("_Test Item for Drop Shipping 2", {"is_stock_item": 1, "delivered_by_supplier": 1}) + + so_items = [ + { + "item_code": po_item1.item_code, + "warehouse": "", + "qty": 2, + "rate": 400, + "delivered_by_supplier": 1, + "supplier": '_Test Supplier' + }, + { + "item_code": po_item2.item_code, + "warehouse": "", + "qty": 2, + "rate": 400, + "delivered_by_supplier": 1, + "supplier": '_Test Supplier' + } + ] + + # create so and po + so = make_sales_order(item_list=so_items, do_not_submit=True) + so.submit() + + # create po for only one item + po1 = make_purchase_order_for_default_supplier(so.name, selected_items=[so_items[0]]) + po1.submit() + + self.assertEqual(so.customer, po1.customer) + self.assertEqual(po1.items[0].sales_order, so.name) + self.assertEqual(po1.items[0].item_code, po_item1.item_code) + #test po item length + self.assertEqual(len(po1.items), 1) + + # create po for remaining item + po2 = make_purchase_order_for_default_supplier(so.name, selected_items=[so_items[1]]) + po2.submit() + + # teardown + so_update_status("Draft", so.name) + + po1.cancel() + po2.cancel() + so.load_from_db() + so.cancel() def test_reserved_qty_for_closing_so(self): bin = frappe.get_all("Bin", filters={"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"}, @@ -898,6 +1054,38 @@ class TestSalesOrder(unittest.TestCase): self.assertRaises(frappe.LinkExistsError, so_doc.cancel) + def test_cancel_sales_order_after_cancel_payment_entry(self): + from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry + # make a sales order + so = make_sales_order() + + # disable unlinking of payment entry + frappe.db.set_value("Accounts Settings", "Accounts Settings", + "unlink_advance_payment_on_cancelation_of_order", 0) + + # create a payment entry against sales order + pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Bank - _TC") + pe.reference_no = "1" + pe.reference_date = nowdate() + pe.paid_from_account_currency = so.currency + pe.paid_to_account_currency = so.currency + pe.source_exchange_rate = 1 + pe.target_exchange_rate = 1 + pe.paid_amount = so.grand_total + pe.save(ignore_permissions=True) + pe.submit() + + # Cancel payment entry + po_doc = frappe.get_doc("Payment Entry", pe.name) + po_doc.cancel() + + # Cancel sales order + try: + so_doc = frappe.get_doc('Sales Order', so.name) + so_doc.cancel() + except Exception: + self.fail("Can not cancel sales order with linked cancelled payment entry") + def test_request_for_raw_materials(self): item = make_item("_Test Finished Item", {"is_stock_item": 1, "maintain_stock": 1, @@ -975,6 +1163,7 @@ def make_sales_order(**args): so.company = args.company or "_Test Company" so.customer = args.customer or "_Test Customer" so.currency = args.currency or "INR" + so.po_no = args.po_no or '12345' if args.selling_price_list: so.selling_price_list = args.selling_price_list @@ -1055,4 +1244,4 @@ def make_sales_order_workflow(): )) workflow.insert(ignore_permissions=True) - return workflow \ No newline at end of file + return workflow 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 eff17f8bc78..1e5590e7489 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -46,6 +46,7 @@ "base_rate", "base_amount", "pricing_rules", + "stock_uom_rate", "is_free_item", "section_break_24", "net_rate", @@ -214,7 +215,6 @@ "fieldtype": "Link", "label": "UOM", "options": "UOM", - "print_hide": 0, "reqd": 1 }, { @@ -780,12 +780,21 @@ "fieldname": "manufacturing_section_section", "fieldtype": "Section Break", "label": "Manufacturing Section" + }, + { + "depends_on": "eval: doc.uom != doc.stock_uom", + "fieldname": "stock_uom_rate", + "fieldtype": "Currency", + "label": "Rate of Stock UOM", + "no_copy": 1, + "options": "currency", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-05-29 20:54:32.309460", + "modified": "2021-02-23 01:15:05.803091", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.py b/erpnext/selling/doctype/sales_order_item/sales_order_item.py index 4a87a0c28af..27f303d43b1 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -5,11 +5,9 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document -from erpnext.controllers.print_settings import print_settings_for_item_table class SalesOrderItem(Document): - def __setup__(self): - print_settings_for_item_table(self) + pass def on_doctype_update(): frappe.db.add_index("Sales Order Item", ["item_code", "warehouse"]) \ 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 dcbc0748f7d..2104c0131c4 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -63,7 +63,7 @@ }, { "default": "15", - "description": "Auto close Opportunity after 15 days", + "description": "Auto close Opportunity after the no. of days mentioned above", "fieldname": "close_opportunity_after_days", "fieldtype": "Int", "label": "Close Opportunity After Days" @@ -80,18 +80,18 @@ { "fieldname": "so_required", "fieldtype": "Select", - "label": "Sales Order Required for Sales Invoice & Delivery Note Creation", + "label": "Is Sales Order Required for Sales Invoice & Delivery Note Creation?", "options": "No\nYes" }, { "fieldname": "dn_required", "fieldtype": "Select", - "label": "Delivery Note Required for Sales Invoice Creation", + "label": "Is Delivery Note Required for Sales Invoice Creation?", "options": "No\nYes" }, { "default": "Each Transaction", - "description": "How often should project and company be updated based on Sales Transactions.", + "description": "How often should Project and Company be updated based on Sales Transactions?", "fieldname": "sales_update_frequency", "fieldtype": "Select", "label": "Sales Update Frequency", @@ -108,38 +108,39 @@ "default": "0", "fieldname": "editable_price_list_rate", "fieldtype": "Check", - "label": "Allow user to edit Price List Rate in transactions" + "label": "Allow User to Edit Price List Rate in Transactions" }, { "default": "0", "fieldname": "allow_multiple_items", "fieldtype": "Check", - "label": "Allow Item to be added multiple times in a transaction" + "label": "Allow Item to Be Added Multiple Times in a Transaction" }, { "default": "0", "fieldname": "allow_against_multiple_purchase_orders", "fieldtype": "Check", - "label": "Allow multiple Sales Orders against a Customer's Purchase Order" + "label": "Allow Multiple Sales Orders Against a Customer's Purchase Order" }, { "default": "0", "fieldname": "validate_selling_price", "fieldtype": "Check", - "label": "Validate Selling Price for Item against Purchase Rate or Valuation Rate" + "label": "Validate Selling Price for Item Against Purchase Rate or Valuation Rate" }, { "default": "0", "fieldname": "hide_tax_id", "fieldtype": "Check", - "label": "Hide Customer's Tax Id from Sales Transactions" + "label": "Hide Customer's Tax ID from Sales Transactions" } ], "icon": "fa fa-cog", "idx": 1, + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-06-01 13:58:35.637858", + "modified": "2021-03-02 17:35:53.603607", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", @@ -156,5 +157,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/onscan.js b/erpnext/selling/page/point_of_sale/onscan.js deleted file mode 100644 index 428dc75cf82..00000000000 --- a/erpnext/selling/page/point_of_sale/onscan.js +++ /dev/null @@ -1 +0,0 @@ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t()):e.onScan=t()}(this,function(){var d={attachTo:function(e,t){if(void 0!==e.scannerDetectionData)throw new Error("onScan.js is already initialized for DOM element "+e);var n={onScan:function(e,t){},onScanError:function(e){},onKeyProcess:function(e,t){},onKeyDetect:function(e,t){},onPaste:function(e,t){},keyCodeMapper:function(e){return d.decodeKeyEvent(e)},onScanButtonLongPress:function(){},scanButtonKeyCode:!1,scanButtonLongPressTime:500,timeBeforeScanTest:100,avgTimeByChar:30,minLength:6,suffixKeyCodes:[9,13],prefixKeyCodes:[],ignoreIfFocusOn:!1,stopPropagation:!1,preventDefault:!1,captureEvents:!1,reactToKeydown:!0,reactToPaste:!1,singleScanQty:1};return t=this._mergeOptions(n,t),e.scannerDetectionData={options:t,vars:{firstCharTime:0,lastCharTime:0,accumulatedString:"",testTimer:!1,longPressTimeStart:0,longPressed:!1}},!0===t.reactToPaste&&e.addEventListener("paste",this._handlePaste,t.captureEvents),!1!==t.scanButtonKeyCode&&e.addEventListener("keyup",this._handleKeyUp,t.captureEvents),!0!==t.reactToKeydown&&!1===t.scanButtonKeyCode||e.addEventListener("keydown",this._handleKeyDown,t.captureEvents),this},detachFrom:function(e){e.scannerDetectionData.options.reactToPaste&&e.removeEventListener("paste",this._handlePaste),!1!==e.scannerDetectionData.options.scanButtonKeyCode&&e.removeEventListener("keyup",this._handleKeyUp),e.removeEventListener("keydown",this._handleKeyDown),e.scannerDetectionData=void 0},getOptions:function(e){return e.scannerDetectionData.options},setOptions:function(e,t){switch(e.scannerDetectionData.options.reactToPaste){case!0:!1===t.reactToPaste&&e.removeEventListener("paste",this._handlePaste);break;case!1:!0===t.reactToPaste&&e.addEventListener("paste",this._handlePaste)}switch(e.scannerDetectionData.options.scanButtonKeyCode){case!1:!1!==t.scanButtonKeyCode&&e.addEventListener("keyup",this._handleKeyUp);break;default:!1===t.scanButtonKeyCode&&e.removeEventListener("keyup",this._handleKeyUp)}return e.scannerDetectionData.options=this._mergeOptions(e.scannerDetectionData.options,t),this._reinitialize(e),this},decodeKeyEvent:function(e){var t=this._getNormalizedKeyNum(e);switch(!0){case 48<=t&&t<=90:case 106<=t&&t<=111:if(void 0!==e.key&&""!==e.key)return e.key;var n=String.fromCharCode(t);switch(e.shiftKey){case!1:n=n.toLowerCase();break;case!0:n=n.toUpperCase()}return n;case 96<=t&&t<=105:return t-96}return""},simulate:function(e,t){return this._reinitialize(e),Array.isArray(t)?t.forEach(function(e){var t={};"object"!=typeof e&&"function"!=typeof e||null===e?t.keyCode=parseInt(e):t=e;var n=new KeyboardEvent("keydown",t);document.dispatchEvent(n)}):this._validateScanCode(e,t),this},_reinitialize:function(e){var t=e.scannerDetectionData.vars;t.firstCharTime=0,t.lastCharTime=0,t.accumulatedString=""},_isFocusOnIgnoredElement:function(e){var t=e.scannerDetectionData.options.ignoreIfFocusOn;if(!t)return!1;var n=document.activeElement;if(Array.isArray(t)){for(var a=0;at.length*i.avgTimeByChar:c={message:"Receieved code was not entered in time"};break;default:return i.onScan.call(e,t,o),n=new CustomEvent("scan",{detail:{scanCode:t,qty:o}}),e.dispatchEvent(n),d._reinitialize(e),!0}return c.scanCode=t,c.scanDuration=s-r,c.avgTimeByChar=i.avgTimeByChar,c.minLength=i.minLength,i.onScanError.call(e,c),n=new CustomEvent("scanError",{detail:c}),e.dispatchEvent(n),d._reinitialize(e),!1},_mergeOptions:function(e,t){var n,a={};for(n in e)Object.prototype.hasOwnProperty.call(e,n)&&(a[n]=e[n]);for(n in t)Object.prototype.hasOwnProperty.call(t,n)&&(a[n]=t[n]);return a},_getNormalizedKeyNum:function(e){return e.which||e.keyCode},_handleKeyDown:function(e){var t=d._getNormalizedKeyNum(e),n=this.scannerDetectionData.options,a=this.scannerDetectionData.vars,i=!1;if(!1!==n.onKeyDetect.call(this,t,e)&&!d._isFocusOnIgnoredElement(this))if(!1===n.scanButtonKeyCode||t!=n.scanButtonKeyCode){switch(!0){case a.firstCharTime&&-1!==n.suffixKeyCodes.indexOf(t):e.preventDefault(),e.stopImmediatePropagation(),i=!0;break;case!a.firstCharTime&&-1!==n.prefixKeyCodes.indexOf(t):e.preventDefault(),e.stopImmediatePropagation(),i=!1;break;default:var o=n.keyCodeMapper.call(this,e);if(null===o)return;a.accumulatedString+=o,n.preventDefault&&e.preventDefault(),n.stopPropagation&&e.stopImmediatePropagation(),i=!1}a.firstCharTime||(a.firstCharTime=Date.now()),a.lastCharTime=Date.now(),a.testTimer&&clearTimeout(a.testTimer),i?(d._validateScanCode(this,a.accumulatedString),a.testTimer=!1):a.testTimer=setTimeout(d._validateScanCode,n.timeBeforeScanTest,this,a.accumulatedString),n.onKeyProcess.call(this,o,e)}else a.longPressed||(a.longPressTimer=setTimeout(n.onScanButtonLongPress,n.scanButtonLongPressTime,this),a.longPressed=!0)},_handlePaste:function(e){if(!d._isFocusOnIgnoredElement(this)){e.preventDefault(),oOptions.stopPropagation&&e.stopImmediatePropagation();var t=(event.clipboardData||window.clipboardData).getData("text");this.scannerDetectionData.options.onPaste.call(this,t,event);var n=this.scannerDetectionData.vars;n.firstCharTime=0,n.lastCharTime=0,d._validateScanCode(this,t)}},_handleKeyUp:function(e){d._isFocusOnIgnoredElement(this)||d._getNormalizedKeyNum(e)==this.scannerDetectionData.options.scanButtonKeyCode&&(clearTimeout(this.scannerDetectionData.vars.longPressTimer),this.scannerDetectionData.vars.longPressed=!1)},isScanInProgressFor:function(e){return 0= {lft} AND rgt <= {rgt}) - AND {condition} + item.disabled = 0 + AND item.is_stock_item = 1 + AND item.has_variants = 0 + AND item.is_sales_item = 1 + AND item.is_fixed_asset = 0 + AND item.item_group in (SELECT name FROM `tabItem Group` WHERE lft >= {lft} AND rgt <= {rgt}) + AND {condition} + {bin_join_condition} ORDER BY - name asc + item.name asc LIMIT {start}, {page_length}""" .format( @@ -66,8 +78,10 @@ def get_items(start, page_length, price_list, item_group, search_value="", pos_p page_length=page_length, lft=lft, rgt=rgt, - condition=condition - ), as_dict=1) + condition=condition, + bin_join_selection=bin_join_selection, + bin_join_condition=bin_join_condition + ), {'warehouse': warehouse}, as_dict=1) if items_data: items = [d.item_code for d in items_data] @@ -82,46 +96,24 @@ def get_items(start, page_length, price_list, item_group, search_value="", pos_p for item in items_data: item_code = item.item_code item_price = item_prices.get(item_code) or {} - item_stock_qty = get_stock_availability(item_code, warehouse) - - if not item_stock_qty: - pass + if allow_negative_stock: + item_stock_qty = frappe.db.sql("""select ifnull(sum(actual_qty), 0) from `tabBin` where item_code = %s""", item_code)[0][0] else: - row = {} - row.update(item) - row.update({ - 'price_list_rate': item_price.get('price_list_rate'), - 'currency': item_price.get('currency'), - 'actual_qty': item_stock_qty, - }) - result.append(row) + item_stock_qty = get_stock_availability(item_code, warehouse) + + row = {} + row.update(item) + row.update({ + 'price_list_rate': item_price.get('price_list_rate'), + 'currency': item_price.get('currency'), + 'actual_qty': item_stock_qty, + }) + result.append(row) res = { 'items': result } - if len(res['items']) == 1: - res['items'][0].setdefault('serial_no', serial_no) - res['items'][0].setdefault('batch_no', batch_no) - res['items'][0].setdefault('barcode', barcode) - - return res - - if serial_no: - res.update({ - 'serial_no': serial_no - }) - - if batch_no: - res.update({ - 'batch_no': batch_no - }) - - if barcode: - res.update({ - 'barcode': barcode - }) - return res @frappe.whitelist() @@ -145,16 +137,16 @@ def search_serial_or_batch_or_barcode_number(search_value): def get_conditions(item_code, serial_no, batch_no, barcode): if serial_no or batch_no or barcode: - return "name = {0}".format(frappe.db.escape(item_code)) + return "item.name = {0}".format(frappe.db.escape(item_code)) - return """(name like {item_code} - or item_name like {item_code})""".format(item_code = frappe.db.escape('%' + item_code + '%')) + return """(item.name like {item_code} + or item.item_name like {item_code})""".format(item_code = frappe.db.escape('%' + item_code + '%')) def get_item_group_condition(pos_profile): cond = "and 1=1" item_groups = get_item_groups(pos_profile) if item_groups: - cond = "and item_group in (%s)"%(', '.join(['%s']*len(item_groups))) + cond = "and item.item_group in (%s)"%(', '.join(['%s']*len(item_groups))) return cond % tuple(item_groups) @@ -238,13 +230,31 @@ def set_customer_info(fieldname, customer, value=""): frappe.db.set_value('Customer', customer, 'loyalty_program', value) contact = frappe.get_cached_value('Customer', customer, 'customer_primary_contact') + if not contact: + contact = frappe.db.sql(""" + SELECT parent FROM `tabDynamic Link` + WHERE + parenttype = 'Contact' AND + parentfield = 'links' AND + link_doctype = 'Customer' AND + link_name = %s + """, (customer), as_dict=1) + contact = contact[0].get('parent') if contact else None - if contact: - contact_doc = frappe.get_doc('Contact', contact) - if fieldname == 'email_id': - contact_doc.set('email_ids', [{ 'email_id': value, 'is_primary': 1}]) - frappe.db.set_value('Customer', customer, 'email_id', value) - elif fieldname == 'mobile_no': - contact_doc.set('phone_nos', [{ 'phone': value, 'is_primary_mobile_no': 1}]) - frappe.db.set_value('Customer', customer, 'mobile_no', value) - contact_doc.save() \ No newline at end of file + if not contact: + new_contact = frappe.new_doc('Contact') + new_contact.is_primary_contact = 1 + new_contact.first_name = customer + new_contact.set('links', [{'link_doctype': 'Customer', 'link_name': customer}]) + new_contact.save() + contact = new_contact.name + frappe.db.set_value('Customer', customer, 'customer_primary_contact', contact) + + contact_doc = frappe.get_doc('Contact', contact) + if fieldname == 'email_id': + contact_doc.set('email_ids', [{ 'email_id': value, 'is_primary': 1}]) + frappe.db.set_value('Customer', customer, 'email_id', value) + elif fieldname == 'mobile_no': + contact_doc.set('phone_nos', [{ 'phone': value, 'is_primary_mobile_no': 1}]) + frappe.db.set_value('Customer', customer, 'mobile_no', value) + contact_doc.save() \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 5018254b0ac..9e3c9a5656a 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -1,46 +1,68 @@ -{% include "erpnext/selling/page/point_of_sale/onscan.js" %} -{% include "erpnext/selling/page/point_of_sale/pos_item_selector.js" %} -{% include "erpnext/selling/page/point_of_sale/pos_item_cart.js" %} -{% include "erpnext/selling/page/point_of_sale/pos_item_details.js" %} -{% include "erpnext/selling/page/point_of_sale/pos_payment.js" %} -{% include "erpnext/selling/page/point_of_sale/pos_number_pad.js" %} -{% include "erpnext/selling/page/point_of_sale/pos_past_order_list.js" %} -{% include "erpnext/selling/page/point_of_sale/pos_past_order_summary.js" %} - erpnext.PointOfSale.Controller = class { constructor(wrapper) { this.wrapper = $(wrapper).find('.layout-main-section'); this.page = wrapper.page; - this.load_assets(); + this.check_opening_entry(); } - load_assets() { - // after loading assets first check if opening entry has been made - frappe.require(['assets/erpnext/css/pos.css'], this.check_opening_entry.bind(this)); + fetch_opening_entry() { + return frappe.call("erpnext.selling.page.point_of_sale.point_of_sale.check_opening_entry", { "user": frappe.session.user }); } check_opening_entry() { - return frappe.call("erpnext.selling.page.point_of_sale.point_of_sale.check_opening_entry", { "user": frappe.session.user }) - .then((r) => { - if (r.message.length) { - // assuming only one opening voucher is available for the current user - this.prepare_app_defaults(r.message[0]); - } else { - this.create_opening_voucher(); - } - }); + this.fetch_opening_entry().then((r) => { + if (r.message.length) { + // assuming only one opening voucher is available for the current user + this.prepare_app_defaults(r.message[0]); + } else { + this.create_opening_voucher(); + } + }); } create_opening_voucher() { + const me = this; const table_fields = [ - { fieldname: "mode_of_payment", fieldtype: "Link", in_list_view: 1, label: "Mode of Payment", options: "Mode of Payment", reqd: 1 }, - { fieldname: "opening_amount", fieldtype: "Currency", default: 0, in_list_view: 1, label: "Opening Amount", - options: "company:company_currency" } + { + fieldname: "mode_of_payment", fieldtype: "Link", + in_list_view: 1, label: "Mode of Payment", + options: "Mode of Payment", reqd: 1 + }, + { + fieldname: "opening_amount", fieldtype: "Currency", + in_list_view: 1, label: "Opening Amount", + options: "company:company_currency", + change: function () { + dialog.fields_dict.balance_details.df.data.some(d => { + if (d.idx == this.doc.idx) { + d.opening_amount = this.value; + dialog.fields_dict.balance_details.grid.refresh(); + return true; + } + }); + } + } ]; - + const fetch_pos_payment_methods = () => { + const pos_profile = dialog.fields_dict.pos_profile.get_value(); + if (!pos_profile) return; + frappe.db.get_doc("POS Profile", pos_profile).then(({ payments }) => { + dialog.fields_dict.balance_details.df.data = []; + payments.forEach(pay => { + const { mode_of_payment } = pay; + dialog.fields_dict.balance_details.df.data.push({ mode_of_payment, opening_amount: '0' }); + }); + dialog.fields_dict.balance_details.grid.refresh(); + }); + } + const pos_profile_query = { + query: 'erpnext.accounts.doctype.pos_profile.pos_profile.pos_profile_query', + filters: { company: frappe.defaults.get_default('company') } + } const dialog = new frappe.ui.Dialog({ title: __('Create POS Opening Entry'), + static: true, fields: [ { fieldtype: 'Link', label: __('Company'), default: frappe.defaults.get_default('company'), @@ -49,20 +71,8 @@ erpnext.PointOfSale.Controller = class { { fieldtype: 'Link', label: __('POS Profile'), options: 'POS Profile', fieldname: 'pos_profile', reqd: 1, - onchange: () => { - const pos_profile = dialog.fields_dict.pos_profile.get_value(); - - if (!pos_profile) return; - - frappe.db.get_doc("POS Profile", pos_profile).then(doc => { - dialog.fields_dict.balance_details.df.data = []; - doc.payments.forEach(pay => { - const { mode_of_payment } = pay; - dialog.fields_dict.balance_details.df.data.push({ mode_of_payment }); - }); - dialog.fields_dict.balance_details.grid.refresh(); - }); - } + get_query: () => pos_profile_query, + onchange: () => fetch_pos_payment_methods() }, { fieldname: "balance_details", @@ -75,49 +85,45 @@ erpnext.PointOfSale.Controller = class { fields: table_fields } ], - primary_action: ({ company, pos_profile, balance_details }) => { + primary_action: async function({ company, pos_profile, balance_details }) { if (!balance_details.length) { frappe.show_alert({ message: __("Please add Mode of payments and opening balance details."), indicator: 'red' }) - frappe.utils.play_sound("error"); - return; + return frappe.utils.play_sound("error"); } - frappe.dom.freeze(); - return frappe.call("erpnext.selling.page.point_of_sale.point_of_sale.create_opening_voucher", - { pos_profile, company, balance_details }) - .then((r) => { - frappe.dom.unfreeze(); - dialog.hide(); - if (r.message) { - this.prepare_app_defaults(r.message); - } - }) + + // filter balance details for empty rows + balance_details = balance_details.filter(d => d.mode_of_payment); + + const method = "erpnext.selling.page.point_of_sale.point_of_sale.create_opening_voucher"; + const res = await frappe.call({ method, args: { pos_profile, company, balance_details }, freeze:true }); + !res.exc && me.prepare_app_defaults(res.message); + dialog.hide(); }, primary_action_label: __('Submit') }); dialog.show(); } - prepare_app_defaults(data) { + async prepare_app_defaults(data) { this.pos_opening = data.name; this.company = data.company; this.pos_profile = data.pos_profile; this.pos_opening_time = data.period_start_date; + this.item_stock_map = {}; + this.settings = {}; frappe.db.get_value('Stock Settings', undefined, 'allow_negative_stock').then(({ message }) => { this.allow_negative_stock = flt(message.allow_negative_stock) || false; }); frappe.db.get_doc("POS Profile", this.pos_profile).then((profile) => { - this.customer_groups = profile.customer_groups.map(group => group.customer_group); - this.cart.make_customer_selector(); + Object.assign(this.settings, profile); + this.settings.customer_groups = profile.customer_groups.map(group => group.customer_group); + this.make_app(); }); - - this.item_stock_map = {}; - - this.make_app(); } set_opening_entry_status() { @@ -130,26 +136,18 @@ erpnext.PointOfSale.Controller = class { } make_app() { - return frappe.run_serially([ - () => frappe.dom.freeze(), - () => { - this.set_opening_entry_status(); - this.prepare_dom(); - this.prepare_components(); - this.prepare_menu(); - }, - () => this.make_new_invoice(), - () => frappe.dom.unfreeze(), - () => this.page.set_title(__('Point of Sale')), - ]); + this.prepare_dom(); + this.prepare_components(); + this.prepare_menu(); + this.make_new_invoice(); } prepare_dom() { - this.wrapper.append(` -
    ` + this.wrapper.append( + `
    ` ); - this.$components_wrapper = this.wrapper.find('.app'); + this.$components_wrapper = this.wrapper.find('.point-of-sale-app'); } prepare_components() { @@ -162,26 +160,25 @@ erpnext.PointOfSale.Controller = class { } prepare_menu() { - var me = this; this.page.clear_menu(); - this.page.add_menu_item(__("Form View"), function () { - frappe.model.sync(me.frm.doc); - frappe.set_route("Form", me.frm.doc.doctype, me.frm.doc.name); - }); + this.page.add_menu_item(__("Open Form View"), this.open_form_view.bind(this), false, 'Ctrl+F'); - this.page.add_menu_item(__("Toggle Recent Orders"), () => { - const show = this.recent_order_list.$component.hasClass('d-none'); - this.toggle_recent_order_list(show); - }); + this.page.add_menu_item(__("Toggle Recent Orders"), this.toggle_recent_order.bind(this), false, 'Ctrl+O'); - this.page.add_menu_item(__("Save as Draft"), this.save_draft_invoice.bind(this)); + this.page.add_menu_item(__("Save as Draft"), this.save_draft_invoice.bind(this), false, 'Ctrl+S'); - frappe.ui.keys.on("ctrl+s", this.save_draft_invoice.bind(this)); + this.page.add_menu_item(__('Close the POS'), this.close_pos.bind(this), false, 'Shift+Ctrl+C'); + } - this.page.add_menu_item(__('Close the POS'), this.close_pos.bind(this)); + open_form_view() { + frappe.model.sync(this.frm.doc); + frappe.set_route("Form", this.frm.doc.doctype, this.frm.doc.name); + } - frappe.ui.keys.on("shift+ctrl+s", this.close_pos.bind(this)); + toggle_recent_order() { + const show = this.recent_order_list.$component.is(':hidden'); + this.toggle_recent_order_list(show); } save_draft_invoice() { @@ -189,7 +186,7 @@ erpnext.PointOfSale.Controller = class { if (this.frm.doc.items.length == 0) { frappe.show_alert({ - message:__("You must add atleast one item to save it as draft."), + message: __("You must add atleast one item to save it as draft."), indicator:'red' }); frappe.utils.play_sound("error"); @@ -198,8 +195,8 @@ erpnext.PointOfSale.Controller = class { this.frm.save(undefined, undefined, undefined, () => { frappe.show_alert({ - message:__("There was an error saving the document."), - indicator:'red' + message: __("There was an error saving the document."), + indicator: 'red' }); frappe.utils.play_sound("error"); }).then(() => { @@ -208,7 +205,7 @@ erpnext.PointOfSale.Controller = class { () => this.make_new_invoice(), () => frappe.dom.unfreeze(), ]); - }) + }); } close_pos() { @@ -228,12 +225,11 @@ erpnext.PointOfSale.Controller = class { this.item_selector = new erpnext.PointOfSale.ItemSelector({ wrapper: this.$components_wrapper, pos_profile: this.pos_profile, + settings: this.settings, events: { item_selected: args => this.on_cart_update(args), - get_frm: () => this.frm || {}, - - get_allowed_item_group: () => this.item_groups + get_frm: () => this.frm || {} } }) } @@ -241,15 +237,14 @@ erpnext.PointOfSale.Controller = class { init_item_cart() { this.cart = new erpnext.PointOfSale.ItemCart({ wrapper: this.$components_wrapper, + settings: this.settings, events: { get_frm: () => this.frm, cart_item_clicked: (item_code, batch_no, uom) => { - const item_row = this.frm.doc.items.find( - i => i.item_code === item_code - && i.uom === uom - && (!batch_no || (batch_no && i.batch_no === batch_no)) - ); + const search_field = batch_no ? 'batch_no' : 'item_code'; + const search_value = batch_no || item_code; + const item_row = this.frm.doc.items.find(i => i[search_field] === search_value && i.uom === uom); this.item_details.toggle_item_details_section(item_row); }, @@ -263,9 +258,7 @@ erpnext.PointOfSale.Controller = class { this.customer_details = details; // will add/remove LP payment method this.payment.render_loyalty_points_payment_mode(); - }, - - get_allowed_customer_group: () => this.customer_groups + } } }) } @@ -273,6 +266,7 @@ erpnext.PointOfSale.Controller = class { init_item_details() { this.item_details = new erpnext.PointOfSale.ItemDetails({ wrapper: this.$components_wrapper, + settings: this.settings, events: { get_frm: () => this.frm, @@ -346,23 +340,22 @@ erpnext.PointOfSale.Controller = class { toggle_other_sections: (show) => { if (show) { - this.item_details.$component.hasClass('d-none') ? '' : this.item_details.$component.addClass('d-none'); - this.item_selector.$component.addClass('d-none'); + this.item_details.$component.is(':visible') ? this.item_details.$component.css('display', 'none') : ''; + this.item_selector.$component.css('display', 'none'); } else { - this.item_selector.$component.removeClass('d-none'); + this.item_selector.$component.css('display', 'flex'); } }, submit_invoice: () => { this.frm.savesubmit() .then((r) => { - // this.set_invoice_status(); this.toggle_components(false); this.order_summary.toggle_component(true); this.order_summary.load_summary_of(this.frm.doc, true); frappe.show_alert({ indicator: 'green', - message: __(`POS invoice ${r.doc.name} created succesfully`) + message: __('POS invoice {0} created succesfully', [r.doc.name]) }); }); } @@ -379,7 +372,7 @@ erpnext.PointOfSale.Controller = class { this.order_summary.load_summary_of(doc); }); }, - reset_summary: () => this.order_summary.show_summary_placeholder() + reset_summary: () => this.order_summary.toggle_summary_placeholder(true) } }) } @@ -404,10 +397,16 @@ erpnext.PointOfSale.Controller = class { this.recent_order_list.toggle_component(false); frappe.run_serially([ () => this.frm.refresh(name), + () => this.frm.call('reset_mode_of_payments'), () => this.cart.load_invoice(), () => this.item_selector.toggle_component(true) ]); }, + delete_order: (name) => { + frappe.model.delete_doc(this.frm.doc.doctype, name, () => { + this.recent_order_list.refresh_list(); + }); + }, new_order: () => { frappe.run_serially([ () => frappe.dom.freeze(), @@ -420,8 +419,6 @@ erpnext.PointOfSale.Controller = class { }) } - - toggle_recent_order_list(show) { this.toggle_components(!show); this.recent_order_list.toggle_component(show); @@ -438,10 +435,12 @@ erpnext.PointOfSale.Controller = class { make_new_invoice() { return frappe.run_serially([ + () => frappe.dom.freeze(), () => this.make_sales_invoice_frm(), () => this.set_pos_profile_data(), () => this.set_pos_profile_status(), () => this.cart.load_invoice(), + () => frappe.dom.unfreeze() ]); } @@ -495,53 +494,20 @@ erpnext.PointOfSale.Controller = class { if (this.pos_profile && !this.frm.doc.pos_profile) this.frm.doc.pos_profile = this.pos_profile; if (!this.frm.doc.company) return; - return new Promise(resolve => { - return this.frm.call({ - doc: this.frm.doc, - method: "set_missing_values", - }).then((r) => { - if(!r.exc) { - if (!this.frm.doc.pos_profile) { - frappe.dom.unfreeze(); - this.raise_exception_for_pos_profile(); - } - this.frm.trigger("update_stock"); - this.frm.trigger('calculate_taxes_and_totals'); - if(this.frm.doc.taxes_and_charges) this.frm.script_manager.trigger("taxes_and_charges"); - frappe.model.set_default_values(this.frm.doc); - if (r.message) { - this.frm.pos_print_format = r.message.print_format || ""; - this.frm.meta.default_print_format = r.message.print_format || ""; - this.frm.allow_edit_rate = r.message.allow_edit_rate; - this.frm.allow_edit_discount = r.message.allow_edit_discount; - this.frm.doc.campaign = r.message.campaign; - } - } - resolve(); - }); - }); - } - - raise_exception_for_pos_profile() { - setTimeout(() => frappe.set_route('List', 'POS Profile'), 2000); - frappe.throw(__("POS Profile is required to use Point-of-Sale")); - } - - set_invoice_status() { - const [status, indicator] = frappe.listview_settings["POS Invoice"].get_indicator(this.frm.doc); - this.page.set_indicator(__(`${status}`), indicator); + return this.frm.trigger("set_pos_data"); } set_pos_profile_status() { - this.page.set_indicator(__(`${this.pos_profile}`), "blue"); + this.page.set_indicator(this.pos_profile, "blue"); } async on_cart_update(args) { frappe.dom.freeze(); + let item_row = undefined; try { let { field, value, item } = args; const { item_code, batch_no, serial_no, uom } = item; - let item_row = this.get_item_from_frm(item_code, batch_no, uom); + item_row = this.get_item_from_frm(item_code, batch_no, uom); const item_selected_from_selector = field === 'qty' && value === "+1" @@ -550,9 +516,11 @@ erpnext.PointOfSale.Controller = class { field === 'qty' && (value = flt(value)); - if (field === 'qty' && value > 0 && !this.allow_negative_stock) - await this.check_stock_availability(item_row, value, this.frm.doc.set_warehouse); - + if (['qty', 'conversion_factor'].includes(field) && value > 0 && !this.allow_negative_stock) { + const qty_needed = field === 'qty' ? value * item_row.conversion_factor : item_row.qty * value; + await this.check_stock_availability(item_row, qty_needed, this.frm.doc.set_warehouse); + } + if (this.is_current_item_being_edited(item_row) || item_selected_from_selector) { await frappe.model.set_value(item_row.doctype, item_row.name, field, value); this.update_cart_html(item_row); @@ -568,11 +536,16 @@ erpnext.PointOfSale.Controller = class { frappe.utils.play_sound("error"); return; } + if (!item_code) return; + item_selected_from_selector && (value = flt(value)) const args = { item_code, batch_no, [field]: value }; - if (serial_no) args['serial_no'] = serial_no; + if (serial_no) { + await this.check_serial_no_availablilty(item_code, this.frm.doc.set_warehouse, serial_no); + args['serial_no'] = serial_no; + } if (field === 'serial_no') args['qty'] = value.split(`\n`).length || 0; @@ -585,18 +558,20 @@ erpnext.PointOfSale.Controller = class { this.check_serial_batch_selection_needed(item_row) && this.edit_item_details_of(item_row); this.update_cart_html(item_row); - } + } + } catch (error) { console.log(error); } finally { frappe.dom.unfreeze(); + return item_row; } } get_item_from_frm(item_code, batch_no, uom) { const has_batch_no = batch_no; return this.frm.doc.items.find( - i => i.item_code === item_code + i => i.item_code === item_code && (!has_batch_no || (has_batch_no && i.batch_no === batch_no)) && (i.uom === uom) ); @@ -625,7 +600,7 @@ erpnext.PointOfSale.Controller = class { const no_serial_selected = !item_row.serial_no; const no_batch_selected = !item_row.batch_no; - if ((serialized && no_serial_selected) || (batched && no_batch_selected) || + if ((serialized && no_serial_selected) || (batched && no_batch_selected) || (serialized && batched && (no_batch_selected || no_serial_selected))) { return true; } @@ -633,29 +608,46 @@ erpnext.PointOfSale.Controller = class { } async trigger_new_item_events(item_row) { - await this.frm.script_manager.trigger('item_code', item_row.doctype, item_row.name) - await this.frm.script_manager.trigger('qty', item_row.doctype, item_row.name) + await this.frm.script_manager.trigger('item_code', item_row.doctype, item_row.name); + await this.frm.script_manager.trigger('qty', item_row.doctype, item_row.name); } async check_stock_availability(item_row, qty_needed, warehouse) { const available_qty = (await this.get_available_stock(item_row.item_code, warehouse)).message; frappe.dom.unfreeze(); + const bold_item_code = item_row.item_code.bold(); + const bold_warehouse = warehouse.bold(); + const bold_available_qty = available_qty.toString().bold() if (!(available_qty > 0)) { frappe.model.clear_doc(item_row.doctype, item_row.name); - frappe.throw(__(`Item Code: ${item_row.item_code.bold()} is not available under warehouse ${warehouse.bold()}.`)) + frappe.throw({ + title: __("Not Available"), + message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse]) + }) } else if (available_qty < qty_needed) { frappe.show_alert({ - message: __(`Stock quantity not enough for Item Code: ${item_row.item_code.bold()} under warehouse ${warehouse.bold()}. - Available quantity ${available_qty.toString().bold()}.`), + message: __('Stock quantity not enough for Item Code: {0} under warehouse {1}. Available quantity {2}.', [bold_item_code, bold_warehouse, bold_available_qty]), indicator: 'orange' }); frappe.utils.play_sound("error"); - this.item_details.qty_control.set_value(flt(available_qty)); } frappe.dom.freeze(); } + async check_serial_no_availablilty(item_code, warehouse, serial_no) { + const method = "erpnext.stock.doctype.serial_no.serial_no.get_pos_reserved_serial_nos"; + const args = {filters: { item_code, warehouse }} + const res = await frappe.call({ method, args }); + + if (res.message.includes(serial_no)) { + frappe.throw({ + title: __("Not Available"), + message: __('Serial No: {0} has already been transacted into another POS Invoice.', [serial_no.bold()]) + }); + } + } + get_available_stock(item_code, warehouse) { const me = this; return frappe.call({ @@ -689,14 +681,14 @@ erpnext.PointOfSale.Controller = class { frappe.dom.freeze(); const { doctype, name, current_item } = this.item_details; - frappe.model.set_value(doctype, name, 'qty', 0); - - this.frm.script_manager.trigger('qty', doctype, name).then(() => { - frappe.model.clear_doc(doctype, name); - this.update_cart_html(current_item, true); - this.item_details.toggle_item_details_section(undefined); - frappe.dom.unfreeze(); - }) + frappe.model.set_value(doctype, name, 'qty', 0) + .then(() => { + frappe.model.clear_doc(doctype, name); + this.update_cart_html(current_item, true); + this.item_details.toggle_item_details_section(undefined); + frappe.dom.unfreeze(); + }) + .catch(e => console.log(e)); } -} +}; diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index 724b60b973a..9ab9eefa30d 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -1,12 +1,16 @@ erpnext.PointOfSale.ItemCart = class { - constructor({ wrapper, events }) { + constructor({ wrapper, events, settings }) { this.wrapper = wrapper; this.events = events; this.customer_info = undefined; - + this.hide_images = settings.hide_images; + this.allowed_customer_groups = settings.customer_groups; + this.allow_rate_change = settings.allow_rate_change; + this.allow_discount_change = settings.allow_discount_change; + this.init_component(); } - + init_component() { this.prepare_dom(); this.init_child_components(); @@ -16,10 +20,10 @@ erpnext.PointOfSale.ItemCart = class { prepare_dom() { this.wrapper.append( - `
    ` + `
    ` ) - this.$component = this.wrapper.find('.item-cart'); + this.$component = this.wrapper.find('.customer-cart-container'); } init_child_components() { @@ -29,32 +33,33 @@ erpnext.PointOfSale.ItemCart = class { init_customer_selector() { this.$component.append( - `
    ` + `
    ` ) this.$customer_section = this.$component.find('.customer-section'); + this.make_customer_selector(); } - + reset_customer_selector() { const frm = this.events.get_frm(); frm.set_value('customer', ''); - this.$customer_section.removeClass('border pr-4 pl-4'); this.make_customer_selector(); this.customer_field.set_focus(); } - + init_cart_components() { this.$component.append( - `
    -
    -
    -
    Item
    -
    Qty
    -
    Amount
    + `
    +
    +
    Item Cart
    +
    +
    Item
    +
    Qty
    +
    Amount
    -
    -
    -
    -
    +
    +
    +
    +
    ` ); this.$cart_container = this.$component.find('.cart-container'); @@ -70,54 +75,48 @@ erpnext.PointOfSale.ItemCart = class { this.make_no_items_placeholder(); } - + make_no_items_placeholder() { - this.$cart_header.addClass('d-none'); + this.$cart_header.css('display', 'none'); this.$cart_items_wrapper.html( - `
    -
    No items in cart
    -
    ` - ) - this.$cart_items_wrapper.addClass('mt-4 border-grey border-dashed'); + `
    No items in cart
    ` + ); + } + + get_discount_icon() { + return ( + ` + + + + + ` + ); } make_cart_totals_section() { this.$totals_section = this.$component.find('.cart-totals-section'); this.$totals_section.append( - `
    - + Add Discount + `
    + ${this.get_discount_icon()} Add Discount
    -
    -
    -
    -
    Net Total
    -
    -
    -
    0.00
    -
    -
    -
    -
    -
    -
    Grand Total
    -
    -
    -
    0.00
    -
    -
    -
    - Checkout -
    -
    - Edit Cart -
    -
    ` +
    +
    Net Total
    +
    0.00
    +
    +
    +
    +
    Grand Total
    +
    0.00
    +
    +
    Checkout
    +
    Edit Cart
    ` ) - this.$add_discount_elem = this.$component.find(".add-discount"); + this.$add_discount_elem = this.$component.find(".add-discount-wrapper"); } - + make_cart_numpad() { this.$numpad_section = this.$component.find('.numpad-section'); @@ -137,39 +136,37 @@ erpnext.PointOfSale.ItemCart = class { [ '', '', '', 'col-span-2' ], [ '', '', '', 'col-span-2' ], [ '', '', '', 'col-span-2' ], - [ '', '', '', 'col-span-2 text-bold text-danger' ] + [ '', '', '', 'col-span-2 remove-btn' ] ], fieldnames_map: { 'Quantity': 'qty', 'Discount': 'discount_percentage' } }) this.$numpad_section.prepend( - `
    + `
    ` ) this.$numpad_section.append( - `
    - Checkout -
    ` + `
    Checkout
    ` ) } - + bind_events() { const me = this; - this.$customer_section.on('click', '.add-remove-customer', function (e) { - const customer_info_is_visible = me.$cart_container.hasClass('d-none'); - customer_info_is_visible ? - me.toggle_customer_info(false) : me.reset_customer_selector(); + this.$customer_section.on('click', '.reset-customer-btn', function () { + me.reset_customer_selector(); }); - this.$customer_section.on('click', '.customer-header', function(e) { - // don't triggger the event if .add-remove-customer btn is clicked which is under .customer-header - if ($(e.target).closest('.add-remove-customer').length) return; + this.$customer_section.on('click', '.close-details-btn', function () { + me.toggle_customer_info(false); + }); - const show = !me.$cart_container.hasClass('d-none'); + this.$customer_section.on('click', '.customer-display', function(e) { + if ($(e.target).closest('.reset-customer-btn').length) return; + + const show = me.$cart_container.is(':visible'); me.toggle_customer_info(show); }); @@ -178,7 +175,7 @@ erpnext.PointOfSale.ItemCart = class { me.toggle_item_highlight(this); - const payment_section_hidden = me.$totals_section.find('.edit-cart-btn').hasClass('d-none'); + const payment_section_hidden = !me.$totals_section.find('.edit-cart-btn').is(':visible'); if (!payment_section_hidden) { // payment section is visible // edit cart first and then open item details section @@ -193,23 +190,21 @@ erpnext.PointOfSale.ItemCart = class { }); this.$component.on('click', '.checkout-btn', function() { - if (!$(this).hasClass('bg-primary')) return; - + if ($(this).attr('style').indexOf('--blue-500') == -1) return; + me.events.checkout(); me.toggle_checkout_btn(false); - me.$add_discount_elem.removeClass("d-none"); + me.allow_discount_change && me.$add_discount_elem.removeClass("d-none"); }); this.$totals_section.on('click', '.edit-cart-btn', () => { this.events.edit_cart(); this.toggle_checkout_btn(true); - - this.$add_discount_elem.addClass("d-none"); }); - this.$component.on('click', '.add-discount', () => { - const can_edit_discount = this.$add_discount_elem.find('.edit-discount').length; + this.$component.on('click', '.add-discount-wrapper', () => { + const can_edit_discount = this.$add_discount_elem.find('.edit-discount-btn').length; if(!this.discount_field || can_edit_discount) this.show_discount_control(); }); @@ -223,56 +218,85 @@ erpnext.PointOfSale.ItemCart = class { attach_shortcuts() { for (let row of this.number_pad.keys) { for (let btn of row) { + if (typeof btn !== 'string') continue; // do not make shortcuts for numbers + let shortcut_key = `ctrl+${frappe.scrub(String(btn))[0]}`; if (btn === 'Delete') shortcut_key = 'ctrl+backspace'; if (btn === 'Remove') shortcut_key = 'shift+ctrl+backspace' if (btn === '.') shortcut_key = 'ctrl+>'; // to account for fieldname map - const fieldname = this.number_pad.fieldnames[btn] ? this.number_pad.fieldnames[btn] : + const fieldname = this.number_pad.fieldnames[btn] ? this.number_pad.fieldnames[btn] : typeof btn === 'string' ? frappe.scrub(btn) : btn; + let shortcut_label = shortcut_key.split('+').map(frappe.utils.to_title_case).join('+'); + shortcut_label = frappe.utils.is_mac() ? shortcut_label.replace('Ctrl', '⌘') : shortcut_label; + this.$numpad_section.find(`.numpad-btn[data-button-value="${fieldname}"]`).attr("title", shortcut_label); + frappe.ui.keys.on(`${shortcut_key}`, () => { const cart_is_visible = this.$component.is(":visible"); if (cart_is_visible && this.item_is_selected && this.$numpad_section.is(":visible")) { this.$numpad_section.find(`.numpad-btn[data-button-value="${fieldname}"]`).click(); - } + } }) } } - - frappe.ui.keys.on("ctrl+enter", () => { - const cart_is_visible = this.$component.is(":visible"); - const payment_section_hidden = this.$totals_section.find('.edit-cart-btn').hasClass('d-none'); - if (cart_is_visible && payment_section_hidden) { - this.$component.find(".checkout-btn").click(); + const ctrl_label = frappe.utils.is_mac() ? '⌘' : 'Ctrl'; + this.$component.find(".checkout-btn").attr("title", `${ctrl_label}+Enter`); + frappe.ui.keys.add_shortcut({ + shortcut: "ctrl+enter", + action: () => this.$component.find(".checkout-btn").click(), + condition: () => this.$component.is(":visible") && !this.$totals_section.find('.edit-cart-btn').is(':visible'), + description: __("Checkout Order / Submit Order / New Order"), + ignore_inputs: true, + page: cur_page.page.page + }); + this.$component.find(".edit-cart-btn").attr("title", `${ctrl_label}+E`); + frappe.ui.keys.on("ctrl+e", () => { + const item_cart_visible = this.$component.is(":visible"); + const checkout_btn_invisible = !this.$totals_section.find('.checkout-btn').is('visible'); + if (item_cart_visible && checkout_btn_invisible) { + this.$component.find(".edit-cart-btn").click(); + } + }); + this.$component.find(".add-discount-wrapper").attr("title", `${ctrl_label}+D`); + frappe.ui.keys.add_shortcut({ + shortcut: "ctrl+d", + action: () => this.$component.find(".add-discount-wrapper").click(), + condition: () => this.$add_discount_elem.is(":visible"), + description: __("Add Order Discount"), + ignore_inputs: true, + page: cur_page.page.page + }); + frappe.ui.keys.on("escape", () => { + const item_cart_visible = this.$component.is(":visible"); + if (item_cart_visible && this.discount_field && this.discount_field.parent.is(":visible")) { + this.discount_field.set_value(0); } }); } - + toggle_item_highlight(item) { const $cart_item = $(item); - const item_is_highlighted = $cart_item.hasClass("shadow"); + const item_is_highlighted = $cart_item.attr("style") == "background-color:var(--gray-50);"; if (!item || item_is_highlighted) { this.item_is_selected = false; - this.$cart_container.find('.cart-item-wrapper').removeClass("shadow").css("opacity", "1"); + this.$cart_container.find('.cart-item-wrapper').css("background-color", ""); } else { - $cart_item.addClass("shadow"); + $cart_item.css("background-color", "var(--gray-50)"); this.item_is_selected = true; - this.$cart_container.find('.cart-item-wrapper').css("opacity", "1"); - this.$cart_container.find('.cart-item-wrapper').not(item).removeClass("shadow").css("opacity", "0.65"); + this.$cart_container.find('.cart-item-wrapper').not(item).css("background-color", ""); } - // highlight with inner shadow - // $cart_item.addClass("shadow-inner bg-selected"); - // me.$cart_container.find('.cart-item-wrapper').not(this).removeClass("shadow-inner bg-selected"); } make_customer_selector() { - this.$customer_section.html(`
    `); + this.$customer_section.html(` +
    + `); const me = this; const query = { query: 'erpnext.controllers.queries.customer_query' }; - const allowed_customer_group = this.events.get_allowed_customer_group() || []; + const allowed_customer_group = this.allowed_customer_groups || []; if (allowed_customer_group.length) { query.filters = { customer_group: ['in', allowed_customer_group] @@ -302,12 +326,12 @@ erpnext.PointOfSale.ItemCart = class { } }, }, - parent: this.$customer_section.find('.customer-search-field'), + parent: this.$customer_section.find('.customer-field'), render_input: true, }); this.customer_field.toggle_label(false); } - + fetch_customer_details(customer) { if (customer) { return new Promise((resolve) => { @@ -341,10 +365,9 @@ erpnext.PointOfSale.ItemCart = class { } show_discount_control() { - this.$add_discount_elem.removeClass("pr-4 pl-4"); + this.$add_discount_elem.css({ 'padding': '0px', 'border': 'none' }); this.$add_discount_elem.html( - `
    -
    ` + `
    ` ); const me = this; @@ -353,15 +376,24 @@ erpnext.PointOfSale.ItemCart = class { label: __('Discount'), fieldtype: 'Data', placeholder: __('Enter discount percentage.'), + input_class: 'input-xs', onchange: function() { - if (this.value || this.value == 0) { - const frm = me.events.get_frm(); + const frm = me.events.get_frm(); + if (flt(this.value) != 0) { frappe.model.set_value(frm.doc.doctype, frm.doc.name, 'additional_discount_percentage', flt(this.value)); me.hide_discount_control(this.value); + } else { + frappe.model.set_value(frm.doc.doctype, frm.doc.name, 'additional_discount_percentage', 0); + me.$add_discount_elem.css({ + 'border': '1px dashed var(--gray-500)', + 'padding': 'var(--padding-sm) var(--padding-md)' + }); + me.$add_discount_elem.html(`${me.get_discount_icon()} Add Discount`); + me.discount_field = undefined; } }, }, - parent: this.$add_discount_elem.find('.add-dicount-field'), + parent: this.$add_discount_elem.find('.add-discount-field'), render_input: true, }); this.discount_field.toggle_label(false); @@ -369,32 +401,38 @@ erpnext.PointOfSale.ItemCart = class { } hide_discount_control(discount) { - this.$add_discount_elem.addClass('pr-4 pl-4'); - this.$add_discount_elem.html( - ` - - -
    - ${String(discount).bold()}% off -
    - ` - ); + if (!discount) { + this.$add_discount_elem.css({ 'padding': '0px', 'border': 'none' }); + this.$add_discount_elem.html( + `
    ` + ); + } else { + this.$add_discount_elem.css({ + 'border': '1px dashed var(--dark-green-500)', + 'padding': 'var(--padding-sm) var(--padding-md)' + }); + this.$add_discount_elem.html( + `
    + ${this.get_discount_icon()} Additional ${String(discount).bold()}% discount applied +
    ` + ); + } } - + update_customer_section() { + const me = this; const { customer, email_id='', mobile_no='', image } = this.customer_info || {}; if (customer) { - this.$customer_section.addClass('border pr-4 pl-4').html( - `
    -
    - ${get_customer_image()} -
    -
    ${customer}
    + this.$customer_section.html( + `
    +
    + ${this.get_customer_image()} +
    +
    ${customer}
    ${get_customer_description()}
    -
    +
    @@ -409,157 +447,144 @@ erpnext.PointOfSale.ItemCart = class { function get_customer_description() { if (!email_id && !mobile_no) { - return `
    Click to add email / phone
    ` + return `
    Click to add email / phone
    `; } else if (email_id && !mobile_no) { - return `
    ${email_id}
    ` + return `
    ${email_id}
    `; } else if (mobile_no && !email_id) { - return `
    ${mobile_no}
    ` + return `
    ${mobile_no}
    `; } else { - return `
    ${email_id} | ${mobile_no}
    ` + return `
    ${email_id} - ${mobile_no}
    `; } } - function get_customer_image() { - if (image) { - return `
    - ${image} -
    ` - } else { - return `
    - ${frappe.get_abbr(customer)} -
    ` - } + } + + get_customer_image() { + const { customer, image } = this.customer_info || {}; + if (image) { + return `
    ${image}
    `; + } else { + return `
    ${frappe.get_abbr(customer)}
    `; } } - + update_totals_section(frm) { if (!frm) frm = this.events.get_frm(); - this.render_net_total(frm.doc.base_net_total); - this.render_grand_total(frm.doc.base_grand_total); + this.render_net_total(frm.doc.net_total); + const grand_total = cint(frappe.sys_defaults.disable_rounded_total) ? frm.doc.grand_total : frm.doc.rounded_total; + this.render_grand_total(grand_total); - const taxes = frm.doc.taxes.map(t => { return { description: t.description, rate: t.rate }}) - this.render_taxes(frm.doc.base_total_taxes_and_charges, taxes); + const taxes = frm.doc.taxes.map(t => { + return { + description: t.description, rate: t.rate + }; + }); + this.render_taxes(frm.doc.total_taxes_and_charges, taxes); } - + render_net_total(value) { const currency = this.events.get_frm().doc.currency; - this.$totals_section.find('.net-total').html( - `
    -
    Net Total
    -
    -
    -
    ${format_currency(value, currency)}
    -
    ` + this.$totals_section.find('.net-total-container').html( + `
    Net Total
    ${format_currency(value, currency)}
    ` ) - this.$numpad_section.find('.numpad-net-total').html(`Net Total: ${format_currency(value, currency)}`) + this.$numpad_section.find('.numpad-net-total').html( + `
    Net Total: ${format_currency(value, currency)}
    ` + ); } - + render_grand_total(value) { const currency = this.events.get_frm().doc.currency; - this.$totals_section.find('.grand-total').html( - `
    -
    Grand Total
    -
    -
    -
    ${format_currency(value, currency)}
    -
    ` + this.$totals_section.find('.grand-total-container').html( + `
    Grand Total
    ${format_currency(value, currency)}
    ` ) - this.$numpad_section.find('.numpad-grand-total').html(`Grand Total: ${format_currency(value, currency)}`) + this.$numpad_section.find('.numpad-grand-total').html( + `
    Grand Total: ${format_currency(value, currency)}
    ` + ); } render_taxes(value, taxes) { if (taxes.length) { const currency = this.events.get_frm().doc.currency; - this.$totals_section.find('.taxes').html( - `
    -
    -
    Tax Charges
    -
    - ${ - taxes.map((t, i) => { - let margin_left = ''; - if (i !== 0) margin_left = 'ml-2'; - return `${t.description}` - }).join('') - } -
    -
    -
    -
    ${format_currency(value, currency)}
    -
    -
    ` - ) + const taxes_html = taxes.map(t => { + const description = /[0-9]+/.test(t.description) ? t.description : `${t.description} @ ${t.rate}%`; + return `
    +
    ${description}
    +
    ${format_currency(value, currency)}
    +
    `; + }).join(''); + this.$totals_section.find('.taxes-container').css('display', 'flex').html(taxes_html); } else { - this.$totals_section.find('.taxes').html('') + this.$totals_section.find('.taxes-container').css('display', 'none').html(''); } } get_cart_item({ item_code, batch_no, uom }) { const batch_attr = `[data-batch-no="${escape(batch_no)}"]`; const item_code_attr = `[data-item-code="${escape(item_code)}"]`; - const uom_attr = `[data-uom=${escape(uom)}]`; + const uom_attr = `[data-uom="${escape(uom)}"]`; - const item_selector = batch_no ? + const item_selector = batch_no ? `.cart-item-wrapper${batch_attr}${uom_attr}` : `.cart-item-wrapper${item_code_attr}${uom_attr}`; - + return this.$cart_items_wrapper.find(item_selector); } - + update_item_html(item, remove_item) { const $item = this.get_cart_item(item); if (remove_item) { - $item && $item.remove(); + $item && $item.next().remove() && $item.remove(); } else { const { item_code, batch_no, uom } = item; const search_field = batch_no ? 'batch_no' : 'item_code'; const search_value = batch_no || item_code; const item_row = this.events.get_frm().doc.items.find(i => i[search_field] === search_value && i.uom === uom); - + this.render_cart_item(item_row, $item); } - const no_of_cart_items = this.$cart_items_wrapper.children().length; - no_of_cart_items > 0 && this.highlight_checkout_btn(no_of_cart_items > 0); - + const no_of_cart_items = this.$cart_items_wrapper.find('.cart-item-wrapper').length; + this.highlight_checkout_btn(no_of_cart_items > 0); + this.update_empty_cart_section(no_of_cart_items); } - + render_cart_item(item_data, $item_to_update) { const currency = this.events.get_frm().doc.currency; const me = this; - + if (!$item_to_update.length) { this.$cart_items_wrapper.append( - `
    -
    ` +
    +
    ` ) $item_to_update = this.get_cart_item(item_data); } $item_to_update.html( - `
    -
    + `${get_item_image_html()} +
    +
    ${item_data.item_name}
    ${get_description_html()}
    - ${get_rate_discount_html()} -
    ` + ${get_rate_discount_html()}` ) set_dynamic_rate_header_width(); this.scroll_to_item($item_to_update); function set_dynamic_rate_header_width() { - const rate_cols = Array.from(me.$cart_items_wrapper.find(".rate-col")); - me.$cart_header.find(".rate-list-header").css("width", ""); - me.$cart_items_wrapper.find(".rate-col").css("width", ""); + const rate_cols = Array.from(me.$cart_items_wrapper.find(".item-rate-amount")); + me.$cart_header.find(".rate-amount-header").css("width", ""); + me.$cart_items_wrapper.find(".item-rate-amount").css("width", ""); let max_width = rate_cols.reduce((max_width, elm) => { if ($(elm).width() > max_width) max_width = $(elm).width(); @@ -569,30 +594,26 @@ erpnext.PointOfSale.ItemCart = class { max_width += 1; if (max_width == 1) max_width = ""; - me.$cart_header.find(".rate-list-header").css("width", max_width); - me.$cart_items_wrapper.find(".rate-col").css("width", max_width); + me.$cart_header.find(".rate-amount-header").css("width", max_width); + me.$cart_items_wrapper.find(".item-rate-amount").css("width", max_width); } - + function get_rate_discount_html() { if (item_data.rate && item_data.amount && item_data.rate !== item_data.amount) { return ` -
    -
    - ${item_data.qty || 0} -
    -
    -
    ${format_currency(item_data.amount, currency)}
    -
    ${format_currency(item_data.rate, currency)}
    +
    +
    ${item_data.qty || 0}
    +
    +
    ${format_currency(item_data.amount, currency)}
    +
    ${format_currency(item_data.rate, currency)}
    ` } else { return ` -
    -
    - ${item_data.qty || 0} -
    -
    -
    ${format_currency(item_data.rate, currency)}
    +
    +
    ${item_data.qty || 0}
    +
    +
    ${format_currency(item_data.rate, currency)}
    ` } @@ -608,10 +629,19 @@ erpnext.PointOfSale.ItemCart = class { } } item_data.description = frappe.ellipsis(item_data.description, 45); - return `
    ${item_data.description}
    ` + return `
    ${item_data.description}
    `; } return ``; } + + function get_item_image_html() { + const { image, item_name } = item_data; + if (image) { + return `
    ${image}
    `; + } else { + return `
    ${frappe.get_abbr(item_name)}
    `; + } + } } scroll_to_item($item) { @@ -619,52 +649,68 @@ erpnext.PointOfSale.ItemCart = class { const scrollTop = $item.offset().top - this.$cart_items_wrapper.offset().top + this.$cart_items_wrapper.scrollTop(); this.$cart_items_wrapper.animate({ scrollTop }); } - + update_selector_value_in_cart_item(selector, value, item) { const $item_to_update = this.get_cart_item(item); - $item_to_update.attr(`data-${selector}`, value); + $item_to_update.attr(`data-${selector}`, escape(value)); } toggle_checkout_btn(show_checkout) { if (show_checkout) { - this.$totals_section.find('.checkout-btn').removeClass('d-none'); - this.$totals_section.find('.edit-cart-btn').addClass('d-none'); + this.$totals_section.find('.checkout-btn').css('display', 'flex'); + this.$totals_section.find('.edit-cart-btn').css('display', 'none'); } else { - this.$totals_section.find('.checkout-btn').addClass('d-none'); - this.$totals_section.find('.edit-cart-btn').removeClass('d-none'); + this.$totals_section.find('.checkout-btn').css('display', 'none'); + this.$totals_section.find('.edit-cart-btn').css('display', 'flex'); } } highlight_checkout_btn(toggle) { - const has_primary_class = this.$totals_section.find('.checkout-btn').hasClass('bg-primary'); - if (toggle && !has_primary_class) { - this.$totals_section.find('.checkout-btn').addClass('bg-primary text-white text-lg'); - } else if (!toggle && has_primary_class) { - this.$totals_section.find('.checkout-btn').removeClass('bg-primary text-white text-lg'); + if (toggle) { + this.$add_discount_elem.css('display', 'flex'); + this.$cart_container.find('.checkout-btn').css({ + 'background-color': 'var(--blue-500)' + }); + } else { + this.$add_discount_elem.css('display', 'none'); + this.$cart_container.find('.checkout-btn').css({ + 'background-color': 'var(--blue-200)' + }); } } - + update_empty_cart_section(no_of_cart_items) { const $no_item_element = this.$cart_items_wrapper.find('.no-item-wrapper'); // if cart has items and no item is present - no_of_cart_items > 0 && $no_item_element && $no_item_element.remove() - && this.$cart_items_wrapper.removeClass('mt-4 border-grey border-dashed') && this.$cart_header.removeClass('d-none'); + no_of_cart_items > 0 && $no_item_element && $no_item_element.remove() && this.$cart_header.css('display', 'flex'); no_of_cart_items === 0 && !$no_item_element.length && this.make_no_items_placeholder(); } - + on_numpad_event($btn) { const current_action = $btn.attr('data-button-value'); const action_is_field_edit = ['qty', 'discount_percentage', 'rate'].includes(current_action); - - this.highlight_numpad_btn($btn, current_action); + const action_is_allowed = action_is_field_edit ? ( + (current_action == 'rate' && this.allow_rate_change) || + (current_action == 'discount_percentage' && this.allow_discount_change) || + (current_action == 'qty')) : true; const action_is_pressed_twice = this.prev_action === current_action; const first_click_event = !this.prev_action; const field_to_edit_changed = this.prev_action && this.prev_action != current_action; if (action_is_field_edit) { + if (!action_is_allowed) { + const label = current_action == 'rate' ? 'Rate'.bold() : 'Discount'.bold(); + const message = __('Editing {0} is not allowed as per POS Profile settings', [label]); + frappe.show_alert({ + indicator: 'red', + message: message + }); + frappe.utils.play_sound("error"); + return; + } if (first_click_event || field_to_edit_changed) { this.prev_action = current_action; @@ -672,7 +718,7 @@ erpnext.PointOfSale.ItemCart = class { this.prev_action = undefined; } this.numpad_value = ''; - + } else if (current_action === 'checkout') { this.prev_action = undefined; this.toggle_item_highlight(); @@ -698,7 +744,7 @@ erpnext.PointOfSale.ItemCart = class { frappe.utils.play_sound("error"); return; } - + if (flt(this.numpad_value) > 100 && this.prev_action === 'discount_percentage') { frappe.show_alert({ message: __('Discount cannot be greater than 100%'), @@ -708,40 +754,41 @@ erpnext.PointOfSale.ItemCart = class { this.numpad_value = current_action; } + this.highlight_numpad_btn($btn, current_action); this.events.numpad_event(this.numpad_value, this.prev_action); } - + highlight_numpad_btn($btn, curr_action) { - const curr_action_is_highlighted = $btn.hasClass('shadow-inner'); + const curr_action_is_highlighted = $btn.hasClass('highlighted-numpad-btn'); const curr_action_is_action = ['qty', 'discount_percentage', 'rate', 'done'].includes(curr_action); if (!curr_action_is_highlighted) { - $btn.addClass('shadow-inner bg-selected'); + $btn.addClass('highlighted-numpad-btn'); } if (this.prev_action === curr_action && curr_action_is_highlighted) { // if Qty is pressed twice - $btn.removeClass('shadow-inner bg-selected'); + $btn.removeClass('highlighted-numpad-btn'); } if (this.prev_action && this.prev_action !== curr_action && curr_action_is_action) { // Order: Qty -> Rate then remove Qty highlight const prev_btn = $(`[data-button-value='${this.prev_action}']`); - prev_btn.removeClass('shadow-inner bg-selected'); + prev_btn.removeClass('highlighted-numpad-btn'); } if (!curr_action_is_action || curr_action === 'done') { // if numbers are clicked setTimeout(() => { - $btn.removeClass('shadow-inner bg-selected'); - }, 100); + $btn.removeClass('highlighted-numpad-btn'); + }, 200); } } toggle_numpad(show) { if (show) { - this.$totals_section.addClass('d-none'); - this.$numpad_section.removeClass('d-none'); + this.$totals_section.css('display', 'none'); + this.$numpad_section.css('display', 'flex'); } else { - this.$totals_section.removeClass('d-none'); - this.$numpad_section.addClass('d-none'); + this.$totals_section.css('display', 'flex'); + this.$numpad_section.css('display', 'none'); } this.reset_numpad(); } @@ -749,7 +796,7 @@ erpnext.PointOfSale.ItemCart = class { reset_numpad() { this.numpad_value = ''; this.prev_action = undefined; - this.$numpad_section.find('.shadow-inner').removeClass('shadow-inner bg-selected'); + this.$numpad_section.find('.highlighted-numpad-btn').removeClass('highlighted-numpad-btn'); } toggle_numpad_field_edit(fieldname) { @@ -760,48 +807,56 @@ erpnext.PointOfSale.ItemCart = class { toggle_customer_info(show) { if (show) { - this.$cart_container.addClass('d-none') - this.$customer_section.addClass('flex-1 scroll-y').removeClass('mb-0 border pr-4 pl-4') - this.$customer_section.find('.icon').addClass('w-24 h-24 text-2xl').removeClass('w-12 h-12 text-md') - this.$customer_section.find('.customer-header').removeClass('h-18'); - this.$customer_section.find('.customer-details').addClass('sticky z-100 bg-white'); + const { customer } = this.customer_info || {}; - this.$customer_section.find('.customer-name').html( - `
    ${this.customer_info.customer}
    -
    ` - ) - - this.$customer_section.find('.customer-details').append( - `
    -
    CONTACT DETAILS
    -
    - -
    -
    -
    + this.$cart_container.css('display', 'none'); + this.$customer_section.css({ + 'height': '100%', + 'padding-top': '0px' + }); + this.$customer_section.find('.customer-details').html( + `
    +
    Contact Details
    +
    + + +
    -
    RECENT TRANSACTIONS
    -
    ` - ) +
    +
    + ${this.get_customer_image()} +
    +
    ${customer}
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    Recent Transactions
    ` + ); // transactions need to be in diff div from sticky elem for scrolling - this.$customer_section.append(`
    `) + this.$customer_section.append(`
    `); - this.render_customer_info_form(); + this.render_customer_fields(); this.fetch_customer_transactions(); } else { - this.$cart_container.removeClass('d-none'); - this.$customer_section.removeClass('flex-1 scroll-y').addClass('mb-0 border pr-4 pl-4'); - this.$customer_section.find('.icon').addClass('w-12 h-12 text-md').removeClass('w-24 h-24 text-2xl'); - this.$customer_section.find('.customer-header').addClass('h-18') - this.$customer_section.find('.customer-details').removeClass('sticky z-100 bg-white'); + this.$cart_container.css('display', 'flex'); + this.$customer_section.css({ + 'height': '', + 'padding-top': '' + }); this.update_customer_section(); } } - render_customer_info_form() { - const $customer_form = this.$customer_section.find('.customer-form'); + render_customer_fields() { + const $customer_form = this.$customer_section.find('.customer-fields-container'); const dfs = [{ fieldname: 'email_id', @@ -823,7 +878,7 @@ erpnext.PointOfSale.ItemCart = class { },{ fieldname: 'loyalty_points', label: __('Loyalty Points'), - fieldtype: 'Int', + fieldtype: 'Data', read_only: 1 }]; @@ -867,7 +922,7 @@ erpnext.PointOfSale.ItemCart = class { } fetch_customer_transactions() { - frappe.db.get_list('POS Invoice', { + frappe.db.get_list('POS Invoice', { filters: { customer: this.customer_info.customer, docstatus: 1 }, fields: ['name', 'grand_total', 'status', 'posting_date', 'posting_time', 'currency'], limit: 20 @@ -875,41 +930,45 @@ erpnext.PointOfSale.ItemCart = class { const transaction_container = this.$customer_section.find('.customer-transactions'); if (!res.length) { - transaction_container.removeClass('flex-1 border rounded').html( - `
    No recent transactions found
    ` + transaction_container.html( + `
    No recent transactions found
    ` ) return; }; const elapsed_time = moment(res[0].posting_date+" "+res[0].posting_time).fromNow(); - this.$customer_section.find('.last-transacted-on').html(`Last transacted ${elapsed_time}`); + this.$customer_section.find('.customer-desc').html(`Last transacted ${elapsed_time}`); res.forEach(invoice => { const posting_datetime = moment(invoice.posting_date+" "+invoice.posting_time).format("Do MMMM, h:mma"); - let indicator_color = ''; - - if (in_list(['Paid', 'Consolidated'], invoice.status)) (indicator_color = 'green'); - if (invoice.status === 'Draft') (indicator_color = 'red'); - if (invoice.status === 'Return') (indicator_color = 'grey'); + let indicator_color = { + 'Paid': 'green', + 'Draft': 'red', + 'Return': 'gray', + 'Consolidated': 'blue' + }; transaction_container.append( - `
    -
    -
    ${invoice.name}
    -
    - ${posting_datetime} -
    + `
    +
    +
    ${invoice.name}
    +
    ${posting_datetime}
    -
    -
    +
    +
    ${format_currency(invoice.grand_total, invoice.currency, 0) || 0}
    -
    ${invoice.status}
    +
    + + ${invoice.status} + +
    -
    ` +
    +
    ` ) }); - }) + }); } load_invoice() { @@ -917,8 +976,8 @@ erpnext.PointOfSale.ItemCart = class { this.fetch_customer_details(frm.doc.customer).then(() => { this.events.customer_details_updated(this.customer_info); this.update_customer_section(); - }) - + }); + this.$cart_items_wrapper.html(''); if (frm.doc.items.length) { frm.doc.items.forEach(item => { @@ -932,20 +991,18 @@ erpnext.PointOfSale.ItemCart = class { this.update_totals_section(frm); if(frm.doc.docstatus === 1) { - this.$totals_section.find('.checkout-btn').addClass('d-none'); - this.$totals_section.find('.edit-cart-btn').addClass('d-none'); - this.$totals_section.find('.grand-total').removeClass('border-b-grey'); + this.$totals_section.find('.checkout-btn').css('display', 'none'); + this.$totals_section.find('.edit-cart-btn').css('display', 'none'); } else { - this.$totals_section.find('.checkout-btn').removeClass('d-none'); - this.$totals_section.find('.edit-cart-btn').addClass('d-none'); - this.$totals_section.find('.grand-total').addClass('border-b-grey'); + this.$totals_section.find('.checkout-btn').css('display', 'flex'); + this.$totals_section.find('.edit-cart-btn').css('display', 'none'); } this.toggle_component(true); } toggle_component(show) { - show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none'); + show ? this.$component.css('display', 'flex') : this.$component.css('display', 'none'); } - + } diff --git a/erpnext/selling/page/point_of_sale/pos_item_details.js b/erpnext/selling/page/point_of_sale/pos_item_details.js index 3a5f89ba937..cb0a0103e00 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_details.js +++ b/erpnext/selling/page/point_of_sale/pos_item_details.js @@ -1,7 +1,9 @@ erpnext.PointOfSale.ItemDetails = class { - constructor({ wrapper, events }) { + constructor({ wrapper, events, settings }) { this.wrapper = wrapper; this.events = events; + this.allow_rate_change = settings.allow_rate_change; + this.allow_discount_change = settings.allow_discount_change; this.current_item = {}; this.init_component(); @@ -16,35 +18,36 @@ erpnext.PointOfSale.ItemDetails = class { prepare_dom() { this.wrapper.append( - `
    ` + `
    ` ) - this.$component = this.wrapper.find('.item-details'); + this.$component = this.wrapper.find('.item-details-container'); } init_child_components() { this.$component.html( - `
    -
    -
    ITEM DETAILS
    -
    Close
    + `
    +
    Item Details
    +
    + + +
    -
    -
    -
    -
    -
    -
    -
    +
    +
    +
    +
    +
    +
    -
    -
    STOCK DETAILS
    -
    -
    ` +
    +
    +
    +
    ` ) this.$item_name = this.$component.find('.item-name'); - this.$item_description = this.$component.find('.item-description'); + this.$item_description = this.$component.find('.item-desc'); this.$item_price = this.$component.find('.item-price'); this.$item_image = this.$component.find('.item-image'); this.$form_container = this.$component.find('.form-container'); @@ -52,7 +55,7 @@ erpnext.PointOfSale.ItemDetails = class { } toggle_item_details_section(item) { - const { item_code, batch_no, uom } = this.current_item; + const { item_code, batch_no, uom } = this.current_item; const item_code_is_same = item && item_code === item.item_code; const batch_is_same = item && batch_no == item.batch_no; const uom_is_same = item && uom === item.uom; @@ -61,16 +64,16 @@ erpnext.PointOfSale.ItemDetails = class { this.events.toggle_item_selector(this.item_has_changed); this.toggle_component(this.item_has_changed); - + if (this.item_has_changed) { this.doctype = item.doctype; this.item_meta = frappe.get_meta(this.doctype); this.name = item.name; this.item_row = item; this.currency = this.events.get_frm().doc.currency; - + this.current_item = { item_code: item.item_code, batch_no: item.batch_no, uom: item.uom }; - + this.render_dom(item); this.render_discount_dom(item); this.render_form(item); @@ -79,7 +82,7 @@ erpnext.PointOfSale.ItemDetails = class { this.current_item = {}; } } - + validate_serial_batch_item() { const doc = this.events.get_frm().doc; const item_row = doc.items.find(item => item.name === this.name); @@ -91,7 +94,7 @@ erpnext.PointOfSale.ItemDetails = class { const no_serial_selected = !item_row.serial_no; const no_batch_selected = !item_row.batch_no; - if ((serialized && no_serial_selected) || (batched && no_batch_selected) || + if ((serialized && no_serial_selected) || (batched && no_batch_selected) || (serialized && batched && (no_batch_selected || no_serial_selected))) { frappe.show_alert({ @@ -102,40 +105,34 @@ erpnext.PointOfSale.ItemDetails = class { this.events.remove_item_from_cart(); } } - + render_dom(item) { - let { item_code ,item_name, description, image, price_list_rate } = item; + let { item_name, description, image, price_list_rate } = item; function get_description_html() { if (description) { - description = description.indexOf('...') === -1 && description.length > 75 ? description.substr(0, 73) + '...' : description; + description = description.indexOf('...') === -1 && description.length > 140 ? description.substr(0, 139) + '...' : description; return description; } return ``; } - + this.$item_name.html(item_name); this.$item_description.html(get_description_html()); this.$item_price.html(format_currency(price_list_rate, this.currency)); if (image) { - this.$item_image.html( - `${image}` - ); + this.$item_image.html(`${image}`); } else { - this.$item_image.html(frappe.get_abbr(item_code)); + this.$item_image.html(`
    ${frappe.get_abbr(item_name)}
    `); } } - + render_discount_dom(item) { if (item.discount_percentage) { this.$dicount_section.html( - `
    - ${format_currency(item.price_list_rate, this.currency)} -
    -
    - ${item.discount_percentage}% off -
    ` + `
    ${format_currency(item.price_list_rate, this.currency)}
    +
    ${item.discount_percentage}% off
    ` ) this.$item_price.html(format_currency(item.rate, this.currency)); } else { @@ -149,18 +146,16 @@ erpnext.PointOfSale.ItemDetails = class { fields_to_display.forEach((fieldname, idx) => { this.$form_container.append( - `
    -
    -
    ` + `
    ` ) const field_meta = this.item_meta.fields.find(df => df.fieldname === fieldname); fieldname === 'discount_percentage' ? (field_meta.label = __('Discount (%)')) : ''; const me = this; - + this[`${fieldname}_control`] = frappe.ui.form.make_control({ - df: { - ...field_meta, + df: { + ...field_meta, onchange: function() { me.events.form_updated(me.doctype, me.name, fieldname, this.value); } @@ -177,7 +172,7 @@ erpnext.PointOfSale.ItemDetails = class { } get_form_fields(item) { - const fields = ['qty', 'uom', 'rate', 'price_list_rate', 'discount_percentage', 'warehouse', 'actual_qty']; + const fields = ['qty', 'uom', 'rate', 'conversion_factor', 'discount_percentage', 'warehouse', 'actual_qty', 'price_list_rate']; if (item.has_serial_no) fields.push('serial_no'); if (item.has_batch_no) fields.push('batch_no'); return fields; @@ -185,39 +180,42 @@ erpnext.PointOfSale.ItemDetails = class { make_auto_serial_selection_btn(item) { if (item.has_serial_no) { - this.$form_container.append( - `
    ` - ) if (!item.has_batch_no) { this.$form_container.append( `
    ` - ) + ); } this.$form_container.append( - `
    - Auto Fetch Serial Numbers -
    ` - ) - this.$form_container.find('.serial_no-control').find('textarea').css('height', '9rem'); - this.$form_container.find('.serial_no-control').parent().addClass('row-span-2'); + `
    Auto Fetch Serial Numbers
    ` + ); + this.$form_container.find('.serial_no-control').find('textarea').css('height', '6rem'); } } - + bind_custom_control_change_event() { const me = this; if (this.rate_control) { - this.rate_control.df.onchange = function() { - if (this.value) { - me.events.form_updated(me.doctype, me.name, 'rate', this.value).then(() => { - const item_row = frappe.get_doc(me.doctype, me.name); - const doc = me.events.get_frm().doc; + if (this.allow_rate_change) { + this.rate_control.df.onchange = function() { + if (this.value || flt(this.value) === 0) { + me.events.form_updated(me.doctype, me.name, 'rate', this.value).then(() => { + const item_row = frappe.get_doc(me.doctype, me.name); + const doc = me.events.get_frm().doc; - me.$item_price.html(format_currency(item_row.rate, doc.currency)); - me.render_discount_dom(item_row); - }); - } + me.$item_price.html(format_currency(item_row.rate, doc.currency)); + me.render_discount_dom(item_row); + }); + } + }; + } else { + this.rate_control.df.read_only = 1; } + this.rate_control.refresh(); + } + + if (this.discount_percentage_control && !this.allow_discount_change) { + this.discount_percentage_control.df.read_only = 1; + this.discount_percentage_control.refresh(); } if (this.warehouse_control) { @@ -234,24 +232,22 @@ erpnext.PointOfSale.ItemDetails = class { }) } else if (available_qty === 0) { me.warehouse_control.set_value(''); - frappe.throw(__(`Item Code: ${me.item_row.item_code.bold()} is not available under warehouse ${this.value.bold()}.`)); + const bold_item_code = me.item_row.item_code.bold(); + const bold_warehouse = this.value.bold(); + frappe.throw( + __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse]) + ); } me.actual_qty_control.set_value(available_qty); }); } } - this.warehouse_control.refresh(); - } - - if (this.discount_percentage_control) { - this.discount_percentage_control.df.onchange = function() { - if (this.value) { - me.events.form_updated(me.doctype, me.name, 'discount_percentage', this.value).then(() => { - const item_row = frappe.get_doc(me.doctype, me.name); - me.rate_control.set_value(item_row.rate); - }); + this.warehouse_control.df.get_query = () => { + return { + filters: { company: this.events.get_frm().doc.company } } - } + }; + this.warehouse_control.refresh(); } if (this.serial_no_control) { @@ -270,7 +266,8 @@ erpnext.PointOfSale.ItemDetails = class { query: 'erpnext.controllers.queries.get_batch_no', filters: { item_code: me.item_row.item_code, - warehouse: me.item_row.warehouse + warehouse: me.item_row.warehouse, + posting_date: me.events.get_frm().doc.posting_date } } }; @@ -287,16 +284,34 @@ erpnext.PointOfSale.ItemDetails = class { me.events.set_value_in_current_cart_item('uom', this.value); me.events.form_updated(me.doctype, me.name, 'uom', this.value); me.current_item.uom = this.value; + + const item_row = frappe.get_doc(me.doctype, me.name); + me.conversion_factor_control.df.read_only = (item_row.stock_uom == this.value); + me.conversion_factor_control.refresh(); } } + + frappe.model.on("POS Invoice Item", "*", (fieldname, value, item_row) => { + const field_control = this[`${fieldname}_control`]; + const { item_code, batch_no, uom } = this.current_item; + const item_code_is_same = item_code === item_row.item_code; + const batch_is_same = batch_no == item_row.batch_no; + const uom_is_same = uom === item_row.uom; + const item_is_same = item_code_is_same && batch_is_same && uom_is_same ? true : false; + + if (item_is_same && field_control && field_control.get_value() !== value) { + field_control.set_value(value); + cur_pos.update_cart_html(item_row); + } + }); } - + async auto_update_batch_no() { if (this.serial_no_control && this.batch_no_control) { const selected_serial_nos = this.serial_no_control.get_value().split(`\n`).filter(s => s); if (!selected_serial_nos.length) return; - // find batch nos of the selected serial no + // find batch nos of the selected serial no const serials_with_batch_no = await frappe.db.get_list("Serial No", { filters: { 'name': ["in", selected_serial_nos]}, fields: ["batch_no", "name"] @@ -311,7 +326,7 @@ erpnext.PointOfSale.ItemDetails = class { const batch_serial_nos = batch_serial_map[batch_no].join(`\n`); // eg. 10 selected serial no. -> 5 belongs to first batch other 5 belongs to second batch const serial_nos_belongs_to_other_batch = selected_serial_nos.length !== batch_serial_map[batch_no].length; - + const current_batch_no = this.batch_no_control.get_value(); current_batch_no != batch_no && await this.batch_no_control.set_value(batch_no); @@ -326,7 +341,7 @@ erpnext.PointOfSale.ItemDetails = class { this.events.clone_new_batch_item_in_frm(batch_serial_map, this.current_item); } } - + bind_events() { this.bind_auto_serial_fetch_event(); this.bind_fields_to_numpad_fields(); @@ -337,6 +352,7 @@ erpnext.PointOfSale.ItemDetails = class { } attach_shortcuts() { + this.wrapper.find('.close-btn').attr("title", "Esc"); frappe.ui.keys.on("escape", () => { const item_details_visible = this.$component.is(":visible"); if (item_details_visible) { @@ -355,18 +371,22 @@ erpnext.PointOfSale.ItemDetails = class { } }); } - + bind_auto_serial_fetch_event() { this.$form_container.on('click', '.auto-fetch-btn', () => { - this.batch_no_control.set_value(''); + this.batch_no_control && this.batch_no_control.set_value(''); let qty = this.qty_control.get_value(); + let conversion_factor = this.conversion_factor_control.get_value(); + let expiry_date = this.item_row.has_batch_no ? this.events.get_frm().doc.posting_date : ""; + let numbers = frappe.call({ method: "erpnext.stock.doctype.serial_no.serial_no.auto_fetch_serial_number", args: { - qty, + qty: qty * conversion_factor, item_code: this.current_item.item_code, warehouse: this.warehouse_control.get_value() || '', batch_nos: this.current_item.batch_no || '', + posting_date: expiry_date, for_doctype: 'POS Invoice' } }); @@ -376,10 +396,14 @@ erpnext.PointOfSale.ItemDetails = class { let records_length = auto_fetched_serial_numbers.length; if (!records_length) { const warehouse = this.warehouse_control.get_value().bold(); - frappe.msgprint(__(`Serial numbers unavailable for Item ${this.current_item.item_code.bold()} - under warehouse ${warehouse}. Please try changing warehouse.`)); + const item_code = this.current_item.item_code.bold(); + frappe.msgprint( + __('Serial numbers unavailable for Item {0} under warehouse {1}. Please try changing warehouse.', [item_code, warehouse]) + ); } else if (records_length < qty) { - frappe.msgprint(`Fetched only ${records_length} available serial numbers.`); + frappe.msgprint( + __('Fetched only {0} available serial numbers.', [records_length]) + ); this.qty_control.set_value(records_length); } numbers = auto_fetched_serial_numbers.join(`\n`); @@ -389,6 +413,6 @@ erpnext.PointOfSale.ItemDetails = class { } toggle_component(show) { - show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none'); + show ? this.$component.css('display', 'flex') : this.$component.css('display', 'none'); } } \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js index c87b845a41f..e0d5b731665 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -1,12 +1,17 @@ +import onScan from 'onscan.js'; + erpnext.PointOfSale.ItemSelector = class { - constructor({ frm, wrapper, events, pos_profile }) { + // eslint-disable-next-line no-unused-vars + constructor({ frm, wrapper, events, pos_profile, settings }) { this.wrapper = wrapper; this.events = events; this.pos_profile = pos_profile; - + this.hide_images = settings.hide_images; + this.auto_add_item = settings.auto_add_item_to_cart; + this.inti_component(); } - + inti_component() { this.prepare_dom(); this.make_search_bar(); @@ -17,29 +22,25 @@ erpnext.PointOfSale.ItemSelector = class { prepare_dom() { this.wrapper.append( - `
    -
    -
    -
    -
    -
    -
    -
    ALL ITEMS
    -
    -
    -
    + `
    +
    +
    All Items
    +
    +
    +
    ` ); - + this.$component = this.wrapper.find('.items-selector'); + this.$items_container = this.$component.find('.items-container'); } async load_items_data() { if (!this.item_group) { const res = await frappe.db.get_value("Item Group", {lft: 1, is_group: 1}, "name"); this.parent_item_group = res.message.name; - }; + } if (!this.price_list) { const res = await frappe.db.get_value("POS Profile", this.pos_profile, "selling_price_list"); this.price_list = res.message.selling_price_list; @@ -51,11 +52,12 @@ erpnext.PointOfSale.ItemSelector = class { } get_items({start = 0, page_length = 40, search_value=''}) { - const price_list = this.events.get_frm().doc?.selling_price_list || this.price_list; + const doc = this.events.get_frm().doc; + const price_list = (doc && doc.selling_price_list) || this.price_list; let { item_group, pos_profile } = this; !item_group && (item_group = this.parent_item_group); - + return frappe.call({ method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items", freeze: true, @@ -65,49 +67,52 @@ erpnext.PointOfSale.ItemSelector = class { render_item_list(items) { - this.$items_container = this.$component.find('.items-container'); this.$items_container.html(''); items.forEach(item => { const item_html = this.get_item_html(item); this.$items_container.append(item_html); - }) + }); } get_item_html(item) { + const me = this; + // eslint-disable-next-line no-unused-vars const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom } = item; - const indicator_color = actual_qty > 10 ? "green" : actual_qty !== 0 ? "orange" : "red"; + const indicator_color = actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange"; function get_item_image_html() { - if (item_image) { + if (!me.hide_images && item_image) { return `
    - ${item_image} -
    ` + ${frappe.get_abbr(item.item_name)} +
    `; } else { - return `
    - ${frappe.get_abbr(item.item_name)} -
    ` + return `
    ${frappe.get_abbr(item.item_name)}
    `; } } return ( - `
    + ${get_item_image_html()} -
    -
    + +
    +
    ${frappe.ellipsis(item.item_name, 18)}
    -
    ${format_currency(item.price_list_rate, item.currency, 0) || 0}
    +
    ${format_currency(item.price_list_rate, item.currency, 0) || 0}
    ` - ) + ); } make_search_bar() { const me = this; + const doc = me.events.get_frm().doc; this.$component.find('.search-field').html(''); this.$component.find('.item-group-field').html(''); @@ -115,7 +120,7 @@ erpnext.PointOfSale.ItemSelector = class { df: { label: __('Search'), fieldtype: 'Data', - placeholder: __('Search by item code, serial number, batch no or barcode') + placeholder: __('Search by item code, serial number or barcode') }, parent: this.$component.find('.search-field'), render_input: true, @@ -135,9 +140,9 @@ erpnext.PointOfSale.ItemSelector = class { return { query: 'erpnext.selling.page.point_of_sale.point_of_sale.item_group_query', filters: { - pos_profile: me.events.get_frm().doc?.pos_profile + pos_profile: doc ? doc.pos_profile : '' } - } + }; }, }, parent: this.$component.find('.item-group-field'), @@ -147,13 +152,18 @@ erpnext.PointOfSale.ItemSelector = class { this.item_group_field.toggle_label(false); } + set_search_value(value) { + $(this.search_field.$input[0]).val(value).trigger("input"); + } + bind_events() { const me = this; + window.onScan = onScan; onScan.attachTo(document, { onScan: (sScancode) => { if (this.search_field && this.$component.is(':visible')) { this.search_field.set_focus(); - $(this.search_field.$input[0]).val(sScancode).trigger("input"); + this.set_search_value(sScancode); this.barcode_scanned = true; } } @@ -165,14 +175,15 @@ erpnext.PointOfSale.ItemSelector = class { let batch_no = unescape($item.attr('data-batch-no')); let serial_no = unescape($item.attr('data-serial-no')); let uom = unescape($item.attr('data-uom')); - + // escape(undefined) returns "undefined" then unescape returns "undefined" batch_no = batch_no === "undefined" ? undefined : batch_no; serial_no = serial_no === "undefined" ? undefined : serial_no; uom = uom === "undefined" ? undefined : uom; me.events.item_selected({ field: 'qty', value: "+1", item: { item_code, batch_no, serial_no, uom }}); - }) + me.set_search_value(''); + }); this.search_field.$input.on('input', (e) => { clearTimeout(this.last_search); @@ -184,16 +195,26 @@ erpnext.PointOfSale.ItemSelector = class { } attach_shortcuts() { - frappe.ui.keys.on("ctrl+i", () => { - const selector_is_visible = this.$component.is(':visible'); - if (!selector_is_visible) return; - this.search_field.set_focus(); + const ctrl_label = frappe.utils.is_mac() ? '⌘' : 'Ctrl'; + this.search_field.parent.attr("title", `${ctrl_label}+I`); + frappe.ui.keys.add_shortcut({ + shortcut: "ctrl+i", + action: () => this.search_field.set_focus(), + condition: () => this.$component.is(':visible'), + description: __("Focus on search input"), + ignore_inputs: true, + page: cur_page.page.page }); - frappe.ui.keys.on("ctrl+g", () => { - const selector_is_visible = this.$component.is(':visible'); - if (!selector_is_visible) return; - this.item_group_field.set_focus(); + this.item_group_field.parent.attr("title", `${ctrl_label}+G`); + frappe.ui.keys.add_shortcut({ + shortcut: "ctrl+g", + action: () => this.item_group_field.set_focus(), + condition: () => this.$component.is(':visible'), + description: __("Focus on Item Group filter"), + ignore_inputs: true, + page: cur_page.page.page }); + // for selecting the last filtered item on search frappe.ui.keys.on("enter", () => { const selector_is_visible = this.$component.is(':visible'); @@ -215,7 +236,7 @@ erpnext.PointOfSale.ItemSelector = class { } }); } - + filter_items({ search_term='' }={}) { if (search_term) { search_term = search_term.toLowerCase(); @@ -226,40 +247,47 @@ erpnext.PointOfSale.ItemSelector = class { const items = this.search_index[search_term]; this.items = items; this.render_item_list(items); + this.auto_add_item && this.items.length == 1 && this.add_filtered_item_to_cart(); return; } } this.get_items({ search_value: search_term }) .then(({ message }) => { + // eslint-disable-next-line no-unused-vars const { items, serial_no, batch_no, barcode } = message; if (search_term && !barcode) { this.search_index[search_term] = items; } this.items = items; this.render_item_list(items); + this.auto_add_item && this.items.length == 1 && this.add_filtered_item_to_cart(); }); } - + + add_filtered_item_to_cart() { + this.$items_container.find(".item-wrapper").click(); + } + resize_selector(minimize) { - minimize ? - this.$component.find('.search-field').removeClass('mr-8') : - this.$component.find('.search-field').addClass('mr-8'); - - minimize ? - this.$component.find('.filter-section').addClass('flex-col') : - this.$component.find('.filter-section').removeClass('flex-col'); + minimize ? + this.$component.find('.filter-section').css('grid-template-columns', 'repeat(1, minmax(0, 1fr))') : + this.$component.find('.filter-section').css('grid-template-columns', 'repeat(12, minmax(0, 1fr))'); minimize ? - this.$component.removeClass('col-span-6').addClass('col-span-2') : - this.$component.removeClass('col-span-2').addClass('col-span-6') + this.$component.find('.search-field').css('margin', 'var(--margin-sm) 0px') : + this.$component.find('.search-field').css('margin', '0px var(--margin-sm)'); minimize ? - this.$items_container.removeClass('grid-cols-4').addClass('grid-cols-1') : - this.$items_container.removeClass('grid-cols-1').addClass('grid-cols-4') + this.$component.css('grid-column', 'span 2 / span 2') : + this.$component.css('grid-column', 'span 6 / span 6'); + + minimize ? + this.$items_container.css('grid-template-columns', 'repeat(1, minmax(0, 1fr))') : + this.$items_container.css('grid-template-columns', 'repeat(4, minmax(0, 1fr))'); } toggle_component(show) { - show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none'); + show ? this.$component.css('display', 'flex') : this.$component.css('display', 'none'); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_number_pad.js b/erpnext/selling/page/point_of_sale/pos_number_pad.js index 4b8e8418055..962bcaf0963 100644 --- a/erpnext/selling/page/point_of_sale/pos_number_pad.js +++ b/erpnext/selling/page/point_of_sale/pos_number_pad.js @@ -22,17 +22,16 @@ erpnext.PointOfSale.NumberPad = class { return keys.reduce((a, row, i) => { return a + row.reduce((a2, number, j) => { const class_to_append = css_classes && css_classes[i] ? css_classes[i][j] : ''; - const fieldname = fieldnames && fieldnames[number] ? + const fieldname = fieldnames && fieldnames[number] ? fieldnames[number] : typeof number === 'string' ? frappe.scrub(number) : number; - return a2 + `
    ${number}
    ` - }, '') + return a2 + `
    ${number}
    `; + }, ''); }, ''); } this.wrapper.html( - `
    + `
    ${get_keys()}
    ` ) diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_list.js b/erpnext/selling/page/point_of_sale/pos_past_order_list.js index 9181ee80007..ec392313f5e 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_list.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_list.js @@ -1,55 +1,51 @@ erpnext.PointOfSale.PastOrderList = class { - constructor({ wrapper, events }) { - this.wrapper = wrapper; - this.events = events; + constructor({ wrapper, events }) { + this.wrapper = wrapper; + this.events = events; - this.init_component(); - } + this.init_component(); + } - init_component() { - this.prepare_dom(); - this.make_filter_section(); - this.bind_events(); - } + init_component() { + this.prepare_dom(); + this.make_filter_section(); + this.bind_events(); + } - prepare_dom() { - this.wrapper.append( - `
    -
    -
    -
    -
    -
    -
    -
    RECENT ORDERS
    -
    -
    -
    -
    ` - ) + prepare_dom() { + this.wrapper.append( + `
    +
    +
    Recent Orders
    +
    +
    +
    +
    +
    ` + ); - this.$component = this.wrapper.find('.past-order-list'); - this.$invoices_container = this.$component.find('.invoices-container'); - } + this.$component = this.wrapper.find('.past-order-list'); + this.$invoices_container = this.$component.find('.invoices-container'); + } - bind_events() { - this.search_field.$input.on('input', (e) => { + bind_events() { + this.search_field.$input.on('input', (e) => { clearTimeout(this.last_search); this.last_search = setTimeout(() => { - const search_term = e.target.value; - this.refresh_list(search_term, this.status_field.get_value()); + const search_term = e.target.value; + this.refresh_list(search_term, this.status_field.get_value()); }, 300); - }); - const me = this; - this.$invoices_container.on('click', '.invoice-wrapper', function() { - const invoice_name = unescape($(this).attr('data-invoice-name')); + }); + const me = this; + this.$invoices_container.on('click', '.invoice-wrapper', function() { + const invoice_name = unescape($(this).attr('data-invoice-name')); - me.events.open_invoice_data(invoice_name); - }) - } + me.events.open_invoice_data(invoice_name); + }); + } - make_filter_section() { - const me = this; + make_filter_section() { + const me = this; this.search_field = frappe.ui.form.make_control({ df: { label: __('Search'), @@ -58,73 +54,70 @@ erpnext.PointOfSale.PastOrderList = class { }, parent: this.$component.find('.search-field'), render_input: true, - }); + }); this.status_field = frappe.ui.form.make_control({ df: { label: __('Invoice Status'), - fieldtype: 'Select', + fieldtype: 'Select', options: `Draft\nPaid\nConsolidated\nReturn`, - placeholder: __('Filter by invoice status'), - onchange: function() { - me.refresh_list(me.search_field.get_value(), this.value); - } + placeholder: __('Filter by invoice status'), + onchange: function() { + if (me.$component.is(':visible')) me.refresh_list(); + } }, - parent: this.$component.find('.status-field'), + parent: this.$component.find('.status-field'), render_input: true, - }); - this.search_field.toggle_label(false); - this.status_field.toggle_label(false); - this.status_field.set_value('Paid'); - } - - toggle_component(show) { - show ? - this.$component.removeClass('d-none') && this.refresh_list() : - this.$component.addClass('d-none'); - } + }); + this.search_field.toggle_label(false); + this.status_field.toggle_label(false); + this.status_field.set_value('Draft'); + } - refresh_list() { - frappe.dom.freeze(); - this.events.reset_summary(); - const search_term = this.search_field.get_value(); - const status = this.status_field.get_value(); + refresh_list() { + frappe.dom.freeze(); + this.events.reset_summary(); + const search_term = this.search_field.get_value(); + const status = this.status_field.get_value(); - this.$invoices_container.html(''); + this.$invoices_container.html(''); - return frappe.call({ + return frappe.call({ method: "erpnext.selling.page.point_of_sale.point_of_sale.get_past_order_list", freeze: true, - args: { search_term, status }, - callback: (response) => { - frappe.dom.unfreeze(); - response.message.forEach(invoice => { - const invoice_html = this.get_invoice_html(invoice); - this.$invoices_container.append(invoice_html); - }); - } - }); - } + args: { search_term, status }, + callback: (response) => { + frappe.dom.unfreeze(); + response.message.forEach(invoice => { + const invoice_html = this.get_invoice_html(invoice); + this.$invoices_container.append(invoice_html); + }); + } + }); + } - get_invoice_html(invoice) { - const posting_datetime = moment(invoice.posting_date+" "+invoice.posting_time).format("Do MMMM, h:mma"); - return ( - `
    -
    -
    ${invoice.name}
    -
    -
    - - - - ${invoice.customer} -
    -
    -
    -
    -
    ${format_currency(invoice.grand_total, invoice.currency, 0) || 0}
    -
    ${posting_datetime}
    -
    -
    ` - ) - } -} \ No newline at end of file + get_invoice_html(invoice) { + const posting_datetime = moment(invoice.posting_date+" "+invoice.posting_time).format("Do MMMM, h:mma"); + return ( + `
    +
    +
    ${invoice.name}
    +
    + + + + ${invoice.customer} +
    +
    +
    +
    ${format_currency(invoice.grand_total, invoice.currency, 0) || 0}
    +
    ${posting_datetime}
    +
    +
    +
    ` + ); + } + + toggle_component(show) { + show ? this.$component.css('display', 'flex') && this.refresh_list() : this.$component.css('display', 'none'); + } +}; \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js index 30e0918ba68..b10a9e33c51 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js @@ -1,456 +1,397 @@ erpnext.PointOfSale.PastOrderSummary = class { - constructor({ wrapper, events }) { - this.wrapper = wrapper; - this.events = events; + constructor({ wrapper, events }) { + this.wrapper = wrapper; + this.events = events; - this.init_component(); - } + this.init_component(); + } - init_component() { - this.prepare_dom(); - this.init_child_components(); - this.bind_events(); - this.attach_shortcuts(); - } + init_component() { + this.prepare_dom(); + this.init_email_print_dialog(); + this.bind_events(); + this.attach_shortcuts(); + } - prepare_dom() { - this.wrapper.append( - `
    -
    -
    -
    Select an invoice to load summary data
    -
    -
    -
    -
    -
    -
    ` - ) + prepare_dom() { + this.wrapper.append( + `
    +
    + Select an invoice to load summary data +
    +
    +
    +
    +
    Items
    +
    +
    Totals
    +
    +
    Payments
    +
    +
    +
    +
    +
    ` + ); - this.$component = this.wrapper.find('.past-order-summary'); - this.$summary_wrapper = this.$component.find('.summary-wrapper'); - this.$summary_container = this.$component.find('.summary-container'); - } + this.$component = this.wrapper.find('.past-order-summary'); + this.$summary_wrapper = this.$component.find('.invoice-summary-wrapper'); + this.$summary_container = this.$component.find('.abs-container'); + this.$upper_section = this.$summary_container.find('.upper-section'); + this.$items_container = this.$summary_container.find('.items-container'); + this.$totals_container = this.$summary_container.find('.totals-container'); + this.$payment_container = this.$summary_container.find('.payments-container'); + this.$summary_btns = this.$summary_container.find('.summary-btns'); + } - init_child_components() { - this.init_upper_section(); - this.init_items_summary(); - this.init_totals_summary(); - this.init_payments_summary(); - this.init_summary_buttons(); - this.init_email_print_dialog(); - } + init_email_print_dialog() { + const email_dialog = new frappe.ui.Dialog({ + title: 'Email Receipt', + fields: [ + {fieldname: 'email_id', fieldtype: 'Data', options: 'Email', label: 'Email ID'}, + // {fieldname:'remarks', fieldtype:'Text', label:'Remarks (if any)'} + ], + primary_action: () => { + this.send_email(); + }, + primary_action_label: __('Send'), + }); + this.email_dialog = email_dialog; - init_upper_section() { - this.$summary_container.append( - `
    ` - ); + const print_dialog = new frappe.ui.Dialog({ + title: 'Print Receipt', + fields: [ + {fieldname: 'print', fieldtype: 'Data', label: 'Print Preview'} + ], + primary_action: () => { + this.print_receipt(); + }, + primary_action_label: __('Print'), + }); + this.print_dialog = print_dialog; + } - this.$upper_section = this.$summary_container.find('.upper-section'); - } + get_upper_section_html(doc) { + const { status } = doc; + let indicator_color = ''; - init_items_summary() { - this.$summary_container.append( - `
    -
    ITEMS
    -
    -
    ` - ) + in_list(['Paid', 'Consolidated'], status) && (indicator_color = 'green'); + status === 'Draft' && (indicator_color = 'red'); + status === 'Return' && (indicator_color = 'grey'); - this.$items_summary_container = this.$summary_container.find('.items-summary-container'); - } + return `
    +
    ${doc.customer}
    +
    ${this.customer_email}
    +
    Sold by: ${doc.owner}
    +
    +
    + +
    ${doc.name}
    + ${doc.status} +
    `; + } - init_totals_summary() { - this.$summary_container.append( - `
    -
    TOTALS
    -
    -
    ` - ) + get_item_html(doc, item_data) { + return `
    +
    ${item_data.item_name}
    +
    ${item_data.qty || 0}
    +
    ${get_rate_discount_html()}
    +
    `; - this.$totals_summary_container = this.$summary_container.find('.summary-totals-container'); - } + function get_rate_discount_html() { + if (item_data.rate && item_data.price_list_rate && item_data.rate !== item_data.price_list_rate) { + return `(${item_data.discount_percentage}% off) +
    ${format_currency(item_data.rate, doc.currency)}
    `; + } else { + return `
    ${format_currency(item_data.price_list_rate || item_data.rate, doc.currency)}
    `; + } + } + } - init_payments_summary() { - this.$summary_container.append( - `
    -
    PAYMENTS
    -
    -
    ` - ) + get_discount_html(doc) { + if (doc.discount_amount) { + return `
    +
    Discount (${doc.additional_discount_percentage} %)
    +
    ${format_currency(doc.discount_amount, doc.currency)}
    +
    `; + } else { + return ``; + } + } - this.$payment_summary_container = this.$summary_container.find('.payments-summary-container'); - } + get_net_total_html(doc) { + return `
    +
    Net Total
    +
    ${format_currency(doc.net_total, doc.currency)}
    +
    `; + } - init_summary_buttons() { - this.$summary_container.append( - `
    ` - ) + get_taxes_html(doc) { + if (!doc.taxes.length) return ''; - this.$summary_btns = this.$summary_container.find('.summary-btns'); - } + let taxes_html = doc.taxes.map(t => { + const description = /[0-9]+/.test(t.description) ? t.description : `${t.description} @ ${t.rate}%`; + return ` +
    +
    ${description}
    +
    ${format_currency(t.tax_amount_after_discount_amount, doc.currency)}
    +
    + `; + }).join(''); - init_email_print_dialog() { - const email_dialog = new frappe.ui.Dialog({ - title: 'Email Receipt', - fields: [ - {fieldname:'email_id', fieldtype:'Data', options: 'Email', label:'Email ID'}, - // {fieldname:'remarks', fieldtype:'Text', label:'Remarks (if any)'} - ], - primary_action: () => { - this.send_email(); - }, - primary_action_label: __('Send'), - }); - this.email_dialog = email_dialog; + return `
    ${taxes_html}
    `; + } - const print_dialog = new frappe.ui.Dialog({ - title: 'Print Receipt', - fields: [ - {fieldname:'print', fieldtype:'Data', label:'Print Preview'} - ], - primary_action: () => { - const frm = this.events.get_frm(); - frm.doc = this.doc; - frm.print_preview.lang_code = frm.doc.language; - frm.print_preview.printit(true); - }, - primary_action_label: __('Print'), - }); - this.print_dialog = print_dialog; - } + get_grand_total_html(doc) { + return `
    +
    Grand Total
    +
    ${format_currency(doc.grand_total, doc.currency)}
    +
    `; + } - get_upper_section_html(doc) { - const { status } = doc; let indicator_color = ''; + get_payment_html(doc, payment) { + return `
    +
    ${payment.mode_of_payment}
    +
    ${format_currency(payment.amount, doc.currency)}
    +
    `; + } - in_list(['Paid', 'Consolidated'], status) && (indicator_color = 'green'); - status === 'Draft' && (indicator_color = 'red'); - status === 'Return' && (indicator_color = 'grey'); + bind_events() { + this.$summary_container.on('click', '.return-btn', () => { + this.events.process_return(this.doc.name); + this.toggle_component(false); + this.$component.find('.no-summary-placeholder').css('display', 'flex'); + this.$summary_wrapper.css('display', 'none'); + }); - return `
    -
    ${doc.customer}
    -
    ${this.customer_email}
    -
    Sold by: ${doc.owner}
    -
    -
    -
    ${format_currency(doc.paid_amount, doc.currency)}
    -
    -
    ${doc.name}
    -
    ${doc.status}
    -
    -
    ` - } + this.$summary_container.on('click', '.edit-btn', () => { + this.events.edit_order(this.doc.name); + this.toggle_component(false); + this.$component.find('.no-summary-placeholder').css('display', 'flex'); + this.$summary_wrapper.css('display', 'none'); + }); - get_discount_html(doc) { - if (doc.discount_amount) { - return `
    -
    -
    - Discount -
    - (${doc.additional_discount_percentage} %) -
    -
    -
    ${format_currency(doc.discount_amount, doc.currency)}
    -
    -
    `; - } else { - return ``; - } - } + this.$summary_container.on('click', '.delete-btn', () => { + this.events.delete_order(this.doc.name); + this.show_summary_placeholder(); + }); - get_net_total_html(doc) { - return `
    -
    -
    - Net Total -
    -
    -
    -
    ${format_currency(doc.net_total, doc.currency)}
    -
    -
    ` - } + this.$summary_container.on('click', '.new-btn', () => { + this.events.new_order(); + this.toggle_component(false); + this.$component.find('.no-summary-placeholder').css('display', 'flex'); + this.$summary_wrapper.css('display', 'none'); + }); - get_taxes_html(doc) { - return `
    -
    -
    Tax Charges
    -
    - ${ - doc.taxes.map((t, i) => { - let margin_left = ''; - if (i !== 0) margin_left = 'ml-2'; - return `${t.description} @${t.rate}%` - }).join('') - } -
    -
    -
    -
    ${format_currency(doc.base_total_taxes_and_charges, doc.currency)}
    -
    -
    ` - } + this.$summary_container.on('click', '.email-btn', () => { + this.email_dialog.fields_dict.email_id.set_value(this.customer_email); + this.email_dialog.show(); + }); - get_grand_total_html(doc) { - return `
    -
    -
    - Grand Total -
    -
    -
    -
    ${format_currency(doc.grand_total, doc.currency)}
    -
    -
    ` - } + this.$summary_container.on('click', '.print-btn', () => { + this.print_receipt(); + }); + } - get_item_html(doc, item_data) { - return `
    -
    - ${item_data.qty || 0} -
    -
    -
    - ${item_data.item_name} -
    -
    -
    - ${get_rate_discount_html()} -
    -
    ` + print_receipt() { + const frm = this.events.get_frm(); + frappe.utils.print( + frm.doctype, + frm.docname, + frm.pos_print_format, + frm.doc.letter_head, + frm.doc.language || frappe.boot.lang + ); + } - function get_rate_discount_html() { - if (item_data.rate && item_data.price_list_rate && item_data.rate !== item_data.price_list_rate) { - return `(${item_data.discount_percentage}% off) -
    ${format_currency(item_data.rate, doc.currency)}
    ` - } else { - return `
    ${format_currency(item_data.price_list_rate || item_data.rate, doc.currency)}
    ` - } - } - } + attach_shortcuts() { + const ctrl_label = frappe.utils.is_mac() ? '⌘' : 'Ctrl'; + this.$summary_container.find('.print-btn').attr("title", `${ctrl_label}+P`); + frappe.ui.keys.add_shortcut({ + shortcut: "ctrl+p", + action: () => this.$summary_container.find('.print-btn').click(), + condition: () => this.$component.is(':visible') && this.$summary_container.find('.print-btn').is(":visible"), + description: __("Print Receipt"), + page: cur_page.page.page + }); + this.$summary_container.find('.new-btn').attr("title", `${ctrl_label}+Enter`); + frappe.ui.keys.on("ctrl+enter", () => { + const summary_is_visible = this.$component.is(":visible"); + if (summary_is_visible && this.$summary_container.find('.new-btn').is(":visible")) { + this.$summary_container.find('.new-btn').click(); + } + }); + this.$summary_container.find('.edit-btn').attr("title", `${ctrl_label}+E`); + frappe.ui.keys.add_shortcut({ + shortcut: "ctrl+e", + action: () => this.$summary_container.find('.edit-btn').click(), + condition: () => this.$component.is(':visible') && this.$summary_container.find('.edit-btn').is(":visible"), + description: __("Edit Receipt"), + page: cur_page.page.page + }); + } - get_payment_html(doc, payment) { - return `
    -
    -
    - ${payment.mode_of_payment} -
    -
    -
    -
    ${format_currency(payment.amount, doc.currency)}
    -
    -
    ` - } + send_email() { + const frm = this.events.get_frm(); + const recipients = this.email_dialog.get_values().recipients; + const doc = this.doc || frm.doc; + const print_format = frm.pos_print_format; - bind_events() { - this.$summary_container.on('click', '.return-btn', () => { - this.events.process_return(this.doc.name); - this.toggle_component(false); - this.$component.find('.no-summary-placeholder').removeClass('d-none'); - this.$summary_wrapper.addClass('d-none'); - }); + frappe.call({ + method: "frappe.core.doctype.communication.email.make", + args: { + recipients: recipients, + subject: __(frm.meta.name) + ': ' + doc.name, + doctype: doc.doctype, + name: doc.name, + send_email: 1, + print_format, + sender_full_name: frappe.user.full_name(), + _lang: doc.language + }, + callback: r => { + if (!r.exc) { + frappe.utils.play_sound("email"); + if (r.message["emails_not_sent_to"]) { + frappe.msgprint(__( + "Email not sent to {0} (unsubscribed / disabled)", + [ frappe.utils.escape_html(r.message["emails_not_sent_to"]) ] + )); + } else { + frappe.show_alert({ + message: __('Email sent successfully.'), + indicator: 'green' + }); + } + this.email_dialog.hide(); + } else { + frappe.msgprint(__("There were errors while sending email. Please try again.")); + } + } + }); + } - this.$summary_container.on('click', '.edit-btn', () => { - this.events.edit_order(this.doc.name); - this.toggle_component(false); - this.$component.find('.no-summary-placeholder').removeClass('d-none'); - this.$summary_wrapper.addClass('d-none'); - }); + add_summary_btns(map) { + this.$summary_btns.html(''); + map.forEach(m => { + if (m.condition) { + m.visible_btns.forEach(b => { + const class_name = b.split(' ')[0].toLowerCase(); + this.$summary_btns.append( + `
    ${b}
    ` + ); + }); + } + }); + this.$summary_btns.children().last().removeClass('mr-4'); + } - this.$summary_container.on('click', '.new-btn', () => { - this.events.new_order(); - this.toggle_component(false); - this.$component.find('.no-summary-placeholder').removeClass('d-none'); - this.$summary_wrapper.addClass('d-none'); - }); + toggle_summary_placeholder(show) { + if (show) { + this.$summary_wrapper.css('display', 'none'); + this.$component.find('.no-summary-placeholder').css('display', 'flex'); + } else { + this.$summary_wrapper.css('display', 'flex'); + this.$component.find('.no-summary-placeholder').css('display', 'none'); + } + } - this.$summary_container.on('click', '.email-btn', () => { - this.email_dialog.fields_dict.email_id.set_value(this.customer_email); - this.email_dialog.show(); - }); + get_condition_btn_map(after_submission) { + if (after_submission) + return [{ condition: true, visible_btns: ['Print Receipt', 'Email Receipt', 'New Order'] }]; - this.$summary_container.on('click', '.print-btn', () => { - // this.print_dialog.show(); - const frm = this.events.get_frm(); - frm.doc = this.doc; - frm.print_preview.lang_code = frm.doc.language; - frm.print_preview.printit(true); - }); - } + return [ + { condition: this.doc.docstatus === 0, visible_btns: ['Edit Order', 'Delete Order'] }, + { condition: !this.doc.is_return && this.doc.docstatus === 1, visible_btns: ['Print Receipt', 'Email Receipt', 'Return']}, + { condition: this.doc.is_return && this.doc.docstatus === 1, visible_btns: ['Print Receipt', 'Email Receipt']} + ]; + } - attach_shortcuts() { - frappe.ui.keys.on("ctrl+p", () => { - const print_btn_visible = this.$summary_container.find('.print-btn').is(":visible"); - const summary_visible = this.$component.is(":visible"); - if (!summary_visible || !print_btn_visible) return; + load_summary_of(doc, after_submission=false) { + after_submission ? + this.$component.css('grid-column', 'span 10 / span 10') : + this.$component.css('grid-column', 'span 6 / span 6'); - this.$summary_container.find('.print-btn').click(); - }); - } + this.toggle_summary_placeholder(false); - toggle_component(show) { - show ? - this.$component.removeClass('d-none') : - this.$component.addClass('d-none'); - } + this.doc = doc; - send_email() { - const frm = this.events.get_frm(); - const recipients = this.email_dialog.get_values().recipients; - const doc = this.doc || frm.doc; - const print_format = frm.pos_print_format; + this.attach_document_info(doc); - frappe.call({ - method:"frappe.core.doctype.communication.email.make", - args: { - recipients: recipients, - subject: __(frm.meta.name) + ': ' + doc.name, - doctype: doc.doctype, - name: doc.name, - send_email: 1, - print_format, - sender_full_name: frappe.user.full_name(), - _lang : doc.language - }, - callback: r => { - if(!r.exc) { - frappe.utils.play_sound("email"); - if(r.message["emails_not_sent_to"]) { - frappe.msgprint(__("Email not sent to {0} (unsubscribed / disabled)", - [ frappe.utils.escape_html(r.message["emails_not_sent_to"]) ]) ); - } else { - frappe.show_alert({ - message: __('Email sent successfully.'), - indicator: 'green' - }); - } - this.email_dialog.hide(); - } else { - frappe.msgprint(__("There were errors while sending email. Please try again.")); - } - } - }); - } + this.attach_items_info(doc); - add_summary_btns(map) { - this.$summary_btns.html(''); - map.forEach(m => { - if (m.condition) { - m.visible_btns.forEach(b => { - const class_name = b.split(' ')[0].toLowerCase(); - this.$summary_btns.append( - `
    - ${b} -
    ` - ) - }); - } - }); - this.$summary_btns.children().last().removeClass('mr-4'); - } + this.attach_totals_info(doc); - show_summary_placeholder() { - this.$summary_wrapper.addClass("d-none"); - this.$component.find('.no-summary-placeholder').removeClass('d-none'); - } + this.attach_payments_info(doc); - switch_to_post_submit_summary() { - // switch to full width view - this.$component.removeClass('col-span-6').addClass('col-span-10'); - this.$summary_wrapper.removeClass('w-66').addClass('w-40'); + const condition_btns_map = this.get_condition_btn_map(after_submission); - // switch place holder with summary container - this.$component.find('.no-summary-placeholder').addClass('d-none'); - this.$summary_wrapper.removeClass('d-none'); - } + this.add_summary_btns(condition_btns_map); + } - switch_to_recent_invoice_summary() { - // switch full width view with 60% view - this.$component.removeClass('col-span-10').addClass('col-span-6'); - this.$summary_wrapper.removeClass('w-40').addClass('w-66'); + attach_document_info(doc) { + frappe.db.get_value('Customer', this.doc.customer, 'email_id').then(({ message }) => { + this.customer_email = message.email_id || ''; + const upper_section_dom = this.get_upper_section_html(doc); + this.$upper_section.html(upper_section_dom); + }); + } - // switch place holder with summary container - this.$component.find('.no-summary-placeholder').addClass('d-none'); - this.$summary_wrapper.removeClass('d-none'); - } + attach_items_info(doc) { + this.$items_container.html(''); + doc.items.forEach(item => { + const item_dom = this.get_item_html(doc, item); + this.$items_container.append(item_dom); + this.set_dynamic_rate_header_width(); + }); + } - get_condition_btn_map(after_submission) { - if (after_submission) - return [{ condition: true, visible_btns: ['Print Receipt', 'Email Receipt', 'New Order'] }]; + set_dynamic_rate_header_width() { + const rate_cols = Array.from(this.$items_container.find(".item-rate-disc")); + this.$items_container.find(".item-rate-disc").css("width", ""); + let max_width = rate_cols.reduce((max_width, elm) => { + if ($(elm).width() > max_width) + max_width = $(elm).width(); + return max_width; + }, 0); - return [ - { condition: this.doc.docstatus === 0, visible_btns: ['Edit Order'] }, - { condition: !this.doc.is_return && this.doc.docstatus === 1, visible_btns: ['Print Receipt', 'Email Receipt', 'Return']}, - { condition: this.doc.is_return && this.doc.docstatus === 1, visible_btns: ['Print Receipt', 'Email Receipt']} - ]; - } + max_width += 1; + if (max_width == 1) max_width = ""; - load_summary_of(doc, after_submission=false) { - this.$summary_wrapper.removeClass("d-none"); + this.$items_container.find(".item-rate-disc").css("width", max_width); + } - after_submission ? - this.switch_to_post_submit_summary() : this.switch_to_recent_invoice_summary(); + attach_payments_info(doc) { + this.$payment_container.html(''); + doc.payments.forEach(p => { + if (p.amount) { + const payment_dom = this.get_payment_html(doc, p); + this.$payment_container.append(payment_dom); + } + }); + if (doc.redeem_loyalty_points && doc.loyalty_amount) { + const payment_dom = this.get_payment_html(doc, { + mode_of_payment: 'Loyalty Points', + amount: doc.loyalty_amount, + }); + this.$payment_container.append(payment_dom); + } + } - this.doc = doc; + attach_totals_info(doc) { + this.$totals_container.html(''); - this.attach_basic_info(doc); + const net_total_dom = this.get_net_total_html(doc); + const taxes_dom = this.get_taxes_html(doc); + const discount_dom = this.get_discount_html(doc); + const grand_total_dom = this.get_grand_total_html(doc); + this.$totals_container.append(net_total_dom); + this.$totals_container.append(taxes_dom); + this.$totals_container.append(discount_dom); + this.$totals_container.append(grand_total_dom); + } - this.attach_items_info(doc); - - this.attach_totals_info(doc); - - this.attach_payments_info(doc); - - const condition_btns_map = this.get_condition_btn_map(after_submission); - - this.add_summary_btns(condition_btns_map); - } - - attach_basic_info(doc) { - frappe.db.get_value('Customer', this.doc.customer, 'email_id').then(({ message }) => { - this.customer_email = message.email_id || ''; - const upper_section_dom = this.get_upper_section_html(doc); - this.$upper_section.html(upper_section_dom); - }); - } - - attach_items_info(doc) { - this.$items_summary_container.html(''); - doc.items.forEach(item => { - const item_dom = this.get_item_html(doc, item); - this.$items_summary_container.append(item_dom); - }); - } - - attach_payments_info(doc) { - this.$payment_summary_container.html(''); - doc.payments.forEach(p => { - if (p.amount) { - const payment_dom = this.get_payment_html(doc, p); - this.$payment_summary_container.append(payment_dom); - } - }); - if (doc.redeem_loyalty_points && doc.loyalty_amount) { - const payment_dom = this.get_payment_html(doc, { - mode_of_payment: 'Loyalty Points', - amount: doc.loyalty_amount, - }); - this.$payment_summary_container.append(payment_dom); - } - } - - attach_totals_info(doc) { - this.$totals_summary_container.html(''); - - const discount_dom = this.get_discount_html(doc); - const net_total_dom = this.get_net_total_html(doc); - const taxes_dom = this.get_taxes_html(doc); - const grand_total_dom = this.get_grand_total_html(doc); - this.$totals_summary_container.append(discount_dom); - this.$totals_summary_container.append(net_total_dom); - this.$totals_summary_container.append(taxes_dom); - this.$totals_summary_container.append(grand_total_dom); - } - -} \ No newline at end of file + toggle_component(show) { + show ? this.$component.css('display', 'flex') : this.$component.css('display', 'none'); + } +}; \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 7f0cabed8b8..22a279d463f 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -1,5 +1,4 @@ -{% include "erpnext/selling/page/point_of_sale/pos_number_pad.js" %} - +/* eslint-disable no-unused-vars */ erpnext.PointOfSale.Payment = class { constructor({ events, wrapper }) { this.wrapper = wrapper; @@ -9,47 +8,37 @@ erpnext.PointOfSale.Payment = class { } init_component() { - this.prepare_dom(); - this.initialize_numpad(); + this.prepare_dom(); + this.initialize_numpad(); this.bind_events(); this.attach_shortcuts(); - + } prepare_dom() { this.wrapper.append( - `
    -
    -
    - PAYMENT METHOD + `
    + +
    +
    +
    + +
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - Complete Order -
    -
    -
    -
    -
    -
    ` - ) - this.$component = this.wrapper.find('.payment-section'); +
    +
    +
    +
    +
    +
    Complete Order
    +
    ` + ); + this.$component = this.wrapper.find('.payment-container'); this.$payment_modes = this.$component.find('.payment-modes'); - this.$totals_remarks = this.$component.find('.totals-remarks'); + this.$totals_section = this.$component.find('.totals-section'); this.$totals = this.$component.find('.totals'); - this.$remarks = this.$component.find('.remarks'); this.$numpad = this.$component.find('.number-pad'); - this.$invoice_details_section = this.$component.find('.invoice-details-section'); + this.$invoice_fields_section = this.$component.find('.fields-section'); } make_invoice_fields_control() { @@ -57,13 +46,8 @@ erpnext.PointOfSale.Payment = class { const fields = doc.invoice_fields; if (!fields.length) return; - this.$invoice_details_section.html( - `
    - ADDITIONAL INFORMATION -
    -
    ` - ); - this.$invoice_fields = this.$invoice_details_section.find('.invoice-fields'); + this.$invoice_fields = this.$invoice_fields_section.find('.invoice-fields'); + this.$invoice_fields.html(''); const frm = this.events.get_frm(); fields.forEach(df => { @@ -71,8 +55,10 @@ erpnext.PointOfSale.Payment = class { `
    ` ); let df_events = { - onchange: function() { frm.set_value(this.df.fieldname, this.value); } - } + onchange: function() { + frm.set_value(this.df.fieldname, this.value); + } + }; if (df.fieldtype == "Button") { df_events = { click: function() { @@ -80,11 +66,11 @@ erpnext.PointOfSale.Payment = class { frm.script_manager.trigger(df.fieldname, frm.doc.doctype, frm.doc.docname); } } - } + }; } this[`${df.fieldname}_field`] = frappe.ui.form.make_control({ - df: { + df: { ...df, ...df_events }, @@ -92,7 +78,7 @@ erpnext.PointOfSale.Payment = class { render_input: true, }); this[`${df.fieldname}_field`].set_value(frm.doc[df.fieldname]); - }) + }); }); } @@ -112,13 +98,12 @@ erpnext.PointOfSale.Payment = class { [ 7, 8, 9 ], [ '.', 0, 'Delete' ] ], - }) + }); this.numpad_value = ''; } on_numpad_clicked($btn) { - const me = this; const button_value = $btn.attr('data-button-value'); highlight_numpad_btn($btn); @@ -127,9 +112,9 @@ erpnext.PointOfSale.Payment = class { this.selected_mode.set_value(this.numpad_value); function highlight_numpad_btn($btn) { - $btn.addClass('shadow-inner bg-selected'); + $btn.addClass('shadow-base-inner bg-selected'); setTimeout(() => { - $btn.removeClass('shadow-inner bg-selected'); + $btn.removeClass('shadow-base-inner bg-selected'); }, 100); } } @@ -142,13 +127,16 @@ erpnext.PointOfSale.Payment = class { // if clicked element doesn't have .mode-of-payment class then return if (!$(e.target).is(mode_clicked)) return; + const scrollLeft = mode_clicked.offset().left - me.$payment_modes.offset().left + me.$payment_modes.scrollLeft(); + me.$payment_modes.animate({ scrollLeft }); + const mode = mode_clicked.attr('data-mode'); // hide all control fields and shortcuts - $(`.mode-of-payment-control`).addClass('d-none'); - $(`.cash-shortcuts`).addClass('d-none'); - me.$payment_modes.find(`.pay-amount`).removeClass('d-none'); - me.$payment_modes.find(`.loyalty-amount-name`).addClass('d-none'); + $(`.mode-of-payment-control`).css('display', 'none'); + $(`.cash-shortcuts`).css('display', 'none'); + me.$payment_modes.find(`.pay-amount`).css('display', 'inline'); + me.$payment_modes.find(`.loyalty-amount-name`).css('display', 'none'); // remove highlight from all mode-of-payments $('.mode-of-payment').removeClass('border-primary'); @@ -157,55 +145,60 @@ erpnext.PointOfSale.Payment = class { // clicked one is selected then unselect it mode_clicked.removeClass('border-primary'); me.selected_mode = ''; - me.toggle_numpad(false); } else { // clicked one is not selected then select it mode_clicked.addClass('border-primary'); - mode_clicked.find('.mode-of-payment-control').removeClass('d-none'); - mode_clicked.find('.cash-shortcuts').removeClass('d-none'); - me.$payment_modes.find(`.${mode}-amount`).addClass('d-none'); - me.$payment_modes.find(`.${mode}-name`).removeClass('d-none'); - me.toggle_numpad(true); + mode_clicked.find('.mode-of-payment-control').css('display', 'flex'); + mode_clicked.find('.cash-shortcuts').css('display', 'grid'); + me.$payment_modes.find(`.${mode}-amount`).css('display', 'none'); + me.$payment_modes.find(`.${mode}-name`).css('display', 'inline'); me.selected_mode = me[`${mode}_control`]; - const doc = me.events.get_frm().doc; - me.selected_mode?.$input?.get(0).focus(); - !me.selected_mode?.get_value() ? me.selected_mode?.set_value(doc.grand_total - doc.paid_amount) : ''; + me.selected_mode && me.selected_mode.$input.get(0).focus(); + me.auto_set_remaining_amount(); } - }) + }); - this.$payment_modes.on('click', '.shortcut', function(e) { + frappe.ui.form.on('POS Invoice', 'contact_mobile', (frm) => { + const contact = frm.doc.contact_mobile; + const request_button = $(this.request_for_payment_field.$input[0]); + if (contact) { + request_button.removeClass('btn-default').addClass('btn-primary'); + } else { + request_button.removeClass('btn-primary').addClass('btn-default'); + } + }); + + this.setup_listener_for_payments(); + + this.$payment_modes.on('click', '.shortcut', () => { const value = $(this).attr('data-value'); me.selected_mode.set_value(value); - }) + }); - // this.$totals_remarks.on('click', '.remarks', () => { - // this.toggle_remarks_control(); - // }) - - this.$component.on('click', '.submit-order', () => { + this.$component.on('click', '.submit-order-btn', () => { const doc = this.events.get_frm().doc; const paid_amount = doc.paid_amount; const items = doc.items; if (paid_amount == 0 || !items.length) { - const message = items.length ? __("You cannot submit the order without payment.") : __("You cannot submit empty order.") + const message = items.length ? __("You cannot submit the order without payment.") : __("You cannot submit empty order."); frappe.show_alert({ message, indicator: "orange" }); frappe.utils.play_sound("error"); return; } this.events.submit_invoice(); - }) + }); frappe.ui.form.on('POS Invoice', 'paid_amount', (frm) => { this.update_totals_section(frm.doc); // need to re calculate cash shortcuts after discount is applied - const is_cash_shortcuts_invisible = this.$payment_modes.find('.cash-shortcuts').hasClass('d-none'); + const is_cash_shortcuts_invisible = !this.$payment_modes.find('.cash-shortcuts').is(':visible'); this.attach_cash_shortcuts(frm.doc); - !is_cash_shortcuts_invisible && this.$payment_modes.find('.cash-shortcuts').removeClass('d-none'); - }) + !is_cash_shortcuts_invisible && this.$payment_modes.find('.cash-shortcuts').css('display', 'grid'); + }); frappe.ui.form.on('POS Invoice', 'loyalty_amount', (frm) => { const formatted_currency = format_currency(frm.doc.loyalty_amount, frm.doc.currency); @@ -215,63 +208,88 @@ erpnext.PointOfSale.Payment = class { frappe.ui.form.on("Sales Invoice Payment", "amount", (frm, cdt, cdn) => { // for setting correct amount after loyalty points are redeemed const default_mop = locals[cdt][cdn]; - const mode = default_mop.mode_of_payment.replace(' ', '_').toLowerCase(); + const mode = default_mop.mode_of_payment.replace(/ +/g, "_").toLowerCase(); if (this[`${mode}_control`] && this[`${mode}_control`].get_value() != default_mop.amount) { this[`${mode}_control`].set_value(default_mop.amount); } }); + } - this.$component.on('click', '.invoice-details-section', function(e) { - if ($(e.target).closest('.invoice-fields').length) return; + setup_listener_for_payments() { + frappe.realtime.on("process_phone_payment", (data) => { + const doc = this.events.get_frm().doc; + const { response, amount, success, failure_message } = data; + let message, title; - me.$payment_modes.addClass('d-none'); - me.$invoice_fields.toggleClass("d-none"); - me.toggle_numpad(false); + if (success) { + title = __("Payment Received"); + const grand_total = cint(frappe.sys_defaults.disable_rounded_total) ? doc.grand_total : doc.rounded_total; + if (amount >= grand_total) { + frappe.dom.unfreeze(); + message = __("Payment of {0} received successfully.", [format_currency(amount, doc.currency, 0)]); + this.events.submit_invoice(); + cur_frm.reload_doc(); + + } else { + message = __("Payment of {0} received successfully. Waiting for other requests to complete...", [format_currency(amount, doc.currency, 0)]); + } + } else if (failure_message) { + message = failure_message; + title = __("Payment Failed"); + } + + frappe.msgprint({ "message": message, "title": title }); }); - this.$component.on('click', '.payment-section', () => { - this.$invoice_fields.addClass("d-none"); - this.$payment_modes.toggleClass('d-none'); - this.toggle_numpad(true); - }) + } + + auto_set_remaining_amount() { + const doc = this.events.get_frm().doc; + const grand_total = cint(frappe.sys_defaults.disable_rounded_total) ? doc.grand_total : doc.rounded_total; + const remaining_amount = grand_total - doc.paid_amount; + const current_value = this.selected_mode ? this.selected_mode.get_value() : undefined; + if (!current_value && remaining_amount > 0 && this.selected_mode) { + this.selected_mode.set_value(remaining_amount); + } } attach_shortcuts() { + const ctrl_label = frappe.utils.is_mac() ? '⌘' : 'Ctrl'; + this.$component.find('.submit-order-btn').attr("title", `${ctrl_label}+Enter`); frappe.ui.keys.on("ctrl+enter", () => { const payment_is_visible = this.$component.is(":visible"); const active_mode = this.$payment_modes.find(".border-primary"); if (payment_is_visible && active_mode.length) { - this.$component.find('.submit-order').click(); + this.$component.find('.submit-order-btn').click(); } }); - frappe.ui.keys.on("tab", () => { - const payment_is_visible = this.$component.is(":visible"); - const mode_of_payments = Array.from(this.$payment_modes.find(".mode-of-payment")).map(m => $(m).attr("data-mode")); - let active_mode = this.$payment_modes.find(".border-primary"); - active_mode = active_mode.length ? active_mode.attr("data-mode") : undefined; + frappe.ui.keys.add_shortcut({ + shortcut: "tab", + action: () => { + const payment_is_visible = this.$component.is(":visible"); + let active_mode = this.$payment_modes.find(".border-primary"); + active_mode = active_mode.length ? active_mode.attr("data-mode") : undefined; - if (!active_mode) return; + if (!active_mode) return; - const mode_index = mode_of_payments.indexOf(active_mode); - const next_mode_index = (mode_index + 1) % mode_of_payments.length; - const next_mode_to_be_clicked = this.$payment_modes.find(`.mode-of-payment[data-mode="${mode_of_payments[next_mode_index]}"]`); + const mode_of_payments = Array.from(this.$payment_modes.find(".mode-of-payment")).map(m => $(m).attr("data-mode")); + const mode_index = mode_of_payments.indexOf(active_mode); + const next_mode_index = (mode_index + 1) % mode_of_payments.length; + const next_mode_to_be_clicked = this.$payment_modes.find(`.mode-of-payment[data-mode="${mode_of_payments[next_mode_index]}"]`); - if (payment_is_visible && mode_index != next_mode_index) { - next_mode_to_be_clicked.click(); - } + if (payment_is_visible && mode_index != next_mode_index) { + next_mode_to_be_clicked.click(); + } + }, + condition: () => this.$component.is(':visible') && this.$payment_modes.find(".border-primary").length, + description: __("Switch Between Payment Modes"), + ignore_inputs: true, + page: cur_page.page.page }); } - toggle_numpad(show) { - if (show) { - this.$numpad.removeClass('d-none'); - this.$remarks.addClass('d-none'); - this.$totals_remarks.addClass('w-60 justify-center').removeClass('justify-end w-full'); - } else { - this.$numpad.addClass('d-none'); - this.$remarks.removeClass('d-none'); - this.$totals_remarks.removeClass('w-60 justify-center').addClass('justify-end w-full'); - } + toggle_numpad() { + // pass } render_payment_section() { @@ -303,7 +321,7 @@ erpnext.PointOfSale.Payment = class { fieldtype: 'Data', onchange: function() {} }, - parent: this.$totals_remarks.find(`.remarks`), + parent: this.$totals_section.find(`.remarks`), render_input: true, }); this[`remark_control`].set_value(''); @@ -315,40 +333,39 @@ erpnext.PointOfSale.Payment = class { const payments = doc.payments; const currency = doc.currency; - this.$payment_modes.html( - `${ - payments.map((p, i) => { - const mode = p.mode_of_payment.replace(' ', '_').toLowerCase(); + this.$payment_modes.html(`${ + payments.map((p, i) => { + const mode = p.mode_of_payment.replace(/ +/g, "_").toLowerCase(); const payment_type = p.type; const margin = i % 2 === 0 ? 'pr-2' : 'pl-2'; const amount = p.amount > 0 ? format_currency(p.amount, currency) : ''; - return ( - `
    -
    + return (` +
    +
    ${p.mode_of_payment} -
    ${amount}
    -
    +
    ${amount}
    +
    -
    ` - ) - }).join('') - }` - ) +
    + `); + }).join('') + }`); payments.forEach(p => { - const mode = p.mode_of_payment.replace(' ', '_').toLowerCase(); + const mode = p.mode_of_payment.replace(/ +/g, "_").toLowerCase(); const me = this; this[`${mode}_control`] = frappe.ui.form.make_control({ df: { - label: __(`${p.mode_of_payment}`), + label: p.mode_of_payment, fieldtype: 'Currency', - placeholder: __(`Enter ${p.mode_of_payment} amount.`), + placeholder: __('Enter {0} amount.', [p.mode_of_payment]), onchange: function() { - if (this.value || this.value == 0) { - frappe.model.set_value(p.doctype, p.name, 'amount', flt(this.value)) - .then(() => me.update_totals_section()); + const current_value = frappe.model.get_value(p.doctype, p.name, 'amount'); + if (current_value != this.value) { + frappe.model + .set_value(p.doctype, p.name, 'amount', flt(this.value)) + .then(() => me.update_totals_section()) const formatted_currency = format_currency(this.value, currency); me.$payment_modes.find(`.${mode}-amount`).html(formatted_currency); @@ -366,31 +383,26 @@ erpnext.PointOfSale.Payment = class { this.$payment_modes.find(`.${mode}.mode-of-payment-control`).parent().click(); }, 500); } - }) + }); this.render_loyalty_points_payment_mode(); - + this.attach_cash_shortcuts(doc); } attach_cash_shortcuts(doc) { - const grand_total = doc.grand_total; + const grand_total = cint(frappe.sys_defaults.disable_rounded_total) ? doc.grand_total : doc.rounded_total; const currency = doc.currency; const shortcuts = this.get_cash_shortcuts(flt(grand_total)); this.$payment_modes.find('.cash-shortcuts').remove(); - this.$payment_modes.find('[data-payment-type="Cash"]').find('.mode-of-payment-control').after( - `
    - ${ - shortcuts.map(s => { - return `
    - ${format_currency(s, currency)} -
    ` - }).join('') - } -
    ` - ) + let shortcuts_html = shortcuts.map(s => { + return `
    ${format_currency(s, currency, 0)}
    `; + }).join(''); + + this.$payment_modes.find('[data-payment-type="Cash"]').find('.mode-of-payment-control') + .after(`
    ${shortcuts_html}
    `); } get_cash_shortcuts(grand_total) { @@ -402,13 +414,13 @@ erpnext.PointOfSale.Payment = class { const get_nearest = (amount, x) => { let nearest_x = Math.ceil((amount / x)) * x; return nearest_x === amount ? nearest_x + x : nearest_x; - } + }; return steps.reduce((finalArr, x) => { let nearest_x = get_nearest(grand_total, x); nearest_x = finalArr.indexOf(nearest_x) != -1 ? nearest_x + x : nearest_x; return [...finalArr, nearest_x]; - }, []); + }, []); } render_loyalty_points_payment_mode() { @@ -417,38 +429,37 @@ erpnext.PointOfSale.Payment = class { const { loyalty_program, loyalty_points, conversion_factor } = this.events.get_customer_details(); this.$payment_modes.find(`.mode-of-payment[data-mode="loyalty-amount"]`).parent().remove(); - + if (!loyalty_program) return; let description, read_only, max_redeemable_amount; if (!loyalty_points) { - description = __(`You don't have enough points to redeem.`); + description = __("You don't have enough points to redeem."); read_only = true; } else { - max_redeemable_amount = flt(flt(loyalty_points) * flt(conversion_factor), precision("loyalty_amount", doc)) - description = __(`You can redeem upto ${format_currency(max_redeemable_amount)}.`); + max_redeemable_amount = flt(flt(loyalty_points) * flt(conversion_factor), precision("loyalty_amount", doc)); + description = __("You can redeem upto {0}.", [format_currency(max_redeemable_amount)]); read_only = false; } const margin = this.$payment_modes.children().length % 2 === 0 ? 'pr-2' : 'pl-2'; const amount = doc.loyalty_amount > 0 ? format_currency(doc.loyalty_amount, doc.currency) : ''; this.$payment_modes.append( - `
    -
    + `
    +
    Redeem Loyalty Points -
    ${amount}
    -
    ${loyalty_program}
    -
    +
    ${amount}
    +
    ${loyalty_program}
    +
    ` - ) + ); this['loyalty-amount_control'] = frappe.ui.form.make_control({ df: { - label: __('Redeem Loyalty Points'), + label: __("Redeem Loyalty Points"), fieldtype: 'Currency', - placeholder: __(`Enter amount to be redeemed.`), + placeholder: __("Enter amount to be redeemed."), options: 'company:currency', read_only, onchange: async function() { @@ -456,7 +467,7 @@ erpnext.PointOfSale.Payment = class { if (this.value > max_redeemable_amount) { frappe.show_alert({ - message: __(`You cannot redeem more than ${format_currency(max_redeemable_amount)}.`), + message: __("You cannot redeem more than {0}.", [format_currency(max_redeemable_amount)]), indicator: "red" }); frappe.utils.play_sound("submit"); @@ -484,30 +495,37 @@ erpnext.PointOfSale.Payment = class { `
    + Add Payment Method
    ` - ) + ); } update_totals_section(doc) { if (!doc) doc = this.events.get_frm().doc; const paid_amount = doc.paid_amount; - const remaining = doc.grand_total - doc.paid_amount; + const grand_total = cint(frappe.sys_defaults.disable_rounded_total) ? doc.grand_total : doc.rounded_total; + const remaining = grand_total - doc.paid_amount; const change = doc.change_amount || remaining <= 0 ? -1 * remaining : undefined; - const currency = doc.currency + const currency = doc.currency; const label = change ? __('Change') : __('To Be Paid'); this.$totals.html( - `
    -
    Paid Amount
    -
    ${format_currency(paid_amount, currency)}
    + `
    +
    Grand Total
    +
    ${format_currency(grand_total, currency)}
    -
    -
    ${label}
    -
    ${format_currency(change || remaining, currency)}
    +
    +
    +
    Paid Amount
    +
    ${format_currency(paid_amount, currency)}
    +
    +
    +
    +
    ${label}
    +
    ${format_currency(change || remaining, currency)}
    ` - ) + ); } toggle_component(show) { - show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none'); - } - } \ No newline at end of file + show ? this.$component.css('display', 'flex') : this.$component.css('display', 'none'); + } +}; \ No newline at end of file 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 c716aa96e0a..84732760019 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 @@ -10,8 +10,8 @@ from frappe.utils.nestedset import get_descendants_of def execute(filters=None): filters = frappe._dict(filters or {}) if filters.from_date > filters.to_date: - frappe.throw(_('From Date cannot be greater than To Date')) - + frappe.throw(_("From Date cannot be greater than To Date")) + columns = get_columns(filters) data = get_data(filters) @@ -148,14 +148,16 @@ def get_data(filters): company_list.append(filters.get("company")) customer_details = get_customer_details() + item_details = get_item_details() sales_order_records = get_sales_order_details(company_list, filters) for record in sales_order_records: customer_record = customer_details.get(record.customer) + item_record = item_details.get(record.item_code) row = { "item_code": record.item_code, - "item_name": record.item_name, - "item_group": record.item_group, + "item_name": item_record.item_name, + "item_group": item_record.item_group, "description": record.description, "quantity": record.qty, "uom": record.uom, @@ -196,8 +198,8 @@ def get_conditions(filters): return conditions def get_customer_details(): - details = frappe.get_all('Customer', - fields=['name', 'customer_name', "customer_group"]) + details = frappe.get_all("Customer", + fields=["name", "customer_name", "customer_group"]) customer_details = {} for d in details: customer_details.setdefault(d.name, frappe._dict({ @@ -206,15 +208,25 @@ def get_customer_details(): })) return customer_details +def get_item_details(): + details = frappe.db.get_all("Item", + fields=["item_code", "item_name", "item_group"]) + item_details = {} + for d in details: + item_details.setdefault(d.item_code, frappe._dict({ + "item_name": d.item_name, + "item_group": d.item_group + })) + return 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.item_name, so_item.item_group, - 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_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 diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.js b/erpnext/selling/report/sales_analytics/sales_analytics.js index 0e565a3fb6f..9089b53fb04 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.js +++ b/erpnext/selling/report/sales_analytics/sales_analytics.js @@ -74,67 +74,71 @@ frappe.query_reports["Sales Analytics"] = { return Object.assign(options, { checkboxColumn: true, events: { - onCheckRow: function(data) { + onCheckRow: function (data) { + if (!data) return; + const data_doctype = $( + data[2].html + )[0].attributes.getNamedItem("data-doctype").value; + const tree_type = frappe.query_report.filters[0].value; + if (data_doctype != tree_type) return; + row_name = data[2].content; length = data.length; - var tree_type = frappe.query_report.filters[0].value; - - if(tree_type == "Customer") { - row_values = data.slice(4,length-1).map(function (column) { - return column.content; - }) + if (tree_type == "Customer") { + row_values = data + .slice(4, length - 1) + .map(function (column) { + return column.content; + }); } else if (tree_type == "Item") { - row_values = data.slice(5,length-1).map(function (column) { - return column.content; - }) - } - else { - row_values = data.slice(3,length-1).map(function (column) { - return column.content; - }) + row_values = data + .slice(5, length - 1) + .map(function (column) { + return column.content; + }); + } else { + row_values = data + .slice(3, length - 1) + .map(function (column) { + return column.content; + }); } entry = { - 'name':row_name, - 'values':row_values - } + name: row_name, + values: row_values, + }; let raw_data = frappe.query_report.chart.data; let new_datasets = raw_data.datasets; - var found = false; - - for(var i=0; i < new_datasets.length;i++){ - if(new_datasets[i].name == row_name){ - found = true; - new_datasets.splice(i,1); - break; + let element_found = new_datasets.some((element, index, array)=>{ + if(element.name == row_name){ + array.splice(index, 1) + return true } - } + return false + }) - if(!found){ + if (!element_found) { new_datasets.push(entry); } let new_data = { labels: raw_data.labels, - datasets: new_datasets - } - - setTimeout(() => { - frappe.query_report.chart.update(new_data) - }, 500) - - - setTimeout(() => { - frappe.query_report.chart.draw(true); - }, 1000) + datasets: new_datasets, + }; + chart_options = { + data: new_data, + type: "line", + }; + frappe.query_report.render_chart(chart_options); frappe.query_report.raw_chart_data = new_data; }, - } - }) + }, + }); }, } diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js index 002cfe41e18..04285735abd 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -42,16 +42,6 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({ me.frm.set_query('customer_address', erpnext.queries.address_query); me.frm.set_query('shipping_address_name', erpnext.queries.address_query); - if(this.frm.fields_dict.taxes_and_charges) { - this.frm.set_query("taxes_and_charges", function() { - return { - filters: [ - ['Sales Taxes and Charges Template', 'company', '=', me.frm.doc.company], - ['Sales Taxes and Charges Template', 'docstatus', '!=', 2] - ] - } - }); - } if(this.frm.fields_dict.selling_price_list) { this.frm.set_query("selling_price_list", function() { @@ -137,20 +127,6 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({ this.set_dynamic_labels(); }, - price_list_rate: function(doc, cdt, cdn) { - var item = frappe.get_doc(cdt, cdn); - frappe.model.round_floats_in(item, ["price_list_rate", "discount_percentage"]); - - // check if child doctype is Sales Order Item/Qutation Item and calculate the rate - if(in_list(["Quotation Item", "Sales Order Item", "Delivery Note Item", "Sales Invoice Item", "POS Invoice Item"]), cdt) - this.apply_pricing_rule_on_item(item); - else - item.rate = flt(item.price_list_rate * (1 - item.discount_percentage / 100.0), - precision("rate", item)); - - this.calculate_taxes_and_totals(); - }, - discount_percentage: function(doc, cdt, cdn) { var item = frappe.get_doc(cdt, cdn); item.discount_amount = 0.0; @@ -363,26 +339,6 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({ refresh_field('product_bundle_help'); }, - margin_rate_or_amount: function(doc, cdt, cdn) { - // calculated the revised total margin and rate on margin rate changes - var item = locals[cdt][cdn]; - this.apply_pricing_rule_on_item(item) - this.calculate_taxes_and_totals(); - cur_frm.refresh_fields(); - }, - - margin_type: function(doc, cdt, cdn){ - // calculate the revised total margin and rate on margin type changes - var item = locals[cdt][cdn]; - if(!item.margin_type) { - frappe.model.set_value(cdt, cdn, "margin_rate_or_amount", 0); - } else { - this.apply_pricing_rule_on_item(item, doc,cdt, cdn) - this.calculate_taxes_and_totals(); - cur_frm.refresh_fields(); - } - }, - company_address: function() { var me = this; if(this.frm.doc.company_address) { @@ -409,6 +365,10 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({ } }, + batch_no: function(doc, cdt, cdn) { + this._super(doc, cdt, cdn); + }, + qty: function(doc, cdt, cdn) { this._super(doc, cdt, cdn); @@ -479,7 +439,7 @@ frappe.ui.form.on(cur_frm.doctype,"project", function(frm) { $.each(frm.doc["items"] || [], function(i, row) { if(r.message) { frappe.model.set_value(row.doctype, row.name, "cost_center", r.message); - frappe.msgprint(__("Cost Center For Item with Item Code '"+row.item_name+"' has been Changed to "+ r.message)); + frappe.msgprint(__("Cost Center For Item with Item Code {0} has been Changed to {1}", [row.item_name, r.message])); } }) } diff --git a/erpnext/selling/workspace/retail/retail.json b/erpnext/selling/workspace/retail/retail.json new file mode 100644 index 00000000000..e20f8347c25 --- /dev/null +++ b/erpnext/selling/workspace/retail/retail.json @@ -0,0 +1,114 @@ +{ + "category": "Domains", + "charts": [], + "creation": "2020-03-02 17:18:32.505616", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "retail", + "idx": 0, + "is_standard": 1, + "label": "Retail", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings & Configurations", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Point-of-Sale Profile", + "link_to": "POS Profile", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "POS Settings", + "link_to": "POS Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Loyalty Program", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loyalty Program", + "link_to": "Loyalty Program", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Loyalty Point Entry", + "link_to": "Loyalty Point Entry", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Opening & Closing", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "POS Opening Entry", + "link_to": "POS Opening Entry", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "POS Closing Entry", + "link_to": "POS Closing Entry", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:36.758038", + "modified_by": "Administrator", + "module": "Selling", + "name": "Retail", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "restrict_to_domain": "Retail", + "shortcuts": [ + { + "doc_view": "", + "label": "Point Of Sale", + "link_to": "point-of-sale", + "type": "Page" + } + ] +} \ No newline at end of file diff --git a/erpnext/selling/workspace/selling/selling.json b/erpnext/selling/workspace/selling/selling.json new file mode 100644 index 00000000000..879034a0dfc --- /dev/null +++ b/erpnext/selling/workspace/selling/selling.json @@ -0,0 +1,563 @@ +{ + "category": "Modules", + "charts": [ + { + "chart_name": "Sales Order Trends", + "label": "Sales Order Trends" + } + ], + "charts_label": "Selling ", + "creation": "2020-01-28 11:49:12.092882", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 1, + "icon": "sell", + "idx": 0, + "is_standard": 1, + "label": "Selling", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Selling", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customer", + "link_to": "Customer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Customer", + "hidden": 0, + "is_query_report": 0, + "label": "Quotation", + "link_to": "Quotation", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Customer", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Order", + "link_to": "Sales Order", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Customer", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Invoice", + "link_to": "Sales Invoice", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Customer", + "hidden": 0, + "is_query_report": 0, + "label": "Blanket Order", + "link_to": "Blanket Order", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Partner", + "link_to": "Sales Partner", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Item, Customer", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Person", + "link_to": "Sales Person", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Items and Pricing", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item", + "link_to": "Item", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Price List", + "hidden": 0, + "is_query_report": 0, + "label": "Item Price", + "link_to": "Item Price", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Price List", + "link_to": "Price List", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Group", + "link_to": "Item Group", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Product Bundle", + "link_to": "Product Bundle", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Promotional Scheme", + "link_to": "Promotional Scheme", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Pricing Rule", + "link_to": "Pricing Rule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shipping Rule", + "link_to": "Shipping Rule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Coupon Code", + "link_to": "Coupon Code", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Selling Settings", + "link_to": "Selling Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Terms and Conditions Template", + "link_to": "Terms and Conditions", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Taxes and Charges Template", + "link_to": "Sales Taxes and Charges Template", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lead Source", + "link_to": "Lead Source", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customer Group", + "link_to": "Customer Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Contact", + "link_to": "Contact", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Address", + "link_to": "Address", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Territory", + "link_to": "Territory", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Campaign", + "link_to": "Campaign", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Key Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Analytics", + "link_to": "Sales Analytics", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Order Analysis", + "link_to": "Sales Order Analysis", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Funnel", + "link_to": "sales-funnel", + "link_type": "Page", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Order Trends", + "link_to": "Sales Order Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Quotation", + "hidden": 0, + "is_query_report": 1, + "label": "Quotation Trends", + "link_to": "Quotation Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Customer", + "hidden": 0, + "is_query_report": 1, + "label": "Customer Acquisition and Loyalty", + "link_to": "Customer Acquisition and Loyalty", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Inactive Customers", + "link_to": "Inactive Customers", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Person-wise Transaction Summary", + "link_to": "Sales Person-wise Transaction Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Item-wise Sales History", + "link_to": "Item-wise Sales History", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Other Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Lead", + "hidden": 0, + "is_query_report": 1, + "label": "Lead Details", + "link_to": "Lead Details", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Address", + "hidden": 0, + "is_query_report": 1, + "label": "Customer Addresses And Contacts", + "link_to": "Address And Contacts", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Available Stock for Packing Items", + "link_to": "Available Stock for Packing Items", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Pending SO Items For Purchase Request", + "link_to": "Pending SO Items For Purchase Request", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Delivery Note", + "hidden": 0, + "is_query_report": 1, + "label": "Delivery Note Trends", + "link_to": "Delivery Note Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Invoice", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Invoice Trends", + "link_to": "Sales Invoice Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Customer", + "hidden": 0, + "is_query_report": 1, + "label": "Customer Credit Balance", + "link_to": "Customer Credit Balance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Customer", + "hidden": 0, + "is_query_report": 1, + "label": "Customers Without Any Sales Transactions", + "link_to": "Customers Without Any Sales Transactions", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Customer", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Partners Commission", + "link_to": "Sales Partners Commission", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Territory Target Variance Based On Item Group", + "link_to": "Territory Target Variance Based On Item Group", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Person Target Variance Based On Item Group", + "link_to": "Sales Person Target Variance Based On Item Group", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Partner Target Variance Based On Item Group", + "link_to": "Sales Partner Target Variance based on Item Group", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:35.971277", + "modified_by": "Administrator", + "module": "Selling", + "name": "Selling", + "onboarding": "Selling", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "color": "Grey", + "format": "{} Available", + "label": "Item", + "link_to": "Item", + "stats_filter": "{\n \"disabled\":0\n}", + "type": "DocType" + }, + { + "color": "Yellow", + "format": "{} To Deliver", + "label": "Sales Order", + "link_to": "Sales Order", + "stats_filter": "{\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%'],\n \"status\":[\"in\", [\"To Deliver\", \"To Deliver and Bill\"]]\n}", + "type": "DocType" + }, + { + "color": "Grey", + "format": "{} Open", + "label": "Sales Analytics", + "link_to": "Sales Analytics", + "stats_filter": "{ \"Status\": \"Open\" }", + "type": "Report" + }, + { + "label": "Sales Order Analysis", + "link_to": "Sales Order Analysis", + "type": "Report" + }, + { + "label": "Dashboard", + "link_to": "Selling", + "type": "Dashboard" + } + ], + "shortcuts_label": "Quick Access" +} \ No newline at end of file diff --git a/erpnext/setup/desk_page/home/home.json b/erpnext/setup/desk_page/home/home.json deleted file mode 100644 index 63cd5c5ceca..00000000000 --- a/erpnext/setup/desk_page/home/home.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Healthcare", - "links": "[\n {\n \"label\": \"Patient\",\n \"name\": \"Patient\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Diagnosis\",\n \"name\": \"Diagnosis\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Agriculture", - "links": "[\n {\n \"label\": \"Crop\",\n \"name\": \"Crop\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Crop Cycle\",\n \"name\": \"Crop Cycle\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Location\",\n \"name\": \"Location\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Fertilizer\",\n \"name\": \"Fertilizer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Education", - "links": "[\n {\n \"label\": \"Student\",\n \"name\": \"Student\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Course\",\n \"name\": \"Course\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Instructor\",\n \"name\": \"Instructor\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Room\",\n \"name\": \"Room\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Non Profit", - "links": "[\n {\n \"description\": \"Member information.\",\n \"label\": \"Member\",\n \"name\": \"Member\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Volunteer information.\",\n \"label\": \"Volunteer\",\n \"name\": \"Volunteer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Chapter information.\",\n \"label\": \"Chapter\",\n \"name\": \"Chapter\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Donor information.\",\n \"label\": \"Donor\",\n \"name\": \"Donor\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Stock", - "links": "[\n {\n \"label\": \"Warehouse\",\n \"name\": \"Warehouse\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Brand\",\n \"name\": \"Brand\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Unit of Measure (UOM)\",\n \"name\": \"UOM\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Stock Reconciliation\",\n \"name\": \"Stock Reconciliation\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Human Resources", - "links": "[\n {\n \"label\": \"Employee\",\n \"name\": \"Employee\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"Employee\"\n ],\n \"hide_count\": true,\n \"label\": \"Employee Attendance Tool\",\n \"name\": \"Employee Attendance Tool\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Salary Structure\",\n \"name\": \"Salary Structure\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "CRM", - "links": "[\n {\n \"description\": \"Database of potential customers.\",\n \"label\": \"Lead\",\n \"name\": \"Lead\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Manage Customer Group Tree.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Customer Group\",\n \"link\": \"Tree/Customer Group\",\n \"name\": \"Customer Group\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Manage Territory Tree.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Territory\",\n \"link\": \"Tree/Territory\",\n \"name\": \"Territory\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Accounting", - "links": "[\n {\n \"label\": \"Item\",\n \"name\": \"Item\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Customer database.\",\n \"label\": \"Customer\",\n \"name\": \"Customer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Supplier database.\",\n \"label\": \"Supplier\",\n \"name\": \"Supplier\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Company (not Customer or Supplier) master.\",\n \"label\": \"Company\",\n \"name\": \"Company\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Tree of financial accounts.\",\n \"icon\": \"fa fa-sitemap\",\n \"label\": \"Chart of Accounts\",\n \"name\": \"Account\",\n \"onboard\": 1,\n \"route\": \"#Tree/Account\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Create Opening Sales and Purchase Invoices\",\n \"label\": \"Opening Invoice Creation Tool\",\n \"name\": \"Opening Invoice Creation Tool\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Data Import and Settings", - "links": "[\n {\n \"description\": \"Import Data from CSV / Excel files.\",\n \"icon\": \"octicon octicon-cloud-upload\",\n \"label\": \"Import Data\",\n \"name\": \"Data Import\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Import Chart Of Accounts from CSV / Excel files\",\n \"labe\": \"Chart Of Accounts Importer\",\n \"label\": \"Chart of Accounts Importer\",\n \"name\": \"Chart of Accounts Importer\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Letter Heads for print templates.\",\n \"label\": \"Letter Head\",\n \"name\": \"Letter Head\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Add / Manage Email Accounts.\",\n \"label\": \"Email Account\",\n \"name\": \"Email Account\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Modules", - "charts": [], - "creation": "2020-01-23 13:46:38.833076", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "idx": 0, - "is_standard": 1, - "label": "Home", - "modified": "2020-05-11 10:20:37.358701", - "modified_by": "Administrator", - "module": "Setup", - "name": "Home", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 1, - "shortcuts": [ - { - "label": "Item", - "link_to": "Item", - "type": "DocType" - }, - { - "label": "Customer", - "link_to": "Customer", - "type": "DocType" - }, - { - "label": "Supplier", - "link_to": "Supplier", - "type": "DocType" - }, - { - "label": "Sales Invoice", - "link_to": "Sales Invoice", - "type": "DocType" - }, - { - "label": "Dashboard", - "link_to": "dashboard", - "type": "Page" - }, - { - "label": "Leaderboard", - "link_to": "leaderboard", - "type": "Page" - } - ] -} \ No newline at end of file diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index f882db60c5d..c041d269a76 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -90,29 +90,41 @@ frappe.ui.form.on("Company", { frm.toggle_enable("default_currency", (frm.doc.__onload && !frm.doc.__onload.transactions_exist)); - frm.add_custom_button(__('Create Tax Template'), function() { - frm.trigger("make_default_tax_template"); - }); + if (frm.has_perm('write')) { + frm.add_custom_button(__('Create Tax Template'), function() { + frm.trigger("make_default_tax_template"); + }); + } - frm.add_custom_button(__('Cost Centers'), function() { - frappe.set_route('Tree', 'Cost Center', {'company': frm.doc.name}) - }, __("View")); + if (frappe.perm.has_perm("Cost Center", 0, 'read')) { + frm.add_custom_button(__('Cost Centers'), function() { + frappe.set_route('Tree', 'Cost Center', {'company': frm.doc.name}); + }, __("View")); + } - frm.add_custom_button(__('Chart of Accounts'), function() { - frappe.set_route('Tree', 'Account', {'company': frm.doc.name}) - }, __("View")); + if (frappe.perm.has_perm("Account", 0, 'read')) { + frm.add_custom_button(__('Chart of Accounts'), function() { + frappe.set_route('Tree', 'Account', {'company': frm.doc.name}); + }, __("View")); + } - frm.add_custom_button(__('Sales Tax Template'), function() { - frappe.set_route('List', 'Sales Taxes and Charges Template', {'company': frm.doc.name}); - }, __("View")); + if (frappe.perm.has_perm("Sales Taxes and Charges Template", 0, 'read')) { + frm.add_custom_button(__('Sales Tax Template'), function() { + frappe.set_route('List', 'Sales Taxes and Charges Template', {'company': frm.doc.name}); + }, __("View")); + } - frm.add_custom_button(__('Purchase Tax Template'), function() { - frappe.set_route('List', 'Purchase Taxes and Charges Template', {'company': frm.doc.name}); - }, __("View")); + if (frappe.perm.has_perm("Purchase Taxes and Charges Template", 0, 'read')) { + frm.add_custom_button(__('Purchase Tax Template'), function() { + frappe.set_route('List', 'Purchase Taxes and Charges Template', {'company': frm.doc.name}); + }, __("View")); + } - frm.add_custom_button(__('Default Tax Template'), function() { - frm.trigger("make_default_tax_template"); - }, __('Create')); + if (frm.has_perm('write')) { + frm.add_custom_button(__('Default Tax Template'), function() { + frm.trigger("make_default_tax_template"); + }, __('Create')); + } } erpnext.company.set_chart_of_accounts_options(frm.doc); @@ -128,7 +140,7 @@ frappe.ui.form.on("Company", { doc: frm.doc, freeze: true, callback: function() { - frappe.msgprint(__("Default tax templates for sales and purchase are created.")); + frappe.msgprint(__("Default tax templates for sales, purchase and items are created.")); } }) }, @@ -262,7 +274,8 @@ erpnext.company.setup_queries = function(frm) { ["default_employee_advance_account", {"root_type": "Asset"}], ["expenses_included_in_asset_valuation", {"account_type": "Expenses Included In Asset Valuation"}], ["capital_work_in_progress_account", {"account_type": "Capital Work in Progress"}], - ["asset_received_but_not_billed", {"account_type": "Asset Received But Not Billed"}] + ["asset_received_but_not_billed", {"account_type": "Asset Received But Not Billed"}], + ["unrealized_profit_loss_account", {"root_type": "Liability"}] ], function(i, v) { erpnext.company.set_custom_query(frm, v); }); diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index 4a26a71970c..56f60dfcff0 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -46,10 +46,9 @@ "round_off_account", "round_off_cost_center", "write_off_account", - "discount_allowed_account", - "discount_received_account", "exchange_gain_loss_account", "unrealized_exchange_gain_loss_account", + "unrealized_profit_loss_account", "column_break0", "allow_account_creation_against_child_company", "default_payable_account", @@ -345,18 +344,6 @@ "label": "Write Off Account", "options": "Account" }, - { - "fieldname": "discount_allowed_account", - "fieldtype": "Link", - "label": "Discount Allowed Account", - "options": "Account" - }, - { - "fieldname": "discount_received_account", - "fieldtype": "Link", - "label": "Discount Received Account", - "options": "Account" - }, { "fieldname": "exchange_gain_loss_account", "fieldtype": "Link", @@ -738,8 +725,14 @@ { "fieldname": "default_in_transit_warehouse", "fieldtype": "Link", - "label": "Default In Transit Warehouse", + "label": "Default In-Transit Warehouse", "options": "Warehouse" + }, + { + "fieldname": "unrealized_profit_loss_account", + "fieldtype": "Link", + "label": "Unrealized Profit / Loss Account", + "options": "Account" } ], "icon": "fa fa-building", @@ -747,7 +740,7 @@ "image_field": "company_logo", "is_tree": 1, "links": [], - "modified": "2020-08-06 00:38:08.311216", + "modified": "2021-02-16 15:53:37.167589", "modified_by": "Administrator", "module": "Setup", "name": "Company", @@ -808,4 +801,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 8e707fe3f49..433851cde53 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -75,7 +75,7 @@ class Company(NestedSet): def validate_default_accounts(self): accounts = [ - ["Default Bank Account", "default_bank_account"], ["Default Cash Account", "default_cash_account"], + ["Default Bank Account", "default_bank_account"], ["Default Cash Account", "default_cash_account"], ["Default Receivable Account", "default_receivable_account"], ["Default Payable Account", "default_payable_account"], ["Default Expense Account", "default_expense_account"], ["Default Income Account", "default_income_account"], ["Stock Received But Not Billed Account", "stock_received_but_not_billed"], ["Stock Adjustment Account", "stock_adjustment_account"], @@ -89,8 +89,9 @@ class Company(NestedSet): frappe.throw(_("Account {0} does not belong to company: {1}").format(self.get(account[1]), self.name)) if get_account_currency(self.get(account[1])) != self.default_currency: - frappe.throw(_("""{0} currency must be same as company's default currency. - Please select another account""").format(frappe.bold(account[0]))) + error_message = _("{0} currency must be same as company's default currency. Please select another account.") \ + .format(frappe.bold(account[0])) + frappe.throw(error_message) def validate_currency(self): if self.is_new(): @@ -389,8 +390,10 @@ class Company(NestedSet): frappe.db.sql("delete from tabDepartment where company=%s", self.name) frappe.db.sql("delete from `tabTax Withholding Account` where company=%s", self.name) + # delete tax templates frappe.db.sql("delete from `tabSales Taxes and Charges Template` where company=%s", self.name) frappe.db.sql("delete from `tabPurchase Taxes and Charges Template` where company=%s", self.name) + frappe.db.sql("delete from `tabItem Tax Template` where company=%s", self.name) @frappe.whitelist() def enqueue_replace_abbr(company, old, new): @@ -442,7 +445,7 @@ def install_country_fixtures(company): module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(company_doc.country)) frappe.get_attr(module_name)(company_doc, False) except Exception as e: - frappe.log_error(str(e), frappe.get_traceback()) + frappe.log_error() frappe.throw(_("Failed to setup defaults for country {0}. Please contact support@erpnext.com").format(frappe.bold(company_doc.country))) diff --git a/erpnext/setup/doctype/company/company_list.js b/erpnext/setup/doctype/company/company_list.js index 017286560fe..1d1184f04d3 100644 --- a/erpnext/setup/doctype/company/company_list.js +++ b/erpnext/setup/doctype/company/company_list.js @@ -1,10 +1,5 @@ frappe.listview_settings['Company'] = { - onload: () => { - frappe.breadcrumbs.add({ - type: 'Custom', - module: __('Accounts'), - label: __('Accounts'), - route: '#modules/Accounts' - }); - } -} \ No newline at end of file + onload() { + frappe.breadcrumbs.add('Accounts'); + }, +}; diff --git a/erpnext/setup/doctype/company/delete_company_transactions.py b/erpnext/setup/doctype/company/delete_company_transactions.py index c94831ef937..0df4c87f51f 100644 --- a/erpnext/setup/doctype/company/delete_company_transactions.py +++ b/erpnext/setup/doctype/company/delete_company_transactions.py @@ -27,7 +27,8 @@ def delete_company_transactions(company_name): if doctype not in ("Account", "Cost Center", "Warehouse", "Budget", "Party Account", "Employee", "Sales Taxes and Charges Template", "Purchase Taxes and Charges Template", "POS Profile", "BOM", - "Company", "Bank Account"): + "Company", "Bank Account", "Item Tax Template", "Mode Of Payment", + "Item Default", "Customer", "Supplier", "GST Account"): delete_for_doctype(doctype, company_name) # reset company values diff --git a/erpnext/setup/doctype/company/test_records.json b/erpnext/setup/doctype/company/test_records.json index 21302417d2b..9e55702ddc9 100644 --- a/erpnext/setup/doctype/company/test_records.json +++ b/erpnext/setup/doctype/company/test_records.json @@ -7,7 +7,8 @@ "doctype": "Company", "domain": "Manufacturing", "chart_of_accounts": "Standard", - "default_holiday_list": "_Test Holiday List" + "default_holiday_list": "_Test Holiday List", + "enable_perpetual_inventory": 0 }, { "abbr": "_TC1", @@ -17,7 +18,8 @@ "doctype": "Company", "domain": "Retail", "chart_of_accounts": "Standard", - "default_holiday_list": "_Test Holiday List" + "default_holiday_list": "_Test Holiday List", + "enable_perpetual_inventory": 0 }, { "abbr": "_TC2", @@ -27,7 +29,8 @@ "doctype": "Company", "domain": "Retail", "chart_of_accounts": "Standard", - "default_holiday_list": "_Test Holiday List" + "default_holiday_list": "_Test Holiday List", + "enable_perpetual_inventory": 0 }, { "abbr": "_TC3", @@ -38,7 +41,8 @@ "doctype": "Company", "domain": "Manufacturing", "chart_of_accounts": "Standard", - "default_holiday_list": "_Test Holiday List" + "default_holiday_list": "_Test Holiday List", + "enable_perpetual_inventory": 0 }, { "abbr": "_TC4", @@ -50,7 +54,8 @@ "doctype": "Company", "domain": "Manufacturing", "chart_of_accounts": "Standard", - "default_holiday_list": "_Test Holiday List" + "default_holiday_list": "_Test Holiday List", + "enable_perpetual_inventory": 0 }, { "abbr": "_TC5", @@ -61,7 +66,8 @@ "doctype": "Company", "domain": "Manufacturing", "chart_of_accounts": "Standard", - "default_holiday_list": "_Test Holiday List" + "default_holiday_list": "_Test Holiday List", + "enable_perpetual_inventory": 0 }, { "abbr": "TCP1", diff --git a/erpnext/setup/doctype/customer_group/customer_group.json b/erpnext/setup/doctype/customer_group/customer_group.json index 10f9bd00300..0e2ed9efcf8 100644 --- a/erpnext/setup/doctype/customer_group/customer_group.json +++ b/erpnext/setup/doctype/customer_group/customer_group.json @@ -139,7 +139,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-03-18 18:10:13.048492", + "modified": "2021-02-08 17:01:52.162202", "modified_by": "Administrator", "module": "Setup", "name": "Customer Group", @@ -189,6 +189,15 @@ "permlevel": 1, "read": 1, "role": "Sales Manager" + }, + { + "email": 1, + "export": 1, + "print": 1, + "report": 1, + "role": "Customer", + "select": 1, + "share": 1 } ], "search_fields": "parent_customer_group", diff --git a/erpnext/setup/doctype/email_digest/email_digest.py b/erpnext/setup/doctype/email_digest/email_digest.py index b30bd7814b6..cbb4c7c5deb 100644 --- a/erpnext/setup/doctype/email_digest/email_digest.py +++ b/erpnext/setup/doctype/email_digest/email_digest.py @@ -48,12 +48,8 @@ class EmailDigest(Document): recipients = list(filter(lambda r: r in valid_users, self.recipient_list.split("\n"))) - original_user = frappe.session.user - if recipients: for user_id in recipients: - frappe.set_user(user_id) - frappe.set_user_lang(user_id) msg_for_this_recipient = self.get_msg_html() if msg_for_this_recipient: frappe.sendmail( @@ -64,9 +60,6 @@ class EmailDigest(Document): reference_name = self.name, unsubscribe_message = _("Unsubscribe from this Email Digest")) - frappe.set_user(original_user) - frappe.set_user_lang(original_user) - def get_msg_html(self): """Build email digest content""" frappe.flags.ignore_account_permission = True diff --git a/erpnext/setup/doctype/item_group/item_group.js b/erpnext/setup/doctype/item_group/item_group.js index 9892dc3dcc0..1413cb28622 100644 --- a/erpnext/setup/doctype/item_group/item_group.js +++ b/erpnext/setup/doctype/item_group/item_group.js @@ -61,6 +61,19 @@ frappe.ui.form.on("Item Group", { frappe.set_route("List", "Item", {"item_group": frm.doc.name}); }); } + + frappe.model.with_doctype('Item', () => { + const item_meta = frappe.get_meta('Item'); + + const valid_fields = item_meta.fields.filter( + df => ['Link', 'Table MultiSelect'].includes(df.fieldtype) && !df.hidden + ).map(df => ({ label: df.label, value: df.fieldname })); + + const field = frappe.meta.get_docfield("Website Filter Field", "fieldname", frm.docname); + field.fieldtype = 'Select'; + field.options = valid_fields; + frm.fields_dict.filter_fields.grid.refresh(); + }); }, set_root_readonly: function(frm) { diff --git a/erpnext/setup/doctype/item_group/item_group.json b/erpnext/setup/doctype/item_group/item_group.json index 004421d2bcb..3e0680f4f51 100644 --- a/erpnext/setup/doctype/item_group/item_group.json +++ b/erpnext/setup/doctype/item_group/item_group.json @@ -24,8 +24,12 @@ "route", "weightage", "slideshow", + "website_title", "description", "website_specifications", + "website_filters_section", + "filter_fields", + "filter_attributes", "lft", "rgt", "old_parent" @@ -180,6 +184,28 @@ "options": "Item Group", "print_hide": 1, "report_hide": 1 + }, + { + "fieldname": "website_filters_section", + "fieldtype": "Section Break", + "label": "Website Filters" + }, + { + "fieldname": "filter_fields", + "fieldtype": "Table", + "label": "Item Fields", + "options": "Website Filter Field" + }, + { + "fieldname": "filter_attributes", + "fieldtype": "Table", + "label": "Attributes", + "options": "Website Attribute" + }, + { + "fieldname": "website_title", + "fieldtype": "Data", + "label": "Title" } ], "icon": "fa fa-sitemap", @@ -188,7 +214,7 @@ "is_tree": 1, "links": [], "max_attachments": 3, - "modified": "2020-03-18 18:10:34.383363", + "modified": "2021-02-18 13:40:30.049650", "modified_by": "Administrator", "module": "Setup", "name": "Item Group", @@ -245,6 +271,15 @@ "read": 1, "report": 1, "role": "Accounts User" + }, + { + "email": 1, + "export": 1, + "print": 1, + "report": 1, + "role": "All", + "select": 1, + "share": 1 } ], "search_fields": "parent_item_group", diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index 43778404b60..bff806d5472 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -13,13 +13,16 @@ from frappe.website.doctype.website_slideshow.website_slideshow import get_slide from erpnext.shopping_cart.product_info import set_product_info_for_website from erpnext.utilities.product import get_qty_in_stock from six.moves.urllib.parse import quote +from erpnext.shopping_cart.product_query import ProductQuery +from erpnext.shopping_cart.filters import ProductFiltersBuilder class ItemGroup(NestedSet, WebsiteGenerator): nsm_parent_field = 'parent_item_group' website = frappe._dict( condition_field = "show_in_website", template = "templates/generators/item_group.html", - no_cache = 1 + no_cache = 1, + no_breadcrumbs = 1 ) def autoname(self): @@ -70,18 +73,58 @@ class ItemGroup(NestedSet, WebsiteGenerator): context.page_length = cint(frappe.db.get_single_value('Products Settings', 'products_per_page')) or 6 context.search_link = '/product_search' - start = int(frappe.form_dict.start or 0) - if start < 0: + if frappe.form_dict: + search = frappe.form_dict.search + field_filters = frappe.parse_json(frappe.form_dict.field_filters) + attribute_filters = frappe.parse_json(frappe.form_dict.attribute_filters) + start = frappe.parse_json(frappe.form_dict.start) + else: + search = None + attribute_filters = None + field_filters = {} start = 0 + + if not field_filters: + field_filters = {} + + # Ensure the query remains within current item group + field_filters['item_group'] = self.name + + engine = ProductQuery() + context.items = engine.query(attribute_filters, field_filters, search, start) + + filter_engine = ProductFiltersBuilder(self.name) + + context.field_filters = filter_engine.get_field_filters() + context.attribute_filters = filter_engine.get_attribute_fitlers() + context.update({ - "items": get_product_list_for_group(product_group = self.name, start=start, - limit=context.page_length + 1, search=frappe.form_dict.get("search")), "parents": get_parent_item_groups(self.parent_item_group), "title": self.name }) if self.slideshow: - context.update(get_slideshow(self)) + values = { + 'show_indicators': 1, + 'show_controls': 0, + 'rounded': 1, + 'slider_name': self.slideshow + } + slideshow = frappe.get_doc("Website Slideshow", self.slideshow) + slides = slideshow.get({"doctype":"Website Slideshow Item"}) + for index, slide in enumerate(slides): + values[f"slide_{index + 1}_image"] = slide.image + values[f"slide_{index + 1}_title"] = slide.heading + values[f"slide_{index + 1}_subtitle"] = slide.description + values[f"slide_{index + 1}_theme"] = slide.theme or "Light" + values[f"slide_{index + 1}_content_align"] = slide.content_align or "Centre" + values[f"slide_{index + 1}_primary_action_label"] = slide.label + values[f"slide_{index + 1}_primary_action"] = slide.url + + context.slideshow = values + + context.breadcrumbs = 0 + context.title = self.website_title or self.name return context diff --git a/erpnext/setup/doctype/item_group/test_records.json b/erpnext/setup/doctype/item_group/test_records.json index 71159643209..146da87bddc 100644 --- a/erpnext/setup/doctype/item_group/test_records.json +++ b/erpnext/setup/doctype/item_group/test_records.json @@ -79,13 +79,13 @@ { "doctype": "Item Tax", "parentfield": "taxes", - "item_tax_template": "_Test Account Excise Duty @ 10", + "item_tax_template": "_Test Account Excise Duty @ 10 - _TC", "tax_category": "" }, { "doctype": "Item Tax", "parentfield": "taxes", - "item_tax_template": "_Test Account Excise Duty @ 12", + "item_tax_template": "_Test Account Excise Duty @ 12 - _TC", "tax_category": "_Test Tax Category 1" } ] @@ -99,7 +99,7 @@ { "doctype": "Item Tax", "parentfield": "taxes", - "item_tax_template": "_Test Account Excise Duty @ 15", + "item_tax_template": "_Test Account Excise Duty @ 15 - _TC", "tax_category": "" } ] diff --git a/erpnext/setup/doctype/naming_series/naming_series.py b/erpnext/setup/doctype/naming_series/naming_series.py index abff97364c0..2ea0bc08ca2 100644 --- a/erpnext/setup/doctype/naming_series/naming_series.py +++ b/erpnext/setup/doctype/naming_series/naming_series.py @@ -10,6 +10,7 @@ from frappe import msgprint, throw, _ from frappe.model.document import Document from frappe.model.naming import parse_naming_series from frappe.permissions import get_doctypes_with_read +from frappe.core.doctype.doctype.doctype import validate_series class NamingSeriesNotSetError(frappe.ValidationError): pass @@ -126,7 +127,7 @@ class NamingSeries(Document): dt = frappe.get_doc("DocType", self.select_doc_for_series) options = self.scrub_options_list(self.set_options.split("\n")) for series in options: - dt.validate_series(series) + validate_series(dt, series) for i in sr: if i[0]: existing_series = [d.split('.')[0] for d in i[0].split("\n")] diff --git a/erpnext/setup/doctype/sales_person/sales_person.js b/erpnext/setup/doctype/sales_person/sales_person.js index 8f7593d6eef..b71a92f8a98 100644 --- a/erpnext/setup/doctype/sales_person/sales_person.js +++ b/erpnext/setup/doctype/sales_person/sales_person.js @@ -5,8 +5,7 @@ frappe.ui.form.on('Sales Person', { refresh: function(frm) { if(frm.doc.__onload && frm.doc.__onload.dashboard_info) { var info = frm.doc.__onload.dashboard_info; - frm.dashboard.add_indicator(__('Total Contribution Amount: {0}', - [format_currency(info.allocated_amount, info.currency)]), 'blue'); + frm.dashboard.add_indicator(__('Total Contribution Amount: {0}', [format_currency(info.allocated_amount, info.currency)]), 'blue'); } }, diff --git a/erpnext/setup/doctype/territory/territory.json b/erpnext/setup/doctype/territory/territory.json index aa8e0486f59..a25bda054b9 100644 --- a/erpnext/setup/doctype/territory/territory.json +++ b/erpnext/setup/doctype/territory/territory.json @@ -123,7 +123,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-03-18 18:11:36.623555", + "modified": "2021-02-08 17:10:03.767426", "modified_by": "Administrator", "module": "Setup", "name": "Territory", @@ -166,6 +166,15 @@ { "read": 1, "role": "Maintenance User" + }, + { + "email": 1, + "export": 1, + "print": 1, + "report": 1, + "role": "Customer", + "select": 1, + "share": 1 } ], "search_fields": "parent_territory,territory_manager", diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 2225fe169f5..82f191d0b7f 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -28,6 +28,7 @@ def after_install(): create_default_energy_point_rules() add_company_to_session_defaults() add_standard_navbar_items() + add_app_name() frappe.db.commit() @@ -141,13 +142,15 @@ def add_standard_navbar_items(): } ] - current_nabvar_items = navbar_settings.help_dropdown + current_navbar_items = navbar_settings.help_dropdown navbar_settings.set('help_dropdown', []) for item in erpnext_navbar_items: - navbar_settings.append('help_dropdown', item) + current_labels = [item.get('item_label') for item in current_navbar_items] + if not item.get('item_label') in current_labels: + navbar_settings.append('help_dropdown', item) - for item in current_nabvar_items: + for item in current_navbar_items: navbar_settings.append('help_dropdown', { 'item_label': item.item_label, 'item_type': item.item_type, @@ -158,3 +161,6 @@ def add_standard_navbar_items(): }) navbar_settings.save() + +def add_app_name(): + frappe.db.set_value('System Settings', None, 'app_name', 'ERPNext') diff --git a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.html b/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.html index 5808ce73ee9..7166ba37867 100644 --- a/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.html +++ b/erpnext/setup/page/welcome_to_erpnext/welcome_to_erpnext.html @@ -21,7 +21,6 @@

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

    diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index 19318df38e0..beddaeed793 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -60,14 +60,10 @@ }, "Australia": { - "Australia GST1": { + "Australia GST": { "account_name": "GST 10%", "tax_rate": 10.00, "default": 1 - }, - "Australia GST 2%": { - "account_name": "GST 2%", - "tax_rate": 2 } }, @@ -648,10 +644,19 @@ }, "Italy": { - "Italy Tax": { - "account_name": "VAT", - "tax_rate": 22.00 - } + "Italy VAT 22%": { + "account_name": "IVA 22%", + "tax_rate": 22.00, + "default": 1 + }, + "Italy VAT 10%":{ + "account_name": "IVA 10%", + "tax_rate": 10.00 + }, + "Italy VAT 4%":{ + "account_name": "IVA 4%", + "tax_rate": 4.00 + } }, "Ivory Coast": { diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 72ed00293ed..5053c6a5124 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -195,6 +195,7 @@ def install(country=None): {'doctype': "Party Type", "party_type": "Member", "account_type": "Receivable"}, {'doctype': "Party Type", "party_type": "Shareholder", "account_type": "Payable"}, {'doctype': "Party Type", "party_type": "Student", "account_type": "Receivable"}, + {'doctype': "Party Type", "party_type": "Donor", "account_type": "Receivable"}, {'doctype': "Opportunity Type", "name": "Hub"}, {'doctype': "Opportunity Type", "name": _("Sales")}, diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py index e66fa76f93a..c3c1593c046 100644 --- a/erpnext/setup/setup_wizard/operations/taxes_setup.py +++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py @@ -29,6 +29,7 @@ def make_tax_account_and_template(company, account_name, tax_rate, template_name try: if accounts: make_sales_and_purchase_tax_templates(accounts, template_name) + make_item_tax_templates(accounts, template_name) except frappe.NameError: if frappe.message_log: frappe.message_log.pop() except RootNotEditable: @@ -84,6 +85,27 @@ def make_sales_and_purchase_tax_templates(accounts, template_name=None): doc = frappe.get_doc(purchase_tax_template) doc.insert(ignore_permissions=True) +def make_item_tax_templates(accounts, template_name=None): + if not template_name: + template_name = accounts[0].name + + item_tax_template = { + "doctype": "Item Tax Template", + "title": template_name, + "company": accounts[0].company, + 'taxes': [] + } + + + for account in accounts: + item_tax_template['taxes'].append({ + "tax_type": account.name, + "tax_rate": account.tax_rate + }) + + # Items + frappe.get_doc(copy.deepcopy(item_tax_template)).insert(ignore_permissions=True) + def get_tax_account_group(company): tax_group = frappe.db.get_value("Account", {"account_name": "Duties and Taxes", "is_group": 1, "company": company}) diff --git a/erpnext/setup/desk_page/erpnext_settings/erpnext_settings.json b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json similarity index 77% rename from erpnext/setup/desk_page/erpnext_settings/erpnext_settings.json rename to erpnext/setup/workspace/erpnext_settings/erpnext_settings.json index 253d711b322..014f4095c15 100644 --- a/erpnext/setup/desk_page/erpnext_settings/erpnext_settings.json +++ b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json @@ -1,18 +1,20 @@ { - "cards": [], "category": "Modules", "charts": [], "creation": "2020-03-12 14:47:51.166455", "developer_mode_only": 0, "disable_user_customization": 0, "docstatus": 0, - "doctype": "Desk Page", + "doctype": "Workspace", "extends": "Settings", "extends_another_page": 1, + "hide_custom": 0, + "icon": "settings", "idx": 0, "is_standard": 1, "label": "ERPNext Settings", - "modified": "2020-04-01 11:28:51.400851", + "links": [], + "modified": "2020-12-01 13:38:37.759596", "modified_by": "Administrator", "module": "Setup", "name": "ERPNext Settings", @@ -21,89 +23,89 @@ "pin_to_top": 0, "shortcuts": [ { - "icon": "octicon octicon-rocket", + "icon": "project", "label": "Projects Settings", "link_to": "Projects Settings", "type": "DocType" }, { - "icon": "octicon octicon-repo", + "icon": "accounting", "label": "Accounts Settings", "link_to": "Accounts Settings", "type": "DocType" }, { - "icon": "octicon octicon-package", + "icon": "stock", "label": "Stock Settings", "link_to": "Stock Settings", "type": "DocType" }, { - "icon": "octicon octicon-organization", + "icon": "hr", "label": "HR Settings", "link_to": "HR Settings", "type": "DocType" }, { - "icon": "octicon octicon-tag", + "icon": "sell", "label": "Selling Settings", "link_to": "Selling Settings", "type": "DocType" }, { - "icon": "octicon octicon-briefcase", + "icon": "buying", "label": "Buying Settings", "link_to": "Buying Settings", "type": "DocType" }, { - "icon": "fa fa-life-ring", + "icon": "support", "label": "Support Settings", "link_to": "Support Settings", "type": "DocType" }, { - "icon": "fa fa-shopping-cart", + "icon": "retail", "label": "Shopping Cart Settings", "link_to": "Shopping Cart Settings", "type": "DocType" }, { - "icon": "fa fa-globe", + "icon": "website", "label": "Portal Settings", "link_to": "Portal Settings", "type": "DocType" }, { - "icon": "octicon octicon-tools", + "icon": "organization", "label": "Manufacturing Settings", "link_to": "Manufacturing Settings", "restrict_to_domain": "Manufacturing", "type": "DocType" }, { - "icon": "octicon octicon-mortar-board", + "icon": "education", "label": "Education Settings", "link_to": "Education Settings", "restrict_to_domain": "Education", "type": "DocType" }, { - "icon": "fa fa-bed", + "icon": "organization", "label": "Hotel Settings", "link_to": "Hotel Settings", "restrict_to_domain": "Hospitality", "type": "DocType" }, { - "icon": "fa fa-heartbeat", + "icon": "non-profit", "label": "Healthcare Settings", "link_to": "Healthcare Settings", "restrict_to_domain": "Healthcare", "type": "DocType" }, { - "icon": "fa fa-cog", + "icon": "setting", "label": "Domain Settings", "link_to": "Domain Settings", "type": "DocType" diff --git a/erpnext/setup/workspace/home/home.json b/erpnext/setup/workspace/home/home.json new file mode 100644 index 00000000000..305456b2666 --- /dev/null +++ b/erpnext/setup/workspace/home/home.json @@ -0,0 +1,455 @@ +{ + "category": "Modules", + "charts": [], + "creation": "2020-01-23 13:46:38.833076", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "getting-started", + "idx": 0, + "is_default": 0, + "is_standard": 1, + "label": "Home", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Accounting", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Chart of Accounts", + "link_to": "Account", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Company", + "link_to": "Company", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customer", + "link_to": "Customer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Supplier", + "link_to": "Supplier", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Stock", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item", + "link_to": "Item", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Warehouse", + "link_to": "Warehouse", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Brand", + "link_to": "Brand", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Unit of Measure (UOM)", + "link_to": "UOM", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Stock Reconciliation", + "link_to": "Stock Reconciliation", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Human Resources", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee", + "link_to": "Employee", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Attendance Tool", + "link_to": "Employee Attendance Tool", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Salary Structure", + "link_to": "Salary Structure", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "CRM", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Lead", + "link_to": "Lead", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customer Group", + "link_to": "Customer Group", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Territory", + "link_to": "Territory", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Data Import and Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Import Data", + "link_to": "Data Import", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Opening Invoice Creation Tool", + "link_to": "Opening Invoice Creation Tool", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Chart of Accounts Importer", + "link_to": "Chart of Accounts Importer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Letter Head", + "link_to": "Letter Head", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Account", + "link_to": "Email Account", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Healthcare", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Patient", + "link_to": "Patient", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Diagnosis", + "link_to": "Diagnosis", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Education", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Student", + "link_to": "Student", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Instructor", + "link_to": "Instructor", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Course", + "link_to": "Course", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Room", + "link_to": "Room", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Non Profit", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Donor", + "link_to": "Donor", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Member", + "link_to": "Member", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Volunteer", + "link_to": "Volunteer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Chapter", + "link_to": "Chapter", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Agriculture", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Location", + "link_to": "Location", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Crop", + "link_to": "Crop", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Crop Cycle", + "link_to": "Crop Cycle", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Fertilizer", + "link_to": "Fertilizer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + } + ], + "modified": "2021-03-16 15:59:58.416154", + "modified_by": "Administrator", + "module": "Setup", + "name": "Home", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 1, + "shortcuts": [ + { + "label": "Item", + "link_to": "Item", + "type": "DocType" + }, + { + "label": "Customer", + "link_to": "Customer", + "type": "DocType" + }, + { + "label": "Supplier", + "link_to": "Supplier", + "type": "DocType" + }, + { + "label": "Sales Invoice", + "link_to": "Sales Invoice", + "type": "DocType" + }, + { + "label": "Leaderboard", + "link_to": "leaderboard", + "type": "Page" + } + ] +} \ No newline at end of file diff --git a/erpnext/shopping_cart/cart.py b/erpnext/shopping_cart/cart.py index 0ccc0252c31..681d161edcd 100644 --- a/erpnext/shopping_cart/cart.py +++ b/erpnext/shopping_cart/cart.py @@ -42,14 +42,30 @@ def get_cart_quotation(doc=None): return { "doc": decorate_quotation_doc(doc), - "shipping_addresses": [{"name": address.name, "title": address.address_title, "display": address.display} - for address in addresses if address.address_type == "Shipping"], - "billing_addresses": [{"name": address.name, "title": address.address_title, "display": address.display} - for address in addresses if address.address_type == "Billing"], + "shipping_addresses": get_shipping_addresses(party), + "billing_addresses": get_billing_addresses(party), "shipping_rules": get_applicable_shipping_rules(party), "cart_settings": frappe.get_cached_doc("Shopping Cart Settings") } +@frappe.whitelist() +def get_shipping_addresses(party=None): + if not party: + party = get_party() + addresses = get_address_docs(party=party) + return [{"name": address.name, "title": address.address_title, "display": address.display} + for address in addresses if address.address_type == "Shipping" + ] + +@frappe.whitelist() +def get_billing_addresses(party=None): + if not party: + party = get_party() + addresses = get_address_docs(party=party) + return [{"name": address.name, "title": address.address_title, "display": address.display} + for address in addresses if address.address_type == "Billing" + ] + @frappe.whitelist() def place_order(): quotation = _get_cart_quotation() @@ -180,6 +196,13 @@ def create_lead_for_item_inquiry(lead, subject, message): lead_doc.update(lead) lead_doc.set('lead_owner', '') + if not frappe.db.exists('Lead Source', 'Product Inquiry'): + frappe.get_doc({ + 'doctype': 'Lead Source', + 'source_name' : 'Product Inquiry' + }).insert(ignore_permissions=True) + lead_doc.set('source', 'Product Inquiry') + try: lead_doc.save(ignore_permissions=True) except frappe.exceptions.DuplicateEntryError: @@ -203,27 +226,33 @@ def get_terms_and_conditions(terms_name): @frappe.whitelist() def update_cart_address(address_type, address_name): quotation = _get_cart_quotation() - address_display = get_address_display(frappe.get_doc("Address", address_name).as_dict()) + address_doc = frappe.get_doc("Address", address_name).as_dict() + address_display = get_address_display(address_doc) if address_type.lower() == "billing": quotation.customer_address = address_name quotation.address_display = address_display quotation.shipping_address_name == quotation.shipping_address_name or address_name + address_doc = next((doc for doc in get_billing_addresses() if doc["name"] == address_name), None) elif address_type.lower() == "shipping": quotation.shipping_address_name = address_name quotation.shipping_address = address_display quotation.customer_address == quotation.customer_address or address_name - + address_doc = next((doc for doc in get_shipping_addresses() if doc["name"] == address_name), None) apply_cart_settings(quotation=quotation) quotation.flags.ignore_permissions = True quotation.save() context = get_cart_quotation(quotation) + context['address'] = address_doc + return { "taxes": frappe.render_template("templates/includes/order/order_taxes.html", context), - } + "address": frappe.render_template("templates/includes/cart/address_card.html", + context) + } def guess_territory(): territory = None @@ -345,7 +374,7 @@ def _set_price_list(cart_settings, quotation=None): selling_price_list = None # check if default customer price list exists - if party_name: + if party_name and frappe.db.exists("Customer", party_name): selling_price_list = get_default_price_list(frappe.get_doc("Customer", party_name)) # check default price list in shopping cart @@ -433,6 +462,9 @@ def get_party(user=None): return customer def get_debtors_account(cart_settings): + if not cart_settings.payment_gateway_account: + frappe.throw(_("Payment Gateway Account not set"), _("Mandatory")) + payment_gateway_account_currency = \ frappe.get_doc("Payment Gateway Account", cart_settings.payment_gateway_account).currency diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js index 21fa4c3065f..b38828e0d75 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js +++ b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js @@ -7,6 +7,22 @@ frappe.ui.form.on("Shopping Cart Settings", { frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series; frm.refresh_field("quotation_series"); } + + frm.set_query('payment_gateway_account', function() { + return { 'filters': { 'payment_channel': "Email" } }; + }); + }, + refresh: function(frm) { + if (frm.doc.enabled) { + frm.get_field('store_page_docs').$wrapper.removeClass('hide-control').html( + `
    ${__("Follow these steps to create a landing page for your store")}: + + docs/store-landing-page + +
    ` + ); + } }, enabled: function(frm) { if (frm.doc.enabled === 1) { diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json index 9d61e7d0ece..7a4bb20136f 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json +++ b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json @@ -7,6 +7,7 @@ "engine": "InnoDB", "field_order": [ "enabled", + "store_page_docs", "display_settings", "show_attachments", "show_price", @@ -25,10 +26,10 @@ "quotation_series", "section_break_8", "enable_checkout", - "payment_success_url", - "column_break_11", "save_quotations_as_draft", - "payment_gateway_account" + "column_break_11", + "payment_gateway_account", + "payment_success_url" ], "fields": [ { @@ -142,10 +143,12 @@ }, { "default": "Orders", + "depends_on": "enable_checkout", "description": "After payment completion redirect user to selected page.", "fieldname": "payment_success_url", "fieldtype": "Select", "label": "Payment Success Url", + "mandatory_depends_on": "enable_checkout", "options": "\nOrders\nInvoices\nMy Account" }, { @@ -153,9 +156,11 @@ "fieldtype": "Column Break" }, { + "depends_on": "enable_checkout", "fieldname": "payment_gateway_account", "fieldtype": "Link", "label": "Payment Gateway Account", + "mandatory_depends_on": "enable_checkout", "options": "Payment Gateway Account" }, { @@ -174,13 +179,18 @@ "fieldname": "save_quotations_as_draft", "fieldtype": "Check", "label": "Save Quotations as Draft" + }, + { + "depends_on": "doc.enabled", + "fieldname": "store_page_docs", + "fieldtype": "HTML" } ], "icon": "fa fa-shopping-cart", "idx": 1, "issingle": 1, "links": [], - "modified": "2020-09-24 16:28:07.192525", + "modified": "2021-03-02 17:34:57.642565", "modified_by": "Administrator", "module": "Shopping Cart", "name": "Shopping Cart Settings", @@ -197,5 +207,6 @@ } ], "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/shopping_cart/filters.py b/erpnext/shopping_cart/filters.py new file mode 100644 index 00000000000..6c63d8759b4 --- /dev/null +++ b/erpnext/shopping_cart/filters.py @@ -0,0 +1,82 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _dict + +class ProductFiltersBuilder: + def __init__(self, item_group=None): + if not item_group or item_group == "Products Settings": + self.doc = frappe.get_doc("Products Settings") + else: + self.doc = frappe.get_doc("Item Group", item_group) + + self.item_group = item_group + + def get_field_filters(self): + filter_fields = [row.fieldname for row in self.doc.filter_fields] + + meta = frappe.get_meta('Item') + fields = [df for df in meta.fields if df.fieldname in filter_fields] + + filter_data = [] + for df in fields: + filters = {} + if df.fieldtype == "Link": + if self.item_group: + filters['item_group'] = self.item_group + + values = frappe.get_all("Item", fields=[df.fieldname], filters=filters, distinct="True", pluck=df.fieldname) + else: + doctype = df.get_link_doctype() + + # apply enable/disable/show_in_website filter + meta = frappe.get_meta(doctype) + + if meta.has_field('enabled'): + filters['enabled'] = 1 + if meta.has_field('disabled'): + filters['disabled'] = 0 + if meta.has_field('show_in_website'): + filters['show_in_website'] = 1 + + values = [d.name for d in frappe.get_all(doctype, filters)] + + # Remove None + values = values.remove(None) if None in values else values + if values: + filter_data.append([df, values]) + + return filter_data + + def get_attribute_fitlers(self): + attributes = [row.attribute for row in self.doc.filter_attributes] + attribute_docs = [ + frappe.get_doc('Item Attribute', attribute) for attribute in attributes + ] + + valid_attributes = [] + + for attr_doc in attribute_docs: + selected_attributes = [] + for attr in attr_doc.item_attribute_values: + filters= [ + ["Item Variant Attribute", "attribute", "=", attr.parent], + ["Item Variant Attribute", "attribute_value", "=", attr.attribute_value] + ] + if self.item_group: + filters.append(["item_group", "=", self.item_group]) + + if frappe.db.get_all("Item", filters, limit=1): + selected_attributes.append(attr) + + if selected_attributes: + valid_attributes.append( + _dict( + item_attribute_values=selected_attributes, + name=attr_doc.name + ) + ) + + return valid_attributes diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py new file mode 100644 index 00000000000..36d446ed0fd --- /dev/null +++ b/erpnext/shopping_cart/product_query.py @@ -0,0 +1,123 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from erpnext.shopping_cart.product_info import get_product_info_for_website + +class ProductQuery: + """Query engine for product listing + + Attributes: + cart_settings (Document): Settings for Cart + fields (list): Fields to fetch in query + filters (TYPE): Description + or_filters (list): Description + page_length (Int): Length of page for the query + settings (Document): Products Settings DocType + filters (list) + or_filters (list) + """ + + def __init__(self): + self.settings = frappe.get_doc("Products Settings") + self.cart_settings = frappe.get_doc("Shopping Cart Settings") + self.page_length = self.settings.products_per_page or 20 + self.fields = ['name', 'item_name', 'item_code', 'website_image', 'variant_of', 'has_variants', 'item_group', 'image', 'web_long_description', 'description', 'route'] + self.filters = [] + self.or_filters = [['show_in_website', '=', 1]] + if not self.settings.get('hide_variants'): + self.or_filters.append(['show_variant_in_website', '=', 1]) + + def query(self, attributes=None, fields=None, search_term=None, start=0): + """Summary + + Args: + attributes (dict, optional): Item Attribute filters + fields (dict, optional): Field level filters + search_term (str, optional): Search term to lookup + start (int, optional): Page start + + Returns: + list: List of results with set fields + """ + if fields: self.build_fields_filters(fields) + if search_term: self.build_search_filters(search_term) + + result = [] + + if attributes: + all_items = [] + for attribute, values in attributes.items(): + if not isinstance(values, list): + values = [values] + + items = frappe.get_all( + "Item", + fields=self.fields, + filters=[ + *self.filters, + ["Item Variant Attribute", "attribute", "=", attribute], + ["Item Variant Attribute", "attribute_value", "in", values], + ], + or_filters=self.or_filters, + start=start, + limit=self.page_length + ) + + items_dict = {item.name: item for item in items} + # TODO: Replace Variants by their parent templates + + all_items.append(set(items_dict.keys())) + + result = [items_dict.get(item) for item in list(set.intersection(*all_items))] + else: + result = frappe.get_all("Item", fields=self.fields, filters=self.filters, or_filters=self.or_filters, start=start, limit=self.page_length) + + for item in result: + product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info') + if product_info: + item.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None + + return result + + def build_fields_filters(self, filters): + """Build filters for field values + + Args: + filters (dict): Filters + """ + for field, values in filters.items(): + if not values: + continue + + if isinstance(values, list): + # If value is a list use `IN` query + self.filters.append([field, 'IN', values]) + else: + # `=` will be faster than `IN` for most cases + self.filters.append([field, '=', values]) + + def build_search_filters(self, search_term): + """Query search term in specified fields + + Args: + search_term (str): Search candidate + """ + # Default fields to search from + default_fields = {'name', 'item_name', 'description', 'item_group'} + + # Get meta search fields + meta = frappe.get_meta("Item") + meta_fields = set(meta.get_search_fields()) + + # Join the meta fields and default fields set + search_fields = default_fields.union(meta_fields) + try: + if frappe.db.count('Item', cache=True) > 50000: + search_fields.remove('description') + except KeyError: + pass + + # Build or filters for query + search = '%{}%'.format(search_term) + self.or_filters += [[field, 'like', search] for field in search_fields] diff --git a/erpnext/shopping_cart/search.py b/erpnext/shopping_cart/search.py new file mode 100644 index 00000000000..63e9fe1b31b --- /dev/null +++ b/erpnext/shopping_cart/search.py @@ -0,0 +1,126 @@ +import frappe +from frappe.search.full_text_search import FullTextSearch +from whoosh.fields import TEXT, ID, KEYWORD, Schema +from frappe.utils import strip_html_tags +from whoosh.qparser import MultifieldParser, FieldsPlugin, WildcardPlugin +from whoosh.analysis import StemmingAnalyzer +from whoosh.query import Prefix + +INDEX_NAME = "products" + +class ProductSearch(FullTextSearch): + """ Wrapper for WebsiteSearch """ + + def get_schema(self): + return Schema( + title=TEXT(stored=True, field_boost=1.5), + name=ID(stored=True), + path=ID(stored=True), + content=TEXT(stored=True, analyzer=StemmingAnalyzer()), + keywords=KEYWORD(stored=True, scorable=True, commas=True), + ) + + def get_id(self): + return "name" + + def get_items_to_index(self): + """Get all routes to be indexed, this includes the static pages + in www/ and routes from published documents + + Returns: + self (object): FullTextSearch Instance + """ + items = get_all_published_items() + documents = [self.get_document_to_index(item) for item in items] + return documents + + def get_document_to_index(self, item): + try: + item = frappe.get_doc("Item", item) + title = item.item_name + keywords = [item.item_group] + + if item.brand: + keywords.append(item.brand) + + if item.website_image_alt: + keywords.append(item.website_image_alt) + + if item.has_variants and item.variant_based_on == "Item Attribute": + keywords = keywords + [attr.attribute for attr in item.attributes] + + if item.web_long_description: + content = strip_html_tags(item.web_long_description) + elif item.description: + content = strip_html_tags(item.description) + + return frappe._dict( + title=title, + name=item.name, + path=item.route, + content=content, + keywords=", ".join(keywords), + ) + except Exception: + pass + + def search(self, text, scope=None, limit=20): + """Search from the current index + + Args: + text (str): String to search for + scope (str, optional): Scope to limit the search. Defaults to None. + limit (int, optional): Limit number of search results. Defaults to 20. + + Returns: + [List(_dict)]: Search results + """ + ix = self.get_index() + + results = None + out = [] + + with ix.searcher() as searcher: + parser = MultifieldParser(["title", "content", "keywords"], ix.schema) + parser.remove_plugin_class(FieldsPlugin) + parser.remove_plugin_class(WildcardPlugin) + query = parser.parse(text) + + filter_scoped = None + if scope: + filter_scoped = Prefix(self.id, scope) + results = searcher.search(query, limit=limit, filter=filter_scoped) + + for r in results: + out.append(self.parse_result(r)) + + return out + + def parse_result(self, result): + title_highlights = result.highlights("title") + content_highlights = result.highlights("content") + keyword_highlights = result.highlights("keywords") + + return frappe._dict( + title=result["title"], + path=result["path"], + keywords=result["keywords"], + title_highlights=title_highlights, + content_highlights=content_highlights, + keyword_highlights=keyword_highlights, + ) + +def get_all_published_items(): + return frappe.get_all("Item", filters={"variant_of": "", "show_in_website": 1},pluck="name") + +def update_index_for_path(path): + search = ProductSearch(INDEX_NAME) + return search.update_index_by_name(path) + +def remove_document_from_index(path): + search = ProductSearch(INDEX_NAME) + return search.remove_document_from_index(path) + +def build_index_for_all_routes(): + search = ProductSearch(INDEX_NAME) + return search.build() \ No newline at end of file diff --git a/erpnext/shopping_cart/web_template/__init__.py b/erpnext/shopping_cart/web_template/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/hero_slider/__init__.py b/erpnext/shopping_cart/web_template/hero_slider/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.html b/erpnext/shopping_cart/web_template/hero_slider/hero_slider.html new file mode 100644 index 00000000000..1b3953435e4 --- /dev/null +++ b/erpnext/shopping_cart/web_template/hero_slider/hero_slider.html @@ -0,0 +1,85 @@ +{%- macro slide(image, title, subtitle, action, label, index, align="Left", theme="Dark") -%} +{%- set align_class = resolve_class({ + 'text-right': align == 'Right', + 'text-centre': align == 'Center', + 'text-left': align == 'Left', +}) -%} + +{%- set heading_class = resolve_class({ + 'text-white': theme == 'Dark', + '': theme == 'Light', +}) -%} + +{%- endmacro -%} + + + + + + \ No newline at end of file diff --git a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.json b/erpnext/shopping_cart/web_template/hero_slider/hero_slider.json new file mode 100644 index 00000000000..04fb1d27059 --- /dev/null +++ b/erpnext/shopping_cart/web_template/hero_slider/hero_slider.json @@ -0,0 +1,284 @@ +{ + "creation": "2020-11-17 15:21:51.207221", + "docstatus": 0, + "doctype": "Web Template", + "fields": [ + { + "fieldname": "slider_name", + "fieldtype": "Data", + "label": "Slider Name", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "show_indicators", + "fieldtype": "Check", + "label": "Show Indicators", + "reqd": 0 + }, + { + "default": "1", + "fieldname": "show_controls", + "fieldtype": "Check", + "label": "Show Controls", + "reqd": 0 + }, + { + "fieldname": "slide_1", + "fieldtype": "Section Break", + "label": "Slide 1", + "reqd": 0 + }, + { + "fieldname": "slide_1_image", + "fieldtype": "Attach Image", + "label": "Image", + "reqd": 0 + }, + { + "fieldname": "slide_1_title", + "fieldtype": "Data", + "label": "Title", + "reqd": 0 + }, + { + "fieldname": "slide_1_subtitle", + "fieldtype": "Small Text", + "label": "Subtitle", + "reqd": 0 + }, + { + "fieldname": "slide_1_primary_action_label", + "fieldtype": "Data", + "label": "Primary Action Label", + "reqd": 0 + }, + { + "fieldname": "slide_1_primary_action", + "fieldtype": "Data", + "label": "Primary Action", + "reqd": 0 + }, + { + "fieldname": "slide_1_content_align", + "fieldtype": "Select", + "label": "Content Align", + "options": "Left\nCentre\nRight", + "reqd": 0 + }, + { + "fieldname": "slide_1_theme", + "fieldtype": "Select", + "label": "Slide Theme", + "options": "Dark\nLight", + "reqd": 0 + }, + { + "fieldname": "slide_2", + "fieldtype": "Section Break", + "label": "Slide 2", + "reqd": 0 + }, + { + "fieldname": "slide_2_image", + "fieldtype": "Attach Image", + "label": "Image ", + "reqd": 0 + }, + { + "fieldname": "slide_2_title", + "fieldtype": "Data", + "label": "Title ", + "reqd": 0 + }, + { + "fieldname": "slide_2_subtitle", + "fieldtype": "Small Text", + "label": "Subtitle ", + "reqd": 0 + }, + { + "fieldname": "slide_2_primary_action_label", + "fieldtype": "Data", + "label": "Primary Action Label ", + "reqd": 0 + }, + { + "fieldname": "slide_2_primary_action", + "fieldtype": "Data", + "label": "Primary Action ", + "reqd": 0 + }, + { + "default": "Left", + "fieldname": "slide_2_content_align", + "fieldtype": "Select", + "label": "Content Align", + "options": "Left\nCentre\nRight", + "reqd": 0 + }, + { + "fieldname": "slide_2_theme", + "fieldtype": "Select", + "label": "Slide Theme", + "options": "Dark\nLight", + "reqd": 0 + }, + { + "fieldname": "slide_3", + "fieldtype": "Section Break", + "label": "Slide 3", + "reqd": 0 + }, + { + "fieldname": "slide_3_image", + "fieldtype": "Attach Image", + "label": "Image", + "reqd": 0 + }, + { + "fieldname": "slide_3_title", + "fieldtype": "Data", + "label": "Title", + "reqd": 0 + }, + { + "fieldname": "slide_3_subtitle", + "fieldtype": "Small Text", + "label": "Subtitle", + "reqd": 0 + }, + { + "fieldname": "slide_3_primary_action_label", + "fieldtype": "Data", + "label": "Primary Action Label", + "reqd": 0 + }, + { + "fieldname": "slide_3_primary_action", + "fieldtype": "Data", + "label": "Primary Action", + "reqd": 0 + }, + { + "fieldname": "slide_3_content_align", + "fieldtype": "Select", + "label": "Content Align", + "reqd": 0 + }, + { + "fieldname": "slide_3_theme", + "fieldtype": "Select", + "label": "Slide Theme", + "options": "Dark\nLight", + "reqd": 0 + }, + { + "fieldname": "slide_4", + "fieldtype": "Section Break", + "label": "Slide 4", + "reqd": 0 + }, + { + "fieldname": "slide_4_image", + "fieldtype": "Attach Image", + "label": "Image", + "reqd": 0 + }, + { + "fieldname": "slide_4_title", + "fieldtype": "Data", + "label": "Title", + "reqd": 0 + }, + { + "fieldname": "slide_4_subtitle", + "fieldtype": "Small Text", + "label": "Subtitle", + "reqd": 0 + }, + { + "fieldname": "slide_4_primary_action_label", + "fieldtype": "Data", + "label": "Primary Action Label", + "reqd": 0 + }, + { + "fieldname": "slide_4_primary_action", + "fieldtype": "Data", + "label": "Primary Action", + "reqd": 0 + }, + { + "fieldname": "slide_4_content_align", + "fieldtype": "Select", + "label": "Content Align", + "reqd": 0 + }, + { + "fieldname": "slide_4_theme", + "fieldtype": "Select", + "label": "Slide Theme", + "options": "Dark\nLight", + "reqd": 0 + }, + { + "fieldname": "slide_5", + "fieldtype": "Section Break", + "label": "Slide 5", + "reqd": 0 + }, + { + "fieldname": "slide_5_image", + "fieldtype": "Attach Image", + "label": "Image", + "reqd": 0 + }, + { + "fieldname": "slide_5_title", + "fieldtype": "Data", + "label": "Title", + "reqd": 0 + }, + { + "fieldname": "slide_5_subtitle", + "fieldtype": "Small Text", + "label": "Subtitle", + "reqd": 0 + }, + { + "fieldname": "slide_5_primary_action_label", + "fieldtype": "Data", + "label": "Primary Action Label", + "reqd": 0 + }, + { + "fieldname": "slide_5_primary_action", + "fieldtype": "Data", + "label": "Primary Action", + "reqd": 0 + }, + { + "fieldname": "slide_5_content_align", + "fieldtype": "Select", + "label": "Content Align", + "reqd": 0 + }, + { + "fieldname": "slide_5_theme", + "fieldtype": "Select", + "label": "Slide Theme", + "options": "Dark\nLight", + "reqd": 0 + } + ], + "idx": 2, + "modified": "2020-12-29 12:30:02.794994", + "modified_by": "Administrator", + "module": "Shopping Cart", + "name": "Hero Slider", + "owner": "Administrator", + "standard": 1, + "template": "", + "type": "Section" +} \ No newline at end of file diff --git a/erpnext/shopping_cart/web_template/item_card_group/__init__.py b/erpnext/shopping_cart/web_template/item_card_group/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.html b/erpnext/shopping_cart/web_template/item_card_group/item_card_group.html new file mode 100644 index 00000000000..890ae502c82 --- /dev/null +++ b/erpnext/shopping_cart/web_template/item_card_group/item_card_group.html @@ -0,0 +1,38 @@ +{% from "erpnext/templates/includes/macros.html" import item_card, item_card_body %} + +
    +
    +
    + {%- if title -%} +

    {{ title }}

    + {%- endif -%} + {%- if subtitle -%} +

    {{ subtitle }}

    + {%- endif -%} +
    +
    + {%- if primary_action -%} + + {{ primary_action_label }} + + {%- endif -%} +
    +
    + +
    + {%- for index in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] -%} + {%- set item = values['card_' + index + '_item'] -%} + {%- if item -%} + {%- set item = frappe.get_doc("Item", item) -%} + {{ item_card( + item.item_name, item.image, item.route, item.description, + None, item.item_group, values['card_' + index + '_featured'], + True, "Center" + ) }} + {%- endif -%} + {%- endfor -%} +
    +
    + + \ No newline at end of file diff --git a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.json b/erpnext/shopping_cart/web_template/item_card_group/item_card_group.json new file mode 100644 index 00000000000..ad087b04704 --- /dev/null +++ b/erpnext/shopping_cart/web_template/item_card_group/item_card_group.json @@ -0,0 +1,273 @@ +{ + "__unsaved": 1, + "creation": "2020-11-17 15:35:05.285322", + "docstatus": 0, + "doctype": "Web Template", + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "subtitle", + "fieldtype": "Data", + "label": "Subtitle", + "reqd": 0 + }, + { + "__unsaved": 1, + "fieldname": "primary_action_label", + "fieldtype": "Data", + "label": "Primary Action Label", + "reqd": 0 + }, + { + "__islocal": 1, + "__unsaved": 1, + "fieldname": "primary_action", + "fieldtype": "Data", + "label": "Primary Action", + "reqd": 0 + }, + { + "fieldname": "card_1", + "fieldtype": "Section Break", + "label": "Card 1", + "reqd": 0 + }, + { + "fieldname": "card_1_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_1_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_2", + "fieldtype": "Section Break", + "label": "Card 2", + "reqd": 0 + }, + { + "fieldname": "card_2_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_2_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_3", + "fieldtype": "Section Break", + "label": "Card 3", + "options": "", + "reqd": 0 + }, + { + "fieldname": "card_3_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_3_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_4", + "fieldtype": "Section Break", + "label": "Card 4", + "reqd": 0 + }, + { + "fieldname": "card_4_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_4_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_5", + "fieldtype": "Section Break", + "label": "Card 5", + "reqd": 0 + }, + { + "fieldname": "card_5_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_5_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_6", + "fieldtype": "Section Break", + "label": "Card 6", + "reqd": 0 + }, + { + "fieldname": "card_6_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_6_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_7", + "fieldtype": "Section Break", + "label": "Card 7", + "reqd": 0 + }, + { + "fieldname": "card_7_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_7_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_8", + "fieldtype": "Section Break", + "label": "Card 8", + "reqd": 0 + }, + { + "fieldname": "card_8_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_8_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_9", + "fieldtype": "Section Break", + "label": "Card 9", + "reqd": 0 + }, + { + "fieldname": "card_9_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_9_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_10", + "fieldtype": "Section Break", + "label": "Card 10", + "reqd": 0 + }, + { + "fieldname": "card_10_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_10_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_11", + "fieldtype": "Section Break", + "label": "Card 11", + "reqd": 0 + }, + { + "fieldname": "card_11_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_11_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + }, + { + "fieldname": "card_12", + "fieldtype": "Section Break", + "label": "Card 12", + "reqd": 0 + }, + { + "fieldname": "card_12_item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "fieldname": "card_12_featured", + "fieldtype": "Check", + "label": "Featured", + "reqd": 0 + } + ], + "idx": 0, + "modified": "2020-11-19 18:48:52.633045", + "modified_by": "Administrator", + "module": "Shopping Cart", + "name": "Item Card Group", + "owner": "Administrator", + "standard": 1, + "template": "", + "type": "Section" +} \ No newline at end of file diff --git a/erpnext/shopping_cart/web_template/product_card/__init__.py b/erpnext/shopping_cart/web_template/product_card/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/product_card/product_card.html b/erpnext/shopping_cart/web_template/product_card/product_card.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/product_card/product_card.json b/erpnext/shopping_cart/web_template/product_card/product_card.json new file mode 100644 index 00000000000..1059c1b2519 --- /dev/null +++ b/erpnext/shopping_cart/web_template/product_card/product_card.json @@ -0,0 +1,33 @@ +{ + "__unsaved": 1, + "creation": "2020-11-17 15:28:47.809342", + "docstatus": 0, + "doctype": "Web Template", + "fields": [ + { + "__unsaved": 1, + "fieldname": "item", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + "reqd": 0 + }, + { + "__unsaved": 1, + "fieldname": "featured", + "fieldtype": "Check", + "label": "Featured", + "options": "", + "reqd": 0 + } + ], + "idx": 0, + "modified": "2020-11-17 15:33:34.982515", + "modified_by": "Administrator", + "module": "Shopping Cart", + "name": "Product Card", + "owner": "Administrator", + "standard": 1, + "template": "", + "type": "Component" +} \ No newline at end of file diff --git a/erpnext/shopping_cart/web_template/product_category_cards/__init__.py b/erpnext/shopping_cart/web_template/product_category_cards/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html b/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html new file mode 100644 index 00000000000..06b76af9018 --- /dev/null +++ b/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html @@ -0,0 +1,40 @@ +{%- macro card(title, image, url, text_primary=False) -%} +{%- set align_class = resolve_class({ + 'text-right': text_primary, + 'text-centre': align == 'Center', + 'text-left': align == 'Left', +}) -%} +
    + {% if image %} + {{ title }} + {% endif %} +
    + {{ title or '' }} +
    + +
    +{%- endmacro -%} + +
    + {%- if title -%} +

    {{ title }}

    + {%- endif -%} + {%- if subtitle -%} +

    {{ subtitle }}

    + {%- endif -%} + +
    +
    + {%- for index in ['1', '2', '3', '4', '5', '6', '7', '8'] -%} + {%- set category = values['category_' + index] -%} + {%- if category -%} + {%- set category = frappe.get_doc("Item Group", category) -%} + {{ card(category.name, category.image, category.route) }} + {%- endif -%} + {%- endfor -%} +
    +
    +
    + + diff --git a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json b/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json new file mode 100644 index 00000000000..ba5f63b48b2 --- /dev/null +++ b/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json @@ -0,0 +1,85 @@ +{ + "__unsaved": 1, + "creation": "2020-11-17 15:25:50.855934", + "docstatus": 0, + "doctype": "Web Template", + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "subtitle", + "fieldtype": "Data", + "label": "Subtitle", + "reqd": 0 + }, + { + "fieldname": "category_1", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "reqd": 0 + }, + { + "fieldname": "category_2", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "reqd": 0 + }, + { + "fieldname": "category_3", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "reqd": 0 + }, + { + "fieldname": "category_4", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "reqd": 0 + }, + { + "fieldname": "category_5", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "reqd": 0 + }, + { + "fieldname": "category_6", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "reqd": 0 + }, + { + "fieldname": "category_7", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "reqd": 0 + }, + { + "fieldname": "category_8", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + "reqd": 0 + } + ], + "idx": 0, + "modified": "2020-11-18 17:26:28.726260", + "modified_by": "Administrator", + "module": "Shopping Cart", + "name": "Product Category Cards", + "owner": "Administrator", + "standard": 1, + "template": "", + "type": "Section" +} \ No newline at end of file diff --git a/erpnext/startup/filters.py b/erpnext/startup/filters.py index a99e49b4917..ec07329dedf 100644 --- a/erpnext/startup/filters.py +++ b/erpnext/startup/filters.py @@ -2,13 +2,13 @@ import frappe def get_filters_config(): - filters_config = { + filters_config = { "fiscal year": { "label": "Fiscal Year", "get_field": "erpnext.accounts.utils.get_fiscal_year_filter_field", "valid_for_fieldtypes": ["Date", "Datetime", "DateRange"], "depends_on": "company", } - } + } - return filters_config \ No newline at end of file + return filters_config \ No newline at end of file diff --git a/erpnext/startup/leaderboard.py b/erpnext/startup/leaderboard.py index ef238f1165d..8819a55c0ab 100644 --- a/erpnext/startup/leaderboard.py +++ b/erpnext/startup/leaderboard.py @@ -12,6 +12,7 @@ def get_leaderboards(): {'fieldname': 'outstanding_amount', 'fieldtype': 'Currency'} ], "method": "erpnext.startup.leaderboard.get_all_customers", + "icon": "customer" }, "Item": { "fields": [ @@ -23,6 +24,7 @@ def get_leaderboards(): {'fieldname': 'available_stock_value', 'fieldtype': 'Currency'} ], "method": "erpnext.startup.leaderboard.get_all_items", + "icon": "stock" }, "Supplier": { "fields": [ @@ -31,6 +33,7 @@ def get_leaderboards(): {'fieldname': 'outstanding_amount', 'fieldtype': 'Currency'} ], "method": "erpnext.startup.leaderboard.get_all_suppliers", + "icon": "buying" }, "Sales Partner": { "fields": [ @@ -38,12 +41,14 @@ def get_leaderboards(): {'fieldname': 'total_commission', 'fieldtype': 'Currency'} ], "method": "erpnext.startup.leaderboard.get_all_sales_partner", + "icon": "hr" }, "Sales Person": { "fields": [ {'fieldname': 'total_sales_amount', 'fieldtype': 'Currency'} ], "method": "erpnext.startup.leaderboard.get_all_sales_person", + "icon": "customer" } } diff --git a/erpnext/stock/__init__.py b/erpnext/stock/__init__.py index 8d64efe41dd..283f7d5fdaf 100644 --- a/erpnext/stock/__init__.py +++ b/erpnext/stock/__init__.py @@ -38,7 +38,7 @@ def get_warehouse_account_map(company=None): frappe.flags.warehouse_account_map[company] = warehouse_account else: frappe.flags.warehouse_account_map = warehouse_account - + return frappe.flags.warehouse_account_map.get(company) or frappe.flags.warehouse_account_map def get_warehouse_account(warehouse, warehouse_account=None): @@ -65,9 +65,13 @@ def get_warehouse_account(warehouse, warehouse_account=None): account = get_company_default_inventory_account(warehouse.company) if not account and warehouse.company: + account = frappe.db.get_value('Account', + {'account_type': 'Stock', 'is_group': 0, 'company': warehouse.company}, 'name') + + if not account and warehouse.company and not warehouse.is_group: frappe.throw(_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}") .format(warehouse.name, warehouse.company)) return account def get_company_default_inventory_account(company): - return frappe.get_cached_value('Company', company, 'default_inventory_account') + return frappe.get_cached_value('Company', company, 'default_inventory_account') diff --git a/erpnext/stock/dashboard/item_dashboard.js b/erpnext/stock/dashboard/item_dashboard.js index 9bd03d45cbb..95cb92b1b36 100644 --- a/erpnext/stock/dashboard/item_dashboard.js +++ b/erpnext/stock/dashboard/item_dashboard.js @@ -24,6 +24,16 @@ erpnext.stock.ItemDashboard = Class.extend({ handle_move_add($(this), "Add") }); + this.content.on('click', '.btn-edit', function() { + let item = unescape($(this).attr('data-item')); + let warehouse = unescape($(this).attr('data-warehouse')); + let company = unescape($(this).attr('data-company')); + frappe.db.get_value('Putaway Rule', + {'item_code': item, 'warehouse': warehouse, 'company': company}, 'name', (r) => { + frappe.set_route("Form", "Putaway Rule", r.name); + }); + }); + function handle_move_add(element, action) { let item = unescape(element.attr('data-item')); let warehouse = unescape(element.attr('data-warehouse')); @@ -59,7 +69,7 @@ erpnext.stock.ItemDashboard = Class.extend({ // more this.content.find('.btn-more').on('click', function() { - me.start += 20; + me.start += me.page_length; me.refresh(); }); @@ -69,33 +79,43 @@ erpnext.stock.ItemDashboard = Class.extend({ this.before_refresh(); } + let args = { + item_code: this.item_code, + warehouse: this.warehouse, + parent_warehouse: this.parent_warehouse, + item_group: this.item_group, + company: this.company, + start: this.start, + sort_by: this.sort_by, + sort_order: this.sort_order + }; + var me = this; frappe.call({ - method: 'erpnext.stock.dashboard.item_dashboard.get_data', - args: { - item_code: this.item_code, - warehouse: this.warehouse, - item_group: this.item_group, - start: this.start, - sort_by: this.sort_by, - sort_order: this.sort_order, - }, + method: this.method, + args: args, callback: function(r) { me.render(r.message); } }); }, render: function(data) { - if(this.start===0) { + if (this.start===0) { this.max_count = 0; this.result.empty(); } - var context = this.get_item_dashboard_data(data, this.max_count, true); + let context = ""; + if (this.page_name === "warehouse-capacity-summary") { + context = this.get_capacity_dashboard_data(data); + } else { + context = this.get_item_dashboard_data(data, this.max_count, true); + } + this.max_count = this.max_count; // show more button - if(data && data.length===21) { + if (data && data.length===(this.page_length + 1)) { this.content.find('.more').removeClass('hidden'); // remove the last element @@ -106,12 +126,17 @@ erpnext.stock.ItemDashboard = Class.extend({ // If not any stock in any warehouses provide a message to end user if (context.data.length > 0) { - $(frappe.render_template('item_dashboard_list', context)).appendTo(this.result); + this.content.find('.result').css('text-align', 'unset'); + $(frappe.render_template(this.template, context)).appendTo(this.result); } else { - var message = __("Currently no stock available in any warehouse"); - $(` ${message} `).appendTo(this.result); + var message = __("No Stock Available Currently"); + this.content.find('.result').css('text-align', 'center'); + + $(`
    + ${message}
    `).appendTo(this.result); } }, + get_item_dashboard_data: function(data, max_count, show_item) { if(!max_count) max_count = 0; if(!data) data = []; @@ -128,8 +153,8 @@ erpnext.stock.ItemDashboard = Class.extend({ d.total_reserved, max_count); }); - var can_write = 0; - if(frappe.boot.user.can_write.indexOf("Stock Entry")>=0){ + let can_write = 0; + if (frappe.boot.user.can_write.indexOf("Stock Entry") >= 0) { can_write = 1; } @@ -138,9 +163,27 @@ erpnext.stock.ItemDashboard = Class.extend({ max_count: max_count, can_write:can_write, show_item: show_item || false + }; + }, + + get_capacity_dashboard_data: function(data) { + if (!data) data = []; + + data.forEach(function(d) { + d.color = d.percent_occupied >=80 ? "#f8814f" : "#2490ef"; + }); + + let can_write = 0; + if (frappe.boot.user.can_write.indexOf("Putaway Rule") >= 0) { + can_write = 1; } + + return { + data: data, + can_write: can_write, + }; } -}) +}); erpnext.stock.move_item = function(item, source, target, actual_qty, rate, callback) { var dialog = new frappe.ui.Dialog({ @@ -198,7 +241,7 @@ erpnext.stock.move_item = function(item, source, target, actual_qty, rate, callb freeze: true, callback: function(r) { frappe.show_alert(__('Stock Entry {0} created', - ['' + r.message.name+ ''])); + ['' + r.message.name+ ''])); dialog.hide(); callback(r); }, diff --git a/erpnext/stock/dashboard/item_dashboard_list.html b/erpnext/stock/dashboard/item_dashboard_list.html index e1914ed76a2..0c10be462a1 100644 --- a/erpnext/stock/dashboard/item_dashboard_list.html +++ b/erpnext/stock/dashboard/item_dashboard_list.html @@ -1,10 +1,10 @@ {% for d in data %}
    -
    + -
    +
    {% if show_item %} {{ d.item_code }} @@ -12,7 +12,7 @@ {% endif %}
    -
    +
    {{ d.total_reserved }} @@ -40,7 +40,7 @@
    {% if can_write %} -
    +
    {% if d.actual_qty %}
  • {0}{1}
    + + + + + {2} +
    {0}{1}
    + """.format(_("Item"), _("Unassigned Qty"), formatted_item_rows) + + frappe.msgprint(msg, title=_("Insufficient Capacity"), is_minimizable=True, wide=True) + +def get_serial_nos_to_allocate(serial_nos, to_allocate): + if serial_nos: + allocated_serial_nos = serial_nos[0: cint(to_allocate)] + serial_nos[:] = serial_nos[cint(to_allocate):] # pop out allocated serial nos and modify list + return "\n".join(allocated_serial_nos) if allocated_serial_nos else "" + else: return "" \ No newline at end of file diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule_list.js b/erpnext/stock/doctype/putaway_rule/putaway_rule_list.js new file mode 100644 index 00000000000..725e91ee8d9 --- /dev/null +++ b/erpnext/stock/doctype/putaway_rule/putaway_rule_list.js @@ -0,0 +1,18 @@ +frappe.listview_settings['Putaway Rule'] = { + add_fields: ["disable"], + get_indicator: (doc) => { + if (doc.disable) { + return [__("Disabled"), "darkgrey", "disable,=,1"]; + } else { + return [__("Active"), "blue", "disable,=,0"]; + } + }, + + reports: [ + { + name: 'Warehouse Capacity Summary', + report_type: 'Page', + route: 'warehouse-capacity-summary' + } + ] +}; diff --git a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py new file mode 100644 index 00000000000..86f7dc3e084 --- /dev/null +++ b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py @@ -0,0 +1,389 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals +import frappe +import unittest +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.get_item_details import get_conversion_factor +from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry +from erpnext.stock.doctype.batch.test_batch import make_new_batch +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + +class TestPutawayRule(unittest.TestCase): + def setUp(self): + if not frappe.db.exists("Item", "_Rice"): + make_item("_Rice", { + 'is_stock_item': 1, + 'has_batch_no' : 1, + 'create_new_batch': 1, + 'stock_uom': 'Kg' + }) + + if not frappe.db.exists("Warehouse", {"warehouse_name": "Rack 1"}): + create_warehouse("Rack 1") + if not frappe.db.exists("Warehouse", {"warehouse_name": "Rack 2"}): + create_warehouse("Rack 2") + + self.warehouse_1 = frappe.db.get_value("Warehouse", {"warehouse_name": "Rack 1"}) + self.warehouse_2 = frappe.db.get_value("Warehouse", {"warehouse_name": "Rack 2"}) + + if not frappe.db.exists("UOM", "Bag"): + new_uom = frappe.new_doc("UOM") + new_uom.uom_name = "Bag" + new_uom.save() + + def test_putaway_rules_priority(self): + """Test if rule is applied by priority, irrespective of free space.""" + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200, + uom="Kg") + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=300, + uom="Kg", priority=2) + + pr = make_purchase_receipt(item_code="_Rice", qty=300, apply_putaway_rule=1, + do_not_submit=1) + self.assertEqual(len(pr.items), 2) + self.assertEqual(pr.items[0].qty, 200) + self.assertEqual(pr.items[0].warehouse, self.warehouse_1) + self.assertEqual(pr.items[1].qty, 100) + self.assertEqual(pr.items[1].warehouse, self.warehouse_2) + + pr.delete() + rule_1.delete() + rule_2.delete() + + def test_putaway_rules_with_same_priority(self): + """Test if rule with more free space is applied, + among two rules with same priority and capacity.""" + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=500, + uom="Kg") + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=500, + uom="Kg") + + # out of 500 kg capacity, occupy 100 kg in warehouse_1 + stock_receipt = make_stock_entry(item_code="_Rice", target=self.warehouse_1, qty=100, basic_rate=50) + + pr = make_purchase_receipt(item_code="_Rice", qty=700, apply_putaway_rule=1, + do_not_submit=1) + self.assertEqual(len(pr.items), 2) + self.assertEqual(pr.items[0].qty, 500) + # warehouse_2 has 500 kg free space, it is given priority + self.assertEqual(pr.items[0].warehouse, self.warehouse_2) + self.assertEqual(pr.items[1].qty, 200) + # warehouse_1 has 400 kg free space, it is given less priority + self.assertEqual(pr.items[1].warehouse, self.warehouse_1) + + stock_receipt.cancel() + pr.delete() + rule_1.delete() + rule_2.delete() + + def test_putaway_rules_with_insufficient_capacity(self): + """Test if qty exceeding capacity, is handled.""" + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=100, + uom="Kg") + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=200, + uom="Kg") + + pr = make_purchase_receipt(item_code="_Rice", qty=350, apply_putaway_rule=1, + do_not_submit=1) + self.assertEqual(len(pr.items), 2) + self.assertEqual(pr.items[0].qty, 200) + self.assertEqual(pr.items[0].warehouse, self.warehouse_2) + self.assertEqual(pr.items[1].qty, 100) + self.assertEqual(pr.items[1].warehouse, self.warehouse_1) + # total 300 assigned, 50 unassigned + + pr.delete() + rule_1.delete() + rule_2.delete() + + def test_putaway_rules_multi_uom(self): + """Test rules applied on uom other than stock uom.""" + item = frappe.get_doc("Item", "_Rice") + if not frappe.db.get_value("UOM Conversion Detail", {"parent": "_Rice", "uom": "Bag"}): + item.append("uoms", { + "uom": "Bag", + "conversion_factor": 1000 + }) + item.save() + + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=3, + uom="Bag") + self.assertEqual(rule_1.stock_capacity, 3000) + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=4, + uom="Bag") + self.assertEqual(rule_2.stock_capacity, 4000) + + # populate 'Rack 1' with 1 Bag, making the free space 2 Bags + stock_receipt = make_stock_entry(item_code="_Rice", target=self.warehouse_1, qty=1000, basic_rate=50) + + pr = make_purchase_receipt(item_code="_Rice", qty=6, uom="Bag", stock_uom="Kg", + conversion_factor=1000, apply_putaway_rule=1, do_not_submit=1) + self.assertEqual(len(pr.items), 2) + self.assertEqual(pr.items[0].qty, 4) + self.assertEqual(pr.items[0].warehouse, self.warehouse_2) + self.assertEqual(pr.items[1].qty, 2) + self.assertEqual(pr.items[1].warehouse, self.warehouse_1) + + stock_receipt.cancel() + pr.delete() + rule_1.delete() + rule_2.delete() + + def test_putaway_rules_multi_uom_whole_uom(self): + """Test if whole UOMs are handled.""" + item = frappe.get_doc("Item", "_Rice") + if not frappe.db.get_value("UOM Conversion Detail", {"parent": "_Rice", "uom": "Bag"}): + item.append("uoms", { + "uom": "Bag", + "conversion_factor": 1000 + }) + item.save() + + frappe.db.set_value("UOM", "Bag", "must_be_whole_number", 1) + + # Putaway Rule in different UOM + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=1, + uom="Bag") + self.assertEqual(rule_1.stock_capacity, 1000) + # Putaway Rule in Stock UOM + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=500) + self.assertEqual(rule_2.stock_capacity, 500) + # total capacity is 1500 Kg + + pr = make_purchase_receipt(item_code="_Rice", qty=2, uom="Bag", stock_uom="Kg", + conversion_factor=1000, apply_putaway_rule=1, do_not_submit=1) + self.assertEqual(len(pr.items), 1) + self.assertEqual(pr.items[0].qty, 1) + self.assertEqual(pr.items[0].warehouse, self.warehouse_1) + # leftover space was for 500 kg (0.5 Bag) + # Since Bag is a whole UOM, 1(out of 2) Bag will be unassigned + + pr.delete() + rule_1.delete() + rule_2.delete() + + def test_putaway_rules_with_reoccurring_item(self): + """Test rules on same item entered multiple times with different rate.""" + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200, + uom="Kg") + # total capacity is 200 Kg + + pr = make_purchase_receipt(item_code="_Rice", qty=100, apply_putaway_rule=1, + do_not_submit=1) + pr.append("items", { + "item_code": "_Rice", + "warehouse": "_Test Warehouse - _TC", + "qty": 200, + "uom": "Kg", + "stock_uom": "Kg", + "stock_qty": 200, + "received_qty": 200, + "rate": 100, + "conversion_factor": 1.0, + }) # same item entered again in PR but with different rate + pr.save() + self.assertEqual(len(pr.items), 2) + self.assertEqual(pr.items[0].qty, 100) + self.assertEqual(pr.items[0].warehouse, self.warehouse_1) + self.assertEqual(pr.items[0].putaway_rule, rule_1.name) + # same rule applied to second item row + # with previous assignment considered + self.assertEqual(pr.items[1].qty, 100) # 100 unassigned in second row from 200 + self.assertEqual(pr.items[1].warehouse, self.warehouse_1) + self.assertEqual(pr.items[1].putaway_rule, rule_1.name) + + pr.delete() + rule_1.delete() + + def test_validate_over_receipt_in_warehouse(self): + """Test if overreceipt is blocked in the presence of putaway rules.""" + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200, + uom="Kg") + + pr = make_purchase_receipt(item_code="_Rice", qty=300, apply_putaway_rule=1, + do_not_submit=1) + self.assertEqual(len(pr.items), 1) + self.assertEqual(pr.items[0].qty, 200) # 100 is unassigned fro 300 Kg + self.assertEqual(pr.items[0].warehouse, self.warehouse_1) + self.assertEqual(pr.items[0].putaway_rule, rule_1.name) + + # force overreceipt and disable apply putaway rule in PR + pr.items[0].qty = 300 + pr.items[0].stock_qty = 300 + pr.apply_putaway_rule = 0 + self.assertRaises(frappe.ValidationError, pr.save) + + pr.delete() + rule_1.delete() + + def test_putaway_rule_on_stock_entry_material_transfer(self): + """Test if source warehouse is considered while applying rules.""" + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200, + uom="Kg") # higher priority + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=100, + uom="Kg", priority=2) + + stock_entry = make_stock_entry(item_code="_Rice", source=self.warehouse_1, qty=200, + target="_Test Warehouse - _TC", purpose="Material Transfer", + apply_putaway_rule=1, do_not_submit=1) + + stock_entry_item = stock_entry.get("items")[0] + + # since source warehouse is Rack 1, rule 1 (for Rack 1) will be avoided + # even though it has more free space and higher priority + self.assertEqual(stock_entry_item.t_warehouse, self.warehouse_2) + self.assertEqual(stock_entry_item.qty, 100) # unassigned 100 out of 200 Kg + self.assertEqual(stock_entry_item.putaway_rule, rule_2.name) + + stock_entry.delete() + rule_1.delete() + rule_2.delete() + + def test_putaway_rule_on_stock_entry_material_transfer_reoccuring_item(self): + """Test if reoccuring item is correctly considered.""" + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=300, + uom="Kg") + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=600, + uom="Kg", priority=2) + + # create SE with first row having source warehouse as Rack 2 + stock_entry = make_stock_entry(item_code="_Rice", source=self.warehouse_2, qty=200, + target="_Test Warehouse - _TC", purpose="Material Transfer", + apply_putaway_rule=1, do_not_submit=1) + + # Add rows with source warehouse as Rack 1 + stock_entry.extend("items", [ + { + "item_code": "_Rice", + "s_warehouse": self.warehouse_1, + "t_warehouse": "_Test Warehouse - _TC", + "qty": 100, + "basic_rate": 50, + "conversion_factor": 1.0, + "transfer_qty": 100 + }, + { + "item_code": "_Rice", + "s_warehouse": self.warehouse_1, + "t_warehouse": "_Test Warehouse - _TC", + "qty": 200, + "basic_rate": 60, + "conversion_factor": 1.0, + "transfer_qty": 200 + } + ]) + + stock_entry.save() + + # since source warehouse was Rack 2, exclude rule_2 + self.assertEqual(stock_entry.items[0].t_warehouse, self.warehouse_1) + self.assertEqual(stock_entry.items[0].qty, 200) + self.assertEqual(stock_entry.items[0].putaway_rule, rule_1.name) + + # since source warehouse was Rack 1, exclude rule_1 even though it has + # higher priority + self.assertEqual(stock_entry.items[1].t_warehouse, self.warehouse_2) + self.assertEqual(stock_entry.items[1].qty, 100) + self.assertEqual(stock_entry.items[1].putaway_rule, rule_2.name) + + self.assertEqual(stock_entry.items[2].t_warehouse, self.warehouse_2) + self.assertEqual(stock_entry.items[2].qty, 200) + self.assertEqual(stock_entry.items[2].putaway_rule, rule_2.name) + + stock_entry.delete() + rule_1.delete() + rule_2.delete() + + def test_putaway_rule_on_stock_entry_material_transfer_batch_serial_item(self): + """Test if batch and serial items are split correctly.""" + if not frappe.db.exists("Item", "Water Bottle"): + make_item("Water Bottle", { + "is_stock_item": 1, + "has_batch_no" : 1, + "create_new_batch": 1, + "has_serial_no": 1, + "serial_no_series": "BOTTL-.####", + "stock_uom": "Nos" + }) + + rule_1 = create_putaway_rule(item_code="Water Bottle", warehouse=self.warehouse_1, capacity=3, + uom="Nos") + rule_2 = create_putaway_rule(item_code="Water Bottle", warehouse=self.warehouse_2, capacity=2, + uom="Nos") + + make_new_batch(batch_id="BOTTL-BATCH-1", item_code="Water Bottle") + + pr = make_purchase_receipt(item_code="Water Bottle", qty=5, do_not_submit=1) + pr.items[0].batch_no = "BOTTL-BATCH-1" + pr.save() + pr.submit() + + serial_nos = frappe.get_list("Serial No", filters={"purchase_document_no": pr.name, "status": "Active"}) + serial_nos = [d.name for d in serial_nos] + + stock_entry = make_stock_entry(item_code="Water Bottle", source="_Test Warehouse - _TC", qty=5, + target="Finished Goods - _TC", purpose="Material Transfer", + apply_putaway_rule=1, do_not_save=1) + stock_entry.items[0].batch_no = "BOTTL-BATCH-1" + stock_entry.items[0].serial_no = "\n".join(serial_nos) + stock_entry.save() + + self.assertEqual(stock_entry.items[0].t_warehouse, self.warehouse_1) + self.assertEqual(stock_entry.items[0].qty, 3) + self.assertEqual(stock_entry.items[0].putaway_rule, rule_1.name) + self.assertEqual(stock_entry.items[0].serial_no, "\n".join(serial_nos[:3])) + self.assertEqual(stock_entry.items[0].batch_no, "BOTTL-BATCH-1") + + self.assertEqual(stock_entry.items[1].t_warehouse, self.warehouse_2) + self.assertEqual(stock_entry.items[1].qty, 2) + self.assertEqual(stock_entry.items[1].putaway_rule, rule_2.name) + self.assertEqual(stock_entry.items[1].serial_no, "\n".join(serial_nos[3:])) + self.assertEqual(stock_entry.items[1].batch_no, "BOTTL-BATCH-1") + + stock_entry.delete() + pr.cancel() + rule_1.delete() + rule_2.delete() + + def test_putaway_rule_on_stock_entry_material_receipt(self): + """Test if rules are applied in Stock Entry of type Receipt.""" + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200, + uom="Kg") # more capacity + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=100, + uom="Kg") + + stock_entry = make_stock_entry(item_code="_Rice", qty=100, + target="_Test Warehouse - _TC", purpose="Material Receipt", + apply_putaway_rule=1, do_not_submit=1) + + stock_entry_item = stock_entry.get("items")[0] + + self.assertEqual(stock_entry_item.t_warehouse, self.warehouse_1) + self.assertEqual(stock_entry_item.qty, 100) + self.assertEqual(stock_entry_item.putaway_rule, rule_1.name) + + stock_entry.delete() + rule_1.delete() + rule_2.delete() + +def create_putaway_rule(**args): + args = frappe._dict(args) + putaway = frappe.new_doc("Putaway Rule") + + putaway.disable = args.disable or 0 + putaway.company = args.company or "_Test Company" + putaway.item_code = args.item or args.item_code or "_Test Item" + putaway.warehouse = args.warehouse + putaway.priority = args.priority or 1 + putaway.capacity = args.capacity or 1 + putaway.stock_uom = frappe.db.get_value("Item", putaway.item_code, "stock_uom") + putaway.uom = args.uom or putaway.stock_uom + putaway.conversion_factor = get_conversion_factor(putaway.item_code, putaway.uom)['conversion_factor'] + + if not args.do_not_save: + putaway.save() + + return putaway \ No newline at end of file diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.js b/erpnext/stock/doctype/quality_inspection/quality_inspection.js index 22f29e05b49..f7565fd505c 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.js +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.js @@ -4,6 +4,60 @@ cur_frm.cscript.refresh = cur_frm.cscript.inspection_type; frappe.ui.form.on("Quality Inspection", { + + setup: function(frm) { + frm.set_query("batch_no", function() { + return { + filters: { + "item": frm.doc.item_code + } + }; + }); + + // Serial No based on item_code + frm.set_query("item_serial_no", function() { + let filters = {}; + if (frm.doc.item_code) { + filters = { + 'item_code': frm.doc.item_code + }; + } + return { filters: filters }; + }); + + // item code based on GRN/DN + frm.set_query("item_code", function(doc) { + let doctype = doc.reference_type; + + if (doc.reference_type !== "Job Card") { + doctype = (doc.reference_type == "Stock Entry") ? + "Stock Entry Detail" : doc.reference_type + " Item"; + } + + if (doc.reference_type && doc.reference_name) { + let filters = { + "from": doctype, + "inspection_type": doc.inspection_type + }; + + if (doc.reference_type == doctype) + filters["reference_name"] = doc.reference_name; + else + filters["parent"] = doc.reference_name; + + return { + query: "erpnext.stock.doctype.quality_inspection.quality_inspection.item_query", + filters: filters + }; + } + }); + }, + + refresh: function(frm) { + // Ignore cancellation of reference doctype on cancel all. + frm.ignore_doctypes_on_cancel_all = [frm.doc.reference_type]; + }, + item_code: function(frm) { if (frm.doc.item_code) { return frm.call({ @@ -26,45 +80,5 @@ frappe.ui.form.on("Quality Inspection", { } }); } - } -}) - -// item code based on GRN/DN -cur_frm.fields_dict['item_code'].get_query = function(doc, cdt, cdn) { - const doctype = (doc.reference_type == "Stock Entry") ? - "Stock Entry Detail" : doc.reference_type + " Item"; - - if (doc.reference_type && doc.reference_name) { - return { - query: "erpnext.stock.doctype.quality_inspection.quality_inspection.item_query", - filters: { - "from": doctype, - "parent": doc.reference_name, - "inspection_type": doc.inspection_type - } - }; - } -}, - -// Serial No based on item_code -cur_frm.fields_dict['item_serial_no'].get_query = function(doc, cdt, cdn) { - var filters = {}; - if (doc.item_code) { - filters = { - 'item_code': doc.item_code - } - } - return { filters: filters } -} - -cur_frm.set_query("batch_no", function(doc) { - return { - filters: { - "item": doc.item_code - } - } -}) - -cur_frm.add_fetch('item_code', 'item_name', 'item_name'); -cur_frm.add_fetch('item_code', 'description', 'description'); - + }, +}); \ No newline at end of file diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.json b/erpnext/stock/doctype/quality_inspection/quality_inspection.json index 3643174fb46..edfe7e98b2e 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.json +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.json @@ -73,7 +73,7 @@ "fieldname": "reference_type", "fieldtype": "Select", "label": "Reference Type", - "options": "\nPurchase Receipt\nPurchase Invoice\nDelivery Note\nSales Invoice\nStock Entry", + "options": "\nPurchase Receipt\nPurchase Invoice\nDelivery Note\nSales Invoice\nStock Entry\nJob Card", "reqd": 1 }, { @@ -136,6 +136,7 @@ "width": "50%" }, { + "fetch_from": "item_code.item_name", "fieldname": "item_name", "fieldtype": "Data", "in_global_search": 1, @@ -143,6 +144,7 @@ "read_only": 1 }, { + "fetch_from": "item_code.description", "fieldname": "description", "fieldtype": "Small Text", "label": "Description", @@ -236,7 +238,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-09-12 16:11:31.910508", + "modified": "2020-12-18 19:59:55.710300", "modified_by": "Administrator", "module": "Stock", "name": "Quality Inspection", @@ -257,7 +259,6 @@ "write": 1 } ], - "quick_entry": 1, "search_fields": "item_code, report_date, reference_name", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index c3bb5141849..58b1eca2d33 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -4,15 +4,20 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe import _ +from frappe.utils import flt, cint from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template \ import get_template_details -from frappe.model.mapper import get_mapped_doc class QualityInspection(Document): def validate(self): if not self.readings and self.item_code: self.get_item_specification_details() + if self.readings: + self.inspect_and_set_status() + def get_item_specification_details(self): if not self.quality_inspection_template: self.quality_inspection_template = frappe.db.get_value('Item', @@ -24,8 +29,7 @@ class QualityInspection(Document): parameters = get_template_details(self.quality_inspection_template) for d in parameters: child = self.append('readings', {}) - child.specification = d.specification - child.value = d.value + child.update(d) child.status = "Accepted" def get_quality_inspection_template(self): @@ -47,16 +51,114 @@ class QualityInspection(Document): def update_qc_reference(self): quality_inspection = self.name if self.docstatus == 1 else "" - doctype = self.reference_type + ' Item' - if self.reference_type == 'Stock Entry': - doctype = 'Stock Entry Detail' - if self.reference_type and self.reference_name: - frappe.db.sql("""update `tab{child_doc}` t1, `tab{parent_doc}` t2 - set t1.quality_inspection = %s, t2.modified = %s - where t1.parent = %s and t1.item_code = %s and t1.parent = t2.name""" - .format(parent_doc=self.reference_type, child_doc=doctype), - (quality_inspection, self.modified, self.reference_name, self.item_code)) + if self.reference_type == 'Job Card': + if self.reference_name: + frappe.db.sql(""" + UPDATE `tab{doctype}` + SET quality_inspection = %s, modified = %s + WHERE name = %s and production_item = %s + """.format(doctype=self.reference_type), + (quality_inspection, self.modified, self.reference_name, self.item_code)) + + else: + doctype = self.reference_type + ' Item' + if self.reference_type == 'Stock Entry': + doctype = 'Stock Entry Detail' + + if self.reference_type and self.reference_name: + conditions = "" + if self.batch_no and self.docstatus == 1: + conditions += " and t1.batch_no = '%s'"%(self.batch_no) + + if self.docstatus == 2: # if cancel, then remove qi link wherever same name + conditions += " and t1.quality_inspection = '%s'"%(self.name) + + frappe.db.sql(""" + UPDATE + `tab{child_doc}` t1, `tab{parent_doc}` t2 + SET + t1.quality_inspection = %s, t2.modified = %s + WHERE + t1.parent = %s + and t1.item_code = %s + and t1.parent = t2.name + {conditions} + """.format(parent_doc=self.reference_type, child_doc=doctype, conditions=conditions), + (quality_inspection, self.modified, self.reference_name, self.item_code)) + + def inspect_and_set_status(self): + for reading in self.readings: + if not reading.manual_inspection: # dont auto set status if manual + if reading.formula_based_criteria: + self.set_status_based_on_acceptance_formula(reading) + else: + # if not formula based check acceptance values set + self.set_status_based_on_acceptance_values(reading) + + def set_status_based_on_acceptance_values(self, reading): + if not cint(reading.numeric): + result = reading.get("reading_value") == reading.get("value") + else: + # numeric readings + result = self.min_max_criteria_passed(reading) + + reading.status = "Accepted" if result else "Rejected" + + def min_max_criteria_passed(self, reading): + """Determine whether all readings fall in the acceptable range.""" + for i in range(1, 11): + reading_value = reading.get("reading_" + str(i)) + if reading_value is not None and reading_value.strip(): + result = flt(reading.get("min_value")) <= flt(reading_value) <= flt(reading.get("max_value")) + if not result: return False + return True + + def set_status_based_on_acceptance_formula(self, reading): + if not reading.acceptance_formula: + frappe.throw(_("Row #{0}: Acceptance Criteria Formula is required.").format(reading.idx), + title=_("Missing Formula")) + + condition = reading.acceptance_formula + data = self.get_formula_evaluation_data(reading) + + try: + result = frappe.safe_eval(condition, None, data) + reading.status = "Accepted" if result else "Rejected" + except NameError as e: + field = frappe.bold(e.args[0].split()[1]) + frappe.throw(_("Row #{0}: {1} is not a valid reading field. Please refer to the field description.") + .format(reading.idx, field), + title=_("Invalid Formula")) + except Exception: + frappe.throw(_("Row #{0}: Acceptance Criteria Formula is incorrect.").format(reading.idx), + title=_("Invalid Formula")) + + def get_formula_evaluation_data(self, reading): + data = {} + if not cint(reading.numeric): + data = {"reading_value": reading.get("reading_value")} + else: + # numeric readings + for i in range(1, 11): + field = "reading_" + str(i) + data[field] = flt(reading.get(field)) + data["mean"] = self.calculate_mean(reading) + + return data + + def calculate_mean(self, reading): + """Calculate mean of all non-empty readings.""" + from statistics import mean + readings_list = [] + + for i in range(1, 11): + reading_value = reading.get("reading_" + str(i)) + if reading_value is not None and reading_value.strip(): + readings_list.append(flt(reading_value)) + + actual_mean = mean(readings_list) if readings_list else 0 + return actual_mean @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs @@ -66,27 +168,44 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): mcond = get_match_cond(filters["from"]) cond, qi_condition = "", "and (quality_inspection is null or quality_inspection = '')" - if filters.get('from') in ['Purchase Invoice Item', 'Purchase Receipt Item']\ - and filters.get("inspection_type") != "In Process": - cond = """and item_code in (select name from `tabItem` where - inspection_required_before_purchase = 1)""" - elif filters.get('from') in ['Sales Invoice Item', 'Delivery Note Item']\ - and filters.get("inspection_type") != "In Process": - cond = """and item_code in (select name from `tabItem` where - inspection_required_before_delivery = 1)""" - elif filters.get('from') == 'Stock Entry Detail': - cond = """and s_warehouse is null""" + if filters.get("parent"): + if filters.get('from') in ['Purchase Invoice Item', 'Purchase Receipt Item']\ + and filters.get("inspection_type") != "In Process": + cond = """and item_code in (select name from `tabItem` where + inspection_required_before_purchase = 1)""" + elif filters.get('from') in ['Sales Invoice Item', 'Delivery Note Item']\ + and filters.get("inspection_type") != "In Process": + cond = """and item_code in (select name from `tabItem` where + inspection_required_before_delivery = 1)""" + elif filters.get('from') == 'Stock Entry Detail': + cond = """and s_warehouse is null""" - if filters.get('from') in ['Supplier Quotation Item']: - qi_condition = "" + if filters.get('from') in ['Supplier Quotation Item']: + qi_condition = "" - return frappe.db.sql(""" select item_code from `tab{doc}` - where parent=%(parent)s and docstatus < 2 and item_code like %(txt)s - {qi_condition} {cond} {mcond} - order by item_code limit {start}, {page_len}""".format(doc=filters.get('from'), - parent=filters.get('parent'), cond = cond, mcond = mcond, start = start, - page_len = page_len, qi_condition = qi_condition), - {'parent': filters.get('parent'), 'txt': "%%%s%%" % txt}) + return frappe.db.sql(""" + SELECT item_code + FROM `tab{doc}` + WHERE parent=%(parent)s and docstatus < 2 and item_code like %(txt)s + {qi_condition} {cond} {mcond} + ORDER BY item_code limit {start}, {page_len} + """.format(doc=filters.get('from'), + cond = cond, mcond = mcond, start = start, + page_len = page_len, qi_condition = qi_condition), + {'parent': filters.get('parent'), 'txt': "%%%s%%" % txt}) + + elif filters.get("reference_name"): + return frappe.db.sql(""" + SELECT production_item + FROM `tab{doc}` + WHERE name = %(reference_name)s and docstatus < 2 and production_item like %(txt)s + {qi_condition} {cond} {mcond} + ORDER BY production_item + LIMIT {start}, {page_len} + """.format(doc=filters.get("from"), + cond = cond, mcond = mcond, start = start, + page_len = page_len, qi_condition = qi_condition), + {'reference_name': filters.get('reference_name'), 'txt': "%%%s%%" % txt}) @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index bb535c1f6a0..a7dfc9ee288 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -7,6 +7,7 @@ import unittest from frappe.utils import nowdate from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.controllers.stock_controller import QualityInspectionRejectedError, QualityInspectionRequiredError, QualityInspectionNotSubmittedError # test_records = frappe.get_test_records('Quality Inspection') @@ -17,10 +18,12 @@ class TestQualityInspection(unittest.TestCase): frappe.db.set_value("Item", "_Test Item with QA", "inspection_required_before_delivery", 1) def test_qa_for_delivery(self): + make_stock_entry(item_code="_Test Item with QA", target="_Test Warehouse - _TC", qty=1, basic_rate=100) dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + self.assertRaises(QualityInspectionRequiredError, dn.submit) - qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, status="Rejected", submit=True) + qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, status="Rejected") dn.reload() self.assertRaises(QualityInspectionRejectedError, dn.submit) @@ -28,12 +31,89 @@ class TestQualityInspection(unittest.TestCase): dn.reload() dn.submit() + qa.cancel() + dn.reload() + dn.cancel() + def test_qa_not_submit(self): dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) - qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, submit = False) + qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, do_not_submit=True) dn.items[0].quality_inspection = qa.name self.assertRaises(QualityInspectionNotSubmittedError, dn.submit) + qa.delete() + dn.delete() + + def test_value_based_qi_readings(self): + # Test QI based on acceptance values (Non formula) + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + readings = [{ + "specification": "Iron Content", # numeric reading + "min_value": 0.1, + "max_value": 0.9, + "reading_1": "0.4" + }, + { + "specification": "Particle Inspection Needed", # non-numeric reading + "numeric": 0, + "value": "Yes", + "reading_value": "Yes" + }] + + qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, + readings=readings, do_not_save=True) + qa.save() + + # status must be auto set as per formula + self.assertEqual(qa.readings[0].status, "Accepted") + self.assertEqual(qa.readings[1].status, "Accepted") + + qa.delete() + dn.delete() + + def test_formula_based_qi_readings(self): + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + readings = [{ + "specification": "Iron Content", # numeric reading + "formula_based_criteria": 1, + "acceptance_formula": "reading_1 > 0.35 and reading_1 < 0.50", + "reading_1": "0.4" + }, + { + "specification": "Calcium Content", # numeric reading + "formula_based_criteria": 1, + "acceptance_formula": "reading_1 > 0.20 and reading_1 < 0.50", + "reading_1": "0.7" + }, + { + "specification": "Mg Content", # numeric reading + "formula_based_criteria": 1, + "acceptance_formula": "mean < 0.9", + "reading_1": "0.5", + "reading_2": "0.7", + "reading_3": "random text" # check if random string input causes issues + }, + { + "specification": "Calcium Content", # non-numeric reading + "formula_based_criteria": 1, + "numeric": 0, + "acceptance_formula": "reading_value in ('Grade A', 'Grade B', 'Grade C')", + "reading_value": "Grade B" + }] + + qa = create_quality_inspection(reference_type="Delivery Note", reference_name=dn.name, + readings=readings, do_not_save=True) + qa.save() + + # status must be auto set as per formula + self.assertEqual(qa.readings[0].status, "Accepted") + self.assertEqual(qa.readings[1].status, "Rejected") + self.assertEqual(qa.readings[2].status, "Accepted") + self.assertEqual(qa.readings[3].status, "Accepted") + + qa.delete() + dn.delete() + def create_quality_inspection(**args): args = frappe._dict(args) qa = frappe.new_doc("Quality Inspection") @@ -44,12 +124,35 @@ def create_quality_inspection(**args): qa.item_code = args.item_code or "_Test Item with QA" qa.sample_size = 1 qa.inspected_by = frappe.session.user - qa.append("readings", { - "specification": "Size", - "status": args.status - }) - qa.save() - if args.submit: - qa.submit() + qa.status = args.status or "Accepted" + + if not args.readings: + create_quality_inspection_parameter("Size") + readings = {"specification": "Size", "min_value": 0, "max_value": 10} + else: + readings = args.readings + + if args.status == "Rejected": + readings["reading_1"] = "12" # status is auto set in child on save + + if isinstance(readings, list): + for entry in readings: + create_quality_inspection_parameter(entry["specification"]) + qa.append("readings", entry) + else: + qa.append("readings", readings) + + if not args.do_not_save: + qa.save() + if not args.do_not_submit: + qa.submit() return qa + +def create_quality_inspection_parameter(parameter): + if not frappe.db.exists("Quality Inspection Parameter", parameter): + frappe.get_doc({ + "doctype": "Quality Inspection Parameter", + "parameter": parameter, + "description": parameter + }).insert() \ No newline at end of file diff --git a/erpnext/stock/doctype/quality_inspection_parameter/__init__.py b/erpnext/stock/doctype/quality_inspection_parameter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.js b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.js new file mode 100644 index 00000000000..47c7e11d237 --- /dev/null +++ b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Quality Inspection Parameter', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json new file mode 100644 index 00000000000..418b4825f2f --- /dev/null +++ b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json @@ -0,0 +1,96 @@ +{ + "actions": [], + "autoname": "field:parameter", + "creation": "2020-12-28 17:06:00.254129", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "parameter", + "parameter_group", + "description" + ], + "fields": [ + { + "fieldname": "parameter", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Parameter", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description" + }, + { + "fieldname": "parameter_group", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Parameter Group", + "options": "Quality Inspection Parameter Group" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-02-19 20:33:30.657406", + "modified_by": "Administrator", + "module": "Stock", + "name": "Quality Inspection Parameter", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Quality Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing User", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.py b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.py new file mode 100644 index 00000000000..86784221a0c --- /dev/null +++ b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class QualityInspectionParameter(Document): + pass diff --git a/erpnext/stock/doctype/quality_inspection_parameter/test_quality_inspection_parameter.py b/erpnext/stock/doctype/quality_inspection_parameter/test_quality_inspection_parameter.py new file mode 100644 index 00000000000..cefdc0867b1 --- /dev/null +++ b/erpnext/stock/doctype/quality_inspection_parameter/test_quality_inspection_parameter.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestQualityInspectionParameter(unittest.TestCase): + pass diff --git a/erpnext/stock/doctype/quality_inspection_parameter_group/__init__.py b/erpnext/stock/doctype/quality_inspection_parameter_group/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/quality_inspection_parameter_group/quality_inspection_parameter_group.js b/erpnext/stock/doctype/quality_inspection_parameter_group/quality_inspection_parameter_group.js new file mode 100644 index 00000000000..8716a298716 --- /dev/null +++ b/erpnext/stock/doctype/quality_inspection_parameter_group/quality_inspection_parameter_group.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Quality Inspection Parameter Group', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/stock/doctype/quality_inspection_parameter_group/quality_inspection_parameter_group.json b/erpnext/stock/doctype/quality_inspection_parameter_group/quality_inspection_parameter_group.json new file mode 100644 index 00000000000..57264741a64 --- /dev/null +++ b/erpnext/stock/doctype/quality_inspection_parameter_group/quality_inspection_parameter_group.json @@ -0,0 +1,82 @@ +{ + "actions": [], + "autoname": "field:group_name", + "creation": "2021-02-04 18:44:12.223295", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "group_name" + ], + "fields": [ + { + "fieldname": "group_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Parameter Group Name", + "reqd": 1, + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-02-04 18:44:12.223295", + "modified_by": "Administrator", + "module": "Stock", + "name": "Quality Inspection Parameter Group", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Quality Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Manufacturing User", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/stock/doctype/quality_inspection_parameter_group/quality_inspection_parameter_group.py b/erpnext/stock/doctype/quality_inspection_parameter_group/quality_inspection_parameter_group.py new file mode 100644 index 00000000000..1a3b1a04639 --- /dev/null +++ b/erpnext/stock/doctype/quality_inspection_parameter_group/quality_inspection_parameter_group.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class QualityInspectionParameterGroup(Document): + pass diff --git a/erpnext/stock/doctype/quality_inspection_parameter_group/test_quality_inspection_parameter_group.py b/erpnext/stock/doctype/quality_inspection_parameter_group/test_quality_inspection_parameter_group.py new file mode 100644 index 00000000000..212d4b8c21b --- /dev/null +++ b/erpnext/stock/doctype/quality_inspection_parameter_group/test_quality_inspection_parameter_group.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestQualityInspectionParameterGroup(unittest.TestCase): + pass diff --git a/erpnext/stock/doctype/quality_inspection_reading/quality_inspection_reading.json b/erpnext/stock/doctype/quality_inspection_reading/quality_inspection_reading.json index f9f8a71c029..0eff5a8f003 100644 --- a/erpnext/stock/doctype/quality_inspection_reading/quality_inspection_reading.json +++ b/erpnext/stock/doctype/quality_inspection_reading/quality_inspection_reading.json @@ -1,45 +1,61 @@ { + "actions": [], "autoname": "hash", "creation": "2013-02-22 01:27:43", "doctype": "DocType", "editable_grid": 1, + "engine": "InnoDB", "field_order": [ "specification", + "parameter_group", + "status", "value", + "numeric", + "manual_inspection", + "column_break_4", + "min_value", + "max_value", + "formula_based_criteria", + "acceptance_formula", + "section_break_3", + "reading_value", + "section_break_14", "reading_1", "reading_2", "reading_3", "reading_4", + "column_break_10", "reading_5", "reading_6", "reading_7", "reading_8", + "column_break_14", "reading_9", - "reading_10", - "status" + "reading_10" ], "fields": [ { "columns": 3, "fieldname": "specification", - "fieldtype": "Data", + "fieldtype": "Link", "in_list_view": 1, "label": "Parameter", "oldfieldname": "specification", "oldfieldtype": "Data", + "options": "Quality Inspection Parameter", "reqd": 1 }, { "columns": 2, + "depends_on": "eval:(!doc.formula_based_criteria && !doc.numeric)", "fieldname": "value", "fieldtype": "Data", - "in_list_view": 1, - "label": "Acceptance Criteria", + "label": "Acceptance Criteria Value", "oldfieldname": "value", "oldfieldtype": "Data" }, { - "columns": 1, + "columns": 2, "fieldname": "reading_1", "fieldtype": "Data", "in_list_view": 1, @@ -51,7 +67,6 @@ "columns": 1, "fieldname": "reading_2", "fieldtype": "Data", - "in_list_view": 1, "label": "Reading 2", "oldfieldname": "reading_2", "oldfieldtype": "Data" @@ -60,7 +75,6 @@ "columns": 1, "fieldname": "reading_3", "fieldtype": "Data", - "in_list_view": 1, "label": "Reading 3", "oldfieldname": "reading_3", "oldfieldtype": "Data" @@ -123,16 +137,100 @@ "label": "Status", "oldfieldname": "status", "oldfieldtype": "Select", - "options": "Accepted\nRejected" + "options": "\nAccepted\nRejected" + }, + { + "depends_on": "eval:!doc.numeric", + "fieldname": "section_break_3", + "fieldtype": "Section Break", + "label": "Value Based Inspection" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "depends_on": "formula_based_criteria", + "description": "Simple Python formula applied on Reading fields.
    Numeric eg. 1: reading_1 > 0.2 and reading_1 < 0.5
    \nNumeric eg. 2: mean > 3.5 (mean of populated fields)
    \nValue based eg.: reading_value in (\"A\", \"B\", \"C\")", + "fieldname": "acceptance_formula", + "fieldtype": "Code", + "label": "Acceptance Criteria Formula" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_14", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "formula_based_criteria", + "fieldtype": "Check", + "label": "Formula Based Criteria" + }, + { + "depends_on": "eval:(!doc.formula_based_criteria && doc.numeric)", + "description": "Applied on each reading.", + "fieldname": "min_value", + "fieldtype": "Float", + "label": "Minimum Value" + }, + { + "depends_on": "eval:(!doc.formula_based_criteria && doc.numeric)", + "description": "Applied on each reading.", + "fieldname": "max_value", + "fieldtype": "Float", + "label": "Maximum Value" + }, + { + "columns": 2, + "depends_on": "eval:!doc.numeric", + "fieldname": "reading_value", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Reading Value" + }, + { + "depends_on": "numeric", + "fieldname": "section_break_14", + "fieldtype": "Section Break", + "label": "Numeric Inspection" + }, + { + "default": "0", + "description": "Set the status manually.", + "fieldname": "manual_inspection", + "fieldtype": "Check", + "label": "Manual Inspection" + }, + { + "default": "1", + "fieldname": "numeric", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Numeric" + }, + { + "fetch_from": "specification.parameter_group", + "fieldname": "parameter_group", + "fieldtype": "Link", + "label": "Parameter Group", + "options": "Quality Inspection Parameter Group", + "read_only": 1 } ], "idx": 1, "istable": 1, - "modified": "2019-07-11 18:48:12.667404", + "links": [], + "modified": "2021-02-04 19:15:37.991221", "modified_by": "Administrator", "module": "Stock", "name": "Quality Inspection Reading", "owner": "Administrator", "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/quality_inspection_template/quality_inspection_template.py b/erpnext/stock/doctype/quality_inspection_template/quality_inspection_template.py index 0d9a90312be..01d2031b3a4 100644 --- a/erpnext/stock/doctype/quality_inspection_template/quality_inspection_template.py +++ b/erpnext/stock/doctype/quality_inspection_template/quality_inspection_template.py @@ -12,5 +12,8 @@ class QualityInspectionTemplate(Document): def get_template_details(template): if not template: return [] - return frappe.get_all('Item Quality Inspection Parameter', fields=["specification", "value"], - filters={'parenttype': 'Quality Inspection Template', 'parent': template}, order_by="idx") \ No newline at end of file + return frappe.get_all('Item Quality Inspection Parameter', + fields=["specification", "value", "acceptance_formula", + "numeric", "formula_based_criteria", "min_value", "max_value"], + filters={'parenttype': 'Quality Inspection Template', 'parent': template}, + order_by="idx") \ No newline at end of file diff --git a/erpnext/stock/doctype/repost_item_valuation/__init__.py b/erpnext/stock/doctype/repost_item_valuation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js new file mode 100644 index 00000000000..b3e4286bccb --- /dev/null +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js @@ -0,0 +1,52 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Repost Item Valuation', { + setup: function(frm) { + frm.set_query("warehouse", () => { + let filters = { + 'is_group': 0 + }; + if (frm.doc.company) filters['company'] = frm.doc.company; + return {filters: filters}; + }); + + frm.set_query("voucher_type", () => { + return { + filters: { + name: ['in', ['Purchase Receipt', 'Purchase Invoice', 'Delivery Note', + 'Sales Invoice', 'Stock Entry', 'Stock Reconciliation']] + } + }; + }); + + if (frm.doc.company) { + frm.set_query("voucher_no", () => { + return { + filters: { + company: frm.doc.company + } + }; + }); + } + }, + refresh: function(frm) { + if (frm.doc.status == "Failed" && frm.doc.docstatus==1) { + frm.add_custom_button(__('Restart'), function () { + frm.trigger("restart_reposting"); + }).addClass("btn-primary"); + } + }, + + restart_reposting: function(frm) { + frappe.call({ + method: "restart_reposting", + doc: frm.doc, + callback: function(r) { + if (!r.exc) { + frm.refresh(); + } + } + }); + } +}); diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json new file mode 100644 index 00000000000..071fc86d9b3 --- /dev/null +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json @@ -0,0 +1,215 @@ +{ + "actions": [], + "autoname": "REPOST-ITEM-VAL-.######", + "creation": "2020-10-22 22:27:07.742161", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "based_on", + "voucher_type", + "voucher_no", + "item_code", + "warehouse", + "posting_date", + "posting_time", + "column_break_5", + "status", + "company", + "allow_negative_stock", + "via_landed_cost_voucher", + "allow_zero_rate", + "amended_from", + "error_section", + "error_log" + ], + "fields": [ + { + "depends_on": "eval:doc.based_on=='Item and Warehouse'", + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item Code", + "mandatory_depends_on": "eval:doc.based_on=='Item and Warehouse'", + "options": "Item" + }, + { + "depends_on": "eval:doc.based_on=='Item and Warehouse'", + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "mandatory_depends_on": "eval:doc.based_on=='Item and Warehouse'", + "options": "Warehouse" + }, + { + "fetch_from": "voucher_no.posting_date", + "fieldname": "posting_date", + "fieldtype": "Date", + "label": "Posting Date", + "reqd": 1 + }, + { + "fetch_from": "voucher_no.posting_time", + "fieldname": "posting_time", + "fieldtype": "Time", + "label": "Posting Time" + }, + { + "default": "Queued", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "no_copy": 1, + "options": "Queued\nIn Progress\nCompleted\nFailed", + "read_only": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Repost Item Valuation", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.status=='Failed'", + "fieldname": "error_section", + "fieldtype": "Section Break", + "label": "Error" + }, + { + "fieldname": "error_log", + "fieldtype": "Long Text", + "label": "Error Log", + "no_copy": 1, + "read_only": 1 + }, + { + "fetch_from": "warehouse.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "depends_on": "eval:doc.based_on=='Transaction'", + "fieldname": "voucher_type", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Voucher Type", + "mandatory_depends_on": "eval:doc.based_on=='Transaction'", + "options": "DocType" + }, + { + "depends_on": "eval:doc.based_on=='Transaction'", + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Voucher No", + "mandatory_depends_on": "eval:doc.based_on=='Transaction'", + "options": "voucher_type" + }, + { + "default": "Transaction", + "fieldname": "based_on", + "fieldtype": "Select", + "label": "Based On", + "options": "Transaction\nItem and Warehouse", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "allow_negative_stock", + "fieldtype": "Check", + "label": "Allow Negative Stock" + }, + { + "default": "0", + "fieldname": "via_landed_cost_voucher", + "fieldtype": "Check", + "label": "Via Landed Cost Voucher" + }, + { + "default": "0", + "fieldname": "allow_zero_rate", + "fieldtype": "Check", + "label": "Allow Zero Rate" + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2020-12-10 07:52:12.476589", + "modified_by": "Administrator", + "module": "Stock", + "name": "Repost Item Valuation", + "owner": "Administrator", + "permissions": [ + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py new file mode 100644 index 00000000000..559f9a5ed9e --- /dev/null +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe, erpnext +from frappe.model.document import Document +from frappe.utils import cint, get_link_to_form, add_to_date, today +from erpnext.stock.stock_ledger import repost_future_sle +from erpnext.accounts.utils import update_gl_entries_after, check_if_stock_and_account_balance_synced +from frappe.utils.user import get_users_with_role +from frappe import _ +class RepostItemValuation(Document): + def validate(self): + self.set_status() + self.reset_field_values() + self.set_company() + + def reset_field_values(self): + if self.based_on == 'Transaction': + self.item_code = None + self.warehouse = None + else: + self.voucher_type = None + self.voucher_no = None + + def set_company(self): + if self.voucher_type and self.voucher_no: + self.company = frappe.get_cached_value(self.voucher_type, self.voucher_no, "company") + elif self.warehouse: + self.company = frappe.get_cached_value("Warehouse", self.warehouse, "company") + + def set_status(self, status=None): + if not status: + status = 'Queued' + self.db_set('status', status) + + def on_submit(self): + frappe.enqueue(repost, timeout=1800, queue='long', + job_name='repost_sle', now=frappe.flags.in_test, doc=self) + + def restart_reposting(self): + self.set_status('Queued') + frappe.enqueue(repost, timeout=1800, queue='long', + job_name='repost_sle', now=True, doc=self) + +def repost(doc): + try: + if not frappe.db.exists("Repost Item Valuation", doc.name): + return + + doc.set_status('In Progress') + frappe.db.commit() + + repost_sl_entries(doc) + repost_gl_entries(doc) + + doc.set_status('Completed') + except Exception: + frappe.db.rollback() + traceback = frappe.get_traceback() + frappe.log_error(traceback) + + message = frappe.message_log.pop() + if traceback: + message += "
    " + "Traceback:
    " + traceback + frappe.db.set_value(doc.doctype, doc.name, 'error_log', message) + + notify_error_to_stock_managers(doc, message) + doc.set_status('Failed') + raise + finally: + frappe.db.commit() + +def repost_sl_entries(doc): + if doc.based_on == 'Transaction': + repost_future_sle(voucher_type=doc.voucher_type, voucher_no=doc.voucher_no, + allow_negative_stock=doc.allow_negative_stock, via_landed_cost_voucher=doc.via_landed_cost_voucher) + else: + repost_future_sle(args=[frappe._dict({ + "item_code": doc.item_code, + "warehouse": doc.warehouse, + "posting_date": doc.posting_date, + "posting_time": doc.posting_time + })], allow_negative_stock=doc.allow_negative_stock, via_landed_cost_voucher=doc.via_landed_cost_voucher) + +def repost_gl_entries(doc): + if not cint(erpnext.is_perpetual_inventory_enabled(doc.company)): + return + + if doc.based_on == 'Transaction': + ref_doc = frappe.get_doc(doc.voucher_type, doc.voucher_no) + items, warehouses = ref_doc.get_items_and_warehouses() + else: + items = [doc.item_code] + warehouses = [doc.warehouse] + + update_gl_entries_after(doc.posting_date, doc.posting_time, + warehouses, items, company=doc.company) + +def notify_error_to_stock_managers(doc, traceback): + recipients = get_users_with_role("Stock Manager") + if not recipients: + get_users_with_role("System Manager") + + subject = _("Error while reposting item valuation") + message = (_("Hi,") + "
    " + + _("An error has been appeared while reposting item valuation via {0}") + .format(get_link_to_form(doc.doctype, doc.name)) + "
    " + + _("Please check the error message and take necessary actions to fix the error and then restart the reposting again.") + ) + frappe.sendmail(recipients=recipients, subject=subject, message=message) + +def repost_entries(): + riv_entries = get_repost_item_valuation_entries() + + for row in riv_entries: + doc = frappe.get_cached_doc('Repost Item Valuation', row.name) + repost(doc) + + riv_entries = get_repost_item_valuation_entries() + if riv_entries: + return + + for d in frappe.get_all('Company', filters= {'enable_perpetual_inventory': 1}): + check_if_stock_and_account_balance_synced(today(), d.company) + +def get_repost_item_valuation_entries(): + date = add_to_date(today(), hours=-12) + + return frappe.db.sql(""" SELECT name from `tabRepost Item Valuation` + WHERE status != 'Completed' and creation <= %s and docstatus = 1 + ORDER BY timestamp(posting_date, posting_time) asc, creation asc + """, date, as_dict=1) \ No newline at end of file diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py new file mode 100644 index 00000000000..13ceb68669c --- /dev/null +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestRepostItemValuation(unittest.TestCase): + pass diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index f7ff916c5a2..c8d8ca9e17e 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -6,12 +6,13 @@ import frappe import json from frappe.model.naming import make_autoname -from frappe.utils import cint, cstr, flt, add_days, nowdate, getdate +from frappe.utils import cint, cstr, flt, add_days, nowdate, getdate, get_link_to_form from erpnext.stock.get_item_details import get_reserved_qty_for_so from frappe import _, ValidationError from erpnext.controllers.stock_controller import StockController +from six import string_types from six.moves import map class SerialNoCannotCreateDirectError(ValidationError): pass class SerialNoCannotCannotChangeError(ValidationError): pass @@ -133,17 +134,13 @@ class SerialNo(StockController): sle_dict = self.get_stock_ledger_entries(serial_no) if sle_dict: if sle_dict.get("incoming", []): - sle_list = [sle for sle in sle_dict["incoming"] if sle.is_cancelled == 0] - if sle_list: - entries["purchase_sle"] = sle_list[0] + entries["purchase_sle"] = sle_dict["incoming"][0] if len(sle_dict.get("incoming", [])) - len(sle_dict.get("outgoing", [])) > 0: entries["last_sle"] = sle_dict["incoming"][0] else: entries["last_sle"] = sle_dict["outgoing"][0] - sle_list = [sle for sle in sle_dict["outgoing"] if sle.is_cancelled == 0] - if sle_list: - entries["delivery_sle"] = sle_list[0] + entries["delivery_sle"] = sle_dict["outgoing"][0] return entries @@ -154,11 +151,12 @@ class SerialNo(StockController): for sle in frappe.db.sql(""" SELECT voucher_type, voucher_no, - posting_date, posting_time, incoming_rate, actual_qty, serial_no, is_cancelled + posting_date, posting_time, incoming_rate, actual_qty, serial_no FROM `tabStock Ledger Entry` WHERE item_code=%s AND company = %s + AND is_cancelled = 0 AND (serial_no = %s OR serial_no like %s OR serial_no like %s @@ -178,7 +176,7 @@ class SerialNo(StockController): def on_trash(self): sl_entries = frappe.db.sql("""select serial_no from `tabStock Ledger Entry` - where serial_no like %s and item_code=%s""", + where serial_no like %s and item_code=%s and is_cancelled=0""", ("%%%s%%" % self.name, self.item_code), as_dict=True) # Find the exact match @@ -228,7 +226,7 @@ def validate_serial_no(sle, item_det): if serial_nos: frappe.throw(_("Item {0} is not setup for Serial Nos. Column must be blank").format(sle.item_code), SerialNoNotRequiredError) - else: + elif not sle.is_cancelled: if serial_nos: if cint(sle.actual_qty) != flt(sle.actual_qty): frappe.throw(_("Serial No {0} quantity {1} cannot be a fraction").format(sle.item_code, sle.actual_qty)) @@ -243,21 +241,18 @@ def validate_serial_no(sle, item_det): for serial_no in serial_nos: if frappe.db.exists("Serial No", serial_no): sr = frappe.db.get_value("Serial No", serial_no, ["name", "item_code", "batch_no", "sales_order", - "delivery_document_no", "delivery_document_type", "warehouse", + "delivery_document_no", "delivery_document_type", "warehouse", "purchase_document_type", "purchase_document_no", "company"], as_dict=1) - if sr and cint(sle.actual_qty) < 0 and sr.warehouse != sle.warehouse: - frappe.throw(_("Cannot cancel {0} {1} because Serial No {2} does not belong to the warehouse {3}") - .format(sle.voucher_type, sle.voucher_no, serial_no, sle.warehouse), SerialNoWarehouseError) - if sr.item_code!=sle.item_code: if not allow_serial_nos_with_different_item(serial_no, sle): frappe.throw(_("Serial No {0} does not belong to Item {1}").format(serial_no, sle.item_code), SerialNoItemError) - if cint(sle.actual_qty) > 0 and has_duplicate_serial_no(sr, sle): - frappe.throw(_("Serial No {0} has already been received").format(serial_no), - SerialNoDuplicateError) + if cint(sle.actual_qty) > 0 and has_serial_no_exists(sr, sle): + doc_name = frappe.bold(get_link_to_form(sr.purchase_document_type, sr.purchase_document_no)) + frappe.throw(_("Serial No {0} has already been received in the {1} #{2}") + .format(frappe.bold(serial_no), sr.purchase_document_type, doc_name), SerialNoDuplicateError) if (sr.delivery_document_no and sle.voucher_type not in ['Stock Entry', 'Stock Reconciliation'] and sle.voucher_type == sr.delivery_document_type): @@ -276,7 +271,7 @@ def validate_serial_no(sle, item_det): frappe.throw(_("Serial No {0} does not belong to Batch {1}").format(serial_no, sle.batch_no), SerialNoBatchError) - if not sr.warehouse: + if not sle.is_cancelled and not sr.warehouse: frappe.throw(_("Serial No {0} does not belong to any Warehouse") .format(serial_no), SerialNoWarehouseError) @@ -285,8 +280,10 @@ def validate_serial_no(sle, item_det): if sle.voucher_type == "Sales Invoice": if not frappe.db.exists("Sales Invoice Item", {"parent": sle.voucher_no, "item_code": sle.item_code, "sales_order": sr.sales_order}): - frappe.throw(_("Cannot deliver Serial No {0} of item {1} as it is reserved \ - to fullfill Sales Order {2}").format(sr.name, sle.item_code, sr.sales_order)) + frappe.throw( + _("Cannot deliver Serial No {0} of item {1} as it is reserved to fullfill Sales Order {2}") + .format(sr.name, sle.item_code, sr.sales_order) + ) elif sle.voucher_type == "Delivery Note": if not frappe.db.exists("Delivery Note Item", {"parent": sle.voucher_no, "item_code": sle.item_code, "against_sales_order": sr.sales_order}): @@ -295,8 +292,10 @@ def validate_serial_no(sle, item_det): if not invoice or frappe.db.exists("Sales Invoice Item", {"parent": invoice, "item_code": sle.item_code, "sales_order": sr.sales_order}): - frappe.throw(_("Cannot deliver Serial No {0} of item {1} as it is reserved to \ - fullfill Sales Order {2}").format(sr.name, sle.item_code, sr.sales_order)) + frappe.throw( + _("Cannot deliver Serial No {0} of item {1} as it is reserved to fullfill Sales Order {2}") + .format(sr.name, sle.item_code, sr.sales_order) + ) # if Sales Order reference in Delivery Note or Invoice validate SO reservations for item if sle.voucher_type == "Sales Invoice": sales_order = frappe.db.get_value("Sales Invoice Item", {"parent": sle.voucher_no, @@ -322,6 +321,12 @@ def validate_serial_no(sle, item_det): elif cint(sle.actual_qty) < 0 or not item_det.serial_no_series: frappe.throw(_("Serial Nos Required for Serialized Item {0}").format(sle.item_code), SerialNoRequiredError) + elif serial_nos: + for serial_no in serial_nos: + sr = frappe.db.get_value("Serial No", serial_no, ["name", "warehouse"], as_dict=1) + if sr and cint(sle.actual_qty) < 0 and sr.warehouse != sle.warehouse: + frappe.throw(_("Cannot cancel {0} {1} because Serial No {2} does not belong to the warehouse {3}") + .format(sle.voucher_type, sle.voucher_no, serial_no, sle.warehouse)) def validate_material_transfer_entry(sle_doc): sle_doc.update({ @@ -329,20 +334,22 @@ def validate_material_transfer_entry(sle_doc): "skip_serial_no_validaiton": False }) - if (sle_doc.voucher_type == "Stock Entry" and + if (sle_doc.voucher_type == "Stock Entry" and not sle_doc.is_cancelled and frappe.get_cached_value("Stock Entry", sle_doc.voucher_no, "purpose") == "Material Transfer"): if sle_doc.actual_qty < 0: sle_doc.skip_update_serial_no = True else: sle_doc.skip_serial_no_validaiton = True -def validate_so_serial_no(sr, sales_order,): +def validate_so_serial_no(sr, sales_order): if not sr.sales_order or sr.sales_order!= sales_order: - frappe.throw(_("""Sales Order {0} has reservation for item {1}, you can - only deliver reserved {1} against {0}. Serial No {2} cannot - be delivered""").format(sales_order, sr.item_code, sr.name)) + msg = (_("Sales Order {0} has reservation for the item {1}, you can only deliver reserved {1} against {0}.") + .format(sales_order, sr.item_code)) -def has_duplicate_serial_no(sn, sle): + frappe.throw(_("""{0} Serial No {1} cannot be delivered""") + .format(msg, sr.name)) + +def has_serial_no_exists(sn, sle): if (sn.warehouse and not sle.skip_serial_no_validaiton and sle.voucher_type != 'Stock Reconciliation'): return True @@ -352,12 +359,13 @@ def has_duplicate_serial_no(sn, sle): status = False if sn.purchase_document_no: - if sle.voucher_type in ['Purchase Receipt', 'Stock Entry', "Purchase Invoice"] and \ - sn.delivery_document_type not in ['Purchase Receipt', 'Stock Entry', "Purchase Invoice"]: + if (sle.voucher_type in ['Purchase Receipt', 'Stock Entry', "Purchase Invoice"] and + sn.delivery_document_type not in ['Purchase Receipt', 'Stock Entry', "Purchase Invoice"]): status = True - if status and sle.voucher_type == 'Stock Entry' and \ - frappe.db.get_value('Stock Entry', sle.voucher_no, 'purpose') != 'Material Receipt': + # If status is receipt then system will allow to in-ward the delivered serial no + if (status and sle.voucher_type == "Stock Entry" and frappe.db.get_value("Stock Entry", + sle.voucher_no, "purpose") in ("Material Receipt", "Material Transfer")): status = False return status @@ -372,7 +380,7 @@ def allow_serial_nos_with_different_item(sle_serial_no, sle): stock_entry = frappe.get_cached_doc("Stock Entry", sle.voucher_no) if stock_entry.purpose in ("Repack", "Manufacture"): for d in stock_entry.get("items"): - if d.serial_no and (d.s_warehouse or d.t_warehouse): + if d.serial_no and (d.s_warehouse if not sle.is_cancelled else d.t_warehouse): serial_nos = get_serial_nos(d.serial_no) if sle_serial_no in serial_nos: allow_serial_nos = True @@ -381,7 +389,7 @@ def allow_serial_nos_with_different_item(sle_serial_no, sle): def update_serial_nos(sle, item_det): if sle.skip_update_serial_no: return - if not sle.serial_no and cint(sle.actual_qty) > 0 \ + if not sle.is_cancelled and not sle.serial_no and cint(sle.actual_qty) > 0 \ and item_det.has_serial_no == 1 and item_det.serial_no_series: serial_nos = get_auto_serial_nos(item_det.serial_no_series, sle.actual_qty) frappe.db.set(sle, "serial_no", serial_nos) @@ -413,7 +421,7 @@ def auto_make_serial_nos(args): if is_new: created_numbers.append(sr.name) - form_links = list(map(lambda d: frappe.utils.get_link_to_form('Serial No', d), created_numbers)) + form_links = list(map(lambda d: get_link_to_form('Serial No', d), created_numbers)) # Setting up tranlated title field for all cases singular_title = _("Serial Number Created") @@ -443,6 +451,9 @@ def get_item_details(item_code): from tabItem where name=%s""", item_code, as_dict=True)[0] def get_serial_nos(serial_no): + if isinstance(serial_no, list): + return serial_no + return [s.strip() for s in cstr(serial_no).strip().upper().replace(',', '\n').split('\n') if s.strip()] @@ -538,54 +549,81 @@ def get_delivery_note_serial_no(item_code, qty, delivery_note): return serial_nos @frappe.whitelist() -def auto_fetch_serial_number(qty, item_code, warehouse, batch_nos=None, for_doctype=None): - filters = { - "item_code": item_code, - "warehouse": warehouse, - "delivery_document_no": "", - "sales_invoice": "" - } +def auto_fetch_serial_number(qty, item_code, warehouse, posting_date=None, batch_nos=None, for_doctype=None): + filters = { "item_code": item_code, "warehouse": warehouse } if batch_nos: try: - filters["batch_no"] = ["in", json.loads(batch_nos)] + filters["batch_no"] = json.loads(batch_nos) if (type(json.loads(batch_nos)) == list) else [json.loads(batch_nos)] except: - filters["batch_no"] = ["in", [batch_nos]] + filters["batch_no"] = [batch_nos] + if posting_date: + filters["expiry_date"] = posting_date + + serial_numbers = [] if for_doctype == 'POS Invoice': - reserved_serial_nos, unreserved_serial_nos = get_pos_reserved_serial_nos(filters, qty) - return unreserved_serial_nos + reserved_sr_nos = get_pos_reserved_serial_nos(filters) + serial_numbers = fetch_serial_numbers(filters, qty, do_not_include=reserved_sr_nos) + else: + serial_numbers = fetch_serial_numbers(filters, qty) - serial_numbers = frappe.get_list("Serial No", filters=filters, limit=qty, order_by="creation") - return [item['name'] for item in serial_numbers] + return [d.get('name') for d in serial_numbers] @frappe.whitelist() -def get_pos_reserved_serial_nos(filters, qty=None): - batch_no_cond = "" - if filters.get("batch_no"): - batch_no_cond = "and item.batch_no = {}".format(frappe.db.escape(filters.get('batch_no'))) +def get_pos_reserved_serial_nos(filters): + if isinstance(filters, string_types): + filters = json.loads(filters) - reserved_serial_nos_str = [d.serial_no for d in frappe.db.sql("""select item.serial_no as serial_no + pos_transacted_sr_nos = frappe.db.sql("""select item.serial_no as serial_no from `tabPOS Invoice` p, `tabPOS Invoice Item` item - where p.name = item.parent - and p.consolidated_invoice is NULL + where p.name = item.parent + and p.consolidated_invoice is NULL and p.docstatus = 1 and item.docstatus = 1 - and item.item_code = %s - and item.warehouse = %s - {} - """.format(batch_no_cond), [filters.get('item_code'), filters.get('warehouse')], as_dict=1)] + and item.item_code = %(item_code)s + and item.warehouse = %(warehouse)s + and item.serial_no is NOT NULL and item.serial_no != '' + """, filters, as_dict=1) - reserved_serial_nos = [] - for s in reserved_serial_nos_str: - if not s: continue + reserved_sr_nos = [] + for d in pos_transacted_sr_nos: + reserved_sr_nos += get_serial_nos(d.serial_no) - serial_nos = s.split("\n") - serial_nos = ' '.join(serial_nos).split() # remove whitespaces - if len(serial_nos): reserved_serial_nos += serial_nos + return reserved_sr_nos - filters["name"] = ["not in", reserved_serial_nos] - serial_numbers = frappe.get_list("Serial No", filters=filters, limit=qty, order_by="creation") - unreserved_serial_nos = [item['name'] for item in serial_numbers] +def fetch_serial_numbers(filters, qty, do_not_include=[]): + batch_join_selection = "" + batch_no_condition = "" + batch_nos = filters.get("batch_no") + expiry_date = filters.get("expiry_date") + if batch_nos: + batch_no_condition = """and sr.batch_no in ({}) """.format(', '.join(["'%s'" % d for d in batch_nos])) - return reserved_serial_nos, unreserved_serial_nos \ No newline at end of file + if expiry_date: + batch_join_selection = "LEFT JOIN `tabBatch` batch on sr.batch_no = batch.name " + expiry_date_cond = "AND ifnull(batch.expiry_date, '2500-12-31') >= %(expiry_date)s " + batch_no_condition += expiry_date_cond + + excluded_sr_nos = ", ".join(["" + frappe.db.escape(sr) + "" for sr in do_not_include]) or "''" + serial_numbers = frappe.db.sql(""" + SELECT sr.name FROM `tabSerial No` sr {batch_join_selection} + WHERE + sr.name not in ({excluded_sr_nos}) AND + sr.item_code = %(item_code)s AND + sr.warehouse = %(warehouse)s AND + ifnull(sr.sales_invoice,'') = '' AND + ifnull(sr.delivery_document_no, '') = '' + {batch_no_condition} + ORDER BY + sr.creation + LIMIT + {qty} + """.format( + excluded_sr_nos=excluded_sr_nos, + qty=qty or 1, + batch_join_selection=batch_join_selection, + batch_no_condition=batch_no_condition + ), filters, as_dict=1) + + return serial_numbers diff --git a/erpnext/stock/doctype/serial_no/test_serial_no.py b/erpnext/stock/doctype/serial_no/test_serial_no.py index ab061076e52..ed70790b2ca 100644 --- a/erpnext/stock/doctype/serial_no/test_serial_no.py +++ b/erpnext/stock/doctype/serial_no/test_serial_no.py @@ -12,7 +12,6 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_pu from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse -from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_perpetual_inventory test_dependencies = ["Item"] test_records = frappe.get_test_records('Serial No') @@ -38,8 +37,6 @@ class TestSerialNo(unittest.TestCase): self.assertTrue(SerialNoCannotCannotChangeError, sr.save) def test_inter_company_transfer(self): - set_perpetual_inventory(0, "_Test Company 1") - set_perpetual_inventory(0) se = make_serialized_item(target_warehouse="_Test Warehouse - _TC") serial_nos = get_serial_nos(se.get("items")[0].serial_no) diff --git a/erpnext/stock/doctype/shipment/__init__.py b/erpnext/stock/doctype/shipment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/shipment/shipment.js b/erpnext/stock/doctype/shipment/shipment.js new file mode 100644 index 00000000000..7af16af8986 --- /dev/null +++ b/erpnext/stock/doctype/shipment/shipment.js @@ -0,0 +1,451 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Shipment', { + address_query: function(frm, link_doctype, link_name, is_your_company_address) { + return { + query: 'frappe.contacts.doctype.address.address.address_query', + filters: { + link_doctype: link_doctype, + link_name: link_name, + is_your_company_address: is_your_company_address + } + }; + }, + contact_query: function(frm, link_doctype, link_name) { + return { + query: 'frappe.contacts.doctype.contact.contact.contact_query', + filters: { + link_doctype: link_doctype, + link_name: link_name + } + }; + }, + onload: function(frm) { + frm.set_query("delivery_address_name", () => { + let delivery_to = `delivery_${frappe.model.scrub(frm.doc.delivery_to_type)}`; + return frm.events.address_query(frm, frm.doc.delivery_to_type, frm.doc[delivery_to], frm.doc.delivery_to_type === 'Company' ? 1 : 0); + }); + frm.set_query("pickup_address_name", () => { + let pickup_from = `pickup_${frappe.model.scrub(frm.doc.pickup_from_type)}`; + return frm.events.address_query(frm, frm.doc.pickup_from_type, frm.doc[pickup_from], frm.doc.pickup_from_type === 'Company' ? 1 : 0); + }); + frm.set_query("delivery_contact_name", () => { + let delivery_to = `delivery_${frappe.model.scrub(frm.doc.delivery_to_type)}`; + return frm.events.contact_query(frm, frm.doc.delivery_to_type, frm.doc[delivery_to]); + }); + frm.set_query("pickup_contact_name", () => { + let pickup_from = `pickup_${frappe.model.scrub(frm.doc.pickup_from_type)}`; + return frm.events.contact_query(frm, frm.doc.pickup_from_type, frm.doc[pickup_from]); + }); + frm.set_query("delivery_note", "shipment_delivery_note", function() { + let customer = ''; + if (frm.doc.delivery_to_type == "Customer") { + customer = frm.doc.delivery_customer; + } + if (frm.doc.delivery_to_type == "Company") { + customer = frm.doc.delivery_company; + } + if (customer) { + return { + filters: { + customer: customer, + docstatus: 1, + status: ["not in", ["Cancelled"]] + } + }; + } + }); + }, + refresh: function() { + $('div[data-fieldname=pickup_address] > div > .clearfix').hide(); + $('div[data-fieldname=pickup_contact] > div > .clearfix').hide(); + $('div[data-fieldname=delivery_address] > div > .clearfix').hide(); + $('div[data-fieldname=delivery_contact] > div > .clearfix').hide(); + }, + before_save: function(frm) { + let delivery_to = `delivery_${frappe.model.scrub(frm.doc.delivery_to_type)}`; + frm.set_value("delivery_to", frm.doc[delivery_to]); + let pickup_from = `pickup_${frappe.model.scrub(frm.doc.pickup_from_type)}`; + frm.set_value("pickup", frm.doc[pickup_from]); + }, + set_pickup_company_address: function(frm) { + frappe.db.get_value('Address', { + address_title: frm.doc.pickup_company, + is_your_company_address: 1 + }, 'name', (r) => { + frm.set_value("pickup_address_name", r.name); + }); + }, + set_delivery_company_address: function(frm) { + frappe.db.get_value('Address', { + address_title: frm.doc.delivery_company, + is_your_company_address: 1 + }, 'name', (r) => { + frm.set_value("delivery_address_name", r.name); + }); + }, + pickup_from_type: function(frm) { + if (frm.doc.pickup_from_type == 'Company') { + frm.set_value("pickup_company", frappe.defaults.get_default('company')); + frm.set_value("pickup_customer", ''); + frm.set_value("pickup_supplier", ''); + } else { + frm.trigger('clear_pickup_fields'); + } + if (frm.doc.pickup_from_type == 'Customer') { + frm.set_value("pickup_company", ''); + frm.set_value("pickup_supplier", ''); + } + if (frm.doc.pickup_from_type == 'Supplier') { + frm.set_value("pickup_customer", ''); + frm.set_value("pickup_company", ''); + } + }, + delivery_to_type: function(frm) { + if (frm.doc.delivery_to_type == 'Company') { + frm.set_value("delivery_company", frappe.defaults.get_default('company')); + frm.set_value("delivery_customer", ''); + frm.set_value("delivery_supplier", ''); + } else { + frm.trigger('clear_delivery_fields'); + } + if (frm.doc.delivery_to_type == 'Customer') { + frm.set_value("delivery_company", ''); + frm.set_value("delivery_supplier", ''); + } + if (frm.doc.delivery_to_type == 'Supplier') { + frm.set_value("delivery_customer", ''); + frm.set_value("delivery_company", ''); + frm.toggle_display("shipment_delivery_note", false); + } else { + frm.toggle_display("shipment_delivery_note", true); + } + }, + delivery_address_name: function(frm) { + if (frm.doc.delivery_to_type == 'Company') { + erpnext.utils.get_address_display(frm, 'delivery_address_name', 'delivery_address', true); + } else { + erpnext.utils.get_address_display(frm, 'delivery_address_name', 'delivery_address', false); + } + }, + pickup_address_name: function(frm) { + if (frm.doc.pickup_from_type == 'Company') { + erpnext.utils.get_address_display(frm, 'pickup_address_name', 'pickup_address', true); + } else { + erpnext.utils.get_address_display(frm, 'pickup_address_name', 'pickup_address', false); + } + }, + get_contact_display: function(frm, contact_name, contact_type) { + frappe.call({ + method: "frappe.contacts.doctype.contact.contact.get_contact_details", + args: { contact: contact_name }, + callback: function(r) { + if (r.message) { + if (!(r.message.contact_email && (r.message.contact_phone || r.message.contact_mobile))) { + if (contact_type == 'Delivery') { + frm.set_value('delivery_contact_name', ''); + frm.set_value('delivery_contact', ''); + } else { + frm.set_value('pickup_contact_name', ''); + frm.set_value('pickup_contact', ''); + } + frappe.throw(__("Email or Phone/Mobile of the Contact are mandatory to continue.") + + "
    " + __("Please set Email/Phone for the contact") + + ` ${contact_name}`); + } + let contact_display = r.message.contact_display; + if (r.message.contact_email) { + contact_display += '
    ' + r.message.contact_email; + } + if (r.message.contact_phone) { + contact_display += '
    ' + r.message.contact_phone; + } + if (r.message.contact_mobile && !r.message.contact_phone) { + contact_display += '
    ' + r.message.contact_mobile; + } + if (contact_type == 'Delivery') { + frm.set_value('delivery_contact', contact_display); + if (r.message.contact_email) { + frm.set_value('delivery_contact_email', r.message.contact_email); + } + } else { + frm.set_value('pickup_contact', contact_display); + if (r.message.contact_email) { + frm.set_value('pickup_contact_email', r.message.contact_email); + } + } + } + } + }); + }, + delivery_contact_name: function(frm) { + if (frm.doc.delivery_contact_name) { + frm.events.get_contact_display(frm, frm.doc.delivery_contact_name, 'Delivery'); + } + }, + pickup_contact_name: function(frm) { + if (frm.doc.pickup_contact_name) { + frm.events.get_contact_display(frm, frm.doc.pickup_contact_name, 'Pickup'); + } + }, + pickup_contact_person: function(frm) { + if (frm.doc.pickup_contact_person) { + frappe.call({ + method: "erpnext.stock.doctype.shipment.shipment.get_company_contact", + args: { user: frm.doc.pickup_contact_person }, + callback: function({ message }) { + const r = message; + let contact_display = `${r.first_name} ${r.last_name}`; + if (r.email) { + contact_display += `
    ${ r.email }`; + frm.set_value('pickup_contact_email', r.email); + } + if (r.phone) { + contact_display += `
    ${ r.phone }`; + } + if (r.mobile_no && !r.phone) { + contact_display += `
    ${ r.mobile_no }`; + } + frm.set_value('pickup_contact', contact_display); + } + }); + } else { + if (frm.doc.pickup_from_type === 'Company') { + frappe.call({ + method: "erpnext.stock.doctype.shipment.shipment.get_company_contact", + args: { user: frappe.session.user }, + callback: function({ message }) { + const r = message; + let contact_display = `${r.first_name} ${r.last_name}`; + if (r.email) { + contact_display += `
    ${ r.email }`; + frm.set_value('pickup_contact_email', r.email); + } + if (r.phone) { + contact_display += `
    ${ r.phone }`; + } + if (r.mobile_no && !r.phone) { + contact_display += `
    ${ r.mobile_no }`; + } + frm.set_value('pickup_contact', contact_display); + } + }); + } + } + }, + set_company_contact: function(frm, delivery_type) { + frappe.db.get_value('User', { name: frappe.session.user }, ['full_name', 'last_name', 'email', 'phone', 'mobile_no'], (r) => { + if (!(r.last_name && r.email && (r.phone || r.mobile_no))) { + if (delivery_type == 'Delivery') { + frm.set_value('delivery_company', ''); + frm.set_value('delivery_contact', ''); + } else { + frm.set_value('pickup_company', ''); + frm.set_value('pickup_contact', ''); + } + frappe.throw(__("Last Name, Email or Phone/Mobile of the user are mandatory to continue.") + "
    " + + __("Please first set Last Name, Email and Phone for the user") + + ` ${frappe.session.user}`); + } + let contact_display = r.full_name; + if (r.email) { + contact_display += '
    ' + r.email; + } + if (r.phone) { + contact_display += '
    ' + r.phone; + } + if (r.mobile_no && !r.phone) { + contact_display += '
    ' + r.mobile_no; + } + if (delivery_type == 'Delivery') { + frm.set_value('delivery_contact', contact_display); + if (r.email) { + frm.set_value('delivery_contact_email', r.email); + } + } else { + frm.set_value('pickup_contact', contact_display); + if (r.email) { + frm.set_value('pickup_contact_email', r.email); + } + } + }); + frm.set_value('pickup_contact_person', frappe.session.user); + }, + pickup_company: function(frm) { + if (frm.doc.pickup_from_type == 'Company' && frm.doc.pickup_company) { + frm.trigger('set_pickup_company_address'); + frm.events.set_company_contact(frm, 'Pickup'); + } + }, + delivery_company: function(frm) { + if (frm.doc.delivery_to_type == 'Company' && frm.doc.delivery_company) { + frm.trigger('set_delivery_company_address'); + frm.events.set_company_contact(frm, 'Delivery'); + } + }, + delivery_customer: function(frm) { + frm.trigger('clear_delivery_fields'); + if (frm.doc.delivery_customer) { + frm.events.set_address_name(frm, 'Customer', frm.doc.delivery_customer, 'Delivery'); + frm.events.set_contact_name(frm, 'Customer', frm.doc.delivery_customer, 'Delivery'); + } + }, + delivery_supplier: function(frm) { + frm.trigger('clear_delivery_fields'); + if (frm.doc.delivery_supplier) { + frm.events.set_address_name(frm, 'Supplier', frm.doc.delivery_supplier, 'Delivery'); + frm.events.set_contact_name(frm, 'Supplier', frm.doc.delivery_supplier, 'Delivery'); + } + }, + pickup_customer: function(frm) { + if (frm.doc.pickup_customer) { + frm.events.set_address_name(frm, 'Customer', frm.doc.pickup_customer, 'Pickup'); + frm.events.set_contact_name(frm, 'Customer', frm.doc.pickup_customer, 'Pickup'); + } + }, + pickup_supplier: function(frm) { + if (frm.doc.pickup_supplier) { + frm.events.set_address_name(frm, 'Supplier', frm.doc.pickup_supplier, 'Pickup'); + frm.events.set_contact_name(frm, 'Supplier', frm.doc.pickup_supplier, 'Pickup'); + } + }, + set_address_name: function(frm, ref_doctype, ref_docname, delivery_type) { + frappe.call({ + method: "erpnext.stock.doctype.shipment.shipment.get_address_name", + args: { + ref_doctype: ref_doctype, + docname: ref_docname + }, + callback: function(r) { + if (r.message) { + if (delivery_type == 'Delivery') { + frm.set_value('delivery_address_name', r.message); + } else { + frm.set_value('pickup_address_name', r.message); + } + } + } + }); + }, + set_contact_name: function(frm, ref_doctype, ref_docname, delivery_type) { + frappe.call({ + method: "erpnext.stock.doctype.shipment.shipment.get_contact_name", + args: { + ref_doctype: ref_doctype, + docname: ref_docname + }, + callback: function(r) { + if (r.message) { + if (delivery_type == 'Delivery') { + frm.set_value('delivery_contact_name', r.message); + } else { + frm.set_value('pickup_contact_name', r.message); + } + } + } + }); + }, + add_template: function(frm) { + if (frm.doc.parcel_template) { + frappe.model.with_doc("Shipment Parcel Template", frm.doc.parcel_template, () => { + let parcel_template = frappe.model.get_doc("Shipment Parcel Template", frm.doc.parcel_template); + let row = frappe.model.add_child(frm.doc, "Shipment Parcel", "shipment_parcel"); + row.length = parcel_template.length; + row.width = parcel_template.width; + row.height = parcel_template.height; + row.weight = parcel_template.weight; + frm.refresh_fields("shipment_parcel"); + }); + } + }, + pickup_date: function(frm) { + if (frm.doc.pickup_date < frappe.datetime.get_today()) { + frappe.throw(__("Pickup Date cannot be before this day")); + } + if (frm.doc.pickup_date == frappe.datetime.get_today()) { + var pickup_time = frm.events.get_pickup_time(frm); + frm.set_value("pickup_from", pickup_time); + frm.trigger('set_pickup_to_time'); + } + }, + pickup_from: function(frm) { + var pickup_time = frm.events.get_pickup_time(frm); + if (frm.doc.pickup_from && frm.doc.pickup_date == frappe.datetime.get_today()) { + let current_hour = pickup_time.split(':')[0]; + let current_min = pickup_time.split(':')[1]; + let pickup_hour = frm.doc.pickup_from.split(':')[0]; + let pickup_min = frm.doc.pickup_from.split(':')[1]; + if (pickup_hour < current_hour || (pickup_hour == current_hour && pickup_min < current_min)) { + frm.set_value("pickup_from", pickup_time); + frappe.throw(__("Pickup Time cannot be in the past")); + } + } + frm.trigger('set_pickup_to_time'); + }, + get_pickup_time: function() { + let current_hour = new Date().getHours(); + let current_min = new Date().toLocaleString('en-US', {minute: 'numeric'}); + if (current_min < 30) { + current_min = '30'; + } else { + current_min = '00'; + current_hour = Number(current_hour)+1; + } + let pickup_time = current_hour +':'+ current_min; + return pickup_time; + }, + set_pickup_to_time: function(frm) { + let pickup_to_hour = Number(frm.doc.pickup_from.split(':')[0])+5; + let pickup_to_min = frm.doc.pickup_from.split(':')[1]; + let pickup_to = pickup_to_hour +':'+ pickup_to_min; + frm.set_value("pickup_to", pickup_to); + }, + clear_pickup_fields: function(frm) { + let fields = ["pickup_address_name", "pickup_contact_name", "pickup_address", "pickup_contact", "pickup_contact_email", "pickup_contact_person"]; + for (let field of fields) { + frm.set_value(field, ''); + } + }, + clear_delivery_fields: function(frm) { + let fields = ["delivery_address_name", "delivery_contact_name", "delivery_address", "delivery_contact", "delivery_contact_email"]; + for (let field of fields) { + frm.set_value(field, ''); + } + }, + remove_email_row: function(frm, table, fieldname) { + $.each(frm.doc[table] || [], function(i, detail) { + if (detail.email === fieldname) { + cur_frm.get_field(table).grid.grid_rows[i].remove(); + } + }); + } +}); + +frappe.ui.form.on('Shipment Delivery Note', { + delivery_note: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.delivery_note) { + let row_index = row.idx - 1; + if (validate_duplicate(frm, 'shipment_delivery_note', row.delivery_note, row_index)) { + frappe.throw(__("You have entered a duplicate Delivery Note on Row") + ` ${row.idx}. ` + __("Please rectify and try again.")); + } + } + }, + grand_total: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.grand_total) { + var value_of_goods = parseFloat(frm.doc.value_of_goods)+parseFloat(row.grand_total); + frm.set_value("value_of_goods", Math.round(value_of_goods)); + frm.refresh_fields("value_of_goods"); + } + }, +}); + +var validate_duplicate = function(frm, table, fieldname, index) { + return ( + table === 'shipment_delivery_note' + ? frm.doc[table].some((detail, i) => detail.delivery_note === fieldname && !(index === i)) + : frm.doc[table].some((detail, i) => detail.email === fieldname && !(index === i)) + ); +}; diff --git a/erpnext/stock/doctype/shipment/shipment.json b/erpnext/stock/doctype/shipment/shipment.json new file mode 100644 index 00000000000..76c331c5c25 --- /dev/null +++ b/erpnext/stock/doctype/shipment/shipment.json @@ -0,0 +1,472 @@ +{ + "actions": [], + "autoname": "SHIPMENT-.#####", + "creation": "2020-07-09 10:58:52.508703", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "heading_pickup_from", + "pickup_from_type", + "pickup_company", + "pickup_customer", + "pickup_supplier", + "pickup", + "pickup_address_name", + "pickup_address", + "pickup_contact_person", + "pickup_contact_name", + "pickup_contact_email", + "pickup_contact", + "column_break_2", + "heading_delivery_to", + "delivery_to_type", + "delivery_company", + "delivery_customer", + "delivery_supplier", + "delivery_to", + "delivery_address_name", + "delivery_address", + "delivery_contact_name", + "delivery_contact_email", + "delivery_contact", + "parcels_section", + "shipment_parcel", + "parcel_template", + "add_template", + "column_break_28", + "shipment_delivery_note", + "shipment_details_section", + "pallets", + "value_of_goods", + "pickup_date", + "pickup_from", + "pickup_to", + "column_break_36", + "shipment_type", + "pickup_type", + "incoterm", + "description_of_content", + "section_break_40", + "shipment_information_section", + "service_provider", + "shipment_id", + "shipment_amount", + "status", + "tracking_url", + "column_break_55", + "carrier", + "carrier_service", + "awb_number", + "tracking_status", + "tracking_status_info", + "amended_from" + ], + "fields": [ + { + "fieldname": "heading_pickup_from", + "fieldtype": "Heading", + "label": "Pickup from" + }, + { + "default": "Company", + "fieldname": "pickup_from_type", + "fieldtype": "Select", + "label": "Pickup from", + "options": "Company\nCustomer\nSupplier" + }, + { + "depends_on": "eval:doc.pickup_from_type == 'Company'", + "fieldname": "pickup_company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "depends_on": "eval:doc.pickup_from_type == 'Customer'", + "fieldname": "pickup_customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer" + }, + { + "depends_on": "eval:doc.pickup_from_type == 'Supplier'", + "fieldname": "pickup_supplier", + "fieldtype": "Link", + "label": "Supplier", + "options": "Supplier" + }, + { + "fieldname": "pickup", + "fieldtype": "Data", + "hidden": 1, + "in_list_view": 1, + "label": "Pickup From", + "read_only": 1 + }, + { + "depends_on": "eval: doc.pickup_customer || doc.pickup_supplier || doc.pickup_from_type == \"Company\"", + "fieldname": "pickup_address_name", + "fieldtype": "Link", + "label": "Address", + "options": "Address", + "reqd": 1 + }, + { + "fieldname": "pickup_address", + "fieldtype": "Small Text", + "read_only": 1 + }, + { + "depends_on": "eval: doc.pickup_customer || doc.pickup_supplier || doc.pickup_from_type !== \"Company\"", + "fieldname": "pickup_contact_name", + "fieldtype": "Link", + "label": "Contact", + "mandatory_depends_on": "eval: doc.pickup_from_type !== 'Company'", + "options": "Contact" + }, + { + "fieldname": "pickup_contact_email", + "fieldtype": "Data", + "hidden": 1, + "label": "Contact Email", + "read_only": 1 + }, + { + "fieldname": "pickup_contact", + "fieldtype": "Small Text", + "read_only": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "heading_delivery_to", + "fieldtype": "Heading", + "label": "Delivery to" + }, + { + "default": "Customer", + "fieldname": "delivery_to_type", + "fieldtype": "Select", + "label": "Delivery to", + "options": "Company\nCustomer\nSupplier" + }, + { + "depends_on": "eval:doc.delivery_to_type == 'Company'", + "fieldname": "delivery_company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "depends_on": "eval:doc.delivery_to_type == 'Customer'", + "fieldname": "delivery_customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer" + }, + { + "depends_on": "eval:doc.delivery_to_type == 'Supplier'", + "fieldname": "delivery_supplier", + "fieldtype": "Link", + "label": "Supplier", + "options": "Supplier" + }, + { + "fieldname": "delivery_to", + "fieldtype": "Data", + "hidden": 1, + "in_list_view": 1, + "label": "Delivery To", + "read_only": 1 + }, + { + "depends_on": "eval: doc.delivery_customer || doc.delivery_supplier || doc.delivery_to_type == \"Company\"", + "fieldname": "delivery_address_name", + "fieldtype": "Link", + "label": "Address", + "options": "Address", + "reqd": 1 + }, + { + "fieldname": "delivery_address", + "fieldtype": "Small Text", + "read_only": 1 + }, + { + "depends_on": "eval: doc.delivery_customer || doc.delivery_supplier || doc.delivery_to_type == \"Company\"", + "fieldname": "delivery_contact_name", + "fieldtype": "Link", + "label": "Contact", + "mandatory_depends_on": "eval: doc.delivery_from_type !== 'Company'", + "options": "Contact" + }, + { + "fieldname": "delivery_contact_email", + "fieldtype": "Data", + "hidden": 1, + "label": "Contact Email", + "read_only": 1 + }, + { + "depends_on": "eval:doc.delivery_contact_name", + "fieldname": "delivery_contact", + "fieldtype": "Small Text", + "read_only": 1 + }, + { + "fieldname": "parcels_section", + "fieldtype": "Section Break", + "label": "Parcels" + }, + { + "fieldname": "shipment_parcel", + "fieldtype": "Table", + "label": "Shipment Parcel", + "options": "Shipment Parcel" + }, + { + "fieldname": "parcel_template", + "fieldtype": "Link", + "label": "Parcel Template", + "options": "Shipment Parcel Template" + }, + { + "depends_on": "eval:doc.docstatus !== 1\n", + "fieldname": "add_template", + "fieldtype": "Button", + "label": "Add Template" + }, + { + "fieldname": "column_break_28", + "fieldtype": "Column Break" + }, + { + "fieldname": "shipment_details_section", + "fieldtype": "Section Break", + "label": "Shipment details" + }, + { + "default": "No", + "fieldname": "pallets", + "fieldtype": "Select", + "label": "Pallets", + "options": "No\nYes" + }, + { + "fieldname": "value_of_goods", + "fieldtype": "Currency", + "label": "Value of Goods", + "precision": "2", + "reqd": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "pickup_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Pickup Date", + "reqd": 1 + }, + { + "allow_on_submit": 1, + "default": "09:00", + "fieldname": "pickup_from", + "fieldtype": "Time", + "label": "Pickup from" + }, + { + "allow_on_submit": 1, + "default": "17:00", + "fieldname": "pickup_to", + "fieldtype": "Time", + "label": "Pickup to" + }, + { + "fieldname": "column_break_36", + "fieldtype": "Column Break" + }, + { + "default": "Goods", + "fieldname": "shipment_type", + "fieldtype": "Select", + "label": "Shipment Type", + "options": "Goods\nDocuments" + }, + { + "default": "Pickup", + "fieldname": "pickup_type", + "fieldtype": "Select", + "label": "Pickup Type", + "options": "Pickup\nSelf delivery" + }, + { + "fieldname": "description_of_content", + "fieldtype": "Small Text", + "label": "Description of Content", + "reqd": 1 + }, + { + "fieldname": "section_break_40", + "fieldtype": "Section Break" + }, + { + "fieldname": "shipment_information_section", + "fieldtype": "Section Break", + "label": "Shipment Information" + }, + { + "fieldname": "service_provider", + "fieldtype": "Data", + "label": "Service Provider", + "no_copy": 1, + "print_hide": 1 + }, + { + "fieldname": "shipment_id", + "fieldtype": "Data", + "label": "Shipment ID", + "no_copy": 1, + "print_hide": 1 + }, + { + "fieldname": "shipment_amount", + "fieldtype": "Currency", + "label": "Shipment Amount", + "no_copy": 1, + "precision": "2", + "print_hide": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "no_copy": 1, + "options": "Draft\nSubmitted\nBooked\nCancelled\nCompleted", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "tracking_url", + "fieldtype": "Small Text", + "hidden": 1, + "label": "Tracking URL", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "carrier", + "fieldtype": "Data", + "label": "Carrier", + "no_copy": 1, + "print_hide": 1 + }, + { + "fieldname": "carrier_service", + "fieldtype": "Data", + "label": "Carrier Service", + "no_copy": 1, + "print_hide": 1 + }, + { + "fieldname": "awb_number", + "fieldtype": "Data", + "label": "AWB Number", + "no_copy": 1, + "print_hide": 1 + }, + { + "fieldname": "tracking_status", + "fieldtype": "Select", + "label": "Tracking Status", + "no_copy": 1, + "options": "\nIn Progress\nDelivered\nReturned\nLost", + "print_hide": 1 + }, + { + "fieldname": "tracking_status_info", + "fieldtype": "Data", + "label": "Tracking Status Info", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "hidden": 1, + "label": "Amended From", + "no_copy": 1, + "options": "Shipment", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_55", + "fieldtype": "Column Break" + }, + { + "fieldname": "incoterm", + "fieldtype": "Select", + "label": "Incoterm", + "options": "EXW (Ex Works)\nFCA (Free Carrier)\nCPT (Carriage Paid To)\nCIP (Carriage and Insurance Paid to)\nDPU (Delivered At Place Unloaded)\nDAP (Delivered At Place)\nDDP (Delivered Duty Paid)" + }, + { + "fieldname": "shipment_delivery_note", + "fieldtype": "Table", + "label": "Shipment Delivery Note", + "options": "Shipment Delivery Note" + }, + { + "depends_on": "eval:doc.pickup_from_type === 'Company'", + "fieldname": "pickup_contact_person", + "fieldtype": "Link", + "label": "Pickup Contact Person", + "mandatory_depends_on": "eval:doc.pickup_from_type === 'Company'", + "options": "User" + } + ], + "is_submittable": 1, + "links": [], + "modified": "2020-12-25 15:02:34.891976", + "modified_by": "Administrator", + "module": "Stock", + "name": "Shipment", + "owner": "Administrator", + "permissions": [ + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/stock/doctype/shipment/shipment.py b/erpnext/stock/doctype/shipment/shipment.py new file mode 100644 index 00000000000..4697a7b3235 --- /dev/null +++ b/erpnext/stock/doctype/shipment/shipment.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import flt, get_time +from frappe.model.document import Document +from erpnext.accounts.party import get_party_shipping_address +from frappe.contacts.doctype.contact.contact import get_default_contact + +class Shipment(Document): + def validate(self): + self.validate_weight() + self.validate_pickup_time() + self.set_value_of_goods() + if self.docstatus == 0: + self.status = 'Draft' + + def on_submit(self): + if not self.shipment_parcel: + frappe.throw(_('Please enter Shipment Parcel information')) + if self.value_of_goods == 0: + frappe.throw(_('Value of goods cannot be 0')) + self.status = 'Submitted' + + def on_cancel(self): + self.status = 'Cancelled' + + def validate_weight(self): + for parcel in self.shipment_parcel: + if flt(parcel.weight) <= 0: + frappe.throw(_('Parcel weight cannot be 0')) + + def validate_pickup_time(self): + if self.pickup_from and self.pickup_to and get_time(self.pickup_to) < get_time(self.pickup_from): + frappe.throw(_("Pickup To time should be greater than Pickup From time")) + + def set_value_of_goods(self): + value_of_goods = 0 + for entry in self.get("shipment_delivery_note"): + value_of_goods += flt(entry.get("grand_total")) + self.value_of_goods = value_of_goods if value_of_goods else self.value_of_goods + +@frappe.whitelist() +def get_address_name(ref_doctype, docname): + # Return address name + return get_party_shipping_address(ref_doctype, docname) + +@frappe.whitelist() +def get_contact_name(ref_doctype, docname): + # Return address name + return get_default_contact(ref_doctype, docname) + +@frappe.whitelist() +def get_company_contact(user): + contact = frappe.db.get_value('User', user, [ + 'first_name', + 'last_name', + 'email', + 'phone', + 'mobile_no', + 'gender', + ], as_dict=1) + if not contact.phone: + contact.phone = contact.mobile_no + return contact diff --git a/erpnext/stock/doctype/shipment/shipment_list.js b/erpnext/stock/doctype/shipment/shipment_list.js new file mode 100644 index 00000000000..52b052c81f3 --- /dev/null +++ b/erpnext/stock/doctype/shipment/shipment_list.js @@ -0,0 +1,8 @@ +frappe.listview_settings['Shipment'] = { + add_fields: ["status"], + get_indicator: function(doc) { + if (doc.status=='Booked') { + return [__("Booked"), "green"]; + } + } +}; \ No newline at end of file diff --git a/erpnext/stock/doctype/shipment/test_shipment.py b/erpnext/stock/doctype/shipment/test_shipment.py new file mode 100644 index 00000000000..9c3e22f0231 --- /dev/null +++ b/erpnext/stock/doctype/shipment/test_shipment.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals +from datetime import date, timedelta + +import frappe +import unittest +from erpnext.stock.doctype.delivery_note.delivery_note import make_shipment + +class TestShipment(unittest.TestCase): + def test_shipment_from_delivery_note(self): + delivery_note = create_test_delivery_note() + delivery_note.submit() + shipment = create_test_shipment([ delivery_note ]) + shipment.submit() + second_shipment = make_shipment(delivery_note.name) + self.assertEqual(second_shipment.value_of_goods, delivery_note.grand_total) + self.assertEqual(len(second_shipment.shipment_delivery_note), 1) + self.assertEqual(second_shipment.shipment_delivery_note[0].delivery_note, delivery_note.name) + +def create_test_delivery_note(): + company = get_shipment_company() + customer = get_shipment_customer() + item = get_shipment_item(company.name) + posting_date = date.today() + timedelta(days=1) + + create_material_receipt(item, company.name) + delivery_note = frappe.new_doc("Delivery Note") + delivery_note.company = company.name + delivery_note.posting_date = posting_date.strftime("%Y-%m-%d") + delivery_note.posting_time = '10:00' + delivery_note.customer = customer.name + delivery_note.append('items', + { + "item_code": item.name, + "item_name": item.item_name, + "description": 'Test delivery note for shipment', + "qty": 5, + "uom": 'Nos', + "warehouse": 'Stores - SC', + "rate": item.standard_rate, + "cost_center": 'Main - SC' + } + ) + delivery_note.insert() + frappe.db.commit() + return delivery_note + + +def create_test_shipment(delivery_notes = None): + company = get_shipment_company() + company_address = get_shipment_company_address(company.name) + customer = get_shipment_customer() + customer_address = get_shipment_customer_address(customer.name) + customer_contact = get_shipment_customer_contact(customer.name) + posting_date = date.today() + timedelta(days=5) + + shipment = frappe.new_doc("Shipment") + shipment.pickup_from_type = 'Company' + shipment.pickup_company = company.name + shipment.pickup_address_name = company_address.name + shipment.delivery_to_type = 'Customer' + shipment.delivery_customer = customer.name + shipment.delivery_address_name = customer_address.name + shipment.delivery_contact_name = customer_contact.name + shipment.pallets = 'No' + shipment.shipment_type = 'Goods' + shipment.value_of_goods = 1000 + shipment.pickup_type = 'Pickup' + shipment.pickup_date = posting_date.strftime("%Y-%m-%d") + shipment.pickup_from = '09:00' + shipment.pickup_to = '17:00' + shipment.description_of_content = 'unit test entry' + for delivery_note in delivery_notes: + shipment.append('shipment_delivery_note', + { + "delivery_note": delivery_note.name + } + ) + shipment.append('shipment_parcel', + { + "length": 5, + "width": 5, + "height": 5, + "weight": 5, + "count": 5 + } + ) + shipment.insert() + frappe.db.commit() + return shipment + + +def get_shipment_customer_contact(customer_name): + contact_fname = 'Customer Shipment' + contact_lname = 'Testing' + customer_name = contact_fname + ' ' + contact_lname + contacts = frappe.get_all("Contact", fields=["name"], filters = {"name": customer_name}) + if len(contacts): + return contacts[0] + else: + return create_customer_contact(contact_fname, contact_lname) + + +def get_shipment_customer_address(customer_name): + address_title = customer_name + ' address 123' + customer_address = frappe.get_all("Address", fields=["name"], filters = {"address_title": address_title}) + if len(customer_address): + return customer_address[0] + else: + return create_shipment_address(address_title, customer_name, 81929) + +def get_shipment_customer(): + customer_name = 'Shipment Customer' + customer = frappe.get_all("Customer", fields=["name"], filters = {"name": customer_name}) + if len(customer): + return customer[0] + else: + return create_shipment_customer(customer_name) + +def get_shipment_company_address(company_name): + address_title = company_name + ' address 123' + addresses = frappe.get_all("Address", fields=["name"], filters = {"address_title": address_title}) + if len(addresses): + return addresses[0] + else: + return create_shipment_address(address_title, company_name, 80331) + +def get_shipment_company(): + company_name = 'Shipment Company' + abbr = 'SC' + companies = frappe.get_all("Company", fields=["name"], filters = {"company_name": company_name}) + if len(companies): + return companies[0] + else: + return create_shipment_company(company_name, abbr) + +def get_shipment_item(company_name): + item_name = 'Testing Shipment item' + items = frappe.get_all("Item", + fields=["name", "item_name", "item_code", "standard_rate"], + filters = {"item_name": item_name} + ) + if len(items): + return items[0] + else: + return create_shipment_item(item_name, company_name) + +def create_shipment_address(address_title, company_name, postal_code): + address = frappe.new_doc("Address") + address.address_title = address_title + address.address_type = 'Shipping' + address.address_line1 = company_name + ' address line 1' + address.city = 'Random City' + address.postal_code = postal_code + address.country = 'Germany' + address.insert() + return address + + +def create_customer_contact(fname, lname): + customer = frappe.new_doc("Contact") + customer.customer_name = fname + ' ' + lname + customer.first_name = fname + customer.last_name = lname + customer.is_primary_contact = 1 + customer.is_billing_contact = 1 + customer.append('email_ids', + { + 'email_id': 'randomme@email.com', + 'is_primary': 1 + } + ) + customer.append('phone_nos', + { + 'phone': '123123123', + 'is_primary_phone': 1, + 'is_primary_mobile_no': 1 + } + ) + customer.status = 'Passive' + customer.insert() + return customer + + +def create_shipment_company(company_name, abbr): + company = frappe.new_doc("Company") + company.company_name = company_name + company.abbr = abbr + company.default_currency = 'EUR' + company.country = 'Germany' + company.enable_perpetual_inventory = 0 + company.insert() + return company + +def create_shipment_customer(customer_name): + customer = frappe.new_doc("Customer") + customer.customer_name = customer_name + customer.customer_type = 'Company' + customer.customer_group = 'All Customer Groups' + customer.territory = 'All Territories' + customer.gst_category = 'Unregistered' + customer.insert() + return customer + +def create_material_receipt(item, company): + posting_date = date.today() + stock = frappe.new_doc("Stock Entry") + stock.company = company + stock.stock_entry_type = 'Material Receipt' + stock.posting_date = posting_date.strftime("%Y-%m-%d") + stock.append('items', + { + "t_warehouse": 'Stores - SC', + "item_code": item.name, + "qty": 5, + "uom": 'Nos', + "basic_rate": item.standard_rate, + "cost_center": 'Main - SC' + } + ) + stock.insert() + stock.submit() + + +def create_shipment_item(item_name, company_name): + item = frappe.new_doc("Item") + item.item_name = item_name + item.item_code = item_name + item.item_group = 'All Item Groups' + item.stock_uom = 'Nos' + item.standard_rate = 50 + item.append('item_defaults', + { + "company": company_name, + "default_warehouse": 'Stores - SC' + } + ) + item.insert() + return item diff --git a/erpnext/stock/doctype/shipment_delivery_note/__init__.py b/erpnext/stock/doctype/shipment_delivery_note/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/shipment_delivery_note/shipment_delivery_note.json b/erpnext/stock/doctype/shipment_delivery_note/shipment_delivery_note.json new file mode 100644 index 00000000000..86259137186 --- /dev/null +++ b/erpnext/stock/doctype/shipment_delivery_note/shipment_delivery_note.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "creation": "2020-07-09 11:52:57.939021", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "delivery_note", + "grand_total" + ], + "fields": [ + { + "fieldname": "delivery_note", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Delivery Note", + "options": "Delivery Note", + "reqd": 1 + }, + { + "fieldname": "grand_total", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Value", + "read_only": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2020-12-02 15:44:34.028703", + "modified_by": "Administrator", + "module": "Stock", + "name": "Shipment Delivery Note", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/stock/doctype/shipment_delivery_note/shipment_delivery_note.py b/erpnext/stock/doctype/shipment_delivery_note/shipment_delivery_note.py new file mode 100644 index 00000000000..43421516057 --- /dev/null +++ b/erpnext/stock/doctype/shipment_delivery_note/shipment_delivery_note.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class ShipmentDeliveryNote(Document): + pass diff --git a/erpnext/stock/doctype/shipment_parcel/__init__.py b/erpnext/stock/doctype/shipment_parcel/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/shipment_parcel/shipment_parcel.json b/erpnext/stock/doctype/shipment_parcel/shipment_parcel.json new file mode 100644 index 00000000000..6943edcdc91 --- /dev/null +++ b/erpnext/stock/doctype/shipment_parcel/shipment_parcel.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "creation": "2020-07-09 11:28:48.887737", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "length", + "width", + "height", + "weight", + "count" + ], + "fields": [ + { + "fieldname": "length", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Length (cm)", + "reqd": 1 + }, + { + "fieldname": "width", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Width (cm)", + "reqd": 1 + }, + { + "fieldname": "height", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Height (cm)", + "reqd": 1 + }, + { + "fieldname": "weight", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Weight (kg)", + "precision": "1", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "count", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Count", + "reqd": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2020-07-09 12:54:14.847170", + "modified_by": "Administrator", + "module": "Stock", + "name": "Shipment Parcel", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/stock/doctype/shipment_parcel/shipment_parcel.py b/erpnext/stock/doctype/shipment_parcel/shipment_parcel.py new file mode 100644 index 00000000000..53e6ed55dd3 --- /dev/null +++ b/erpnext/stock/doctype/shipment_parcel/shipment_parcel.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class ShipmentParcel(Document): + pass diff --git a/erpnext/stock/doctype/shipment_parcel_template/__init__.py b/erpnext/stock/doctype/shipment_parcel_template/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.js b/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.js new file mode 100644 index 00000000000..785a3b304de --- /dev/null +++ b/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Shipment Parcel Template', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.json b/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.json new file mode 100644 index 00000000000..4735d9f8866 --- /dev/null +++ b/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.json @@ -0,0 +1,78 @@ +{ + "actions": [], + "autoname": "field:parcel_template_name", + "creation": "2020-07-09 11:43:43.470339", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "parcel_template_name", + "length", + "width", + "height", + "weight" + ], + "fields": [ + { + "fieldname": "length", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Length (cm)", + "reqd": 1 + }, + { + "fieldname": "width", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Width (cm)", + "reqd": 1 + }, + { + "fieldname": "height", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Height (cm)", + "reqd": 1 + }, + { + "fieldname": "weight", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Weight (kg)", + "precision": "1", + "reqd": 1 + }, + { + "fieldname": "parcel_template_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Parcel Template Name", + "reqd": 1, + "unique": 1 + } + ], + "links": [], + "modified": "2020-09-28 12:51:00.320421", + "modified_by": "Administrator", + "module": "Stock", + "name": "Shipment Parcel Template", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.py b/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.py new file mode 100644 index 00000000000..2a8d58d8305 --- /dev/null +++ b/erpnext/stock/doctype/shipment_parcel_template/shipment_parcel_template.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class ShipmentParcelTemplate(Document): + pass diff --git a/erpnext/stock/doctype/shipment_parcel_template/test_shipment_parcel_template.py b/erpnext/stock/doctype/shipment_parcel_template/test_shipment_parcel_template.py new file mode 100644 index 00000000000..6e2caa768bf --- /dev/null +++ b/erpnext/stock/doctype/shipment_parcel_template/test_shipment_parcel_template.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestShipmentParcelTemplate(unittest.TestCase): + pass diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 39fd029a89b..64dcbed1d85 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -1,9 +1,20 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt frappe.provide("erpnext.stock"); +frappe.provide("erpnext.accounts.dimensions"); + +{% include 'erpnext/stock/landed_taxes_and_charges_common.js' %}; frappe.ui.form.on('Stock Entry', { setup: function(frm) { + frm.set_indicator_formatter('item_code', function(doc) { + if (!doc.s_warehouse) { + return 'blue'; + } else { + return (doc.qty<=doc.actual_qty) ? 'green' : 'orange'; + } + }); + frm.set_query('work_order', function() { return { filters: [ @@ -86,17 +97,9 @@ frappe.ui.form.on('Stock Entry', { } }); - frm.set_query("expense_account", "additional_costs", function() { - return { - query: "erpnext.controllers.queries.tax_account_query", - filters: { - "account_type": ["Tax", "Chargeable", "Income Account", "Expenses Included In Valuation", "Expenses Included In Asset Valuation"], - "company": frm.doc.company - } - }; - }); frm.add_fetch("bom_no", "inspection_required", "inspection_required"); + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, setup_quality_inspection: function(frm) { @@ -225,22 +228,44 @@ frappe.ui.form.on('Stock Entry', { docstatus: 1 } }) - }, __("Get items from")); + }, __("Get Items From")); frm.add_custom_button(__('Material Request'), function() { - erpnext.utils.map_current_doc({ + const allowed_request_types = ["Material Transfer", "Material Issue", "Customer Provided"]; + const depends_on_condition = "eval:doc.material_request_type==='Customer Provided'"; + const d = erpnext.utils.map_current_doc({ method: "erpnext.stock.doctype.material_request.material_request.make_stock_entry", source_doctype: "Material Request", target: frm, date_field: "schedule_date", - setters: {}, + setters: [{ + fieldtype: 'Select', + label: __('Purpose'), + options: allowed_request_types.join("\n"), + fieldname: 'material_request_type', + default: "Material Transfer", + mandatory: 1, + change() { + if (this.value === 'Customer Provided') { + d.dialog.get_field("customer").set_focus(); + } + }, + }, + { + fieldtype: 'Link', + label: __('Customer'), + options: 'Customer', + fieldname: 'customer', + depends_on: depends_on_condition, + mandatory_depends_on: depends_on_condition, + }], get_query_filters: { docstatus: 1, - material_request_type: ["in", ["Material Transfer", "Material Issue"]], + material_request_type: ["in", allowed_request_types], status: ["not in", ["Transferred", "Issued"]] } }) - }, __("Get items from")); + }, __("Get Items From")); } if (frm.doc.docstatus===0 && frm.doc.purpose == "Material Issue") { frm.add_custom_button(__('Expired Batches'), function() { @@ -263,7 +288,7 @@ frappe.ui.form.on('Stock Entry', { } } }); - }, __("Get items from")); + }, __("Get Items From")); } frm.events.show_bom_custom_button(frm); @@ -282,7 +307,7 @@ frappe.ui.form.on('Stock Entry', { }, stock_entry_type: function(frm){ - frm.remove_custom_button('Bill of Materials', "Get items from"); + frm.remove_custom_button('Bill of Materials', "Get Items From"); frm.events.show_bom_custom_button(frm); frm.trigger('add_to_transit'); }, @@ -312,6 +337,8 @@ frappe.ui.form.on('Stock Entry', { frm.set_value("letter_head", company_doc.default_letter_head); } frm.trigger("toggle_display_account_head"); + + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); } }, @@ -425,9 +452,9 @@ frappe.ui.form.on('Stock Entry', { show_bom_custom_button: function(frm){ if (frm.doc.docstatus === 0 && ['Material Issue', 'Material Receipt', 'Material Transfer', 'Send to Subcontractor'].includes(frm.doc.purpose)) { - frm.add_custom_button(__('Bill of Materials'), function() { - frm.events.get_items_from_bom(frm); - }, __("Get items from")); + frm.add_custom_button(__('Bill of Materials'), function() { + frm.events.get_items_from_bom(frm); + }, __("Get Items From")); } }, @@ -510,22 +537,31 @@ frappe.ui.form.on('Stock Entry', { calculate_amount: function(frm) { frm.events.calculate_total_additional_costs(frm); - - const total_basic_amount = frappe.utils.sum( - (frm.doc.items || []).map(function(i) { return i.t_warehouse ? flt(i.basic_amount) : 0; }) - ); + let total_basic_amount = 0; + if (in_list(["Repack", "Manufacture"], frm.doc.purpose)) { + total_basic_amount = frappe.utils.sum( + (frm.doc.items || []).map(function(i) { + return i.is_finished_item ? flt(i.basic_amount) : 0; + }) + ); + } else { + total_basic_amount = frappe.utils.sum( + (frm.doc.items || []).map(function(i) { + return i.t_warehouse ? flt(i.basic_amount) : 0; + }) + ); + } for (let i in frm.doc.items) { let item = frm.doc.items[i]; - if (item.t_warehouse && total_basic_amount) { + if (((in_list(["Repack", "Manufacture"], frm.doc.purpose) && item.is_finished_item) || item.t_warehouse) && total_basic_amount) { item.additional_cost = (flt(item.basic_amount) / total_basic_amount) * frm.doc.total_additional_costs; } else { item.additional_cost = 0; } - item.amount = flt(item.basic_amount + flt(item.additional_cost), - precision("amount", item)); + item.amount = flt(item.basic_amount + flt(item.additional_cost), precision("amount", item)); if (flt(item.transfer_qty)) { item.valuation_rate = flt(flt(item.basic_rate) + (flt(item.additional_cost) / flt(item.transfer_qty)), @@ -538,7 +574,7 @@ frappe.ui.form.on('Stock Entry', { calculate_total_additional_costs: function(frm) { const total_additional_costs = frappe.utils.sum( - (frm.doc.additional_costs || []).map(function(c) { return flt(c.amount); }) + (frm.doc.additional_costs || []).map(function(c) { return flt(c.base_amount); }) ); frm.set_value("total_additional_costs", @@ -555,6 +591,7 @@ frappe.ui.form.on('Stock Entry', { add_to_transit: function(frm) { if(frm.doc.add_to_transit && frm.doc.purpose=='Material Transfer') { + frm.set_value('to_warehouse', ''); frm.set_value('stock_entry_type', 'Material Transfer'); frm.fields_dict.to_warehouse.get_query = function() { return { @@ -565,14 +602,26 @@ frappe.ui.form.on('Stock Entry', { } }; }; - frappe.db.get_value('Company', frm.doc.company, 'default_in_transit_warehouse', (r) => { + frm.trigger('set_tansit_warehouse'); + } + }, + + set_tansit_warehouse: function(frm) { + if(frm.doc.add_to_transit && frm.doc.purpose == 'Material Transfer' && !frm.doc.to_warehouse) { + let dt = frm.doc.from_warehouse ? 'Warehouse' : 'Company'; + let dn = frm.doc.from_warehouse ? frm.doc.from_warehouse : frm.doc.company; + frappe.db.get_value(dt, dn, 'default_in_transit_warehouse', (r) => { if (r.default_in_transit_warehouse) { frm.set_value('to_warehouse', r.default_in_transit_warehouse); } }); } + }, + + apply_putaway_rule: function (frm) { + if (frm.doc.apply_putaway_rule) erpnext.apply_putaway_rule(frm, frm.doc.purpose); } -}) +}); frappe.ui.form.on('Stock Entry Detail', { qty: function(frm, cdt, cdn) { @@ -666,7 +715,13 @@ frappe.ui.form.on('Stock Entry Detail', { }); refresh_field("items"); - if (!d.serial_no) { + let no_batch_serial_number_value = !d.serial_no; + if (d.has_batch_no && !d.has_serial_no) { + // check only batch_no for batched item + no_batch_serial_number_value = !d.batch_no; + } + + if (no_batch_serial_number_value) { erpnext.stock.select_batch_and_serial_no(frm, d); } } @@ -707,8 +762,18 @@ var validate_sample_quantity = function(frm, cdt, cdn) { }; frappe.ui.form.on('Landed Cost Taxes and Charges', { - amount: function(frm) { - frm.events.calculate_amount(frm); + amount: function(frm, cdt, cdn) { + frm.events.set_base_amount(frm, cdt, cdn); + + // Adding this check because same table in used in LCV + // This causes an error if you try to post an LCV immediately after a Stock Entry + if (frm.doc.doctype == 'Stock Entry') { + frm.events.calculate_amount(frm); + } + }, + + expense_account: function(frm, cdt, cdn) { + frm.events.set_account_currency(frm, cdt, cdn); } }); @@ -756,15 +821,6 @@ erpnext.stock.StockEntry = erpnext.stock.StockController.extend({ } } - this.frm.set_indicator_formatter('item_code', - function(doc) { - if (!doc.s_warehouse) { - return 'blue'; - } else { - return (doc.qty<=doc.actual_qty) ? "green" : "orange" - } - }) - this.frm.add_fetch("purchase_order", "supplier", "supplier"); frappe.dynamic_link = { doc: this.frm.doc, fieldname: 'supplier', doctype: 'Supplier' } @@ -841,6 +897,10 @@ erpnext.stock.StockEntry = erpnext.stock.StockController.extend({ } }, + fg_completed_qty: function() { + this.get_items(); + }, + get_items: function() { var me = this; if(!this.frm.doc.fg_completed_qty || !this.frm.doc.bom_no) @@ -850,6 +910,7 @@ erpnext.stock.StockEntry = erpnext.stock.StockController.extend({ // if work order / bom is mentioned, get items return this.frm.call({ doc: me.frm.doc, + freeze: true, method: "get_items", callback: function(r) { if(!r.exc) refresh_field("items"); @@ -916,6 +977,7 @@ erpnext.stock.StockEntry = erpnext.stock.StockController.extend({ }, from_warehouse: function(doc) { + this.frm.trigger('set_tansit_warehouse'); this.set_warehouse_in_children(doc.items, "s_warehouse", doc.from_warehouse); }, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 61e0df67238..98c047a09ed 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -27,6 +27,7 @@ "set_posting_time", "inspection_required", "from_bom", + "apply_putaway_rule", "sb1", "bom_no", "fg_completed_qty", @@ -640,13 +641,21 @@ "fieldtype": "Check", "label": "Add to Transit", "no_copy": 1 + }, + { + "default": "0", + "depends_on": "eval:in_list([\"Material Transfer\", \"Material Receipt\"], doc.purpose)", + "fieldname": "apply_putaway_rule", + "fieldtype": "Check", + "label": "Apply Putaway Rule" } ], "icon": "fa fa-file-text", "idx": 1, + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-08-11 19:10:07.954981", + "modified": "2020-12-09 14:58:13.267321", "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 a92d04ff8cd..b5f7e05f224 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -18,7 +18,8 @@ from erpnext.stock.utils import get_bin from frappe.model.mapper import get_mapped_doc from erpnext.stock.doctype.serial_no.serial_no import update_serial_nos_after_submit, get_serial_nos from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import OpeningEntryAccountError - +from erpnext.accounts.general_ledger import process_gl_map +from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals import json from six import string_types, itervalues, iteritems @@ -42,6 +43,14 @@ class StockEntry(StockController): for item in self.get("items"): item.update(get_bin_details(item.item_code, item.s_warehouse)) + def before_validate(self): + from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule + apply_rule = self.apply_putaway_rule and (self.purpose in ["Material Transfer", "Material Receipt"]) + + if self.get("items") and apply_rule: + apply_putaway_rule(self.doctype, self.get("items"), self.company, + purpose=self.purpose) + def validate(self): self.pro_doc = frappe._dict() if self.work_order: @@ -58,6 +67,7 @@ class StockEntry(StockController): self.validate_warehouse() self.validate_work_order() self.validate_bom() + self.mark_finished_and_scrap_items() self.validate_finished_goods() self.validate_with_material_request() self.validate_batch() @@ -75,13 +85,12 @@ class StockEntry(StockController): else: set_batch_nos(self, 's_warehouse') - self.set_incoming_rate() self.validate_serialized_batch() self.set_actual_qty() - self.calculate_rate_and_amount(update_finished_item_rate=False) + self.calculate_rate_and_amount() + self.validate_putaway_capacity() def on_submit(self): - self.update_stock_ledger() update_serial_nos_after_submit(self, "items") @@ -89,14 +98,18 @@ class StockEntry(StockController): self.validate_purchase_order() if self.purchase_order and self.purpose == "Send to Subcontractor": self.update_purchase_order_supplied_items() + self.make_gl_entries() + + self.repost_future_sle_and_gle() self.update_cost_in_project() self.validate_reserved_serial_no_consumption() self.update_transferred_qty() self.update_quality_inspection() + if self.work_order and self.purpose == "Manufacture": self.update_so_in_serial_number() - + if self.purpose == 'Material Transfer' and self.add_to_transit: self.set_material_request_transfer_status('In Transit') if self.purpose == 'Material Transfer' and self.outgoing_stock_entry: @@ -113,13 +126,15 @@ class StockEntry(StockController): self.update_work_order() self.update_stock_ledger() - self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry') + self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry', 'Repost Item Valuation') self.make_gl_entries_on_cancel() + self.repost_future_sle_and_gle() self.update_cost_in_project() self.update_transferred_qty() self.update_quality_inspection() self.delete_auto_created_batches() + self.delete_linked_stock_entry() if self.purpose == 'Material Transfer' and self.add_to_transit: self.set_material_request_transfer_status('Not Started') @@ -148,10 +163,16 @@ class StockEntry(StockController): if self.purpose not in valid_purposes: frappe.throw(_("Purpose must be one of {0}").format(comma_or(valid_purposes))) - if self.job_card and self.purpose != 'Material Transfer for Manufacture': + if self.job_card and self.purpose not in ['Material Transfer for Manufacture', 'Repack']: frappe.throw(_("For job card {0}, you can only make the 'Material Transfer for Manufacture' type stock entry") .format(self.job_card)) + def delete_linked_stock_entry(self): + if self.purpose == "Send to Warehouse": + for d in frappe.get_all("Stock Entry", filters={"docstatus": 0, + "outgoing_stock_entry": self.name, "purpose": "Receive at Warehouse"}): + frappe.delete_doc("Stock Entry", d.name) + def set_transfer_qty(self): for item in self.get("items"): if not flt(item.qty): @@ -175,7 +196,7 @@ class StockEntry(StockController): and (sed.t_warehouse is null or sed.t_warehouse = '')""", self.project, as_list=1) amount = amount[0][0] if amount else 0 - additional_costs = frappe.db.sql(""" select ifnull(sum(sed.amount), 0) + additional_costs = frappe.db.sql(""" select ifnull(sum(sed.base_amount), 0) from `tabStock Entry` se, `tabLanded Cost Taxes and Charges` sed where @@ -205,7 +226,9 @@ class StockEntry(StockController): for f in ("uom", "stock_uom", "description", "item_name", "expense_account", "cost_center", "conversion_factor"): - if f in ["stock_uom", "conversion_factor"] or not item.get(f): + if f == "stock_uom" or not item.get(f): + item.set(f, item_details.get(f)) + if f == 'conversion_factor' and item.uom == item_details.get('stock_uom'): item.set(f, item_details.get(f)) if not item.transfer_qty and item.qty: @@ -246,12 +269,17 @@ class StockEntry(StockController): item_code.append(item.item_code) def validate_fg_completed_qty(self): + item_wise_qty = {} if self.purpose == "Manufacture" and self.work_order: - production_item = frappe.get_value('Work Order', self.work_order, 'production_item') - for item in self.items: - if item.item_code == production_item and item.t_warehouse and item.qty != self.fg_completed_qty: - frappe.throw(_("Finished product quantity {0} and For Quantity {1} cannot be different") - .format(item.qty, self.fg_completed_qty)) + for d in self.items: + if d.is_finished_item: + item_wise_qty.setdefault(d.item_code, []).append(d.qty) + + for item_code, qty_list in iteritems(item_wise_qty): + total = flt(sum(qty_list), frappe.get_precision("Stock Entry Detail", "qty")) + if self.fg_completed_qty != total: + frappe.throw(_("The finished product {0} quantity {1} and For Quantity {2} cannot be different") + .format(frappe.bold(item_code), frappe.bold(total), frappe.bold(self.fg_completed_qty))) def validate_difference_account(self): if not cint(erpnext.is_perpetual_inventory_enabled(self.company)): @@ -307,7 +335,7 @@ class StockEntry(StockController): if self.purpose == "Manufacture": if validate_for_manufacture: - if d.bom_no: + 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)) @@ -373,21 +401,6 @@ class StockEntry(StockController): frappe.throw(_("Stock Entries already created for Work Order ") + self.work_order + ":" + ", ".join(other_ste), DuplicateEntryForWorkOrderError) - def set_incoming_rate(self): - if self.purpose == "Repack": - self.set_basic_rate_for_finished_goods() - - for d in self.items: - if d.s_warehouse: - args = self.get_args_for_incoming_rate(d) - d.basic_rate = get_incoming_rate(args) - elif d.allow_zero_valuation_rate and not d.s_warehouse: - d.basic_rate = 0.0 - elif d.t_warehouse and not d.basic_rate: - d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse, - self.doctype, self.name, d.allow_zero_valuation_rate, - currency=erpnext.get_company_currency(self.company), company=self.company) - def set_actual_qty(self): allow_negative_stock = cint(frappe.db.get_value("Stock Settings", None, "allow_negative_stock")) @@ -423,57 +436,66 @@ class StockEntry(StockController): d.serial_no = transferred_serial_no def get_stock_and_rate(self): + """ + Updates rate and availability of all the items. + Called from Update Rate and Availability button. + """ self.set_work_order_details() self.set_transfer_qty() self.set_actual_qty() self.calculate_rate_and_amount() - def calculate_rate_and_amount(self, force=False, - update_finished_item_rate=True, raise_error_if_no_rate=True): - self.set_basic_rate(force, update_finished_item_rate, raise_error_if_no_rate) + def calculate_rate_and_amount(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): + self.set_basic_rate(reset_outgoing_rate, raise_error_if_no_rate) + init_landed_taxes_and_totals(self) self.distribute_additional_costs() self.update_valuation_rate() self.set_total_incoming_outgoing_value() self.set_total_amount() - def set_basic_rate(self, force=False, update_finished_item_rate=True, raise_error_if_no_rate=True): - """get stock and incoming rate on posting date""" - raw_material_cost = 0.0 - scrap_material_cost = 0.0 - fg_basic_rate = 0.0 + def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): + """ + Set rate for outgoing, scrapped and finished items + """ + # Set rate for outgoing items + 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]) + # Set basic rate for incoming items for d in self.get('items'): - if d.t_warehouse: fg_basic_rate = flt(d.basic_rate) - args = self.get_args_for_incoming_rate(d) + if d.s_warehouse or d.set_basic_rate_manually: continue - # get basic rate - if not d.bom_no: - if (not flt(d.basic_rate) and not d.allow_zero_valuation_rate) or d.s_warehouse or force: - basic_rate = flt(get_incoming_rate(args, raise_error_if_no_rate), self.precision("basic_rate", d)) - if basic_rate > 0: - d.basic_rate = basic_rate + if d.allow_zero_valuation_rate: + d.basic_rate = 0.0 + elif d.is_finished_item: + if self.purpose == "Manufacture": + d.basic_rate = self.get_basic_rate_for_manufactured_item(finished_item_qty, outgoing_items_cost) + elif self.purpose == "Repack": + d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost) + + if not d.basic_rate and not d.allow_zero_valuation_rate: + d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse, + self.doctype, self.name, d.allow_zero_valuation_rate, + currency=erpnext.get_company_currency(self.company), company=self.company, + raise_error_if_no_rate=raise_error_if_no_rate) + + d.basic_rate = flt(d.basic_rate, d.precision("basic_rate")) + d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) + + 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'): + if d.s_warehouse: + if reset_outgoing_rate: + args = self.get_args_for_incoming_rate(d) + rate = get_incoming_rate(args, raise_error_if_no_rate) + if rate > 0: + d.basic_rate = rate d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) if not d.t_warehouse: - raw_material_cost += flt(d.basic_amount) - - # get scrap items basic rate - if d.bom_no: - if not flt(d.basic_rate) and not d.allow_zero_valuation_rate and \ - getattr(self, "pro_doc", frappe._dict()).scrap_warehouse == d.t_warehouse: - basic_rate = flt(get_incoming_rate(args, raise_error_if_no_rate), - self.precision("basic_rate", d)) - if basic_rate > 0: - d.basic_rate = basic_rate - d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) - - if getattr(self, "pro_doc", frappe._dict()).scrap_warehouse == d.t_warehouse: - - scrap_material_cost += flt(d.basic_amount) - - number_of_fg_items = len([t.t_warehouse for t in self.get("items") if t.t_warehouse]) - if (fg_basic_rate == 0.0 and number_of_fg_items == 1) or update_finished_item_rate: - self.set_basic_rate_for_finished_goods(raw_material_cost, scrap_material_cost) + outgoing_items_cost += flt(d.basic_amount) + return outgoing_items_cost def get_args_for_incoming_rate(self, item): return frappe._dict({ @@ -489,44 +511,44 @@ class StockEntry(StockController): "allow_zero_valuation": item.allow_zero_valuation_rate, }) - def set_basic_rate_for_finished_goods(self, raw_material_cost=0, scrap_material_cost=0): - total_fg_qty = 0 - if not raw_material_cost and self.get("items"): - raw_material_cost = sum([flt(row.basic_amount) for row in self.items - if row.s_warehouse and not row.t_warehouse]) + def get_basic_rate_for_repacked_items(self, finished_item_qty, outgoing_items_cost): + finished_items = [d.item_code for d in self.get("items") if d.is_finished_item] + if len(finished_items) == 1: + return flt(outgoing_items_cost / finished_item_qty) + else: + unique_finished_items = set(finished_items) + if len(unique_finished_items) == 1: + total_fg_qty = sum([flt(d.transfer_qty) for d in self.items if d.is_finished_item]) + return flt(outgoing_items_cost / total_fg_qty) - total_fg_qty = sum([flt(row.qty) for row in self.items - if row.t_warehouse and not row.s_warehouse]) + def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0): + scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_scrap_item]) - if self.purpose in ["Manufacture", "Repack"]: - for d in self.get("items"): - if (d.transfer_qty and (d.bom_no or d.t_warehouse) - and (getattr(self, "pro_doc", frappe._dict()).scrap_warehouse != d.t_warehouse)): + # Get raw materials cost from BOM if multiple material consumption entries + if frappe.db.get_single_value("Manufacturing Settings", "material_consumption"): + bom_items = self.get_bom_raw_materials(finished_item_qty) + outgoing_items_cost = sum([flt(row.qty)*flt(row.rate) for row in bom_items.values()]) - if (self.work_order and self.purpose == "Manufacture" - and frappe.db.get_single_value("Manufacturing Settings", "material_consumption")): - bom_items = self.get_bom_raw_materials(d.transfer_qty) - raw_material_cost = sum([flt(row.qty)*flt(row.rate) for row in bom_items.values()]) - - if raw_material_cost and self.purpose == "Manufacture": - d.basic_rate = flt((raw_material_cost - scrap_material_cost) / flt(d.transfer_qty), d.precision("basic_rate")) - d.basic_amount = flt((raw_material_cost - scrap_material_cost), d.precision("basic_amount")) - elif self.purpose == "Repack" and total_fg_qty and not d.set_basic_rate_manually: - d.basic_rate = flt(raw_material_cost) / flt(total_fg_qty) - d.basic_amount = d.basic_rate * flt(d.qty) + return flt((outgoing_items_cost - scrap_items_cost) / finished_item_qty) def distribute_additional_costs(self): - if self.purpose == "Material Issue": + # If no incoming items, set additional costs blank + if not any([d.item_code for d in self.items if d.t_warehouse]): self.additional_costs = [] - self.total_additional_costs = sum([flt(t.amount) for t in self.get("additional_costs")]) - total_basic_amount = sum([flt(t.basic_amount) for t in self.get("items") if t.t_warehouse]) + self.total_additional_costs = sum([flt(t.base_amount) for t in self.get("additional_costs")]) - for d in self.get("items"): - if d.t_warehouse and total_basic_amount: - d.additional_cost = (flt(d.basic_amount) / total_basic_amount) * self.total_additional_costs - else: - d.additional_cost = 0 + if self.purpose in ("Repack", "Manufacture"): + incoming_items_cost = sum([flt(t.basic_amount) for t in self.get("items") if t.is_finished_item]) + else: + incoming_items_cost = sum([flt(t.basic_amount) for t in self.get("items") if t.t_warehouse]) + + if incoming_items_cost: + for d in self.get("items"): + if (self.purpose in ("Repack", "Manufacture") and d.is_finished_item) or d.t_warehouse: + d.additional_cost = (flt(d.basic_amount) / incoming_items_cost) * self.total_additional_costs + else: + d.additional_cost = 0 def update_valuation_rate(self): for d in self.get("items"): @@ -569,8 +591,9 @@ class StockEntry(StockController): qty_allowance = flt(frappe.db.get_single_value("Buying Settings", "over_transfer_allowance")) - if (self.purpose == "Send to Subcontractor" and self.purchase_order and - backflush_raw_materials_based_on == 'BOM'): + if not (self.purpose == "Send to Subcontractor" and self.purchase_order): return + + if (backflush_raw_materials_based_on == 'BOM'): purchase_order = frappe.get_doc("Purchase Order", self.purchase_order) for se_item in self.items: item_code = se_item.original_item or se_item.item_code @@ -607,6 +630,20 @@ class StockEntry(StockController): if flt(total_supplied, precision) > flt(total_allowed, precision): frappe.throw(_("Row {0}# Item {1} cannot be transferred more than {2} against Purchase Order {3}") .format(se_item.idx, se_item.item_code, total_allowed, self.purchase_order)) + elif backflush_raw_materials_based_on == "Material Transferred for Subcontract": + for row in self.items: + if not row.subcontracted_item: + frappe.throw(_("Row {0}: Subcontracted Item is mandatory for the raw material {1}") + .format(row.idx, frappe.bold(row.item_code))) + elif not row.po_detail: + filters = { + "parent": self.purchase_order, "docstatus": 1, + "rm_item_code": row.item_code, "main_item_code": row.subcontracted_item + } + + po_detail = frappe.db.get_value("Purchase Order Item Supplied", filters, "name") + if po_detail: + row.db_set("po_detail", po_detail) def validate_bom(self): for d in self.get('items'): @@ -614,71 +651,115 @@ class StockEntry(StockController): item_code = d.original_item or d.item_code validate_bom_no(item_code, d.bom_no) + def mark_finished_and_scrap_items(self): + if self.purpose in ("Repack", "Manufacture"): + if 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() + + for d in self.items: + if d.t_warehouse and not d.s_warehouse: + if self.purpose=="Repack" or d.item_code == finished_item: + d.is_finished_item = 1 + else: + d.is_scrap_item = 1 + else: + d.is_finished_item = 0 + d.is_scrap_item = 0 + + def get_finished_item(self): + finished_item = None + if self.work_order: + finished_item = frappe.db.get_value("Work Order", self.work_order, "production_item") + elif self.bom_no: + finished_item = frappe.db.get_value("BOM", self.bom_no, "item") + + return finished_item + def validate_finished_goods(self): """validation: finished good quantity should be same as manufacturing quantity""" if not self.work_order: return - items_with_target_warehouse = [] - allowance_percentage = flt(frappe.db.get_single_value("Manufacturing Settings", - "overproduction_percentage_for_work_order")) - production_item, wo_qty = frappe.db.get_value("Work Order", self.work_order, ["production_item", "qty"]) + finished_items = [] for d in self.get('items'): - if (self.purpose != "Send to Subcontractor" and d.bom_no - and flt(d.transfer_qty) > flt(self.fg_completed_qty) and d.item_code == production_item): - frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}"). \ - format(d.idx, d.transfer_qty, self.fg_completed_qty)) + if d.is_finished_item: + if d.item_code != production_item: + frappe.throw(_("Finished Item {0} does not match with Work Order {1}") + .format(d.item_code, self.work_order)) + elif flt(d.transfer_qty) > flt(self.fg_completed_qty): + frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}"). \ + format(d.idx, d.transfer_qty, self.fg_completed_qty)) + finished_items.append(d.item_code) - if self.work_order and self.purpose == "Manufacture" and d.t_warehouse: - items_with_target_warehouse.append(d.item_code) + if len(set(finished_items)) > 1: + frappe.throw(_("Multiple items cannot be marked as finished item")) + + if self.purpose == "Manufacture": + allowance_percentage = flt(frappe.db.get_single_value("Manufacturing Settings", + "overproduction_percentage_for_work_order")) - if self.work_order and self.purpose == "Manufacture": allowed_qty = wo_qty + (allowance_percentage/100 * wo_qty) if self.fg_completed_qty > allowed_qty: frappe.throw(_("For quantity {0} should not be greater than work order quantity {1}") .format(flt(self.fg_completed_qty), wo_qty)) - if production_item not in items_with_target_warehouse: - frappe.throw(_("Finished Item {0} must be entered for Manufacture type entry") - .format(production_item)) - def update_stock_ledger(self): sl_entries = [] + finished_item_row = self.get_finished_item_row() - # make sl entries for source warehouse first, then do for target warehouse - for d in self.get('items'): - if cstr(d.s_warehouse): - sl_entries.append(self.get_sl_entries(d, { - "warehouse": cstr(d.s_warehouse), - "actual_qty": -flt(d.transfer_qty), - "incoming_rate": 0 - })) + # make sl entries for source warehouse first + self.get_sle_for_source_warehouse(sl_entries, finished_item_row) - for d in self.get('items'): - if cstr(d.t_warehouse): - sl_entries.append(self.get_sl_entries(d, { - "warehouse": cstr(d.t_warehouse), - "actual_qty": flt(d.transfer_qty), - "incoming_rate": flt(d.valuation_rate) - })) - - # On cancellation, make stock ledger entry for - # target warehouse first, to update serial no values properly - - # if cstr(d.s_warehouse) and self.docstatus == 2: - # sl_entries.append(self.get_sl_entries(d, { - # "warehouse": cstr(d.s_warehouse), - # "actual_qty": -flt(d.transfer_qty), - # "incoming_rate": 0 - # })) + # SLE for target warehouse + self.get_sle_for_target_warehouse(sl_entries, finished_item_row) + # reverse sl entries if cancel if self.docstatus == 2: sl_entries.reverse() self.make_sl_entries(sl_entries) + def get_finished_item_row(self): + finished_item_row = None + if self.purpose in ("Manufacture", "Repack"): + for d in self.get('items'): + if d.is_finished_item: + finished_item_row = d + + return finished_item_row + + def get_sle_for_source_warehouse(self, sl_entries, finished_item_row): + for d in self.get('items'): + if cstr(d.s_warehouse): + sle = self.get_sl_entries(d, { + "warehouse": cstr(d.s_warehouse), + "actual_qty": -flt(d.transfer_qty), + "incoming_rate": 0 + }) + if cstr(d.t_warehouse): + sle.dependant_sle_voucher_detail_no = d.name + elif finished_item_row and (finished_item_row.item_code != d.item_code or finished_item_row.t_warehouse != d.s_warehouse): + sle.dependant_sle_voucher_detail_no = finished_item_row.name + + sl_entries.append(sle) + + def get_sle_for_target_warehouse(self, sl_entries, finished_item_row): + for d in self.get('items'): + if cstr(d.t_warehouse): + sle = self.get_sl_entries(d, { + "warehouse": cstr(d.t_warehouse), + "actual_qty": flt(d.transfer_qty), + "incoming_rate": flt(d.valuation_rate) + }) + if cstr(d.s_warehouse) or (finished_item_row and d.name == finished_item_row.name): + sle.recalculate_rate = 1 + + sl_entries.append(sle) + def get_gl_entries(self, warehouse_account): gl_entries = super(StockEntry, self).get_gl_entries(warehouse_account) @@ -695,13 +776,19 @@ class StockEntry(StockController): for d in self.get("items"): if d.t_warehouse: item_account_wise_additional_cost.setdefault((d.item_code, d.name), {}) - item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(t.expense_account, 0.0) + item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(t.expense_account, { + "amount": 0.0, + "base_amount": 0.0 + }) multiply_based_on = d.basic_amount if total_basic_amount else d.qty - item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account] += \ + item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]["amount"] += \ flt(t.amount * multiply_based_on) / divide_based_on + item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]["base_amount"] += \ + flt(t.base_amount * multiply_based_on) / divide_based_on + if item_account_wise_additional_cost: for d in self.get("items"): for account, amount in iteritems(item_account_wise_additional_cost.get((d.item_code, d.name), {})): @@ -712,7 +799,8 @@ class StockEntry(StockController): "against": d.expense_account, "cost_center": d.cost_center, "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": amount + "credit_in_account_currency": flt(amount["amount"]), + "credit": flt(amount["base_amount"]) }, item=d)) gl_entries.append(self.get_gl_dict({ @@ -720,10 +808,10 @@ class StockEntry(StockController): "against": account, "cost_center": d.cost_center, "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": -1 * amount # put it as negative credit instead of debit purposefully + "credit": -1 * amount['base_amount'] # put it as negative credit instead of debit purposefully }, item=d)) - return gl_entries + return process_gl_map(gl_entries) def update_work_order(self): def _validate_work_order(pro_doc): @@ -736,6 +824,7 @@ class StockEntry(StockController): if self.job_card: job_doc = frappe.get_doc('Job Card', self.job_card) job_doc.set_transferred_qty(update_status=True) + job_doc.set_transferred_qty_in_job_card(self) if self.work_order: pro_doc = frappe.get_doc("Work Order", self.work_order) @@ -815,6 +904,13 @@ class StockEntry(StockController): ret.get('has_batch_no') and not args.get('batch_no')): args.batch_no = get_batch_no(args['item_code'], args['s_warehouse'], args['qty']) + if self.purpose == "Send to Subcontractor" and self.get("purchase_order") and args.get('item_code'): + subcontract_items = frappe.get_all("Purchase Order Item Supplied", + {"parent": self.purchase_order, "rm_item_code": args.get('item_code')}, "main_item_code") + + if subcontract_items and len(subcontract_items) == 1: + ret["subcontracted_item"] = subcontract_items[0].main_item_code + return ret def set_items_for_stock_in(self): @@ -849,6 +945,8 @@ class StockEntry(StockController): frappe.throw(_("Posting date and posting time is mandatory")) self.set_work_order_details() + self.flags.backflush_based_on = frappe.db.get_single_value("Manufacturing Settings", + "backflush_raw_materials_based_on") if self.bom_no: @@ -865,14 +963,16 @@ class StockEntry(StockController): item["to_warehouse"] = self.pro_doc.wip_warehouse self.add_to_stock_entry_detail(item_dict) - elif (self.work_order and (self.purpose == "Manufacture" or self.purpose == "Material Consumption for Manufacture") - and not self.pro_doc.skip_transfer and backflush_based_on == "Material Transferred for Manufacture"): + elif (self.work_order and (self.purpose == "Manufacture" + or self.purpose == "Material Consumption for Manufacture") and not self.pro_doc.skip_transfer + and self.flags.backflush_based_on == "Material Transferred for Manufacture"): self.get_transfered_raw_materials() - elif (self.work_order and backflush_based_on== "BOM" and - (self.purpose == "Manufacture" or self.purpose == "Material Consumption for Manufacture") + elif (self.work_order and (self.purpose == "Manufacture" or + self.purpose == "Material Consumption for Manufacture") and self.flags.backflush_based_on== "BOM" and frappe.db.get_single_value("Manufacturing Settings", "material_consumption")== 1): self.get_unconsumed_raw_materials() + else: if not self.fg_completed_qty: frappe.throw(_("Manufacturing Quantity is mandatory")) @@ -910,7 +1010,8 @@ class StockEntry(StockController): self.set_scrap_items() self.set_actual_qty() - self.calculate_rate_and_amount(raise_error_if_no_rate=False) + self.validate_customer_provided_item() + self.calculate_rate_and_amount() def set_scrap_items(self): if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]: @@ -961,6 +1062,7 @@ class StockEntry(StockController): "stock_uom": item.stock_uom, "expense_account": item.get("expense_account"), "cost_center": item.get("buying_cost_center"), + "is_finished_item": 1 } }, bom_no = self.bom_no) @@ -999,32 +1101,29 @@ class StockEntry(StockController): for item in itervalues(item_dict): item.from_warehouse = "" + item.is_scrap_item = 1 return item_dict def get_unconsumed_raw_materials(self): wo = frappe.get_doc("Work Order", self.work_order) wo_items = frappe.get_all('Work Order Item', filters={'parent': self.work_order}, - fields=["item_code", "required_qty", "consumed_qty"] + fields=["item_code", "required_qty", "consumed_qty", "transferred_qty"] ) + work_order_qty = wo.material_transferred_for_manufacturing or wo.qty for item in wo_items: - qty = item.required_qty - item_account_details = get_item_defaults(item.item_code, self.company) # Take into account consumption if there are any. - if self.purpose == 'Manufacture': - req_qty_each = flt(item.required_qty / wo.qty) - if (flt(item.consumed_qty) != 0): - remaining_qty = flt(item.consumed_qty) - (flt(wo.produced_qty) * req_qty_each) - exhaust_qty = req_qty_each * wo.produced_qty - if remaining_qty > exhaust_qty : - if (remaining_qty/(req_qty_each * flt(self.fg_completed_qty))) >= 1: - qty =0 - else: - qty = (req_qty_each * flt(self.fg_completed_qty)) - remaining_qty - else: - qty = req_qty_each * flt(self.fg_completed_qty) + + wo_item_qty = item.transferred_qty or item.required_qty + + req_qty_each = ( + (flt(wo_item_qty) - flt(item.consumed_qty)) / + (flt(work_order_qty) - flt(wo.produced_qty)) + ) + + qty = req_qty_each * flt(self.fg_completed_qty) if qty > 0: self.add_to_stock_entry_detail({ @@ -1104,15 +1203,23 @@ class StockEntry(StockController): else: qty = (req_qty_each * flt(self.fg_completed_qty)) - remaining_qty else: - qty = req_qty_each * flt(self.fg_completed_qty) - + if self.flags.backflush_based_on == "Material Transferred for Manufacture": + qty = (item.qty/trans_qty) * flt(self.fg_completed_qty) + else: + qty = req_qty_each * flt(self.fg_completed_qty) elif backflushed_materials.get(item.item_code): for d in backflushed_materials.get(item.item_code): if d.get(item.warehouse): if (qty > req_qty): - qty = req_qty - qty-= d.get(item.warehouse) + qty = (qty/trans_qty) * flt(self.fg_completed_qty) + + if consumed_qty and frappe.db.get_single_value("Manufacturing Settings", + "material_consumption"): + qty -= consumed_qty + + if cint(frappe.get_cached_value('UOM', item.stock_uom, 'must_be_whole_number')): + qty = frappe.utils.ceil(qty) if qty > 0: self.add_to_stock_entry_detail({ @@ -1137,12 +1244,24 @@ class StockEntry(StockController): item_dict = self.get_pro_order_required_items(backflush_based_on) max_qty = flt(self.pro_doc.qty) + + allow_overproduction = False + overproduction_percentage = flt(frappe.db.get_single_value("Manufacturing Settings", + "overproduction_percentage_for_work_order")) + + to_transfer_qty = flt(self.pro_doc.material_transferred_for_manufacturing) + flt(self.fg_completed_qty) + transfer_limit_qty = max_qty + ((max_qty * overproduction_percentage) / 100) + + if transfer_limit_qty >= to_transfer_qty: + allow_overproduction = True + for item, item_details in iteritems(item_dict): pending_to_issue = flt(item_details.required_qty) - flt(item_details.transferred_qty) desire_to_transfer = flt(self.fg_completed_qty) * flt(item_details.required_qty) / max_qty - if (desire_to_transfer <= pending_to_issue or - (desire_to_transfer > 0 and backflush_based_on == "Material Transferred for Manufacture")): + if (desire_to_transfer <= pending_to_issue + or (desire_to_transfer > 0 and backflush_based_on == "Material Transferred for Manufacture") + or allow_overproduction): item_dict[item]["qty"] = desire_to_transfer elif pending_to_issue > 0: item_dict[item]["qty"] = pending_to_issue @@ -1185,8 +1304,6 @@ class StockEntry(StockController): return item_dict def add_to_stock_entry_detail(self, item_dict, bom_no=None): - cost_center = frappe.db.get_value("Company", self.company, 'cost_center') - for d in item_dict: stock_uom = item_dict[d].get("stock_uom") or frappe.db.get_value("Item", d, "stock_uom") @@ -1197,9 +1314,12 @@ class StockEntry(StockController): se_child.uom = item_dict[d]["uom"] if item_dict[d].get("uom") else stock_uom se_child.stock_uom = stock_uom se_child.qty = flt(item_dict[d]["qty"], se_child.precision("qty")) - se_child.cost_center = item_dict[d].get("cost_center") or cost_center se_child.allow_alternative_item = item_dict[d].get("allow_alternative_item", 0) se_child.subcontracted_item = item_dict[d].get("main_item_code") + se_child.cost_center = (item_dict[d].get("cost_center") or + get_default_cost_center(item_dict[d], company = self.company)) + se_child.is_finished_item = item_dict[d].get("is_finished_item", 0) + se_child.is_scrap_item = item_dict[d].get("is_scrap_item", 0) for field in ["idx", "po_detail", "original_item", "expense_account", "description", "item_name"]: @@ -1238,9 +1358,6 @@ class StockEntry(StockController): frappe.MappingMismatchError) elif self.purpose == "Material Transfer" and self.add_to_transit: continue - elif mreq_item.warehouse != (item.s_warehouse if self.purpose == "Material Issue" else item.t_warehouse): - frappe.throw(_("Warehouse for row {0} does not match Material Request").format(item.idx), - frappe.MappingMismatchError) def validate_batch(self): if self.purpose in ["Material Transfer for Manufacture", "Manufacture", "Repack", "Send to Subcontractor"]: @@ -1268,9 +1385,16 @@ class StockEntry(StockController): #Update Supplied Qty in PO Supplied Items frappe.db.sql("""UPDATE `tabPurchase Order Item Supplied` pos - SET pos.supplied_qty = (SELECT ifnull(sum(transfer_qty), 0) FROM `tabStock Entry Detail` sed - WHERE pos.name = sed.po_detail and sed.docstatus = 1) - WHERE pos.docstatus = 1 and pos.parent = %s""", self.purchase_order) + SET + pos.supplied_qty = IFNULL((SELECT ifnull(sum(transfer_qty), 0) + FROM + `tabStock Entry Detail` sed, `tabStock Entry` se + WHERE + pos.name = sed.po_detail AND pos.rm_item_code = sed.item_code + AND pos.parent = se.purchase_order AND sed.docstatus = 1 + AND se.name = sed.parent and se.purchase_order = %(po)s + ), 0) + WHERE pos.docstatus = 1 and pos.parent = %(po)s""", {"po": self.purchase_order}) #Update reserved sub contracted quantity in bin based on Supplied Item Details and for d in self.get("items"): @@ -1303,8 +1427,10 @@ class StockEntry(StockController): for sr in get_serial_nos(item.serial_no): sales_order = frappe.db.get_value("Serial No", sr, "sales_order") if sales_order: - frappe.throw(_("Item {0} (Serial No: {1}) cannot be consumed as is reserverd\ - to fullfill Sales Order {2}.").format(item.item_code, sr, sales_order)) + msg = (_("(Serial No: {0}) cannot be consumed as it's reserverd to fullfill Sales Order {1}.") + .format(sr, sales_order)) + + frappe.throw(_("Item {0} {1}").format(item.item_code, msg)) def update_transferred_qty(self): if self.purpose == 'Material Transfer' and self.outgoing_stock_entry: @@ -1370,7 +1496,7 @@ class StockEntry(StockController): if self.outgoing_stock_entry: parent_se = frappe.get_value("Stock Entry", self.outgoing_stock_entry, 'add_to_transit') - for item in self.items: + for item in self.items: material_request = item.material_request or None if self.purpose == "Material Transfer" and material_request not in material_requests: if self.outgoing_stock_entry and parent_se: @@ -1430,7 +1556,7 @@ def make_stock_in_entry(source_name, target_doc=None): if add_to_transit: warehouse = frappe.get_value('Material Request Item', source_doc.material_request_item, 'warehouse') target_doc.t_warehouse = warehouse - + target_doc.s_warehouse = source_doc.t_warehouse target_doc.qty = source_doc.qty - source_doc.transferred_qty diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index b78c6be983f..b12a8547fea 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -53,6 +53,8 @@ def make_stock_entry(**args): args.target = args.to_warehouse if args.item_code: args.item = args.item_code + if args.apply_putaway_rule: + s.apply_putaway_rule = args.apply_putaway_rule if isinstance(args.qty, string_types): if '.' in args.qty: @@ -118,7 +120,8 @@ def make_stock_entry(**args): "t_warehouse": args.target, "qty": args.qty, "basic_rate": args.rate or args.basic_rate, - "conversion_factor": 1.0, + "conversion_factor": args.conversion_factor or 1.0, + "transfer_qty": flt(args.qty) * (flt(args.conversion_factor) or 1.0), "serial_no": args.serial_no, 'batch_no': args.batch_no, 'cost_center': args.cost_center, diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index d98870de3eb..123f0c86471 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -6,7 +6,6 @@ import frappe, unittest import frappe.defaults from frappe.utils import flt, nowdate, nowtime from erpnext.stock.doctype.serial_no.serial_no import * -from erpnext import set_perpetual_inventory from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import StockFreezeError from erpnext.stock.stock_ledger import get_previous_sle from frappe.permissions import add_user_permission, remove_user_permission @@ -32,7 +31,6 @@ def get_sle(**args): class TestStockEntry(unittest.TestCase): def tearDown(self): frappe.set_user("Administrator") - set_perpetual_inventory(0) def test_fifo(self): frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) @@ -181,22 +179,20 @@ class TestStockEntry(unittest.TestCase): def test_material_transfer_gl_entry(self): company = frappe.db.get_value('Warehouse', 'Stores - TCP1', 'company') - create_stock_reconciliation(qty=100, rate=100) - mtn = make_stock_entry(item_code="_Test Item", source="Stores - TCP1", - target="Finished Goods - TCP1", qty=45) + target="Finished Goods - TCP1", qty=45, company=company) self.check_stock_ledger_entries("Stock Entry", mtn.name, [["_Test Item", "Stores - TCP1", -45.0], ["_Test Item", "Finished Goods - TCP1", 45.0]]) - stock_in_hand_account = get_inventory_account(mtn.company, mtn.get("items")[0].s_warehouse) + source_warehouse_account = get_inventory_account(mtn.company, mtn.get("items")[0].s_warehouse) - fixed_asset_account = get_inventory_account(mtn.company, mtn.get("items")[0].t_warehouse) + target_warehouse_account = get_inventory_account(mtn.company, mtn.get("items")[0].t_warehouse) - if stock_in_hand_account == fixed_asset_account: + if source_warehouse_account == target_warehouse_account: # no gl entry as both source and target warehouse has linked to same account. self.assertFalse(frappe.db.sql("""select * from `tabGL Entry` - where voucher_type='Stock Entry' and voucher_no=%s""", mtn.name)) + where voucher_type='Stock Entry' and voucher_no=%s""", mtn.name, as_dict=1)) else: stock_value_diff = abs(frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Stock Entry", @@ -204,8 +200,8 @@ class TestStockEntry(unittest.TestCase): self.check_gl_entries("Stock Entry", mtn.name, sorted([ - [stock_in_hand_account, 0.0, stock_value_diff], - [fixed_asset_account, stock_value_diff, 0.0], + [source_warehouse_account, 0.0, stock_value_diff], + [target_warehouse_account, stock_value_diff, 0.0], ]) ) @@ -213,7 +209,6 @@ class TestStockEntry(unittest.TestCase): def test_repack_no_change_in_valuation(self): company = frappe.db.get_value('Warehouse', '_Test Warehouse - _TC', 'company') - set_perpetual_inventory(0, company) make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=50, basic_rate=100) make_stock_entry(item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", @@ -235,8 +230,6 @@ class TestStockEntry(unittest.TestCase): order by account desc""", repack.name, as_dict=1) self.assertFalse(gl_entries) - set_perpetual_inventory(0, repack.company) - def test_repack_with_additional_costs(self): company = frappe.db.get_value('Warehouse', 'Stores - TCP1', 'company') @@ -474,7 +467,6 @@ class TestStockEntry(unittest.TestCase): def test_warehouse_company_validation(self): company = frappe.db.get_value('Warehouse', '_Test Warehouse 2 - _TC1', 'company') - set_perpetual_inventory(0, company) frappe.get_doc("User", "test2@example.com")\ .add_roles("Sales User", "Sales Manager", "Stock User", "Stock Manager") frappe.set_user("test2@example.com") @@ -500,7 +492,7 @@ class TestStockEntry(unittest.TestCase): st1 = frappe.copy_doc(test_records[0]) st1.company = "_Test Company 1" - set_perpetual_inventory(0, st1.company) + frappe.set_user("test@example.com") st1.get("items")[0].t_warehouse="_Test Warehouse 2 - _TC1" self.assertRaises(frappe.PermissionError, st1.insert) @@ -698,47 +690,54 @@ class TestStockEntry(unittest.TestCase): repack.insert() self.assertRaises(frappe.ValidationError, repack.submit) - def test_material_consumption(self): - from erpnext.manufacturing.doctype.work_order.work_order \ - import make_stock_entry as _make_stock_entry - bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", - "is_default": 1, "docstatus": 1}) + # def test_material_consumption(self): + # frappe.db.set_value("Manufacturing Settings", None, "backflush_raw_materials_based_on", "BOM") + # frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0") - work_order = frappe.new_doc("Work Order") - work_order.update({ - "company": "_Test Company", - "fg_warehouse": "_Test Warehouse 1 - _TC", - "production_item": "_Test FG Item 2", - "bom_no": bom_no, - "qty": 4.0, - "stock_uom": "_Test UOM", - "wip_warehouse": "_Test Warehouse - _TC", - "additional_operating_cost": 1000 - }) - work_order.insert() - work_order.submit() + # from erpnext.manufacturing.doctype.work_order.work_order \ + # import make_stock_entry as _make_stock_entry + # bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", + # "is_default": 1, "docstatus": 1}) - make_stock_entry(item_code="_Test Serialized Item With Series", target="_Test Warehouse - _TC", qty=50, basic_rate=100) - make_stock_entry(item_code="_Test Item 2", target="_Test Warehouse - _TC", qty=50, basic_rate=20) + # work_order = frappe.new_doc("Work Order") + # work_order.update({ + # "company": "_Test Company", + # "fg_warehouse": "_Test Warehouse 1 - _TC", + # "production_item": "_Test FG Item 2", + # "bom_no": bom_no, + # "qty": 4.0, + # "stock_uom": "_Test UOM", + # "wip_warehouse": "_Test Warehouse - _TC", + # "additional_operating_cost": 1000, + # "use_multi_level_bom": 1 + # }) + # work_order.insert() + # work_order.submit() - item_quantity = { - '_Test Item': 10.0, - '_Test Item 2': 12.0, - '_Test Serialized Item With Series': 6.0 - } + # make_stock_entry(item_code="_Test Serialized Item With Series", target="_Test Warehouse - _TC", qty=50, basic_rate=100) + # make_stock_entry(item_code="_Test Item 2", target="_Test Warehouse - _TC", qty=50, basic_rate=20) - stock_entry = frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 2)) - for d in stock_entry.get('items'): - self.assertEqual(item_quantity.get(d.item_code), d.qty) + # item_quantity = { + # '_Test Item': 2.0, + # '_Test Item 2': 12.0, + # '_Test Serialized Item With Series': 6.0 + # } + + # stock_entry = frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 2)) + # for d in stock_entry.get('items'): + # self.assertEqual(item_quantity.get(d.item_code), d.qty) def test_customer_provided_parts_se(self): create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0) - se = make_stock_entry(item_code='CUST-0987', purpose = 'Material Receipt', qty=4, to_warehouse = "_Test Warehouse - _TC") + se = make_stock_entry(item_code='CUST-0987', purpose = 'Material Receipt', + qty=4, to_warehouse = "_Test Warehouse - _TC") self.assertEqual(se.get("items")[0].allow_zero_valuation_rate, 1) self.assertEqual(se.get("items")[0].amount, 0) def test_gle_for_opening_stock_entry(self): - mr = make_stock_entry(item_code="_Test Item", target="Stores - TCP1", company="_Test Company with perpetual inventory",qty=50, basic_rate=100, expense_account="Stock Adjustment - TCP1", is_opening="Yes", do_not_save=True) + mr = make_stock_entry(item_code="_Test Item", target="Stores - TCP1", + company="_Test Company with perpetual inventory", qty=50, basic_rate=100, + expense_account="Stock Adjustment - TCP1", is_opening="Yes", do_not_save=True) self.assertRaises(OpeningEntryAccountError, mr.save) @@ -753,37 +752,37 @@ class TestStockEntry(unittest.TestCase): def test_total_basic_amount_zero(self): se = frappe.get_doc({"doctype":"Stock Entry", - "purpose":"Material Receipt", - "stock_entry_type":"Material Receipt", - "posting_date": nowdate(), - "company":"_Test Company with perpetual inventory", - "items":[ - { - "item_code":"Basil Leaves", - "description":"Basil Leaves", - "qty": 1, - "basic_rate": 0, - "uom":"Nos", - "t_warehouse": "Stores - TCP1", - "allow_zero_valuation_rate": 1, - "cost_center": "Main - TCP1" - }, - { - "item_code":"Basil Leaves", - "description":"Basil Leaves", - "qty": 2, - "basic_rate": 0, - "uom":"Nos", - "t_warehouse": "Stores - TCP1", - "allow_zero_valuation_rate": 1, - "cost_center": "Main - TCP1" - }, - ], - "additional_costs":[ - {"expense_account":"Miscellaneous Expenses - TCP1", - "amount":100, - "description": "miscellanous"} - ] + "purpose":"Material Receipt", + "stock_entry_type":"Material Receipt", + "posting_date": nowdate(), + "company":"_Test Company with perpetual inventory", + "items":[ + { + "item_code":"_Test Item", + "description":"_Test Item", + "qty": 1, + "basic_rate": 0, + "uom":"Nos", + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": "Main - TCP1" + }, + { + "item_code":"_Test Item", + "description":"_Test Item", + "qty": 2, + "basic_rate": 0, + "uom":"Nos", + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": "Main - TCP1" + }, + ], + "additional_costs":[ + {"expense_account":"Miscellaneous Expenses - TCP1", + "amount":100, + "description": "miscellanous" + }] }) se.insert() se.submit() @@ -795,6 +794,32 @@ class TestStockEntry(unittest.TestCase): ]) ) + def test_conversion_factor_change(self): + frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) + repack_entry = frappe.copy_doc(test_records[3]) + repack_entry.posting_date = nowdate() + repack_entry.posting_time = nowtime() + repack_entry.set_stock_entry_type() + repack_entry.insert() + + # check current uom and conversion factor + self.assertTrue(repack_entry.items[0].uom, "_Test UOM") + self.assertTrue(repack_entry.items[0].conversion_factor, 1) + + # change conversion factor + repack_entry.items[0].uom = "_Test UOM 1" + repack_entry.items[0].stock_uom = "_Test UOM 1" + repack_entry.items[0].conversion_factor = 2 + repack_entry.save() + repack_entry.submit() + + self.assertEqual(repack_entry.items[0].conversion_factor, 2) + self.assertEqual(repack_entry.items[0].uom, "_Test UOM 1") + self.assertEqual(repack_entry.items[0].qty, 50) + self.assertEqual(repack_entry.items[0].transfer_qty, 100) + + frappe.db.set_default("allow_negative_stock", 0) + def make_serialized_item(**args): args = frappe._dict(args) se = frappe.copy_doc(test_records[0]) 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 7b9c129804e..864ff488b22 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -14,58 +14,63 @@ "t_warehouse", "sec_break1", "item_code", - "col_break2", "item_name", + "col_break2", + "is_finished_item", + "is_scrap_item", + "subcontracted_item", "section_break_8", "description", "column_break_10", "item_group", "image", "image_view", - "quantity_and_rate", - "set_basic_rate_manually", + "quantity_section", "qty", - "basic_rate", - "basic_amount", - "additional_cost", - "amount", - "valuation_rate", - "col_break3", - "uom", - "conversion_factor", - "stock_uom", "transfer_qty", "retain_sample", + "column_break_20", + "uom", + "stock_uom", + "conversion_factor", "sample_quantity", + "rates_section", + "basic_rate", + "additional_cost", + "valuation_rate", + "allow_zero_valuation_rate", + "col_break3", + "set_basic_rate_manually", + "basic_amount", + "amount", "serial_no_batch", "serial_no", "col_break4", "batch_no", - "quality_inspection", "accounting", "expense_account", - "col_break5", "accounting_dimensions_section", "cost_center", + "project", "dimension_col_break", "more_info", - "allow_zero_valuation_rate", "actual_qty", + "transferred_qty", "bom_no", "allow_alternative_item", "col_break6", "material_request", "material_request_item", "original_item", - "subcontracted_item", "reference_section", "against_stock_entry", "ste_detail", "po_detail", + "putaway_rule", "column_break_51", - "transferred_qty", "reference_purchase_receipt", - "project" + "quality_inspection", + "job_card_item" ], "fields": [ { @@ -160,11 +165,6 @@ "options": "image", "print_hide": 1 }, - { - "fieldname": "quantity_and_rate", - "fieldtype": "Section Break", - "label": "Quantity and Rate" - }, { "bold": 1, "fieldname": "qty", @@ -238,7 +238,6 @@ "oldfieldname": "conversion_factor", "oldfieldtype": "Currency", "print_hide": 1, - "read_only": 1, "reqd": 1 }, { @@ -323,10 +322,6 @@ "options": "Account", "print_hide": 1 }, - { - "fieldname": "col_break5", - "fieldtype": "Column Break" - }, { "default": ":Company", "depends_on": "eval:cint(erpnext.is_perpetual_inventory_enabled(parent.company))", @@ -337,6 +332,7 @@ "print_hide": 1 }, { + "collapsible": 1, "fieldname": "more_info", "fieldtype": "Section Break", "label": "More Information" @@ -416,6 +412,7 @@ "read_only": 1 }, { + "depends_on": "eval:parent.purpose == 'Send to Subcontractor'", "fieldname": "subcontracted_item", "fieldtype": "Link", "label": "Subcontracted Item", @@ -457,6 +454,7 @@ "read_only": 1 }, { + "collapsible": 1, "fieldname": "accounting_dimensions_section", "fieldtype": "Section Break", "label": "Accounting Dimensions" @@ -498,15 +496,59 @@ "depends_on": "eval:parent.purpose===\"Repack\" && doc.t_warehouse", "fieldname": "set_basic_rate_manually", "fieldtype": "Check", - "label": "Set Basic Rate Manually", - "show_days": 1, - "show_seconds": 1 + "label": "Set Basic Rate Manually" + }, + { + "depends_on": "eval:in_list([\"Material Transfer\", \"Material Receipt\"], parent.purpose)", + "fieldname": "putaway_rule", + "fieldtype": "Link", + "label": "Putaway Rule", + "no_copy": 1, + "options": "Putaway Rule", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "quantity_section", + "fieldtype": "Section Break", + "label": "Quantity" + }, + { + "fieldname": "column_break_20", + "fieldtype": "Column Break" + }, + { + "fieldname": "rates_section", + "fieldtype": "Section Break", + "label": "Rates" + }, + { + "default": "0", + "fieldname": "is_scrap_item", + "fieldtype": "Check", + "label": "Is Scrap Item" + }, + { + "default": "0", + "fieldname": "is_finished_item", + "fieldtype": "Check", + "label": "Is Finished Item" + }, + { + "fieldname": "job_card_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Job Card Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], "idx": 1, + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-06-08 12:57:03.172887", + "modified": "2021-02-11 13:47:50.158754", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json index fda17e08ab3..2463a21ed61 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json @@ -8,26 +8,33 @@ "engine": "InnoDB", "field_order": [ "item_code", - "serial_no", - "batch_no", "warehouse", "posting_date", "posting_time", + "column_break_6", "voucher_type", "voucher_no", "voucher_detail_no", + "dependant_sle_voucher_detail_no", + "recalculate_rate", + "section_break_11", "actual_qty", + "qty_after_transaction", "incoming_rate", "outgoing_rate", - "stock_uom", - "qty_after_transaction", + "column_break_17", "valuation_rate", "stock_value", "stock_value_difference", "stock_queue", - "project", + "section_break_21", "company", + "stock_uom", + "project", + "batch_no", + "column_break_26", "fiscal_year", + "serial_no", "is_cancelled", "to_rename" ], @@ -50,7 +57,6 @@ { "fieldname": "serial_no", "fieldtype": "Long Text", - "in_list_view": 1, "label": "Serial No", "print_width": "100px", "read_only": 1, @@ -59,7 +65,6 @@ { "fieldname": "batch_no", "fieldtype": "Data", - "in_list_view": 1, "label": "Batch No", "oldfieldname": "batch_no", "oldfieldtype": "Data", @@ -119,6 +124,7 @@ "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "in_filter": 1, + "in_list_view": 1, "in_standard_filter": 1, "label": "Voucher No", "oldfieldname": "voucher_no", @@ -142,6 +148,7 @@ "fieldname": "actual_qty", "fieldtype": "Float", "in_filter": 1, + "in_list_view": 1, "label": "Actual Quantity", "oldfieldname": "actual_qty", "oldfieldtype": "Currency", @@ -152,6 +159,7 @@ { "fieldname": "incoming_rate", "fieldtype": "Currency", + "in_list_view": 1, "label": "Incoming Rate", "oldfieldname": "incoming_rate", "oldfieldtype": "Currency", @@ -217,13 +225,11 @@ { "fieldname": "stock_queue", "fieldtype": "Text", - "hidden": 1, "label": "Stock Queue (FIFO)", "oldfieldname": "fcfs_stack", "oldfieldtype": "Text", "print_hide": 1, - "read_only": 1, - "report_hide": 1 + "read_only": 1 }, { "fieldname": "project", @@ -269,14 +275,48 @@ "hidden": 1, "label": "To Rename", "search_index": 1 + }, + { + "fieldname": "dependant_sle_voucher_detail_no", + "fieldtype": "Data", + "label": "Dependant SLE Voucher Detail No" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_21", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_26", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "recalculate_rate", + "fieldtype": "Check", + "label": "Recalculate Incoming/Outgoing Rate", + "no_copy": 1, + "read_only": 1 } ], "hide_toolbar": 1, "icon": "fa fa-list", "idx": 1, "in_create": 1, + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-04-23 05:57:03.985520", + "modified": "2020-09-07 11:10:35.318872", "modified_by": "Administrator", "module": "Stock", "name": "Stock Ledger Entry", 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 101c6e099ec..b0e7440e6cc 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -5,13 +5,15 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import flt, getdate, add_days, formatdate +from frappe.utils import flt, getdate, add_days, formatdate, get_datetime, date_diff from frappe.model.document import Document from datetime import date from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock from erpnext.accounts.utils import get_fiscal_year +from frappe.core.doctype.role.role import get_users class StockFreezeError(frappe.ValidationError): pass +class BackDatedStockTransaction(frappe.ValidationError): pass exclude_from_linked_with = True @@ -25,14 +27,17 @@ class StockLedgerEntry(Document): def validate(self): self.flags.ignore_submit_comment = True - from erpnext.stock.utils import validate_warehouse_company + from erpnext.stock.utils import validate_warehouse_company, validate_disabled_warehouse self.validate_mandatory() self.validate_item() self.validate_batch() + validate_disabled_warehouse(self.warehouse) validate_warehouse_company(self.warehouse, self.company) self.scrub_posting_time() self.validate_and_set_fiscal_year() self.block_transactions_against_group_warehouse() + self.validate_with_last_transaction_posting_time() + def on_submit(self): self.check_stock_frozen_date() @@ -46,7 +51,7 @@ class StockLedgerEntry(Document): def calculate_batch_qty(self): if self.batch_no: batch_qty = frappe.db.get_value("Stock Ledger Entry", - {"docstatus": 1, "batch_no": self.batch_no}, + {"docstatus": 1, "batch_no": self.batch_no, "is_cancelled": 0}, "sum(actual_qty)") or 0 frappe.db.set_value("Batch", self.batch_no, "batch_qty", batch_qty) @@ -86,14 +91,14 @@ class StockLedgerEntry(Document): # check if batch number is required if self.voucher_type != 'Stock Reconciliation': - if item_det.has_batch_no ==1: + if item_det.has_batch_no == 1: batch_item = self.item_code if self.item_code == item_det.item_name else self.item_code + ":" + item_det.item_name if not self.batch_no: frappe.throw(_("Batch number is mandatory for Item {0}").format(batch_item)) elif not frappe.db.get_value("Batch",{"item": self.item_code, "name": self.batch_no}): frappe.throw(_("{0} is not a valid Batch Number for Item {1}").format(self.batch_no, batch_item)) - elif item_det.has_batch_no ==0 and self.batch_no: + elif item_det.has_batch_no == 0 and self.batch_no and self.is_cancelled == 0: frappe.throw(_("The Item {0} cannot have Batch").format(self.item_code)) if item_det.has_variants: @@ -139,6 +144,30 @@ class StockLedgerEntry(Document): from erpnext.stock.utils import is_group_warehouse is_group_warehouse(self.warehouse) + def validate_with_last_transaction_posting_time(self): + authorized_role = frappe.db.get_single_value("Stock Settings", "role_allowed_to_create_edit_back_dated_transactions") + if authorized_role: + authorized_users = get_users(authorized_role) + if authorized_users and frappe.session.user not in authorized_users: + last_transaction_time = frappe.db.sql(""" + select MAX(timestamp(posting_date, posting_time)) as posting_time + from `tabStock Ledger Entry` + where docstatus = 1 and item_code = %s + and warehouse = %s""", (self.item_code, self.warehouse))[0][0] + + cur_doc_posting_datetime = "%s %s" % (self.posting_date, self.get("posting_time") or "00:00:00") + + if last_transaction_time and get_datetime(cur_doc_posting_datetime) < get_datetime(last_transaction_time): + msg = _("Last Stock Transaction for item {0} under warehouse {1} was on {2}.").format(frappe.bold(self.item_code), + frappe.bold(self.warehouse), frappe.bold(last_transaction_time)) + + msg += "

    " + _("You are not authorized to make/edit Stock Transactions for Item {0} under warehouse {1} before this time.").format( + frappe.bold(self.item_code), frappe.bold(self.warehouse)) + + msg += "

    " + _("Please contact any of the following users to {} this transaction.") + msg += "
    " + "
    ".join(authorized_users) + frappe.throw(msg, BackDatedStockTransaction, title=_("Backdated Stock Entry")) + def on_doctype_update(): if not frappe.db.has_index('tabStock Ledger Entry', 'posting_sort_index'): frappe.db.commit() diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 04dae83447b..7ebd4e6cb20 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -5,8 +5,399 @@ from __future__ import unicode_literals import frappe import unittest - -# test_records = frappe.get_test_records('Stock Ledger Entry') +from frappe.utils import today, add_days +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation \ + import create_stock_reconciliation +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.stock_ledger import get_previous_sle +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import create_landed_cost_voucher +from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note +from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import BackDatedStockTransaction class TestStockLedgerEntry(unittest.TestCase): - pass + def setUp(self): + items = create_items() + + # delete SLE and BINs for all items + frappe.db.sql("delete from `tabStock Ledger Entry` where item_code in (%s)" % (', '.join(['%s']*len(items))), items) + frappe.db.sql("delete from `tabBin` where item_code in (%s)" % (', '.join(['%s']*len(items))), items) + + def test_item_cost_reposting(self): + company = "_Test Company" + + # _Test Item for Reposting at Stores warehouse on 10-04-2020: Qty = 50, Rate = 100 + create_stock_reconciliation( + item_code="_Test Item for Reposting", + warehouse="Stores - _TC", + qty=50, + rate=100, + company=company, + expense_account = "Stock Adjustment - _TC", + posting_date='2020-04-10', + posting_time='14:00' + ) + + # _Test Item for Reposting at FG warehouse on 20-04-2020: Qty = 10, Rate = 200 + create_stock_reconciliation( + item_code="_Test Item for Reposting", + warehouse="Finished Goods - _TC", + qty=10, + rate=200, + company=company, + expense_account = "Stock Adjustment - _TC", + posting_date='2020-04-20', + posting_time='14:00' + ) + + # _Test Item for Reposting transferred from Stores to FG warehouse on 30-04-2020 + make_stock_entry( + item_code="_Test Item for Reposting", + source="Stores - _TC", + target="Finished Goods - _TC", + company=company, + qty=10, + expense_account="Stock Adjustment - _TC", + posting_date='2020-04-30', + posting_time='14:00' + ) + target_wh_sle = get_previous_sle({ + "item_code": "_Test Item for Reposting", + "warehouse": "Finished Goods - _TC", + "posting_date": '2020-04-30', + "posting_time": '14:00' + }) + + self.assertEqual(target_wh_sle.get("valuation_rate"), 150) + + # Repack entry on 5-5-2020 + repack = create_repack_entry(company=company, posting_date='2020-05-05', posting_time='14:00') + + finished_item_sle = get_previous_sle({ + "item_code": "_Test Finished Item for Reposting", + "warehouse": "Finished Goods - _TC", + "posting_date": '2020-05-05', + "posting_time": '14:00' + }) + self.assertEqual(finished_item_sle.get("incoming_rate"), 540) + self.assertEqual(finished_item_sle.get("valuation_rate"), 540) + + # Reconciliation for _Test Item for Reposting at Stores on 12-04-2020: Qty = 50, Rate = 150 + create_stock_reconciliation( + item_code="_Test Item for Reposting", + warehouse="Stores - _TC", + qty=50, + rate=150, + company=company, + expense_account = "Stock Adjustment - _TC", + posting_date='2020-04-12', + posting_time='14:00' + ) + + + # Check valuation rate of finished goods warehouse after back-dated entry at Stores + target_wh_sle = get_previous_sle({ + "item_code": "_Test Item for Reposting", + "warehouse": "Finished Goods - _TC", + "posting_date": '2020-04-30', + "posting_time": '14:00' + }) + self.assertEqual(target_wh_sle.get("incoming_rate"), 150) + self.assertEqual(target_wh_sle.get("valuation_rate"), 175) + + # Check valuation rate of repacked item after back-dated entry at Stores + finished_item_sle = get_previous_sle({ + "item_code": "_Test Finished Item for Reposting", + "warehouse": "Finished Goods - _TC", + "posting_date": '2020-05-05', + "posting_time": '14:00' + }) + self.assertEqual(finished_item_sle.get("incoming_rate"), 790) + self.assertEqual(finished_item_sle.get("valuation_rate"), 790) + + # Check updated rate in Repack entry + repack.reload() + self.assertEqual(repack.items[0].get("basic_rate"), 150) + self.assertEqual(repack.items[1].get("basic_rate"), 750) + + def test_purchase_return_valuation_reposting(self): + pr = make_purchase_receipt(company="_Test Company", posting_date='2020-04-10', + warehouse="Stores - _TC", item_code="_Test Item for Reposting", qty=5, rate=100) + + return_pr = make_purchase_receipt(company="_Test Company", posting_date='2020-04-15', + warehouse="Stores - _TC", item_code="_Test Item for Reposting", is_return=1, return_against=pr.name, qty=-2) + + # check sle + outgoing_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Purchase Receipt", + "voucher_no": return_pr.name}, ["outgoing_rate", "stock_value_difference"]) + + self.assertEqual(outgoing_rate, 100) + self.assertEqual(stock_value_difference, -200) + + create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company) + + outgoing_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Purchase Receipt", + "voucher_no": return_pr.name}, ["outgoing_rate", "stock_value_difference"]) + + self.assertEqual(outgoing_rate, 110) + self.assertEqual(stock_value_difference, -220) + + def test_sales_return_valuation_reposting(self): + company = "_Test Company" + item_code="_Test Item for Reposting" + + # Purchase Return: Qty = 5, Rate = 100 + pr = make_purchase_receipt(company=company, posting_date='2020-04-10', + warehouse="Stores - _TC", item_code=item_code, qty=5, rate=100) + + #Delivery Note: Qty = 5, Rate = 150 + dn = create_delivery_note(item_code=item_code, qty=5, rate=150, warehouse="Stores - _TC", + company=company, expense_account="Cost of Goods Sold - _TC", cost_center="Main - _TC") + + # check outgoing_rate for DN + outgoing_rate = abs(frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Delivery Note", + "voucher_no": dn.name}, "stock_value_difference") / 5) + + self.assertEqual(dn.items[0].incoming_rate, 100) + self.assertEqual(outgoing_rate, 100) + + # Return Entry: Qty = -2, Rate = 150 + return_dn = create_delivery_note(is_return=1, return_against=dn.name, item_code=item_code, qty=-2, rate=150, + company=company, warehouse="Stores - _TC", expense_account="Cost of Goods Sold - _TC", cost_center="Main - _TC") + + # check incoming rate for Return entry + incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry", + {"voucher_type": "Delivery Note", "voucher_no": return_dn.name}, + ["incoming_rate", "stock_value_difference"]) + + self.assertEqual(return_dn.items[0].incoming_rate, 100) + self.assertEqual(incoming_rate, 100) + self.assertEqual(stock_value_difference, 200) + + #------------------------------- + + # Landed Cost Voucher to update the rate of incoming Purchase Return: Additional cost = 50 + lcv = create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company) + + # check outgoing_rate for DN after reposting + outgoing_rate = abs(frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Delivery Note", + "voucher_no": dn.name}, "stock_value_difference") / 5) + self.assertEqual(outgoing_rate, 110) + + dn.reload() + self.assertEqual(dn.items[0].incoming_rate, 110) + + # check incoming rate for Return entry after reposting + incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry", + {"voucher_type": "Delivery Note", "voucher_no": return_dn.name}, + ["incoming_rate", "stock_value_difference"]) + + self.assertEqual(incoming_rate, 110) + self.assertEqual(stock_value_difference, 220) + + return_dn.reload() + self.assertEqual(return_dn.items[0].incoming_rate, 110) + + # Cleanup data + return_dn.cancel() + dn.cancel() + lcv.cancel() + pr.cancel() + + def test_reposting_of_sales_return_for_packed_item(self): + company = "_Test Company" + packed_item_code="_Test Item for Reposting" + bundled_item = "_Test Bundled Item for Reposting" + create_product_bundle_item(bundled_item, [[packed_item_code, 4]]) + + # Purchase Return: Qty = 50, Rate = 100 + pr = make_purchase_receipt(company=company, posting_date='2020-04-10', + warehouse="Stores - _TC", item_code=packed_item_code, qty=50, rate=100) + + #Delivery Note: Qty = 5, Rate = 150 + dn = create_delivery_note(item_code=bundled_item, qty=5, rate=150, warehouse="Stores - _TC", + company=company, expense_account="Cost of Goods Sold - _TC", cost_center="Main - _TC") + + # check outgoing_rate for DN + outgoing_rate = abs(frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Delivery Note", + "voucher_no": dn.name}, "stock_value_difference") / 20) + + self.assertEqual(dn.packed_items[0].incoming_rate, 100) + self.assertEqual(outgoing_rate, 100) + + # Return Entry: Qty = -2, Rate = 150 + return_dn = create_delivery_note(is_return=1, return_against=dn.name, item_code=bundled_item, qty=-2, rate=150, + company=company, warehouse="Stores - _TC", expense_account="Cost of Goods Sold - _TC", cost_center="Main - _TC") + + # check incoming rate for Return entry + incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry", + {"voucher_type": "Delivery Note", "voucher_no": return_dn.name}, + ["incoming_rate", "stock_value_difference"]) + + self.assertEqual(return_dn.packed_items[0].incoming_rate, 100) + self.assertEqual(incoming_rate, 100) + self.assertEqual(stock_value_difference, 800) + + #------------------------------- + + # Landed Cost Voucher to update the rate of incoming Purchase Return: Additional cost = 50 + lcv = create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company) + + # check outgoing_rate for DN after reposting + outgoing_rate = abs(frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Delivery Note", + "voucher_no": dn.name}, "stock_value_difference") / 20) + self.assertEqual(outgoing_rate, 101) + + dn.reload() + self.assertEqual(dn.packed_items[0].incoming_rate, 101) + + # check incoming rate for Return entry after reposting + incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry", + {"voucher_type": "Delivery Note", "voucher_no": return_dn.name}, + ["incoming_rate", "stock_value_difference"]) + + self.assertEqual(incoming_rate, 101) + self.assertEqual(stock_value_difference, 808) + + return_dn.reload() + self.assertEqual(return_dn.packed_items[0].incoming_rate, 101) + + # Cleanup data + return_dn.cancel() + dn.cancel() + lcv.cancel() + pr.cancel() + + def test_sub_contracted_item_costing(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + company = "_Test Company" + rm_item_code="_Test Item for Reposting" + subcontracted_item = "_Test Subcontracted Item for Reposting" + + frappe.db.set_value("Buying Settings", None, "backflush_raw_materials_of_subcontract_based_on", "BOM") + make_bom(item = subcontracted_item, raw_materials =[rm_item_code], currency="INR") + + # Purchase raw materials on supplier warehouse: Qty = 50, Rate = 100 + pr = make_purchase_receipt(company=company, posting_date='2020-04-10', + warehouse="Stores - _TC", item_code=rm_item_code, qty=10, rate=100) + + # Purchase Receipt for subcontracted item + pr1 = make_purchase_receipt(company=company, posting_date='2020-04-20', + warehouse="Finished Goods - _TC", supplier_warehouse="Stores - _TC", + item_code=subcontracted_item, qty=10, rate=20, is_subcontracted="Yes") + + self.assertEqual(pr1.items[0].valuation_rate, 120) + + # Update raw material's valuation via LCV, Additional cost = 50 + lcv = create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company) + + pr1.reload() + self.assertEqual(pr1.items[0].valuation_rate, 125) + + # check outgoing_rate for DN after reposting + incoming_rate = frappe.db.get_value("Stock Ledger Entry", {"voucher_type": "Purchase Receipt", + "voucher_no": pr1.name, "item_code": subcontracted_item}, "incoming_rate") + self.assertEqual(incoming_rate, 125) + + # cleanup data + pr1.cancel() + lcv.cancel() + pr.cancel() + + def test_back_dated_entry_not_allowed(self): + # Back dated stock transactions are only allowed to stock managers + frappe.db.set_value("Stock Settings", None, + "role_allowed_to_create_edit_back_dated_transactions", "Stock Manager") + + # Set User with Stock User role but not Stock Manager + try: + frappe.set_user("test@example.com") + user = frappe.get_doc("User", "test@example.com") + user.add_roles("Stock User") + user.remove_roles("Stock Manager") + + stock_entry_on_today = make_stock_entry(target="_Test Warehouse - _TC", qty=10, basic_rate=100) + back_dated_se_1 = make_stock_entry(target="_Test Warehouse - _TC", qty=10, basic_rate=100, + posting_date=add_days(today(), -1), do_not_submit=True) + + # Block back-dated entry + self.assertRaises(BackDatedStockTransaction, back_dated_se_1.submit) + + user.add_roles("Stock Manager") + + # Back dated entry allowed to Stock Manager + back_dated_se_2 = make_stock_entry(target="_Test Warehouse - _TC", qty=10, basic_rate=100, + posting_date=add_days(today(), -1)) + + back_dated_se_2.cancel() + stock_entry_on_today.cancel() + + finally: + frappe.db.set_value("Stock Settings", None, "role_allowed_to_create_edit_back_dated_transactions", None) + frappe.set_user("Administrator") + + +def create_repack_entry(**args): + args = frappe._dict(args) + repack = frappe.new_doc("Stock Entry") + repack.stock_entry_type = "Repack" + repack.company = args.company or "_Test Company" + repack.posting_date = args.posting_date + repack.set_posting_time = 1 + repack.append("items", { + "item_code": "_Test Item for Reposting", + "s_warehouse": "Stores - _TC", + "qty": 5, + "conversion_factor": 1, + "expense_account": "Stock Adjustment - _TC", + "cost_center": "Main - _TC" + }) + + repack.append("items", { + "item_code": "_Test Finished Item for Reposting", + "t_warehouse": "Finished Goods - _TC", + "qty": 1, + "conversion_factor": 1, + "expense_account": "Stock Adjustment - _TC", + "cost_center": "Main - _TC" + }) + + repack.append("additional_costs", { + "expense_account": "Freight and Forwarding Charges - _TC", + "description": "transport cost", + "amount": 40 + }) + + repack.save() + repack.submit() + + return repack + +def create_product_bundle_item(new_item_code, packed_items): + if not frappe.db.exists("Product Bundle", new_item_code): + item = frappe.new_doc("Product Bundle") + item.new_item_code = new_item_code + + for d in packed_items: + item.append("items", { + "item_code": d[0], + "qty": d[1] + }) + + item.save() + +def create_items(): + items = ["_Test Item for Reposting", "_Test Finished Item for Reposting", + "_Test Subcontracted Item for Reposting", "_Test Bundled Item for Reposting"] + for d in items: + properties = {"valuation_method": "FIFO"} + if d == "_Test Bundled Item for Reposting": + properties.update({"is_stock_item": 0}) + elif d == "_Test Subcontracted Item for Reposting": + properties.update({"is_sub_contracted_item": 1}) + + make_item(d, properties=properties) + + return items \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index ecee97ce86a..ac4ed5e75d9 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -2,6 +2,7 @@ // License: GNU General Public License v3. See license.txt frappe.provide("erpnext.stock"); +frappe.provide("erpnext.accounts.dimensions"); frappe.ui.form.on("Stock Reconciliation", { onload: function(frm) { @@ -26,6 +27,12 @@ frappe.ui.form.on("Stock Reconciliation", { if (!frm.doc.expense_account) { frm.trigger("set_expense_account"); } + + erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); + }, + + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); }, refresh: function(frm) { @@ -109,6 +116,10 @@ frappe.ui.form.on("Stock Reconciliation", { frappe.model.set_value(cdt, cdn, "current_amount", r.message.rate * r.message.qty); frappe.model.set_value(cdt, cdn, "amount", r.message.rate * r.message.qty); frappe.model.set_value(cdt, cdn, "current_serial_no", r.message.serial_nos); + + if (frm.doc.purpose == "Stock Reconciliation") { + frappe.model.set_value(cdt, cdn, "serial_no", r.message.serial_nos); + } } }); } diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index b81f8a086d5..b452e96c5e9 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -29,7 +29,10 @@ class StockReconciliation(StockController): self.remove_items_with_no_change() self.validate_data() self.validate_expense_account() + self.validate_customer_provided_item() + self.set_zero_value_for_customer_provided_items() self.set_total_qty_and_amount() + self.validate_putaway_capacity() if self._action=="submit": self.make_batches('warehouse') @@ -37,14 +40,16 @@ class StockReconciliation(StockController): def on_submit(self): self.update_stock_ledger() self.make_gl_entries() + self.repost_future_sle_and_gle() from erpnext.stock.doctype.serial_no.serial_no import update_serial_nos_after_submit update_serial_nos_after_submit(self, "items") def on_cancel(self): - self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry') + self.ignore_linked_doctypes = ('GL Entry', 'Stock Ledger Entry', 'Repost Item Valuation') self.make_sle_on_cancel() self.make_gl_entries_on_cancel() + self.repost_future_sle_and_gle() def remove_items_with_no_change(self): """Remove items if qty or rate is not changed""" @@ -67,6 +72,8 @@ class StockReconciliation(StockController): if item_dict.get("serial_nos"): item.current_serial_no = item_dict.get("serial_nos") + if self.purpose == "Stock Reconciliation": + item.serial_no = item.current_serial_no item.current_qty = item_dict.get("qty") item.current_valuation_rate = item_dict.get("rate") @@ -212,7 +219,7 @@ class StockReconciliation(StockController): if row.valuation_rate in ("", None): row.valuation_rate = previous_sle.get("valuation_rate", 0) - if row.qty and not row.valuation_rate: + if row.qty and not row.valuation_rate and not row.allow_zero_valuation_rate: frappe.throw(_("Valuation Rate required for Item {0} at row {1}").format(row.item_code, row.idx)) if ((previous_sle and row.qty == previous_sle.get("qty_after_transaction") @@ -431,6 +438,20 @@ class StockReconciliation(StockController): if frappe.db.get_value("Account", self.expense_account, "report_type") == "Profit and Loss": frappe.throw(_("Difference Account must be a Asset/Liability type account, since this Stock Reconciliation is an Opening Entry"), OpeningEntryAccountError) + def set_zero_value_for_customer_provided_items(self): + changed_any_values = False + + for d in self.get('items'): + is_customer_item = frappe.db.get_value('Item', d.item_code, 'is_customer_provided_item') + if is_customer_item and d.valuation_rate: + d.valuation_rate = 0.0 + changed_any_values = True + + if changed_any_values: + msgprint(_("Valuation rate for customer provided items has been set to zero."), + title=_("Note"), indicator="blue") + + def set_total_qty_and_amount(self): for d in self.get("items"): d.amount = flt(d.qty, d.precision("qty")) * flt(d.valuation_rate, d.precision("valuation_rate")) @@ -526,4 +547,4 @@ def get_difference_account(purpose, company): account = frappe.db.get_value('Account', {'is_group': 0, 'company': company, 'account_type': 'Temporary'}, 'name') - return account \ No newline at end of file + return account diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 15714161c66..6690c6a606c 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -8,12 +8,11 @@ from __future__ import unicode_literals import frappe, unittest from frappe.utils import flt, nowdate, nowtime from erpnext.accounts.utils import get_stock_and_account_balance -from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import set_perpetual_inventory from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import EmptyStockReconciliationItemsError, get_items from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.item.test_item import create_item -from erpnext.stock.utils import get_stock_balance, get_incoming_rate, get_available_serial_nos, get_stock_value_on +from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos class TestStockReconciliation(unittest.TestCase): @@ -29,16 +28,17 @@ class TestStockReconciliation(unittest.TestCase): self._test_reco_sle_gle("Moving Average") def _test_reco_sle_gle(self, valuation_method): - insert_existing_sle(warehouse='Stores - TCP1') + se1, se2, se3 = insert_existing_sle(warehouse='Stores - TCP1') company = frappe.db.get_value('Warehouse', 'Stores - TCP1', 'company') # [[qty, valuation_rate, posting_date, # posting_time, expected_stock_value, bin_qty, bin_valuation]] + input_data = [ - [50, 1000], - [25, 900], - ["", 1000], - [20, ""], - [0, ""] + [50, 1000, "2012-12-26", "12:00"], + [25, 900, "2012-12-26", "12:00"], + ["", 1000, "2012-12-20", "12:05"], + [20, "", "2012-12-26", "12:05"], + [0, "", "2012-12-31", "12:10"] ] for d in input_data: @@ -47,13 +47,13 @@ class TestStockReconciliation(unittest.TestCase): last_sle = get_previous_sle({ "item_code": "_Test Item", "warehouse": "Stores - TCP1", - "posting_date": nowdate(), - "posting_time": nowtime() + "posting_date": d[2], + "posting_time": d[3] }) # submit stock reconciliation stock_reco = create_stock_reconciliation(qty=d[0], rate=d[1], - posting_date=nowdate(), posting_time=nowtime(), warehouse="Stores - TCP1", + posting_date=d[2], posting_time=d[3], warehouse="Stores - TCP1", company=company, expense_account = "Stock Adjustment - TCP1") # check stock value @@ -81,10 +81,15 @@ class TestStockReconciliation(unittest.TestCase): stock_reco.cancel() + se3.cancel() + se2.cancel() + se1.cancel() + def test_get_items(self): - create_warehouse("_Test Warehouse Group 1", {"is_group": 1}) + create_warehouse("_Test Warehouse Group 1", + {"is_group": 1, "company": "_Test Company", "parent_warehouse": "All Warehouses - _TC"}) create_warehouse("_Test Warehouse Ledger 1", - {"is_group": 0, "parent_warehouse": "_Test Warehouse Group 1 - _TC"}) + {"is_group": 0, "parent_warehouse": "_Test Warehouse Group 1 - _TC", "company": "_Test Company"}) create_item("_Test Stock Reco Item", is_stock_item=1, valuation_rate=100, warehouse="_Test Warehouse Ledger 1 - _TC", opening_stock=100) @@ -95,8 +100,6 @@ class TestStockReconciliation(unittest.TestCase): [items[0]["item_code"], items[0]["warehouse"], items[0]["qty"]]) def test_stock_reco_for_serialized_item(self): - set_perpetual_inventory() - to_delete_records = [] to_delete_serial_nos = [] @@ -124,7 +127,7 @@ class TestStockReconciliation(unittest.TestCase): to_delete_records.append(sr.name) sr = create_stock_reconciliation(item_code=serial_item_code, - warehouse = serial_warehouse, qty=5, rate=300, serial_no = '\n'.join(serial_nos)) + warehouse = serial_warehouse, qty=5, rate=300) serial_nos1 = get_serial_nos(sr.items[0].serial_no) self.assertEqual(len(serial_nos1), 5) @@ -148,8 +151,6 @@ class TestStockReconciliation(unittest.TestCase): stock_doc.cancel() def test_stock_reco_for_batch_item(self): - set_perpetual_inventory() - to_delete_records = [] to_delete_serial_nos = [] @@ -192,19 +193,31 @@ class TestStockReconciliation(unittest.TestCase): stock_doc = frappe.get_doc("Stock Reconciliation", d) stock_doc.cancel() + def test_customer_provided_items(self): + item_code = 'Stock-Reco-customer-Item-100' + create_item(item_code, is_customer_provided_item = 1, + customer = '_Test Customer', is_purchase_item = 0) + + sr = create_stock_reconciliation(item_code = item_code, qty = 10, rate = 420) + + self.assertEqual(sr.get("items")[0].allow_zero_valuation_rate, 1) + self.assertEqual(sr.get("items")[0].valuation_rate, 0) + self.assertEqual(sr.get("items")[0].amount, 0) def insert_existing_sle(warehouse): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry - make_stock_entry(posting_date=nowdate(), posting_time=nowtime(), item_code="_Test Item", + se1 = make_stock_entry(posting_date="2012-12-15", posting_time="02:00", item_code="_Test Item", target=warehouse, qty=10, basic_rate=700) - make_stock_entry(posting_date=nowdate(), posting_time=nowtime(), item_code="_Test Item", + se2 = make_stock_entry(posting_date="2012-12-25", posting_time="03:00", item_code="_Test Item", source=warehouse, qty=15) - make_stock_entry(posting_date=nowdate(), posting_time=nowtime(), item_code="_Test Item", + se3 = make_stock_entry(posting_date="2013-01-05", posting_time="07:00", item_code="_Test Item", target=warehouse, qty=15, basic_rate=1200) + return se1, se2, se3 + def create_batch_or_serial_no_items(): create_warehouse("_Test Warehouse for Stock Reco1", {"is_group": 0, "parent_warehouse": "_Test Warehouse Group - _TC"}) @@ -256,6 +269,10 @@ def create_stock_reconciliation(**args): return sr def set_valuation_method(item_code, valuation_method): + existing_valuation_method = get_valuation_method(item_code) + if valuation_method == existing_valuation_method: + return + frappe.db.set_value("Item", item_code, "valuation_method", valuation_method) for warehouse in frappe.get_all("Warehouse", filters={"company": "_Test Company"}, fields=["name", "is_group"]): diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json index e53db0772b4..85c7ebe2634 100644 --- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json +++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json @@ -13,6 +13,7 @@ "qty", "valuation_rate", "amount", + "allow_zero_valuation_rate", "serial_no_and_batch_section", "serial_no", "column_break_11", @@ -166,10 +167,19 @@ "fieldtype": "Link", "label": "Batch No", "options": "Batch" + }, + { + "default": "0", + "fieldname": "allow_zero_valuation_rate", + "fieldtype": "Check", + "label": "Allow Zero Valuation Rate", + "print_hide": 1, + "read_only": 1 } ], "istable": 1, - "modified": "2019-06-14 17:10:53.188305", + "links": [], + "modified": "2021-03-23 11:09:44.407157", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reconciliation Item", @@ -179,4 +189,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 9c5d3d8340e..84af57b48dd 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -16,6 +16,7 @@ "action_if_quality_inspection_is_not_submitted", "show_barcode_field", "clean_description_html", + "disable_serial_no_and_batch_selector", "section_break_7", "auto_insert_price_list_rate_if_missing", "allow_negative_stock", @@ -28,7 +29,9 @@ "inter_warehouse_transfer_settings_section", "allow_from_dn", "allow_from_pr", - "freeze_stock_entries", + "control_historical_stock_transactions_section", + "role_allowed_to_create_edit_back_dated_transactions", + "column_break_26", "stock_frozen_upto", "stock_frozen_upto_days", "stock_auth_role", @@ -82,7 +85,7 @@ "options": "FIFO\nMoving Average" }, { - "description": "Percentage you are allowed to receive or deliver more against the quantity ordered. For example: If you have ordered 100 units. and your Allowance is 10% then you are allowed to receive 110 units.", + "description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.", "fieldname": "over_delivery_receipt_allowance", "fieldtype": "Float", "label": "Over Delivery/Receipt Allowance (%)" @@ -91,7 +94,7 @@ "default": "Stop", "fieldname": "action_if_quality_inspection_is_not_submitted", "fieldtype": "Select", - "label": "Action if Quality inspection is not submitted", + "label": "Action If Quality Inspection Is Not Submitted", "options": "Stop\nWarn" }, { @@ -114,7 +117,7 @@ "default": "0", "fieldname": "auto_insert_price_list_rate_if_missing", "fieldtype": "Check", - "label": "Auto insert Price List rate if missing" + "label": "Auto Insert Price List Rate If Missing" }, { "default": "0", @@ -130,13 +133,13 @@ "default": "1", "fieldname": "automatically_set_serial_nos_based_on_fifo", "fieldtype": "Check", - "label": "Automatically Set Serial Nos based on FIFO" + "label": "Automatically Set Serial Nos Based on FIFO" }, { "default": "1", "fieldname": "set_qty_in_transactions_based_on_serial_no_input", "fieldtype": "Check", - "label": "Set Qty in Transactions based on Serial No Input" + "label": "Set Qty in Transactions Based on Serial No Input" }, { "fieldname": "auto_material_request", @@ -147,33 +150,32 @@ "default": "0", "fieldname": "auto_indent", "fieldtype": "Check", - "label": "Raise Material Request when stock reaches re-order level" + "label": "Raise Material Request When Stock Reaches Re-order Level" }, { "default": "0", "fieldname": "reorder_email_notify", "fieldtype": "Check", - "label": "Notify by Email on creation of automatic Material Request" - }, - { - "fieldname": "freeze_stock_entries", - "fieldtype": "Section Break", - "label": "Freeze Stock Entries" + "label": "Notify by Email on Creation of Automatic Material Request" }, { + "description": "No stock transactions can be created or modified before this date.", "fieldname": "stock_frozen_upto", "fieldtype": "Date", "label": "Stock Frozen Upto" }, { + "description": "Stock transactions that are older than the mentioned days cannot be modified.", "fieldname": "stock_frozen_upto_days", "fieldtype": "Int", - "label": "Freeze Stocks Older Than [Days]" + "label": "Freeze Stocks Older Than (Days)" }, { + "depends_on": "eval:(doc.stock_frozen_upto || doc.stock_frozen_upto_days)", + "description": "The users with this Role are allowed to create/modify a stock transaction, even though the transaction is frozen.", "fieldname": "stock_auth_role", "fieldtype": "Link", - "label": "Role Allowed to edit frozen stock", + "label": "Role Allowed to Edit Frozen Stock", "options": "Role" }, { @@ -203,20 +205,43 @@ "default": "0", "fieldname": "allow_from_dn", "fieldtype": "Check", - "label": "Allow Material Transfer From Delivery Note and Sales Invoice" + "label": "Allow Material Transfer from Delivery Note to Sales Invoice" }, { "default": "0", "fieldname": "allow_from_pr", "fieldtype": "Check", - "label": "Allow Material Transfer From Purchase Receipt and Purchase Invoice" + "label": "Allow Material Transfer from Purchase Receipt to Purchase Invoice" + }, + { + "description": "If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.", + "fieldname": "role_allowed_to_create_edit_back_dated_transactions", + "fieldtype": "Link", + "label": "Role Allowed to Create/Edit Back-dated Transactions", + "options": "Role" + }, + { + "fieldname": "column_break_26", + "fieldtype": "Column Break" + }, + { + "fieldname": "control_historical_stock_transactions_section", + "fieldtype": "Section Break", + "label": "Control Historical Stock Transactions" + }, + { + "default": "0", + "fieldname": "disable_serial_no_and_batch_selector", + "fieldtype": "Check", + "label": "Disable Serial No And Batch Selector" } ], "icon": "icon-cog", "idx": 1, + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-06-20 11:39:15.344112", + "modified": "2021-01-18 13:15:38.352796", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", @@ -234,5 +259,6 @@ ], "quick_entry": 1, "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 4c7828b8737..3b9608b8056 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -55,7 +55,7 @@ class StockSettings(Document): """) if sle: - frappe.throw(_("Can't change valuation method, as there are transactions against some items which does not have it's own valuation method")) + frappe.throw(_("Can't change the valuation method, as there are transactions against some items which do not have its own valuation method")) def validate_clean_description_html(self): if int(self.clean_description_html or 0) \ diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 3101e8af4c7..95478f61f0a 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -10,13 +10,10 @@ from frappe.test_runner import make_test_records import erpnext from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext import set_perpetual_inventory from erpnext.accounts.doctype.account.test_account import get_inventory_account, create_account - test_records = frappe.get_test_records('Warehouse') - class TestWarehouse(unittest.TestCase): def setUp(self): if not frappe.get_value('Item', '_Test Item'): @@ -37,63 +34,63 @@ class TestWarehouse(unittest.TestCase): self.assertEqual(child_warehouse.is_group, 0) def test_warehouse_renaming(self): - set_perpetual_inventory(1) - create_warehouse("Test Warehouse for Renaming 1") - account = get_inventory_account("_Test Company", "Test Warehouse for Renaming 1 - _TC") + create_warehouse("Test Warehouse for Renaming 1", company="_Test Company with perpetual inventory") + account = get_inventory_account("_Test Company with perpetual inventory", "Test Warehouse for Renaming 1 - TCP1") self.assertTrue(frappe.db.get_value("Warehouse", filters={"account": account})) # Rename with abbr - if frappe.db.exists("Warehouse", "Test Warehouse for Renaming 2 - _TC"): - frappe.delete_doc("Warehouse", "Test Warehouse for Renaming 2 - _TC") - frappe.rename_doc("Warehouse", "Test Warehouse for Renaming 1 - _TC", "Test Warehouse for Renaming 2 - _TC") + if frappe.db.exists("Warehouse", "Test Warehouse for Renaming 2 - TCP1"): + frappe.delete_doc("Warehouse", "Test Warehouse for Renaming 2 - TCP1") + frappe.rename_doc("Warehouse", "Test Warehouse for Renaming 1 - TCP1", "Test Warehouse for Renaming 2 - TCP1") self.assertTrue(frappe.db.get_value("Warehouse", - filters={"account": "Test Warehouse for Renaming 1 - _TC"})) + filters={"account": "Test Warehouse for Renaming 1 - TCP1"})) # Rename without abbr - if frappe.db.exists("Warehouse", "Test Warehouse for Renaming 3 - _TC"): - frappe.delete_doc("Warehouse", "Test Warehouse for Renaming 3 - _TC") + if frappe.db.exists("Warehouse", "Test Warehouse for Renaming 3 - TCP1"): + frappe.delete_doc("Warehouse", "Test Warehouse for Renaming 3 - TCP1") - frappe.rename_doc("Warehouse", "Test Warehouse for Renaming 2 - _TC", "Test Warehouse for Renaming 3") + frappe.rename_doc("Warehouse", "Test Warehouse for Renaming 2 - TCP1", "Test Warehouse for Renaming 3") self.assertTrue(frappe.db.get_value("Warehouse", - filters={"account": "Test Warehouse for Renaming 1 - _TC"})) + filters={"account": "Test Warehouse for Renaming 1 - TCP1"})) # Another rename with multiple dashes - if frappe.db.exists("Warehouse", "Test - Warehouse - Company - _TC"): - frappe.delete_doc("Warehouse", "Test - Warehouse - Company - _TC") - frappe.rename_doc("Warehouse", "Test Warehouse for Renaming 3 - _TC", "Test - Warehouse - Company") + if frappe.db.exists("Warehouse", "Test - Warehouse - Company - TCP1"): + frappe.delete_doc("Warehouse", "Test - Warehouse - Company - TCP1") + frappe.rename_doc("Warehouse", "Test Warehouse for Renaming 3 - TCP1", "Test - Warehouse - Company") def test_warehouse_merging(self): - set_perpetual_inventory(1) + company = "_Test Company with perpetual inventory" + create_warehouse("Test Warehouse for Merging 1", company=company, + properties={"parent_warehouse": "All Warehouses - TCP1"}) + create_warehouse("Test Warehouse for Merging 2", company=company, + properties={"parent_warehouse": "All Warehouses - TCP1"}) - create_warehouse("Test Warehouse for Merging 1") - create_warehouse("Test Warehouse for Merging 2") - - make_stock_entry(item_code="_Test Item", target="Test Warehouse for Merging 1 - _TC", - qty=1, rate=100) - make_stock_entry(item_code="_Test Item", target="Test Warehouse for Merging 2 - _TC", - qty=1, rate=100) + make_stock_entry(item_code="_Test Item", target="Test Warehouse for Merging 1 - TCP1", + qty=1, rate=100, company=company) + make_stock_entry(item_code="_Test Item", target="Test Warehouse for Merging 2 - TCP1", + qty=1, rate=100, company=company) existing_bin_qty = ( cint(frappe.db.get_value("Bin", - {"item_code": "_Test Item", "warehouse": "Test Warehouse for Merging 1 - _TC"}, "actual_qty")) + {"item_code": "_Test Item", "warehouse": "Test Warehouse for Merging 1 - TCP1"}, "actual_qty")) + cint(frappe.db.get_value("Bin", - {"item_code": "_Test Item", "warehouse": "Test Warehouse for Merging 2 - _TC"}, "actual_qty")) + {"item_code": "_Test Item", "warehouse": "Test Warehouse for Merging 2 - TCP1"}, "actual_qty")) ) - frappe.rename_doc("Warehouse", "Test Warehouse for Merging 1 - _TC", - "Test Warehouse for Merging 2 - _TC", merge=True) + frappe.rename_doc("Warehouse", "Test Warehouse for Merging 1 - TCP1", + "Test Warehouse for Merging 2 - TCP1", merge=True) - self.assertFalse(frappe.db.exists("Warehouse", "Test Warehouse for Merging 1 - _TC")) + self.assertFalse(frappe.db.exists("Warehouse", "Test Warehouse for Merging 1 - TCP1")) bin_qty = frappe.db.get_value("Bin", - {"item_code": "_Test Item", "warehouse": "Test Warehouse for Merging 2 - _TC"}, "actual_qty") + {"item_code": "_Test Item", "warehouse": "Test Warehouse for Merging 2 - TCP1"}, "actual_qty") self.assertEqual(bin_qty, existing_bin_qty) self.assertTrue(frappe.db.get_value("Warehouse", - filters={"account": "Test Warehouse for Merging 2 - _TC"})) + filters={"account": "Test Warehouse for Merging 2 - TCP1"})) def create_warehouse(warehouse_name, properties=None, company=None): if not company: diff --git a/erpnext/stock/doctype/warehouse/warehouse.js b/erpnext/stock/doctype/warehouse/warehouse.js index 1bea00e2632..1f172504a7f 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.js +++ b/erpnext/stock/doctype/warehouse/warehouse.js @@ -3,6 +3,18 @@ frappe.ui.form.on("Warehouse", { + onload: function(frm) { + frm.set_query("default_in_transit_warehouse", function() { + return { + filters:{ + 'warehouse_type' : 'Transit', + 'is_group': 0, + 'company': frm.doc.company + } + }; + }); + }, + refresh: function(frm) { frm.toggle_display('warehouse_name', frm.doc.__islocal); frm.toggle_display(['address_html','contact_html'], !frm.doc.__islocal); diff --git a/erpnext/stock/doctype/warehouse/warehouse.json b/erpnext/stock/doctype/warehouse/warehouse.json index 1cc600b9ca7..bddb114c9de 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.json +++ b/erpnext/stock/doctype/warehouse/warehouse.json @@ -13,6 +13,7 @@ "column_break_3", "warehouse_type", "parent_warehouse", + "default_in_transit_warehouse", "is_group", "column_break_4", "account", @@ -230,13 +231,20 @@ { "fieldname": "column_break_3", "fieldtype": "Section Break" + }, + { + "depends_on": "eval: doc.warehouse_type !== 'Transit';", + "fieldname": "default_in_transit_warehouse", + "fieldtype": "Link", + "label": "Default In-Transit Warehouse", + "options": "Warehouse" } ], "icon": "fa fa-building", "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-08-03 18:41:52.442502", + "modified": "2021-02-16 17:21:52.380098", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse", diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index cd86be31150..6c84f168fd4 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -29,7 +29,6 @@ class Warehouse(NestedSet): self.set_onload('account', account) load_address_and_contact(self) - def on_update(self): self.update_nsm_model() diff --git a/erpnext/stock/doctype/warehouse/warehouse_tree.js b/erpnext/stock/doctype/warehouse/warehouse_tree.js index 918d2f15593..3665c0530f2 100644 --- a/erpnext/stock/doctype/warehouse/warehouse_tree.js +++ b/erpnext/stock/doctype/warehouse/warehouse_tree.js @@ -19,7 +19,7 @@ frappe.treeview_settings['Warehouse'] = { ignore_fields:["parent_warehouse"], onrender: function(node) { if (node.data && node.data.balance!==undefined) { - $('' + $('' + format_currency(Math.abs(node.data.balance), node.data.company_currency) + '').insertBefore(node.$ul); } diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 1a7c15ebca7..70e4c2c40e0 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -19,7 +19,7 @@ from erpnext.stock.doctype.item_manufacturer.item_manufacturer import get_item_m from six import string_types, iteritems -sales_doctypes = ['Quotation', 'Sales Order', 'Delivery Note', 'Sales Invoice'] +sales_doctypes = ['Quotation', 'Sales Order', 'Delivery Note', 'Sales Invoice', 'POS Invoice'] purchase_doctypes = ['Material Request', 'Supplier Quotation', 'Purchase Order', 'Purchase Receipt', 'Purchase Invoice'] @frappe.whitelist() @@ -74,7 +74,9 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru update_party_blanket_order(args, out) - get_price_list_rate(args, item, out) + if not doc or cint(doc.get('is_return')) == 0: + # get price list rate only if the invoice is not a credit or debit note + get_price_list_rate(args, item, out) if args.customer and cint(args.is_pos): out.update(get_pos_profile_item_details(args.company, args)) @@ -312,7 +314,9 @@ def get_basic_details(args, item, overwrite_warehouse=True): "last_purchase_rate": item.last_purchase_rate if args.get("doctype") in ["Purchase Order"] else 0, "transaction_date": args.get("transaction_date"), "against_blanket_order": args.get("against_blanket_order"), - "bom_no": item.get("default_bom") + "bom_no": item.get("default_bom"), + "weight_per_unit": args.get("weight_per_unit") or item.get("weight_per_unit"), + "weight_uom": args.get("weight_uom") or item.get("weight_uom") }) if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"): @@ -367,6 +371,9 @@ def get_basic_details(args, item, overwrite_warehouse=True): if meta.get_field("barcode"): update_barcode_value(out) + if out.get("weight_per_unit"): + out['total_weight'] = out.weight_per_unit * out.stock_qty + return out def get_item_warehouse(item, args, overwrite_warehouse, defaults={}): @@ -398,6 +405,11 @@ def get_item_warehouse(item, args, overwrite_warehouse, defaults={}): else: warehouse = args.get('warehouse') + if not warehouse: + default_warehouse = frappe.db.get_single_value("Stock Settings", "default_warehouse") + if frappe.db.get_value("Warehouse", default_warehouse, "company") == args.company: + return default_warehouse + return warehouse def update_barcode_value(out): @@ -554,23 +566,40 @@ def get_default_deferred_account(args, item, fieldname=None): else: return None -def get_default_cost_center(args, item, item_group, brand, company=None): +def get_default_cost_center(args, item=None, item_group=None, brand=None, company=None): cost_center = None + + if not company and args.get("company"): + company = args.get("company") + if args.get('project'): cost_center = frappe.db.get_value("Project", args.get("project"), "cost_center", cache=True) - if not cost_center: + if not cost_center and (item and item_group and brand): if args.get('customer'): cost_center = item.get('selling_cost_center') or item_group.get('selling_cost_center') or brand.get('selling_cost_center') else: cost_center = item.get('buying_cost_center') or item_group.get('buying_cost_center') or brand.get('buying_cost_center') - cost_center = cost_center or args.get("cost_center") + elif not cost_center and args.get("item_code") and company: + for method in ["get_item_defaults", "get_item_group_defaults", "get_brand_defaults"]: + path = "erpnext.stock.get_item_details.{0}".format(method) + data = frappe.get_attr(path)(args.get("item_code"), company) + + if data and (data.selling_cost_center or data.buying_cost_center): + return data.selling_cost_center or data.buying_cost_center + + if not cost_center and args.get("cost_center"): + cost_center = args.get("cost_center") if (company and cost_center and frappe.get_cached_value("Cost Center", cost_center, "company") != company): return None + if not cost_center and company: + cost_center = frappe.get_cached_value("Company", + company, "cost_center") + return cost_center def get_default_supplier(args, item, item_group, brand): @@ -650,6 +679,8 @@ def get_item_price(args, item_code, ignore_party=False): and price_list=%(price_list)s and ifnull(uom, '') in ('', %(uom)s)""" + conditions += "and ifnull(batch_no, '') in ('', %(batch_no)s)" + if not ignore_party: if args.get("customer"): conditions += " and customer=%(customer)s" @@ -668,7 +699,7 @@ def get_item_price(args, item_code, ignore_party=False): return frappe.db.sql(""" select name, price_list_rate, uom from `tabItem Price` {conditions} - order by valid_from desc, uom desc """.format(conditions=conditions), args) + order by valid_from desc, batch_no desc, uom desc """.format(conditions=conditions), args) def get_price_list_rate_for(args, item_code): """ @@ -687,6 +718,7 @@ def get_price_list_rate_for(args, item_code): "uom": args.get('uom'), "transaction_date": args.get('transaction_date'), "posting_date": args.get('posting_date'), + "batch_no": args.get('batch_no') } item_price_data = 0 diff --git a/erpnext/stock/landed_taxes_and_charges_common.js b/erpnext/stock/landed_taxes_and_charges_common.js new file mode 100644 index 00000000000..f3f61963a88 --- /dev/null +++ b/erpnext/stock/landed_taxes_and_charges_common.js @@ -0,0 +1,62 @@ +let document_list = ['Landed Cost Voucher', 'Stock Entry']; + +document_list.forEach((doctype) => { + frappe.ui.form.on(doctype, { + refresh: function(frm) { + let tax_field = frm.doc.doctype == 'Landed Cost Voucher' ? 'taxes' : 'additional_costs'; + frm.set_query("expense_account", tax_field, function() { + return { + filters: { + "account_type": ['in', ["Tax", "Chargeable", "Income Account", "Expenses Included In Valuation", "Expenses Included In Asset Valuation"]], + "company": frm.doc.company + } + }; + }); + }, + + set_account_currency: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.expense_account) { + frappe.db.get_value('Account', row.expense_account, 'account_currency', function(value) { + frappe.model.set_value(cdt, cdn, "account_currency", value.account_currency); + frm.events.set_exchange_rate(frm, cdt, cdn); + }); + } + }, + + set_exchange_rate: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; + + if (row.account_currency == company_currency) { + row.exchange_rate = 1; + frm.set_df_property('taxes', 'hidden', 1, row.name, 'exchange_rate'); + } else if (!row.exchange_rate || row.exchange_rate == 1) { + frm.set_df_property('taxes', 'hidden', 0, row.name, 'exchange_rate'); + frappe.call({ + method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_exchange_rate", + args: { + posting_date: frm.doc.posting_date, + account: row.expense_account, + account_currency: row.account_currency, + company: frm.doc.company + }, + callback: function(r) { + if (r.message) { + frappe.model.set_value(cdt, cdn, "exchange_rate", r.message); + } + } + }); + } + + frm.refresh_field('taxes'); + }, + + set_base_amount: function(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + frappe.model.set_value(cdt, cdn, "base_amount", + flt(flt(row.amount)*row.exchange_rate, precision("base_amount", row))); + } + }); +}); + diff --git a/erpnext/stock/module_onboarding/stock/stock.json b/erpnext/stock/module_onboarding/stock/stock.json index 1d5bf8c97ca..847464822b4 100644 --- a/erpnext/stock/module_onboarding/stock/stock.json +++ b/erpnext/stock/module_onboarding/stock/stock.json @@ -19,7 +19,7 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/stock", "idx": 0, "is_complete": 0, - "modified": "2020-07-08 14:22:07.951891", + "modified": "2020-10-14 14:54:42.741971", "modified_by": "Administrator", "module": "Stock", "name": "Stock", diff --git a/erpnext/stock/onboarding_step/create_a_product/create_a_product.json b/erpnext/stock/onboarding_step/create_a_product/create_a_product.json index d2068e167b7..335137d8528 100644 --- a/erpnext/stock/onboarding_step/create_a_product/create_a_product.json +++ b/erpnext/stock/onboarding_step/create_a_product/create_a_product.json @@ -8,7 +8,7 @@ "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-12 18:30:02.489949", + "modified": "2020-10-14 14:53:00.133574", "modified_by": "Administrator", "name": "Create a Product", "owner": "Administrator", diff --git a/erpnext/stock/onboarding_step/create_a_purchase_receipt/create_a_purchase_receipt.json b/erpnext/stock/onboarding_step/create_a_purchase_receipt/create_a_purchase_receipt.json index b7811a46df4..9012493f57e 100644 --- a/erpnext/stock/onboarding_step/create_a_purchase_receipt/create_a_purchase_receipt.json +++ b/erpnext/stock/onboarding_step/create_a_purchase_receipt/create_a_purchase_receipt.json @@ -8,7 +8,7 @@ "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-19 18:59:13.266713", + "modified": "2020-10-14 14:53:25.618434", "modified_by": "Administrator", "name": "Create a Purchase Receipt", "owner": "Administrator", diff --git a/erpnext/stock/onboarding_step/create_a_stock_entry/create_a_stock_entry.json b/erpnext/stock/onboarding_step/create_a_stock_entry/create_a_stock_entry.json index 2b83f657d6e..09902b8844e 100644 --- a/erpnext/stock/onboarding_step/create_a_stock_entry/create_a_stock_entry.json +++ b/erpnext/stock/onboarding_step/create_a_stock_entry/create_a_stock_entry.json @@ -8,7 +8,7 @@ "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-15 03:30:58.047696", + "modified": "2020-10-14 14:53:00.105905", "modified_by": "Administrator", "name": "Create a Stock Entry", "owner": "Administrator", diff --git a/erpnext/stock/onboarding_step/create_a_supplier/create_a_supplier.json b/erpnext/stock/onboarding_step/create_a_supplier/create_a_supplier.json index 7a64224bd43..ef61fa3b2e2 100644 --- a/erpnext/stock/onboarding_step/create_a_supplier/create_a_supplier.json +++ b/erpnext/stock/onboarding_step/create_a_supplier/create_a_supplier.json @@ -8,7 +8,7 @@ "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 22:09:10.043554", + "modified": "2020-10-14 14:53:00.120455", "modified_by": "Administrator", "name": "Create a Supplier", "owner": "Administrator", diff --git a/erpnext/stock/onboarding_step/introduction_to_stock_entry/introduction_to_stock_entry.json b/erpnext/stock/onboarding_step/introduction_to_stock_entry/introduction_to_stock_entry.json index 009a44f6e4d..212e5055eda 100644 --- a/erpnext/stock/onboarding_step/introduction_to_stock_entry/introduction_to_stock_entry.json +++ b/erpnext/stock/onboarding_step/introduction_to_stock_entry/introduction_to_stock_entry.json @@ -8,7 +8,7 @@ "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-26 15:55:41.457289", + "modified": "2020-10-14 14:53:00.075177", "modified_by": "Administrator", "name": "Introduction to Stock Entry", "owner": "Administrator", diff --git a/erpnext/stock/onboarding_step/setup_your_warehouse/setup_your_warehouse.json b/erpnext/stock/onboarding_step/setup_your_warehouse/setup_your_warehouse.json index 9457deee262..75940ed2a6c 100644 --- a/erpnext/stock/onboarding_step/setup_your_warehouse/setup_your_warehouse.json +++ b/erpnext/stock/onboarding_step/setup_your_warehouse/setup_your_warehouse.json @@ -8,7 +8,7 @@ "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-07-04 12:33:16.970031", + "modified": "2020-10-14 14:53:25.538900", "modified_by": "Administrator", "name": "Setup your Warehouse", "owner": "Administrator", diff --git a/erpnext/stock/onboarding_step/stock_settings/stock_settings.json b/erpnext/stock/onboarding_step/stock_settings/stock_settings.json index 7591bff5386..ae34afa695f 100644 --- a/erpnext/stock/onboarding_step/stock_settings/stock_settings.json +++ b/erpnext/stock/onboarding_step/stock_settings/stock_settings.json @@ -8,7 +8,7 @@ "is_mandatory": 0, "is_single": 1, "is_skipped": 0, - "modified": "2020-05-15 03:55:15.444151", + "modified": "2020-10-14 14:53:00.092504", "modified_by": "Administrator", "name": "Stock Settings", "owner": "Administrator", diff --git a/erpnext/stock/page/stock_balance/stock_balance.js b/erpnext/stock/page/stock_balance/stock_balance.js index da21c6bc64f..bddffd465e6 100644 --- a/erpnext/stock/page/stock_balance/stock_balance.js +++ b/erpnext/stock/page/stock_balance/stock_balance.js @@ -65,6 +65,9 @@ frappe.pages['stock-balance'].on_page_load = function(wrapper) { frappe.require('assets/js/item-dashboard.min.js', function() { page.item_dashboard = new erpnext.stock.ItemDashboard({ parent: page.main, + page_length: 20, + method: 'erpnext.stock.dashboard.item_dashboard.get_data', + template: 'item_dashboard_list' }) page.item_dashboard.before_refresh = function() { diff --git a/erpnext/stock/page/warehouse_capacity_summary/__init__.py b/erpnext/stock/page/warehouse_capacity_summary/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html new file mode 100644 index 00000000000..90112c78a83 --- /dev/null +++ b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html @@ -0,0 +1,40 @@ +{% for d in data %} +
    +
    + + +
    + {{ d.stock_capacity }} +
    +
    + {{ d.actual_qty }} +
    +
    +
    +
    +
    +
    +
    +
    + {{ d.percent_occupied }}% +
    + {% if can_write %} +
    +
    + {% endif %} +
    +
    +{% endfor %} \ No newline at end of file diff --git a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.js b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.js new file mode 100644 index 00000000000..b610e7dd587 --- /dev/null +++ b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.js @@ -0,0 +1,120 @@ +frappe.pages['warehouse-capacity-summary'].on_page_load = function(wrapper) { + var page = frappe.ui.make_app_page({ + parent: wrapper, + title: 'Warehouse Capacity Summary', + single_column: true + }); + page.set_secondary_action('Refresh', () => page.capacity_dashboard.refresh(), 'octicon octicon-sync'); + page.start = 0; + + page.company_field = page.add_field({ + fieldname: 'company', + label: __('Company'), + fieldtype: 'Link', + options: 'Company', + reqd: 1, + default: frappe.defaults.get_default("company"), + change: function() { + page.capacity_dashboard.start = 0; + page.capacity_dashboard.refresh(); + } + }); + + page.warehouse_field = page.add_field({ + fieldname: 'warehouse', + label: __('Warehouse'), + fieldtype: 'Link', + options: 'Warehouse', + change: function() { + page.capacity_dashboard.start = 0; + page.capacity_dashboard.refresh(); + } + }); + + page.item_field = page.add_field({ + fieldname: 'item_code', + label: __('Item'), + fieldtype: 'Link', + options: 'Item', + change: function() { + page.capacity_dashboard.start = 0; + page.capacity_dashboard.refresh(); + } + }); + + page.parent_warehouse_field = page.add_field({ + fieldname: 'parent_warehouse', + label: __('Parent Warehouse'), + fieldtype: 'Link', + options: 'Warehouse', + get_query: function() { + return { + filters: { + "is_group": 1 + } + }; + }, + change: function() { + page.capacity_dashboard.start = 0; + page.capacity_dashboard.refresh(); + } + }); + + page.sort_selector = new frappe.ui.SortSelector({ + parent: page.wrapper.find('.page-form'), + args: { + sort_by: 'stock_capacity', + sort_order: 'desc', + options: [ + {fieldname: 'stock_capacity', label: __('Capacity (Stock UOM)')}, + {fieldname: 'percent_occupied', label: __('% Occupied')}, + {fieldname: 'actual_qty', label: __('Balance Qty (Stock ')} + ] + }, + change: function(sort_by, sort_order) { + page.capacity_dashboard.sort_by = sort_by; + page.capacity_dashboard.sort_order = sort_order; + page.capacity_dashboard.start = 0; + page.capacity_dashboard.refresh(); + } + }); + + frappe.require('assets/js/item-dashboard.min.js', function() { + $(frappe.render_template('warehouse_capacity_summary_header')).appendTo(page.main); + + page.capacity_dashboard = new erpnext.stock.ItemDashboard({ + page_name: "warehouse-capacity-summary", + page_length: 10, + parent: page.main, + sort_by: 'stock_capacity', + sort_order: 'desc', + method: 'erpnext.stock.dashboard.warehouse_capacity_dashboard.get_data', + template: 'warehouse_capacity_summary' + }); + + page.capacity_dashboard.before_refresh = function() { + this.item_code = page.item_field.get_value(); + this.warehouse = page.warehouse_field.get_value(); + this.parent_warehouse = page.parent_warehouse_field.get_value(); + this.company = page.company_field.get_value(); + }; + + page.capacity_dashboard.refresh(); + + let setup_click = function(doctype) { + page.main.on('click', 'a[data-type="'+ doctype.toLowerCase() +'"]', function() { + var name = $(this).attr('data-name'); + var field = page[doctype.toLowerCase() + '_field']; + if (field.get_value()===name) { + frappe.set_route('Form', doctype, name); + } else { + field.set_input(name); + page.capacity_dashboard.refresh(); + } + }); + }; + + setup_click('Item'); + setup_click('Warehouse'); + }); +}; \ No newline at end of file diff --git a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.json b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.json new file mode 100644 index 00000000000..a6e5b45332b --- /dev/null +++ b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.json @@ -0,0 +1,26 @@ +{ + "content": null, + "creation": "2020-11-25 12:07:54.056208", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2020-11-25 11:07:54.056208", + "modified_by": "Administrator", + "module": "Stock", + "name": "warehouse-capacity-summary", + "owner": "Administrator", + "page_name": "Warehouse Capacity Summary", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Stock Manager" + } + ], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0, + "title": "Warehouse Capacity Summary" +} \ No newline at end of file diff --git a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary_header.html b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary_header.html new file mode 100644 index 00000000000..acaf180a903 --- /dev/null +++ b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary_header.html @@ -0,0 +1,19 @@ +
    +
    +
    + Warehouse +
    +
    + Item +
    +
    + Stock Capacity +
    +
    + Balance Stock Qty +
    +
    + % Occupied +
    +
    +
    \ No newline at end of file diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.js b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.js index 2499c801d2d..74b5a5ae36e 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.js +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.js @@ -3,6 +3,14 @@ frappe.query_reports["Batch-Wise Balance History"] = { "filters": [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + }, { "fieldname":"from_date", "label": __("From Date"), @@ -20,12 +28,46 @@ frappe.query_reports["Batch-Wise Balance History"] = { "reqd": 1 }, { - "fieldname": "item", - "label": __("Item"), + "fieldname":"item_code", + "label": __("Item Code"), "fieldtype": "Link", "options": "Item", - "width": "80" - } + "get_query": function() { + return { + filters: { + "has_batch_no": 1 + } + }; + } + }, + { + "fieldname":"warehouse", + "label": __("Warehouse"), + "fieldtype": "Link", + "options": "Warehouse", + "get_query": function() { + let company = frappe.query_report.get_filter_value('company'); + return { + filters: { + "company": company + } + }; + } + }, + { + "fieldname":"batch_no", + "label": __("Batch No"), + "fieldtype": "Link", + "options": "Batch", + "get_query": function() { + let item_code = frappe.query_report.get_filter_value('item_code'); + return { + filters: { + "item": item_code + } + }; + } + }, ], "formatter": function (value, row, column, data, default_formatter) { if (column.fieldname == "Batch" && data && !!data["Batch"]) { @@ -43,4 +85,4 @@ frappe.query_reports["Batch-Wise Balance History"] = { frappe.set_route("query-report", "Stock Ledger"); } -} \ No newline at end of file +} diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index 8f3e246e7f3..087c12ed2df 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -57,6 +57,10 @@ def get_conditions(filters): else: frappe.throw(_("'To Date' is required")) + for field in ["item_code", "warehouse", "batch_no", "company"]: + if filters.get(field): + conditions += " and {0} = {1}".format(field, frappe.db.escape(filters.get(field))) + return conditions diff --git a/erpnext/stock/report/item_prices/item_prices.py b/erpnext/stock/report/item_prices/item_prices.py index aa3ed92079c..12f32972039 100644 --- a/erpnext/stock/report/item_prices/item_prices.py +++ b/erpnext/stock/report/item_prices/item_prices.py @@ -77,38 +77,33 @@ def get_price_list(): return item_rate_map def get_last_purchase_rate(): - item_last_purchase_rate_map = {} - query = """select * from (select - result.item_code, - result.base_rate - from ( - (select - po_item.item_code, - po_item.item_name, - po.transaction_date as posting_date, - po_item.base_price_list_rate, - po_item.discount_percentage, - po_item.base_rate - from `tabPurchase Order` po, `tabPurchase Order Item` po_item - where po.name = po_item.parent and po.docstatus = 1) - union - (select - pr_item.item_code, - pr_item.item_name, - pr.posting_date, - pr_item.base_price_list_rate, - pr_item.discount_percentage, - pr_item.base_rate - from `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pr_item - where pr.name = pr_item.parent and pr.docstatus = 1) - ) result - order by result.item_code asc, result.posting_date desc) result_wrapper - group by item_code""" + query = """select * from ( + (select + po_item.item_code, + po.transaction_date as posting_date, + po_item.base_rate + from `tabPurchase Order` po, `tabPurchase Order Item` po_item + where po.name = po_item.parent and po.docstatus = 1) + union + (select + pr_item.item_code, + pr.posting_date, + pr_item.base_rate + from `tabPurchase Receipt` pr, `tabPurchase Receipt Item` pr_item + where pr.name = pr_item.parent and pr.docstatus = 1) + union + (select + pi_item.item_code, + pi.posting_date, + pi_item.base_rate + from `tabPurchase Invoice` pi, `tabPurchase Invoice Item` pi_item + where pi.name = pi_item.parent and pi.docstatus = 1 and pi.update_stock = 1) + ) result order by result.item_code asc, result.posting_date asc""" for d in frappe.db.sql(query, as_dict=1): - item_last_purchase_rate_map.setdefault(d.item_code, d.base_rate) + item_last_purchase_rate_map[d.item_code] = d.base_rate return item_last_purchase_rate_map diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 4af3c541a69..ff603fcfb3a 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -16,10 +16,12 @@ def execute(filters=None): data = [] for item, item_dict in iteritems(item_details): + earliest_age, latest_age = 0, 0 fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func) details = item_dict["details"] - if not fifo_queue or (not item_dict.get("total_qty")): continue + + if not fifo_queue: continue average_age = get_average_age(fifo_queue, to_date) earliest_age = date_diff(to_date, fifo_queue[0][1]) @@ -60,7 +62,7 @@ def get_range_age(filters, fifo_queue, to_date): range1 = range2 = range3 = above_range3 = 0.0 for item in fifo_queue: age = date_diff(to_date, item[1]) - + if age <= filters.range1: range1 += flt(item[0]) elif age <= filters.range2: @@ -69,7 +71,7 @@ def get_range_age(filters, fifo_queue, to_date): range3 += flt(item[0]) else: above_range3 += flt(item[0]) - + return range1, range2, range3, above_range3 def get_columns(filters): @@ -170,7 +172,8 @@ def get_fifo_queue(filters, sle=None): item_details.setdefault(key, {"details": d, "fifo_queue": []}) fifo_queue = item_details[key]["fifo_queue"] - transferred_item_details.setdefault((d.voucher_no, d.name), []) + transferred_item_key = (d.voucher_no, d.name, d.warehouse) + transferred_item_details.setdefault(transferred_item_key, []) if d.voucher_type == "Stock Reconciliation": d.actual_qty = flt(d.qty_after_transaction) - flt(item_details[key].get("qty_after_transaction", 0)) @@ -178,10 +181,10 @@ def get_fifo_queue(filters, sle=None): serial_no_list = get_serial_nos(d.serial_no) if d.serial_no else [] if d.actual_qty > 0: - if transferred_item_details.get((d.voucher_no, d.name)): - batch = transferred_item_details[(d.voucher_no, d.name)][0] + if transferred_item_details.get(transferred_item_key): + batch = transferred_item_details[transferred_item_key][0] fifo_queue.append(batch) - transferred_item_details[((d.voucher_no, d.name))].pop(0) + transferred_item_details[transferred_item_key].pop(0) else: if serial_no_list: for serial_no in serial_no_list: @@ -205,11 +208,11 @@ def get_fifo_queue(filters, sle=None): # if batch qty > 0 # not enough or exactly same qty in current batch, clear batch qty_to_pop -= flt(batch[0]) - transferred_item_details[(d.voucher_no, d.name)].append(fifo_queue.pop(0)) + transferred_item_details[transferred_item_key].append(fifo_queue.pop(0)) else: # all from current batch batch[0] = flt(batch[0]) - qty_to_pop - transferred_item_details[(d.voucher_no, d.name)].append([qty_to_pop, batch[1]]) + transferred_item_details[transferred_item_key].append([qty_to_pop, batch[1]]) qty_to_pop = 0 item_details[key]["qty_after_transaction"] = d.qty_after_transaction @@ -230,7 +233,8 @@ def get_stock_ledger_entries(filters): from `tabItem` {item_conditions}) item where item_code = item.name and company = %(company)s and - posting_date <= %(to_date)s + posting_date <= %(to_date)s and + is_cancelled != 1 {sle_conditions} order by posting_date, posting_time, sle.creation, actual_qty""" #nosec .format(item_conditions=get_item_conditions(filters), diff --git a/erpnext/stock/report/stock_analytics/stock_analytics.py b/erpnext/stock/report/stock_analytics/stock_analytics.py index 54eefdfaaa4..0cc8ca48aac 100644 --- a/erpnext/stock/report/stock_analytics/stock_analytics.py +++ b/erpnext/stock/report/stock_analytics/stock_analytics.py @@ -7,9 +7,11 @@ from frappe import _, scrub from frappe.utils import getdate, flt from erpnext.stock.report.stock_balance.stock_balance import (get_items, get_stock_ledger_entries, get_item_details) from erpnext.accounts.utils import get_fiscal_year +from erpnext.stock.utils import is_reposting_item_valuation_in_progress from six import iteritems def execute(filters=None): + is_reposting_item_valuation_in_progress() filters = frappe._dict(filters or {}) columns = get_columns(filters) data = get_data(filters) 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 1af68dd7f22..14d543b1740 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 @@ -57,8 +57,7 @@ def get_gl_data(report_filters, filters): if report_filters.account: stock_accounts = [report_filters.account] else: - stock_accounts = [k.name - for k in get_stock_accounts(report_filters.company)] + stock_accounts = get_stock_accounts(report_filters.company) filters.update({ "account": ("in", stock_accounts) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 042087a4a77..6dfede45906 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -7,12 +7,13 @@ from frappe import _ from frappe.utils import flt, cint, getdate, now, date_diff from erpnext.stock.utils import add_additional_uom_columns from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition - +from erpnext.stock.utils import is_reposting_item_valuation_in_progress from erpnext.stock.report.stock_ageing.stock_ageing import get_fifo_queue, get_average_age from six import iteritems def execute(filters=None): + is_reposting_item_valuation_in_progress() if not filters: filters = {} validate_filters(filters) @@ -164,10 +165,11 @@ def get_stock_ledger_entries(filters, items): select sle.item_code, warehouse, sle.posting_date, sle.actual_qty, sle.valuation_rate, sle.company, sle.voucher_type, sle.qty_after_transaction, sle.stock_value_difference, - sle.item_code as name, sle.voucher_no + sle.item_code as name, sle.voucher_no, sle.stock_value from `tabStock Ledger Entry` sle force index (posting_sort_index) where sle.docstatus < 2 %s %s + and is_cancelled = 0 order by sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty""" % #nosec (item_conditions_sql, conditions), as_dict=1) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index 6f12c2731bb..fe2417bba7e 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -82,11 +82,6 @@ frappe.query_reports["Stock Ledger"] = { "label": __("Include UOM"), "fieldtype": "Link", "options": "UOM" - }, - { - "fieldname": "show_cancelled_entries", - "label": __("Show Cancelled Entries"), - "fieldtype": "Check" } ], "formatter": function (value, row, column, data, default_formatter) { diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index fe8ad71b731..36996e96745 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -5,10 +5,12 @@ from __future__ import unicode_literals import frappe from frappe.utils import cint, flt -from erpnext.stock.utils import update_included_uom_in_report +from erpnext.stock.utils import update_included_uom_in_report, is_reposting_item_valuation_in_progress from frappe import _ +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos def execute(filters=None): + is_reposting_item_valuation_in_progress() include_uom = filters.get("include_uom") columns = get_columns() items = get_items(filters) @@ -24,6 +26,7 @@ def execute(filters=None): actual_qty = stock_value = 0 + available_serial_nos = {} for sle in sl_entries: item_detail = item_details[sle.item_code] @@ -47,6 +50,9 @@ def execute(filters=None): "out_qty": min(sle.actual_qty, 0) }) + if sle.serial_no: + update_available_serial_nos(available_serial_nos, sle) + data.append(sle) if include_uom: @@ -55,6 +61,26 @@ def execute(filters=None): update_included_uom_in_report(columns, data, include_uom, conversion_factors) return columns, data +def update_available_serial_nos(available_serial_nos, sle): + serial_nos = get_serial_nos(sle.serial_no) + key = (sle.item_code, sle.warehouse) + if key not in available_serial_nos: + available_serial_nos.setdefault(key, []) + + existing_serial_no = available_serial_nos[key] + for sn in serial_nos: + if sle.actual_qty > 0: + if sn in existing_serial_no: + existing_serial_no.remove(sn) + else: + existing_serial_no.append(sn) + else: + if sn in existing_serial_no: + existing_serial_no.remove(sn) + else: + existing_serial_no.append(sn) + + sle.balance_serial_no = '\n'.join(existing_serial_no) def get_columns(): columns = [ @@ -76,7 +102,8 @@ def get_columns(): {"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110}, {"label": _("Voucher #"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 100}, {"label": _("Batch"), "fieldname": "batch_no", "fieldtype": "Link", "options": "Batch", "width": 100}, - {"label": _("Serial #"), "fieldname": "serial_no", "fieldtype": "Link", "options": "Serial No", "width": 100}, + {"label": _("Serial No"), "fieldname": "serial_no", "fieldtype": "Link", "options": "Serial No", "width": 100}, + {"label": _("Balance Serial No"), "fieldname": "balance_serial_no", "width": 100}, {"label": _("Project"), "fieldname": "project", "fieldtype": "Link", "options": "Project", "width": 100}, {"label": _("Company"), "fieldname": "company", "fieldtype": "Link", "options": "Company", "width": 110} ] @@ -111,7 +138,7 @@ def get_stock_ledger_entries(filters, items): `tabStock Ledger Entry` sle WHERE company = %(company)s - AND posting_date BETWEEN %(from_date)s AND %(to_date)s + AND is_cancelled = 0 AND posting_date BETWEEN %(from_date)s AND %(to_date)s {sle_conditions} {item_conditions_sql} ORDER BY @@ -182,9 +209,6 @@ def get_sle_conditions(filters): if filters.get("project"): conditions.append("project=%(project)s") - if not filters.get("show_cancelled_entries"): - conditions.append("is_cancelled = 0") - return "and {}".format(" and ".join(conditions)) if conditions else "" diff --git a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py index c8efb1637f9..1183e41d041 100644 --- a/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py +++ b/erpnext/stock/report/stock_projected_qty/stock_projected_qty.py @@ -5,9 +5,10 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.utils import flt, today -from erpnext.stock.utils import update_included_uom_in_report +from erpnext.stock.utils import update_included_uom_in_report, is_reposting_item_valuation_in_progress def execute(filters=None): + is_reposting_item_valuation_in_progress() filters = frappe._dict(filters or {}) include_uom = filters.get("include_uom") columns = get_columns() diff --git a/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.py b/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.py index 55f041c95c8..78e95df9898 100644 --- a/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.py +++ b/erpnext/stock/report/stock_qty_vs_serial_no_count/stock_qty_vs_serial_no_count.py @@ -6,10 +6,17 @@ import frappe from frappe import _ def execute(filters=None): + validate_warehouse(filters) columns = get_columns() data = get_data(filters.warehouse) return columns, data +def validate_warehouse(filters): + company = filters.company + warehouse = filters.warehouse + if not frappe.db.exists("Warehouse", {"name": warehouse, "company": company}): + frappe.throw(_("Warehouse: {0} does not belong to {1}").format(warehouse, company)) + def get_columns(): columns = [ { 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 ebcb106b02a..04f7d347ba8 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 @@ -11,9 +11,11 @@ from frappe.utils import flt, cint, getdate from erpnext.stock.report.stock_balance.stock_balance import (get_item_details, get_item_reorder_details, get_item_warehouse_map, get_items, get_stock_ledger_entries) from erpnext.stock.report.stock_ageing.stock_ageing import get_fifo_queue, get_average_age +from erpnext.stock.utils import is_reposting_item_valuation_in_progress from six import iteritems def execute(filters=None): + is_reposting_item_valuation_in_progress() if not filters: filters = {} validate_filters(filters) diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index b5ae1b78eb4..8ba1f1ca5c7 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -6,6 +6,7 @@ import frappe from frappe.utils import flt, cstr, nowdate, nowtime from erpnext.stock.utils import update_bin from erpnext.stock.stock_ledger import update_entries_after +from erpnext.controllers.stock_controller import create_repost_item_valuation_entry def repost(only_actual=False, allow_negative_stock=False, allow_zero_rate=False, only_bin=False): """ @@ -56,12 +57,18 @@ def repost_stock(item_code, warehouse, allow_zero_rate=False, update_bin_qty(item_code, warehouse, qty_dict) def repost_actual_qty(item_code, warehouse, allow_zero_rate=False, allow_negative_stock=False): - update_entries_after({ "item_code": item_code, "warehouse": warehouse }, - allow_zero_rate=allow_zero_rate, allow_negative_stock=allow_negative_stock) + create_repost_item_valuation_entry({ + "item_code": item_code, + "warehouse": warehouse, + "posting_date": "1900-01-01", + "posting_time": "00:01", + "allow_negative_stock": allow_negative_stock, + "allow_zero_rate": allow_zero_rate + }) def get_balance_qty_from_sle(item_code, warehouse): balance_qty = frappe.db.sql("""select qty_after_transaction from `tabStock Ledger Entry` - where item_code=%s and warehouse=%s + where item_code=%s and warehouse=%s and is_cancelled=0 order by posting_date desc, posting_time desc, creation desc limit 1""", (item_code, warehouse)) @@ -191,7 +198,7 @@ def set_stock_balance_as_per_serial_no(item_code=None, posting_date=None, postin print(d[0], d[1], d[2], serial_nos[0][0]) sle = frappe.db.sql("""select valuation_rate, company from `tabStock Ledger Entry` - where item_code = %s and warehouse = %s + where item_code = %s and warehouse = %s and is_cancelled = 0 order by posting_date desc limit 1""", (d[0], d[1])) sle_dict = { @@ -223,7 +230,8 @@ def set_stock_balance_as_per_serial_no(item_code=None, posting_date=None, postin }) update_bin(args) - update_entries_after({ + + create_repost_item_valuation_entry({ "item_code": d[0], "warehouse": d[1], "posting_date": posting_date, diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index f4490f1b01e..121c51cf6a6 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -5,9 +5,10 @@ from __future__ import unicode_literals import frappe, erpnext from frappe import _ from frappe.utils import cint, flt, cstr, now, now_datetime +from frappe.model.meta import get_field_precision from erpnext.stock.utils import get_valuation_method, get_incoming_outgoing_rate_for_cancel +from erpnext.stock.utils import get_bin import json - from six import iteritems # future reposting @@ -22,37 +23,44 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc cancel = sl_entries[0].get("is_cancelled") if cancel: + validate_cancellation(sl_entries) set_as_cancel(sl_entries[0].get('voucher_type'), sl_entries[0].get('voucher_no')) for sle in sl_entries: - sle_id = None - if via_landed_cost_voucher or cancel: - sle['posting_date'] = now_datetime().strftime('%Y-%m-%d') - sle['posting_time'] = now_datetime().strftime('%H:%M:%S.%f') + if cancel: + sle['actual_qty'] = -flt(sle.get('actual_qty')) - if cancel: - sle['actual_qty'] = -flt(sle.get('actual_qty')) - - if sle['actual_qty'] < 0 and not sle.get('outgoing_rate'): - sle['outgoing_rate'] = get_incoming_outgoing_rate_for_cancel(sle.item_code, - sle.voucher_type, sle.voucher_no, sle.voucher_detail_no) - sle['incoming_rate'] = 0.0 - - if sle['actual_qty'] > 0 and not sle.get('incoming_rate'): - sle['incoming_rate'] = get_incoming_outgoing_rate_for_cancel(sle.item_code, - sle.voucher_type, sle.voucher_no, sle.voucher_detail_no) - sle['outgoing_rate'] = 0.0 + if sle['actual_qty'] < 0 and not sle.get('outgoing_rate'): + sle['outgoing_rate'] = get_incoming_outgoing_rate_for_cancel(sle.item_code, + sle.voucher_type, sle.voucher_no, sle.voucher_detail_no) + sle['incoming_rate'] = 0.0 + if sle['actual_qty'] > 0 and not sle.get('incoming_rate'): + sle['incoming_rate'] = get_incoming_outgoing_rate_for_cancel(sle.item_code, + sle.voucher_type, sle.voucher_no, sle.voucher_detail_no) + sle['outgoing_rate'] = 0.0 if sle.get("actual_qty") or sle.get("voucher_type")=="Stock Reconciliation": - sle_id = make_entry(sle, allow_negative_stock, via_landed_cost_voucher) + sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher) - args = sle.copy() - args.update({ - "sle_id": sle_id - }) + args = sle_doc.as_dict() update_bin(args, allow_negative_stock, via_landed_cost_voucher) +def validate_cancellation(args): + if args[0].get("is_cancelled"): + repost_entry = frappe.db.get_value("Repost Item Valuation", { + 'voucher_type': args[0].voucher_type, + 'voucher_no': args[0].voucher_no, + 'docstatus': 1 + }, ['name', 'status'], as_dict=1) + + if repost_entry: + if repost_entry.status == 'In Progress': + frappe.throw(_("Cannot cancel the transaction. Reposting of item valuation on submission is not completed yet.")) + if repost_entry.status == 'Queued': + doc = frappe.get_doc("Repost Item Valuation", repost_entry.name) + doc.cancel() + doc.delete() def set_as_cancel(voucher_type, voucher_no): frappe.db.sql("""update `tabStock Ledger Entry` set is_cancelled=1, @@ -68,8 +76,37 @@ def make_entry(args, allow_negative_stock=False, via_landed_cost_voucher=False): sle.via_landed_cost_voucher = via_landed_cost_voucher sle.insert() sle.submit() - return sle.name + return sle +def repost_future_sle(args=None, voucher_type=None, voucher_no=None, allow_negative_stock=None, via_landed_cost_voucher=False): + if not args and voucher_type and voucher_no: + args = get_args_for_voucher(voucher_type, voucher_no) + + distinct_item_warehouses = [(d.item_code, d.warehouse) for d in args] + + i = 0 + while i < len(args): + obj = update_entries_after({ + "item_code": args[i].item_code, + "warehouse": args[i].warehouse, + "posting_date": args[i].posting_date, + "posting_time": args[i].posting_time, + "creation": args[i].get("creation") + }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher) + + for item_wh, new_sle in iteritems(obj.new_items): + if item_wh not in distinct_item_warehouses: + args.append(new_sle) + + i += 1 + +def get_args_for_voucher(voucher_type, voucher_no): + return frappe.db.get_all("Stock Ledger Entry", + filters={"voucher_type": voucher_type, "voucher_no": voucher_no}, + fields=["item_code", "warehouse", "posting_date", "posting_time", "creation"], + order_by="creation asc", + group_by="item_code, warehouse" + ) class update_entries_after(object): """ @@ -86,141 +123,328 @@ class update_entries_after(object): } """ def __init__(self, args, allow_zero_rate=False, allow_negative_stock=None, via_landed_cost_voucher=False, verbose=1): - from frappe.model.meta import get_field_precision - - self.exceptions = [] + self.exceptions = {} self.verbose = verbose self.allow_zero_rate = allow_zero_rate - self.allow_negative_stock = allow_negative_stock self.via_landed_cost_voucher = via_landed_cost_voucher - if not self.allow_negative_stock: - self.allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", - "allow_negative_stock")) + self.allow_negative_stock = allow_negative_stock \ + or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) - self.args = args - for key, value in iteritems(args): - setattr(self, key, value) + self.args = frappe._dict(args) + self.item_code = args.get("item_code") + if self.args.sle_id: + self.args['name'] = self.args.sle_id - self.previous_sle = self.get_sle_before_datetime() - self.previous_sle = self.previous_sle[0] if self.previous_sle else frappe._dict() + self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company") + self.get_precision() + self.valuation_method = get_valuation_method(self.item_code) + self.new_items = {} + + self.data = frappe._dict() + self.initialize_previous_data(self.args) + + self.build() + + def get_precision(self): + company_base_currency = frappe.get_cached_value('Company', self.company, "default_currency") + self.precision = get_field_precision(frappe.get_meta("Stock Ledger Entry").get_field("stock_value"), + currency=company_base_currency) + + def initialize_previous_data(self, args): + """ + Get previous sl entries for current item for each related warehouse + and assigns into self.data dict + + :Data Structure: + + self.data = { + warehouse1: { + 'previus_sle': {}, + 'qty_after_transaction': 10, + 'valuation_rate': 100, + 'stock_value': 1000, + 'prev_stock_value': 1000, + 'stock_queue': '[[10, 100]]', + 'stock_value_difference': 1000 + } + } + + """ + self.data.setdefault(args.warehouse, frappe._dict()) + warehouse_dict = self.data[args.warehouse] + previous_sle = self.get_previous_sle_of_current_voucher(args) + warehouse_dict.previous_sle = previous_sle for key in ("qty_after_transaction", "valuation_rate", "stock_value"): - setattr(self, key, flt(self.previous_sle.get(key))) + setattr(warehouse_dict, key, flt(previous_sle.get(key))) - self.company = frappe.db.get_value("Warehouse", self.warehouse, "company") - self.precision = get_field_precision(frappe.get_meta("Stock Ledger Entry").get_field("stock_value"), - currency=frappe.get_cached_value('Company', self.company, "default_currency")) + warehouse_dict.update({ + "prev_stock_value": previous_sle.stock_value or 0.0, + "stock_queue": json.loads(previous_sle.stock_queue or "[]"), + "stock_value_difference": 0.0 + }) - self.prev_stock_value = self.previous_sle.stock_value or 0.0 - self.stock_queue = json.loads(self.previous_sle.stock_queue or "[]") - self.valuation_method = get_valuation_method(self.item_code) - self.stock_value_difference = 0.0 - self.build(args.get('sle_id')) + def get_previous_sle_of_current_voucher(self, args): + """get stock ledger entries filtered by specific posting datetime conditions""" - def build(self, sle_id): - if sle_id: - sle = get_sle_by_id(sle_id) - self.process_sle(sle) + args['time_format'] = '%H:%i:%s' + if not args.get("posting_date"): + args["posting_date"] = "1900-01-01" + if not args.get("posting_time"): + args["posting_time"] = "00:00" + + sle = frappe.db.sql(""" + select *, timestamp(posting_date, posting_time) as "timestamp" + from `tabStock Ledger Entry` + where item_code = %(item_code)s + and warehouse = %(warehouse)s + and is_cancelled = 0 + and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s)) + order by timestamp(posting_date, posting_time) desc, creation desc + limit 1""", args, as_dict=1) + + return sle[0] if sle else frappe._dict() + + + def build(self): + from erpnext.controllers.stock_controller import future_sle_exists + + if self.args.get("sle_id"): + self.process_sle_against_current_timestamp() + if not future_sle_exists(self.args): + self.update_bin() else: - # includes current entry! - entries_to_fix = self.get_sle_after_datetime() - for sle in entries_to_fix: + entries_to_fix = self.get_future_entries_to_fix() + + i = 0 + while i < len(entries_to_fix): + sle = entries_to_fix[i] + i += 1 + self.process_sle(sle) + if sle.dependant_sle_voucher_detail_no: + entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle) + + self.update_bin() + if self.exceptions: self.raise_exceptions() - self.update_bin() + def process_sle_against_current_timestamp(self): + sl_entries = self.get_sle_against_current_voucher() + for sle in sl_entries: + self.process_sle(sle) - def update_bin(self): - # update bin - bin_name = frappe.db.get_value("Bin", { - "item_code": self.item_code, - "warehouse": self.warehouse - }) + def get_sle_against_current_voucher(self): + self.args['time_format'] = '%H:%i:%s' - if not bin_name: - bin_doc = frappe.get_doc({ - "doctype": "Bin", - "item_code": self.item_code, - "warehouse": self.warehouse - }) - bin_doc.insert(ignore_permissions=True) - else: - bin_doc = frappe.get_doc("Bin", bin_name) + return frappe.db.sql(""" + select + *, timestamp(posting_date, posting_time) as "timestamp" + from + `tabStock Ledger Entry` + where + item_code = %(item_code)s + and warehouse = %(warehouse)s + and timestamp(posting_date, time_format(posting_time, %(time_format)s)) = timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s)) - bin_doc.update({ - "valuation_rate": self.valuation_rate, - "actual_qty": self.qty_after_transaction, - "stock_value": self.stock_value - }) - bin_doc.flags.via_stock_ledger_entry = True + order by + creation ASC + for update + """, self.args, as_dict=1) - bin_doc.save(ignore_permissions=True) + def get_future_entries_to_fix(self): + # includes current entry! + args = self.data[self.args.warehouse].previous_sle \ + or frappe._dict({"item_code": self.item_code, "warehouse": self.args.warehouse}) + + return list(self.get_sle_after_datetime(args)) + + def get_dependent_entries_to_fix(self, entries_to_fix, sle): + dependant_sle = get_sle_by_voucher_detail_no(sle.dependant_sle_voucher_detail_no, + excluded_sle=sle.name) + + if not dependant_sle: + return entries_to_fix + elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse == self.args.warehouse: + return entries_to_fix + elif dependant_sle.item_code != self.item_code: + if (dependant_sle.item_code, dependant_sle.warehouse) not in self.new_items: + self.new_items[(dependant_sle.item_code, dependant_sle.warehouse)] = dependant_sle + return entries_to_fix + elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse in self.data: + return entries_to_fix + self.initialize_previous_data(dependant_sle) + + args = self.data[dependant_sle.warehouse].previous_sle \ + or frappe._dict({"item_code": self.item_code, "warehouse": dependant_sle.warehouse}) + future_sle_for_dependant = list(self.get_sle_after_datetime(args)) + + entries_to_fix.extend(future_sle_for_dependant) + return sorted(entries_to_fix, key=lambda k: k['timestamp']) def process_sle(self, sle): + # previous sle data for this warehouse + self.wh_data = self.data[sle.warehouse] + if (sle.serial_no and not self.via_landed_cost_voucher) or not cint(self.allow_negative_stock): # validate negative stock for serialized items, fifo valuation # or when negative stock is not allowed for moving average if not self.validate_negative_stock(sle): - self.qty_after_transaction += flt(sle.actual_qty) + self.wh_data.qty_after_transaction += flt(sle.actual_qty) return + # Get dynamic incoming/outgoing rate + self.get_dynamic_incoming_outgoing_rate(sle) + if sle.serial_no: self.get_serialized_values(sle) - self.qty_after_transaction += flt(sle.actual_qty) + self.wh_data.qty_after_transaction += flt(sle.actual_qty) if sle.voucher_type == "Stock Reconciliation": - self.qty_after_transaction = sle.qty_after_transaction + self.wh_data.qty_after_transaction = sle.qty_after_transaction - self.stock_value = flt(self.qty_after_transaction) * flt(self.valuation_rate) + self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) else: if sle.voucher_type=="Stock Reconciliation" and not sle.batch_no: # assert - self.valuation_rate = sle.valuation_rate - self.qty_after_transaction = sle.qty_after_transaction - self.stock_queue = [[self.qty_after_transaction, self.valuation_rate]] - self.stock_value = flt(self.qty_after_transaction) * flt(self.valuation_rate) + self.wh_data.valuation_rate = sle.valuation_rate + self.wh_data.qty_after_transaction = sle.qty_after_transaction + self.wh_data.stock_queue = [[self.wh_data.qty_after_transaction, self.wh_data.valuation_rate]] + self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) else: if self.valuation_method == "Moving Average": self.get_moving_average_values(sle) - self.qty_after_transaction += flt(sle.actual_qty) - self.stock_value = flt(self.qty_after_transaction) * flt(self.valuation_rate) + self.wh_data.qty_after_transaction += flt(sle.actual_qty) + self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate) else: self.get_fifo_values(sle) - self.qty_after_transaction += flt(sle.actual_qty) - self.stock_value = sum((flt(batch[0]) * flt(batch[1]) for batch in self.stock_queue)) + self.wh_data.qty_after_transaction += flt(sle.actual_qty) + self.wh_data.stock_value = sum((flt(batch[0]) * flt(batch[1]) for batch in self.wh_data.stock_queue)) # rounding as per precision - self.stock_value = flt(self.stock_value, self.precision) - - stock_value_difference = self.stock_value - self.prev_stock_value - - self.prev_stock_value = self.stock_value + self.wh_data.stock_value = flt(self.wh_data.stock_value, self.precision) + stock_value_difference = self.wh_data.stock_value - self.wh_data.prev_stock_value + self.wh_data.prev_stock_value = self.wh_data.stock_value # update current sle - sle.qty_after_transaction = self.qty_after_transaction - sle.valuation_rate = self.valuation_rate - sle.stock_value = self.stock_value - sle.stock_queue = json.dumps(self.stock_queue) + sle.qty_after_transaction = self.wh_data.qty_after_transaction + sle.valuation_rate = self.wh_data.valuation_rate + sle.stock_value = self.wh_data.stock_value + sle.stock_queue = json.dumps(self.wh_data.stock_queue) sle.stock_value_difference = stock_value_difference sle.doctype="Stock Ledger Entry" frappe.get_doc(sle).db_update() + self.update_outgoing_rate_on_transaction(sle) + def validate_negative_stock(self, sle): """ validate negative stock for entries current datetime onwards will not consider cancelled entries """ - diff = self.qty_after_transaction + flt(sle.actual_qty) + diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty) if diff < 0 and abs(diff) > 0.0001: # negative stock! exc = sle.copy().update({"diff": diff}) - self.exceptions.append(exc) + self.exceptions.setdefault(sle.warehouse, []).append(exc) return False else: return True + def get_dynamic_incoming_outgoing_rate(self, sle): + # Get updated incoming/outgoing rate from transaction + if sle.recalculate_rate: + rate = self.get_incoming_outgoing_rate_from_transaction(sle) + + if flt(sle.actual_qty) >= 0: + sle.incoming_rate = rate + else: + sle.outgoing_rate = rate + + def get_incoming_outgoing_rate_from_transaction(self, sle): + rate = 0 + # Material Transfer, Repack, Manufacturing + if sle.voucher_type == "Stock Entry": + rate = frappe.db.get_value("Stock Entry Detail", sle.voucher_detail_no, "valuation_rate") + # Sales and Purchase Return + elif sle.voucher_type in ("Purchase Receipt", "Purchase Invoice", "Delivery Note", "Sales Invoice"): + if frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_return"): + from erpnext.controllers.sales_and_purchase_return import get_rate_for_return # don't move this import to top + rate = get_rate_for_return(sle.voucher_type, sle.voucher_no, sle.item_code, voucher_detail_no=sle.voucher_detail_no) + else: + if sle.voucher_type in ("Purchase Receipt", "Purchase Invoice"): + rate_field = "valuation_rate" + else: + rate_field = "incoming_rate" + + # check in item table + item_code, incoming_rate = frappe.db.get_value(sle.voucher_type + " Item", + sle.voucher_detail_no, ["item_code", rate_field]) + + if item_code == sle.item_code: + rate = incoming_rate + else: + if sle.voucher_type in ("Delivery Note", "Sales Invoice"): + ref_doctype = "Packed Item" + else: + ref_doctype = "Purchase Receipt Item Supplied" + + rate = frappe.db.get_value(ref_doctype, {"parent_detail_docname": sle.voucher_detail_no, + "item_code": sle.item_code}, rate_field) + + return rate + + def update_outgoing_rate_on_transaction(self, sle): + """ + Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return + In case of Stock Entry, also calculate FG Item rate and total incoming/outgoing amount + """ + if sle.actual_qty and sle.voucher_detail_no: + outgoing_rate = abs(flt(sle.stock_value_difference)) / abs(sle.actual_qty) + + if flt(sle.actual_qty) < 0 and sle.voucher_type == "Stock Entry": + self.update_rate_on_stock_entry(sle, outgoing_rate) + elif sle.voucher_type in ("Delivery Note", "Sales Invoice"): + self.update_rate_on_delivery_and_sales_return(sle, outgoing_rate) + elif flt(sle.actual_qty) < 0 and sle.voucher_type in ("Purchase Receipt", "Purchase Invoice"): + self.update_rate_on_purchase_receipt(sle, outgoing_rate) + + def update_rate_on_stock_entry(self, sle, outgoing_rate): + frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate) + + # Update outgoing item's rate, recalculate FG Item's rate and total incoming/outgoing amount + stock_entry = frappe.get_doc("Stock Entry", sle.voucher_no) + stock_entry.calculate_rate_and_amount(reset_outgoing_rate=False, raise_error_if_no_rate=False) + stock_entry.db_update() + for d in stock_entry.items: + d.db_update() + + def update_rate_on_delivery_and_sales_return(self, sle, outgoing_rate): + # Update item's incoming rate on transaction + item_code = frappe.db.get_value(sle.voucher_type + " Item", sle.voucher_detail_no, "item_code") + if item_code == sle.item_code: + frappe.db.set_value(sle.voucher_type + " Item", sle.voucher_detail_no, "incoming_rate", outgoing_rate) + else: + # packed item + frappe.db.set_value("Packed Item", + {"parent_detail_docname": sle.voucher_detail_no, "item_code": sle.item_code}, + "incoming_rate", outgoing_rate) + + def update_rate_on_purchase_receipt(self, sle, outgoing_rate): + if frappe.db.exists(sle.voucher_type + " Item", sle.voucher_detail_no): + frappe.db.set_value(sle.voucher_type + " Item", sle.voucher_detail_no, "base_net_rate", outgoing_rate) + else: + frappe.db.set_value("Purchase Receipt Item Supplied", sle.voucher_detail_no, "rate", outgoing_rate) + + # Recalculate subcontracted item's rate in case of subcontracted purchase receipt/invoice + if frappe.db.get_value(sle.voucher_type, sle.voucher_no, "is_subcontracted"): + doc = frappe.get_doc(sle.voucher_type, sle.voucher_no) + doc.update_valuation_rate(reset_outgoing_rate=False) + for d in (doc.items + doc.supplied_items): + d.db_update() + def get_serialized_values(self, sle): incoming_rate = flt(sle.incoming_rate) actual_qty = flt(sle.actual_qty) @@ -228,7 +452,7 @@ class update_entries_after(object): if incoming_rate < 0: # wrong incoming rate - incoming_rate = self.valuation_rate + incoming_rate = self.wh_data.valuation_rate stock_value_change = 0 if incoming_rate: @@ -236,22 +460,25 @@ class update_entries_after(object): elif actual_qty < 0: # In case of delivery/stock issue, get average purchase rate # of serial nos of current entry - outgoing_value = self.get_incoming_value_for_serial_nos(sle, serial_nos) - stock_value_change = -1 * outgoing_value + if not sle.is_cancelled: + outgoing_value = self.get_incoming_value_for_serial_nos(sle, serial_nos) + stock_value_change = -1 * outgoing_value + else: + stock_value_change = actual_qty * sle.outgoing_rate - new_stock_qty = self.qty_after_transaction + actual_qty + new_stock_qty = self.wh_data.qty_after_transaction + actual_qty if new_stock_qty > 0: - new_stock_value = (self.qty_after_transaction * self.valuation_rate) + stock_value_change + new_stock_value = (self.wh_data.qty_after_transaction * self.wh_data.valuation_rate) + stock_value_change if new_stock_value >= 0: # calculate new valuation rate only if stock value is positive # else it remains the same as that of previous entry - self.valuation_rate = new_stock_value / new_stock_qty + self.wh_data.valuation_rate = new_stock_value / new_stock_qty - if not self.valuation_rate and sle.voucher_detail_no: + if not self.wh_data.valuation_rate and sle.voucher_detail_no: allow_zero_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) if not allow_zero_rate: - self.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, + self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, sle.voucher_type, sle.voucher_no, self.allow_zero_rate, currency=erpnext.get_company_currency(sle.company)) @@ -287,39 +514,38 @@ class update_entries_after(object): def get_moving_average_values(self, sle): actual_qty = flt(sle.actual_qty) - new_stock_qty = flt(self.qty_after_transaction) + actual_qty + new_stock_qty = flt(self.wh_data.qty_after_transaction) + actual_qty if new_stock_qty >= 0: if actual_qty > 0: - if flt(self.qty_after_transaction) <= 0: - self.valuation_rate = sle.incoming_rate + if flt(self.wh_data.qty_after_transaction) <= 0: + self.wh_data.valuation_rate = sle.incoming_rate else: - new_stock_value = (self.qty_after_transaction * self.valuation_rate) + \ + new_stock_value = (self.wh_data.qty_after_transaction * self.wh_data.valuation_rate) + \ (actual_qty * sle.incoming_rate) - self.valuation_rate = new_stock_value / new_stock_qty + self.wh_data.valuation_rate = new_stock_value / new_stock_qty elif sle.outgoing_rate: if new_stock_qty: - new_stock_value = (self.qty_after_transaction * self.valuation_rate) + \ + new_stock_value = (self.wh_data.qty_after_transaction * self.wh_data.valuation_rate) + \ (actual_qty * sle.outgoing_rate) - self.valuation_rate = new_stock_value / new_stock_qty + self.wh_data.valuation_rate = new_stock_value / new_stock_qty else: - self.valuation_rate = sle.outgoing_rate - + self.wh_data.valuation_rate = sle.outgoing_rate else: - if flt(self.qty_after_transaction) >= 0 and sle.outgoing_rate: - self.valuation_rate = sle.outgoing_rate + if flt(self.wh_data.qty_after_transaction) >= 0 and sle.outgoing_rate: + self.wh_data.valuation_rate = sle.outgoing_rate - if not self.valuation_rate and actual_qty > 0: - self.valuation_rate = sle.incoming_rate + if not self.wh_data.valuation_rate and actual_qty > 0: + self.wh_data.valuation_rate = sle.incoming_rate # Get valuation rate from previous SLE or Item master, if item does not have the # allow zero valuration rate flag set - if not self.valuation_rate and sle.voucher_detail_no: + if not self.wh_data.valuation_rate and sle.voucher_detail_no: allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) if not allow_zero_valuation_rate: - self.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, + self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, sle.voucher_type, sle.voucher_no, self.allow_zero_rate, currency=erpnext.get_company_currency(sle.company)) @@ -329,22 +555,22 @@ class update_entries_after(object): outgoing_rate = flt(sle.outgoing_rate) if actual_qty > 0: - if not self.stock_queue: - self.stock_queue.append([0, 0]) + if not self.wh_data.stock_queue: + self.wh_data.stock_queue.append([0, 0]) # last row has the same rate, just updated the qty - if self.stock_queue[-1][1]==incoming_rate: - self.stock_queue[-1][0] += actual_qty + if self.wh_data.stock_queue[-1][1]==incoming_rate: + self.wh_data.stock_queue[-1][0] += actual_qty else: - if self.stock_queue[-1][0] > 0: - self.stock_queue.append([actual_qty, incoming_rate]) + if self.wh_data.stock_queue[-1][0] > 0: + self.wh_data.stock_queue.append([actual_qty, incoming_rate]) else: - qty = self.stock_queue[-1][0] + actual_qty - self.stock_queue[-1] = [qty, incoming_rate] + qty = self.wh_data.stock_queue[-1][0] + actual_qty + self.wh_data.stock_queue[-1] = [qty, incoming_rate] else: qty_to_pop = abs(actual_qty) while qty_to_pop: - if not self.stock_queue: + if not self.wh_data.stock_queue: # Get valuation rate from last sle if exists or from valuation rate field in item master allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no) if not allow_zero_valuation_rate: @@ -354,35 +580,35 @@ class update_entries_after(object): else: _rate = 0 - self.stock_queue.append([0, _rate]) + self.wh_data.stock_queue.append([0, _rate]) index = None if outgoing_rate > 0: # Find the entry where rate matched with outgoing rate - for i, v in enumerate(self.stock_queue): + for i, v in enumerate(self.wh_data.stock_queue): if v[1] == outgoing_rate: index = i break # If no entry found with outgoing rate, collapse stack if index == None: - new_stock_value = sum((d[0]*d[1] for d in self.stock_queue)) - qty_to_pop*outgoing_rate - new_stock_qty = sum((d[0] for d in self.stock_queue)) - qty_to_pop - self.stock_queue = [[new_stock_qty, new_stock_value/new_stock_qty if new_stock_qty > 0 else outgoing_rate]] + new_stock_value = sum((d[0]*d[1] for d in self.wh_data.stock_queue)) - qty_to_pop*outgoing_rate + new_stock_qty = sum((d[0] for d in self.wh_data.stock_queue)) - qty_to_pop + self.wh_data.stock_queue = [[new_stock_qty, new_stock_value/new_stock_qty if new_stock_qty > 0 else outgoing_rate]] break else: index = 0 # select first batch or the batch with same rate - batch = self.stock_queue[index] + batch = self.wh_data.stock_queue[index] if qty_to_pop >= batch[0]: # consume current batch qty_to_pop = qty_to_pop - batch[0] - self.stock_queue.pop(index) - if not self.stock_queue and qty_to_pop: + self.wh_data.stock_queue.pop(index) + if not self.wh_data.stock_queue and qty_to_pop: # stock finished, qty still remains to be withdrawn # negative stock, keep in as a negative batch - self.stock_queue.append([-qty_to_pop, outgoing_rate or batch[1]]) + self.wh_data.stock_queue.append([-qty_to_pop, outgoing_rate or batch[1]]) break else: @@ -391,14 +617,14 @@ class update_entries_after(object): batch[0] = batch[0] - qty_to_pop qty_to_pop = 0 - stock_value = sum((flt(batch[0]) * flt(batch[1]) for batch in self.stock_queue)) - stock_qty = sum((flt(batch[0]) for batch in self.stock_queue)) + stock_value = sum((flt(batch[0]) * flt(batch[1]) for batch in self.wh_data.stock_queue)) + stock_qty = sum((flt(batch[0]) for batch in self.wh_data.stock_queue)) if stock_qty: - self.valuation_rate = stock_value / flt(stock_qty) + self.wh_data.valuation_rate = stock_value / flt(stock_qty) - if not self.stock_queue: - self.stock_queue.append([0, sle.incoming_rate or sle.outgoing_rate or self.valuation_rate]) + if not self.wh_data.stock_queue: + self.wh_data.stock_queue.append([0, sle.incoming_rate or sle.outgoing_rate or self.wh_data.valuation_rate]) def check_if_allow_zero_valuation_rate(self, voucher_type, voucher_detail_no): ref_item_dt = "" @@ -413,39 +639,55 @@ class update_entries_after(object): else: return 0 - def get_sle_before_datetime(self): + def get_sle_before_datetime(self, args): """get previous stock ledger entry before current time-bucket""" - if self.args.get('sle_id'): - self.args['name'] = self.args.get('sle_id') + sle = get_stock_ledger_entries(args, "<", "desc", "limit 1", for_update=False) + sle = sle[0] if sle else frappe._dict() + return sle - return get_stock_ledger_entries(self.args, "<=", "desc", "limit 1", for_update=False) - - def get_sle_after_datetime(self): + def get_sle_after_datetime(self, args): """get Stock Ledger Entries after a particular datetime, for reposting""" - return get_stock_ledger_entries(self.previous_sle or frappe._dict({ - "item_code": self.args.get("item_code"), "warehouse": self.args.get("warehouse") }), - ">", "asc", for_update=True, check_serial_no=False) + return get_stock_ledger_entries(args, ">", "asc", for_update=True, check_serial_no=False) def raise_exceptions(self): - deficiency = min(e["diff"] for e in self.exceptions) + msg_list = [] + for warehouse, exceptions in iteritems(self.exceptions): + deficiency = min(e["diff"] for e in exceptions) - if ((self.exceptions[0]["voucher_type"], self.exceptions[0]["voucher_no"]) in - frappe.local.flags.currently_saving): + if ((exceptions[0]["voucher_type"], exceptions[0]["voucher_no"]) in + frappe.local.flags.currently_saving): - msg = _("{0} units of {1} needed in {2} to complete this transaction.").format( - abs(deficiency), frappe.get_desk_link('Item', self.item_code), - frappe.get_desk_link('Warehouse', self.warehouse)) - else: - msg = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format( - abs(deficiency), frappe.get_desk_link('Item', self.item_code), - frappe.get_desk_link('Warehouse', self.warehouse), - self.exceptions[0]["posting_date"], self.exceptions[0]["posting_time"], - frappe.get_desk_link(self.exceptions[0]["voucher_type"], self.exceptions[0]["voucher_no"])) + msg = _("{0} units of {1} needed in {2} to complete this transaction.").format( + abs(deficiency), frappe.get_desk_link('Item', exceptions[0]["item_code"]), + frappe.get_desk_link('Warehouse', warehouse)) + else: + msg = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format( + abs(deficiency), frappe.get_desk_link('Item', exceptions[0]["item_code"]), + frappe.get_desk_link('Warehouse', warehouse), + exceptions[0]["posting_date"], exceptions[0]["posting_time"], + frappe.get_desk_link(exceptions[0]["voucher_type"], exceptions[0]["voucher_no"])) - if self.verbose: - frappe.throw(msg, NegativeStockError, title='Insufficient Stock') - else: - raise NegativeStockError(msg) + if msg: + msg_list.append(msg) + + if msg_list: + message = "\n\n".join(msg_list) + if self.verbose: + frappe.throw(message, NegativeStockError, title='Insufficient Stock') + else: + raise NegativeStockError(message) + + def update_bin(self): + # update bin for each warehouse + for warehouse, data in iteritems(self.data): + bin_doc = get_bin(self.item_code, warehouse) + bin_doc.update({ + "valuation_rate": data.valuation_rate, + "actual_qty": data.qty_after_transaction, + "stock_value": data.stock_value + }) + bin_doc.flags.via_stock_ledger_entry = True + bin_doc.save(ignore_permissions=True) def get_previous_sle(args, for_update=False): """ @@ -489,6 +731,7 @@ def get_stock_ledger_entries(previous_sle, operator=None, select *, timestamp(posting_date, posting_time) as "timestamp" from `tabStock Ledger Entry` where item_code = %%(item_code)s + and is_cancelled = 0 %(conditions)s order by timestamp(posting_date, posting_time) %(order)s, creation %(order)s %(limit)s %(for_update)s""" % { @@ -498,10 +741,11 @@ def get_stock_ledger_entries(previous_sle, operator=None, "order": order }, previous_sle, as_dict=1, debug=debug) -def get_sle_by_id(sle_id): - return frappe.db.get_all('Stock Ledger Entry', - fields=['*', 'timestamp(posting_date, posting_time) as timestamp'], - filters={'name': sle_id})[0] +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]}, + ['item_code', 'warehouse', 'posting_date', 'posting_time', 'timestamp(posting_date, posting_time) as timestamp'], + as_dict=1) def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True): @@ -529,7 +773,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, 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]) # as there is previous records, it might come with zero rate + return flt(last_valuation_rate[0][0]) # If negative stock allowed, and item delivered without any incoming entry, # system does not found any SLE, then take valuation rate from Item @@ -561,3 +805,55 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, frappe.throw(msg=msg, title=_("Valuation Rate Missing")) return valuation_rate + +def update_qty_in_future_sle(args, allow_negative_stock=None): + frappe.db.sql(""" + update `tabStock Ledger Entry` + set qty_after_transaction = qty_after_transaction + {qty} + where + item_code = %(item_code)s + and warehouse = %(warehouse)s + and voucher_no != %(voucher_no)s + and is_cancelled = 0 + and (timestamp(posting_date, posting_time) > timestamp(%(posting_date)s, %(posting_time)s) + or ( + timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s) + and creation > %(creation)s + ) + ) + """.format(qty=args.actual_qty), args) + + validate_negative_qty_in_future_sle(args, allow_negative_stock) + +def validate_negative_qty_in_future_sle(args, allow_negative_stock=None): + allow_negative_stock = allow_negative_stock \ + or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) + + if args.actual_qty < 0 and not allow_negative_stock: + sle = get_future_sle_with_negative_qty(args) + if sle: + message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format( + abs(sle[0]["qty_after_transaction"]), + frappe.get_desk_link('Item', args.item_code), + frappe.get_desk_link('Warehouse', args.warehouse), + sle[0]["posting_date"], sle[0]["posting_time"], + frappe.get_desk_link(sle[0]["voucher_type"], sle[0]["voucher_no"])) + + frappe.throw(message, NegativeStockError, title='Insufficient Stock') + +def get_future_sle_with_negative_qty(args): + return frappe.db.sql(""" + select + qty_after_transaction, posting_date, posting_time, + voucher_type, voucher_no + from `tabStock Ledger Entry` + where + item_code = %(item_code)s + and warehouse = %(warehouse)s + and voucher_no != %(voucher_no)s + and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s) + and is_cancelled = 0 + and qty_after_transaction < 0 + order by timestamp(posting_date, posting_time) asc + limit 1 + """, args, as_dict=1) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 11e758fce32..0af3d908229 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe, erpnext from frappe import _ import json -from frappe.utils import flt, cstr, nowdate, nowtime +from frappe.utils import flt, cstr, nowdate, nowtime, get_link_to_form from six import string_types @@ -63,6 +63,7 @@ def get_stock_value_on(warehouse=None, posting_date=None, item_code=None): SELECT item_code, stock_value, name, warehouse FROM `tabStock Ledger Entry` sle WHERE posting_date <= %s {0} + and is_cancelled = 0 ORDER BY timestamp(posting_date, posting_time) DESC, creation DESC """.format(condition), values, as_dict=1) @@ -211,7 +212,7 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): currency=erpnext.get_company_currency(args.get('company')), company=args.get('company'), raise_error_if_no_rate=raise_error_if_no_rate) - return in_rate + return flt(in_rate) def get_avg_purchase_rate(serial_nos): """get average value of serial numbers""" @@ -283,12 +284,15 @@ def is_group_warehouse(warehouse): if frappe.db.get_value("Warehouse", warehouse, "is_group"): frappe.throw(_("Group node warehouse is not allowed to select for transactions")) +def validate_disabled_warehouse(warehouse): + if frappe.db.get_value("Warehouse", warehouse, "disabled"): + frappe.throw(_("Disabled Warehouse {0} cannot be used for this transaction.").format(get_link_to_form('Warehouse', warehouse))) + def update_included_uom_in_report(columns, result, include_uom, conversion_factors): if not include_uom or not conversion_factors: return convertible_cols = {} - is_dict_obj = False if isinstance(result[0], dict): is_dict_obj = True @@ -310,13 +314,13 @@ def update_included_uom_in_report(columns, result, include_uom, conversion_facto for row_idx, row in enumerate(result): data = row.items() if is_dict_obj else enumerate(row) for key, value in data: - if not key in convertible_columns or not conversion_factors[row_idx]: + if key not in convertible_columns or not conversion_factors[row_idx-1]: continue if convertible_columns.get(key) == 'rate': - new_value = flt(value) * conversion_factors[row_idx] + new_value = flt(value) * conversion_factors[row_idx-1] else: - new_value = flt(value) / conversion_factors[row_idx] + new_value = flt(value) / conversion_factors[row_idx-1] if not is_dict_obj: row.insert(key+1, new_value) @@ -376,4 +380,10 @@ def get_incoming_outgoing_rate_for_cancel(item_code, voucher_type, voucher_no, v outgoing_rate = outgoing_rate[0][0] if outgoing_rate else 0.0 - return outgoing_rate \ No newline at end of file + return outgoing_rate + +def is_reposting_item_valuation_in_progress(): + reposting_in_progress = frappe.db.exists("Repost Item Valuation", + {'docstatus': 1, 'status': ['in', ['Queued','In Progress']]}) + if reposting_in_progress: + frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1) \ No newline at end of file diff --git a/erpnext/stock/workspace/stock/stock.json b/erpnext/stock/workspace/stock/stock.json new file mode 100644 index 00000000000..3221dc4365c --- /dev/null +++ b/erpnext/stock/workspace/stock/stock.json @@ -0,0 +1,721 @@ +{ + "cards_label": "Masters & Reports", + "category": "Modules", + "charts": [ + { + "chart_name": "Warehouse wise Stock Value" + } + ], + "creation": "2020-03-02 15:43:10.096528", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "stock", + "idx": 0, + "is_standard": 1, + "label": "Stock", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Items and Pricing", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item", + "link_to": "Item", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Group", + "link_to": "Item Group", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Product Bundle", + "link_to": "Product Bundle", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Price List", + "link_to": "Price List", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Price", + "link_to": "Item Price", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shipping Rule", + "link_to": "Shipping Rule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Pricing Rule", + "link_to": "Pricing Rule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Alternative", + "link_to": "Item Alternative", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Manufacturer", + "link_to": "Item Manufacturer", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customs Tariff Number", + "link_to": "Customs Tariff Number", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Stock Transactions", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Material Request", + "link_to": "Material Request", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Stock Entry", + "link_to": "Stock Entry", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Customer", + "hidden": 0, + "is_query_report": 0, + "label": "Delivery Note", + "link_to": "Delivery Note", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item, Supplier", + "hidden": 0, + "is_query_report": 0, + "label": "Purchase Receipt", + "link_to": "Purchase Receipt", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Pick List", + "link_to": "Pick List", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Delivery Trip", + "link_to": "Delivery Trip", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Stock Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Stock Ledger", + "link_to": "Stock Ledger", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Stock Balance", + "link_to": "Stock Balance", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Stock Projected Qty", + "link_to": "Stock Projected Qty", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Stock Summary", + "link_to": "stock-balance", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Stock Ageing", + "link_to": "Stock Ageing", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Item Price Stock", + "link_to": "Item Price Stock", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Stock Settings", + "link_to": "Stock Settings", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Warehouse", + "link_to": "Warehouse", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Unit of Measure (UOM)", + "link_to": "UOM", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Variant Settings", + "link_to": "Item Variant Settings", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Brand", + "link_to": "Brand", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Item Attribute", + "link_to": "Item Attribute", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "UOM Conversion Factor", + "link_to": "UOM Conversion Factor", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Serial No and Batch", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Serial No", + "link_to": "Serial No", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Batch", + "link_to": "Batch", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 0, + "label": "Installation Note", + "link_to": "Installation Note", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Serial No", + "hidden": 0, + "is_query_report": 0, + "label": "Serial No Service Contract Expiry", + "link_to": "Serial No Service Contract Expiry", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Serial No", + "hidden": 0, + "is_query_report": 0, + "label": "Serial No Status", + "link_to": "Serial No Status", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Serial No", + "hidden": 0, + "is_query_report": 0, + "label": "Serial No Warranty Expiry", + "link_to": "Serial No Warranty Expiry", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Tools", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Stock Reconciliation", + "link_to": "Stock Reconciliation", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Landed Cost Voucher", + "link_to": "Landed Cost Voucher", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Packing Slip", + "link_to": "Packing Slip", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quality Inspection", + "link_to": "Quality Inspection", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quality Inspection Template", + "link_to": "Quality Inspection Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Quick Stock Balance", + "link_to": "Quick Stock Balance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Key Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Item Price", + "hidden": 0, + "is_query_report": 0, + "label": "Item-wise Price List Rate", + "link_to": "Item-wise Price List Rate", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Stock Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Stock Analytics", + "link_to": "Stock Analytics", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Stock Qty vs Serial No Count", + "link_to": "Stock Qty vs Serial No Count", + "link_type": "Report", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Delivery Note", + "hidden": 0, + "is_query_report": 1, + "label": "Delivery Note Trends", + "link_to": "Delivery Note Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Receipt", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Receipt Trends", + "link_to": "Purchase Receipt Trends", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Sales Order", + "hidden": 0, + "is_query_report": 1, + "label": "Sales Order Analysis", + "link_to": "Sales Order Analysis", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Order", + "hidden": 0, + "is_query_report": 1, + "label": "Purchase Order Analysis", + "link_to": "Purchase Order Analysis", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Bin", + "hidden": 0, + "is_query_report": 1, + "label": "Item Shortage Report", + "link_to": "Item Shortage Report", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Batch", + "hidden": 0, + "is_query_report": 1, + "label": "Batch-Wise Balance History", + "link_to": "Batch-Wise Balance History", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Other Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Material Request", + "hidden": 0, + "is_query_report": 1, + "label": "Requested Items To Be Transferred", + "link_to": "Requested Items To Be Transferred", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Stock Ledger Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Batch Item Expiry Status", + "link_to": "Batch Item Expiry Status", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Price List", + "hidden": 0, + "is_query_report": 1, + "label": "Item Prices", + "link_to": "Item Prices", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Itemwise Recommended Reorder Level", + "link_to": "Itemwise Recommended Reorder Level", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Item", + "hidden": 0, + "is_query_report": 1, + "label": "Item Variant Details", + "link_to": "Item Variant Details", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Order", + "hidden": 0, + "is_query_report": 1, + "label": "Subcontracted Raw Materials To Be Transferred", + "link_to": "Subcontracted Raw Materials To Be Transferred", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Purchase Order", + "hidden": 0, + "is_query_report": 1, + "label": "Subcontracted Item To Be Received", + "link_to": "Subcontracted Item To Be Received", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Stock Ledger Entry", + "hidden": 0, + "is_query_report": 1, + "label": "Stock and Account Value Comparison", + "link_to": "Stock and Account Value Comparison", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:36.282890", + "modified_by": "Administrator", + "module": "Stock", + "name": "Stock", + "onboarding": "Stock", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "color": "Green", + "format": "{} Available", + "label": "Item", + "link_to": "Item", + "stats_filter": "{\n \"disabled\" : 0\n}", + "type": "DocType" + }, + { + "color": "Yellow", + "format": "{} Pending", + "label": "Material Request", + "link_to": "Material Request", + "stats_filter": "{\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%'],\n \"status\": \"Pending\"\n}", + "type": "DocType" + }, + { + "label": "Stock Entry", + "link_to": "Stock Entry", + "type": "DocType" + }, + { + "color": "Yellow", + "format": "{} To Bill", + "label": "Purchase Receipt", + "link_to": "Purchase Receipt", + "stats_filter": "{\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%'],\n \"status\": \"To Bill\"\n}", + "type": "DocType" + }, + { + "color": "Yellow", + "format": "{} To Bill", + "label": "Delivery Note", + "link_to": "Delivery Note", + "stats_filter": "{\n \"company\": [\"like\", '%' + frappe.defaults.get_global_default(\"company\") + '%'],\n \"status\": \"To Bill\"\n}", + "type": "DocType" + }, + { + "label": "Stock Ledger", + "link_to": "Stock Ledger", + "type": "Report" + }, + { + "label": "Stock Balance", + "link_to": "Stock Balance", + "type": "Report" + }, + { + "label": "Dashboard", + "link_to": "Stock", + "type": "Dashboard" + } + ], + "shortcuts_label": "Quick Access" +} \ No newline at end of file diff --git a/erpnext/support/desk_page/support/support.json b/erpnext/support/desk_page/support/support.json deleted file mode 100644 index 28410f3a71a..00000000000 --- a/erpnext/support/desk_page/support/support.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Issues", - "links": "[\n {\n \"description\": \"Support queries from customers.\",\n \"label\": \"Issue\",\n \"name\": \"Issue\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Issue Type.\",\n \"label\": \"Issue Type\",\n \"name\": \"Issue Type\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Issue Priority.\",\n \"label\": \"Issue Priority\",\n \"name\": \"Issue Priority\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Maintenance", - "links": "[\n {\n \"label\": \"Maintenance Schedule\",\n \"name\": \"Maintenance Schedule\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Maintenance Visit\",\n \"name\": \"Maintenance Visit\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Service Level Agreement", - "links": "[\n {\n \"description\": \"Service Level Agreement.\",\n \"label\": \"Service Level Agreement\",\n \"name\": \"Service Level Agreement\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Warranty", - "links": "[\n {\n \"description\": \"Warranty Claim against Serial No.\",\n \"label\": \"Warranty Claim\",\n \"name\": \"Warranty Claim\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Single unit of an Item.\",\n \"label\": \"Serial No\",\n \"name\": \"Serial No\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"label\": \"Support Settings\",\n \"name\": \"Support Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Reports", - "links": "[\n {\n \"dependencies\": [\n \"Issue\"\n ],\n \"doctype\": \"Issue\",\n \"is_query_report\": true,\n \"label\": \"First Response Time for Issues\",\n \"name\": \"First Response Time for Issues\",\n \"type\": \"report\"\n }\n]" - } - ], - "category": "Modules", - "charts": [], - "creation": "2020-03-02 15:48:23.224699", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Support", - "modified": "2020-08-11 15:49:34.307341", - "modified_by": "Administrator", - "module": "Support", - "name": "Support", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "color": "#ffc4c4", - "format": "{} Assigned", - "label": "Issue", - "link_to": "Issue", - "stats_filter": "{\n \"_assign\": [\"like\", '%' + frappe.session.user + '%'],\n \"status\": \"Open\"\n}", - "type": "DocType" - }, - { - "label": "Maintenance Visit", - "link_to": "Maintenance Visit", - "type": "DocType" - }, - { - "label": "Service Level Agreement", - "link_to": "Service Level Agreement", - "type": "DocType" - } - ] -} \ No newline at end of file diff --git a/erpnext/support/doctype/issue/issue.js b/erpnext/support/doctype/issue/issue.js index fe01d4b983c..9fe12f9490b 100644 --- a/erpnext/support/doctype/issue/issue.js +++ b/erpnext/support/doctype/issue/issue.js @@ -1,6 +1,13 @@ frappe.ui.form.on("Issue", { onload: function(frm) { frm.email_field = "raised_by"; + frm.set_query("customer", function () { + return { + filters: { + "disabled": 0 + } + }; + }); frappe.db.get_value("Support Settings", {name: "Support Settings"}, ["allow_resetting_service_level_agreement", "track_service_level_agreement"], (r) => { @@ -21,14 +28,14 @@ frappe.ui.form.on("Issue", { }, callback: function (r) { if (r && r.message) { - frm.set_query('priority', function() { + frm.set_query("priority", function() { return { filters: { "name": ["in", r.message.priority], } }; }); - frm.set_query('service_level_agreement', function() { + frm.set_query("service_level_agreement", function() { return { filters: { "name": ["in", r.message.service_level_agreements], @@ -42,12 +49,12 @@ frappe.ui.form.on("Issue", { }, refresh: function (frm) { - if (frm.doc.status !== "Closed" && frm.doc.agreement_status === "Ongoing") { - if (frm.doc.service_level_agreement) { + if (frm.doc.status !== "Closed") { + if (frm.doc.service_level_agreement && frm.doc.agreement_status === "Ongoing") { frappe.call({ - 'method': 'frappe.client.get', + "method": "frappe.client.get", args: { - doctype: 'Service Level Agreement', + doctype: "Service Level Agreement", name: frm.doc.service_level_agreement }, callback: function(data) { @@ -127,8 +134,8 @@ frappe.ui.form.on("Issue", { reset_sla.clear(); frappe.show_alert({ - indicator: 'green', - message: __('Resetting Service Level Agreement.') + indicator: "green", + message: __("Resetting Service Level Agreement.") }); frm.call("reset_service_level_agreement", { @@ -145,56 +152,73 @@ frappe.ui.form.on("Issue", { reset_sla.show(); }, - timeline_refresh: function(frm) { - // create button for "Help Article" - if(frappe.model.can_create('Help Article')) { - // Removing Help Article button if exists to avoid multiple occurance - frm.timeline.wrapper.find('.comment-header .asset-details .btn-add-to-kb').remove(); - $('') - .appendTo(frm.timeline.wrapper.find('.comment-header .asset-details:not([data-communication-type="Comment"])')) - .on('click', function() { - var content = $(this).parents('.timeline-item:first').find('.timeline-item-content').html(); - var doc = frappe.model.get_new_doc('Help Article'); - doc.title = frm.doc.subject; - doc.content = content; - frappe.set_route('Form', 'Help Article', doc.name); - }); - } - if (!frm.timeline.wrapper.find('.btn-split-issue').length) { - let split_issue = __("Split Issue") - $(``) - .appendTo(frm.timeline.wrapper.find('.comment-header .asset-details:not([data-communication-type="Comment"])')) - if (!frm.timeline.wrapper.data("split-issue-event-attached")){ + timeline_refresh: function(frm) { + if (!frm.timeline.wrapper.find(".btn-split-issue").length) { + let split_issue_btn = $(` + + ${frappe.utils.icon('branch', 'sm')} + + `); + + let communication_box = frm.timeline.wrapper.find('.timeline-item[data-doctype="Communication"]'); + communication_box.find('.actions').prepend(split_issue_btn); + + if (!frm.timeline.wrapper.data("split-issue-event-attached")) { frm.timeline.wrapper.on('click', '.btn-split-issue', (e) => { var dialog = new frappe.ui.Dialog({ title: __("Split Issue"), fields: [ - {fieldname: 'subject', fieldtype: 'Data', reqd:1, label: __('Subject'), description: __('All communications including and above this shall be moved into the new Issue')} + { + fieldname: "subject", + fieldtype: "Data", + reqd: 1, + label: __("Subject"), + description: __("All communications including and above this shall be moved into the new Issue") + } ], primary_action_label: __("Split"), - primary_action: function() { + primary_action: () => { frm.call("split_issue", { subject: dialog.fields_dict.subject.value, communication_id: e.currentTarget.closest(".timeline-item").getAttribute("data-name") }, (r) => { - let url = window.location.href - let arr = url.split("/"); - let result = arr[0] + "//" + arr[2] - frappe.msgprint(`New issue created: ${r.message}`) + frappe.msgprint(`New issue created: ${r.message}`); frm.reload_doc(); dialog.hide(); }); } }); - dialog.show() - }) - frm.timeline.wrapper.data("split-issue-event-attached", true) + dialog.show(); + }); + frm.timeline.wrapper.data("split-issue-event-attached", true); } } + + // create button for "Help Article" + // if (frappe.model.can_create("Help Article")) { + // // Removing Help Article button if exists to avoid multiple occurrence + // frm.timeline.wrapper.find('.action-btn .btn-add-to-kb').remove(); + + // let help_article = $(` + // + // ${frappe.utils.icon('solid-info', 'sm')} + // + // `); + + // let communication_box = frm.timeline.wrapper.find('.timeline-item[data-doctype="Communication"]'); + // communication_box.find('.actions').prepend(help_article); + // if (!frm.timeline.wrapper.data("help-article-event-attached")) { + // frm.timeline.wrapper.on('click', '.btn-add-to-kb', function () { + // const content = $(this).parents('.timeline-item[data-doctype="Communication"]:first').find(".content").html(); + // const doc = frappe.model.get_new_doc("Help Article"); + // doc.title = frm.doc.subject; + // doc.content = content; + // frappe.set_route("Form", "Help Article", doc.name); + // }); + // } + // frm.timeline.wrapper.data("help-article-event-attached", true); + // } }, }); @@ -226,7 +250,7 @@ function set_time_to_resolve_and_response(frm) { function get_time_left(timestamp, agreement_status) { const diff = moment(timestamp).diff(moment()); const diff_display = diff >= 44500 ? moment.duration(diff).humanize() : "Failed"; - let indicator = (diff_display == 'Failed' && agreement_status != "Fulfilled") ? "red" : "green"; + let indicator = (diff_display == "Failed" && agreement_status != "Fulfilled") ? "red" : "green"; return {"diff_display": diff_display, "indicator": indicator}; } diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index 920c13c38d6..bbbbc4a5270 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -7,7 +7,7 @@ import json from frappe import _ from frappe import utils from frappe.model.document import Document -from frappe.utils import time_diff_in_hours, now_datetime, getdate, get_weekdays, add_to_date, today, get_time, get_datetime, time_diff_in_seconds, time_diff +from frappe.utils import now_datetime, getdate, get_weekdays, add_to_date, get_time, get_datetime, time_diff_in_seconds from datetime import datetime, timedelta from frappe.model.mapper import get_mapped_doc from frappe.utils.user import is_website_user @@ -207,14 +207,17 @@ class Issue(Document): "comment_type": "Info", "reference_doctype": "Issue", "reference_name": replicated_issue.name, - "content": " - Split the Issue from {1}".format(self.name, frappe.bold(self.name)), + "content": " - Split the Issue from {1}".format(self.name, frappe.bold(self.name)), }).insert(ignore_permissions=True) return replicated_issue.name def before_insert(self): if frappe.db.get_single_value("Support Settings", "track_service_level_agreement"): - self.set_response_and_resolution_time() + if frappe.flags.in_test: + self.set_response_and_resolution_time(priority=self.priority, service_level_agreement=self.service_level_agreement) + else: + self.set_response_and_resolution_time() def set_response_and_resolution_time(self, priority=None, service_level_agreement=None): service_level_agreement = get_active_service_level_agreement_for(priority=priority, @@ -355,13 +358,13 @@ def set_service_level_agreement_variance(issue=None): doc = frappe.get_doc("Issue", issue.name) if not doc.first_responded_on: # first_responded_on set when first reply is sent to customer - variance = round(time_diff_in_hours(doc.response_by, current_time), 2) + variance = round(time_diff_in_seconds(doc.response_by, current_time), 2) frappe.db.set_value(dt="Issue", dn=doc.name, field="response_by_variance", val=variance, update_modified=False) if variance < 0: frappe.db.set_value(dt="Issue", dn=doc.name, field="agreement_status", val="Failed", update_modified=False) if not doc.resolution_date: # resolution_date set when issue has been closed - variance = round(time_diff_in_hours(doc.resolution_by, current_time), 2) + variance = round(time_diff_in_seconds(doc.resolution_by, current_time), 2) frappe.db.set_value(dt="Issue", dn=doc.name, field="resolution_by_variance", val=variance, update_modified=False) if variance < 0: frappe.db.set_value(dt="Issue", dn=doc.name, field="agreement_status", val="Failed", update_modified=False) diff --git a/erpnext/support/doctype/issue/issue_list.js b/erpnext/support/doctype/issue/issue_list.js index 513a8dca222..e04498e29ee 100644 --- a/erpnext/support/doctype/issue/issue_list.js +++ b/erpnext/support/doctype/issue/issue_list.js @@ -28,7 +28,7 @@ frappe.listview_settings['Issue'] = { } else if (doc.status === 'Closed') { return [__(doc.status), "green", "status,=," + doc.status]; } else { - return [__(doc.status), "darkgrey", "status,=," + doc.status]; + return [__(doc.status), "gray", "status,=," + doc.status]; } } } diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index c962dc6b317..46d02d8bf29 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -12,7 +12,6 @@ from datetime import timedelta class TestIssue(unittest.TestCase): def setUp(self): frappe.db.sql("delete from `tabService Level Agreement`") - frappe.db.sql("delete from `tabEmployee`") frappe.db.set_value("Support Settings", None, "track_service_level_agreement", 1) create_service_level_agreements_for_issues() @@ -135,15 +134,19 @@ class TestIssue(unittest.TestCase): self.assertEqual(flt(issue.total_hold_time, 2), 2700) -def make_issue(creation=None, customer=None, index=0): +def make_issue(creation=None, customer=None, index=0, priority=None, issue_type=None): issue = frappe.get_doc({ "doctype": "Issue", "subject": "Service Level Agreement Issue {0}".format(index), "customer": customer, "raised_by": "test@example.com", "description": "Service Level Agreement Issue", + "issue_type": issue_type, + "priority": priority, "creation": creation, - "service_level_agreement_creation": creation + "opening_date": creation, + "service_level_agreement_creation": creation, + "company": "_Test Company" }).insert(ignore_permissions=True) return issue diff --git a/erpnext/support/report/issue_analytics/__init__.py b/erpnext/support/report/issue_analytics/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/support/report/issue_analytics/issue_analytics.js b/erpnext/support/report/issue_analytics/issue_analytics.js new file mode 100644 index 00000000000..f87b2c2dddc --- /dev/null +++ b/erpnext/support/report/issue_analytics/issue_analytics.js @@ -0,0 +1,141 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Issue Analytics"] = { + "filters": [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + fieldname: "based_on", + label: __("Based On"), + fieldtype: "Select", + options: ["Customer", "Issue Type", "Issue Priority", "Assigned To"], + default: "Customer", + reqd: 1 + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.defaults.get_global_default("year_start_date"), + reqd: 1 + }, + { + fieldname:"to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.defaults.get_global_default("year_end_date"), + reqd: 1 + }, + { + fieldname: "range", + label: __("Range"), + fieldtype: "Select", + options: [ + { "value": "Weekly", "label": __("Weekly") }, + { "value": "Monthly", "label": __("Monthly") }, + { "value": "Quarterly", "label": __("Quarterly") }, + { "value": "Yearly", "label": __("Yearly") } + ], + default: "Monthly", + reqd: 1 + }, + { + fieldname: "status", + label: __("Status"), + fieldtype: "Select", + options:[ + {label: __('Open'), value: 'Open'}, + {label: __('Replied'), value: 'Replied'}, + {label: __('Resolved'), value: 'Resolved'}, + {label: __('Closed'), value: 'Closed'} + ] + }, + { + fieldname: "priority", + label: __("Issue Priority"), + fieldtype: "Link", + options: "Issue Priority" + }, + { + fieldname: "customer", + label: __("Customer"), + fieldtype: "Link", + options: "Customer" + }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "Link", + options: "Project" + }, + { + fieldname: "assigned_to", + label: __("Assigned To"), + fieldtype: "Link", + options: "User" + } + ], + after_datatable_render: function(datatable_obj) { + $(datatable_obj.wrapper).find(".dt-row-0").find('input[type=checkbox]').click(); + }, + get_datatable_options(options) { + return Object.assign(options, { + checkboxColumn: true, + events: { + onCheckRow: function(data) { + if (data && data.length) { + row_name = data[2].content; + row_values = data.slice(3).map(function(column) { + return column.content; + }) + entry = { + 'name': row_name, + 'values': row_values + } + + let raw_data = frappe.query_report.chart.data; + let new_datasets = raw_data.datasets; + + var found = false; + + for(var i=0; i < new_datasets.length; i++){ + if (new_datasets[i].name == row_name){ + found = true; + new_datasets.splice(i,1); + break; + } + } + + if (!found){ + new_datasets.push(entry); + } + + let new_data = { + labels: raw_data.labels, + datasets: new_datasets + } + + setTimeout(() => { + frappe.query_report.chart.update(new_data) + },500) + + + setTimeout(() => { + frappe.query_report.chart.draw(true); + }, 1000) + + frappe.query_report.raw_chart_data = new_data; + } + }, + } + }); + } +}; \ No newline at end of file diff --git a/erpnext/support/report/issue_analytics/issue_analytics.json b/erpnext/support/report/issue_analytics/issue_analytics.json new file mode 100644 index 00000000000..dd18498d1da --- /dev/null +++ b/erpnext/support/report/issue_analytics/issue_analytics.json @@ -0,0 +1,26 @@ +{ + "add_total_row": 1, + "columns": [], + "creation": "2020-10-09 19:52:10.227317", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2020-10-11 19:43:19.358625", + "modified_by": "Administrator", + "module": "Support", + "name": "Issue Analytics", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Issue", + "report_name": "Issue Analytics", + "report_type": "Script Report", + "roles": [ + { + "role": "Support Team" + } + ] +} \ No newline at end of file diff --git a/erpnext/support/report/issue_analytics/issue_analytics.py b/erpnext/support/report/issue_analytics/issue_analytics.py new file mode 100644 index 00000000000..3fdb10ddf38 --- /dev/null +++ b/erpnext/support/report/issue_analytics/issue_analytics.py @@ -0,0 +1,221 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import json +from six import iteritems +from frappe import _, scrub +from frappe.utils import getdate, flt, add_to_date, add_days +from erpnext.accounts.utils import get_fiscal_year + +def execute(filters=None): + return IssueAnalytics(filters).run() + +class IssueAnalytics(object): + def __init__(self, filters=None): + """Issue Analytics Report""" + self.filters = frappe._dict(filters or {}) + self.get_period_date_ranges() + + def run(self): + self.get_columns() + self.get_data() + self.get_chart_data() + + return self.columns, self.data, None, self.chart + + def get_columns(self): + self.columns = [] + + if self.filters.based_on == 'Customer': + self.columns.append({ + 'label': _('Customer'), + 'options': 'Customer', + 'fieldname': 'customer', + 'fieldtype': 'Link', + 'width': 200 + }) + + elif self.filters.based_on == 'Assigned To': + self.columns.append({ + 'label': _('User'), + 'fieldname': 'user', + 'fieldtype': 'Link', + 'options': 'User', + 'width': 200 + }) + + elif self.filters.based_on == 'Issue Type': + self.columns.append({ + 'label': _('Issue Type'), + 'fieldname': 'issue_type', + 'fieldtype': 'Link', + 'options': 'Issue Type', + 'width': 200 + }) + + elif self.filters.based_on == 'Issue Priority': + self.columns.append({ + 'label': _('Issue Priority'), + 'fieldname': 'priority', + 'fieldtype': 'Link', + 'options': 'Issue Priority', + 'width': 200 + }) + + for end_date in self.periodic_daterange: + period = self.get_period(end_date) + self.columns.append({ + 'label': _(period), + 'fieldname': scrub(period), + 'fieldtype': 'Int', + 'width': 120 + }) + + self.columns.append({ + 'label': _('Total'), + 'fieldname': 'total', + 'fieldtype': 'Int', + 'width': 120 + }) + + def get_data(self): + self.get_issues() + self.get_rows() + + def get_period(self, date): + months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + + if self.filters.range == 'Weekly': + period = 'Week ' + str(date.isocalendar()[1]) + elif self.filters.range == 'Monthly': + period = str(months[date.month - 1]) + elif self.filters.range == 'Quarterly': + period = 'Quarter ' + str(((date.month - 1) // 3) + 1) + else: + year = get_fiscal_year(date, self.filters.company) + period = str(year[0]) + + if getdate(self.filters.from_date).year != getdate(self.filters.to_date).year and self.filters.range != 'Yearly': + period += ' ' + str(date.year) + + return period + + def get_period_date_ranges(self): + from dateutil.relativedelta import relativedelta, MO + from_date, to_date = getdate(self.filters.from_date), getdate(self.filters.to_date) + + increment = { + 'Monthly': 1, + 'Quarterly': 3, + 'Half-Yearly': 6, + 'Yearly': 12 + }.get(self.filters.range, 1) + + if self.filters.range in ['Monthly', 'Quarterly']: + from_date = from_date.replace(day=1) + elif self.filters.range == 'Yearly': + from_date = get_fiscal_year(from_date)[1] + else: + from_date = from_date + relativedelta(from_date, weekday=MO(-1)) + + self.periodic_daterange = [] + for dummy in range(1, 53): + if self.filters.range == 'Weekly': + period_end_date = add_days(from_date, 6) + else: + period_end_date = add_to_date(from_date, months=increment, days=-1) + + if period_end_date > to_date: + period_end_date = to_date + + self.periodic_daterange.append(period_end_date) + + from_date = add_days(period_end_date, 1) + if period_end_date == to_date: + break + + def get_issues(self): + filters = self.get_common_filters() + self.field_map = { + 'Customer': 'customer', + 'Issue Type': 'issue_type', + 'Issue Priority': 'priority', + 'Assigned To': '_assign' + } + + self.entries = frappe.db.get_all('Issue', + fields=[self.field_map.get(self.filters.based_on), 'name', 'opening_date'], + filters=filters + ) + + def get_common_filters(self): + filters = {} + filters['opening_date'] = ('between', [self.filters.from_date, self.filters.to_date]) + + if self.filters.get('assigned_to'): + filters['_assign'] = ('like', '%' + self.filters.get('assigned_to') + '%') + + for entry in ['company', 'status', 'priority', 'customer', 'project']: + if self.filters.get(entry): + filters[entry] = self.filters.get(entry) + + return filters + + def get_rows(self): + self.data = [] + self.get_periodic_data() + + for entity, period_data in iteritems(self.issue_periodic_data): + if self.filters.based_on == 'Customer': + row = {'customer': entity} + elif self.filters.based_on == 'Assigned To': + row = {'user': entity} + elif self.filters.based_on == 'Issue Type': + row = {'issue_type': entity} + elif self.filters.based_on == 'Issue Priority': + row = {'priority': entity} + + total = 0 + for end_date in self.periodic_daterange: + period = self.get_period(end_date) + amount = flt(period_data.get(period, 0.0)) + row[scrub(period)] = amount + total += amount + + row['total'] = total + + self.data.append(row) + + def get_periodic_data(self): + self.issue_periodic_data = frappe._dict() + + for d in self.entries: + period = self.get_period(d.get('opening_date')) + + if self.filters.based_on == 'Assigned To': + if d._assign: + for entry in json.loads(d._assign): + self.issue_periodic_data.setdefault(entry, frappe._dict()).setdefault(period, 0.0) + self.issue_periodic_data[entry][period] += 1 + + else: + field = self.field_map.get(self.filters.based_on) + value = d.get(field) + if not value: + value = _('Not Specified') + + self.issue_periodic_data.setdefault(value, frappe._dict()).setdefault(period, 0.0) + self.issue_periodic_data[value][period] += 1 + + def get_chart_data(self): + length = len(self.columns) + labels = [d.get('label') for d in self.columns[1:length-1]] + self.chart = { + 'data': { + 'labels': labels, + 'datasets': [] + }, + 'type': 'line' + } \ No newline at end of file diff --git a/erpnext/support/report/issue_analytics/test_issue_analytics.py b/erpnext/support/report/issue_analytics/test_issue_analytics.py new file mode 100644 index 00000000000..77483198ecc --- /dev/null +++ b/erpnext/support/report/issue_analytics/test_issue_analytics.py @@ -0,0 +1,214 @@ +from __future__ import unicode_literals +import unittest +import frappe +from frappe.utils import getdate, add_months +from erpnext.support.report.issue_analytics.issue_analytics import execute +from erpnext.support.doctype.issue.test_issue import make_issue, create_customer +from erpnext.support.doctype.service_level_agreement.test_service_level_agreement import create_service_level_agreements_for_issues +from frappe.desk.form.assign_to import add as add_assignment + +months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + +class TestIssueAnalytics(unittest.TestCase): + @classmethod + def setUpClass(self): + frappe.db.sql("delete from `tabIssue` where company='_Test Company'") + frappe.db.set_value("Support Settings", None, "track_service_level_agreement", 1) + + current_month_date = getdate() + last_month_date = add_months(current_month_date, -1) + self.current_month = str(months[current_month_date.month - 1]).lower() + self.last_month = str(months[last_month_date.month - 1]).lower() + if current_month_date.year != last_month_date.year: + self.current_month += '_' + str(current_month_date.year) + self.last_month += '_' + str(last_month_date.year) + + def test_issue_analytics(self): + create_service_level_agreements_for_issues() + create_issue_types() + create_records() + + self.compare_result_for_customer() + self.compare_result_for_issue_type() + self.compare_result_for_issue_priority() + self.compare_result_for_assignment() + + def compare_result_for_customer(self): + filters = { + 'company': '_Test Company', + 'based_on': 'Customer', + 'from_date': add_months(getdate(), -1), + 'to_date': getdate(), + 'range': 'Monthly' + } + + report = execute(filters) + + expected_data = [ + { + 'customer': '__Test Customer 2', + self.last_month: 1.0, + self.current_month: 0.0, + 'total': 1.0 + }, + { + 'customer': '__Test Customer 1', + self.last_month: 0.0, + self.current_month: 1.0, + 'total': 1.0 + }, + { + 'customer': '__Test Customer', + self.last_month: 1.0, + self.current_month: 1.0, + 'total': 2.0 + } + ] + + self.assertEqual(expected_data, report[1]) # rows + self.assertEqual(len(report[0]), 4) # cols + + def compare_result_for_issue_type(self): + filters = { + 'company': '_Test Company', + 'based_on': 'Issue Type', + 'from_date': add_months(getdate(), -1), + 'to_date': getdate(), + 'range': 'Monthly' + } + + report = execute(filters) + + expected_data = [ + { + 'issue_type': 'Discomfort', + self.last_month: 1.0, + self.current_month: 0.0, + 'total': 1.0 + }, + { + 'issue_type': 'Service Request', + self.last_month: 0.0, + self.current_month: 1.0, + 'total': 1.0 + }, + { + 'issue_type': 'Bug', + self.last_month: 1.0, + self.current_month: 1.0, + 'total': 2.0 + } + ] + + self.assertEqual(expected_data, report[1]) # rows + self.assertEqual(len(report[0]), 4) # cols + + def compare_result_for_issue_priority(self): + filters = { + 'company': '_Test Company', + 'based_on': 'Issue Priority', + 'from_date': add_months(getdate(), -1), + 'to_date': getdate(), + 'range': 'Monthly' + } + + report = execute(filters) + + expected_data = [ + { + 'priority': 'Medium', + self.last_month: 1.0, + self.current_month: 1.0, + 'total': 2.0 + }, + { + 'priority': 'Low', + self.last_month: 1.0, + self.current_month: 0.0, + 'total': 1.0 + }, + { + 'priority': 'High', + self.last_month: 0.0, + self.current_month: 1.0, + 'total': 1.0 + } + ] + + self.assertEqual(expected_data, report[1]) # rows + self.assertEqual(len(report[0]), 4) # cols + + def compare_result_for_assignment(self): + filters = { + 'company': '_Test Company', + 'based_on': 'Assigned To', + 'from_date': add_months(getdate(), -1), + 'to_date': getdate(), + 'range': 'Monthly' + } + + report = execute(filters) + + expected_data = [ + { + 'user': 'test@example.com', + self.last_month: 1.0, + self.current_month: 1.0, + 'total': 2.0 + }, + { + 'user': 'test1@example.com', + self.last_month: 2.0, + self.current_month: 1.0, + 'total': 3.0 + } + ] + + self.assertEqual(expected_data, report[1]) # rows + self.assertEqual(len(report[0]), 4) # cols + + +def create_issue_types(): + for entry in ['Bug', 'Service Request', 'Discomfort']: + if not frappe.db.exists('Issue Type', entry): + frappe.get_doc({ + 'doctype': 'Issue Type', + '__newname': entry + }).insert() + + +def create_records(): + create_customer("__Test Customer", "_Test SLA Customer Group", "__Test SLA Territory") + create_customer("__Test Customer 1", "_Test SLA Customer Group", "__Test SLA Territory") + create_customer("__Test Customer 2", "_Test SLA Customer Group", "__Test SLA Territory") + + current_month_date = getdate() + last_month_date = add_months(current_month_date, -1) + + issue = make_issue(current_month_date, "__Test Customer", 2, "High", "Bug") + add_assignment({ + "assign_to": ["test@example.com"], + "doctype": "Issue", + "name": issue.name + }) + + issue = make_issue(last_month_date, "__Test Customer", 2, "Low", "Bug") + add_assignment({ + "assign_to": ["test1@example.com"], + "doctype": "Issue", + "name": issue.name + }) + + issue = make_issue(current_month_date, "__Test Customer 1", 2, "Medium", "Service Request") + add_assignment({ + "assign_to": ["test1@example.com"], + "doctype": "Issue", + "name": issue.name + }) + + issue = make_issue(last_month_date, "__Test Customer 2", 2, "Medium", "Discomfort") + add_assignment({ + "assign_to": ["test@example.com", "test1@example.com"], + "doctype": "Issue", + "name": issue.name + }) \ No newline at end of file diff --git a/erpnext/support/report/issue_summary/__init__.py b/erpnext/support/report/issue_summary/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/support/report/issue_summary/issue_summary.js b/erpnext/support/report/issue_summary/issue_summary.js new file mode 100644 index 00000000000..684482ac8d2 --- /dev/null +++ b/erpnext/support/report/issue_summary/issue_summary.js @@ -0,0 +1,73 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Issue Summary"] = { + "filters": [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + fieldname: "based_on", + label: __("Based On"), + fieldtype: "Select", + options: ["Customer", "Issue Type", "Issue Priority", "Assigned To"], + default: "Customer", + reqd: 1 + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.defaults.get_global_default("year_start_date"), + reqd: 1 + }, + { + fieldname:"to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.defaults.get_global_default("year_end_date"), + reqd: 1 + }, + { + fieldname: "status", + label: __("Status"), + fieldtype: "Select", + options:[ + {label: __('Open'), value: 'Open'}, + {label: __('Replied'), value: 'Replied'}, + {label: __('Resolved'), value: 'Resolved'}, + {label: __('Closed'), value: 'Closed'} + ] + }, + { + fieldname: "priority", + label: __("Issue Priority"), + fieldtype: "Link", + options: "Issue Priority" + }, + { + fieldname: "customer", + label: __("Customer"), + fieldtype: "Link", + options: "Customer" + }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "Link", + options: "Project" + }, + { + fieldname: "assigned_to", + label: __("Assigned To"), + fieldtype: "Link", + options: "User" + } + ] +}; \ No newline at end of file diff --git a/erpnext/support/report/issue_summary/issue_summary.json b/erpnext/support/report/issue_summary/issue_summary.json new file mode 100644 index 00000000000..b8a580ccef1 --- /dev/null +++ b/erpnext/support/report/issue_summary/issue_summary.json @@ -0,0 +1,26 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2020-10-12 01:01:55.181777", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2020-10-12 14:54:55.655920", + "modified_by": "Administrator", + "module": "Support", + "name": "Issue Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Issue", + "report_name": "Issue Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "Support Team" + } + ] +} \ No newline at end of file diff --git a/erpnext/support/report/issue_summary/issue_summary.py b/erpnext/support/report/issue_summary/issue_summary.py new file mode 100644 index 00000000000..7861e30d252 --- /dev/null +++ b/erpnext/support/report/issue_summary/issue_summary.py @@ -0,0 +1,351 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import json +from six import iteritems +from frappe import _, scrub +from frappe.utils import flt + +def execute(filters=None): + return IssueSummary(filters).run() + +class IssueSummary(object): + def __init__(self, filters=None): + self.filters = frappe._dict(filters or {}) + + def run(self): + self.get_columns() + self.get_data() + self.get_chart_data() + self.get_report_summary() + + return self.columns, self.data, None, self.chart, self.report_summary + + def get_columns(self): + self.columns = [] + + if self.filters.based_on == 'Customer': + self.columns.append({ + 'label': _('Customer'), + 'options': 'Customer', + 'fieldname': 'customer', + 'fieldtype': 'Link', + 'width': 200 + }) + + elif self.filters.based_on == 'Assigned To': + self.columns.append({ + 'label': _('User'), + 'fieldname': 'user', + 'fieldtype': 'Link', + 'options': 'User', + 'width': 200 + }) + + elif self.filters.based_on == 'Issue Type': + self.columns.append({ + 'label': _('Issue Type'), + 'fieldname': 'issue_type', + 'fieldtype': 'Link', + 'options': 'Issue Type', + 'width': 200 + }) + + elif self.filters.based_on == 'Issue Priority': + self.columns.append({ + 'label': _('Issue Priority'), + 'fieldname': 'priority', + 'fieldtype': 'Link', + 'options': 'Issue Priority', + 'width': 200 + }) + + self.statuses = ['Open', 'Replied', 'Resolved', 'Closed'] + for status in self.statuses: + self.columns.append({ + 'label': _(status), + 'fieldname': scrub(status), + 'fieldtype': 'Int', + 'width': 80 + }) + + self.columns.append({ + 'label': _('Total Issues'), + 'fieldname': 'total_issues', + 'fieldtype': 'Int', + 'width': 100 + }) + + self.sla_status_map = { + 'SLA Failed': 'failed', + 'SLA Fulfilled': 'fulfilled', + 'SLA Ongoing': 'ongoing' + } + + for label, fieldname in self.sla_status_map.items(): + self.columns.append({ + 'label': _(label), + 'fieldname': fieldname, + 'fieldtype': 'Int', + 'width': 100 + }) + + self.metrics = ['Avg First Response Time', 'Avg Response Time', 'Avg Hold Time', + 'Avg Resolution Time', 'Avg User Resolution Time'] + + for metric in self.metrics: + self.columns.append({ + 'label': _(metric), + 'fieldname': scrub(metric), + 'fieldtype': 'Duration', + 'width': 170 + }) + + def get_data(self): + self.get_issues() + self.get_rows() + + def get_issues(self): + filters = self.get_common_filters() + self.field_map = { + 'Customer': 'customer', + 'Issue Type': 'issue_type', + 'Issue Priority': 'priority', + 'Assigned To': '_assign' + } + + self.entries = frappe.db.get_all('Issue', + fields=[self.field_map.get(self.filters.based_on), 'name', 'opening_date', 'status', 'avg_response_time', + 'first_response_time', 'total_hold_time', 'user_resolution_time', 'resolution_time', 'agreement_status'], + filters=filters + ) + + def get_common_filters(self): + filters = {} + filters['opening_date'] = ('between', [self.filters.from_date, self.filters.to_date]) + + if self.filters.get('assigned_to'): + filters['_assign'] = ('like', '%' + self.filters.get('assigned_to') + '%') + + for entry in ['company', 'status', 'priority', 'customer', 'project']: + if self.filters.get(entry): + filters[entry] = self.filters.get(entry) + + return filters + + def get_rows(self): + self.data = [] + self.get_summary_data() + + for entity, data in iteritems(self.issue_summary_data): + if self.filters.based_on == 'Customer': + row = {'customer': entity} + elif self.filters.based_on == 'Assigned To': + row = {'user': entity} + elif self.filters.based_on == 'Issue Type': + row = {'issue_type': entity} + elif self.filters.based_on == 'Issue Priority': + row = {'priority': entity} + + for status in self.statuses: + count = flt(data.get(status, 0.0)) + row[scrub(status)] = count + + row['total_issues'] = data.get('total_issues', 0.0) + + for sla_status in self.sla_status_map.values(): + value = flt(data.get(sla_status), 0.0) + row[sla_status] = value + + for metric in self.metrics: + value = flt(data.get(scrub(metric)), 0.0) + row[scrub(metric)] = value + + self.data.append(row) + + def get_summary_data(self): + self.issue_summary_data = frappe._dict() + + for d in self.entries: + status = d.status + agreement_status = scrub(d.agreement_status) + + if self.filters.based_on == 'Assigned To': + if d._assign: + for entry in json.loads(d._assign): + self.issue_summary_data.setdefault(entry, frappe._dict()).setdefault(status, 0.0) + self.issue_summary_data.setdefault(entry, frappe._dict()).setdefault(agreement_status, 0.0) + self.issue_summary_data.setdefault(entry, frappe._dict()).setdefault('total_issues', 0.0) + self.issue_summary_data[entry][status] += 1 + self.issue_summary_data[entry][agreement_status] += 1 + self.issue_summary_data[entry]['total_issues'] += 1 + + else: + field = self.field_map.get(self.filters.based_on) + value = d.get(field) + if not value: + value = _('Not Specified') + + self.issue_summary_data.setdefault(value, frappe._dict()).setdefault(status, 0.0) + self.issue_summary_data.setdefault(value, frappe._dict()).setdefault(agreement_status, 0.0) + self.issue_summary_data.setdefault(value, frappe._dict()).setdefault('total_issues', 0.0) + self.issue_summary_data[value][status] += 1 + self.issue_summary_data[value][agreement_status] += 1 + self.issue_summary_data[value]['total_issues'] += 1 + + self.get_metrics_data() + + def get_metrics_data(self): + issues = [] + + metrics_list = ['avg_response_time', 'avg_first_response_time', 'avg_hold_time', + 'avg_resolution_time', 'avg_user_resolution_time'] + + for entry in self.entries: + issues.append(entry.name) + + field = self.field_map.get(self.filters.based_on) + + if issues: + if self.filters.based_on == 'Assigned To': + assignment_map = frappe._dict() + for d in self.entries: + if d._assign: + for entry in json.loads(d._assign): + for metric in metrics_list: + self.issue_summary_data.setdefault(entry, frappe._dict()).setdefault(metric, 0.0) + + self.issue_summary_data[entry]['avg_response_time'] += d.get('avg_response_time') or 0.0 + self.issue_summary_data[entry]['avg_first_response_time'] += d.get('first_response_time') or 0.0 + self.issue_summary_data[entry]['avg_hold_time'] += d.get('total_hold_time') or 0.0 + self.issue_summary_data[entry]['avg_resolution_time'] += d.get('resolution_time') or 0.0 + self.issue_summary_data[entry]['avg_user_resolution_time'] += d.get('user_resolution_time') or 0.0 + + if not assignment_map.get(entry): + assignment_map[entry] = 0 + assignment_map[entry] += 1 + + for entry in assignment_map: + for metric in metrics_list: + self.issue_summary_data[entry][metric] /= flt(assignment_map.get(entry)) + + else: + data = frappe.db.sql(""" + SELECT + {0}, AVG(first_response_time) as avg_frt, + AVG(avg_response_time) as avg_resp_time, + AVG(total_hold_time) as avg_hold_time, + AVG(resolution_time) as avg_resolution_time, + AVG(user_resolution_time) as avg_user_resolution_time + FROM `tabIssue` + WHERE + name IN %(issues)s + GROUP BY {0} + """.format(field), {'issues': issues}, as_dict=1) + + for entry in data: + value = entry.get(field) + if not value: + value = _('Not Specified') + + for metric in metrics_list: + self.issue_summary_data.setdefault(value, frappe._dict()).setdefault(metric, 0.0) + + self.issue_summary_data[value]['avg_response_time'] = entry.get('avg_resp_time') or 0.0 + self.issue_summary_data[value]['avg_first_response_time'] = entry.get('avg_frt') or 0.0 + self.issue_summary_data[value]['avg_hold_time'] = entry.get('avg_hold_time') or 0.0 + self.issue_summary_data[value]['avg_resolution_time'] = entry.get('avg_resolution_time') or 0.0 + self.issue_summary_data[value]['avg_user_resolution_time'] = entry.get('avg_user_resolution_time') or 0.0 + + def get_chart_data(self): + self.chart = [] + + labels = [] + open_issues = [] + replied_issues = [] + resolved_issues = [] + closed_issues = [] + + entity = self.filters.based_on + entity_field = self.field_map.get(entity) + if entity == 'Assigned To': + entity_field = 'user' + + for entry in self.data: + labels.append(entry.get(entity_field)) + open_issues.append(entry.get('open')) + replied_issues.append(entry.get('replied')) + resolved_issues.append(entry.get('resolved')) + closed_issues.append(entry.get('closed')) + + self.chart = { + 'data': { + 'labels': labels[:30], + 'datasets': [ + { + 'name': 'Open', + 'values': open_issues[:30] + }, + { + 'name': 'Replied', + 'values': replied_issues[:30] + }, + { + 'name': 'Resolved', + 'values': resolved_issues[:30] + }, + { + 'name': 'Closed', + 'values': closed_issues[:30] + } + ] + }, + 'type': 'bar', + 'barOptions': { + 'stacked': True + } + } + + def get_report_summary(self): + self.report_summary = [] + + open_issues = 0 + replied = 0 + resolved = 0 + closed = 0 + + for entry in self.data: + open_issues += entry.get('open') + replied += entry.get('replied') + resolved += entry.get('resolved') + closed += entry.get('closed') + + self.report_summary = [ + { + 'value': open_issues, + 'indicator': 'Red', + 'label': _('Open'), + 'datatype': 'Int', + }, + { + 'value': replied, + 'indicator': 'Grey', + 'label': _('Replied'), + 'datatype': 'Int', + }, + { + 'value': resolved, + 'indicator': 'Green', + 'label': _('Resolved'), + 'datatype': 'Int', + }, + { + 'value': closed, + 'indicator': 'Green', + 'label': _('Closed'), + 'datatype': 'Int', + } + ] + diff --git a/erpnext/support/workspace/support/support.json b/erpnext/support/workspace/support/support.json new file mode 100644 index 00000000000..01a8676f05d --- /dev/null +++ b/erpnext/support/workspace/support/support.json @@ -0,0 +1,186 @@ +{ + "category": "Modules", + "charts": [], + "creation": "2020-03-02 15:48:23.224699", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "support", + "idx": 0, + "is_standard": 1, + "label": "Support", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Issues", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Issue", + "link_to": "Issue", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Issue Type", + "link_to": "Issue Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Issue Priority", + "link_to": "Issue Priority", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Maintenance", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Maintenance Schedule", + "link_to": "Maintenance Schedule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Maintenance Visit", + "link_to": "Maintenance Visit", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Service Level Agreement", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Service Level Agreement", + "link_to": "Service Level Agreement", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Warranty", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Warranty Claim", + "link_to": "Warranty Claim", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Serial No", + "link_to": "Serial No", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Support Settings", + "link_to": "Support Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Issue", + "hidden": 0, + "is_query_report": 1, + "label": "First Response Time for Issues", + "link_to": "First Response Time for Issues", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:37.073482", + "modified_by": "Administrator", + "module": "Support", + "name": "Support", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "color": "Yellow", + "format": "{} Assigned", + "label": "Issue", + "link_to": "Issue", + "stats_filter": "{\n \"_assign\": [\"like\", '%' + frappe.session.user + '%'],\n \"status\": \"Open\"\n}", + "type": "DocType" + }, + { + "label": "Maintenance Visit", + "link_to": "Maintenance Visit", + "type": "DocType" + }, + { + "label": "Service Level Agreement", + "link_to": "Service Level Agreement", + "type": "DocType" + } + ] +} \ No newline at end of file diff --git a/erpnext/telephony/__init__.py b/erpnext/telephony/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/telephony/doctype/__init__.py b/erpnext/telephony/doctype/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/telephony/doctype/call_log/__init__.py b/erpnext/telephony/doctype/call_log/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/telephony/doctype/call_log/call_log.js b/erpnext/telephony/doctype/call_log/call_log.js new file mode 100644 index 00000000000..e7afa0b7d09 --- /dev/null +++ b/erpnext/telephony/doctype/call_log/call_log.js @@ -0,0 +1,27 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Call Log', { + refresh: function(frm) { + frm.events.setup_recording_audio_control(frm); + const incoming_call = frm.doc.type == 'Incoming'; + frm.add_custom_button(incoming_call ? __('Callback'): __('Call Again'), () => { + const number = incoming_call ? frm.doc.from : frm.doc.to; + frappe.phone_call.handler(number, frm); + }); + }, + setup_recording_audio_control(frm) { + const recording_wrapper = frm.get_field('recording_html').$wrapper; + if (!frm.doc.recording_url || frm.doc.recording_url == 'null') { + recording_wrapper.empty(); + } else { + recording_wrapper.addClass('input-max-width'); + recording_wrapper.html(` + + `); + } + } +}); diff --git a/erpnext/communication/doctype/call_log/call_log.json b/erpnext/telephony/doctype/call_log/call_log.json similarity index 60% rename from erpnext/communication/doctype/call_log/call_log.json rename to erpnext/telephony/doctype/call_log/call_log.json index 31e79f17cd2..1d6c39edf6e 100644 --- a/erpnext/communication/doctype/call_log/call_log.json +++ b/erpnext/telephony/doctype/call_log/call_log.json @@ -5,34 +5,26 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "call_details_section", "id", "from", "to", - "column_break_3", - "received_by", "medium", - "caller_information", - "contact", - "contact_name", - "column_break_10", + "start_time", + "end_time", + "column_break_4", + "type", "customer", - "lead", - "lead_name", - "section_break_5", "status", "duration", - "recording_url" + "recording_url", + "recording_html", + "section_break_11", + "summary", + "section_break_19", + "links" ], "fields": [ - { - "fieldname": "column_break_3", - "fieldtype": "Column Break" - }, - { - "fieldname": "section_break_5", - "fieldtype": "Section Break", - "label": "Call Details" - }, { "fieldname": "id", "fieldtype": "Data", @@ -50,6 +42,7 @@ { "fieldname": "to", "fieldtype": "Data", + "in_list_view": 1, "label": "To", "read_only": 1 }, @@ -58,13 +51,13 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Status", - "options": "Ringing\nIn Progress\nCompleted\nMissed", + "options": "Ringing\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled", "read_only": 1 }, { "description": "Call Duration in seconds", "fieldname": "duration", - "fieldtype": "Int", + "fieldtype": "Duration", "in_list_view": 1, "label": "Duration", "read_only": 1 @@ -72,8 +65,8 @@ { "fieldname": "recording_url", "fieldtype": "Data", - "label": "Recording URL", - "read_only": 1 + "hidden": 1, + "label": "Recording URL" }, { "fieldname": "medium", @@ -82,51 +75,52 @@ "read_only": 1 }, { - "fieldname": "received_by", - "fieldtype": "Link", - "label": "Received By", - "options": "Employee", + "fieldname": "type", + "fieldtype": "Select", + "label": "Type", + "options": "Incoming\nOutgoing", "read_only": 1 }, { - "fieldname": "caller_information", + "fieldname": "recording_html", + "fieldtype": "HTML", + "label": "Recording HTML" + }, + { + "fieldname": "section_break_19", "fieldtype": "Section Break", - "label": "Caller Information" + "label": "Reference" }, { - "fieldname": "contact", - "fieldtype": "Link", - "label": "Contact", - "options": "Contact", - "read_only": 1 + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "Dynamic Link" }, { - "fieldname": "lead", - "fieldtype": "Link", - "label": "Lead ", - "options": "Lead", - "read_only": 1 - }, - { - "fetch_from": "contact.name", - "fieldname": "contact_name", - "fieldtype": "Data", - "hidden": 1, - "in_list_view": 1, - "label": "Contact Name", - "read_only": 1 - }, - { - "fieldname": "column_break_10", + "fieldname": "column_break_4", "fieldtype": "Column Break" }, { - "fetch_from": "lead.lead_name", - "fieldname": "lead_name", - "fieldtype": "Data", - "hidden": 1, - "in_list_view": 1, - "label": "Lead Name", + "fieldname": "summary", + "fieldtype": "Small Text" + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break", + "hide_border": 1, + "label": "Call Summary" + }, + { + "fieldname": "start_time", + "fieldtype": "Datetime", + "label": "Start Time", + "read_only": 1 + }, + { + "fieldname": "end_time", + "fieldtype": "Datetime", + "label": "End Time", "read_only": 1 }, { @@ -135,14 +129,19 @@ "label": "Customer", "options": "Customer", "read_only": 1 + }, + { + "fieldname": "call_details_section", + "fieldtype": "Section Break", + "label": "Call Details" } ], "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-08-25 17:08:34.085731", + "modified": "2021-02-08 14:23:28.744844", "modified_by": "Administrator", - "module": "Communication", + "module": "Telephony", "name": "Call Log", "owner": "Administrator", "permissions": [ @@ -163,8 +162,8 @@ "role": "Employee" } ], - "sort_field": "modified", - "sort_order": "ASC", + "sort_field": "creation", + "sort_order": "DESC", "title_field": "from", "track_changes": 1, "track_views": 1 diff --git a/erpnext/telephony/doctype/call_log/call_log.py b/erpnext/telephony/doctype/call_log/call_log.py new file mode 100644 index 00000000000..4d553df08b8 --- /dev/null +++ b/erpnext/telephony/doctype/call_log/call_log.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.model.document import Document +from erpnext.crm.doctype.utils import get_scheduled_employees_for_popup, strip_number +from frappe.contacts.doctype.contact.contact import get_contact_with_phone_number +from frappe.core.doctype.dynamic_link.dynamic_link import deduplicate_dynamic_links + +from erpnext.crm.doctype.lead.lead import get_lead_with_phone_number + +END_CALL_STATUSES = ['No Answer', 'Completed', 'Busy', 'Failed'] +ONGOING_CALL_STATUSES = ['Ringing', 'In Progress'] + + +class CallLog(Document): + def validate(self): + deduplicate_dynamic_links(self) + + def before_insert(self): + """Add lead(third party person) links to the document. + """ + lead_number = self.get('from') if self.is_incoming_call() else self.get('to') + lead_number = strip_number(lead_number) + + contact = get_contact_with_phone_number(strip_number(lead_number)) + if contact: + self.add_link(link_type='Contact', link_name=contact) + + lead = get_lead_with_phone_number(lead_number) + if lead: + self.add_link(link_type='Lead', link_name=lead) + + def after_insert(self): + self.trigger_call_popup() + + def on_update(self): + def _is_call_missed(doc_before_save, doc_after_save): + # FIXME: This works for Exotel but not for all telepony providers + return doc_before_save.to != doc_after_save.to and doc_after_save.status not in END_CALL_STATUSES + + def _is_call_ended(doc_before_save, doc_after_save): + return doc_before_save.status not in END_CALL_STATUSES and self.status in END_CALL_STATUSES + + doc_before_save = self.get_doc_before_save() + if not doc_before_save: return + + if _is_call_missed(doc_before_save, self): + frappe.publish_realtime('call_{id}_missed'.format(id=self.id), self) + self.trigger_call_popup() + + if _is_call_ended(doc_before_save, self): + frappe.publish_realtime('call_{id}_ended'.format(id=self.id), self) + + def is_incoming_call(self): + return self.type == 'Incoming' + + def add_link(self, link_type, link_name): + self.append('links', { + 'link_doctype': link_type, + 'link_name': link_name + }) + + def trigger_call_popup(self): + if self.is_incoming_call(): + scheduled_employees = get_scheduled_employees_for_popup(self.medium) + employee_emails = get_employees_with_number(self.to) + + # check if employees with matched number are scheduled to receive popup + emails = set(scheduled_employees).intersection(employee_emails) + + if frappe.conf.developer_mode: + self.add_comment(text=f""" + Scheduled Employees: {scheduled_employees} + Matching Employee: {employee_emails} + Show Popup To: {emails} + """) + + if employee_emails and not emails: + self.add_comment(text=_("No employee was scheduled for call popup")) + + for email in emails: + frappe.publish_realtime('show_call_popup', self, user=email) + + +@frappe.whitelist() +def add_call_summary(call_log, summary): + doc = frappe.get_doc('Call Log', call_log) + doc.add_comment('Comment', frappe.bold(_('Call Summary')) + '

    ' + summary) + +def get_employees_with_number(number): + number = strip_number(number) + if not number: return [] + + employee_emails = frappe.cache().hget('employees_with_number', number) + if employee_emails: return employee_emails + + employees = frappe.get_all('Employee', filters={ + 'cell_number': ['like', '%{}%'.format(number)], + 'user_id': ['!=', ''] + }, fields=['user_id']) + + employee_emails = [employee.user_id for employee in employees] + frappe.cache().hset('employees_with_number', number, employee_emails) + + return employee_emails + +def link_existing_conversations(doc, state): + """ + Called from hooks on creation of Contact or Lead to link all the existing conversations. + """ + if doc.doctype != 'Contact': return + try: + numbers = [d.phone for d in doc.phone_nos] + + for number in numbers: + number = strip_number(number) + if not number: continue + logs = frappe.db.sql_list(""" + SELECT cl.name FROM `tabCall Log` cl + LEFT JOIN `tabDynamic Link` dl + ON cl.name = dl.parent + WHERE (cl.`from` like %(phone_number)s or cl.`to` like %(phone_number)s) + GROUP BY cl.name + HAVING SUM( + CASE + WHEN dl.link_doctype = %(doctype)s AND dl.link_name = %(docname)s + THEN 1 + ELSE 0 + END + )=0 + """, dict( + phone_number='%{}'.format(number), + docname=doc.name, + doctype = doc.doctype + ) + ) + + for log in logs: + call_log = frappe.get_doc('Call Log', log) + call_log.add_link(link_type=doc.doctype, link_name=doc.name) + call_log.save() + frappe.db.commit() + except Exception: + frappe.log_error(title=_('Error during caller information update')) + +def get_linked_call_logs(doctype, docname): + # content will be shown in timeline + logs = frappe.get_all('Dynamic Link', fields=['parent'], filters={ + 'parenttype': 'Call Log', + 'link_doctype': doctype, + 'link_name': docname + }) + + logs = set([log.parent for log in logs]) + + logs = frappe.get_all('Call Log', fields=['*'], filters={ + 'name': ['in', logs] + }) + + timeline_contents = [] + for log in logs: + log.show_call_button = 0 + timeline_contents.append({ + 'icon': 'call', + 'is_card': True, + 'creation': log.creation, + 'template': 'call_link', + 'template_data': log + }) + + return timeline_contents + diff --git a/erpnext/telephony/doctype/call_log/test_call_log.py b/erpnext/telephony/doctype/call_log/test_call_log.py new file mode 100644 index 00000000000..faa63041ba3 --- /dev/null +++ b/erpnext/telephony/doctype/call_log/test_call_log.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestCallLog(unittest.TestCase): + pass diff --git a/erpnext/telephony/doctype/incoming_call_handling_schedule/__init__.py b/erpnext/telephony/doctype/incoming_call_handling_schedule/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/telephony/doctype/incoming_call_handling_schedule/incoming_call_handling_schedule.json b/erpnext/telephony/doctype/incoming_call_handling_schedule/incoming_call_handling_schedule.json new file mode 100644 index 00000000000..6d46b4e2cdb --- /dev/null +++ b/erpnext/telephony/doctype/incoming_call_handling_schedule/incoming_call_handling_schedule.json @@ -0,0 +1,60 @@ +{ + "actions": [], + "creation": "2020-11-19 11:15:54.967710", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "day_of_week", + "from_time", + "to_time", + "agent_group" + ], + "fields": [ + { + "fieldname": "day_of_week", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Day Of Week", + "options": "Monday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday", + "reqd": 1 + }, + { + "default": "9:00:00", + "fieldname": "from_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "From Time", + "reqd": 1 + }, + { + "default": "17:00:00", + "fieldname": "to_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "To Time", + "reqd": 1 + }, + { + "fieldname": "agent_group", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Agent Group", + "options": "Employee Group", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-11-19 11:15:54.967710", + "modified_by": "Administrator", + "module": "Telephony", + "name": "Incoming Call Handling Schedule", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/telephony/doctype/incoming_call_handling_schedule/incoming_call_handling_schedule.py b/erpnext/telephony/doctype/incoming_call_handling_schedule/incoming_call_handling_schedule.py new file mode 100644 index 00000000000..fcf29745e2b --- /dev/null +++ b/erpnext/telephony/doctype/incoming_call_handling_schedule/incoming_call_handling_schedule.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class IncomingCallHandlingSchedule(Document): + pass diff --git a/erpnext/telephony/doctype/incoming_call_settings/__init__.py b/erpnext/telephony/doctype/incoming_call_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/telephony/doctype/incoming_call_settings/incoming_call_settings.js b/erpnext/telephony/doctype/incoming_call_settings/incoming_call_settings.js new file mode 100644 index 00000000000..1bcc8461323 --- /dev/null +++ b/erpnext/telephony/doctype/incoming_call_settings/incoming_call_settings.js @@ -0,0 +1,102 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +function time_to_seconds(time_str) { + // Convert time string of format HH:MM:SS into seconds. + let seq = time_str.split(':'); + seq = seq.map((n) => parseInt(n)); + return (seq[0]*60*60) + (seq[1]*60) + seq[2]; +} + +function number_sort(array, ascending=true) { + let array_copy = [...array]; + if (ascending) { + array_copy.sort((a, b) => a-b); // ascending order + } else { + array_copy.sort((a, b) => b-a); // descending order + } + return array_copy; +} + +function groupby(items, key) { + // Group the list of items using the given key. + const obj = {}; + items.forEach((item) => { + if (item[key] in obj) { + obj[item[key]].push(item); + } else { + obj[item[key]] = [item]; + } + }); + return obj; +} + +function check_timeslot_overlap(ts1, ts2) { + /// Timeslot is a an array of length 2 ex: [from_time, to_time] + /// time in timeslot is an integer represents number of seconds. + if ((ts1[0] < ts2[0] && ts1[1] <= ts2[0]) || (ts1[0] >= ts2[1] && ts1[1] > ts2[1])) { + return false; + } + return true; +} + +function validate_call_schedule(schedule) { + validate_call_schedule_timeslot(schedule); + validate_call_schedule_overlaps(schedule); +} + +function validate_call_schedule_timeslot(schedule) { + // Make sure that to time slot is ahead of from time slot. + let errors = []; + + for (let row in schedule) { + let record = schedule[row]; + let from_time_in_secs = time_to_seconds(record.from_time); + let to_time_in_secs = time_to_seconds(record.to_time); + if (from_time_in_secs >= to_time_in_secs) { + errors.push(__('Call Schedule Row {0}: To time slot should always be ahead of From time slot.', [row])); + } + } + + if (errors.length > 0) { + frappe.throw(errors.join("
    ")); + } +} + +function is_call_schedule_overlapped(day_schedule) { + // Check if any time slots are overlapped in a day schedule. + let timeslots = []; + day_schedule.forEach((record)=> { + timeslots.push([time_to_seconds(record.from_time), time_to_seconds(record.to_time)]); + }); + + if (timeslots.length < 2) { + return false; + } + + timeslots = number_sort(timeslots); + + // Sorted timeslots will be in ascending order if not overlapped. + for (let i=1; i < timeslots.length; i++) { + if (check_timeslot_overlap(timeslots[i-1], timeslots[i])) { + return true; + } + } + return false; +} + +function validate_call_schedule_overlaps(schedule) { + let group_by_day = groupby(schedule, 'day_of_week'); + for (const [day, day_schedule] of Object.entries(group_by_day)) { + if (is_call_schedule_overlapped(day_schedule)) { + frappe.throw(__('Please fix overlapping time slots for {0}', [day])); + } + } +} + +frappe.ui.form.on('Incoming Call Settings', { + validate(frm) { + validate_call_schedule(frm.doc.call_handling_schedule); + } +}); + diff --git a/erpnext/telephony/doctype/incoming_call_settings/incoming_call_settings.json b/erpnext/telephony/doctype/incoming_call_settings/incoming_call_settings.json new file mode 100644 index 00000000000..3ffb3e49db1 --- /dev/null +++ b/erpnext/telephony/doctype/incoming_call_settings/incoming_call_settings.json @@ -0,0 +1,82 @@ +{ + "actions": [], + "autoname": "Prompt", + "creation": "2020-11-19 10:37:20.734245", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "call_routing", + "column_break_2", + "greeting_message", + "agent_busy_message", + "agent_unavailable_message", + "section_break_6", + "call_handling_schedule" + ], + "fields": [ + { + "default": "Sequential", + "fieldname": "call_routing", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Call Routing", + "options": "Sequential\nSimultaneous" + }, + { + "fieldname": "greeting_message", + "fieldtype": "Data", + "label": "Greeting Message" + }, + { + "fieldname": "agent_busy_message", + "fieldtype": "Data", + "label": "Agent Busy Message" + }, + { + "fieldname": "agent_unavailable_message", + "fieldtype": "Data", + "label": "Agent Unavailable Message" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, + { + "fieldname": "call_handling_schedule", + "fieldtype": "Table", + "label": "Call Handling Schedule", + "options": "Incoming Call Handling Schedule", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-11-19 11:17:14.527862", + "modified_by": "Administrator", + "module": "Telephony", + "name": "Incoming Call Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/telephony/doctype/incoming_call_settings/incoming_call_settings.py b/erpnext/telephony/doctype/incoming_call_settings/incoming_call_settings.py new file mode 100644 index 00000000000..2b2008a8ab7 --- /dev/null +++ b/erpnext/telephony/doctype/incoming_call_settings/incoming_call_settings.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document +from datetime import datetime +from typing import Tuple +from frappe import _ + +class IncomingCallSettings(Document): + def validate(self): + """List of validations + * Make sure that to time slot is ahead of from time slot in call schedule + * Make sure that no overlapping timeslots for a given day + """ + self.validate_call_schedule_timeslot(self.call_handling_schedule) + self.validate_call_schedule_overlaps(self.call_handling_schedule) + + def validate_call_schedule_timeslot(self, schedule: list): + """ Make sure that to time slot is ahead of from time slot. + """ + errors = [] + for record in schedule: + from_time = self.time_to_seconds(record.from_time) + to_time = self.time_to_seconds(record.to_time) + if from_time >= to_time: + errors.append( + _('Call Schedule Row {0}: To time slot should always be ahead of From time slot.').format(record.idx) + ) + + if errors: + frappe.throw('
    '.join(errors)) + + def validate_call_schedule_overlaps(self, schedule: list): + """Check if any time slots are overlapped in a day schedule. + """ + week_days = set([each.day_of_week for each in schedule]) + + for day in week_days: + timeslots = [(record.from_time, record.to_time) for record in schedule if record.day_of_week==day] + + # convert time in timeslot into an integer represents number of seconds + timeslots = sorted(map(lambda seq: tuple(map(self.time_to_seconds, seq)), timeslots)) + if len(timeslots) < 2: continue + + for i in range(1, len(timeslots)): + if self.check_timeslots_overlap(timeslots[i-1], timeslots[i]): + frappe.throw(_('Please fix overlapping time slots for {0}.').format(day)) + + @staticmethod + def check_timeslots_overlap(ts1: Tuple[int, int], ts2: Tuple[int, int]) -> bool: + if (ts1[0] < ts2[0] and ts1[1] <= ts2[0]) or (ts1[0] >= ts2[1] and ts1[1] > ts2[1]): + return False + return True + + @staticmethod + def time_to_seconds(time: str) -> int: + """Convert time string of format HH:MM:SS into seconds + """ + date_time = datetime.strptime(time, "%H:%M:%S") + return date_time - datetime(1900, 1, 1) diff --git a/erpnext/telephony/doctype/incoming_call_settings/test_incoming_call_settings.py b/erpnext/telephony/doctype/incoming_call_settings/test_incoming_call_settings.py new file mode 100644 index 00000000000..c058c117b32 --- /dev/null +++ b/erpnext/telephony/doctype/incoming_call_settings/test_incoming_call_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestIncomingCallSettings(unittest.TestCase): + pass diff --git a/erpnext/telephony/doctype/voice_call_settings/__init__.py b/erpnext/telephony/doctype/voice_call_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/telephony/doctype/voice_call_settings/test_voice_call_settings.py b/erpnext/telephony/doctype/voice_call_settings/test_voice_call_settings.py new file mode 100644 index 00000000000..85d6adda093 --- /dev/null +++ b/erpnext/telephony/doctype/voice_call_settings/test_voice_call_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestVoiceCallSettings(unittest.TestCase): + pass diff --git a/erpnext/telephony/doctype/voice_call_settings/voice_call_settings.js b/erpnext/telephony/doctype/voice_call_settings/voice_call_settings.js new file mode 100644 index 00000000000..4a61b612d00 --- /dev/null +++ b/erpnext/telephony/doctype/voice_call_settings/voice_call_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Voice Call Settings', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/telephony/doctype/voice_call_settings/voice_call_settings.json b/erpnext/telephony/doctype/voice_call_settings/voice_call_settings.json new file mode 100644 index 00000000000..25e55a22dce --- /dev/null +++ b/erpnext/telephony/doctype/voice_call_settings/voice_call_settings.json @@ -0,0 +1,124 @@ +{ + "actions": [], + "autoname": "field:user", + "creation": "2020-12-08 16:52:40.590146", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "call_receiving_device", + "column_break_3", + "greeting_message", + "agent_busy_message", + "agent_unavailable_message" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "permlevel": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "greeting_message", + "fieldtype": "Data", + "label": "Greeting Message" + }, + { + "fieldname": "agent_busy_message", + "fieldtype": "Data", + "label": "Agent Busy Message" + }, + { + "fieldname": "agent_unavailable_message", + "fieldtype": "Data", + "label": "Agent Unavailable Message" + }, + { + "default": "Computer", + "fieldname": "call_receiving_device", + "fieldtype": "Select", + "label": "Call Receiving Device", + "options": "Computer\nPhone" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-12-14 18:49:34.600194", + "modified_by": "Administrator", + "module": "Telephony", + "name": "Voice Call Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "delete": 1, + "email": 1, + "export": 1, + "permlevel": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "delete": 1, + "email": 1, + "export": 1, + "permlevel": 2, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "permlevel": 2, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/telephony/doctype/voice_call_settings/voice_call_settings.py b/erpnext/telephony/doctype/voice_call_settings/voice_call_settings.py new file mode 100644 index 00000000000..ad3bbf1784d --- /dev/null +++ b/erpnext/telephony/doctype/voice_call_settings/voice_call_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class VoiceCallSettings(Document): + pass diff --git a/erpnext/templates/emails/birthday_reminder.html b/erpnext/templates/emails/birthday_reminder.html new file mode 100644 index 00000000000..12cdf1ec600 --- /dev/null +++ b/erpnext/templates/emails/birthday_reminder.html @@ -0,0 +1,25 @@ +
    +
    + {% for person in birthday_persons %} + {% if person.image %} + + + {% else %} + + {{ frappe.utils.get_abbr(person.name) }} + + {% endif %} + {% endfor %} +
    +
    + {{ reminder_text }} +

    {{ message }}

    +
    +
    \ No newline at end of file diff --git a/erpnext/templates/emails/request_for_quotation.html b/erpnext/templates/emails/request_for_quotation.html index b4dfb88c675..812939a5538 100644 --- a/erpnext/templates/emails/request_for_quotation.html +++ b/erpnext/templates/emails/request_for_quotation.html @@ -1,11 +1,24 @@ -

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

    +

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

    +

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

    {{ message }}

    + +

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

    +

    + +


    + +

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


    + {% if update_password_link %} -

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

    -

    {{ update_password_link }}

    -{% else %} -

    {{_("The request for quotation can be accessed by clicking on the following link")}}:

    -

    Submit your Quotation

    -{% endif %} -

    {{_("Thank you")}},
    -{{ user_fullname }}

    + +

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

    +

    + +

    + +{% endif %} \ No newline at end of file diff --git a/erpnext/templates/generators/item/item.html b/erpnext/templates/generators/item/item.html index d3691a6e99e..135982d7090 100644 --- a/erpnext/templates/generators/item/item.html +++ b/erpnext/templates/generators/item/item.html @@ -3,21 +3,25 @@ {% block title %} {{ title }} {% endblock %} {% block breadcrumbs %} +
    {% include "templates/includes/breadcrumbs.html" %} +
    {% endblock %} {% block page_content %} -{% from "erpnext/templates/includes/macros.html" import product_image %} -
    -
    -
    - {% include "templates/generators/item/item_image.html" %} - {% include "templates/generators/item/item_details.html" %} +
    + {% from "erpnext/templates/includes/macros.html" import product_image %} +
    +
    +
    + {% include "templates/generators/item/item_image.html" %} + {% include "templates/generators/item/item_details.html" %} +
    + + {% include "templates/generators/item/item_specifications.html" %} + + {{ doc.website_content or '' }}
    - - {% include "templates/generators/item/item_specifications.html" %} - - {{ doc.website_content or '' }}
    {% endblock %} diff --git a/erpnext/templates/generators/item/item_add_to_cart.html b/erpnext/templates/generators/item/item_add_to_cart.html index dbf15de1e48..f5adbf01e3d 100644 --- a/erpnext/templates/generators/item/item_add_to_cart.html +++ b/erpnext/templates/generators/item/item_add_to_cart.html @@ -6,10 +6,10 @@
    {% if cart_settings.show_price and product_info.price %} -

    +
    {{ product_info.price.formatted_price_sales_uom }} - ({{ product_info.price.formatted_price }} / {{ product_info.uom }}) -

    + ({{ product_info.price.formatted_price }} / {{ product_info.uom }}) +
    {% else %} {{ _("Unit of Measurement") }} : {{ product_info.uom }} {% endif %} @@ -17,11 +17,11 @@ {% if cart_settings.show_stock_availability %}
    {% if product_info.in_stock == 0 %} - + {{ _('Not in stock') }} {% elif product_info.in_stock == 1 %} - + {{ _('In stock') }} {% if product_info.show_stock_qty and product_info.stock_qty %} ({{ product_info.stock_qty[0][0] }}) @@ -30,7 +30,7 @@ {% endif %}
    {% endif %} -
    +
    {% if product_info.price and (cart_settings.allow_items_not_in_stock or product_info.in_stock) %} {% endif %} diff --git a/erpnext/templates/generators/item/item_configure.html b/erpnext/templates/generators/item/item_configure.html index 73f9ec99b34..b61ac73072d 100644 --- a/erpnext/templates/generators/item/item_configure.html +++ b/erpnext/templates/generators/item/item_configure.html @@ -1,9 +1,9 @@ {% if shopping_cart and shopping_cart.cart_settings.enabled %} {% set cart_settings = shopping_cart.cart_settings %} -
    +
    {% if cart_settings.enable_variants | int %} - + ` : ''; const items_found = filtered_items_count === 1 ? __('{0} item found.', [filtered_items_count]) : __('{0} items found.', [filtered_items_count]); - const item_found_status = ` - ` + : ``; + /* eslint-disable indent */ return ` - ${item_add_to_cart} ${item_found_status} + ${item_add_to_cart} `; } @@ -254,8 +267,8 @@ class ItemConfigure { } append_status_area() { - this.dialog.$status_area = $('
    '); - this.dialog.$wrapper.find('.modal-body').prepend(this.dialog.$status_area); + this.dialog.$status_area = $('
    '); + this.dialog.$wrapper.find('.modal-body').append(this.dialog.$status_area); this.dialog.$wrapper.on('click', '[data-action]', (e) => { e.preventDefault(); const $target = $(e.currentTarget); @@ -263,7 +276,7 @@ class ItemConfigure { const method = this[action]; method.call(this, e); }); - this.dialog.$body.css({ maxHeight: '75vh', overflow: 'auto', overflowX: 'hidden' }); + this.dialog.$wrapper.addClass('item-configurator-dialog'); } get_next_attribute_and_values(selected_attributes) { diff --git a/erpnext/templates/generators/item/item_details.html b/erpnext/templates/generators/item/item_details.html index 4cbecb02155..3b775858276 100644 --- a/erpnext/templates/generators/item/item_details.html +++ b/erpnext/templates/generators/item/item_details.html @@ -1,14 +1,21 @@ -
    +
    -

    +

    {{ item_name }}

    -

    +

    {{ _("Item Code") }}: {{ doc.name }}

    +{% if has_variants %} + + {% include "templates/generators/item/item_configure.html" %} +{% else %} + + {% include "templates/generators/item/item_add_to_cart.html" %} +{% endif %} -
    +
    {% if frappe.utils.strip_html(doc.web_long_description or '') %} {{ doc.web_long_description | safe }} {% elif frappe.utils.strip_html(doc.description or '') %} @@ -17,12 +24,4 @@ {{ _("No description given") }} {% endif %}
    - -{% if has_variants %} - - {% include "templates/generators/item/item_configure.html" %} -{% else %} - - {% include "templates/generators/item/item_add_to_cart.html" %} -{% endif %}
    diff --git a/erpnext/templates/generators/item/item_image.html b/erpnext/templates/generators/item/item_image.html index 5d46a45053d..39a30d0d7cb 100644 --- a/erpnext/templates/generators/item/item_image.html +++ b/erpnext/templates/generators/item/item_image.html @@ -1,42 +1,42 @@ -
    -{% if slides %} -{{ product_image(slides[0].image, 'product-image') }} -
    - {% for item in slides %} - {{ item.heading }} - {% endfor %} -
    - - -{% else %} -{{ product_image(website_image or image or 'no-image.jpg', alt=website_image_alt or item_name) }} -{% endif %} + $('.item-slideshow-image').removeClass('active'); + $img.addClass('active'); + }); + }) + + {% else %} + {{ product_image(website_image or image or 'no-image.jpg', alt=website_image_alt or item_name) }} + {% endif %} - + - +