diff --git a/.github/helper/install.sh b/.github/helper/install.sh new file mode 100644 index 0000000000..7b0f944c66 --- /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/.github/helper/semgrep_rules/README.md b/.github/helper/semgrep_rules/README.md new file mode 100644 index 0000000000..670d8d280f --- /dev/null +++ b/.github/helper/semgrep_rules/README.md @@ -0,0 +1,38 @@ +# Semgrep linting + +## What is semgrep? +Semgrep or "semantic grep" is language agnostic static analysis tool. In simple terms semgrep is syntax-aware `grep`, so unlike regex it doesn't get confused by different ways of writing same thing or whitespaces or code split in multiple lines etc. + +Example: + +To check if a translate function is using f-string or not the regex would be `r"_\(\s*f[\"']"` while equivalent rule in semgrep would be `_(f"...")`. As semgrep knows grammer of language it takes care of unnecessary whitespace, type of quotation marks etc. + +You can read more such examples in `.github/helper/semgrep_rules` directory. + +# Why/when to use this? +We want to maintain quality of contributions, at the same time remembering all the good practices can be pain to deal with while evaluating contributions. Using semgrep if you can translate "best practice" into a rule then it can automate the task for us. + +## Running locally + +Install semgrep using homebrew `brew install semgrep` or pip `pip install semgrep`. + +To run locally use following command: + +`semgrep --config=.github/helper/semgrep_rules [file/folder names]` + +## Testing +semgrep allows testing the tests. Refer to this page: https://semgrep.dev/docs/writing-rules/testing-rules/ + +When writing new rules you should write few positive and few negative cases as shown in the guide and current tests. + +To run current tests: `semgrep --test --test-ignore-todo .github/helper/semgrep_rules` + + +## Reference + +If you are new to Semgrep read following pages to get started on writing/modifying rules: + +- https://semgrep.dev/docs/getting-started/ +- https://semgrep.dev/docs/writing-rules/rule-syntax +- https://semgrep.dev/docs/writing-rules/pattern-examples/ +- https://semgrep.dev/docs/writing-rules/rule-ideas/#common-use-cases diff --git a/.github/helper/semgrep_rules/frappe_correctness.py b/.github/helper/semgrep_rules/frappe_correctness.py new file mode 100644 index 0000000000..4798b927f8 --- /dev/null +++ b/.github/helper/semgrep_rules/frappe_correctness.py @@ -0,0 +1,28 @@ +import frappe +from frappe import _, flt + +from frappe.model.document import Document + + +def on_submit(self): + if self.value_of_goods == 0: + frappe.throw(_('Value of goods cannot be 0')) + # ruleid: frappe-modifying-after-submit + self.status = 'Submitted' + +def on_submit(self): + if flt(self.per_billed) < 100: + self.update_billing_status() + else: + # todook: frappe-modifying-after-submit + self.status = "Completed" + self.db_set("status", "Completed") + +class TestDoc(Document): + pass + + def validate(self): + #ruleid: frappe-modifying-child-tables-while-iterating + for item in self.child_table: + if item.value < 0: + self.remove(item) diff --git a/.github/helper/semgrep_rules/frappe_correctness.yml b/.github/helper/semgrep_rules/frappe_correctness.yml new file mode 100644 index 0000000000..54df062480 --- /dev/null +++ b/.github/helper/semgrep_rules/frappe_correctness.yml @@ -0,0 +1,74 @@ +# This file specifies rules for correctness according to how frappe doctype data model works. + +rules: +- id: frappe-modifying-after-submit + patterns: + - pattern: self.$ATTR = ... + - pattern-inside: | + def on_submit(self, ...): + ... + - metavariable-regex: + metavariable: '$ATTR' + # this is negative look-ahead, add more attrs to ignore like (ignore|ignore_this_too|ignore_me) + regex: '^(?!status_updater)(.*)$' + message: | + Doctype modified after submission. Please check if modification of self.$ATTR is commited to database. + languages: [python] + severity: ERROR + +- id: frappe-modifying-after-cancel + patterns: + - pattern: self.$ATTR = ... + - pattern-inside: | + def on_cancel(self, ...): + ... + - metavariable-regex: + metavariable: '$ATTR' + regex: '^(?!ignore_linked_doctypes|status_updater)(.*)$' + message: | + Doctype modified after cancellation. Please check if modification of self.$ATTR is commited to database. + languages: [python] + severity: ERROR + +- id: frappe-print-function-in-doctypes + pattern: print(...) + message: | + Did you mean to leave this print statement in? Consider using msgprint or logger instead of print statement. + languages: [python] + severity: WARNING + paths: + exclude: + - test_*.py + include: + - "*/**/doctype/*" + +- id: frappe-modifying-child-tables-while-iterating + pattern-either: + - pattern: | + for $ROW in self.$TABLE: + ... + self.remove(...) + - pattern: | + for $ROW in self.$TABLE: + ... + self.append(...) + message: | + Child table being modified while iterating on it. + languages: [python] + severity: ERROR + paths: + include: + - "*/**/doctype/*" + +- id: frappe-same-key-assigned-twice + pattern-either: + - pattern: | + {..., $X: $A, ..., $X: $B, ...} + - pattern: | + dict(..., ($X, $A), ..., ($X, $B), ...) + - pattern: | + _dict(..., ($X, $A), ..., ($X, $B), ...) + message: | + key `$X` is uselessly assigned twice. This could be a potential bug. + languages: [python] + severity: ERROR diff --git a/.github/helper/semgrep_rules/security.py b/.github/helper/semgrep_rules/security.py new file mode 100644 index 0000000000..f477d7c176 --- /dev/null +++ b/.github/helper/semgrep_rules/security.py @@ -0,0 +1,6 @@ +def function_name(input): + # ruleid: frappe-codeinjection-eval + eval(input) + +# ok: frappe-codeinjection-eval +eval("1 + 1") diff --git a/.github/helper/semgrep_rules/security.yml b/.github/helper/semgrep_rules/security.yml new file mode 100644 index 0000000000..5a5098bf50 --- /dev/null +++ b/.github/helper/semgrep_rules/security.yml @@ -0,0 +1,25 @@ +rules: +- id: frappe-codeinjection-eval + patterns: + - pattern-not: eval("...") + - pattern: eval(...) + message: | + Detected the use of eval(). eval() can be dangerous if used to evaluate + dynamic content. Avoid it or use safe_eval(). + languages: [python] + severity: ERROR + +- id: frappe-sqli-format-strings + patterns: + - pattern-inside: | + @frappe.whitelist() + def $FUNC(...): + ... + - pattern-either: + - pattern: frappe.db.sql("..." % ...) + - pattern: frappe.db.sql(f"...", ...) + - pattern: frappe.db.sql("...".format(...), ...) + message: | + Detected use of raw string formatting for SQL queries. This can lead to sql injection vulnerabilities. Refer security guidelines - https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines + languages: [python] + severity: WARNING diff --git a/.github/helper/semgrep_rules/translate.js b/.github/helper/semgrep_rules/translate.js new file mode 100644 index 0000000000..7b92fe2dff --- /dev/null +++ b/.github/helper/semgrep_rules/translate.js @@ -0,0 +1,37 @@ +// ruleid: frappe-translation-empty-string +__("") +// ruleid: frappe-translation-empty-string +__('') + +// ok: frappe-translation-js-formatting +__('Welcome {0}, get started with ERPNext in just a few clicks.', [full_name]); + +// ruleid: frappe-translation-js-formatting +__(`Welcome ${full_name}, get started with ERPNext in just a few clicks.`); + +// ok: frappe-translation-js-formatting +__('This is fine'); + + +// ok: frappe-translation-trailing-spaces +__('This is fine'); + +// ruleid: frappe-translation-trailing-spaces +__(' this is not ok '); +// ruleid: frappe-translation-trailing-spaces +__('this is not ok '); +// ruleid: frappe-translation-trailing-spaces +__(' this is not ok'); + +// ok: frappe-translation-js-splitting +__('You have {0} subscribers in your mailing list.', [subscribers.length]) + +// todoruleid: frappe-translation-js-splitting +__('You have') + subscribers.length + __('subscribers in your mailing list.') + +// ruleid: frappe-translation-js-splitting +__('You have' + 'subscribers in your mailing list.') + +// ruleid: frappe-translation-js-splitting +__('You have {0} subscribers' + + 'in your mailing list', [subscribers.length]) diff --git a/.github/helper/semgrep_rules/translate.py b/.github/helper/semgrep_rules/translate.py new file mode 100644 index 0000000000..bd6cd9126c --- /dev/null +++ b/.github/helper/semgrep_rules/translate.py @@ -0,0 +1,53 @@ +# Examples taken from https://frappeframework.com/docs/user/en/translations +# This file is used for testing the tests. + +from frappe import _ + +full_name = "Jon Doe" +# ok: frappe-translation-python-formatting +_('Welcome {0}, get started with ERPNext in just a few clicks.').format(full_name) + +# ruleid: frappe-translation-python-formatting +_('Welcome %s, get started with ERPNext in just a few clicks.' % full_name) +# ruleid: frappe-translation-python-formatting +_('Welcome %(name)s, get started with ERPNext in just a few clicks.' % {'name': full_name}) + +# ruleid: frappe-translation-python-formatting +_('Welcome {0}, get started with ERPNext in just a few clicks.'.format(full_name)) + + +subscribers = ["Jon", "Doe"] +# ok: frappe-translation-python-formatting +_('You have {0} subscribers in your mailing list.').format(len(subscribers)) + +# ruleid: frappe-translation-python-splitting +_('You have') + len(subscribers) + _('subscribers in your mailing list.') + +# ruleid: frappe-translation-python-splitting +_('You have {0} subscribers \ + in your mailing list').format(len(subscribers)) + +# ok: frappe-translation-python-splitting +_('You have {0} subscribers') \ + + 'in your mailing list' + +# ruleid: frappe-translation-trailing-spaces +msg = _(" You have {0} pending invoice ") +# ruleid: frappe-translation-trailing-spaces +msg = _("You have {0} pending invoice ") +# ruleid: frappe-translation-trailing-spaces +msg = _(" You have {0} pending invoice") + +# ok: frappe-translation-trailing-spaces +msg = ' ' + _("You have {0} pending invoices") + ' ' + +# ruleid: frappe-translation-python-formatting +_(f"can not format like this - {subscribers}") +# ruleid: frappe-translation-python-splitting +_(f"what" + f"this is also not cool") + + +# ruleid: frappe-translation-empty-string +_("") +# ruleid: frappe-translation-empty-string +_('') diff --git a/.github/helper/semgrep_rules/translate.yml b/.github/helper/semgrep_rules/translate.yml new file mode 100644 index 0000000000..3737da5a7e --- /dev/null +++ b/.github/helper/semgrep_rules/translate.yml @@ -0,0 +1,63 @@ +rules: +- id: frappe-translation-empty-string + pattern-either: + - pattern: _("") + - pattern: __("") + message: | + Empty string is useless for translation. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [python, javascript, json] + severity: ERROR + +- id: frappe-translation-trailing-spaces + pattern-either: + - pattern: _("=~/(^[ \t]+|[ \t]+$)/") + - pattern: __("=~/(^[ \t]+|[ \t]+$)/") + message: | + Trailing or leading whitespace not allowed in translate strings. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [python, javascript, json] + severity: ERROR + +- id: frappe-translation-python-formatting + pattern-either: + - pattern: _("..." % ...) + - pattern: _("...".format(...)) + - pattern: _(f"...") + message: | + Only positional formatters are allowed and formatting should not be done before translating. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [python] + severity: ERROR + +- id: frappe-translation-js-formatting + patterns: + - pattern: __(`...`) + - pattern-not: __("...") + message: | + Template strings are not allowed for text formatting. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [javascript, json] + severity: ERROR + +- id: frappe-translation-python-splitting + pattern-either: + - pattern: _(...) + ... + _(...) + - pattern: _("..." + "...") + - pattern-regex: '_\([^\)]*\\\s*' + message: | + Do not split strings inside translate function. Do not concatenate using translate functions. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [python] + severity: ERROR + +- id: frappe-translation-js-splitting + pattern-either: + - pattern-regex: '__\([^\)]*[\+\\]\s*' + - pattern: __('...' + '...') + - pattern: __('...') + __('...') + message: | + Do not split strings inside translate function. Do not concatenate using translate functions. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [javascript, json] + severity: ERROR diff --git a/.github/helper/semgrep_rules/ux.py b/.github/helper/semgrep_rules/ux.py new file mode 100644 index 0000000000..4a74457435 --- /dev/null +++ b/.github/helper/semgrep_rules/ux.py @@ -0,0 +1,31 @@ +import frappe +from frappe import msgprint, throw, _ + + +# ruleid: frappe-missing-translate-function +throw("Error Occured") + +# ruleid: frappe-missing-translate-function +frappe.throw("Error Occured") + +# ruleid: frappe-missing-translate-function +frappe.msgprint("Useful message") + +# ruleid: frappe-missing-translate-function +msgprint("Useful message") + + +# ok: frappe-missing-translate-function +translatedmessage = _("Hello") + +# ok: frappe-missing-translate-function +throw(translatedmessage) + +# ok: frappe-missing-translate-function +msgprint(translatedmessage) + +# ok: frappe-missing-translate-function +msgprint(_("Helpful message")) + +# ok: frappe-missing-translate-function +frappe.throw(_("Error occured")) diff --git a/.github/helper/semgrep_rules/ux.yml b/.github/helper/semgrep_rules/ux.yml new file mode 100644 index 0000000000..ed06a6a80c --- /dev/null +++ b/.github/helper/semgrep_rules/ux.yml @@ -0,0 +1,15 @@ +rules: +- id: frappe-missing-translate-function + pattern-either: + - patterns: + - pattern: frappe.msgprint("...", ...) + - pattern-not: frappe.msgprint(_("..."), ...) + - pattern-not: frappe.msgprint(__("..."), ...) + - patterns: + - pattern: frappe.throw("...", ...) + - pattern-not: frappe.throw(_("..."), ...) + - pattern-not: frappe.throw(__("..."), ...) + message: | + All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations + languages: [python, javascript, json] + severity: ERROR diff --git a/.travis/site_config.json b/.github/helper/site_config.json similarity index 89% rename from .travis/site_config.json rename to .github/helper/site_config.json index 572bbd0853..60ef80cbad 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", diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000000..78c2f5a187 --- /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/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000000..df08263236 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,24 @@ +name: Semgrep + +on: + pull_request: + branches: + - develop +jobs: + semgrep: + name: Frappe Linter + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup python3 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Run semgrep + run: | + python -m pip install -q semgrep + git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q + files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF) + [[ -d .github/helper/semgrep_rules ]] && semgrep --severity ERROR --config=.github/helper/semgrep_rules --quiet --error $files + semgrep --config="r/python.lang.correctness" --quiet --error $files + [[ -d .github/helper/semgrep_rules ]] && semgrep --severity WARNING --severity INFO --config=.github/helper/semgrep_rules --quiet $files diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 77d427e5a5..0000000000 --- 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 15782a2e0c..bb592ae75c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

ERP made simple

-[![Build Status](https://api.travis-ci.com/frappe/erpnext.svg?branch=develop)](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/__init__.py b/erpnext/__init__.py index 1a5a0fa275..199a183e47 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -5,7 +5,7 @@ import frappe from erpnext.hooks import regional_overrides from frappe.utils import getdate -__version__ = '13.0.2' +__version__ = '13.0.0-dev' def get_default_company(user=None): '''Get default company for user''' diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index c801cfcbba..0606823821 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -214,6 +214,7 @@ class Account(NestedSet): if parent_value_changed: doc.save() + @frappe.whitelist() def convert_group_to_ledger(self): if self.check_if_child_exists(): throw(_("Account with child nodes cannot be converted to ledger")) @@ -224,6 +225,7 @@ class Account(NestedSet): self.save() return 1 + @frappe.whitelist() def convert_ledger_to_group(self): if self.check_gle_exists(): throw(_("Account with existing transaction can not be converted to group.")) diff --git a/erpnext/accounts/doctype/accounting_period/accounting_period.py b/erpnext/accounts/doctype/accounting_period/accounting_period.py index df6cedd7cf..63b5dbbd3e 100644 --- a/erpnext/accounts/doctype/accounting_period/accounting_period.py +++ b/erpnext/accounts/doctype/accounting_period/accounting_period.py @@ -39,6 +39,7 @@ class AccountingPeriod(Document): frappe.throw(_("Accounting Period overlaps with {0}") .format(existing_accounting_period[0].get("name")), OverlapError) + @frappe.whitelist() def get_doctypes_for_closing(self): docs_for_closing = [] doctypes = ["Sales Invoice", "Purchase Invoice", "Journal Entry", "Payroll Entry", \ diff --git a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py index 022d7a7e80..10cd939894 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.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index a3c29b6d64..e1276e7da3 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -12,6 +12,7 @@ "frozen_accounts_modifier", "determine_address_tax_category_from", "over_billing_allowance", + "role_allowed_to_over_bill", "column_break_4", "credit_controller", "check_supplier_invoice_uniqueness", @@ -226,6 +227,13 @@ "fieldname": "delete_linked_ledger_entries", "fieldtype": "Check", "label": "Delete Accounting and Stock Ledger Entries on deletion of Transaction" + }, + { + "description": "Users with this role are allowed to over bill above the allowance percentage", + "fieldname": "role_allowed_to_over_bill", + "fieldtype": "Link", + "label": "Role Allowed to Over Bill ", + "options": "Role" } ], "icon": "icon-cog", @@ -233,7 +241,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-01-05 13:04:00.118892", + "modified": "2021-03-11 18:52:05.601996", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js index 49b2b186c4..059e1d3158 100644 --- a/erpnext/accounts/doctype/bank/bank.js +++ b/erpnext/accounts/doctype/bank/bank.js @@ -42,10 +42,9 @@ let add_fields_to_mapping_table = function (frm) { }); }); - frappe.meta.get_docfield("Bank Transaction Mapping", "bank_transaction_field", - frm.doc.name).options = options; - - frm.fields_dict.bank_transaction_mapping.grid.refresh(); + frm.fields_dict.bank_transaction_mapping.grid.update_docfield_property( + 'bank_transaction_field', 'options', options + ); }; erpnext.integrations.refreshPlaidLink = class refreshPlaidLink { diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 76d82e7339..79f5596384 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -12,6 +12,7 @@ form_grid_templates = { } class BankClearance(Document): + @frappe.whitelist() def get_payment_entries(self): if not (self.from_date and self.to_date): frappe.throw(_("From Date and To Date are Mandatory")) @@ -108,6 +109,7 @@ class BankClearance(Document): row.update(d) self.total_amount += flt(amount) + @frappe.whitelist() def update_clearance_date(self): clearance_date_updated = False for d in self.get('payment_entries'): diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js index 297dd4333f..10f660a140 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js @@ -8,6 +8,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { return { filters: { company: ["in", frm.doc.company], + 'is_company_account': 1 }, }; }); diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js index ad4ff9ee60..3dbd605344 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js @@ -532,43 +532,4 @@ frappe.ui.form.on("Bank Statement Import", { `); }, - - 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}
- - `; - }) - .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_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json index 69ee4971cd..88aa7ef8b5 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json @@ -175,22 +175,24 @@ }, { "fieldname": "deposit", - "oldfieldname": "debit", "fieldtype": "Currency", "in_list_view": 1, - "label": "Deposit" + "label": "Deposit", + "oldfieldname": "debit", + "options": "currency" }, { "fieldname": "withdrawal", - "oldfieldname": "credit", "fieldtype": "Currency", "in_list_view": 1, - "label": "Withdrawal" + "label": "Withdrawal", + "oldfieldname": "credit", + "options": "currency" } ], "is_submittable": 1, "links": [], - "modified": "2020-12-30 19:40:54.221070", + "modified": "2021-04-14 17:31:58.963529", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Transaction", diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 3b14e4efa0..ce149f96e6 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -15,12 +15,14 @@ from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profi test_dependencies = ["Item", "Cost Center"] class TestBankTransaction(unittest.TestCase): - def setUp(self): + @classmethod + def setUpClass(cls): make_pos_profile() add_transactions() add_vouchers() - def tearDown(self): + @classmethod + def tearDownClass(cls): for bt in frappe.get_all("Bank Transaction"): doc = frappe.get_doc("Bank Transaction", bt.name) doc.cancel() @@ -33,9 +35,6 @@ class TestBankTransaction(unittest.TestCase): # 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")) @@ -44,8 +43,8 @@ class TestBankTransaction(unittest.TestCase): # 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)) + bank_transaction = frappe.get_doc("Bank Transaction", dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G")) + payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700)) vouchers = json.dumps([{ "payment_doctype":"Payment Entry", "payment_name":payment.name, @@ -62,7 +61,6 @@ class TestBankTransaction(unittest.TestCase): 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, ['payment_entry', 'exact_match']) - print(linked_payments) self.assertTrue(linked_payments[0][3]) # Check error if already reconciled @@ -116,10 +114,6 @@ def create_bank_account(bank_name="Citi Bank", account_name="_Test Bank - _TC"): pass def add_transactions(): - if frappe.flags.test_bank_transactions_created: - return - - frappe.set_user("Administrator") create_bank_account() doc = frappe.get_doc({ @@ -172,14 +166,8 @@ def add_transactions(): }).insert() doc.submit() - frappe.flags.test_bank_transactions_created = True def add_vouchers(): - if frappe.flags.test_payments_created: - return - - frappe.set_user("Administrator") - try: frappe.get_doc({ "doctype": "Supplier", @@ -272,13 +260,6 @@ def add_vouchers(): except frappe.DuplicateEntryError: pass - si = create_sales_invoice(customer="Fayva", qty=1, rate=109080) - pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC") - pe.reference_no = "Fayva Oct 18" - pe.reference_date = "2018-10-29" - pe.insert() - pe.submit() - mode_of_payment = frappe.get_doc({ "doctype": "Mode of Payment", "name": "Cash" @@ -291,14 +272,12 @@ def add_vouchers(): }) mode_of_payment.save() - si = create_sales_invoice(customer="Fayva", qty=1, rate=109080, do_not_submit=1) + si = create_sales_invoice(customer="Fayva", qty=1, rate=109080, do_not_save=1) si.is_pos = 1 si.append("payments", { "mode_of_payment": "Cash", "account": "_Test Bank - _TC", "amount": 109080 }) - si.save() + si.insert() si.submit() - - frappe.flags.test_payments_created = True diff --git a/erpnext/accounts/doctype/c_form/c_form.py b/erpnext/accounts/doctype/c_form/c_form.py index 9b64f8100f..fd86ed4c90 100644 --- a/erpnext/accounts/doctype/c_form/c_form.py +++ b/erpnext/accounts/doctype/c_form/c_form.py @@ -57,6 +57,7 @@ class CForm(Document): total = sum([flt(d.grand_total) for d in self.get('invoices')]) frappe.db.set(self, 'total_invoiced_amount', total) + @frappe.whitelist() def get_invoice_details(self, invoice_no): """ Pull details from invoices for referrence """ if invoice_no: 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 03c3eb0ac0..f96f59169e 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 @@ -293,6 +293,11 @@ def validate_accounts(file_name): accounts_dict = {} for account in accounts: accounts_dict.setdefault(account["account_name"], account) + if not hasattr(account, "parent_account"): + msg = _("Please make sure the file you are using has 'Parent Account' column present in the header.") + msg += "

" + msg += _("Alternatively, you can download the template and fill your data in.") + frappe.throw(msg, title=_("Parent Account Missing")) if account["parent_account"] and accounts_dict.get(account["parent_account"]): accounts_dict[account["parent_account"]]["is_group"] = 1 diff --git a/erpnext/accounts/doctype/cost_center/cost_center.py b/erpnext/accounts/doctype/cost_center/cost_center.py index 12094d4f98..8a5473f3a1 100644 --- a/erpnext/accounts/doctype/cost_center/cost_center.py +++ b/erpnext/accounts/doctype/cost_center/cost_center.py @@ -50,6 +50,7 @@ class CostCenter(NestedSet): frappe.throw(_("{0} is not a group node. Please select a group node as parent cost center").format( frappe.bold(self.parent_cost_center))) + @frappe.whitelist() def convert_group_to_ledger(self): if self.check_if_child_exists(): frappe.throw(_("Cannot convert Cost Center to ledger as it has child nodes")) @@ -60,6 +61,7 @@ class CostCenter(NestedSet): self.save() return 1 + @frappe.whitelist() def convert_ledger_to_group(self): if cint(self.enable_distributed_cost_center): frappe.throw(_("Cost Center with enabled distributed cost center can not be converted to group")) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 9594706d0f..c1b8ba70ba 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -27,6 +27,7 @@ class ExchangeRateRevaluation(Document): if not (self.company and self.posting_date): frappe.throw(_("Please select Company and Posting Date to getting entries")) + @frappe.whitelist() def get_accounts_data(self, account=None): accounts = [] self.validate_mandatory() @@ -95,6 +96,7 @@ class ExchangeRateRevaluation(Document): message = _("No outstanding invoices found") frappe.msgprint(message) + @frappe.whitelist() def make_jv_entry(self): if self.total_gain_loss == 0: return diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py index da6a3fd2ef..42556269fd 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py @@ -12,6 +12,7 @@ from frappe.model.document import Document class FiscalYearIncorrectDate(frappe.ValidationError): pass class FiscalYear(Document): + @frappe.whitelist() def set_as_default(self): frappe.db.set_value("Global Defaults", None, "current_fiscal_year", self.name) global_defaults = frappe.get_doc("Global Defaults") @@ -54,7 +55,7 @@ class FiscalYear(Document): def on_update(self): check_duplicate_fiscal_year(self) frappe.cache().delete_value("fiscal_years") - + def on_trash(self): global_defaults = frappe.get_doc("Global Defaults") if global_defaults.current_fiscal_year == self.name: diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index ce76d0a39c..78febf9c2e 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -290,4 +290,8 @@ def rename_temporarily_named_docs(doctype): oldname = doc.name set_name_from_naming_options(frappe.get_meta(doctype).autoname, doc) newname = doc.name - frappe.db.sql("""UPDATE `tab{}` SET name = %s, to_rename = 0 where name = %s""".format(doctype), (newname, oldname)) + frappe.db.sql( + "UPDATE `tab{}` SET name = %s, to_rename = 0 where name = %s".format(doctype), + (newname, oldname), + auto_commit=True + ) diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py index af8940cde5..7b62b617f9 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py @@ -125,6 +125,7 @@ class InvoiceDiscounting(AccountsController): make_gl_entries(gl_entries, cancel=(self.docstatus == 2), update_outstanding='No') + @frappe.whitelist() def create_disbursement_entry(self): je = frappe.new_doc("Journal Entry") je.voucher_type = 'Journal Entry' @@ -174,6 +175,7 @@ class InvoiceDiscounting(AccountsController): return je + @frappe.whitelist() def close_loan(self): je = frappe.new_doc("Journal Entry") je.voucher_type = 'Journal Entry' diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index 37b03f3f0e..d76641dc9b 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -327,18 +327,16 @@ erpnext.accounts.JournalEntry = frappe.ui.form.Controller.extend({ }, setup_balance_formatter: function() { - var me = this; - $.each(["balance", "party_balance"], function(i, field) { - var df = frappe.meta.get_docfield("Journal Entry Account", field, me.frm.doc.name); - df.formatter = function(value, df, options, doc) { - var currency = frappe.meta.get_field_currency(df, doc); - var dr_or_cr = value ? ('') : ""; - return "
" - + ((value==null || value==="") ? "" : format_currency(Math.abs(value), currency)) - + " " + dr_or_cr - + "
"; - } - }) + const formatter = function(value, df, options, doc) { + var currency = frappe.meta.get_field_currency(df, doc); + var dr_or_cr = value ? ('') : ""; + return "
" + + ((value==null || value==="") ? "" : format_currency(Math.abs(value), currency)) + + " " + dr_or_cr + + "
"; + }; + this.frm.fields_dict.accounts.grid.update_docfield_property('balance', 'formatter', formatter); + this.frm.fields_dict.accounts.grid.update_docfield_property('party_balance', 'formatter', formatter); }, reference_name: function(doc, cdt, cdn) { @@ -431,15 +429,6 @@ cur_frm.cscript.validate = function(doc,cdt,cdn) { cur_frm.cscript.update_totals(doc); } -cur_frm.cscript.select_print_heading = function(doc,cdt,cdn){ - if(doc.select_print_heading){ - // print heading - cur_frm.pformat.print_heading = doc.select_print_heading; - } - else - cur_frm.pformat.print_heading = __("Journal Entry"); -} - frappe.ui.form.on("Journal Entry Account", { party: function(frm, cdt, cdn) { var d = frappe.get_doc(cdt, cdn); @@ -511,8 +500,11 @@ $.extend(erpnext.journal_entry, { }; $.each(field_label_map, function (fieldname, label) { - var df = frappe.meta.get_docfield("Journal Entry Account", fieldname, frm.doc.name); - df.label = frm.doc.multi_currency ? (label + " in Account Currency") : label; + frm.fields_dict.accounts.grid.update_docfield_property( + fieldname, + 'label', + frm.doc.multi_currency ? (label + " in Account Currency") : label + ); }) }, diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 3419bb6c3e..ff2c8c29b4 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -564,6 +564,7 @@ class JournalEntry(AccountsController): if gl_map: make_gl_entries(gl_map, cancel=cancel, adv_adj=adv_adj, update_outstanding=update_outstanding) + @frappe.whitelist() def get_balance(self): if not self.get('accounts'): msgprint(_("'Entries' cannot be empty"), raise_exception=True) diff --git a/erpnext/accounts/doctype/monthly_distribution/monthly_distribution.py b/erpnext/accounts/doctype/monthly_distribution/monthly_distribution.py index 18f853cadc..88667d7207 100644 --- a/erpnext/accounts/doctype/monthly_distribution/monthly_distribution.py +++ b/erpnext/accounts/doctype/monthly_distribution/monthly_distribution.py @@ -8,6 +8,7 @@ from frappe.utils import (flt, add_months) from frappe.model.document import Document class MonthlyDistribution(Document): + @frappe.whitelist() def get_months(self): month_list = ['January','February','March','April','May','June','July','August','September', 'October','November','December'] 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 e6449b7831..29dc96e8c6 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 @@ -167,6 +167,7 @@ class OpeningInvoiceCreationTool(Document): return invoice + @frappe.whitelist() def make_invoices(self): self.validate_company() invoices = self.get_invoices() 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 bdfe532b9f..8d6de2d562 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,10 +6,12 @@ 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 setUp(self): if not frappe.db.exists("Company", "_Test Opening Invoice Company"): @@ -24,22 +26,25 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase): def test_opening_sales_invoice_creation(self): property_setter = make_property_setter("Sales Invoice", "update_stock", "default", 1, "Check") - invoices = self.make_invoices(company="_Test Opening Invoice Company") + 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]) + si = frappe.get_doc("Sales Invoice", invoices[0]) - # Check if update stock is not enabled - self.assertEqual(si.update_stock, 0) + # Check if update stock is not enabled + self.assertEqual(si.update_stock, 0) - property_setter.delete() + 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" @@ -143,4 +148,4 @@ def make_customer(customer=None): customer.insert(ignore_permissions=True) return customer.name else: - return frappe.db.exists("Customer", customer_name) \ No newline at end of file + return frappe.db.exists("Customer", customer_name) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index 6b07197ec1..08103184d5 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -234,8 +234,9 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext }); if (invoices) { - frappe.meta.get_docfield("Payment Reconciliation Payment", "invoice_number", - me.frm.doc.name).options = "\n" + invoices.join("\n"); + this.frm.fields_dict.payment.grid.update_docfield_property( + 'invoice_number', 'options', "\n" + invoices.join("\n") + ); $.each(me.frm.doc.payments || [], function(i, p) { if(!in_list(invoices, cstr(p.invoice_number))) p.invoice_number = null; diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index f7a15c04fa..cf6ec18f3b 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -11,6 +11,7 @@ from erpnext.accounts.utils import (get_outstanding_invoices, from erpnext.controllers.accounts_controller import get_advance_payment_entries class PaymentReconciliation(Document): + @frappe.whitelist() def get_unreconciled_entries(self): self.get_nonreconciled_payment_entries() self.get_invoice_entries() @@ -147,6 +148,7 @@ class PaymentReconciliation(Document): ent.currency = e.get('currency') ent.outstanding_amount = e.get('outstanding_amount') + @frappe.whitelist() def reconcile(self, args): for e in self.get('payments'): e.invoice_type = None @@ -197,6 +199,7 @@ class PaymentReconciliation(Document): 'difference_account': row.difference_account }) + @frappe.whitelist() def get_difference_amount(self, child_row): if child_row.get("reference_type") != 'Payment Entry': return 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 f5224a269e..a05e5984f5 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py @@ -18,7 +18,7 @@ class POSClosingEntry(StatusUpdater): self.validate_pos_closing() self.validate_pos_invoices() - + def validate_pos_closing(self): user = frappe.db.sql(""" SELECT name FROM `tabPOS Closing Entry` @@ -37,12 +37,12 @@ class POSClosingEntry(StatusUpdater): 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, + 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"))) @@ -68,14 +68,15 @@ class POSClosingEntry(StatusUpdater): frappe.throw(error_list, title=_("Invalid POS Invoices"), as_list=True) + @frappe.whitelist() 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) 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 40db09ec3b..b596c0cf25 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,12 +5,21 @@ 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) @@ -41,9 +50,6 @@ 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) @@ -84,8 +90,6 @@ class TestPOSClosingEntry(unittest.TestCase): self.assertEqual(si_doc.docstatus, 2) self.assertEqual(pos_inv1.status, 'Paid') - frappe.set_user("Administrator") - frappe.db.sql("delete from `tabPOS Profile`") def init_user_and_profile(**args): user = 'test@example.com' @@ -103,4 +107,4 @@ def init_user_and_profile(**args): 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_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 304e1f27c8..e614459252 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -220,7 +220,7 @@ 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: frappe.msgprint(_("Please enter Account for Change Amount"), raise_exception=1) @@ -354,6 +354,7 @@ class POSInvoice(SalesInvoice): return profile + @frappe.whitelist() def set_missing_values(self, for_validate=False): profile = self.set_pos_fields(for_validate) @@ -376,12 +377,20 @@ class POSInvoice(SalesInvoice): "allow_print_before_pay": profile.get("allow_print_before_pay") } + @frappe.whitelist() + 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") + @frappe.whitelist() def create_payment_request(self): for pay in self.payments: if pay.type == "Phone": diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index eb52fd6275..6d388c4aaa 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -9,8 +9,20 @@ from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profi 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): + @classmethod + def setUpClass(cls): + frappe.db.sql("delete from `tabTax Rule`") + + 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 @@ -370,7 +382,6 @@ class TestPOSInvoice(unittest.TestCase): pos_inv.load_from_db() rounded_total = frappe.db.get_value("Sales Invoice", pos_inv.consolidated_invoice, "rounded_total") self.assertEqual(rounded_total, 3470) - frappe.set_user("Administrator") 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 @@ -412,7 +423,6 @@ class TestPOSInvoice(unittest.TestCase): pos_inv.load_from_db() rounded_total = frappe.db.get_value("Sales Invoice", pos_inv.consolidated_invoice, "rounded_total") self.assertEqual(rounded_total, 840) - frappe.set_user("Administrator") def test_merging_with_validate_selling_price(self): from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile @@ -421,10 +431,12 @@ class TestPOSInvoice(unittest.TestCase): if not frappe.db.get_single_value("Selling Settings", "validate_selling_price"): frappe.db.set_value("Selling Settings", "Selling Settings", "validate_selling_price", 1) - make_purchase_receipt(item_code="_Test Item", warehouse="_Test Warehouse - _TC", qty=1, rate=300) + 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(rate=300, do_not_submit=1) + 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 }) @@ -438,7 +450,7 @@ class TestPOSInvoice(unittest.TestCase): }) self.assertRaises(frappe.ValidationError, pos_inv.submit) - pos_inv2 = create_pos_invoice(rate=400, do_not_submit=1) + 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 }) @@ -457,8 +469,6 @@ class TestPOSInvoice(unittest.TestCase): pos_inv2.load_from_db() rounded_total = frappe.db.get_value("Sales Invoice", pos_inv2.consolidated_invoice, "rounded_total") self.assertEqual(rounded_total, 400) - frappe.set_user("Administrator") - frappe.db.set_value("Selling Settings", "Selling Settings", "validate_selling_price", 0) def create_pos_invoice(**args): args = frappe._dict(args) @@ -508,4 +518,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_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 40f77b4088..6d2cffcf68 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -12,6 +12,7 @@ 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 +import json from six import iteritems @@ -78,8 +79,11 @@ class POSInvoiceMergeLog(Document): sales_invoice = self.merge_pos_invoice_into(sales_invoice, data) sales_invoice.is_consolidated = 1 + sales_invoice.set_posting_time = 1 + sales_invoice.posting_date = getdate(self.posting_date) sales_invoice.save() sales_invoice.submit() + self.consolidated_invoice = sales_invoice.name return sales_invoice.name @@ -91,10 +95,13 @@ class POSInvoiceMergeLog(Document): credit_note = self.merge_pos_invoice_into(credit_note, data) credit_note.is_consolidated = 1 + credit_note.set_posting_time = 1 + credit_note.posting_date = getdate(self.posting_date) # TODO: return could be against multiple sales invoice which could also have been consolidated? # credit_note.return_against = self.consolidated_invoice credit_note.save() credit_note.submit() + self.consolidated_credit_note = credit_note.name return credit_note.name @@ -131,12 +138,14 @@ class POSInvoiceMergeLog(Document): 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) + update_item_wise_tax_detail(t, tax) 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 + tax.item_wise_tax_detail = tax.item_wise_tax_detail taxes.append(tax) for payment in doc.get('payments'): @@ -168,11 +177,9 @@ class POSInvoiceMergeLog(Document): sales_invoice = frappe.new_doc('Sales Invoice') sales_invoice.customer = self.customer sales_invoice.is_pos = 1 - # date can be pos closing date? - sales_invoice.posting_date = getdate(nowdate()) return sales_invoice - + def update_pos_invoices(self, invoice_docs, sales_invoice='', credit_note=''): for doc in invoice_docs: doc.load_from_db() @@ -187,6 +194,26 @@ class POSInvoiceMergeLog(Document): si.flags.ignore_validate = True si.cancel() +def update_item_wise_tax_detail(consolidate_tax_row, tax_row): + consolidated_tax_detail = json.loads(consolidate_tax_row.item_wise_tax_detail) + tax_row_detail = json.loads(tax_row.item_wise_tax_detail) + + if not consolidated_tax_detail: + consolidated_tax_detail = {} + + for item_code, tax_data in tax_row_detail.items(): + if consolidated_tax_detail.get(item_code): + consolidated_tax_data = consolidated_tax_detail.get(item_code) + consolidated_tax_detail.update({ + item_code: [consolidated_tax_data[0], consolidated_tax_data[1] + tax_data[1]] + }) + else: + consolidated_tax_detail.update({ + item_code: [tax_data[0], tax_data[1]] + }) + + consolidate_tax_row.item_wise_tax_detail = json.dumps(consolidated_tax_detail, separators=(',', ':')) + def get_all_unconsolidated_invoices(): filters = { 'consolidated_invoice': [ 'in', [ '', None ]], @@ -214,7 +241,7 @@ def consolidate_pos_invoices(pos_invoices=[], closing_entry={}): 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) + enqueue_job(create_merge_logs, invoice_by_customer=invoice_by_customer, closing_entry=closing_entry) else: create_merge_logs(invoice_by_customer, closing_entry) @@ -227,21 +254,21 @@ def unconsolidate_pos_invoices(closing_entry): if len(merge_logs) >= 5: closing_entry.set_status(update=True, status='Queued') - enqueue_job(cancel_merge_logs, merge_logs, closing_entry) + enqueue_job(cancel_merge_logs, merge_logs=merge_logs, closing_entry=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.posting_date = getdate(closing_entry.get('posting_date')) 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() @@ -256,7 +283,7 @@ def cancel_merge_logs(merge_logs, 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): +def enqueue_job(job, merge_logs=None, invoice_by_customer=None, closing_entry=None): check_scheduler_status() job_name = closing_entry.get("name") @@ -269,6 +296,7 @@ def enqueue_job(job, invoice_by_customer, closing_entry): job_name=job_name, closing_entry=closing_entry, invoice_by_customer=invoice_by_customer, + merge_logs=merge_logs, now=frappe.conf.developer_mode or frappe.flags.in_test ) 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 db046c9800..040a815fab 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 @@ -5,6 +5,7 @@ from __future__ import unicode_literals import frappe import unittest +import json 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 consolidate_pos_invoices @@ -14,85 +15,136 @@ 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() - consolidate_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() - consolidate_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`") + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") + + def test_consolidated_invoice_item_taxes(self): frappe.db.sql("delete from `tabPOS Invoice`") + try: + inv = create_pos_invoice(qty=1, rate=100, do_not_save=True) + + inv.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 9 + }) + inv.insert() + inv.submit() + + inv2 = create_pos_invoice(qty=1, rate=100, do_not_save=True) + inv2.get('items')[0].item_code = '_Test Item 2' + inv2.append("taxes", { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "cost_center": "_Test Cost Center - _TC", + "description": "VAT", + "doctype": "Sales Taxes and Charges", + "rate": 5 + }) + inv2.insert() + inv2.submit() + + consolidate_pos_invoices() + inv.load_from_db() + + consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) + item_wise_tax_detail = json.loads(consolidated_invoice.get('taxes')[0].item_wise_tax_detail) + + tax_rate, amount = item_wise_tax_detail.get('_Test Item') + self.assertEqual(tax_rate, 9) + self.assertEqual(amount, 9) + + tax_rate2, amount2 = item_wise_tax_detail.get('_Test Item 2') + self.assertEqual(tax_rate2, 5) + self.assertEqual(amount2, 5) + 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_settings/pos_settings.js b/erpnext/accounts/doctype/pos_settings/pos_settings.js index 8890d59403..3625393a80 100644 --- a/erpnext/accounts/doctype/pos_settings/pos_settings.js +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.js @@ -16,8 +16,11 @@ frappe.ui.form.on('POS Settings', { } }); - frappe.meta.get_docfield("POS Field", "fieldname", frm.doc.name).options = [""].concat(fields); + frm.fields_dict.invoice_fields.grid.update_docfield_property( + 'fieldname', 'options', [""].concat(fields) + ); }); + } }); diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index c676abd4c6..b91a7a5bd2 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -471,7 +471,7 @@ def apply_pricing_rule_on_transaction(doc): if not d.get(pr_field): continue - if d.validate_applied_rule and doc.get(field) < d.get(pr_field): + if d.validate_applied_rule and doc.get(field) is not None and doc.get(field) < d.get(pr_field): frappe.msgprint(_("User has not applied rule on the invoice {0}") .format(doc.name)) else: diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html index e1ddeff61f..94ae79a0c6 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html @@ -38,22 +38,22 @@ {% endif %} - {{ frappe.utils.fmt_money(row.debit, filters.presentation_currency) }} + {{ frappe.utils.fmt_money(row.debit, currency=filters.presentation_currency) }} - {{ frappe.utils.fmt_money(row.credit, filters.presentation_currency) }} + {{ frappe.utils.fmt_money(row.credit, currency=filters.presentation_currency) }} {% else %} {{ frappe.format(row.account, {fieldtype: "Link"}) or " " }} - {{ row.account and frappe.utils.fmt_money(row.debit, filters.presentation_currency) }} + {{ row.account and frappe.utils.fmt_money(row.debit, currency=filters.presentation_currency) }} - {{ row.account and frappe.utils.fmt_money(row.credit, filters.presentation_currency) }} + {{ row.account and frappe.utils.fmt_money(row.credit, currency=filters.presentation_currency) }} {% endif %} - {{ frappe.utils.fmt_money(row.balance, filters.presentation_currency) }} + {{ frappe.utils.fmt_money(row.balance, currency=filters.presentation_currency) }} {% endfor %} diff --git a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py index 523e9ee08a..7d9302382f 100644 --- a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py +++ b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py @@ -9,7 +9,7 @@ from frappe.utils import cstr from frappe.model.naming import make_autoname from frappe.model.document import Document -pricing_rule_fields = ['apply_on', 'mixed_conditions', 'is_cumulative', 'other_item_code', 'other_item_group' +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', 'apply_multiple_pricing_rules'] @@ -111,4 +111,4 @@ def get_args_for_pricing_rule(doc): for d in pricing_rule_fields: args[d] = doc.get(d) - return args \ No newline at end of file + return args diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 66a8e206a8..e61cde8fd0 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -496,15 +496,6 @@ cur_frm.fields_dict['items'].grid.get_field('project').get_query = function(doc, } } -cur_frm.cscript.select_print_heading = function(doc,cdt,cdn){ - if(doc.select_print_heading){ - // print heading - cur_frm.pformat.print_heading = doc.select_print_heading; - } - else - cur_frm.pformat.print_heading = __("Purchase Invoice"); -} - frappe.ui.form.on("Purchase Invoice", { setup: function(frm) { frm.custom_make_buttons = { diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index ff35d677c4..2d5760b505 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -127,7 +127,6 @@ "write_off_cost_center", "advances_section", "allocate_advances_automatically", - "adjust_advance_taxes", "get_advances", "advances", "payment_schedule_section", @@ -1326,13 +1325,6 @@ "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", @@ -1378,7 +1370,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2021-03-09 21:15:30.422084", + "modified": "2021-03-30 22:45:58.334107", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index ded293b88d..50492f50b5 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -898,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() diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index b361c0c345..8a42d9e13c 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -1,9 +1,6 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -// print heading -cur_frm.pformat.print_heading = 'Invoice'; - {% include 'erpnext/selling/sales_common.js' %}; frappe.provide("erpnext.accounts"); @@ -916,7 +913,7 @@ frappe.ui.form.on('Sales Invoice Timesheet', { }, callback: function(r, rt) { if(r.message){ - data = r.message; + let data = r.message; frappe.model.set_value(cdt, cdn, "billing_hours", data.billing_hours); frappe.model.set_value(cdt, cdn, "billing_amount", data.billing_amount); frappe.model.set_value(cdt, cdn, "timesheet_detail", data.timesheet_detail); diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index d382386a32..c6c67b4ddc 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -118,6 +118,7 @@ "in_words", "total_advance", "outstanding_amount", + "disable_rounded_total", "advances_section", "allocate_advances_automatically", "get_advances", @@ -1109,6 +1110,7 @@ "reqd": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounding_adjustment", "fieldtype": "Currency", "hide_days": 1, @@ -1120,6 +1122,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounded_total", "fieldtype": "Currency", "hide_days": 1, @@ -1168,6 +1171,7 @@ "reqd": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounding_adjustment", "fieldtype": "Currency", "hide_days": 1, @@ -1180,6 +1184,7 @@ }, { "bold": 1, + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounded_total", "fieldtype": "Currency", "hide_days": 1, @@ -1945,6 +1950,13 @@ "fieldtype": "Link", "label": "Set Target Warehouse", "options": "Warehouse" + }, + { + "default": "0", + "depends_on": "grand_total", + "fieldname": "disable_rounded_total", + "fieldtype": "Check", + "label": "Disable Rounded Total" } ], "icon": "fa fa-file-text", @@ -1957,7 +1969,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2021-03-31 15:42:26.261540", + "modified": "2021-04-15 23:57:58.766651", "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 14a3e4129f..4461f29fe3 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -46,7 +46,6 @@ class SalesInvoice(SellingController): 'target_parent_dt': 'Sales Order', 'target_parent_field': 'per_billed', 'source_field': 'amount', - 'join_field': 'so_detail', 'percent_join_field': 'sales_order', 'status_field': 'billing_status', 'keyword': 'Billed', @@ -77,7 +76,7 @@ class SalesInvoice(SellingController): if not self.is_pos: self.so_dn_required() - + self.set_tax_withholding() self.validate_proj_cust() @@ -276,7 +275,7 @@ class SalesInvoice(SellingController): pluck="pos_closing_entry" ) if pos_closing_entry: - msg = _("To cancel a {} you need to cancel the POS Closing Entry {}. ").format( + 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]) ) @@ -394,6 +393,7 @@ class SalesInvoice(SellingController): if validate_against_credit_limit: check_credit_limit(self.customer, self.company, bypass_credit_limit_check_at_sales_order) + @frappe.whitelist() def set_missing_values(self, for_validate=False): pos = self.set_pos_fields(for_validate) @@ -548,12 +548,12 @@ class SalesInvoice(SellingController): frappe.throw(_("Debit To is required"), title=_("Account Missing")) if account.report_type != "Balance Sheet": - msg = _("Please ensure {} account is a Balance Sheet account. ").format(frappe.bold("Debit To")) + 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": - msg = _("Please ensure {} account is a Receivable account. ").format(frappe.bold("Debit To")) + 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")) @@ -733,6 +733,7 @@ class SalesInvoice(SellingController): else: self.calculate_billing_amount_for_timesheet() + @frappe.whitelist() def add_timesheet_data(self): self.set('timesheets', []) if self.project: @@ -1290,6 +1291,7 @@ class SalesInvoice(SellingController): break # Healthcare + @frappe.whitelist() def set_healthcare_services(self, checked_values): self.set("items", []) from erpnext.stock.get_item_details import get_item_details diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index dd08e844b7..9059d0b040 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1802,6 +1802,15 @@ class TestSalesInvoice(unittest.TestCase): 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() @@ -1870,7 +1879,17 @@ class TestSalesInvoice(unittest.TestCase): def test_einvoice_submission_without_irn(self): # init - frappe.db.set_value('E Invoice Settings', 'E Invoice Settings', 'enable', 1) + einvoice_settings = frappe.get_doc('E Invoice Settings') + einvoice_settings.enable = 1 + einvoice_settings.applicable_from = nowdate() + einvoice_settings.append('credentials', { + 'company': '_Test Company', + 'gstin': '27AAECE4835E1ZR', + 'username': 'test', + 'password': 'test' + }) + einvoice_settings.save() + country = frappe.flags.country frappe.flags.country = 'India' @@ -1881,7 +1900,8 @@ class TestSalesInvoice(unittest.TestCase): si.submit() # reset - frappe.db.set_value('E Invoice Settings', 'E Invoice Settings', 'enable', 0) + einvoice_settings = frappe.get_doc('E Invoice Settings') + einvoice_settings.enable = 0 frappe.flags.country = country def test_einvoice_json(self): @@ -2272,4 +2292,4 @@ def add_taxes(doc): "cost_center": "Main - TCP1", "description": "Excise Duty", "rate": 12 - }) \ No newline at end of file + }) 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 429a9f3591..52d19d54a8 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 @@ -46,5 +46,5 @@ def validate_disabled(doc): 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}): + 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/tax_rule/test_tax_rule.py b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py index 632e30db45..ac1ffd9e75 100644 --- a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py @@ -14,10 +14,15 @@ test_records = frappe.get_test_records('Tax Rule') from six import iteritems class TestTaxRule(unittest.TestCase): - def setUp(self): + @classmethod + def setUpClass(cls): + frappe.db.set_value("Shopping Cart Settings", None, "enabled", 0) + + @classmethod + def tearDownClass(cls): frappe.db.sql("delete from `tabTax Rule`") - def tearDown(self): + def setUp(self): frappe.db.sql("delete from `tabTax Rule`") def test_conflict(self): 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 961bdb147f..09db7fee2b 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -251,7 +251,7 @@ def get_tds_amount(ldc, parties, inv, tax_details, fiscal_year_details, tax_dedu 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 ((threshold and inv.net_total >= 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, 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 9ce8e3fe83..0cea7612dd 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 @@ -87,50 +87,6 @@ class TestTaxWithholdingCategory(unittest.TestCase): for d in invoices: d.cancel() - def test_single_threshold_tds_with_previous_vouchers(self): - invoices = [] - frappe.db.set_value("Supplier", "Test TDS Supplier2", "tax_withholding_category", "Single Threshold TDS") - pi = create_purchase_invoice(supplier="Test TDS Supplier2") - pi.submit() - invoices.append(pi) - - pi = create_purchase_invoice(supplier="Test TDS Supplier2") - 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_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 = [] @@ -177,7 +133,7 @@ def cancel_invoices(): for d in purchase_invoices: frappe.get_doc('Purchase Invoice', d).cancel() - + for d in sales_invoices: frappe.get_doc('Sales Invoice', d).cancel() @@ -229,7 +185,8 @@ def create_sales_invoice(**args): 'qty': args.qty or 1, 'rate': args.rate or 10000, 'cost_center': 'Main - _TC', - 'expense_account': 'Cost of Goods Sold - _TC' + 'expense_account': 'Cost of Goods Sold - _TC', + 'warehouse': args.warehouse or '_Test Warehouse - _TC' }] }) @@ -353,4 +310,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/utils.py b/erpnext/accounts/utils.py index 89a05b187d..5a64e27ccb 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -406,9 +406,10 @@ def check_if_advance_entry_modified(args): throw(_("""Payment Entry has been modified after you pulled it. Please pull it again.""")) def validate_allocated_amount(args): + precision = args.get('precision') or frappe.db.get_single_value("System Settings", "currency_precision") if args.get("allocated_amount") < 0: throw(_("Allocated amount cannot be negative")) - elif args.get("allocated_amount") > args.get("unadjusted_amount"): + elif flt(args.get("allocated_amount"), precision) > flt(args.get("unadjusted_amount"), precision): throw(_("Allocated amount cannot be greater than unadjusted amount")) def update_reference_in_journal_entry(d, jv_obj): diff --git a/erpnext/agriculture/doctype/crop_cycle/crop_cycle.py b/erpnext/agriculture/doctype/crop_cycle/crop_cycle.py index afbd9b4e6e..9000dea913 100644 --- a/erpnext/agriculture/doctype/crop_cycle/crop_cycle.py +++ b/erpnext/agriculture/doctype/crop_cycle/crop_cycle.py @@ -71,6 +71,7 @@ class CropCycle(Document): "exp_end_date": add_days(start_date, crop_task.get("end_day") - 1) }).insert() + @frappe.whitelist() def reload_linked_analysis(self): linked_doctypes = ['Soil Texture', 'Soil Analysis', 'Plant Analysis'] required_fields = ['location', 'name', 'collection_datetime'] @@ -87,6 +88,7 @@ class CropCycle(Document): frappe.publish_realtime("List of Linked Docs", output, user=frappe.session.user) + @frappe.whitelist() def append_to_child(self, obj_to_append): for doctype in obj_to_append: for doc_name in set(obj_to_append[doctype]): diff --git a/erpnext/agriculture/doctype/fertilizer/fertilizer.py b/erpnext/agriculture/doctype/fertilizer/fertilizer.py index dc2781cf00..9cb492aff1 100644 --- a/erpnext/agriculture/doctype/fertilizer/fertilizer.py +++ b/erpnext/agriculture/doctype/fertilizer/fertilizer.py @@ -7,6 +7,7 @@ import frappe from frappe.model.document import Document class Fertilizer(Document): + @frappe.whitelist() def load_contents(self): docs = frappe.get_all("Agriculture Analysis Criteria", filters={'linked_doctype':'Fertilizer'}) for doc in docs: diff --git a/erpnext/agriculture/doctype/plant_analysis/plant_analysis.py b/erpnext/agriculture/doctype/plant_analysis/plant_analysis.py index 304727e04f..2806cc6523 100644 --- a/erpnext/agriculture/doctype/plant_analysis/plant_analysis.py +++ b/erpnext/agriculture/doctype/plant_analysis/plant_analysis.py @@ -8,6 +8,7 @@ from frappe.model.naming import make_autoname from frappe.model.document import Document class PlantAnalysis(Document): + @frappe.whitelist() def load_contents(self): docs = frappe.get_all("Agriculture Analysis Criteria", filters={'linked_doctype':'Plant Analysis'}) for doc in docs: diff --git a/erpnext/agriculture/doctype/soil_analysis/soil_analysis.py b/erpnext/agriculture/doctype/soil_analysis/soil_analysis.py index 17b96a0ac1..37835f8c7b 100644 --- a/erpnext/agriculture/doctype/soil_analysis/soil_analysis.py +++ b/erpnext/agriculture/doctype/soil_analysis/soil_analysis.py @@ -7,6 +7,7 @@ import frappe from frappe.model.document import Document class SoilAnalysis(Document): + @frappe.whitelist() def load_contents(self): docs = frappe.get_all("Agriculture Analysis Criteria", filters={'linked_doctype':'Soil Analysis'}) for doc in docs: diff --git a/erpnext/agriculture/doctype/soil_texture/soil_texture.py b/erpnext/agriculture/doctype/soil_texture/soil_texture.py index 8c1d7ed5ac..209b2c8598 100644 --- a/erpnext/agriculture/doctype/soil_texture/soil_texture.py +++ b/erpnext/agriculture/doctype/soil_texture/soil_texture.py @@ -13,6 +13,7 @@ class SoilTexture(Document): soil_edit_order = [2, 1, 0] soil_types = ['clay_composition', 'sand_composition', 'silt_composition'] + @frappe.whitelist() def load_contents(self): docs = frappe.get_all("Agriculture Analysis Criteria", filters={'linked_doctype':'Soil Texture'}) for doc in docs: @@ -26,6 +27,7 @@ class SoilTexture(Document): if sum(self.get(soil_type) for soil_type in self.soil_types) != 100: frappe.throw(_('Soil compositions do not add up to 100')) + @frappe.whitelist() def update_soil_edit(self, soil_type): self.soil_edit_order[self.soil_types.index(soil_type)] = max(self.soil_edit_order)+1 self.soil_type = self.get_soil_type() @@ -35,8 +37,8 @@ class SoilTexture(Document): if sum(self.soil_edit_order) < 5: return last_edit_index = self.soil_edit_order.index(min(self.soil_edit_order)) - # set composition of the last edited soil - self.set( self.soil_types[last_edit_index], + # set composition of the last edited soil + self.set(self.soil_types[last_edit_index], 100 - sum(cint(self.get(soil_type)) for soil_type in self.soil_types) + cint(self.get(self.soil_types[last_edit_index]))) # calculate soil type @@ -67,4 +69,4 @@ class SoilTexture(Document): elif (c >= 40 and sa <= 45 and si < 40): return 'Clay' else: - return 'Select' \ No newline at end of file + return 'Select' diff --git a/erpnext/agriculture/doctype/water_analysis/water_analysis.py b/erpnext/agriculture/doctype/water_analysis/water_analysis.py index 88f1fbd9cc..d9f007cea1 100644 --- a/erpnext/agriculture/doctype/water_analysis/water_analysis.py +++ b/erpnext/agriculture/doctype/water_analysis/water_analysis.py @@ -9,11 +9,13 @@ from frappe.model.document import Document from frappe import _ class WaterAnalysis(Document): + @frappe.whitelist() def load_contents(self): docs = frappe.get_all("Agriculture Analysis Criteria", filters={'linked_doctype':'Water Analysis'}) for doc in docs: self.append('water_analysis_criteria', {'title': str(doc.name)}) + @frappe.whitelist() def update_lab_result_date(self): if not self.result_datetime: self.result_datetime = self.laboratory_testing_datetime diff --git a/erpnext/agriculture/doctype/weather/weather.py b/erpnext/agriculture/doctype/weather/weather.py index 938daa207e..235e684e51 100644 --- a/erpnext/agriculture/doctype/weather/weather.py +++ b/erpnext/agriculture/doctype/weather/weather.py @@ -7,6 +7,7 @@ import frappe from frappe.model.document import Document class Weather(Document): + @frappe.whitelist() def load_contents(self): docs = frappe.get_all("Agriculture Analysis Criteria", filters={'linked_doctype':'Weather'}) for doc in docs: diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index e8e8ec6cc0..9aff1440d6 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -553,6 +553,7 @@ class Asset(AccountsController): make_gl_entries(gl_entries) self.db_set('booked_fixed_asset', 1) + @frappe.whitelist() def get_depreciation_rate(self, args, on_validate=False): if isinstance(args, string_types): args = json.loads(args) diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 248cb9a8a0..630a1dc8cd 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -13,6 +13,8 @@ "po_required", "pr_required", "maintain_same_rate", + "maintain_same_rate_action", + "role_to_override_stop_action", "allow_multiple_items", "subcontract", "backflush_raw_materials_of_subcontract_based_on", @@ -89,6 +91,23 @@ { "fieldname": "column_break_11", "fieldtype": "Column Break" + }, + { + "default": "Stop", + "depends_on": "maintain_same_rate", + "description": "Configure the action to stop the transaction or just warn if the same rate is not maintained.", + "fieldname": "maintain_same_rate_action", + "fieldtype": "Select", + "label": "Action If Same Rate is Not Maintained", + "mandatory_depends_on": "maintain_same_rate", + "options": "Stop\nWarn" + }, + { + "depends_on": "eval:doc.maintain_same_rate_action == 'Stop'", + "fieldname": "role_to_override_stop_action", + "fieldtype": "Link", + "label": "Role Allowed to Override Stop Action", + "options": "Role" } ], "icon": "fa fa-cog", @@ -96,7 +115,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-03-02 17:34:04.190677", + "modified": "2021-04-04 20:01:44.087066", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 29a8d59cb0..ef9372eeb6 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -133,6 +133,7 @@ class PurchaseOrder(BuyingController): d.material_request_item, "schedule_date") + @frappe.whitelist() def get_last_purchase_rate(self): """get last purchase rates for all items""" @@ -367,7 +368,6 @@ def make_purchase_receipt(source_name, target_doc=None): "Purchase Order": { "doctype": "Purchase Receipt", "field_map": { - "per_billed": "per_billed", "supplier_warehouse":"supplier_warehouse" }, "validation": { diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 1b231b3acb..3c4f908ee4 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -778,7 +778,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", 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 5baf6939cd..1dbd7c60c3 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -56,6 +56,8 @@ "base_net_amount", "warehouse_and_reference", "warehouse", + "actual_qty", + "company_total_stock", "material_request", "material_request_item", "sales_order", @@ -743,6 +745,22 @@ "options": "currency", "read_only": 1 }, + { + "allow_on_submit": 1, + "fieldname": "actual_qty", + "fieldtype": "Float", + "label": "Available Qty at Warehouse", + "print_hide": 1, + "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "company_total_stock", + "fieldtype": "Float", + "label": "Available Qty at Company", + "no_copy": 1, + "read_only": 1 + }, { "collapsible": 1, "fieldname": "discount_and_margin_section", @@ -791,7 +809,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-02-23 01:00:27.132705", + "modified": "2021-03-22 11:46:12.357435", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", 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 7cf22f87e4..b530d1ab24 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -66,6 +66,7 @@ class RequestforQuotation(BuyingController): def on_cancel(self): frappe.db.set(self, 'status', 'Cancelled') + @frappe.whitelist() def get_supplier_email_preview(self, supplier): """Returns formatted email preview as string.""" rfq_suppliers = list(filter(lambda row: row.supplier == supplier, self.suppliers)) diff --git a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py index 6e6eaed95d..2528240549 100644 --- a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py +++ b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py @@ -9,9 +9,7 @@ import unittest class TestSupplierScorecard(unittest.TestCase): def test_create_scorecard(self): - delete_test_scorecards() - my_doc = make_supplier_scorecard() - doc = my_doc.insert() + doc = make_supplier_scorecard().insert() self.assertEqual(doc.name, valid_scorecard[0].get("supplier")) def test_criteria_weight(self): @@ -121,7 +119,8 @@ valid_scorecard = [ { "weight":100.0, "doctype":"Supplier Scorecard Scoring Criteria", - "criteria_name":"Delivery" + "criteria_name":"Delivery", + "formula": "100" } ], "supplier":"_Test Supplier", diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 5e4d58e893..d36e7b03f4 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -517,6 +517,7 @@ class AccountsController(TransactionBase): frappe.db.sql("""delete from `tab%s` where parentfield=%s and parent = %s and allocated_amount = 0""" % (childtype, '%s', '%s'), (parentfield, self.name)) + @frappe.whitelist() def apply_shipping_rule(self): if self.shipping_rule: shipping_rule = frappe.get_doc("Shipping Rule", self.shipping_rule) @@ -537,6 +538,7 @@ class AccountsController(TransactionBase): return {} + @frappe.whitelist() def set_advances(self): """Returns list of advances against Account, Party, Reference""" @@ -657,6 +659,7 @@ class AccountsController(TransactionBase): 'dr_or_cr': dr_or_cr, 'unadjusted_amount': flt(d.advance_amount), 'allocated_amount': flt(d.allocated_amount), + 'precision': d.precision('advance_amount'), 'exchange_rate': (self.conversion_rate if self.party_account_currency != self.company_currency else 1), 'grand_total': (self.base_grand_total @@ -714,7 +717,9 @@ class AccountsController(TransactionBase): total_billed_amt = abs(total_billed_amt) max_allowed_amt = abs(max_allowed_amt) - if total_billed_amt - max_allowed_amt > 0.01: + role_allowed_to_over_bill = frappe.db.get_single_value('Accounts Settings', 'role_allowed_to_over_bill') + + if total_billed_amt - max_allowed_amt > 0.01 and role_allowed_to_over_bill not in frappe.get_roles(): frappe.throw(_("Cannot overbill for Item {0} in row {1} more than {2}. To allow over-billing, please set allowance in Accounts Settings") .format(item.item_code, item.idx, max_allowed_amt)) @@ -1444,7 +1449,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) def get_new_child_item(item_row): - child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item" + 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): diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 219d5295c3..b686dc026c 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -6,6 +6,7 @@ import frappe from frappe import _, msgprint from frappe.utils import flt,cint, cstr, getdate from six import iteritems +from collections import OrderedDict 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 @@ -391,10 +392,12 @@ class BuyingController(StockController): 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, item.purchase_order) + for batch_data in batches_qty: qty = batch_data['qty'] raw_material.batch_no = batch_data['batch'] - self.append_raw_material_to_be_backflushed(item, raw_material, qty) + if qty > 0: + self.append_raw_material_to_be_backflushed(item, raw_material, qty) else: self.append_raw_material_to_be_backflushed(item, raw_material, qty) @@ -1056,7 +1059,7 @@ def get_transferred_batch_qty_map(purchase_order, fg_item): for batch_data in transferred_batches: 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.setdefault(key, OrderedDict()) transferred_batch_qty_map[key][batch_data.batch_no] = batch_data.qty return transferred_batch_qty_map @@ -1109,8 +1112,14 @@ def get_batches_with_qty(item_code, fg_item, required_qty, transferred_batch_qty if available_qty >= required_qty: available_batches.append({'batch': batch, 'qty': required_qty}) break - else: + elif available_qty != 0: available_batches.append({'batch': batch, 'qty': available_qty}) required_qty -= available_qty + for row in available_batches: + if backflushed_batches.get(row.get('batch'), 0) > 0: + backflushed_batches[row.get('batch')] += row.get('qty') + else: + backflushed_batches[row.get('batch')] = row.get('qty') + return available_batches diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 81f0ad3fed..bc1ac5ea06 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -325,7 +325,7 @@ def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len, and status not in ("Stopped", "Closed") %(fcond)s and ( (`tabDelivery Note`.is_return = 0 and `tabDelivery Note`.per_billed < 100) - or `tabDelivery Note`.grand_total = 0 + or (`tabDelivery Note`.grand_total = 0 and `tabDelivery Note`.per_billed < 100) or ( `tabDelivery Note`.is_return = 1 and return_against in (select name from `tabDelivery Note` where per_billed < 100) @@ -713,7 +713,9 @@ def get_tax_template(doctype, txt, searchfield, start, page_len, filters): return [(d,) for d in set(taxes)] -def get_fields(doctype, fields=[]): +def get_fields(doctype, fields=None): + if fields is None: + fields = [] meta = frappe.get_meta(doctype) fields.extend(meta.get_search_fields()) diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 6d921625b7..54156f379c 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -144,7 +144,7 @@ class SellingController(StockController): if sales_person.commission_rate: sales_person.incentives = flt( - sales_person.allocated_amount * flt(sales_person.commission_rate) / 100.0, + sales_person.allocated_amount * flt(sales_person.commission_rate) / 100.0, self.precision("incentives", sales_person)) total += sales_person.allocated_percentage @@ -504,4 +504,4 @@ def set_default_income_account_for_item(obj): for d in obj.get("items"): if d.item_code: if getattr(d, "income_account", None): - set_item_default(d.item_code, obj.company, 'income_account', d.income_account) \ No newline at end of file + set_item_default(d.item_code, obj.company, 'income_account', d.income_account) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 0987d0985e..5276da9720 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -201,10 +201,14 @@ class StatusUpdater(Document): get_allowance_for(item['item_code'], self.item_allowance, self.global_qty_allowance, self.global_amount_allowance, qty_or_amount) - overflow_percent = ((item[args['target_field']] - item[args['target_ref_field']]) / - item[args['target_ref_field']]) * 100 + role_allowed_to_over_deliver_receive = frappe.db.get_single_value('Stock Settings', 'role_allowed_to_over_deliver_receive') + role_allowed_to_over_bill = frappe.db.get_single_value('Accounts Settings', 'role_allowed_to_over_bill') + role = role_allowed_to_over_deliver_receive if qty_or_amount == 'qty' else role_allowed_to_over_bill - if overflow_percent - allowance > 0.01: + overflow_percent = ((item[args['target_field']] - item[args['target_ref_field']]) / + item[args['target_ref_field']]) * 100 + + if overflow_percent - allowance > 0.01 and role not in frappe.get_roles(): item['max_allowed'] = flt(item[args['target_ref_field']] * (100+allowance)/100) item['reduce_by'] = item[args['target_field']] - item['max_allowed'] @@ -371,10 +375,12 @@ class StatusUpdater(Document): ref_doc.db_set("per_billed", per_billed) ref_doc.set_status(update=True) -def get_allowance_for(item_code, item_allowance={}, global_qty_allowance=None, global_amount_allowance=None, qty_or_amount="qty"): +def get_allowance_for(item_code, item_allowance=None, global_qty_allowance=None, global_amount_allowance=None, qty_or_amount="qty"): """ Returns the allowance for the item, if not set, returns global allowance """ + if item_allowance is None: + item_allowance = {} if qty_or_amount == "qty": if item_allowance.get(item_code, frappe._dict()).get("qty"): return item_allowance[item_code].qty, item_allowance, global_qty_allowance, global_amount_allowance diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 20499579ca..b14c274515 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -117,7 +117,6 @@ class StockController(AccountsController): "account": expense_account, "against": warehouse_account[sle.warehouse]["account"], "cost_center": item_row.cost_center, - "project": item_row.project or self.get('project'), "remarks": self.get("remarks") or "Accounting Entry for Stock", "credit": flt(sle.stock_value_difference, precision), "project": item_row.get("project") or self.get("project"), @@ -483,7 +482,7 @@ class StockController(AccountsController): ) 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) + message += _("Please adjust the qty or edit {0} to proceed.").format(rule_link) return message def repost_future_sle_and_gle(self): diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 407438c8a5..9fae49482d 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -149,7 +149,9 @@ class calculate_taxes_and_totals(object): validate_taxes_and_charges(tax) validate_inclusive_tax(tax, self.doc) - tax.item_wise_tax_detail = {} + if not self.doc.get('is_consolidated'): + tax.item_wise_tax_detail = {} + tax_fields = ["total", "tax_amount_after_discount_amount", "tax_amount_for_current_item", "grand_total_for_current_item", "tax_fraction_for_current_item", "grand_total_fraction_for_current_item"] @@ -289,10 +291,13 @@ class calculate_taxes_and_totals(object): # set precision in the last item iteration if n == len(self.doc.get("items")) - 1: self.round_off_totals(tax) + self._set_in_company_currency(tax, + ["tax_amount", "tax_amount_after_discount_amount"]) + + self.round_off_base_values(tax) self.set_cumulative_total(i, tax) - self._set_in_company_currency(tax, - ["total", "tax_amount", "tax_amount_after_discount_amount"]) + self._set_in_company_currency(tax, ["total"]) # adjust Discount Amount loss in last tax iteration if i == (len(self.doc.get("taxes")) - 1) and self.discount_amount_applied \ @@ -339,18 +344,11 @@ 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) + if not self.doc.get("is_consolidated"): + 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 @@ -361,10 +359,20 @@ class calculate_taxes_and_totals(object): tax.item_wise_tax_detail[key] = [tax_rate,flt(item_wise_tax_amount)] def round_off_totals(self, tax): + if tax.account_head in frappe.flags.round_off_applicable_accounts: + tax.tax_amount = round(tax.tax_amount, 0) + tax.tax_amount_after_discount_amount = round(tax.tax_amount_after_discount_amount, 0) + tax.tax_amount = flt(tax.tax_amount, tax.precision("tax_amount")) tax.tax_amount_after_discount_amount = flt(tax.tax_amount_after_discount_amount, tax.precision("tax_amount")) + def round_off_base_values(self, tax): + # Round off to nearest integer based on regional settings + if tax.account_head in frappe.flags.round_off_applicable_accounts: + tax.base_tax_amount = round(tax.base_tax_amount, 0) + tax.base_tax_amount_after_discount_amount = round(tax.base_tax_amount_after_discount_amount, 0) + def manipulate_grand_total_for_inclusive_tax(self): # if fully inclusive taxes and diff if self.doc.get("taxes") and any([cint(t.included_in_print_rate) for t in self.doc.get("taxes")]): @@ -442,8 +450,9 @@ class calculate_taxes_and_totals(object): self._set_in_company_currency(self.doc, ["rounding_adjustment", "rounded_total"]) def _cleanup(self): - for tax in self.doc.get("taxes"): - tax.item_wise_tax_detail = json.dumps(tax.item_wise_tax_detail, separators=(',', ':')) + if not self.doc.get('is_consolidated'): + for tax in self.doc.get("taxes"): + tax.item_wise_tax_detail = json.dumps(tax.item_wise_tax_detail, separators=(',', ':')) def set_discount_amount(self): if self.doc.additional_discount_percentage: @@ -810,4 +819,4 @@ class init_landed_taxes_and_totals(object): 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 + d.base_amount = flt(d.amount * flt(d.exchange_rate), d.precision("base_amount")) diff --git a/erpnext/selling/doctype/lead_source/__init__.py b/erpnext/crm/doctype/lead_source/__init__.py similarity index 100% rename from erpnext/selling/doctype/lead_source/__init__.py rename to erpnext/crm/doctype/lead_source/__init__.py diff --git a/erpnext/crm/doctype/lead_source/lead_source.js b/erpnext/crm/doctype/lead_source/lead_source.js new file mode 100644 index 0000000000..3cbe649209 --- /dev/null +++ b/erpnext/crm/doctype/lead_source/lead_source.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('Lead Source', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/crm/doctype/lead_source/lead_source.json b/erpnext/crm/doctype/lead_source/lead_source.json new file mode 100644 index 0000000000..723c6d993d --- /dev/null +++ b/erpnext/crm/doctype/lead_source/lead_source.json @@ -0,0 +1,62 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:source_name", + "creation": "2016-09-16 01:47:47.382372", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "source_name", + "details" + ], + "fields": [ + { + "fieldname": "source_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Source Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "details", + "fieldtype": "Text Editor", + "label": "Details" + } + ], + "links": [], + "modified": "2021-02-08 12:51:48.971517", + "modified_by": "Administrator", + "module": "CRM", + "name": "Lead Source", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/selling/doctype/lead_source/lead_source.py b/erpnext/crm/doctype/lead_source/lead_source.py similarity index 71% rename from erpnext/selling/doctype/lead_source/lead_source.py rename to erpnext/crm/doctype/lead_source/lead_source.py index d2d7558621..5c64fb8b4a 100644 --- a/erpnext/selling/doctype/lead_source/lead_source.py +++ b/erpnext/crm/doctype/lead_source/lead_source.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2021, 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 LeadSource(Document): diff --git a/erpnext/selling/doctype/lead_source/test_lead_source.py b/erpnext/crm/doctype/lead_source/test_lead_source.py similarity index 52% rename from erpnext/selling/doctype/lead_source/test_lead_source.py rename to erpnext/crm/doctype/lead_source/test_lead_source.py index 42df18f181..b5bc6490cf 100644 --- a/erpnext/selling/doctype/lead_source/test_lead_source.py +++ b/erpnext/crm/doctype/lead_source/test_lead_source.py @@ -1,12 +1,10 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt from __future__ import unicode_literals -import frappe +# import frappe import unittest -# test_records = frappe.get_test_records('Lead Source') - class TestLeadSource(unittest.TestCase): pass diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py index 377e061fdf..d8c6fb4f90 100644 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py @@ -11,7 +11,8 @@ from frappe.utils.file_manager import get_file, get_file_path from six.moves.urllib.parse import urlencode class LinkedInSettings(Document): - def get_authorization_url(self): + @frappe.whitelist() + def get_authorization_url(self): params = urlencode({ "response_type":"code", "client_id": self.consumer_key, @@ -35,7 +36,7 @@ class LinkedInSettings(Document): headers = { "Content-Type": "application/x-www-form-urlencoded" } - + response = self.http_post(url=url, data=body, headers=headers) response = frappe.parse_json(response.content.decode()) self.db_set("access_token", response["access_token"]) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 47b05f306b..23ad98a282 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -85,6 +85,7 @@ class Opportunity(TransactionBase): self.opportunity_from = "Lead" self.party_name = lead_name + @frappe.whitelist() def declare_enquiry_lost(self, lost_reasons_list, detailed_reason=None): if not self.has_active_quotation(): frappe.db.set(self, 'status', 'Lost') @@ -248,7 +249,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/twitter_settings/twitter_settings.py b/erpnext/crm/doctype/twitter_settings/twitter_settings.py index 976a23dfc7..1e1beab2d2 100644 --- a/erpnext/crm/doctype/twitter_settings/twitter_settings.py +++ b/erpnext/crm/doctype/twitter_settings/twitter_settings.py @@ -11,6 +11,7 @@ from frappe.utils import get_url_to_form, get_link_to_form from tweepy.error import TweepError class TwitterSettings(Document): + @frappe.whitelist() def get_authorize_url(self): callback_url = "{0}/api/method/erpnext.crm.doctype.twitter_settings.twitter_settings.callback?".format(frappe.utils.get_url()) auth = tweepy.OAuthHandler(self.consumer_key, self.get_password(fieldname="consumer_secret"), callback_url) @@ -21,12 +22,12 @@ class TwitterSettings(Document): frappe.msgprint(_("Error! Failed to get request token.")) frappe.throw(_('Invalid {0} or {1}').format(frappe.bold("Consumer Key"), frappe.bold("Consumer Secret Key"))) - + def get_access_token(self, oauth_token, oauth_verifier): auth = tweepy.OAuthHandler(self.consumer_key, self.get_password(fieldname="consumer_secret")) - auth.request_token = { + auth.request_token = { 'oauth_token' : oauth_token, - 'oauth_token_secret' : oauth_verifier + 'oauth_token_secret' : oauth_verifier } try: @@ -50,10 +51,10 @@ class TwitterSettings(Document): frappe.throw(_('Invalid Consumer Key or Consumer Secret Key')) def get_api(self, access_token, access_token_secret): - # authentication of consumer key and secret - auth = tweepy.OAuthHandler(self.consumer_key, self.get_password(fieldname="consumer_secret")) - # authentication of access token and secret - auth.set_access_token(access_token, access_token_secret) + # authentication of consumer key and secret + auth = tweepy.OAuthHandler(self.consumer_key, self.get_password(fieldname="consumer_secret")) + # authentication of access token and secret + auth.set_access_token(access_token, access_token_secret) return tweepy.API(auth) @@ -64,7 +65,7 @@ class TwitterSettings(Document): if media: media_id = self.upload_image(media) return self.send_tweet(text, media_id) - + def upload_image(self, media): media = get_file_path(media) api = self.get_api(self.access_token, self.access_token_secret) diff --git a/erpnext/education/doctype/course_enrollment/course_enrollment.py b/erpnext/education/doctype/course_enrollment/course_enrollment.py index f7aa6e9fc1..2b3acf1b93 100644 --- a/erpnext/education/doctype/course_enrollment/course_enrollment.py +++ b/erpnext/education/doctype/course_enrollment/course_enrollment.py @@ -41,7 +41,7 @@ class CourseEnrollment(Document): 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): + def add_quiz_activity(self, quiz_name, quiz_response, answers, score, status, time_taken): result = {k: ('Correct' if v else 'Wrong') for k,v in answers.items()} result_data = [] for key in answers: @@ -66,7 +66,8 @@ class CourseEnrollment(Document): "activity_date": frappe.utils.datetime.datetime.now(), "result": result_data, "score": score, - "status": status + "status": status, + "time_taken": time_taken }).insert(ignore_permissions = True) def add_activity(self, content_type, content): diff --git a/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py b/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py index 97c29ab667..6a0dcf460a 100644 --- a/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py +++ b/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py @@ -13,6 +13,7 @@ from erpnext.education.utils import OverlapError class CourseSchedulingTool(Document): + @frappe.whitelist() def schedule_course(self): """Creates course schedules as per specified parameters""" diff --git a/erpnext/education/doctype/fee_schedule/fee_schedule.py b/erpnext/education/doctype/fee_schedule/fee_schedule.py index 1543acdca9..0b025c7534 100644 --- a/erpnext/education/doctype/fee_schedule/fee_schedule.py +++ b/erpnext/education/doctype/fee_schedule/fee_schedule.py @@ -52,6 +52,7 @@ class FeeSchedule(Document): self.grand_total = no_of_students*self.total_amount self.grand_total_in_words = money_in_words(self.grand_total) + @frappe.whitelist() def create_fees(self): self.db_set("fee_creation_status", "In Process") frappe.publish_realtime("fee_schedule_progress", diff --git a/erpnext/education/doctype/program_enrollment/program_enrollment.py b/erpnext/education/doctype/program_enrollment/program_enrollment.py index d18c0f9625..b282babd0f 100644 --- a/erpnext/education/doctype/program_enrollment/program_enrollment.py +++ b/erpnext/education/doctype/program_enrollment/program_enrollment.py @@ -91,6 +91,8 @@ class ProgramEnrollment(Document): (fee, fee) for fee in fee_list] msgprint(_("Fee Records Created - {0}").format(comma_and(fee_list))) + + @frappe.whitelist() def get_courses(self): return frappe.db.sql('''select course from `tabProgram Course` where parent = %s and required = 1''', (self.program), as_dict=1) 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 8180102c58..5833b67f9b 100644 --- a/erpnext/education/doctype/program_enrollment_tool/program_enrollment_tool.py +++ b/erpnext/education/doctype/program_enrollment_tool/program_enrollment_tool.py @@ -14,6 +14,7 @@ class ProgramEnrollmentTool(Document): academic_term_reqd = cint(frappe.db.get_single_value('Education Settings', 'academic_term_reqd')) self.set_onload("academic_term_reqd", academic_term_reqd) + @frappe.whitelist() def get_students(self): students = [] if not self.get_students_from: @@ -49,6 +50,7 @@ class ProgramEnrollmentTool(Document): else: frappe.throw(_("No students Found")) + @frappe.whitelist() def enroll_students(self): total = len(self.students) for i, stud in enumerate(self.students): diff --git a/erpnext/education/doctype/quiz/quiz.json b/erpnext/education/doctype/quiz/quiz.json index 569c281f4c..16d7d7e4bf 100644 --- a/erpnext/education/doctype/quiz/quiz.json +++ b/erpnext/education/doctype/quiz/quiz.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "field:title", @@ -12,7 +13,10 @@ "quiz_configuration_section", "passing_score", "max_attempts", - "grading_basis" + "grading_basis", + "column_break_7", + "is_time_bound", + "duration" ], "fields": [ { @@ -58,9 +62,26 @@ "fieldtype": "Select", "label": "Grading Basis", "options": "Latest Highest Score\nLatest Attempt" + }, + { + "default": "0", + "fieldname": "is_time_bound", + "fieldtype": "Check", + "label": "Is Time-Bound" + }, + { + "depends_on": "is_time_bound", + "fieldname": "duration", + "fieldtype": "Duration", + "label": "Duration" + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" } ], - "modified": "2019-06-12 12:23:57.020508", + "links": [], + "modified": "2020-12-24 15:41:35.043262", "modified_by": "Administrator", "module": "Education", "name": "Quiz", diff --git a/erpnext/education/doctype/quiz_activity/quiz_activity.json b/erpnext/education/doctype/quiz_activity/quiz_activity.json index e78db42f7d..742c88754a 100644 --- a/erpnext/education/doctype/quiz_activity/quiz_activity.json +++ b/erpnext/education/doctype/quiz_activity/quiz_activity.json @@ -1,490 +1,163 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, + "actions": [], "autoname": "format:EDU-QA-{YYYY}-{#####}", "beta": 1, "creation": "2018-10-15 15:48:40.482821", - "custom": 0, - "docstatus": 0, "doctype": "DocType", - "document_type": "", "editable_grid": 1, "engine": "InnoDB", + "field_order": [ + "enrollment", + "student", + "column_break_3", + "course", + "section_break_5", + "quiz", + "column_break_7", + "status", + "section_break_9", + "result", + "section_break_11", + "activity_date", + "score", + "column_break_14", + "time_taken" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "enrollment", "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": "Enrollment", - "length": 0, - "no_copy": 0, "options": "Course Enrollment", - "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": 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_from": "enrollment.student", "fieldname": "student", "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", - "length": 0, - "no_copy": 0, "options": "Student", - "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, "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, "fetch_from": "enrollment.course", "fieldname": "course", "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": "Course", - "length": 0, - "no_copy": 0, "options": "Course", - "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": 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, "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 + "fieldtype": "Section Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "quiz", "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": "Quiz", - "length": 0, - "no_copy": 0, "options": "Quiz", - "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": 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, "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, "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": 0, "label": "Status", - "length": 0, - "no_copy": 0, "options": "\nPass\nFail", - "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, "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 + "fieldtype": "Section Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "result", "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": "Result", - "length": 0, - "no_copy": 0, "options": "Quiz Result", - "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": 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, "fieldname": "activity_date", "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": "Activity 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": 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, "fieldname": "score", "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": "Score", - "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": 1, - "translatable": 0, - "unique": 0 + "set_only_once": 1 + }, + { + "fieldname": "time_taken", + "fieldtype": "Duration", + "label": "Time Taken", + "set_only_once": 1 + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_14", + "fieldtype": "Column Break" } ], - "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-11-25 19:05:52.434437", + "links": [], + "modified": "2020-12-24 15:41:20.085380", "modified_by": "Administrator", "module": "Education", "name": "Quiz Activity", - "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": "Academics User", - "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": "LMS User", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, "email": 1, "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "Instructor", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 + "share": 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, - "track_views": 0 + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/education/doctype/student/student.py b/erpnext/education/doctype/student/student.py index 81626f1918..2dc0f634f0 100644 --- a/erpnext/education/doctype/student/student.py +++ b/erpnext/education/doctype/student/student.py @@ -114,7 +114,7 @@ class Student(Document): status = check_content_completion(content.name, content.doctype, course_enrollment_name) progress.append({'content': content.name, 'content_type': content.doctype, 'is_complete': status}) elif content.doctype == 'Quiz': - status, score, result = check_quiz_completion(content, course_enrollment_name) + status, score, result, time_taken = check_quiz_completion(content, course_enrollment_name) progress.append({'content': content.name, 'content_type': content.doctype, 'is_complete': status, 'score': score, 'result': result}) return progress diff --git a/erpnext/education/doctype/student_attendance/student_attendance.json b/erpnext/education/doctype/student_attendance/student_attendance.json index 55384b9e53..e6e46d1c1b 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_group_creation_tool/student_group_creation_tool.py b/erpnext/education/doctype/student_group_creation_tool/student_group_creation_tool.py index d7645e30cd..dc8667ec06 100644 --- a/erpnext/education/doctype/student_group_creation_tool/student_group_creation_tool.py +++ b/erpnext/education/doctype/student_group_creation_tool/student_group_creation_tool.py @@ -9,6 +9,7 @@ from frappe.model.document import Document from erpnext.education.doctype.student_group.student_group import get_students class StudentGroupCreationTool(Document): + @frappe.whitelist() def get_courses(self): group_list = [] @@ -42,6 +43,7 @@ class StudentGroupCreationTool(Document): return group_list + @frappe.whitelist() def create_student_groups(self): if not self.courses: frappe.throw(_("""No Student Groups created.""")) diff --git a/erpnext/education/utils.py b/erpnext/education/utils.py index cffc3960a0..8f51fef847 100644 --- a/erpnext/education/utils.py +++ b/erpnext/education/utils.py @@ -194,7 +194,7 @@ def add_activity(course, content_type, content, program): return enrollment.add_activity(content_type, content) @frappe.whitelist() -def evaluate_quiz(quiz_response, quiz_name, course, program): +def evaluate_quiz(quiz_response, quiz_name, course, program, time_taken): import json student = get_current_student() @@ -209,7 +209,7 @@ def evaluate_quiz(quiz_response, quiz_name, course, program): if student: enrollment = get_or_create_course_enrollment(course, program) if quiz.allowed_attempt(enrollment, quiz_name): - enrollment.add_quiz_activity(quiz_name, quiz_response, result, score, status) + enrollment.add_quiz_activity(quiz_name, quiz_response, result, score, status, time_taken) return {'result': result, 'score': score, 'status': status} else: return None @@ -219,8 +219,9 @@ def get_quiz(quiz_name, course): try: quiz = frappe.get_doc("Quiz", quiz_name) questions = quiz.get_questions() + duration = quiz.duration except: - frappe.throw(_("Quiz {0} does not exist").format(quiz_name)) + frappe.throw(_("Quiz {0} does not exist").format(quiz_name), frappe.DoesNotExistError) return None questions = [{ @@ -232,12 +233,20 @@ def get_quiz(quiz_name, course): } for question in questions] if has_super_access(): - return {'questions': questions, 'activity': None} + return { + 'questions': questions, + 'activity': None, + 'duration':duration + } student = get_current_student() course_enrollment = get_enrollment("course", course, student.name) - status, score, result = check_quiz_completion(quiz, course_enrollment) - return {'questions': questions, 'activity': {'is_complete': status, 'score': score, 'result': result}} + status, score, result, time_taken = check_quiz_completion(quiz, course_enrollment) + return { + 'questions': questions, + 'activity': {'is_complete': status, 'score': score, 'result': result, 'time_taken': time_taken}, + 'duration': quiz.duration + } def get_topic_progress(topic, course_name, program): """ @@ -361,15 +370,23 @@ def check_content_completion(content_name, content_type, enrollment_name): return False def check_quiz_completion(quiz, enrollment_name): - attempts = frappe.get_all("Quiz Activity", filters={'enrollment': enrollment_name, 'quiz': quiz.name}, fields=["name", "activity_date", "score", "status"]) + attempts = frappe.get_all("Quiz Activity", + filters={ + 'enrollment': enrollment_name, + 'quiz': quiz.name + }, + fields=["name", "activity_date", "score", "status", "time_taken"] + ) status = False if quiz.max_attempts == 0 else bool(len(attempts) >= quiz.max_attempts) score = None result = None + time_taken = None if attempts: if quiz.grading_basis == 'Last Highest Score': attempts = sorted(attempts, key = lambda i: int(i.score), reverse=True) score = attempts[0]['score'] result = attempts[0]['status'] + time_taken = attempts[0]['time_taken'] if result == 'Pass': status = True - return status, score, result \ No newline at end of file + return status, score, result, time_taken \ 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 index b5718026c1..fdfaa1b054 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -59,9 +59,10 @@ class MpesaSettings(Document): request_amounts.append(amount) else: request_amounts = [request_amount] - + return request_amounts + @frappe.whitelist() def get_account_balance_info(self): payload = dict( reference_doctype="Mpesa Settings", @@ -198,7 +199,7 @@ def get_completed_integration_requests_info(reference_doctype, reference_docname 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): diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 21f6fee79c..16c65733f0 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -15,6 +15,7 @@ from frappe.utils import add_months, formatdate, getdate, today class PlaidSettings(Document): @staticmethod + @frappe.whitelist() def get_link_token(): plaid = PlaidConnector() return plaid.get_link_token() diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py index 3c906374c4..e2243eabde 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py @@ -23,14 +23,9 @@ class TestPlaidSettings(unittest.TestCase): doc.cancel() doc.delete() - for ba in frappe.get_all("Bank Account"): - frappe.get_doc("Bank Account", ba.name).delete() - - for at in frappe.get_all("Bank Account Type"): - frappe.get_doc("Bank Account Type", at.name).delete() - - for ast in frappe.get_all("Bank Account Subtype"): - frappe.get_doc("Bank Account Subtype", ast.name).delete() + for doctype in ("Bank Account", "Bank Account Type", "Bank Account Subtype"): + for d in frappe.get_all(doctype): + frappe.delete_doc(doctype, d.name, force=True) def test_plaid_disabled(self): frappe.db.set_value("Plaid Settings", None, "enabled", 0) diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py index 96a533ee10..866ea66278 100644 --- a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py @@ -54,6 +54,7 @@ class QuickBooksMigrator(Document): self.authorization_url = self.oauth.authorization_url(self.authorization_endpoint)[0] + @frappe.whitelist() def migrate(self): frappe.enqueue_doc("QuickBooks Migrator", "QuickBooks Migrator", "_migrate", queue="long") 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 24cbf744ae..6bec301b8e 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,9 +13,14 @@ 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")) @@ -24,9 +29,15 @@ class ShopifySettings(unittest.TestCase): 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 = [] @@ -56,21 +67,20 @@ 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) @@ -80,17 +90,17 @@ class ShopifySettings(unittest.TestCase): 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.py b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py index 462685f5e7..907a22333b 100644 --- a/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py +++ b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py @@ -594,18 +594,22 @@ class TallyMigration(Document): frappe.db.set_value("Price List", "Tally Price List", "enabled", 0) frappe.flags.in_migrate = False + @frappe.whitelist() def process_master_data(self): self.set_status("Processing Master Data") frappe.enqueue_doc(self.doctype, self.name, "_process_master_data", queue="long", timeout=3600) + @frappe.whitelist() def import_master_data(self): self.set_status("Importing Master Data") frappe.enqueue_doc(self.doctype, self.name, "_import_master_data", queue="long", timeout=3600) + @frappe.whitelist() def process_day_book_data(self): self.set_status("Processing Day Book Data") frappe.enqueue_doc(self.doctype, self.name, "_process_day_book_data", queue="long", timeout=3600) + @frappe.whitelist() def import_day_book_data(self): self.set_status("Importing Day Book Data") frappe.enqueue_doc(self.doctype, self.name, "_import_day_book_data", queue="long", timeout=3600) diff --git a/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py b/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py index 325c2094fb..cbf89ee3bd 100644 --- a/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py +++ b/erpnext/healthcare/doctype/clinical_procedure/clinical_procedure.py @@ -54,6 +54,7 @@ class ClinicalProcedure(Document): def set_title(self): self.title = _('{0} - {1}').format(self.patient_name or self.patient, self.procedure_template)[:100] + @frappe.whitelist() def complete_procedure(self): if self.consume_stock and self.items: stock_entry = make_stock_entry(self) @@ -96,6 +97,7 @@ class ClinicalProcedure(Document): if self.consume_stock and self.items: return stock_entry + @frappe.whitelist() def start_procedure(self): allow_start = self.set_actual_qty() if allow_start: @@ -116,6 +118,7 @@ class ClinicalProcedure(Document): return allow_start + @frappe.whitelist() def make_material_receipt(self, submit=False): stock_entry = frappe.new_doc('Stock Entry') diff --git a/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py index e7319085e4..3a299eda26 100644 --- a/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py +++ b/erpnext/healthcare/doctype/inpatient_medication_entry/inpatient_medication_entry.py @@ -14,6 +14,7 @@ class InpatientMedicationEntry(Document): def validate(self): self.validate_medication_orders() + @frappe.whitelist() def get_medication_orders(self): # pull inpatient medication orders based on selected filters orders = get_pending_medication_orders(self) diff --git a/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.py b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.py index 33cbbec812..b379e98fe1 100644 --- a/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.py +++ b/erpnext/healthcare/doctype/inpatient_medication_order/inpatient_medication_order.py @@ -57,6 +57,7 @@ class InpatientMedicationOrder(Document): self.db_set('status', status) + @frappe.whitelist() def add_order_entries(self, order): if order.get('drug_code'): dosage = frappe.get_doc('Prescription Dosage', order.get('dosage')) 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 index a21caca8ff..21776d2380 100644 --- a/erpnext/healthcare/doctype/inpatient_medication_order/test_inpatient_medication_order.py +++ b/erpnext/healthcare/doctype/inpatient_medication_order/test_inpatient_medication_order.py @@ -81,15 +81,8 @@ class TestInpatientMedicationOrder(unittest.TestCase): 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() + for doctype in ["Inpatient Medication Entry", "Inpatient Medication Order"]: + frappe.db.sql("delete from `tab{doctype}`".format(doctype=doctype)) def create_dosage_form(): if not frappe.db.exists('Dosage Form', 'Tablet'): diff --git a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.json index 5ced845c1b..aaf0e855d4 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 88d7f0b233..f4d1eaf2e3 100644 --- a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py +++ b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py @@ -53,12 +53,15 @@ class InpatientRecord(Document): + """ {0}""".format(ip_record[0].name)) frappe.throw(msg) + @frappe.whitelist() def admit(self, service_unit, check_in, expected_discharge=None): admit_patient(self, service_unit, check_in, expected_discharge) + @frappe.whitelist() def discharge(self): discharge_patient(self) + @frappe.whitelist() def transfer(self, service_unit, check_in, leave_from): if leave_from: patient_leave_service_unit(self, check_in, leave_from) @@ -151,7 +154,7 @@ def check_out_inpatient(inpatient_record): def discharge_patient(inpatient_record): validate_inpatient_invoicing(inpatient_record) - inpatient_record.discharge_date = today() + inpatient_record.discharge_datetime = now_datetime() inpatient_record.status = "Discharged" inpatient_record.save(ignore_permissions = True) diff --git a/erpnext/healthcare/doctype/patient/patient.py b/erpnext/healthcare/doctype/patient/patient.py index 8603f974c3..789d452c07 100644 --- a/erpnext/healthcare/doctype/patient/patient.py +++ b/erpnext/healthcare/doctype/patient/patient.py @@ -111,6 +111,7 @@ class Patient(Document): age_str = str(age.years) + ' ' + _("Years(s)") + ' ' + str(age.months) + ' ' + _("Month(s)") + ' ' + str(age.days) + ' ' + _("Day(s)") return age_str + @frappe.whitelist() def invoice_patient_registration(self): if frappe.db.get_single_value('Healthcare Settings', 'registration_fee'): company = frappe.defaults.get_user_default('company') diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py index 1f76cd624c..cdd4ad39c8 100755 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py @@ -113,6 +113,7 @@ class PatientAppointment(Document): if fee_validity: frappe.msgprint(_('{0} has fee validity till {1}').format(self.patient, fee_validity.valid_till)) + @frappe.whitelist() def get_therapy_types(self): if not self.therapy_plan: return diff --git a/erpnext/healthcare/doctype/patient_assessment/patient_assessment.js b/erpnext/healthcare/doctype/patient_assessment/patient_assessment.js index c7074e88d5..f28d32c22c 100644 --- a/erpnext/healthcare/doctype/patient_assessment/patient_assessment.js +++ b/erpnext/healthcare/doctype/patient_assessment/patient_assessment.js @@ -39,11 +39,13 @@ frappe.ui.form.on('Patient Assessment', { }, set_score_range: function(frm) { - let options = []; + let options = ['']; for(let i = frm.doc.scale_min; i <= frm.doc.scale_max; i++) { options.push(i); } - frappe.meta.get_docfield('Patient Assessment Sheet', 'score', frm.doc.name).options = [''].concat(options); + frm.fields_dict.assessment_sheet.grid.update_docfield_property( + 'score', 'options', options + ); }, calculate_total_score: function(frm, cdt, cdn) { @@ -83,4 +85,4 @@ frappe.ui.form.on('Patient Assessment Sheet', { score: function(frm, cdt, cdn) { frm.events.calculate_total_score(frm, cdt, cdn); } -}); \ 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 index 2e8c994c3d..887d58a2e0 100644 --- a/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.py +++ b/erpnext/healthcare/doctype/patient_history_settings/patient_history_settings.py @@ -34,6 +34,7 @@ class PatientHistorySettings(Document): 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))) + @frappe.whitelist() def get_doctype_fields(self, document_type, fields): multicheck_fields = [] doc_fields = frappe.get_meta(document_type).fields @@ -49,6 +50,7 @@ class PatientHistorySettings(Document): return multicheck_fields + @frappe.whitelist() def get_date_field_for_dt(self, document_type): meta = frappe.get_meta(document_type) date_fields = meta.get('fields', { diff --git a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.js b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.js index d1f72d625b..42e231dc66 100644 --- a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.js +++ b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.js @@ -58,8 +58,12 @@ frappe.ui.form.on('Therapy Plan', { } 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.fields_dict.therapy_plan_details.grid.update_docfield_property( + 'therapy_type', 'read_only', 1 + ); + frm.fields_dict.therapy_plan_details.grid.update_docfield_property( + 'no_of_sessions', 'read_only', 1 + ); } }, @@ -126,4 +130,4 @@ frappe.ui.form.on('Therapy Plan Detail', { frm.set_value('total_sessions', total); refresh_field('total_sessions'); } -}); \ No newline at end of file +}); diff --git a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py index ac01c604dd..e209660434 100644 --- a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py +++ b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py @@ -33,6 +33,7 @@ class TherapyPlan(Document): self.db_set('total_sessions', total_sessions) self.db_set('total_sessions_completed', total_sessions_completed) + @frappe.whitelist() def set_therapy_details_from_template(self): # Add therapy types in the child table self.set('therapy_plan_details', []) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 5d091ddfbc..bb6cd8bdc2 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -195,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", @@ -258,6 +262,7 @@ doc_events = { ], "on_trash": "erpnext.regional.check_deletion_permission", "validate": [ + "erpnext.regional.india.utils.validate_document_name", "erpnext.regional.india.utils.update_taxable_values" ] }, @@ -281,9 +286,6 @@ doc_events = { ('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.telephony.doctype.call_log.call_log.link_existing_conversations", @@ -305,6 +307,8 @@ auto_cancel_exempted_doctypes= [ "Inpatient Medication Entry" ] +after_migrate = ["erpnext.setup.install.update_select_perm_after_install"] + scheduler_events = { "cron": { "0/30 * * * *": [ diff --git a/erpnext/hr/doctype/attendance_request/test_attendance_request.py b/erpnext/hr/doctype/attendance_request/test_attendance_request.py index 92b1eaee2c..3c42bd9fc3 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/compensatory_leave_request.py b/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py index 7a9727f18c..a6fe429be1 100644 --- a/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py +++ b/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _ -from frappe.utils import date_diff, add_days, getdate, cint +from frappe.utils import date_diff, add_days, getdate, cint, format_date from frappe.model.document import Document from erpnext.hr.utils import validate_dates, validate_overlap, get_leave_period, \ get_holidays_for_employee, create_additional_leave_ledger_entry @@ -40,7 +40,12 @@ class CompensatoryLeaveRequest(Document): def validate_holidays(self): holidays = get_holidays_for_employee(self.employee, self.work_from_date, self.work_end_date) if len(holidays) < date_diff(self.work_end_date, self.work_from_date) + 1: - frappe.throw(_("Compensatory leave request days not in valid holidays")) + if date_diff(self.work_end_date, self.work_from_date): + msg = _("The days between {0} to {1} are not valid holidays.").format(frappe.bold(format_date(self.work_from_date)), frappe.bold(format_date(self.work_end_date))) + else: + msg = _("{0} is not a holiday.").format(frappe.bold(format_date(self.work_from_date))) + + frappe.throw(msg) def on_submit(self): company = frappe.db.get_value("Employee", self.employee, "company") @@ -61,9 +66,9 @@ class CompensatoryLeaveRequest(Document): else: leave_allocation = self.create_leave_allocation(leave_period, date_difference) - self.leave_allocation=leave_allocation.name + self.db_set("leave_allocation", leave_allocation.name) else: - frappe.throw(_("There is no leave period in between {0} and {1}").format(self.work_from_date, self.work_end_date)) + frappe.throw(_("There is no leave period in between {0} and {1}").format(format_date(self.work_from_date), format_date(self.work_end_date))) def on_cancel(self): if self.leave_allocation: @@ -119,4 +124,4 @@ class CompensatoryLeaveRequest(Document): )) allocation.insert(ignore_permissions=True) allocation.submit() - return allocation \ No newline at end of file + return allocation 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 1615ab30f1..74ce30108f 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/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index d0e7d0537b..ed7d588434 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 @@ -66,7 +66,7 @@ class Employee(NestedSet): 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() @@ -80,6 +80,7 @@ class Employee(NestedSet): self.update_user() self.update_user_permissions() self.reset_employee_emails_cache() + self.update_approver_role() def update_user_permissions(self): if not self.create_user_permission: return @@ -145,6 +146,17 @@ class Employee(NestedSet): user.save() + def update_approver_role(self): + if self.leave_approver: + user = frappe.get_doc("User", self.leave_approver) + user.flags.ignore_permissions = True + user.add_roles("Leave Approver") + + if self.expense_approver: + user = frappe.get_doc("User", self.expense_approver) + user.flags.ignore_permissions = True + user.add_roles("Expense Approver") + def validate_date(self): if self.date_of_birth and getdate(self.date_of_birth) > getdate(today()): throw(_("Date of Birth cannot be greater than today.")) @@ -501,3 +513,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_advance/employee_advance.json b/erpnext/hr/doctype/employee_advance/employee_advance.json index a25a828344..ea25aa720a 100644 --- a/erpnext/hr/doctype/employee_advance/employee_advance.json +++ b/erpnext/hr/doctype/employee_advance/employee_advance.json @@ -200,7 +200,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:31:53.746659", + "modified": "2021-03-31 22:31:53.746659", "modified_by": "Administrator", "module": "HR", "name": "Employee Advance", diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.py b/erpnext/hr/doctype/expense_claim/expense_claim.py index bf893d5fab..5010fc3f75 100644 --- a/erpnext/hr/doctype/expense_claim/expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/expense_claim.py @@ -6,7 +6,7 @@ import frappe, erpnext from frappe import _ from frappe.utils import get_fullname, flt, cstr, get_link_to_form from frappe.model.document import Document -from erpnext.hr.utils import set_employee_name +from erpnext.hr.utils import set_employee_name, share_doc_with_approver from erpnext.accounts.party import get_party_account from erpnext.accounts.general_ledger import make_gl_entries from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account @@ -53,6 +53,9 @@ class ExpenseClaim(AccountsController): elif self.docstatus == 1 and self.approval_status == 'Rejected': self.status = 'Rejected' + def on_update(self): + share_doc_with_approver(self, self.expense_approver) + def set_payable_account(self): if not self.payable_account and not self.is_paid: self.payable_account = frappe.get_cached_value('Company', self.company, 'default_expense_claim_payable_account') @@ -211,6 +214,7 @@ class ExpenseClaim(AccountsController): self.total_claimed_amount += flt(d.amount) self.total_sanctioned_amount += flt(d.sanctioned_amount) + @frappe.whitelist() def calculate_taxes(self): self.total_taxes_and_charges = 0 for tax in self.taxes: diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py index f9e3a441bf..3f22ca2141 100644 --- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py @@ -95,12 +95,12 @@ class TestExpenseClaim(unittest.TestCase): def test_rejected_expense_claim(self): payable_account = get_payable_account(company_name) expense_claim = frappe.get_doc({ - "doctype": "Expense Claim", - "employee": "_T-Employee-00001", - "payable_account": payable_account, - "approval_status": "Rejected", - "expenses": - [{ "expense_type": "Travel", "default_account": "Travel Expenses - _TC4", "amount": 300, "sanctioned_amount": 200 }] + "doctype": "Expense Claim", + "employee": "_T-Employee-00001", + "payable_account": payable_account, + "approval_status": "Rejected", + "expenses": + [{ "expense_type": "Travel", "default_account": "Travel Expenses - _TC4", "amount": 300, "sanctioned_amount": 200 }] }) expense_claim.submit() @@ -110,6 +110,34 @@ class TestExpenseClaim(unittest.TestCase): gl_entry = frappe.get_all('GL Entry', {'voucher_type': 'Expense Claim', 'voucher_no': expense_claim.name}) self.assertEquals(len(gl_entry), 0) + def test_expense_approver_perms(self): + user = "test_approver_perm_emp@example.com" + make_employee(user, "_Test Company") + + # check doc shared + payable_account = get_payable_account("_Test Company") + expense_claim = make_expense_claim(payable_account, 300, 200, "_Test Company", "Travel Expenses - _TC", do_not_submit=True) + expense_claim.expense_approver = user + expense_claim.save() + self.assertTrue(expense_claim.name in frappe.share.get_shared("Expense Claim", user)) + + # check shared doc revoked + expense_claim.reload() + expense_claim.expense_approver = "test@example.com" + expense_claim.save() + self.assertTrue(expense_claim.name not in frappe.share.get_shared("Expense Claim", user)) + + expense_claim.reload() + expense_claim.expense_approver = user + expense_claim.save() + + frappe.set_user(user) + expense_claim.reload() + expense_claim.status = "Approved" + expense_claim.submit() + frappe.set_user("Administrator") + + def get_payable_account(company): return frappe.get_cached_value('Company', company, 'default_payable_account') @@ -133,21 +161,21 @@ def make_expense_claim(payable_account, amount, sanctioned_amount, company, acco currency, cost_center = frappe.db.get_value('Company', company, ['default_currency', 'cost_center']) expense_claim = { - "doctype": "Expense Claim", - "employee": employee, - "payable_account": payable_account, - "approval_status": "Approved", - "company": company, - 'currency': currency, - "expenses": [{ + "doctype": "Expense Claim", + "employee": employee, + "payable_account": payable_account, + "approval_status": "Approved", + "company": company, + "currency": currency, + "expenses": [{ "expense_type": "Travel", "default_account": account, "currency": currency, "amount": amount, "sanctioned_amount": sanctioned_amount, "cost_center": cost_center - }] - } + }] + } if taxes: expense_claim.update(taxes) diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json index d8aae66796..09666c5db5 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", @@ -69,15 +70,19 @@ "label": "Leave Settings" }, { + "depends_on": "eval: doc.send_leave_notification == 1", "fieldname": "leave_approval_notification_template", "fieldtype": "Link", "label": "Leave Approval Notification Template", + "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", + "mandatory_depends_on": "eval: doc.send_leave_notification == 1", "options": "Email Template" }, { @@ -132,13 +137,19 @@ "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": "2021-02-25 12:31:14.947865", + "modified": "2021-03-14 02:04:22.907159", "modified_by": "Administrator", "module": "HR", "name": "HR Settings", diff --git a/erpnext/hr/doctype/job_applicant/test_job_applicant.py b/erpnext/hr/doctype/job_applicant/test_job_applicant.py index 6d275c82d9..872834230e 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/test_job_offer.py b/erpnext/hr/doctype/job_offer/test_job_offer.py index 8886596450..690a692ddc 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/leave_allocation/leave_allocation.json b/erpnext/hr/doctype/leave_allocation/leave_allocation.json index 3a300c0d63..ae02c512c2 100644 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.json +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.json @@ -218,8 +218,7 @@ "fieldname": "leave_policy_assignment", "fieldtype": "Link", "label": "Leave Policy Assignment", - "options": "Leave Policy Assignment", - "read_only": 1 + "options": "Leave Policy Assignment" }, { "fetch_from": "employee.company", @@ -236,7 +235,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-01-04 18:46:13.184104", + "modified": "2021-04-14 15:28:26.335104", "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 69d605d063..11302cad75 100755 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.py @@ -99,6 +99,7 @@ class LeaveAllocation(Document): .format(formatdate(future_allocation[0].from_date), future_allocation[0].name), BackDatedAllocationError) + @frappe.whitelist() def set_total_leaves_allocated(self): self.unused_leaves = get_carry_forwarded_leaves(self.employee, self.leave_type, self.from_date, self.carry_forward) diff --git a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py index 26f077a649..0b71036c86 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.py b/erpnext/hr/doctype/leave_application/leave_application.py index 132c3bd3b9..0bf551e178 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.py +++ b/erpnext/hr/doctype/leave_application/leave_application.py @@ -6,7 +6,7 @@ import frappe from frappe import _ from frappe.utils import cint, cstr, date_diff, flt, formatdate, getdate, get_link_to_form, \ comma_or, get_fullname, add_days, nowdate, get_datetime_str -from erpnext.hr.utils import set_employee_name, get_leave_period +from erpnext.hr.utils import set_employee_name, get_leave_period, share_doc_with_approver from erpnext.hr.doctype.leave_block_list.leave_block_list import get_applicable_block_dates from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard import daterange @@ -40,7 +40,10 @@ 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() + + share_doc_with_approver(self, self.leave_approver) def on_submit(self): if self.status == "Open": @@ -50,7 +53,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 +64,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): @@ -414,6 +419,7 @@ class LeaveApplication(Document): )) create_leave_ledger_entry(self, args, submit) + def get_allocation_expiry(employee, leave_type, to_date, from_date): ''' Returns expiry of carry forward allocation in leave ledger entry ''' expiry = frappe.get_all("Leave Ledger Entry", diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index 53b7a39e51..b54c9712c8 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -11,8 +11,9 @@ 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 +from erpnext.hr.doctype.employee.test_employee import make_employee -test_dependencies = ["Leave Allocation", "Leave Block List"] +test_dependencies = ["Leave Allocation", "Leave Block List", "Employee"] _test_records = [ { @@ -56,6 +57,7 @@ class TestLeaveApplication(unittest.TestCase): @classmethod def setUpClass(cls): set_leave_approver() + frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'") def tearDown(self): frappe.set_user("Administrator") @@ -230,8 +232,9 @@ class TestLeaveApplication(unittest.TestCase): def test_optional_leave(self): leave_period = get_leave_period() today = nowdate() - from datetime import date holiday_list = 'Test Holiday List for Optional Holiday' + optional_leave_date = add_days(today, 7) + if not frappe.db.exists('Holiday List', holiday_list): frappe.get_doc(dict( doctype = 'Holiday List', @@ -239,7 +242,7 @@ class TestLeaveApplication(unittest.TestCase): from_date = add_months(today, -6), to_date = add_months(today, 6), holidays = [ - dict(holiday_date = today, description = 'Test') + dict(holiday_date = optional_leave_date, description = 'Test') ] )).insert() employee = get_employee() @@ -255,7 +258,7 @@ class TestLeaveApplication(unittest.TestCase): allocate_leaves(employee, leave_period, leave_type, 10) - date = add_days(today, - 1) + date = add_days(today, 6) leave_application = frappe.get_doc(dict( doctype = 'Leave Application', @@ -270,14 +273,14 @@ class TestLeaveApplication(unittest.TestCase): # can only apply on optional holidays self.assertRaises(NotAnOptionalHoliday, leave_application.insert) - leave_application.from_date = today - leave_application.to_date = today + leave_application.from_date = optional_leave_date + leave_application.to_date = optional_leave_date leave_application.status = "Approved" leave_application.insert() leave_application.submit() # check leave balance is reduced - self.assertEqual(get_leave_balance_on(employee.name, leave_type, today), 9) + self.assertEqual(get_leave_balance_on(employee.name, leave_type, optional_leave_date), 9) def test_leaves_allowed(self): employee = get_employee() @@ -341,7 +344,7 @@ class TestLeaveApplication(unittest.TestCase): to_date = add_days(date, 4), company = "_Test Company", docstatus = 1, - status = "Approved" + status = "Approved" )) self.assertRaises(frappe.ValidationError, leave_application.insert) @@ -363,7 +366,7 @@ class TestLeaveApplication(unittest.TestCase): to_date = add_days(date, 4), company = "_Test Company", docstatus = 1, - status = "Approved" + status = "Approved" )) self.assertTrue(leave_application.insert()) @@ -393,7 +396,7 @@ class TestLeaveApplication(unittest.TestCase): to_date = add_days(date, 4), company = "_Test Company", docstatus = 1, - status = "Approved" + status = "Approved" )) self.assertRaises(frappe.ValidationError, leave_application.insert) @@ -508,7 +511,7 @@ class TestLeaveApplication(unittest.TestCase): description = "_Test Reason", company = "_Test Company", docstatus = 1, - status = "Approved" + status = "Approved" )) leave_application.submit() leave_ledger_entry = frappe.get_all('Leave Ledger Entry', fields='*', filters=dict(transaction_name=leave_application.name)) @@ -540,7 +543,7 @@ class TestLeaveApplication(unittest.TestCase): description = "_Test Reason", company = "_Test Company", docstatus = 1, - status = "Approved" + status = "Approved" )) leave_application.submit() @@ -565,6 +568,48 @@ class TestLeaveApplication(unittest.TestCase): self.assertEquals(get_leave_balance_on(employee.name, leave_type.name, add_days(nowdate(), -85), add_days(nowdate(), -84)), 0) + def test_leave_approver_perms(self): + employee = get_employee() + user = "test_approver_perm_emp@example.com" + make_employee(user, "_Test Company") + + # set approver for employee + employee.reload() + employee.leave_approver = user + employee.save() + self.assertTrue("Leave Approver" in frappe.get_roles(user)) + + make_allocation_record(employee.name) + + application = self.get_application(_test_records[0]) + application.from_date = '2018-01-01' + application.to_date = '2018-01-03' + application.leave_approver = user + application.insert() + self.assertTrue(application.name in frappe.share.get_shared("Leave Application", user)) + + # check shared doc revoked + application.reload() + application.leave_approver = "test@example.com" + application.save() + self.assertTrue(application.name not in frappe.share.get_shared("Leave Application", user)) + + application.reload() + application.leave_approver = user + application.save() + + frappe.set_user(user) + application.reload() + application.status = "Approved" + application.submit() + + # unset leave approver + frappe.set_user("Administrator") + employee.reload() + employee.leave_approver = "" + employee.save() + + def create_carry_forwarded_allocation(employee, leave_type): # initial leave allocation leave_allocation = create_leave_allocation( @@ -639,4 +684,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.json b/erpnext/hr/doctype/leave_encashment/leave_encashment.json index ec419ec2c6..1f6c03f7b6 100644 --- a/erpnext/hr/doctype/leave_encashment/leave_encashment.json +++ b/erpnext/hr/doctype/leave_encashment/leave_encashment.json @@ -154,7 +154,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:32:55.492327", + "modified": "2021-03-31 22:32:55.492327", "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 4c1a46522f..e041b7fb8f 100644 --- a/erpnext/hr/doctype/leave_encashment/leave_encashment.py +++ b/erpnext/hr/doctype/leave_encashment/leave_encashment.py @@ -63,6 +63,7 @@ class LeaveEncashment(Document): frappe.db.get_value('Leave Allocation', self.leave_allocation, 'total_leaves_encashed') - self.encashable_days) self.create_leave_ledger_entry(submit=False) + @frappe.whitelist() def get_leave_details_for_encashment(self): salary_structure = get_assigned_salary_structure(self.employee, self.encashment_date or getdate(nowdate())) if not salary_structure: diff --git a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py index 63559c4f5a..cf13036181 100644 --- a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py +++ b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py @@ -34,8 +34,8 @@ def validate_leave_allocation_against_leave_application(ledger): """, (ledger.employee, ledger.leave_type, ledger.from_date, ledger.to_date)) if leave_application_records: - frappe.throw(_("Leave allocation %s is linked with leave application %s" - % (ledger.transaction_name, ', '.join(leave_application_records)))) + frappe.throw(_("Leave allocation {0} is linked with the Leave Application {1}").format( + ledger.transaction_name, ', '.join(leave_application_records))) def create_leave_ledger_entry(ref_doc, args, submit=True): ledger = frappe._dict( @@ -52,7 +52,9 @@ def create_leave_ledger_entry(ref_doc, args, submit=True): ledger.update(args) if submit: - frappe.get_doc(ledger).submit() + doc = frappe.get_doc(ledger) + doc.flags.ignore_permissions = 1 + doc.submit() else: delete_ledger_entry(ledger) diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index 4064c56e44..462b81df1d 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -36,6 +36,7 @@ class LeavePolicyAssignment(Document): 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)))) + @frappe.whitelist() def grant_leave_alloc_for_employee(self): if self.leaves_allocated: frappe.throw(_("Leave already have been assigned for this Leave Policy Assignment")) 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 index c7bc6fb775..838e794795 100644 --- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py +++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -9,6 +9,8 @@ from erpnext.hr.doctype.leave_application.test_leave_application import get_leav 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): diff --git a/erpnext/hr/doctype/shift_request/shift_request.py b/erpnext/hr/doctype/shift_request/shift_request.py index 473193d5ac..177c45edc6 100644 --- a/erpnext/hr/doctype/shift_request/shift_request.py +++ b/erpnext/hr/doctype/shift_request/shift_request.py @@ -7,6 +7,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.utils import formatdate, getdate +from erpnext.hr.utils import share_doc_with_approver class OverlapError(frappe.ValidationError): pass @@ -17,6 +18,9 @@ class ShiftRequest(Document): self.validate_approver() self.validate_default_shift() + def on_update(self): + share_doc_with_approver(self, self.approver) + def on_submit(self): if self.status not in ["Approved", "Rejected"]: frappe.throw(_("Only Shift Request with status 'Approved' and 'Rejected' can be submitted")) @@ -29,6 +33,7 @@ class ShiftRequest(Document): if self.to_date: assignment_doc.end_date = self.to_date assignment_doc.shift_request = self.name + assignment_doc.flags.ignore_permissions = 1 assignment_doc.insert() assignment_doc.submit() diff --git a/erpnext/hr/doctype/shift_request/test_shift_request.py b/erpnext/hr/doctype/shift_request/test_shift_request.py index 3dcfcbf4a5..9c0d8e3198 100644 --- a/erpnext/hr/doctype/shift_request/test_shift_request.py +++ b/erpnext/hr/doctype/shift_request/test_shift_request.py @@ -6,6 +6,9 @@ from __future__ import unicode_literals import frappe import unittest from frappe.utils import nowdate, add_days +from erpnext.hr.doctype.employee.test_employee import make_employee + +test_dependencies = ["Shift Type"] class TestShiftRequest(unittest.TestCase): def setUp(self): @@ -17,19 +20,8 @@ class TestShiftRequest(unittest.TestCase): set_shift_approver(department) approver = frappe.db.sql("""select approver from `tabDepartment Approver` where parent= %s and parentfield = 'shift_request_approver'""", (department))[0][0] - shift_request = frappe.get_doc({ - "doctype": "Shift Request", - "shift_type": "Day Shift", - "company": "_Test Company", - "employee": "_T-Employee-00001", - "employee_name": "_Test Employee", - "from_date": nowdate(), - "to_date": add_days(nowdate(), 10), - "approver": approver, - "status": "Approved" - }) - shift_request.insert() - shift_request.submit() + shift_request = make_shift_request(approver) + shift_assignments = frappe.db.sql(''' SELECT shift_request, employee FROM `tabShift Assignment` @@ -42,8 +34,65 @@ class TestShiftRequest(unittest.TestCase): shift_assignment_doc = frappe.get_doc("Shift Assignment", {"shift_request": d.get('shift_request')}) self.assertEqual(shift_assignment_doc.docstatus, 2) + def test_shift_request_approver_perms(self): + employee = frappe.get_doc("Employee", "_T-Employee-00001") + user = "test_approver_perm_emp@example.com" + make_employee(user, "_Test Company") + + # set approver for employee + employee.reload() + employee.shift_request_approver = user + employee.save() + + shift_request = make_shift_request(user, do_not_submit=True) + self.assertTrue(shift_request.name in frappe.share.get_shared("Shift Request", user)) + + # check shared doc revoked + shift_request.reload() + department = frappe.get_value("Employee", "_T-Employee-00001", "department") + set_shift_approver(department) + department_approver = frappe.db.sql("""select approver from `tabDepartment Approver` where parent= %s and parentfield = 'shift_request_approver'""", (department))[0][0] + shift_request.approver = department_approver + shift_request.save() + self.assertTrue(shift_request.name not in frappe.share.get_shared("Shift Request", user)) + + shift_request.reload() + shift_request.approver = user + shift_request.save() + + frappe.set_user(user) + shift_request.reload() + shift_request.status = "Approved" + shift_request.submit() + + # unset approver + frappe.set_user("Administrator") + employee.reload() + employee.shift_request_approver = "" + employee.save() + + 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() + +def make_shift_request(approver, do_not_submit=0): + shift_request = frappe.get_doc({ + "doctype": "Shift Request", + "shift_type": "Day Shift", + "company": "_Test Company", + "employee": "_T-Employee-00001", + "employee_name": "_Test Employee", + "from_date": nowdate(), + "to_date": add_days(nowdate(), 10), + "approver": approver, + "status": "Approved" + }).insert() + + if do_not_submit: + return shift_request + + shift_request.submit() + return shift_request \ No newline at end of file diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index 054e7e3688..d5fdda8094 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -15,6 +15,7 @@ from erpnext.hr.doctype.attendance.attendance import mark_attendance from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee class ShiftType(Document): + @frappe.whitelist() def process_auto_attendance(self): if not cint(self.enable_auto_attendance) or not self.process_attendance_after or not self.last_sync_of_checkin: return 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 0000000000..9040b915a1 --- /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 535072a035..bc4f0eafcd 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/staffing_plan/staffing_plan.py b/erpnext/hr/doctype/staffing_plan/staffing_plan.py index 5b84d00bd6..533149a823 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/utils.py b/erpnext/hr/utils.py index 0c4c1cafb0..190eb4f10a 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -504,3 +504,25 @@ def grant_leaves_automatically(): 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() + +def share_doc_with_approver(doc, user): + # if approver does not have permissions, share + if not frappe.has_permission(doc=doc, ptype="submit", user=user): + frappe.share.add(doc.doctype, doc.name, user, submit=1, + flags={"ignore_share_permission": True}) + + frappe.msgprint(_("Shared with the user {0} with {1} access").format( + user, frappe.bold("submit"), alert=True)) + + # remove shared doc if approver changes + doc_before_save = doc.get_doc_before_save() + if doc_before_save: + approvers = { + "Leave Application": "leave_approver", + "Expense Claim": "expense_approver", + "Shift Request": "approver" + } + + approver = approvers.get(doc.doctype) + if doc_before_save.get(approver) != doc.get(approver): + frappe.share.remove(doc.doctype, doc.name, doc_before_save.get(approver)) diff --git a/erpnext/hr/workspace/hr/hr.json b/erpnext/hr/workspace/hr/hr.json index f650b24d86..f4b56a0e17 100644 --- a/erpnext/hr/workspace/hr/hr.json +++ b/erpnext/hr/workspace/hr/hr.json @@ -15,6 +15,7 @@ "hide_custom": 0, "icon": "hr", "idx": 0, + "is_default": 0, "is_standard": 1, "label": "HR", "links": [ @@ -226,42 +227,12 @@ "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", + "label": "Holiday List", + "link_to": "Holiday List", "link_type": "DocType", "onboard": 0, "type": "Link" @@ -280,8 +251,28 @@ "dependencies": "", "hidden": 0, "is_query_report": 0, - "label": "Holiday List", - "link_to": "Holiday List", + "label": "Leave Period", + "link_to": "Leave Period", + "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": "Leave Policy", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Policy Assignment", + "link_to": "Leave Policy Assignment", "link_type": "DocType", "onboard": 0, "type": "Link" @@ -290,8 +281,18 @@ "dependencies": "Employee", "hidden": 0, "is_query_report": 0, - "label": "Compensatory Leave Request", - "link_to": "Compensatory Leave Request", + "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" @@ -317,12 +318,12 @@ "type": "Link" }, { - "dependencies": "Leave Application", + "dependencies": "Employee", "hidden": 0, - "is_query_report": 1, - "label": "Employee Leave Balance", - "link_to": "Employee Leave Balance", - "link_type": "Report", + "is_query_report": 0, + "label": "Compensatory Leave Request", + "link_to": "Compensatory Leave Request", + "link_type": "DocType", "onboard": 0, "type": "Link" }, @@ -383,16 +384,6 @@ "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, @@ -420,6 +411,15 @@ "onboard": 0, "type": "Link" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Travel Request", + "link_to": "Travel Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, { "hidden": 0, "is_query_report": 0, @@ -464,6 +464,15 @@ "onboard": 0, "type": "Card Break" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Driver", + "link_to": "Driver", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, { "dependencies": "", "hidden": 0, @@ -541,6 +550,24 @@ "onboard": 0, "type": "Link" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Appointment Letter", + "link_to": "Appointment Letter", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Appointment Letter Template", + "link_to": "Appointment Letter Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, { "hidden": 0, "is_query_report": 0, @@ -625,33 +652,6 @@ "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, @@ -702,7 +702,74 @@ { "hidden": 0, "is_query_report": 0, - "label": "Employee Tax and Benefits", + "label": "Key Reports", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Attendance", + "hidden": 0, + "is_query_report": 1, + "label": "Monthly Attendance Sheet", + "link_to": "Monthly Attendance Sheet", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Staffing Plan", + "hidden": 0, + "is_query_report": 1, + "label": "Recruitment Analytics", + "link_to": "Recruitment Analytics", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Analytics", + "link_to": "Employee Analytics", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Leave Balance", + "link_to": "Employee Leave Balance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Leave Balance Summary", + "link_to": "Employee Leave Balance Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee Advance", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Advance Summary", + "link_to": "Employee Advance Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Other Reports", "onboard": 0, "type": "Card Break" }, @@ -710,74 +777,44 @@ "dependencies": "Employee", "hidden": 0, "is_query_report": 0, - "label": "Employee Tax Exemption Declaration", - "link_to": "Employee Tax Exemption Declaration", - "link_type": "DocType", + "label": "Employee Information", + "link_to": "Employee Information", + "link_type": "Report", "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", + "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": 0, - "label": "Employee Benefit Application", - "link_to": "Employee Benefit Application", - "link_type": "DocType", + "is_query_report": 1, + "label": "Employees Working on a Holiday", + "link_to": "Employees working on a holiday", + "link_type": "Report", "onboard": 0, "type": "Link" }, { - "dependencies": "Employee", + "dependencies": "Daily Work Summary", "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", + "is_query_report": 1, + "label": "Daily Work Summary Replies", + "link_to": "Daily Work Summary Replies", + "link_type": "Report", "onboard": 0, "type": "Link" } ], - "modified": "2021-01-21 13:38:38.941001", + "modified": "2021-03-24 17:35:21.483297", "modified_by": "Administrator", "module": "HR", "name": "HR", diff --git a/erpnext/loan_management/doctype/loan/loan.json b/erpnext/loan_management/doctype/loan/loan.json index acf09f5c03..4f8ceb0de8 100644 --- a/erpnext/loan_management/doctype/loan/loan.json +++ b/erpnext/loan_management/doctype/loan/loan.json @@ -23,6 +23,7 @@ "rate_of_interest", "is_secured_loan", "disbursement_date", + "closure_date", "disbursed_amount", "column_break_11", "maximum_loan_amount", @@ -348,12 +349,18 @@ "no_copy": 1, "options": "Company:company:default_currency", "read_only": 1 + }, + { + "fieldname": "closure_date", + "fieldtype": "Date", + "label": "Closure Date", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-11-24 12:27:23.208240", + "modified": "2021-04-10 09:28:21.946972", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan", diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py index 13a209418d..6f8da3166f 100644 --- a/erpnext/loan_management/doctype/loan/test_loan.py +++ b/erpnext/loan_management/doctype/loan/test_loan.py @@ -275,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", @@ -518,33 +523,7 @@ class TestLoan(unittest.TestCase): 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() - + loan, amounts = create_loan_scenario_for_penalty(self) # 30 days - grace period penalty_days = 30 - 4 penalty_applicable_amount = flt(amounts['interest_amount']/2) @@ -554,8 +533,28 @@ class TestLoan(unittest.TestCase): calculated_penalty_amount = frappe.db.get_value('Loan Interest Accrual', {'process_loan_interest_accrual': process, 'loan': loan.name}, 'penalty_amount') + self.assertEquals(loan.loan_amount, 1000000) self.assertEquals(calculated_penalty_amount, penalty_amount) + def test_penalty_repayment(self): + loan, dummy = create_loan_scenario_for_penalty(self) + amounts = calculate_amounts(loan.name, '2019-11-30 00:00:00') + + first_penalty = 10000 + second_penalty = amounts['penalty_amount'] - 10000 + + repayment_entry = create_repayment_entry(loan.name, self.applicant2, '2019-11-30 00:00:00', 10000) + repayment_entry.submit() + + amounts = calculate_amounts(loan.name, '2019-11-30 00:00:01') + self.assertEquals(amounts['penalty_amount'], second_penalty) + + repayment_entry = create_repayment_entry(loan.name, self.applicant2, '2019-11-30 00:00:01', second_penalty) + repayment_entry.submit() + + amounts = calculate_amounts(loan.name, '2019-11-30 00:00:02') + self.assertEquals(amounts['penalty_amount'], 0) + def test_loan_write_off_limit(self): pledge = [{ "loan_security": "Test Security 1", @@ -646,6 +645,32 @@ class TestLoan(unittest.TestCase): amounts = calculate_amounts(loan.name, add_days(last_date, 5)) self.assertEquals(flt(amounts['pending_principal_amount'], 0), 0) +def create_loan_scenario_for_penalty(doc): + pledge = [{ + "loan_security": "Test Security 1", + "qty": 4000.00 + }] + + loan_application = create_loan_application('_Test Company', doc.applicant2, 'Demand Loan', pledge) + create_pledge(loan_application) + loan = create_demand_loan(doc.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01') + loan.submit() + + 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, doc.applicant2, add_days(last_date, 5), + paid_amount) + + repayment_entry.submit() + + return loan, amounts def create_loan_accounts(): if not frappe.db.exists("Account", "Loans and Advances (Assets) - _TC"): diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json index cd5df4d3cd..662c626b8d 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json @@ -20,6 +20,10 @@ "cost_center", "customer_details_section", "bank_account", + "disbursement_references_section", + "reference_date", + "column_break_17", + "reference_number", "amended_from" ], "fields": [ @@ -126,12 +130,31 @@ { "fieldname": "column_break_8", "fieldtype": "Column Break" + }, + { + "fieldname": "disbursement_references_section", + "fieldtype": "Section Break", + "label": "Disbursement References" + }, + { + "fieldname": "reference_date", + "fieldtype": "Date", + "label": "Reference Date" + }, + { + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, + { + "fieldname": "reference_number", + "fieldtype": "Data", + "label": "Reference Number" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-11-06 10:04:30.882322", + "modified": "2021-04-10 10:03:41.502210", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Disbursement", diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json index 2b5df4be24..8fbf233be5 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json @@ -21,6 +21,7 @@ "interest_payable", "payable_amount", "column_break_9", + "shortfall_amount", "payable_principal_amount", "penalty_amount", "amount_paid", @@ -31,6 +32,7 @@ "column_break_21", "reference_date", "principal_amount_paid", + "total_penalty_paid", "total_interest_paid", "repayment_details", "amended_from" @@ -226,12 +228,27 @@ "fieldtype": "Percent", "label": "Rate Of Interest", "read_only": 1 + }, + { + "fieldname": "shortfall_amount", + "fieldtype": "Currency", + "label": "Shortfall Amount", + "options": "Company:company:default_currency", + "read_only": 1 + }, + { + "fieldname": "total_penalty_paid", + "fieldtype": "Currency", + "hidden": 1, + "label": "Total Penalty Paid", + "options": "Company:company:default_currency", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-11-05 10:06:58.792841", + "modified": "2021-04-10 10:00:31.859076", "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 bac06c4e9e..728eadf22a 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -21,6 +21,7 @@ class LoanRepayment(AccountsController): def validate(self): amounts = calculate_amounts(self.against_loan, self.posting_date) self.set_missing_values(amounts) + self.check_future_entries() self.validate_amount() self.allocate_amounts(amounts) @@ -60,19 +61,28 @@ class LoanRepayment(AccountsController): if not self.payable_amount: self.payable_amount = flt(amounts['payable_amount'], precision) + shortfall_amount = flt(frappe.db.get_value('Loan Security Shortfall', {'loan': self.against_loan, 'status': 'Pending'}, + 'shortfall_amount')) + + if shortfall_amount: + self.shortfall_amount = shortfall_amount + if amounts.get('due_date'): self.due_date = amounts.get('due_date') + def check_future_entries(self): + future_repayment_date = frappe.db.get_value("Loan Repayment", {"posting_date": (">", self.posting_date), + "docstatus": 1, "against_loan": self.against_loan}, 'posting_date') + + if future_repayment_date: + frappe.throw("Repayment already made till date {0}".format(get_datetime(future_repayment_date))) + def validate_amount(self): precision = cint(frappe.db.get_default("currency_precision")) or 2 if not self.amount_paid: frappe.throw(_("Amount paid cannot be zero")) - if self.amount_paid < self.penalty_amount: - msg = _("Paid amount cannot be less than {0}").format(self.penalty_amount) - frappe.throw(msg) - def book_unaccrued_interest(self): precision = cint(frappe.db.get_default("currency_precision")) or 2 if self.total_interest_paid > self.interest_payable: @@ -148,11 +158,28 @@ class LoanRepayment(AccountsController): 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 + self.total_penalty_paid = 0 + interest_paid = self.amount_paid - if self.amount_paid - self.penalty_amount > 0: - interest_paid = self.amount_paid - self.penalty_amount + if self.shortfall_amount and self.amount_paid > self.shortfall_amount: + self.principal_amount_paid = self.shortfall_amount + elif self.shortfall_amount: + self.principal_amount_paid = self.amount_paid + + interest_paid -= self.principal_amount_paid + + if interest_paid > 0: + if self.penalty_amount and interest_paid > self.penalty_amount: + self.total_penalty_paid = self.penalty_amount + elif self.penalty_amount: + self.total_penalty_paid = interest_paid + + interest_paid -= self.total_penalty_paid + + total_interest_paid = 0 + # interest_paid = self.amount_paid - self.principal_amount_paid - self.penalty_amount + + if interest_paid > 0: 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'] @@ -177,7 +204,7 @@ class LoanRepayment(AccountsController): 'paid_principal_amount': paid_principal }) - if repayment_details['unaccrued_interest'] and interest_paid: + if repayment_details['unaccrued_interest'] and interest_paid > 0: # 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']: @@ -193,20 +220,28 @@ class LoanRepayment(AccountsController): interest_paid -= no_of_days * per_day_interest self.total_interest_paid = total_interest_paid - if interest_paid: + if interest_paid > 0: self.principal_amount_paid += interest_paid def make_gl_entries(self, cancel=0, adv_adj=0): gle_map = [] loan_details = frappe.get_doc("Loan", self.against_loan) - if self.penalty_amount: + if self.shortfall_amount and self.amount_paid > self.shortfall_amount: + remarks = _("Shortfall Repayment of {0}.\nRepayment against Loan: {1}").format(self.shortfall_amount, + self.against_loan) + elif self.shortfall_amount: + remarks = _("Shortfall Repayment of {0}").format(self.shortfall_amount) + else: + remarks = _("Repayment against Loan: ") + self.against_loan + + if self.total_penalty_paid: gle_map.append( self.get_gl_dict({ "account": loan_details.loan_account, "against": loan_details.payment_account, - "debit": self.penalty_amount, - "debit_in_account_currency": self.penalty_amount, + "debit": self.total_penalty_paid, + "debit_in_account_currency": self.total_penalty_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, "remarks": _("Penalty against loan:") + self.against_loan, @@ -221,8 +256,8 @@ class LoanRepayment(AccountsController): self.get_gl_dict({ "account": loan_details.penalty_income_account, "against": loan_details.payment_account, - "credit": self.penalty_amount, - "credit_in_account_currency": self.penalty_amount, + "credit": self.total_penalty_paid, + "credit_in_account_currency": self.total_penalty_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, "remarks": _("Penalty against loan:") + self.against_loan, @@ -240,7 +275,7 @@ class LoanRepayment(AccountsController): "debit_in_account_currency": self.amount_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": _("Repayment against Loan: ") + self.against_loan, + "remarks": remarks, "cost_center": self.cost_center, "posting_date": getdate(self.posting_date) }) @@ -256,7 +291,7 @@ class LoanRepayment(AccountsController): "credit_in_account_currency": self.amount_paid, "against_voucher_type": "Loan", "against_voucher": self.against_loan, - "remarks": _("Repayment against Loan: ") + self.against_loan, + "remarks": remarks, "cost_center": self.cost_center, "posting_date": getdate(self.posting_date) }) @@ -284,7 +319,9 @@ def create_repayment_entry(loan, applicant, company, posting_date, loan_type, return lr -def get_accrued_interest_entries(against_loan): +def get_accrued_interest_entries(against_loan, posting_date=None): + if not posting_date: + posting_date = getdate() unpaid_accrued_entries = frappe.db.sql( """ @@ -295,15 +332,28 @@ def get_accrued_interest_entries(against_loan): `tabLoan Interest Accrual` WHERE loan = %s + AND posting_date <= %s AND (interest_amount - paid_interest_amount > 0 OR payable_principal_amount - paid_principal_amount > 0) AND docstatus = 1 ORDER BY posting_date - """, (against_loan), as_dict=1) + """, (against_loan, posting_date), as_dict=1) return unpaid_accrued_entries +def get_penalty_details(against_loan): + penalty_details = frappe.db.sql(""" + SELECT posting_date, (penalty_amount - total_penalty_paid) as pending_penalty_amount + FROM `tabLoan Repayment` where posting_date >= (SELECT MAX(posting_date) from `tabLoan Repayment` + where against_loan = %s) and docstatus = 1 and against_loan = %s + """, (against_loan, against_loan)) + + if penalty_details: + return penalty_details[0][0], flt(penalty_details[0][1]) + else: + return None, 0 + # 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 @@ -312,8 +362,9 @@ def get_amounts(amounts, against_loan, posting_date): against_loan_doc = frappe.get_doc("Loan", against_loan) loan_type_details = frappe.get_doc("Loan Type", against_loan_doc.loan_type) - accrued_interest_entries = get_accrued_interest_entries(against_loan_doc.name) + accrued_interest_entries = get_accrued_interest_entries(against_loan_doc.name, posting_date) + computed_penalty_date, pending_penalty_amount = get_penalty_details(against_loan) pending_accrual_entries = {} total_pending_interest = 0 @@ -328,8 +379,13 @@ def get_amounts(amounts, against_loan, posting_date): # and if no_of_late days are positive then penalty is levied 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)) + 1 + due_date_after_grace_period = add_days(due_date, loan_type_details.grace_period_in_days) + + # Consider one day after already calculated penalty + if computed_penalty_date and getdate(computed_penalty_date) >= due_date_after_grace_period: + due_date_after_grace_period = add_days(computed_penalty_date, 1) + + no_of_late_days = date_diff(posting_date, due_date_after_grace_period) + 1 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) @@ -367,7 +423,7 @@ def get_amounts(amounts, against_loan, posting_date): amounts["pending_principal_amount"] = flt(pending_principal_amount, precision) amounts["payable_principal_amount"] = flt(payable_principal_amount, precision) amounts["interest_amount"] = flt(total_pending_interest, precision) - amounts["penalty_amount"] = flt(penalty_amount, precision) + amounts["penalty_amount"] = flt(penalty_amount + pending_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) diff --git a/erpnext/loan_management/doctype/loan_security_shortfall/loan_security_shortfall.json b/erpnext/loan_management/doctype/loan_security_shortfall/loan_security_shortfall.json index 102bc0d71d..99b5c72b2d 100644 --- a/erpnext/loan_management/doctype/loan_security_shortfall/loan_security_shortfall.json +++ b/erpnext/loan_management/doctype/loan_security_shortfall/loan_security_shortfall.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "LM-LSS-.#####", "creation": "2019-09-06 11:33:34.709540", "doctype": "DocType", @@ -14,6 +15,7 @@ "shortfall_amount", "column_break_8", "security_value", + "shortfall_percentage", "section_break_8", "process_loan_security_shortfall" ], @@ -85,10 +87,18 @@ { "fieldname": "column_break_8", "fieldtype": "Column Break" + }, + { + "fieldname": "shortfall_percentage", + "fieldtype": "Percent", + "label": "Shortfall Percentage", + "read_only": 1 } ], "in_create": 1, - "modified": "2019-10-24 06:24:26.128997", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-04-01 08:13:43.263772", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Security Shortfall", 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 6469806884..8233b7b297 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 @@ -12,7 +12,7 @@ from erpnext.loan_management.doctype.loan_security_unpledge.loan_security_unpled class LoanSecurityShortfall(Document): pass -def update_shortfall_status(loan, security_value): +def update_shortfall_status(loan, security_value, on_cancel=0): loan_security_shortfall = frappe.db.get_value("Loan Security Shortfall", {"loan": loan, "status": "Pending"}, ['name', 'shortfall_amount'], as_dict=1) @@ -22,7 +22,9 @@ 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_amount": loan_security_shortfall.shortfall_amount}) + "shortfall_amount": loan_security_shortfall.shortfall_amount, + "shortfall_percentage": 0 + }) else: frappe.db.set_value("Loan Security Shortfall", loan_security_shortfall.name, "shortfall_amount", loan_security_shortfall.shortfall_amount - security_value) @@ -55,6 +57,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: @@ -62,7 +67,8 @@ def check_for_ltv_shortfall(process_loan_security_shortfall): outstanding_amount = flt(loan.total_payment) - flt(loan.total_interest_payable) \ - flt(loan.total_principal_paid) else: - outstanding_amount = loan.disbursed_amount + outstanding_amount = flt(loan.disbursed_amount) - flt(loan.total_interest_payable) \ + - flt(loan.total_principal_paid) pledged_securities = get_pledged_security_qty(loan.name) ltv_ratio = '' @@ -71,16 +77,22 @@ 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) + current_ratio, 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): +def create_loan_security_shortfall(loan, loan_amount, security_value, shortfall_amount, shortfall_ratio, + process_loan_security_shortfall): existing_shortfall = frappe.db.get_value("Loan Security Shortfall", {"loan": loan, "status": "Pending"}, "name") if existing_shortfall: @@ -93,6 +105,7 @@ def create_loan_security_shortfall(loan, loan_amount, security_value, shortfall_ ltv_shortfall.loan_amount = loan_amount ltv_shortfall.security_value = security_value ltv_shortfall.shortfall_amount = shortfall_amount + ltv_shortfall.shortfall_percentage = shortfall_ratio ltv_shortfall.process_loan_security_shortfall = process_loan_security_shortfall ltv_shortfall.save() @@ -101,3 +114,12 @@ 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, + "shortfall_percentage": 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 c4c2d68378..b24dc2f7c2 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 @@ -6,7 +6,7 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import get_datetime, flt +from frappe.utils import get_datetime, flt, getdate import json from six import iteritems from erpnext.loan_management.doctype.loan_security_price.loan_security_price import get_loan_security_price @@ -113,7 +113,11 @@ class LoanSecurityUnpledge(Document): pledged_qty += qty if not pledged_qty: - frappe.db.set_value('Loan', self.loan, 'status', 'Closed') + frappe.db.set_value('Loan', self.loan, + { + 'status': 'Closed', + 'closure_date': getdate() + }) @frappe.whitelist() def get_pledged_security_qty(loan): diff --git a/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json b/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json index 2f4fe24945..3d07081215 100644 --- a/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json +++ b/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json @@ -70,7 +70,9 @@ { "fieldname": "loan_repayment_entry", "fieldtype": "Link", + "hidden": 1, "label": "Loan Repayment Entry", + "no_copy": 1, "options": "Loan Repayment", "read_only": 1 }, @@ -83,9 +85,10 @@ "read_only": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-04-16 13:17:04.798335", + "modified": "2021-03-14 20:47:11.725818", "modified_by": "Administrator", "module": "Loan Management", "name": "Salary Slip Loan", diff --git a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py index 0f72c3cce7..2a74a1eb85 100644 --- a/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py +++ b/erpnext/loan_management/report/loan_interest_report/loan_interest_report.py @@ -63,9 +63,11 @@ def get_active_loan_details(filters): currency = erpnext.get_company_currency(filters.get('company')) for loan in loan_details: + total_payment = loan.total_payment if loan.status == 'Disbursed' else loan.disbursed_amount + loan.update({ "sanctioned_amount": flt(sanctioned_amount_map.get(loan.applicant_name)), - "principal_outstanding": flt(loan.total_payment) - flt(loan.total_principal_paid) \ + "principal_outstanding": flt(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")), diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py index cba6a2d014..0aefe19c8d 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py +++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py @@ -12,6 +12,7 @@ from erpnext.stock.utils import get_valid_serial_nos from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee class MaintenanceSchedule(TransactionBase): + @frappe.whitelist() def generate_schedule(self): self.set('schedules', []) frappe.db.sql("""delete from `tabMaintenance Schedule Detail` diff --git a/erpnext/manufacturing/dashboard_fixtures.py b/erpnext/manufacturing/dashboard_fixtures.py index 0e9a21c026..7ba43d6471 100644 --- a/erpnext/manufacturing/dashboard_fixtures.py +++ b/erpnext/manufacturing/dashboard_fixtures.py @@ -43,7 +43,6 @@ def get_charts(): return [{ "doctype": "Dashboard Chart", "based_on": "modified", - "time_interval": "Yearly", "chart_type": "Sum", "chart_name": _("Produced Quantity"), "name": "Produced Quantity", @@ -60,7 +59,6 @@ def get_charts(): }, { "doctype": "Dashboard Chart", "based_on": "creation", - "time_interval": "Yearly", "chart_type": "Sum", "chart_name": _("Completed Operation"), "name": "Completed Operation", @@ -238,4 +236,4 @@ def get_number_cards(): "label": _("Monthly Quality Inspections"), "show_percentage_stats": 1, "stats_time_interval": "Weekly" - }] \ No newline at end of file + }] diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 03beedb663..979f7ca312 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -113,6 +113,7 @@ class BOM(WebsiteGenerator): return item + @frappe.whitelist() def get_routing(self): if self.routing: self.set("operations", []) @@ -145,6 +146,7 @@ class BOM(WebsiteGenerator): if not item.get(r): item.set(r, ret[r]) + @frappe.whitelist() def get_bom_material_detail(self, args=None): """ Get raw material details like uom, desc and rate""" if not args: @@ -210,6 +212,7 @@ class BOM(WebsiteGenerator): .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) + @frappe.whitelist() def update_cost(self, update_parent=True, from_child_bom=False, save=True): if self.docstatus == 2: return diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 3239478872..7108338dab 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import unittest import frappe -from frappe.utils import cstr +from frappe.utils import cstr, flt 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 @@ -81,15 +81,27 @@ class TestBOM(unittest.TestCase): bom = frappe.copy_doc(test_records[2]) bom.insert() - # test amounts in selected currency - self.assertEqual(bom.operating_cost, 100) - self.assertEqual(bom.raw_material_cost, 351.68) - self.assertEqual(bom.total_cost, 451.68) + raw_material_cost = 0.0 + op_cost = 0.0 + + for op_row in bom.operations: + op_cost += op_row.operating_cost + + for row in bom.items: + raw_material_cost += row.amount + + base_raw_material_cost = raw_material_cost * flt(bom.conversion_rate, bom.precision("conversion_rate")) + base_op_cost = op_cost * flt(bom.conversion_rate, bom.precision("conversion_rate")) + + # test amounts in selected currency, almostEqual checks for 7 digits by default + self.assertAlmostEqual(bom.operating_cost, op_cost) + self.assertAlmostEqual(bom.raw_material_cost, raw_material_cost) + self.assertAlmostEqual(bom.total_cost, raw_material_cost + op_cost) # test amounts in selected currency - self.assertEqual(bom.base_operating_cost, 6000) - self.assertEqual(bom.base_raw_material_cost, 21100.80) - self.assertEqual(bom.base_total_cost, 27100.80) + self.assertAlmostEqual(bom.base_operating_cost, base_op_cost) + self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost) + self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) def test_bom_cost_multi_uom_multi_currency_based_on_price_list(self): frappe.db.set_value("Price List", "_Test Price List", "price_not_uom_dependent", 1) @@ -134,7 +146,13 @@ class TestBOM(unittest.TestCase): bom.items[0].conversion_factor = 6 bom.insert() - reset_item_valuation_rate(item_code='_Test Item', qty=200, rate=200) + reset_item_valuation_rate( + item_code='_Test Item', + warehouse_list=frappe.get_all("Warehouse", + {"is_group":0, "company": bom.company}, pluck="name"), + qty=200, + rate=200 + ) bom.update_cost() diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py index 742d18c4cd..8fbcd4ea1d 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py @@ -53,7 +53,9 @@ class BOMUpdateTool(Document): rate=%s, amount=stock_qty*%s where bom_no = %s and docstatus < 2 and parenttype='BOM'""", (self.new_bom, unit_cost, unit_cost, self.current_bom)) - def get_parent_boms(self, bom, bom_list=[]): + def get_parent_boms(self, bom, bom_list=None): + if bom_list is None: + bom_list = [] data = frappe.db.sql("""SELECT DISTINCT parent FROM `tabBOM Item` WHERE bom_no = %s AND docstatus < 2 AND parenttype='BOM'""", bom) @@ -106,4 +108,4 @@ def update_cost(): for bom in bom_list: frappe.get_doc("BOM", bom).update_cost(update_parent=False, from_child_bom=True) - frappe.db.auto_commit_on_many_writes = 0 \ No newline at end of file + frappe.db.auto_commit_on_many_writes = 0 diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index d2ac71223d..fb26062566 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -47,6 +47,8 @@ class JobCard(Document): if d.completed_qty: self.total_completed_qty += d.completed_qty + self.total_completed_qty = flt(self.total_completed_qty, self.precision("total_completed_qty")) + def get_overlap_for(self, args, check_next_available_slot=False): production_capacity = 1 @@ -164,6 +166,7 @@ class JobCard(Document): "time_in_mins": time_diff_in_minutes(row.planned_end_time, row.planned_start_time), }) + @frappe.whitelist() def get_required_items(self): if not self.get('work_order'): return diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index 15ec6209c1..288c1d0cd6 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -25,6 +25,16 @@ frappe.ui.form.on('Production Plan', { } }); + frm.set_query('material_request', 'material_requests', function() { + return { + filters: { + material_request_type: "Manufacture", + docstatus: 1, + status: ["!=", "Stopped"], + } + }; + }); + frm.fields_dict['po_items'].grid.get_field('item_code').get_query = function(doc) { return { query: "erpnext.controllers.queries.item_query", @@ -370,4 +380,4 @@ cur_frm.fields_dict['sales_orders'].grid.get_field("sales_order").get_query = fu ['Sales Order','docstatus', '=' ,1] ] } -}; \ No newline at end of file +}; diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 109c8b5647..a3e23a6897 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -29,6 +29,7 @@ class ProductionPlan(Document): if not flt(d.planned_qty): frappe.throw(_("Please enter Planned Qty for Item {0} at row {1}").format(d.item_code, d.idx)) + @frappe.whitelist() def get_open_sales_orders(self): """ Pull sales orders which are pending to deliver based on criteria selected""" open_so = get_sales_orders(self) @@ -50,6 +51,7 @@ class ProductionPlan(Document): 'grand_total': data.base_grand_total }) + @frappe.whitelist() def get_pending_material_requests(self): """ Pull Material Requests that are pending based on criteria selected""" mr_filter = item_filter = "" @@ -68,7 +70,7 @@ class ProductionPlan(Document): from `tabMaterial Request` mr, `tabMaterial Request Item` mr_item where mr_item.parent = mr.name and mr.material_request_type = "Manufacture" - and mr.docstatus = 1 and mr.company = %(company)s + and mr.docstatus = 1 and mr.status != "Stopped" and mr.company = %(company)s and mr_item.qty > ifnull(mr_item.ordered_qty,0) {0} {1} and (exists (select name from `tabBOM` bom where bom.item=mr_item.item_code and bom.is_active = 1)) @@ -92,6 +94,7 @@ class ProductionPlan(Document): 'material_request_date': data.transaction_date }) + @frappe.whitelist() def get_items(self): if self.get_items_from == "Sales Order": self.get_so_items() @@ -219,6 +222,7 @@ class ProductionPlan(Document): filters = {'docstatus': 0, 'production_plan': ("=", self.name)}): frappe.delete_doc('Work Order', d.name) + @frappe.whitelist() def set_status(self, close=None): self.status = { 0: 'Draft', @@ -302,6 +306,7 @@ class ProductionPlan(Document): return item_dict + @frappe.whitelist() def make_work_order(self): wo_list = [] self.validate_data() @@ -367,6 +372,7 @@ class ProductionPlan(Document): except OverProductionError: pass + @frappe.whitelist() def make_material_request(self): '''Create Material Requests grouped by Sales Order and Material Request Type''' material_request_list = [] @@ -555,7 +561,6 @@ def get_material_request_items(row, sales_order, company, '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"), @@ -760,7 +765,7 @@ def get_items_for_material_requests(doc, warehouses=None): 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) + message += _("If you still want to proceed, please enable {0}.").format(to_enable) frappe.msgprint(message, title=_("Note")) diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js index 9b1a8ca670..032c9cd9a2 100644 --- a/erpnext/manufacturing/doctype/routing/routing.js +++ b/erpnext/manufacturing/doctype/routing/routing.js @@ -11,10 +11,9 @@ frappe.ui.form.on('Routing', { }, 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(); + frm.fields_dict.operations.grid.update_docfield_property( + 'sequence_id', 'in_list_view', 1 + ); }, calculate_operating_cost: function(frm, child) { @@ -69,4 +68,4 @@ frappe.ui.form.on('BOM Operation', { const d = locals[cdt][cdn]; frm.events.calculate_operating_cost(frm, d); } -}); \ 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 73d05a6157..6a38dcfa03 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -13,8 +13,15 @@ from erpnext.manufacturing.doctype.workstation.test_workstation import make_work from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record class TestRouting(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.item_code = "Test Routing Item - A" + + @classmethod + def tearDownClass(cls): + frappe.db.sql('delete from tabBOM where item=%s', cls.item_code) + 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}] @@ -22,8 +29,8 @@ class TestRouting(unittest.TestCase): 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) + bom_doc = setup_bom(item_code=self.item_code, routing=routing_doc.name) + wo_doc = make_wo_order_test_record(production_item = self.item_code, bom_no=bom_doc.name) for row in routing_doc.operations: self.assertEqual(row.sequence_id, row.idx) @@ -74,7 +81,7 @@ def setup_bom(**args): }) if not args.raw_materials: - if not frappe.db.exists('Item', "Test Extra Item 1"): + if not frappe.db.exists('Item', "Test Extra Item N-1"): make_item("Test Extra Item N-1", { 'is_stock_item': 1, }) @@ -88,4 +95,4 @@ def setup_bom(**args): else: bom_doc = frappe.get_doc("BOM", name) - return bom_doc \ No newline at end of file + return bom_doc diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 00e8c5418a..6b1fafe5f4 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -82,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") @@ -109,7 +109,7 @@ class TestWorkOrder(unittest.TestCase): s.submit() bin1_at_completion = get_bin(item, warehouse) - + self.assertEqual(cint(bin1_at_completion.reserved_qty_for_production), reserved_qty_on_submission - 1) @@ -371,14 +371,14 @@ class TestWorkOrder(unittest.TestCase): def test_job_card(self): stock_entries = [] - data = frappe.get_cached_value('BOM', - {'docstatus': 1, 'with_operations': 1, 'company': '_Test Company'}, ['name', 'item']) + bom = frappe.get_doc('BOM', { + 'docstatus': 1, + 'with_operations': 1, + 'company': '_Test Company' + }) - 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") + work_order = make_wo_order_test_record(item=bom.item, qty=1, + bom_no=bom.name, source_warehouse="_Test Warehouse - _TC") for row in work_order.required_items: stock_entry_doc = test_stock_entry.make_stock_entry(item_code=row.item_code, @@ -390,14 +390,14 @@ class TestWorkOrder(unittest.TestCase): 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)) + self.assertEqual(len(job_cards), len(bom.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), + "from_time": add_to_date(None, i), + "hours": 1, + "to_time": add_to_date(None, i + 1), "completed_qty": doc.for_quantity }) doc.submit() @@ -592,6 +592,55 @@ class TestWorkOrder(unittest.TestCase): 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` @@ -609,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.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 3d64ad4318..8507f5eb34 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -509,6 +509,7 @@ class WorkOrder(Document): stock_bin = get_bin(d.item_code, d.source_warehouse) stock_bin.update_reserved_qty_for_production() + @frappe.whitelist() def get_items_and_operations_from_bom(self): self.set_required_items() self.set_work_order_operations() @@ -613,6 +614,7 @@ class WorkOrder(Document): item.db_set('consumed_qty', flt(consumed_qty), update_modified=False) + @frappe.whitelist() def make_bom(self): data = frappe.db.sql(""" select sed.item_code, sed.qty, sed.s_warehouse from `tabStock Entry Detail` sed, `tabStock Entry` se diff --git a/erpnext/non_profit/doctype/member/member.py b/erpnext/non_profit/doctype/member/member.py index 3ba2ee71c6..efc072ee97 100644 --- a/erpnext/non_profit/doctype/member/member.py +++ b/erpnext/non_profit/doctype/member/member.py @@ -53,6 +53,7 @@ class Member(Document): return subscription + @frappe.whitelist() def make_customer_and_link(self): if self.customer: frappe.msgprint(_("A customer is already linked to this Member")) diff --git a/erpnext/non_profit/doctype/membership/membership.py b/erpnext/non_profit/doctype/membership/membership.py index 52447e4386..e8ae6187b7 100644 --- a/erpnext/non_profit/doctype/membership/membership.py +++ b/erpnext/non_profit/doctype/membership/membership.py @@ -74,6 +74,7 @@ class Membership(Document): self.generate_invoice(with_payment_entry=settings.automate_membership_payment_entries, save=True) + @frappe.whitelist() 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")) @@ -130,6 +131,7 @@ class Membership(Document): pe.save() pe.submit() + @frappe.whitelist() def send_acknowlement(self): settings = frappe.get_doc("Non Profit Settings") if not settings.send_email: 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 index 108554c6a0..a84cc2cdb5 100644 --- a/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.py +++ b/erpnext/non_profit/doctype/non_profit_settings/non_profit_settings.py @@ -9,6 +9,7 @@ from frappe.integrations.utils import get_payment_gateway_controller from frappe.model.document import Document class NonProfitSettings(Document): + @frappe.whitelist() def generate_webhook_secret(self, field="membership_webhook_secret"): key = frappe.generate_hash(length=20) self.set(field, key) @@ -21,6 +22,7 @@ class NonProfitSettings(Document): _("Webhook Secret") ) + @frappe.whitelist() def revoke_key(self, key): self.set(key, None) self.save() diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 6d5b4264fb..620cc5be62 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -693,7 +693,7 @@ 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 +erpnext.patches.v13_0.set_company_field_in_healthcare_doctypes #2021-04-16 erpnext.patches.v12_0.update_bom_in_so_mr execute:frappe.delete_doc("Report", "Department Analytics") execute:frappe.rename_doc("Desk Page", "Loan Management", "Loan", force=True) @@ -763,6 +763,13 @@ 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 -erpnext.patches.v13_0.fix_non_unique_represents_company -erpnext.patches.v12_0.create_taxable_value_field +execute:frappe.db.set_value('System Settings', None, 'app_name', 'ERPNext') erpnext.patches.v12_0.add_company_link_to_einvoice_settings +erpnext.patches.v13_0.rename_discharge_date_in_ip_record +erpnext.patches.v12_0.create_taxable_value_field +erpnext.patches.v12_0.add_gst_category_in_delivery_note +erpnext.patches.v12_0.purchase_receipt_status +erpnext.patches.v13_0.fix_non_unique_represents_company +erpnext.patches.v12_0.add_document_type_field_for_italy_einvoicing +erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 +erpnext.patches.v13_0.update_shipment_status diff --git a/erpnext/patches/v12_0/add_document_type_field_for_italy_einvoicing.py b/erpnext/patches/v12_0/add_document_type_field_for_italy_einvoicing.py new file mode 100644 index 0000000000..4d649dd0f0 --- /dev/null +++ b/erpnext/patches/v12_0/add_document_type_field_for_italy_einvoicing.py @@ -0,0 +1,18 @@ +from __future__ import unicode_literals +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +import frappe + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'Italy'}) + if not company: + return + + custom_fields = { + 'Sales Invoice': [ + dict(fieldname='type_of_document', label='Type of Document', + fieldtype='Select', insert_after='customer_fiscal_code', + options='\nTD01\nTD02\nTD03\nTD04\nTD05\nTD06\nTD16\nTD17\nTD18\nTD19\nTD20\nTD21\nTD22\nTD23\nTD24\nTD25\nTD26\nTD27'), + ] + } + + create_custom_fields(custom_fields, update=True) \ No newline at end of file diff --git a/erpnext/patches/v12_0/add_gst_category_in_delivery_note.py b/erpnext/patches/v12_0/add_gst_category_in_delivery_note.py new file mode 100644 index 0000000000..1208222504 --- /dev/null +++ b/erpnext/patches/v12_0/add_gst_category_in_delivery_note.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + custom_fields = { + 'Delivery Note': [ + dict(fieldname='gst_category', label='GST Category', + fieldtype='Select', insert_after='gst_vehicle_type', print_hide=1, + options='\nRegistered Regular\nRegistered Composition\nUnregistered\nSEZ\nOverseas\nConsumer\nDeemed Export\nUIN Holders', + fetch_from='customer.gst_category', fetch_if_empty=1), + ] + } + + create_custom_fields(custom_fields, update=True) \ 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 index d41101cc46..29a7b4bd60 100644 --- a/erpnext/patches/v12_0/add_state_code_for_ladakh.py +++ b/erpnext/patches/v12_0/add_state_code_for_ladakh.py @@ -11,6 +11,7 @@ def execute(): # Update options in gst_state custom fields for field in custom_fields: - gst_state_field = frappe.get_doc('Custom Field', field) - gst_state_field.options = '\n'.join(states) - gst_state_field.save() + 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/purchase_receipt_status.py b/erpnext/patches/v12_0/purchase_receipt_status.py new file mode 100644 index 0000000000..1a99b3163b --- /dev/null +++ b/erpnext/patches/v12_0/purchase_receipt_status.py @@ -0,0 +1,30 @@ +""" This patch fixes old purchase receipts (PR) where even after submitting + the PR, the `status` remains "Draft". `per_billed` field was copied over from previous + doc (PO), hence it is recalculated for setting new correct status of PR. +""" + +import frappe + +logger = frappe.logger("patch", allow_site=True, file_count=50) + +def execute(): + affected_purchase_receipts = frappe.db.sql( + """select name from `tabPurchase Receipt` + where status = 'Draft' and per_billed = 100 and docstatus = 1""" + ) + + if not affected_purchase_receipts: + return + + logger.info("purchase_receipt_status: begin patch, PR count: {}" + .format(len(affected_purchase_receipts))) + + + for pr in affected_purchase_receipts: + pr_name = pr[0] + logger.info("purchase_receipt_status: patching PR - {}".format(pr_name)) + + pr_doc = frappe.get_doc("Purchase Receipt", pr_name) + + pr_doc.update_billing_status(update_modified=False) + pr_doc.set_status(update=True, update_modified=False) diff --git a/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py b/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py index 5920bf1f70..a78f802574 100644 --- a/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py +++ b/erpnext/patches/v13_0/healthcare_lab_module_rename_doctypes.py @@ -18,6 +18,7 @@ def execute(): for old_dt, new_dt in doctypes.items(): if not frappe.db.table_exists(new_dt) and frappe.db.table_exists(old_dt): + frappe.reload_doc('healthcare', 'doctype', frappe.scrub(old_dt)) frappe.rename_doc('DocType', old_dt, new_dt, force=True) frappe.reload_doc('healthcare', 'doctype', frappe.scrub(new_dt)) frappe.delete_doc_if_exists('DocType', old_dt) @@ -36,6 +37,18 @@ def execute(): SET parentfield = %(parentfield)s """.format(doctype), {'parentfield': parentfield}) + # copy renamed child table fields (fields were already renamed in old doctype json, hence sql) + frappe.db.sql("""UPDATE `tabNormal Test Result` SET lab_test_name = test_name""") + frappe.db.sql("""UPDATE `tabNormal Test Result` SET lab_test_event = test_event""") + frappe.db.sql("""UPDATE `tabNormal Test Result` SET lab_test_uom = test_uom""") + frappe.db.sql("""UPDATE `tabNormal Test Result` SET lab_test_comment = test_comment""") + frappe.db.sql("""UPDATE `tabNormal Test Template` SET lab_test_event = test_event""") + frappe.db.sql("""UPDATE `tabNormal Test Template` SET lab_test_uom = test_uom""") + frappe.db.sql("""UPDATE `tabDescriptive Test Result` SET lab_test_particulars = test_particulars""") + frappe.db.sql("""UPDATE `tabLab Test Group Template` SET lab_test_template = test_template""") + frappe.db.sql("""UPDATE `tabLab Test Group Template` SET lab_test_description = test_description""") + frappe.db.sql("""UPDATE `tabLab Test Group Template` SET lab_test_rate = test_rate""") + # rename field frappe.reload_doc('healthcare', 'doctype', 'lab_test') if frappe.db.has_column('Lab Test', 'special_toggle'): diff --git a/erpnext/patches/v13_0/make_non_standard_user_type.py b/erpnext/patches/v13_0/make_non_standard_user_type.py new file mode 100644 index 0000000000..a9d7883d40 --- /dev/null +++ b/erpnext/patches/v13_0/make_non_standard_user_type.py @@ -0,0 +1,24 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe +from six import iteritems +from erpnext.setup.install import add_non_standard_user_types + +def execute(): + doctype_dict = { + 'projects': ['Timesheet'], + 'payroll': ['Salary Slip', 'Employee Tax Exemption Declaration', 'Employee Tax Exemption Proof Submission'], + 'hr': ['Employee', 'Expense Claim', 'Leave Application', 'Attendance Request', 'Compensatory Leave Request'] + } + + for module, doctypes in iteritems(doctype_dict): + for doctype in doctypes: + frappe.reload_doc(module, 'doctype', doctype) + + + frappe.flags.ignore_select_perm = True + frappe.flags.update_select_perm_after_migrate = True + + add_non_standard_user_types() \ No newline at end of file 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 0000000000..491dc82f78 --- /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/set_company_field_in_healthcare_doctypes.py b/erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py index be5e30f307..a5b93f6307 100644 --- a/erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py +++ b/erpnext/patches/v13_0/set_company_field_in_healthcare_doctypes.py @@ -3,7 +3,7 @@ import frappe def execute(): company = frappe.db.get_single_value('Global Defaults', 'default_company') - doctypes = ['Clinical Procedure', 'Inpatient Record', 'Lab Test', 'Sample Collection' 'Patient Appointment', 'Patient Encounter', 'Vital Signs', 'Therapy Session', 'Therapy Plan', 'Patient Assessment'] + doctypes = ['Clinical Procedure', 'Inpatient Record', 'Lab Test', 'Sample Collection', 'Patient Appointment', 'Patient Encounter', 'Vital Signs', 'Therapy Session', 'Therapy Plan', 'Patient Assessment'] for entry in doctypes: if frappe.db.exists('DocType', entry): frappe.reload_doc('Healthcare', 'doctype', entry) 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 index aea53f8add..833c355d5f 100644 --- 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 @@ -2,15 +2,12 @@ import frappe from erpnext.regional.india.setup import make_custom_fields def execute(): - company = frappe.get_all('Company', filters = {'country': 'India'}) - if not company: - return + if frappe.get_all('Company', filters = {'country': 'India'}): + make_custom_fields() - 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) \ No newline at end of file + 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_patient_history_settings_for_standard_doctypes.py b/erpnext/patches/v13_0/setup_patient_history_settings_for_standard_doctypes.py index 7ec470c740..d927524a3c 100644 --- 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 @@ -8,6 +8,7 @@ def execute(): frappe.reload_doc("healthcare", "doctype", "Inpatient Medication Order") frappe.reload_doc("healthcare", "doctype", "Therapy Session") + frappe.reload_doc("healthcare", "doctype", "Clinical Procedure") 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") diff --git a/erpnext/patches/v13_0/update_shipment_status.py b/erpnext/patches/v13_0/update_shipment_status.py new file mode 100644 index 0000000000..c425599e26 --- /dev/null +++ b/erpnext/patches/v13_0/update_shipment_status.py @@ -0,0 +1,14 @@ +import frappe + +def execute(): + frappe.reload_doc("stock", "doctype", "shipment") + + # update submitted status + frappe.db.sql("""UPDATE `tabShipment` + SET status = "Submitted" + WHERE status = "Draft" AND docstatus = 1""") + + # update cancelled status + frappe.db.sql("""UPDATE `tabShipment` + SET status = "Cancelled" + WHERE status = "Draft" AND docstatus = 2""") diff --git a/erpnext/patches/v7_1/update_lead_source.py b/erpnext/patches/v7_1/update_lead_source.py index 517e66c4bc..a2a48a62e1 100644 --- a/erpnext/patches/v7_1/update_lead_source.py +++ b/erpnext/patches/v7_1/update_lead_source.py @@ -5,7 +5,7 @@ from frappe import _ def execute(): from erpnext.setup.setup_wizard.operations.install_fixtures import default_lead_sources - frappe.reload_doc('selling', 'doctype', 'lead_source') + frappe.reload_doc('crm', 'doctype', 'lead_source') frappe.local.lang = frappe.db.get_default("lang") or 'en' diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.json b/erpnext/payroll/doctype/additional_salary/additional_salary.json index 3544244d60..5e17a5cbb7 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.json +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.json @@ -175,7 +175,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:33:59.098532", + "modified": "2021-03-31 22:33:59.098532", "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 029e11ff9b..13b6c05e22 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.py +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.py @@ -9,17 +9,10 @@ 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() 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 dcd01b5445..83326975b0 100644 --- a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.json +++ b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.json @@ -147,7 +147,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:35:08.940087", + "modified": "2021-03-31 22:35:08.940087", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Benefit Application", 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 d731ff90c0..b3bac01818 100644 --- a/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.json +++ b/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.json @@ -144,7 +144,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:37:21.024625", + "modified": "2021-03-31 22:37:21.024625", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Benefit Claim", diff --git a/erpnext/payroll/doctype/employee_incentive/employee_incentive.json b/erpnext/payroll/doctype/employee_incentive/employee_incentive.json index f11e3aa32d..0d10b2c19a 100644 --- a/erpnext/payroll/doctype/employee_incentive/employee_incentive.json +++ b/erpnext/payroll/doctype/employee_incentive/employee_incentive.json @@ -94,7 +94,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:38:20.332316", + "modified": "2021-03-31 22:38:20.332316", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Incentive", 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 bceada3870..b247d266ae 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 @@ -119,7 +119,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:39:59.237361", + "modified": "2021-03-31 22:39:59.237361", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Tax Exemption Declaration", 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 6770d3e1a8..77b107ef4a 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 @@ -142,7 +142,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:41:13.723339", + "modified": "2021-03-31 22:41:13.723339", "modified_by": "Administrator", "module": "Payroll", "name": "Employee Tax Exemption Proof Submission", diff --git a/erpnext/payroll/doctype/gratuity/test_gratuity.py b/erpnext/payroll/doctype/gratuity/test_gratuity.py index e89e3dd077..7daea2da47 100644 --- a/erpnext/payroll/doctype/gratuity/test_gratuity.py +++ b/erpnext/payroll/doctype/gratuity/test_gratuity.py @@ -15,9 +15,12 @@ from frappe.utils import getdate, add_days, get_datetime, flt test_dependencies = ["Salary Component", "Salary Slip", "Account"] class TestGratuity(unittest.TestCase): - def setUp(self): + @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'") 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 935d89fbc9..5a7de37bec 100644 --- a/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.json +++ b/erpnext/payroll/doctype/income_tax_slab/income_tax_slab.json @@ -104,7 +104,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:42:08.139520", + "modified": "2021-03-31 22:42:08.139520", "modified_by": "Administrator", "module": "Payroll", "name": "Income Tax Slab", diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js index 7ead0b3882..f2892600d1 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js @@ -137,29 +137,40 @@ frappe.ui.form.on('Payroll Entry', { frm.set_query('employee', 'employees', () => { if (!frm.doc.company) { frappe.msgprint(__("Please set a Company")); - return [] - } - let filters = {}; - filters['company'] = frm.doc.company; - filters['start_date'] = frm.doc.start_date; - filters['end_date'] = frm.doc.end_date; - - if (frm.doc.department) { - filters['department'] = frm.doc.department; - } - if (frm.doc.branch) { - filters['branch'] = frm.doc.branch; - } - if (frm.doc.designation) { - filters['designation'] = frm.doc.designation; + return []; } return { query: "erpnext.payroll.doctype.payroll_entry.payroll_entry.employee_query", - filters: filters - } + filters: frm.events.get_employee_filters(frm) + }; }); }, + get_employee_filters: function (frm) { + let filters = {}; + filters['company'] = frm.doc.company; + filters['start_date'] = frm.doc.start_date; + filters['end_date'] = frm.doc.end_date; + filters['salary_slip_based_on_timesheet'] = frm.doc.salary_slip_based_on_timesheet; + filters['payroll_frequency'] = frm.doc.payroll_frequency; + filters['payroll_payable_account'] = frm.doc.payroll_payable_account; + filters['currency'] = frm.doc.currency; + + if (frm.doc.department) { + filters['department'] = frm.doc.department; + } + if (frm.doc.branch) { + filters['branch'] = frm.doc.branch; + } + if (frm.doc.designation) { + filters['designation'] = frm.doc.designation; + } + if (frm.doc.employees) { + filters['employees'] = frm.doc.employees.filter(d => d.employee).map(d => d.employee); + } + return filters; + }, + payroll_frequency: function (frm) { frm.trigger("set_start_end_dates").then( ()=> { frm.events.clear_employee_table(frm); @@ -169,6 +180,16 @@ frappe.ui.form.on('Payroll Entry', { company: function (frm) { frm.events.clear_employee_table(frm); erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + frm.trigger("set_payable_account_and_currency"); + }, + + set_payable_account_and_currency: function (frm) { + frappe.db.get_value("Company", {"name": frm.doc.company}, "default_currency", (r) => { + frm.set_value('currency', r.default_currency); + }); + frappe.db.get_value("Company", {"name": frm.doc.company}, "default_payroll_payable_account", (r) => { + frm.set_value('payroll_payable_account', r.default_payroll_payable_account); + }); }, currency: function (frm) { @@ -342,11 +363,3 @@ let render_employee_attendance = function (frm, 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.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 1a6a53438d..3953b463f1 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -15,12 +15,12 @@ from frappe.desk.reportview import get_match_cond, get_filters_cond class PayrollEntry(Document): def onload(self): if not self.docstatus==1 or self.salary_slips_submitted: - return + return # 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) @@ -52,50 +52,34 @@ class PayrollEntry(Document): Returns list of active employees based on selected criteria and for which salary structure exists """ - cond = self.get_filter_condition() - cond += self.get_joining_relieving_condition() + self.check_mandatory() + filters = self.make_filters() + cond = get_filter_condition(filters) + cond += get_joining_relieving_condition(self.start_date, self.end_date) condition = '' if self.payroll_frequency: condition = """and payroll_frequency = '%(payroll_frequency)s'"""% {"payroll_frequency": self.payroll_frequency} - sal_struct = frappe.db.sql_list(""" - select - name from `tabSalary Structure` - where - docstatus = 1 and - is_active = 'Yes' - 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, "currency": self.currency, "salary_slip_based_on_timesheet":self.salary_slip_based_on_timesheet}) - + sal_struct = get_sal_struct(self.company, self.currency, self.salary_slip_based_on_timesheet, condition) 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 - distinct t1.name as employee, t1.employee_name, t1.department, t1.designation - from - `tabEmployee` t1, `tabSalary Structure Assignment` t2 - where - 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, "payroll_payable_account": self.payroll_payable_account}, as_dict=True) - - emp_list = self.remove_payrolled_employees(emp_list) + emp_list = get_emp_list(sal_struct, cond, self.end_date, self.payroll_payable_account) + emp_list = remove_payrolled_employees(emp_list, self.start_date, self.end_date) 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) + def make_filters(self): + filters = frappe._dict() + filters['company'] = self.company + filters['branch'] = self.branch + filters['department'] = self.department + filters['designation'] = self.designation - return emp_list + return filters + @frappe.whitelist() def fill_employee_details(self): self.set('employees', []) employees = self.get_emp_list() @@ -121,28 +105,12 @@ class PayrollEntry(Document): if self.validate_attendance: return self.validate_employee_attendance() - def get_filter_condition(self): - self.check_mandatory() - - cond = '' - for f in ['company', 'branch', 'department', 'designation']: - if self.get(f): - cond += " and t1." + f + " = " + frappe.db.escape(self.get(f)) - - return cond - - def get_joining_relieving_condition(self): - cond = """ - and ifnull(t1.date_of_joining, '0000-00-00') <= '%(end_date)s' - and ifnull(t1.relieving_date, '2199-12-31') >= '%(start_date)s' - """ % {"start_date": self.start_date, "end_date": self.end_date} - return cond - def check_mandatory(self): for fieldname in ['company', 'start_date', 'end_date']: if not self.get(fieldname): frappe.throw(_("Please set {0}").format(self.meta.get_label(fieldname))) + @frappe.whitelist() def create_salary_slips(self): """ Creates salary slip for selected employees if already not created @@ -269,26 +237,26 @@ class PayrollEntry(Document): 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(amt, precision), - "exchange_rate": flt(exchange_rate), - "party_type": '', - "cost_center": acc_cc[1] or self.cost_center, - "project": self.project - }) + "account": acc_cc[0], + "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 + }) # 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(amt, precision), - "exchange_rate": flt(exchange_rate), - "cost_center": acc_cc[1] or self.cost_center, - "party_type": '', - "project": self.project - }) + "account": acc_cc[0], + "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) @@ -330,6 +298,7 @@ class PayrollEntry(Document): amount = flt(amount) * flt(conversion_rate) return exchange_rate, amount + @frappe.whitelist() def make_payment_entry(self): self.check_permission('write') @@ -367,20 +336,20 @@ class PayrollEntry(Document): 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), - }) + "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 - }) + "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 @@ -422,7 +391,7 @@ class PayrollEntry(Document): employees_to_mark_attendance.append({ "employee": employee_detail.employee, "employee_name": employee_detail.employee_name - }) + }) return employees_to_mark_attendance def get_count_holidays_of_employee(self, employee, start_date): @@ -439,15 +408,62 @@ class PayrollEntry(Document): def get_count_employee_attendance(self, employee, start_date): marked_days = 0 attendances = frappe.get_all("Attendance", - fields = ["count(*)"], - filters = { - "employee": employee, - "attendance_date": ('between', [start_date, self.end_date]) - }, as_list=1) + 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 +def get_sal_struct(company, currency, salary_slip_based_on_timesheet, condition): + return frappe.db.sql_list(""" + select + name from `tabSalary Structure` + where + docstatus = 1 and + is_active = 'Yes' + 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": company, "currency": currency, "salary_slip_based_on_timesheet": salary_slip_based_on_timesheet}) + +def get_filter_condition(filters): + cond = '' + for f in ['company', 'branch', 'department', 'designation']: + if filters.get(f): + cond += " and t1." + f + " = " + frappe.db.escape(filters.get(f)) + + return cond + +def get_joining_relieving_condition(start_date, end_date): + cond = """ + and ifnull(t1.date_of_joining, '0000-00-00') <= '%(end_date)s' + and ifnull(t1.relieving_date, '2199-12-31') >= '%(start_date)s' + """ % {"start_date": start_date, "end_date": end_date} + return cond + +def get_emp_list(sal_struct, cond, end_date, payroll_payable_account): + return frappe.db.sql(""" + select + distinct t1.name as employee, t1.employee_name, t1.department, t1.designation + from + `tabEmployee` t1, `tabSalary Structure Assignment` t2 + where + t1.name = t2.employee + and t2.docstatus = 1 + %s order by t2.from_date desc + """ % cond, {"sal_struct": tuple(sal_struct), "from_date": end_date, "payroll_payable_account": payroll_payable_account}, as_dict=True) + +def remove_payrolled_employees(emp_list, start_date, end_date): + for employee_details in emp_list: + if frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}): + emp_list.remove(employee_details) + + return emp_list + @frappe.whitelist() def get_start_end_dates(payroll_frequency, start_date=None, company=None): '''Returns dict of start and end dates for given payroll frequency based on start_date''' @@ -579,11 +595,11 @@ def create_salary_slips_for_employees(employees, args, publish_progress=True): def get_existing_salary_slips(employees, args): return frappe.db.sql_list(""" select distinct employee from `tabSalary Slip` - where docstatus!= 2 and company = %s + where docstatus!= 2 and company = %s and payroll_entry = %s and start_date >= %s and end_date <= %s and employee in (%s) - """ % ('%s', '%s', '%s', ', '.join(['%s']*len(employees))), - [args.company, args.start_date, args.end_date] + employees) + """ % ('%s', '%s', '%s', '%s', ', '.join(['%s']*len(employees))), + [args.company, args.payroll_entry, args.start_date, args.end_date] + employees) def submit_salary_slips_for_employees(payroll_entry, salary_slips, publish_progress=True): submitted_ss = [] @@ -636,34 +652,41 @@ def get_payroll_entries_for_jv(doctype, txt, searchfield, start, page_len, filte 'start': start, 'page_len': page_len }) -def get_employee_with_existing_salary_slip(start_date, end_date): - - return frappe.db.sql_list(""" - select employee from `tabSalary Slip` - where - (start_date between %(start_date)s and %(end_date)s - or - end_date between %(start_date)s and %(end_date)s - or - %(start_date)s between start_date and end_date) - and docstatus = 1 - """, {'start_date': start_date, 'end_date': end_date}) +def get_employee_list(filters): + cond = get_filter_condition(filters) + cond += get_joining_relieving_condition(filters.start_date, filters.end_date) + condition = """and payroll_frequency = '%(payroll_frequency)s'"""% {"payroll_frequency": filters.payroll_frequency} + sal_struct = get_sal_struct(filters.company, filters.currency, filters.salary_slip_based_on_timesheet, condition) + 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 = get_emp_list(sal_struct, cond, filters.end_date, filters.payroll_payable_account) + emp_list = remove_payrolled_employees(emp_list, filters.start_date, filters.end_date) + return emp_list @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def employee_query(doctype, txt, searchfield, start, page_len, filters): filters = frappe._dict(filters) conditions = [] + include_employees = [] emp_cond = '' if filters.start_date and filters.end_date: - employee_list = get_employee_with_existing_salary_slip(filters.start_date, filters.end_date) + employee_list = get_employee_list(filters) + emp = filters.get('employees') + include_employees = [employee.employee for employee in employee_list if employee.employee not in emp] filters.pop('start_date') filters.pop('end_date') - if employee_list: - emp_cond += 'and employee not in %(employee_list)s' - else: - employee_list = [] - + filters.pop('salary_slip_based_on_timesheet') + filters.pop('payroll_frequency') + filters.pop('payroll_payable_account') + filters.pop('currency') + if filters.employees is not None: + filters.pop('employees') + + if include_employees: + emp_cond += 'and employee in %(include_employees)s' return frappe.db.sql("""select name, employee_name from `tabEmployee` where status = 'Active' @@ -687,5 +710,4 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): '_txt': txt.replace("%", ""), 'start': start, 'page_len': page_len, - 'employee_list': employee_list - }) + 'include_employees': include_employees}) diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py index 9e68df99eb..7528bf7a7f 100644 --- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py @@ -12,7 +12,7 @@ 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, 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 +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): @@ -41,6 +41,41 @@ class TestPayrollEntry(unittest.TestCase): 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, currency='USD') + 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 + if payroll_je: + 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"]): @@ -134,15 +169,23 @@ class TestPayrollEntry(unittest.TestCase): salary_structure = "Test Salary Structure for Loan" 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, 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") @@ -233,4 +276,4 @@ def get_salary_slip(user, period, salary_structure): salary_slip.calculate_net_pay() salary_slip.db_update() - return salary_slip \ No newline at end of file + return salary_slip diff --git a/erpnext/payroll/doctype/retention_bonus/retention_bonus.json b/erpnext/payroll/doctype/retention_bonus/retention_bonus.json index 65b566f83a..7ea6210c7a 100644 --- a/erpnext/payroll/doctype/retention_bonus/retention_bonus.json +++ b/erpnext/payroll/doctype/retention_bonus/retention_bonus.json @@ -105,7 +105,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:43:28.363644", + "modified": "2021-03-31 22:43:28.363644", "modified_by": "Administrator", "module": "Payroll", "name": "Retention Bonus", diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.js b/erpnext/payroll/doctype/salary_slip/salary_slip.js index 3e8a213ca9..5258f3aff9 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.js +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.js @@ -39,7 +39,10 @@ frappe.ui.form.on("Salary Slip", { frm.set_query("employee", function() { return { - query: "erpnext.controllers.queries.employee_query" + query: "erpnext.controllers.queries.employee_query", + filters: { + company: frm.doc.company + } }; }); }, @@ -93,27 +96,31 @@ frappe.ui.form.on("Salary Slip", { }, 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", ""); + if (frm.doc.docstatus === 0) { + 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) { + if (r.message) { + 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", "" ); + } } } }, diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.json b/erpnext/payroll/doctype/salary_slip/salary_slip.json index 262b7164a1..42a0f290cb 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.json +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.json @@ -631,7 +631,7 @@ "idx": 9, "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:44:09.772331", + "modified": "2021-03-31 22:44:09.772331", "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 794f3645d1..afdf081ac8 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -124,9 +124,12 @@ class SalarySlip(TransactionBase): def check_existing(self): if not self.salary_slip_based_on_timesheet: + cond = "" + if self.payroll_entry: + cond += "and payroll_entry = '{0}'".format(self.payroll_entry) ret_exist = frappe.db.sql("""select name from `tabSalary Slip` where start_date = %s and end_date = %s and docstatus != 2 - and employee = %s and name != %s""", + and employee = %s and name != %s {0}""".format(cond), (self.start_date, self.end_date, self.employee, self.name)) if ret_exist: self.employee = '' @@ -142,6 +145,7 @@ class SalarySlip(TransactionBase): self.start_date = date_details.start_date self.end_date = date_details.end_date + @frappe.whitelist() def get_emp_and_working_day_details(self): '''First time, load all the components from salary structure''' if self.employee: @@ -524,7 +528,7 @@ class SalarySlip(TransactionBase): except NameError as err: frappe.throw(_("{0}
This error can be due to missing or deleted field.").format(err), - title=_("Name error")) + title=_("Name error")) except SyntaxError as err: frappe.throw(_("Syntax error in formula or condition: {0}").format(err)) except Exception as e: @@ -617,13 +621,16 @@ class SalarySlip(TransactionBase): component_row = self.append(component_type) for attr in ( - 'depends_on_payment_days', 'salary_component', 'abbr' + 'depends_on_payment_days', 'salary_component', '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)) + abbr = component_data.get('abbr') or component_data.get('salary_component_abbr') + component_row.set('abbr', abbr) + if additional_salary: component_row.default_amount = 0 component_row.additional_amount = amount @@ -963,7 +970,7 @@ class SalarySlip(TransactionBase): return frappe.safe_eval(condition, self.whitelisted_globals, data) except NameError as err: frappe.throw(_("{0}
This error can be due to missing or deleted field.").format(err), - title=_("Name error")) + title=_("Name error")) except SyntaxError as err: frappe.throw(_("Syntax error in condition: {0}").format(err)) except Exception as e: @@ -1049,7 +1056,7 @@ class SalarySlip(TransactionBase): repayment_entry.save() repayment_entry.submit() - loan.loan_repayment_entry = repayment_entry.name + frappe.db.set_value("Salary Slip Loan", loan.name, "loan_repayment_entry", repayment_entry.name) def cancel_loan_repayment_entry(self): for loan in self.loans: @@ -1114,10 +1121,12 @@ class SalarySlip(TransactionBase): self.bank_name = emp.bank_name self.bank_account_no = emp.bank_ac_no + @frappe.whitelist() def process_salary_based_on_working_days(self): self.get_working_days_details(lwp=self.leave_without_pay) self.calculate_net_pay() + @frappe.whitelist() def set_totals(self): self.gross_pay = 0.0 if self.salary_slip_based_on_timesheet == 1: diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index a59a67c51e..01e4170d31 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -361,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() diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.js b/erpnext/payroll/doctype/salary_structure/salary_structure.js index 6aa1387363..b539b1b8a9 100755 --- a/erpnext/payroll/doctype/salary_structure/salary_structure.js +++ b/erpnext/payroll/doctype/salary_structure/salary_structure.js @@ -111,12 +111,19 @@ frappe.ui.form.on('Salary Structure', { frappe.set_route('Form', 'Salary Structure Assignment', doc.name); }); frm.add_custom_button(__("Assign to Employees"),function () { - frm.trigger('assign_to_employees') - }) + frm.trigger('assign_to_employees') + }) } + + // set columns read-only let fields_read_only = ["is_tax_applicable", "is_flexible_benefit", "variable_based_on_taxable_salary"]; fields_read_only.forEach(function(field) { - frappe.meta.get_docfield("Salary Detail", field, frm.doc.name).read_only = 1; + frm.fields_dict.earnings.grid.update_docfield_property( + field, 'read_only', 1 + ); + frm.fields_dict.deductions.grid.update_docfield_property( + field, 'read_only', 1 + ); }); frm.trigger('set_earning_deduction_component'); }, diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.json b/erpnext/payroll/doctype/salary_structure/salary_structure.json index de56fc8457..5dd1d701f0 100644 --- a/erpnext/payroll/doctype/salary_structure/salary_structure.json +++ b/erpnext/payroll/doctype/salary_structure/salary_structure.json @@ -232,7 +232,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2020-09-30 11:30:32.190798", + "modified": "2021-03-31 15:41:12.342380", "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 1712081550..352c1804f0 100644 --- a/erpnext/payroll/doctype/salary_structure/salary_structure.py +++ b/erpnext/payroll/doctype/salary_structure/salary_structure.py @@ -100,7 +100,7 @@ class SalaryStructure(Document): from_date=from_date, base=base, variable=variable, income_tax_slab=income_tax_slab) else: assign_salary_structure_for_employees(employees, self, - payroll_payable_account=payroll_payable_account, + 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")) 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 a4e1a5ad1a..c8b98e5aaf 100644 --- a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json +++ b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json @@ -145,7 +145,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-03-31 21:44:46.267974", + "modified": "2021-03-31 22:44:46.267974", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Structure Assignment", diff --git a/erpnext/portal/doctype/products_settings/products_settings.js b/erpnext/portal/doctype/products_settings/products_settings.js index b68b5d7aa8..2f8b037164 100644 --- a/erpnext/portal/doctype/products_settings/products_settings.js +++ b/erpnext/portal/doctype/products_settings/products_settings.js @@ -10,10 +10,12 @@ frappe.ui.form.on('Products Settings', { 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(); + frm.fields_dict.filter_fields.grid.update_docfield_property( + 'fieldname', 'fieldtype', 'Select' + ); + frm.fields_dict.filter_fields.grid.update_docfield_property( + 'fieldname', 'options', valid_fields + ); }); } }); diff --git a/erpnext/portal/product_configurator/test_product_configurator.py b/erpnext/portal/product_configurator/test_product_configurator.py index 97042dba92..3521e7e8bf 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 21fd7c2878..d77eb2c396 100644 --- a/erpnext/portal/product_configurator/utils.py +++ b/erpnext/portal/product_configurator/utils.py @@ -298,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 diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 8ba0b6cb54..f9e1359b45 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -81,12 +81,18 @@ class Project(Document): def calculate_start_date(self, task_details): self.start_date = add_days(self.expected_start_date, task_details.start) - self.start_date = update_if_holiday(self.holiday_list, self.start_date) + 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 update_if_holiday(self.holiday_list, self.end_date) + 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: @@ -541,9 +547,3 @@ def set_project_status(project, status): project.status = status project.save() - -def update_if_holiday(holiday_list, date): - holiday_list = holiday_list or get_holiday_list() - while is_holiday(holiday_list, date): - date = add_days(date, 1) - return date diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index 62905385a3..70139c6da8 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -4,13 +4,14 @@ from __future__ import unicode_literals import frappe, unittest +from frappe.utils import getdate, nowdate, add_days + +from erpnext.projects.doctype.project_template.test_project_template import make_project_template +from erpnext.projects.doctype.task.test_task import create_task + test_records = frappe.get_test_records('Project') test_ignore = ["Sales Order"] -from erpnext.projects.doctype.project_template.test_project_template import make_project_template -from erpnext.projects.doctype.project.project import update_if_holiday -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_having_no_parent_and_depend_tasks(self): @@ -32,12 +33,16 @@ class TestProject(unittest.TestCase): def test_project_template_having_parent_child_tasks(self): project_name = "Test Project with Template - Tasks with Parent-Child Relation" + + if frappe.db.get_value('Project', {'project_name': project_name}, 'name'): + project_name = frappe.db.get_value('Project', {'project_name': project_name}, 'name') + frappe.db.sql(""" delete from tabTask where project = %s """, project_name) frappe.delete_doc('Project', project_name) 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) + task1 = create_task(subject="Test Template Task Parent", is_group=1, is_template=1, begin=1, duration=10) task2 = task_exists("Test Template Task Child 1") if not task2: @@ -52,7 +57,7 @@ class TestProject(unittest.TestCase): 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(getdate(tasks[0].exp_end_date), calculate_end_date(project, 1, 10)) self.assertEqual(tasks[1].subject, 'Test Template Task Child 1') self.assertEqual(getdate(tasks[1].exp_end_date), calculate_end_date(project, 1, 3)) @@ -97,7 +102,8 @@ def get_project(name, template): project_name = name, status = 'Open', project_template = template.name, - expected_start_date = nowdate() + expected_start_date = nowdate(), + company="_Test Company" )).insert() return project @@ -112,7 +118,8 @@ def make_project(args): doctype = 'Project', project_name = args.project_name, status = 'Open', - expected_start_date = args.start_date + expected_start_date = args.start_date, + company= args.company or '_Test Company' )) if args.project_template_name: @@ -131,7 +138,7 @@ def task_exists(subject): def calculate_end_date(project, start, duration): start = add_days(project.expected_start_date, start) - start = update_if_holiday(project.holiday_list, start) + start = project.update_if_holiday(start) end = add_days(start, duration) - end = update_if_holiday(project.holiday_list, end) - return getdate(end) \ No newline at end of file + end = project.update_if_holiday(end) + return getdate(end) diff --git a/erpnext/projects/doctype/task/task.json b/erpnext/projects/doctype/task/task.json index 160cc5812f..ef4740d9ee 100644 --- a/erpnext/projects/doctype/task/task.json +++ b/erpnext/projects/doctype/task/task.json @@ -11,15 +11,16 @@ "project", "issue", "type", + "color", "is_group", "is_template", "column_break0", "status", "priority", "task_weight", - "completed_by", - "color", "parent_task", + "completed_by", + "completed_on", "sb_timeline", "exp_start_date", "expected_time", @@ -358,6 +359,7 @@ "read_only": 1 }, { + "depends_on": "eval: doc.status == \"Completed\"", "fieldname": "completed_by", "fieldtype": "Link", "label": "Completed By", @@ -381,6 +383,13 @@ "fieldname": "duration", "fieldtype": "Int", "label": "Duration (Days)" + }, + { + "depends_on": "eval: doc.status == \"Completed\"", + "fieldname": "completed_on", + "fieldtype": "Date", + "label": "Completed On", + "mandatory_depends_on": "eval: doc.status == \"Completed\"" } ], "icon": "fa fa-check", @@ -388,7 +397,7 @@ "is_tree": 1, "links": [], "max_attachments": 5, - "modified": "2020-12-28 11:32:58.714991", + "modified": "2021-04-16 12:46:51.556741", "modified_by": "Administrator", "module": "Projects", "name": "Task", diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index 855ff5f83e..d1583f1473 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -36,6 +36,7 @@ class Task(NestedSet): self.validate_status() self.update_depends_on() self.validate_dependencies_for_template_task() + self.validate_completed_on() 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): @@ -100,6 +101,10 @@ class Task(NestedSet): dependent_task_format = """{0}""".format(task.task) frappe.throw(_("Dependent Task {0} is not a Template Task").format(dependent_task_format)) + def validate_completed_on(self): + if self.completed_on and getdate(self.completed_on) > getdate(): + frappe.throw(_("Completed On cannot be greater than Today")) + def update_depends_on(self): depends_on_tasks = self.depends_on_tasks or "" for d in self.depends_on: diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 4cb38049ff..f7c764e1bd 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) diff --git a/erpnext/projects/report/delayed_tasks_summary/__init__.py b/erpnext/projects/report/delayed_tasks_summary/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.js b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.js new file mode 100644 index 0000000000..5aa44c0a8c --- /dev/null +++ b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.js @@ -0,0 +1,41 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Delayed Tasks Summary"] = { + "filters": [ + { + "fieldname": "from_date", + "label": __("From Date"), + "fieldtype": "Date" + }, + { + "fieldname": "to_date", + "label": __("To Date"), + "fieldtype": "Date" + }, + { + "fieldname": "priority", + "label": __("Priority"), + "fieldtype": "Select", + "options": ["", "Low", "Medium", "High", "Urgent"] + }, + { + "fieldname": "status", + "label": __("Status"), + "fieldtype": "Select", + "options": ["", "Open", "Working","Pending Review","Overdue","Completed"] + }, + ], + "formatter": function(value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + if (column.id == "delay") { + if (data["delay"] > 0) { + value = `

${value}

`; + } else { + value = `

${value}

`; + } + } + return value + } +}; diff --git a/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.json b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.json new file mode 100644 index 0000000000..100c422433 --- /dev/null +++ b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.json @@ -0,0 +1,29 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-03-25 15:03:19.857418", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-04-15 15:49:35.432486", + "modified_by": "Administrator", + "module": "Projects", + "name": "Delayed Tasks Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Task", + "report_name": "Delayed Tasks Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "Projects User" + }, + { + "role": "Projects Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py new file mode 100644 index 0000000000..cdabe6487e --- /dev/null +++ b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py @@ -0,0 +1,133 @@ +# 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.utils import date_diff, nowdate + +def execute(filters=None): + columns, data = [], [] + data = get_data(filters) + columns = get_columns() + charts = get_chart_data(data) + return columns, data, None, charts + +def get_data(filters): + conditions = get_conditions(filters) + tasks = frappe.get_all("Task", + filters = conditions, + fields = ["name", "subject", "exp_start_date", "exp_end_date", + "status", "priority", "completed_on", "progress"], + order_by="creation" + ) + for task in tasks: + if task.exp_end_date: + if task.completed_on: + task.delay = date_diff(task.completed_on, task.exp_end_date) + elif task.status == "Completed": + # task is completed but completed on is not set (for older tasks) + task.delay = 0 + else: + # task not completed + task.delay = date_diff(nowdate(), task.exp_end_date) + else: + # task has no end date, hence no delay + task.delay = 0 + + # Sort by descending order of delay + tasks.sort(key=lambda x: x["delay"], reverse=True) + return tasks + +def get_conditions(filters): + conditions = frappe._dict() + keys = ["priority", "status"] + for key in keys: + if filters.get(key): + conditions[key] = filters.get(key) + if filters.get("from_date"): + conditions.exp_end_date = [">=", filters.get("from_date")] + if filters.get("to_date"): + conditions.exp_start_date = ["<=", filters.get("to_date")] + return conditions + +def get_chart_data(data): + delay, on_track = 0, 0 + for entry in data: + if entry.get("delay") > 0: + delay = delay + 1 + else: + on_track = on_track + 1 + charts = { + "data": { + "labels": ["On Track", "Delayed"], + "datasets": [ + { + "name": "Delayed", + "values": [on_track, delay] + } + ] + }, + "type": "percentage", + "colors": ["#84D5BA", "#CB4B5F"] + } + return charts + +def get_columns(): + columns = [ + { + "fieldname": "name", + "fieldtype": "Link", + "label": "Task", + "options": "Task", + "width": 150 + }, + { + "fieldname": "subject", + "fieldtype": "Data", + "label": "Subject", + "width": 200 + }, + { + "fieldname": "status", + "fieldtype": "Data", + "label": "Status", + "width": 100 + }, + { + "fieldname": "priority", + "fieldtype": "Data", + "label": "Priority", + "width": 80 + }, + { + "fieldname": "progress", + "fieldtype": "Data", + "label": "Progress (%)", + "width": 120 + }, + { + "fieldname": "exp_start_date", + "fieldtype": "Date", + "label": "Expected Start Date", + "width": 150 + }, + { + "fieldname": "exp_end_date", + "fieldtype": "Date", + "label": "Expected End Date", + "width": 150 + }, + { + "fieldname": "completed_on", + "fieldtype": "Date", + "label": "Actual End Date", + "width": 130 + }, + { + "fieldname": "delay", + "fieldtype": "Data", + "label": "Delay (In Days)", + "width": 120 + } + ] + return columns diff --git a/erpnext/projects/report/delayed_tasks_summary/test_delayed_tasks_summary.py b/erpnext/projects/report/delayed_tasks_summary/test_delayed_tasks_summary.py new file mode 100644 index 0000000000..dbeedb4be9 --- /dev/null +++ b/erpnext/projects/report/delayed_tasks_summary/test_delayed_tasks_summary.py @@ -0,0 +1,54 @@ +from __future__ import unicode_literals +import unittest +import frappe +from frappe.utils import nowdate, add_days, add_months +from erpnext.projects.doctype.task.test_task import create_task +from erpnext.projects.report.delayed_tasks_summary.delayed_tasks_summary import execute + +class TestDelayedTasksSummary(unittest.TestCase): + @classmethod + def setUp(self): + task1 = create_task("_Test Task 98", add_days(nowdate(), -10), nowdate()) + create_task("_Test Task 99", add_days(nowdate(), -10), add_days(nowdate(), -1)) + + task1.status = "Completed" + task1.completed_on = add_days(nowdate(), -1) + task1.save() + + def test_delayed_tasks_summary(self): + filters = frappe._dict({ + "from_date": add_months(nowdate(), -1), + "to_date": nowdate(), + "priority": "Low", + "status": "Open" + }) + expected_data = [ + { + "subject": "_Test Task 99", + "status": "Open", + "priority": "Low", + "delay": 1 + }, + { + "subject": "_Test Task 98", + "status": "Completed", + "priority": "Low", + "delay": -1 + } + ] + report = execute(filters) + data = list(filter(lambda x: x.subject == "_Test Task 99", report[1]))[0] + + for key in ["subject", "status", "priority", "delay"]: + self.assertEqual(expected_data[0].get(key), data.get(key)) + + filters.status = "Completed" + report = execute(filters) + data = list(filter(lambda x: x.subject == "_Test Task 98", report[1]))[0] + + for key in ["subject", "status", "priority", "delay"]: + self.assertEqual(expected_data[1].get(key), data.get(key)) + + def tearDown(self): + for task in ["_Test Task 98", "_Test Task 99"]: + frappe.get_doc("Task", {"subject": task}).delete() \ No newline at end of file diff --git a/erpnext/projects/workspace/projects/projects.json b/erpnext/projects/workspace/projects/projects.json index dbbd7e1458..0ec17029a2 100644 --- a/erpnext/projects/workspace/projects/projects.json +++ b/erpnext/projects/workspace/projects/projects.json @@ -15,6 +15,7 @@ "hide_custom": 0, "icon": "project", "idx": 0, + "is_default": 0, "is_standard": 1, "label": "Projects", "links": [ @@ -148,9 +149,19 @@ "link_type": "Report", "onboard": 0, "type": "Link" + }, + { + "dependencies": "Task", + "hidden": 0, + "is_query_report": 1, + "label": "Delayed Tasks Summary", + "link_to": "Delayed Tasks Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" } ], - "modified": "2020-12-01 13:38:37.856224", + "modified": "2021-03-26 16:32:00.628561", "modified_by": "Administrator", "module": "Projects", "name": "Projects", diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js index 649eb454ac..ceeecb28a2 100644 --- a/erpnext/public/js/controllers/accounts.js +++ b/erpnext/public/js/controllers/accounts.js @@ -276,74 +276,3 @@ erpnext.taxes.set_conditional_mandatory_rate_or_amount = function(grid_row) { } } } - - -// For customizing print -cur_frm.pformat.total = function(doc) { return ''; } -cur_frm.pformat.discount_amount = function(doc) { return ''; } -cur_frm.pformat.grand_total = function(doc) { return ''; } -cur_frm.pformat.rounded_total = function(doc) { return ''; } -cur_frm.pformat.in_words = function(doc) { return ''; } - -cur_frm.pformat.taxes= function(doc){ - //function to make row of table - var make_row = function(title, val, bold, is_negative) { - var bstart = ''; var bend = ''; - return '' + (bold?bstart:'') + title + (bold?bend:'') + '' - + '' + (is_negative ? '- ' : '') - + format_currency(val, doc.currency) + ''; - } - - function print_hide(fieldname) { - var doc_field = frappe.meta.get_docfield(doc.doctype, fieldname, doc.name); - return doc_field.print_hide; - } - - out =''; - if (!doc.print_without_amount) { - var cl = doc.taxes || []; - - // outer table - var out='
'; - - // main table - - out +=''; - - if(!print_hide('total')) { - out += make_row('Total', doc.total, 1); - } - - // Discount Amount on net total - if(!print_hide('discount_amount') && doc.apply_discount_on == "Net Total" && doc.discount_amount) - out += make_row('Discount Amount', doc.discount_amount, 0, 1); - - // add rows - if(cl.length){ - for(var i=0;i'; - } - out += '
'; - } - return out; -} \ No newline at end of file diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 67b12fbca4..cdfd909b04 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -216,7 +216,8 @@ erpnext.buying.BuyingController = erpnext.TransactionController.extend({ child: item, args: { item_code: item.item_code, - warehouse: item.warehouse + warehouse: item.warehouse, + company: doc.company } }); } diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 3a3ee3858b..2e133bed2e 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -323,12 +323,15 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ // set precision in the last item iteration if (n == me.frm.doc["items"].length - 1) { me.round_off_totals(tax); + me.set_in_company_currency(tax, + ["tax_amount", "tax_amount_after_discount_amount"]); + + me.round_off_base_values(tax); // in tax.total, accumulate grand total for each item me.set_cumulative_total(i, tax); - me.set_in_company_currency(tax, - ["total", "tax_amount", "tax_amount_after_discount_amount"]); + me.set_in_company_currency(tax, ["total"]); // adjust Discount Amount loss in last tax iteration if ((i == me.frm.doc["taxes"].length - 1) && me.discount_amount_applied @@ -393,20 +396,11 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ current_tax_amount = tax_rate * item.qty; } - current_tax_amount = this.get_final_tax_amount(tax, current_tax_amount); this.set_item_wise_tax(item, tax, tax_rate, current_tax_amount); return current_tax_amount; }, - get_final_tax_amount: function(tax, current_tax_amount) { - if (frappe.flags.round_off_applicable_accounts.includes(tax.account_head)) { - current_tax_amount = Math.round(current_tax_amount); - } - - return current_tax_amount; - }, - set_item_wise_tax: function(item, tax, tax_rate, current_tax_amount) { // store tax breakup for each item let tax_detail = tax.item_wise_tax_detail; @@ -420,10 +414,22 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ }, round_off_totals: function(tax) { + if (frappe.flags.round_off_applicable_accounts.includes(tax.account_head)) { + tax.tax_amount= Math.round(tax.tax_amount); + tax.tax_amount_after_discount_amount = Math.round(tax.tax_amount_after_discount_amount); + } + tax.tax_amount = flt(tax.tax_amount, precision("tax_amount", tax)); tax.tax_amount_after_discount_amount = flt(tax.tax_amount_after_discount_amount, precision("tax_amount", tax)); }, + round_off_base_values: function(tax) { + if (frappe.flags.round_off_applicable_accounts.includes(tax.account_head)) { + tax.base_tax_amount= Math.round(tax.base_tax_amount); + tax.base_tax_amount_after_discount_amount = Math.round(tax.base_tax_amount_after_discount_amount); + } + }, + manipulate_grand_total_for_inclusive_tax: function() { var me = this; // if fully inclusive taxes and diff diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index d1fc37930f..a0398e718f 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1103,6 +1103,8 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ to_currency: to_currency, args: args }, + freeze: true, + freeze_message: __("Fetching exchange rates ..."), callback: function(r) { callback(flt(r.message)); } @@ -1174,8 +1176,8 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ } // for handling customization not to fetch price list rate - if (frappe.flags.dont_fetch_price_list_rate) { - return; + if(frappe.flags.dont_fetch_price_list_rate) { + return } if (!dont_fetch_price_list_rate && diff --git a/erpnext/public/js/education/lms/quiz.js b/erpnext/public/js/education/lms/quiz.js index 4a9d1e34e6..32fa4ab1ec 100644 --- a/erpnext/public/js/education/lms/quiz.js +++ b/erpnext/public/js/education/lms/quiz.js @@ -20,6 +20,16 @@ class Quiz { } make(data) { + if (data.duration) { + const timer_display = document.createElement("div"); + timer_display.classList.add("lms-timer", "float-right", "font-weight-bold"); + document.getElementsByClassName("lms-title")[0].appendChild(timer_display); + if (!data.activity || (data.activity && !data.activity.is_complete)) { + this.initialiseTimer(data.duration); + this.is_time_bound = true; + this.time_taken = 0; + } + } data.questions.forEach(question_data => { let question_wrapper = document.createElement('div'); let question = new Question({ @@ -37,12 +47,51 @@ class Quiz { indicator = 'green' message = 'You have already cleared the quiz.' } - + if (data.activity.time_taken) { + this.calculate_and_display_time(data.activity.time_taken, "Time Taken - "); + } this.set_quiz_footer(message, indicator, data.activity.score) } else { this.make_actions(); } + window.addEventListener('beforeunload', (event) => { + event.preventDefault(); + event.returnValue = ''; + }); + } + + initialiseTimer(duration) { + this.time_left = duration; + var self = this; + var old_diff; + this.calculate_and_display_time(this.time_left, "Time Left - "); + this.start_time = new Date().getTime(); + this.timer = setInterval(function () { + var diff = (new Date().getTime() - self.start_time)/1000; + var variation = old_diff ? diff - old_diff : diff; + old_diff = diff; + self.time_left -= variation; + self.time_taken += variation; + self.calculate_and_display_time(self.time_left, "Time Left - "); + if (self.time_left <= 0) { + clearInterval(self.timer); + self.time_taken -= 1; + self.submit(); + } + }, 1000); + } + + calculate_and_display_time(second, text) { + var timer_display = document.getElementsByClassName("lms-timer")[0]; + var hours = this.append_zero(Math.floor(second / 3600)); + var minutes = this.append_zero(Math.floor(second % 3600 / 60)); + var seconds = this.append_zero(Math.ceil(second % 3600 % 60)); + timer_display.innerText = text + hours + ":" + minutes + ":" + seconds; + } + + append_zero(time) { + return time > 9 ? time : "0" + time; } make_actions() { @@ -57,6 +106,10 @@ class Quiz { } submit() { + if (this.is_time_bound) { + clearInterval(this.timer); + $(".lms-timer").text(""); + } this.submit_btn.innerText = 'Evaluating..' this.submit_btn.disabled = true this.disable() @@ -64,7 +117,8 @@ class Quiz { quiz_name: this.name, quiz_response: this.get_selected(), course: this.course, - program: this.program + program: this.program, + time_taken: this.is_time_bound ? this.time_taken : "" }).then(res => { this.submit_btn.remove() if (!res.message) { @@ -157,7 +211,7 @@ class Question { return input; } - let make_label = function(name, value) { + let make_label = function (name, value) { let label = document.createElement('label'); label.classList.add('form-check-label'); label.htmlFor = name; @@ -166,14 +220,14 @@ class Question { } let make_option = function (wrapper, option) { - let option_div = document.createElement('div') - option_div.classList.add('form-check', 'pb-1') + let option_div = document.createElement('div'); + option_div.classList.add('form-check', 'pb-1'); let input = make_input(option.name, option.option); let label = make_label(option.name, option.option); - option_div.appendChild(input) - option_div.appendChild(label) - wrapper.appendChild(option_div) - return {input: input, ...option} + option_div.appendChild(input); + option_div.appendChild(label); + wrapper.appendChild(option_div); + return { input: input, ...option }; } let options_wrapper = document.createElement('div') diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index e5b50d86ed..fd98f17ac1 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -291,17 +291,15 @@ $.extend(erpnext.utils, { return options[0]; } }, - copy_parent_value_in_all_row: function(doc, dt, dn, table_fieldname, fieldname, parent_fieldname) { - var d = locals[dt][dn]; - if(d[fieldname]){ - var cl = doc[table_fieldname] || []; - for(var i = 0; i < cl.length; i++) { + overrides_parent_value_in_all_rows: function(doc, dt, dn, table_fieldname, fieldname, parent_fieldname) { + if (doc[parent_fieldname]) { + let cl = doc[table_fieldname] || []; + for (let i = 0; i < cl.length; i++) { cl[i][fieldname] = doc[parent_fieldname]; } + frappe.refresh_field(table_fieldname); } - refresh_field(table_fieldname); }, - create_new_doc: function (doctype, update_fields) { frappe.model.with_doctype(doctype, function() { var new_doc = frappe.model.get_new_doc(doctype); diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index d49a8138fb..3333d569a7 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -353,9 +353,9 @@ erpnext.SerialNoBatchSelector = Class.extend({ return row.on_grid_fields_dict.batch_no.get_value(); } }); - if (selected_batches.includes(val)) { + if (selected_batches.includes(batch_no)) { this.set_value(""); - frappe.throw(__('Batch {0} already selected.', [val])); + frappe.throw(__('Batch {0} already selected.', [batch_no])); } if (me.warehouse_details.name) { diff --git a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.py b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.py index bf82cc080a..5a8ec73cfe 100644 --- a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.py +++ b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.py @@ -7,6 +7,7 @@ import frappe from frappe.model.document import Document class QualityFeedback(Document): + @frappe.whitelist() def set_parameters(self): if self.template and not getattr(self, 'parameters', []): for d in frappe.get_doc('Quality Feedback Template', self.template).parameters: 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 index 41c7b23146..41a0f1193b 100644 --- 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 @@ -50,6 +50,7 @@ class TaxExemption80GCertificate(Document): frappe.throw(_('Please set the {0} for company {1}').format(frappe.bold('PAN Number'), get_link_to_form('Company', self.company))) + @frappe.whitelist() def set_company_address(self): address = get_company_address(self.company) self.company_address = address.company_address @@ -70,6 +71,7 @@ class TaxExemption80GCertificate(Document): else: self.title = self.donor_name + @frappe.whitelist() def get_payments(self): if not self.member: frappe.throw(_('Please select a Member first.')) diff --git a/erpnext/regional/india/__init__.py b/erpnext/regional/india/__init__.py index 378b735e07..faeb36fc69 100644 --- a/erpnext/regional/india/__init__.py +++ b/erpnext/regional/india/__init__.py @@ -69,7 +69,7 @@ state_numbers = { "Mizoram": "15", "Nagaland": "13", "Odisha": "21", - "Other Territory": "98", + "Other Territory": "97", "Pondicherry": "34", "Punjab": "03", "Rajasthan": "08", diff --git a/erpnext/regional/india/e_invoice/einv_validation.json b/erpnext/regional/india/e_invoice/einv_validation.json index 86290cfe52..f4a3542a60 100644 --- a/erpnext/regional/india/e_invoice/einv_validation.json +++ b/erpnext/regional/india/e_invoice/einv_validation.json @@ -919,7 +919,8 @@ "minLength": 1, "maxLength": 15, "pattern": "^([0-9A-Z/-]){1,15}$", - "description": "Tranport Document Number" + "description": "Tranport Document Number", + "validationMsg": "Transport Receipt No is invalid" }, "TransDocDt": { "type": "string", diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index baec796b75..1d3cb661dd 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -38,12 +38,13 @@ def validate_eligibility(doc): einvoicing_eligible_from = frappe.db.get_single_value('E Invoice Settings', 'applicable_from') or '2021-04-01' if getdate(doc.get('posting_date')) < getdate(einvoicing_eligible_from): return False - + + invalid_company = not frappe.db.get_value('E Invoice User', { 'company': doc.get('company') }) 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 invalid_supply_type or company_transaction or no_taxes_applied: + if invalid_company or invalid_supply_type or company_transaction or no_taxes_applied: return False return True @@ -400,7 +401,7 @@ def validate_totals(einvoice): if abs(flt(value_details['AssVal']) - total_item_ass_value) > 1: frappe.throw(_('Total Taxable Value of the items is not equal to the Invoice Net Total. Please check item taxes / discounts for any correction.')) - if abs(flt(value_details['TotInvVal']) + flt(value_details['Discount']) - total_item_value) > 1: + if abs(flt(value_details['TotInvVal']) + flt(value_details['Discount']) - flt(value_details['OthChrg']) - total_item_value) > 1: frappe.throw(_('Total Value of the items is not equal to the Invoice Grand Total. Please check item taxes / discounts for any correction.')) calculated_invoice_value = \ @@ -945,6 +946,8 @@ class GSPConnector(): self.invoice.irn = res.get('Irn') self.invoice.ewaybill = res.get('EwbNo') + self.invoice.ack_no = res.get('AckNo') + self.invoice.ack_date = res.get('AckDt') self.invoice.signed_einvoice = dec_signed_invoice self.invoice.ack_no = res.get('AckNo') self.invoice.ack_date = res.get('AckDt') diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index 3440202a2b..9ded8dab5b 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -160,6 +160,13 @@ def make_custom_fields(update=True): fetch_if_empty=1), ] + delivery_note_gst_category = [ + dict(fieldname='gst_category', label='GST Category', + fieldtype='Select', insert_after='gst_vehicle_type', print_hide=1, + options='\nRegistered Regular\nRegistered Composition\nUnregistered\nSEZ\nOverseas\nConsumer\nDeemed Export\nUIN Holders', + fetch_from='customer.gst_category', fetch_if_empty=1), + ] + invoice_gst_fields = [ dict(fieldname='invoice_copy', label='Invoice Copy', fieldtype='Select', insert_after='export_type', print_hide=1, allow_on_submit=1, @@ -284,7 +291,7 @@ def make_custom_fields(update=True): 'allow_on_submit': 1, 'insert_after': 'customer_name_in_arabic', 'translatable': 0, - } + } ] si_ewaybill_fields = [ @@ -458,7 +465,7 @@ def make_custom_fields(update=True): '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 + si_einvoice_fields, - 'Delivery Note': sales_invoice_gst_fields + ewaybill_fields + sales_invoice_shipping_fields, + 'Delivery Note': sales_invoice_gst_fields + ewaybill_fields + sales_invoice_shipping_fields + delivery_note_gst_category, 'Sales Order': sales_invoice_gst_fields, 'Tax Category': inter_state_gst_field, 'Item': [ diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 9d23369fc4..6338056698 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -825,8 +825,11 @@ def get_regional_round_off_accounts(company, account_list): return gst_accounts = get_gst_accounts(company) - gst_account_list = gst_accounts.get('cgst_account') + gst_accounts.get('sgst_account') \ - + gst_accounts.get('igst_account') + + 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) diff --git a/erpnext/regional/italy/setup.py b/erpnext/regional/italy/setup.py index a1f5bb9836..7db2f6b0f8 100644 --- a/erpnext/regional/italy/setup.py +++ b/erpnext/regional/italy/setup.py @@ -139,6 +139,9 @@ def make_custom_fields(update=True): dict(fieldname='customer_fiscal_code', label='Customer Fiscal Code', fieldtype='Data', insert_after='cb_e_invoicing_reference', read_only=1, fetch_from="customer.fiscal_code"), + dict(fieldname='type_of_document', label='Type of Document', + fieldtype='Select', insert_after='customer_fiscal_code', + options='\nTD01\nTD02\nTD03\nTD04\nTD05\nTD06\nTD16\nTD17\nTD18\nTD19\nTD20\nTD21\nTD22\nTD23\nTD24\nTD25\nTD26\nTD27'), ], 'Purchase Invoice Item': invoice_item_fields, 'Sales Order Item': invoice_item_fields, diff --git a/erpnext/regional/italy/utils.py b/erpnext/regional/italy/utils.py index 08573cddcd..ba1aeafc3e 100644 --- a/erpnext/regional/italy/utils.py +++ b/erpnext/regional/italy/utils.py @@ -57,11 +57,12 @@ def prepare_invoice(invoice, progressive_number): invoice.company_address_data = company_address #Set invoice type - if invoice.is_return and invoice.return_against: - invoice.type_of_document = "TD04" #Credit Note (Nota di Credito) - invoice.return_against_unamended = get_unamended_name(frappe.get_doc("Sales Invoice", invoice.return_against)) - else: - invoice.type_of_document = "TD01" #Sales Invoice (Fattura) + if not invoice.type_of_document: + if invoice.is_return and invoice.return_against: + invoice.type_of_document = "TD04" #Credit Note (Nota di Credito) + invoice.return_against_unamended = get_unamended_name(frappe.get_doc("Sales Invoice", invoice.return_against)) + else: + invoice.type_of_document = "TD01" #Sales Invoice (Fattura) #set customer information invoice.customer_data = frappe.get_doc("Customer", invoice.customer) diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index 09b04ff367..b637fb47b3 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -78,7 +78,7 @@ class Gstr1Report(object): place_of_supply = invoice_details.get("place_of_supply") ecommerce_gstin = invoice_details.get("ecommerce_gstin") - b2cs_output.setdefault((rate, place_of_supply, ecommerce_gstin, inv),{ + b2cs_output.setdefault((rate, place_of_supply, ecommerce_gstin),{ "place_of_supply": "", "ecommerce_gstin": "", "rate": "", @@ -90,7 +90,7 @@ class Gstr1Report(object): "invoice_value": invoice_details.get("base_grand_total"), }) - row = b2cs_output.get((rate, place_of_supply, ecommerce_gstin, inv)) + row = b2cs_output.get((rate, place_of_supply, ecommerce_gstin)) row["place_of_supply"] = place_of_supply row["ecommerce_gstin"] = ecommerce_gstin row["rate"] = rate @@ -199,7 +199,7 @@ class Gstr1Report(object): self.item_tax_rate = frappe._dict() items = frappe.db.sql(""" - select item_code, parent, base_net_amount, item_tax_rate + select item_code, parent, taxable_value, item_tax_rate from `tab%s Item` where parent in (%s) """ % (self.doctype, ', '.join(['%s']*len(self.invoices))), tuple(self.invoices), as_dict=1) @@ -207,7 +207,7 @@ class Gstr1Report(object): for d in items: if d.item_code not in self.invoice_items.get(d.parent, {}): self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, - sum(i.get('base_net_amount', 0) for i in items + sum(i.get('taxable_value', 0) for i in items if i.item_code == d.item_code and i.parent == d.parent)) item_tax_rate = {} @@ -561,7 +561,7 @@ def get_json(filters, report_name, data): fp = "%02d%s" % (getdate(filters["to_date"]).month, getdate(filters["to_date"]).year) - gst_json = {"gstin": "", "version": "GST2.2.9", + gst_json = {"version": "GST2.2.9", "hash": "hash", "gstin": gstin, "fp": fp} res = {} diff --git a/erpnext/regional/saudi_arabia/setup.py b/erpnext/regional/saudi_arabia/setup.py index d9ac6cb0f6..9b3677d2c6 100644 --- a/erpnext/regional/saudi_arabia/setup.py +++ b/erpnext/regional/saudi_arabia/setup.py @@ -4,11 +4,8 @@ from __future__ import unicode_literals from erpnext.regional.united_arab_emirates.setup import make_custom_fields, add_print_formats -from erpnext.setup.setup_wizard.operations.taxes_setup import create_sales_tax + def setup(company=None, patch=True): make_custom_fields() add_print_formats() - - if company: - create_sales_tax(company) \ No newline at end of file diff --git a/erpnext/regional/united_arab_emirates/setup.py b/erpnext/regional/united_arab_emirates/setup.py index 68208ab31b..bd12d661f0 100644 --- a/erpnext/regional/united_arab_emirates/setup.py +++ b/erpnext/regional/united_arab_emirates/setup.py @@ -6,7 +6,6 @@ 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): @@ -16,9 +15,6 @@ def setup(company=None, patch=True): 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', diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index c452594608..49ca9423e8 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -38,11 +38,19 @@ class Customer(TransactionBase): set_name_by_naming_series(self) def get_customer_name(self): - if frappe.db.get_value("Customer", self.customer_name): + + if frappe.db.get_value("Customer", self.customer_name) and not frappe.flags.in_import: count = frappe.db.sql("""select ifnull(MAX(CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED)), 0) from tabCustomer where name like %s""", "%{0} - %".format(self.customer_name), as_list=1)[0][0] count = cint(count) + 1 - return "{0} - {1}".format(self.customer_name, cstr(count)) + + new_customer_name = "{0} - {1}".format(self.customer_name, cstr(count)) + + msgprint(_("Changed customer name to '{}' as '{}' already exists.") + .format(new_customer_name, self.customer_name), + title=_("Note"), indicator="yellow") + + return new_customer_name return self.customer_name @@ -230,13 +238,20 @@ class Customer(TransactionBase): frappe.db.set(self, "customer_name", newdn) def set_loyalty_program(self): - if self.loyalty_program: return + if self.loyalty_program: + return + loyalty_program = get_loyalty_programs(self) - if not loyalty_program: return + if not loyalty_program: + return + if len(loyalty_program) == 1: self.loyalty_program = loyalty_program[0] else: - frappe.msgprint(_("Multiple Loyalty Program found for the Customer. Please select manually.")) + frappe.msgprint( + _("Multiple Loyalty Programs found for Customer {}. Please select manually.") + .format(frappe.bold(self.customer_name)) + ) def create_onboarding_docs(self, args): defaults = frappe.defaults.get_defaults() @@ -340,7 +355,6 @@ def _set_missing_values(source, target): @frappe.whitelist() def get_loyalty_programs(doc): ''' returns applicable loyalty programs for a customer ''' - from frappe.desk.treeview import get_children lp_details = [] loyalty_programs = frappe.get_all("Loyalty Program", @@ -349,15 +363,33 @@ def get_loyalty_programs(doc): "ifnull(to_date, '2500-01-01')": [">=", today()]}) for loyalty_program in loyalty_programs: - customer_groups = [d.value for d in get_children("Customer Group", loyalty_program.customer_group)] + [loyalty_program.customer_group] - customer_territories = [d.value for d in get_children("Territory", loyalty_program.customer_territory)] + [loyalty_program.customer_territory] - - if (not loyalty_program.customer_group or doc.customer_group in customer_groups)\ - and (not loyalty_program.customer_territory or doc.territory in customer_territories): + if ( + (not loyalty_program.customer_group + or doc.customer_group in get_nested_links( + "Customer Group", + loyalty_program.customer_group, + doc.flags.ignore_permissions + )) + and (not loyalty_program.customer_territory + or doc.territory in get_nested_links( + "Territory", + loyalty_program.customer_territory, + doc.flags.ignore_permissions + )) + ): lp_details.append(loyalty_program.name) return lp_details +def get_nested_links(link_doctype, link_name, ignore_permissions=False): + from frappe.desk.treeview import _get_children + + links = [link_name] + for d in _get_children(link_doctype, link_name, ignore_permissions): + links.append(d.value) + + return links + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_customer_list(doctype, txt, searchfield, start, page_len, filters=None): @@ -572,4 +604,4 @@ def get_customer_primary_contact(doctype, txt, searchfield, start, page_len, fil """, { 'customer': customer, 'txt': '%%%s%%' % txt - }) \ No newline at end of file + }) diff --git a/erpnext/selling/doctype/lead_source/lead_source.js b/erpnext/selling/doctype/lead_source/lead_source.js deleted file mode 100644 index 6af6a4f648..0000000000 --- a/erpnext/selling/doctype/lead_source/lead_source.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Lead Source', { - refresh: function(frm) { - - } -}); diff --git a/erpnext/selling/doctype/lead_source/lead_source.json b/erpnext/selling/doctype/lead_source/lead_source.json deleted file mode 100644 index 373e83af9c..0000000000 --- a/erpnext/selling/doctype/lead_source/lead_source.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 1, - "autoname": "field:source_name", - "beta": 0, - "creation": "2016-09-16 01:47:47.382372", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "fields": [ - { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "source_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Source Name", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, - { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "details", - "fieldtype": "Text Editor", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Details", - "length": 0, - "no_copy": 0, - "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 - } - ], - "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": "2020-09-16 02:03:01.441622", - "modified_by": "Administrator", - "module": "Selling", - "name": "Lead Source", - "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": "Sales Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - }, - { - "amend": 0, - "apply_user_permissions": 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": "Sales User", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_seen": 0 -} diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 5da248c1b5..246f9234a4 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -64,6 +64,7 @@ class Quotation(SellingController): opp = frappe.get_doc("Opportunity", opportunity) opp.set_status(status=status, update=True) + @frappe.whitelist() def declare_enquiry_lost(self, lost_reasons_list, detailed_reason=None): if not self.has_sales_order(): get_lost_reasons = frappe.get_list('Quotation Lost Reason', diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 0a5c6651ba..762b6f1d6c 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -98,6 +98,7 @@ "rounded_total", "in_words", "advance_paid", + "disable_rounded_total", "packing_list", "packed_items", "payment_schedule_section", @@ -901,6 +902,7 @@ "width": "150px" }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounding_adjustment", "fieldtype": "Currency", "hide_days": 1, @@ -912,6 +914,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounded_total", "fieldtype": "Currency", "hide_days": 1, @@ -961,6 +964,7 @@ "width": "150px" }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounding_adjustment", "fieldtype": "Currency", "hide_days": 1, @@ -973,6 +977,7 @@ }, { "bold": 1, + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounded_total", "fieldtype": "Currency", "hide_days": 1, @@ -1474,13 +1479,20 @@ "label": "Represents Company", "options": "Company", "read_only": 1 + }, + { + "default": "0", + "depends_on": "grand_total", + "fieldname": "disable_rounded_total", + "fieldtype": "Check", + "label": "Disable Rounded Total" } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2021-01-20 23:40:39.929296", + "modified": "2021-04-15 23:55:13.439068", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index bad4d9780c..d9e52e1d69 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -372,6 +372,7 @@ class SalesOrder(SellingController): self.indicator_color = "green" self.indicator_title = _("Paid") + @frappe.whitelist() def get_work_order_items(self, for_raw_material_request=0): '''Returns items with BOM that already do not have a linked work order''' items = [] diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index ab5f089970..3137621fd7 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 @@ -461,10 +462,8 @@ class TestSalesOrder(unittest.TestCase): 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}]) @@ -473,18 +472,14 @@ 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 - frappe.set_user("Administrator") workflow = make_sales_order_workflow() 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") @@ -644,33 +639,31 @@ class TestSalesOrder(unittest.TestCase): frappe.db.set_value("Stock Settings", None, "default_warehouse", old_stock_settings_value) 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") - - test_user = frappe.get_doc("User", "test@example.com") - test_user.add_roles("Sales User", "Stock User") - test_user.remove_roles("Sales Manager") + 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() diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 2104c0131c..f01934b7e6 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -18,6 +18,8 @@ "dn_required", "sales_update_frequency", "maintain_same_sales_rate", + "maintain_same_rate_action", + "role_to_override_stop_action", "editable_price_list_rate", "allow_multiple_items", "allow_against_multiple_purchase_orders", @@ -133,6 +135,23 @@ "fieldname": "hide_tax_id", "fieldtype": "Check", "label": "Hide Customer's Tax ID from Sales Transactions" + }, + { + "default": "Stop", + "depends_on": "maintain_same_sales_rate", + "description": "Configure the action to stop the transaction or just warn if the same rate is not maintained.", + "fieldname": "maintain_same_rate_action", + "fieldtype": "Select", + "label": "Action If Same Rate is Not Maintained", + "mandatory_depends_on": "maintain_same_sales_rate", + "options": "Stop\nWarn" + }, + { + "depends_on": "eval: doc.maintain_same_rate_action == 'Stop'", + "fieldname": "role_to_override_stop_action", + "fieldtype": "Link", + "label": "Role Allowed to Override Stop Action", + "options": "Role" } ], "icon": "fa fa-cog", @@ -140,7 +159,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-03-02 17:35:53.603607", + "modified": "2021-04-04 20:18:12.814624", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 278821e392..8adf5bf747 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -279,11 +279,6 @@ erpnext.PointOfSale.Controller = class { const item_row = frappe.model.get_doc(cdt, cdn); if (item_row && item_row[fieldname] != value) { - if (fieldname === 'qty' && flt(value) == 0) { - this.remove_item_from_cart(); - return; - } - const { item_code, batch_no, uom } = this.item_details.current_item; const event = { field: fieldname, @@ -397,6 +392,7 @@ 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) ]); 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 e0d5b73166..9fb3943b53 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -159,6 +159,31 @@ erpnext.PointOfSale.ItemSelector = class { bind_events() { const me = this; window.onScan = onScan; + + onScan.decodeKeyEvent = function (oEvent) { + var iCode = this._getNormalizedKeyNum(oEvent); + switch (true) { + case iCode >= 48 && iCode <= 90: // numbers and letters + case iCode >= 106 && iCode <= 111: // operations on numeric keypad (+, -, etc.) + case (iCode >= 160 && iCode <= 164) || iCode == 170: // ^ ! # $ * + case iCode >= 186 && iCode <= 194: // (; = , - . / `) + case iCode >= 219 && iCode <= 222: // ([ \ ] ') + if (oEvent.key !== undefined && oEvent.key !== '') { + return oEvent.key; + } + + var sDecoded = String.fromCharCode(iCode); + switch (oEvent.shiftKey) { + case false: sDecoded = sDecoded.toLowerCase(); break; + case true: sDecoded = sDecoded.toUpperCase(); break; + } + return sDecoded; + case iCode >= 96 && iCode <= 105: // numbers on numeric keypad + return 0 + (iCode - 96); + } + return ''; + }; + onScan.attachTo(document, { onScan: (sScancode) => { if (this.search_field && this.$component.is(':visible')) { 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 a5a739cff9..acf4eb371f 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 @@ -204,11 +204,11 @@ erpnext.PointOfSale.PastOrderSummary = class { print_receipt() { const frm = this.events.get_frm(); frappe.utils.print( - frm.doctype, - frm.docname, + this.doc.doctype, + this.doc.name, frm.pos_print_format, - frm.doc.letter_head, - frm.doc.language || frappe.boot.lang + this.doc.letter_head, + this.doc.language || frappe.boot.lang ); } diff --git a/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py b/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py index f396705460..6fb7666c2c 100644 --- a/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py +++ b/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py @@ -57,18 +57,18 @@ def get_columns(customer_naming_type): return columns def get_details(filters): - conditions = "" + sql_query = """SELECT + c.name, c.customer_name, + ccl.bypass_credit_limit_check, + c.is_frozen, c.disabled + FROM `tabCustomer` c, `tabCustomer Credit Limit` ccl + WHERE + c.name = ccl.parent + AND ccl.company = %(company)s""" + + # customer filter is optional. if filters.get("customer"): - conditions += " AND c.name = '" + filters.get("customer") + "'" + sql_query += " AND c.name = %(customer)s" - return frappe.db.sql("""SELECT - c.name, c.customer_name, - ccl.bypass_credit_limit_check, - c.is_frozen, c.disabled - FROM `tabCustomer` c, `tabCustomer Credit Limit` ccl - WHERE - c.name = ccl.parent - AND ccl.company = '{0}' - {1} - """.format( filters.get("company"),conditions), as_dict=1) #nosec + return frappe.db.sql(sql_query, filters, as_dict=1) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 433851cde5..64e027dd28 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -17,6 +17,7 @@ from frappe.utils.nestedset import NestedSet from past.builtins import cmp import functools from erpnext.accounts.doctype.account.account import get_account_currency +from erpnext.setup.setup_wizard.operations.taxes_setup import setup_taxes_and_charges class Company(NestedSet): nsm_parent_field = 'parent_company' @@ -66,12 +67,9 @@ class Company(NestedSet): if frappe.db.sql("select abbr from tabCompany where name!=%s and abbr=%s", (self.name, self.abbr)): frappe.throw(_("Abbreviation already used for another company")) + @frappe.whitelist() def create_default_tax_template(self): - from erpnext.setup.setup_wizard.operations.taxes_setup import create_sales_tax - create_sales_tax({ - 'country': self.country, - 'company_name': self.name - }) + setup_taxes_and_charges(self.name, self.country) def validate_default_accounts(self): accounts = [ diff --git a/erpnext/setup/doctype/company/delete_company_transactions.py b/erpnext/setup/doctype/company/delete_company_transactions.py index 0df4c87f51..8367a257ea 100644 --- a/erpnext/setup/doctype/company/delete_company_transactions.py +++ b/erpnext/setup/doctype/company/delete_company_transactions.py @@ -15,7 +15,7 @@ def delete_company_transactions(company_name): frappe.only_for("System Manager") doc = frappe.get_doc("Company", company_name) - if frappe.session.user != doc.owner: + if frappe.session.user != doc.owner and frappe.session.user != 'Administrator': frappe.throw(_("Transactions can only be deleted by the creator of the Company"), frappe.PermissionError) @@ -27,7 +27,7 @@ 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", "Item Tax Template", "Mode Of Payment", + "Company", "Bank Account", "Item Tax Template", "Mode Of Payment", "Mode of Payment Account", "Item Default", "Customer", "Supplier", "GST Account"): delete_for_doctype(doctype, company_name) diff --git a/erpnext/setup/doctype/email_digest/email_digest.py b/erpnext/setup/doctype/email_digest/email_digest.py index cbb4c7c5de..ac55fdfdb8 100644 --- a/erpnext/setup/doctype/email_digest/email_digest.py +++ b/erpnext/setup/doctype/email_digest/email_digest.py @@ -24,6 +24,7 @@ class EmailDigest(Document): self._accounts = {} self.currency = frappe.db.get_value('Company', self.company, "default_currency") + @frappe.whitelist() def get_users(self): """get list of users""" user_list = frappe.db.sql(""" @@ -41,6 +42,7 @@ class EmailDigest(Document): frappe.response['user_list'] = user_list + @frappe.whitelist() def send(self): # send email only to enabled users valid_users = [p[0] for p in frappe.db.sql("""select name from `tabUser` diff --git a/erpnext/setup/doctype/global_defaults/global_defaults.js b/erpnext/setup/doctype/global_defaults/global_defaults.js index 552331aac8..942dd5989e 100644 --- a/erpnext/setup/doctype/global_defaults/global_defaults.js +++ b/erpnext/setup/doctype/global_defaults/global_defaults.js @@ -17,7 +17,7 @@ frappe.ui.form.on('Global Defaults', { method: "frappe.client.get_list", args: { doctype: "UOM Conversion Factor", - filters: { "category": "Length" }, + filters: { "category": __("Length") }, fields: ["to_uom"], limit_page_length: 500 }, diff --git a/erpnext/setup/doctype/global_defaults/global_defaults.py b/erpnext/setup/doctype/global_defaults/global_defaults.py index fa7bc504b6..76a8450829 100644 --- a/erpnext/setup/doctype/global_defaults/global_defaults.py +++ b/erpnext/setup/doctype/global_defaults/global_defaults.py @@ -50,6 +50,7 @@ class GlobalDefaults(Document): # clear cache frappe.clear_cache() + @frappe.whitelist() def get_defaults(self): return frappe.defaults.get_defaults() diff --git a/erpnext/setup/doctype/item_group/item_group.js b/erpnext/setup/doctype/item_group/item_group.js index 1413cb2862..885d874720 100644 --- a/erpnext/setup/doctype/item_group/item_group.js +++ b/erpnext/setup/doctype/item_group/item_group.js @@ -61,7 +61,7 @@ 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'); @@ -69,10 +69,12 @@ frappe.ui.form.on("Item Group", { 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(); + frm.fields_dict.filter_fields.grid.update_docfield_property( + 'fieldname', 'fieldtype', 'Select' + ); + frm.fields_dict.filter_fields.grid.update_docfield_property( + 'fieldname', 'options', valid_fields + ); }); }, diff --git a/erpnext/setup/doctype/item_group/item_group.json b/erpnext/setup/doctype/item_group/item_group.json index e835214487..3e0680f4f5 100644 --- a/erpnext/setup/doctype/item_group/item_group.json +++ b/erpnext/setup/doctype/item_group/item_group.json @@ -214,7 +214,7 @@ "is_tree": 1, "links": [], "max_attachments": 3, - "modified": "2021-02-08 17:02:44.951572", + "modified": "2021-02-18 13:40:30.049650", "modified_by": "Administrator", "module": "Setup", "name": "Item Group", @@ -277,7 +277,7 @@ "export": 1, "print": 1, "report": 1, - "role": "Customer", + "role": "All", "select": 1, "share": 1 } diff --git a/erpnext/setup/doctype/naming_series/naming_series.py b/erpnext/setup/doctype/naming_series/naming_series.py index 2ea0bc08ca..c4f1de14e4 100644 --- a/erpnext/setup/doctype/naming_series/naming_series.py +++ b/erpnext/setup/doctype/naming_series/naming_series.py @@ -15,6 +15,7 @@ from frappe.core.doctype.doctype.doctype import validate_series class NamingSeriesNotSetError(frappe.ValidationError): pass class NamingSeries(Document): + @frappe.whitelist() def get_transactions(self, arg=None): doctypes = list(set(frappe.db.sql_list("""select parent from `tabDocField` df where fieldname='naming_series'""") @@ -53,6 +54,7 @@ class NamingSeries(Document): options = list(filter(lambda x: x, [cstr(n).strip() for n in ol])) return options + @frappe.whitelist() def update_series(self, arg=None): """update series list""" self.validate_series_set() @@ -139,10 +141,12 @@ class NamingSeries(Document): if not re.match("^[\w\- /.#{}]*$", n, re.UNICODE): throw(_('Special Characters except "-", "#", ".", "/", "{" and "}" not allowed in naming series')) + @frappe.whitelist() def get_options(self, arg=None): if frappe.get_meta(arg or self.select_doc_for_series).get_field("naming_series"): return frappe.get_meta(arg or self.select_doc_for_series).get_field("naming_series").options + @frappe.whitelist() def get_current(self, arg=None): """get series current""" if self.prefix: diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 29fd0e659b..c7220cbc07 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -8,9 +8,11 @@ from erpnext.accounts.doctype.cash_flow_mapper.default_cash_flow_mapper import D from .default_success_action import get_default_success_action from frappe import _ from frappe.utils import cint +from frappe.installer import update_site_config from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to from frappe.custom.doctype.custom_field.custom_field import create_custom_field from erpnext.setup.default_energy_point_rules import get_default_energy_point_rules +from six import iteritems default_mail_footer = """
Sent via ERPNext
""" @@ -29,6 +31,7 @@ def after_install(): add_company_to_session_defaults() add_standard_navbar_items() add_app_name() + add_non_standard_user_types() frappe.db.commit() @@ -163,5 +166,82 @@ def add_standard_navbar_items(): navbar_settings.save() def add_app_name(): - settings = frappe.get_doc("System Settings") - settings.app_name = _("ERPNext") \ No newline at end of file + frappe.db.set_value('System Settings', None, 'app_name', 'ERPNext') + +def add_non_standard_user_types(): + user_types = get_user_types_data() + + user_type_limit = {} + for user_type, data in iteritems(user_types): + user_type_limit.setdefault(frappe.scrub(user_type), 10) + + update_site_config('user_type_doctype_limit', user_type_limit) + + for user_type, data in iteritems(user_types): + create_custom_role(data) + create_user_type(user_type, data) + +def get_user_types_data(): + return { + 'Employee Self Service': { + 'role': 'Employee Self Service', + 'apply_user_permission_on': 'Employee', + 'user_id_field': 'user_id', + 'doctypes': { + 'Salary Slip': ['read'], + 'Employee': ['read', 'write'], + 'Expense Claim': ['read', 'write', 'create', 'delete'], + 'Leave Application': ['read', 'write', 'create', 'delete'], + 'Attendance Request': ['read', 'write', 'create', 'delete'], + 'Compensatory Leave Request': ['read', 'write', 'create', 'delete'], + 'Employee Tax Exemption Declaration': ['read', 'write', 'create', 'delete'], + 'Employee Tax Exemption Proof Submission': ['read', 'write', 'create', 'delete'], + 'Timesheet': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend'] + } + } + } + +def create_custom_role(data): + if data.get('role') and not frappe.db.exists('Role', data.get('role')): + frappe.get_doc({ + 'doctype': 'Role', + 'role_name': data.get('role'), + 'desk_access': 1, + 'is_custom': 1 + }).insert(ignore_permissions=True) + +def create_user_type(user_type, data): + if frappe.db.exists('User Type', user_type): + doc = frappe.get_cached_doc('User Type', user_type) + doc.user_doctypes = [] + else: + doc = frappe.new_doc('User Type') + doc.update({ + 'name': user_type, + 'role': data.get('role'), + 'user_id_field': data.get('user_id_field'), + 'apply_user_permission_on': data.get('apply_user_permission_on') + }) + + create_role_permissions_for_doctype(doc, data) + doc.save(ignore_permissions=True) + +def create_role_permissions_for_doctype(doc, data): + for doctype, perms in iteritems(data.get('doctypes')): + args = {'document_type': doctype} + for perm in perms: + args[perm] = 1 + + doc.append('user_doctypes', args) + +def update_select_perm_after_install(): + if not frappe.flags.update_select_perm_after_migrate: + return + + frappe.flags.ignore_select_perm = False + for row in frappe.get_all('User Type', filters= {'is_standard': 0}): + print('Updating user type :- ', row.name) + doc = frappe.get_doc('User Type', row.name) + doc.save() + + frappe.flags.update_select_perm_after_migrate = False diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index beddaeed79..5876488033 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -481,14 +481,250 @@ }, "Germany": { - "Germany VAT 19%": { - "account_name": "VAT 19%", - "tax_rate": 19.00, - "default": 1 - }, - "Germany VAT 7%": { - "account_name": "VAT 7%", - "tax_rate": 7.00 + "chart_of_accounts": { + "SKR04 mit Kontonummern": { + "sales_tax_templates": [ + { + "title": "Umsatzsteuer 19%", + "taxes": [ + { + "account_head": { + "account_name": "Umsatzsteuer 19%", + "account_number": "3806", + "tax_rate": 19.00 + } + } + ] + }, + { + "title": "Umsatzsteuer 7%", + "taxes": [ + { + "account_head": { + "account_name": "Umsatzsteuer 7%", + "account_number": "3801", + "tax_rate": 7.00 + } + } + ] + } + ], + "purchase_tax_templates": [ + { + "title": "Abziehbare Vorsteuer 19%", + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 19%", + "account_number": "1406", + "root_type": "Asset", + "tax_rate": 19.00 + } + } + ] + }, + { + "title": "Abziehbare Vorsteuer 7%", + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 7%", + "account_number": "1401", + "root_type": "Asset", + "tax_rate": 7.00 + } + } + ] + }, + { + "title": "Innergemeinschaftlicher Erwerb 19% Umsatzsteuer und 19% Vorsteuer", + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer nach § 13b UStG 19%", + "account_number": "1407", + "root_type": "Asset", + "tax_rate": 19.00 + }, + "add_deduct_tax": "Add" + }, + { + "account_head": { + "account_name": "Umsatzsteuer nach § 13b UStG 19%", + "account_number": "3837", + "root_type": "Liability", + "tax_rate": 19.00 + }, + "add_deduct_tax": "Deduct" + } + ] + } + ] + }, + "SKR03 mit Kontonummern": { + "sales_tax_templates": [ + { + "title": "Umsatzsteuer 19%", + "taxes": [ + { + "account_head": { + "account_name": "Umsatzsteuer 19%", + "account_number": "1776", + "tax_rate": 19.00 + } + } + ] + }, + { + "title": "Umsatzsteuer 7%", + "taxes": [ + { + "account_head": { + "account_name": "Umsatzsteuer 7%", + "account_number": "1771", + "tax_rate": 7.00 + } + } + ] + } + ], + "purchase_tax_templates": [ + { + "title": "Abziehbare Vorsteuer 19%", + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 19%", + "account_number": "1576", + "root_type": "Asset", + "tax_rate": 19.00 + } + } + ] + }, + { + "title": "Abziehbare Vorsteuer 7%", + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 7%", + "account_number": "1571", + "root_type": "Asset", + "tax_rate": 7.00 + } + } + ] + } + ] + }, + "Standard with Numbers": { + "sales_tax_templates": [ + { + "title": "Umsatzsteuer 19%", + "taxes": [ + { + "account_head": { + "account_name": "Umsatzsteuer 19%", + "account_number": "2301", + "tax_rate": 19.00 + } + } + ] + }, + { + "title": "Umsatzsteuer 7%", + "taxes": [ + { + "account_head": { + "account_name": "Umsatzsteuer 7%", + "account_number": "2302", + "tax_rate": 7.00 + } + } + ] + } + ], + "purchase_tax_templates": [ + { + "title": "Abziehbare Vorsteuer 19%", + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 19%", + "account_number": "1501", + "root_type": "Asset", + "tax_rate": 19.00 + } + } + ] + }, + { + "title": "Abziehbare Vorsteuer 7%", + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 7%", + "account_number": "1502", + "root_type": "Asset", + "tax_rate": 7.00 + } + } + ] + } + ] + }, + "*": { + "sales_tax_templates": [ + { + "title": "Umsatzsteuer 19%", + "taxes": [ + { + "account_head": { + "account_name": "Umsatzsteuer 19%", + "tax_rate": 19.00 + } + } + ] + }, + { + "title": "Umsatzsteuer 7%", + "taxes": [ + { + "account_head": { + "account_name": "Umsatzsteuer 7%", + "tax_rate": 7.00 + } + } + ] + } + ], + "purchase_tax_templates": [ + { + "title": "Abziehbare Vorsteuer 19%", + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 19%", + "tax_rate": 19.00, + "root_type": "Asset" + } + } + ] + }, + { + "title": "Abziehbare Vorsteuer 7%", + "taxes": [ + { + "account_head": { + "account_name": "Abziehbare Vorsteuer 7%", + "root_type": "Asset", + "tax_rate": 7.00 + } + } + ] + } + ] + } } }, @@ -580,26 +816,135 @@ }, "India": { - "In State GST": { - "account_name": ["SGST", "CGST"], - "tax_rate": [9.00, 9.00], - "default": 1 - }, - "Out of State GST": { - "account_name": "IGST", - "tax_rate": 18.00 - }, - "VAT 5%": { - "account_name": "VAT 5%", - "tax_rate": 5.00 - }, - "VAT 4%": { - "account_name": "VAT 4%", - "tax_rate": 4.00 - }, - "VAT 14%": { - "account_name": "VAT 14%", - "tax_rate": 14.00 + "chart_of_accounts": { + "*": { + "item_tax_templates": [ + { + "title": "In State GST", + "taxes": [ + { + "tax_type": { + "account_name": "SGST", + "tax_rate": 9.00 + } + }, + { + "tax_type": { + "account_name": "CGST", + "tax_rate": 9.00 + } + } + ] + }, + { + "title": "Out of State GST", + "taxes": [ + { + "tax_type": { + "account_name": "IGST", + "tax_rate": 18.00 + } + } + ] + }, + { + "title": "VAT 5%", + "taxes": [ + { + "tax_type": { + "account_name": "VAT 5%", + "tax_rate": 5.00 + } + } + ] + }, + { + "title": "VAT 4%", + "taxes": [ + { + "tax_type": { + "account_name": "VAT 4%", + "tax_rate": 4.00 + } + } + ] + }, + { + "title": "VAT 14%", + "taxes": [ + { + "tax_type": { + "account_name": "VAT 14%", + "tax_rate": 14.00 + } + } + ] + } + ], + "*": [ + { + "title": "In State GST", + "taxes": [ + { + "account_head": { + "account_name": "SGST", + "tax_rate": 9.00 + } + }, + { + "account_head": { + "account_name": "CGST", + "tax_rate": 9.00 + } + } + ] + }, + { + "title": "Out of State GST", + "taxes": [ + { + "account_head": { + "account_name": "IGST", + "tax_rate": 18.00 + } + } + ] + }, + { + "title": "VAT 5%", + "taxes": [ + { + "account_head": { + "account_name": "VAT 5%", + "tax_rate": 5.00 + } + } + ] + }, + { + "title": "VAT 4%", + "taxes": [ + { + "account_head": { + "account_name": "VAT 4%", + "tax_rate": 4.00 + } + } + ] + }, + { + "title": "VAT 14%", + "taxes": [ + { + "account_head": { + "account_name": "VAT 14%", + "tax_rate": 14.00 + } + } + ] + } + ] + } } }, diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py index c3c1593c04..429a558c58 100644 --- a/erpnext/setup/setup_wizard/operations/taxes_setup.py +++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py @@ -1,123 +1,232 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt from __future__ import unicode_literals -import frappe, copy, os, json -from frappe.utils import flt -from erpnext.accounts.doctype.account.account import RootNotEditable -def create_sales_tax(args): - country_wise_tax = get_country_wise_tax(args.get("country")) - if country_wise_tax and len(country_wise_tax) > 0: - for sales_tax, tax_data in country_wise_tax.items(): - make_tax_account_and_template( - args.get("company_name"), - tax_data.get('account_name'), - tax_data.get('tax_rate'), sales_tax) +import os +import json -def make_tax_account_and_template(company, account_name, tax_rate, template_name=None): - if not isinstance(account_name, (list, tuple)): - account_name = [account_name] - tax_rate = [tax_rate] +import frappe +from frappe import _ - accounts = [] - for i, name in enumerate(account_name): - tax_account = make_tax_account(company, account_name[i], tax_rate[i]) - if tax_account: - accounts.append(tax_account) - 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: - pass +def setup_taxes_and_charges(company_name: str, country: str): + file_path = os.path.join(os.path.dirname(__file__), '..', 'data', 'country_wise_tax.json') + with open(file_path, 'r') as json_file: + tax_data = json.load(json_file) -def make_tax_account(company, account_name, tax_rate): - tax_group = get_tax_account_group(company) - if tax_group: - try: - return frappe.get_doc({ - "doctype":"Account", - "company": company, - "parent_account": tax_group, - "account_name": account_name, - "is_group": 0, - "report_type": "Balance Sheet", - "root_type": "Liability", - "account_type": "Tax", - "tax_rate": flt(tax_rate) if tax_rate else None - }).insert(ignore_permissions=True, ignore_mandatory=True) - except frappe.NameError: - if frappe.message_log: frappe.message_log.pop() - abbr = frappe.get_cached_value('Company', company, 'abbr') - account = '{0} - {1}'.format(account_name, abbr) - return frappe.get_doc('Account', account) + country_wise_tax = tax_data.get(country) -def make_sales_and_purchase_tax_templates(accounts, template_name=None): - if not template_name: - template_name = accounts[0].name + if not country_wise_tax: + return - sales_tax_template = { - "doctype": "Sales Taxes and Charges Template", - "title": template_name, - "company": accounts[0].company, - 'taxes': [] + if 'chart_of_accounts' not in country_wise_tax: + country_wise_tax = simple_to_detailed(country_wise_tax) + + from_detailed_data(company_name, country_wise_tax.get('chart_of_accounts')) + + +def simple_to_detailed(templates): + """ + Convert a simple taxes object into a more detailed data structure. + + Example input: + + { + "France VAT 20%": { + "account_name": "VAT 20%", + "tax_rate": 20, + "default": 1 + }, + "France VAT 10%": { + "account_name": "VAT 10%", + "tax_rate": 10 + } } - - for account in accounts: - sales_tax_template['taxes'].append({ - "category": "Total", - "charge_type": "On Net Total", - "account_head": account.name, - "description": "{0} @ {1}".format(account.account_name, account.tax_rate), - "rate": account.tax_rate - }) - # Sales - frappe.get_doc(copy.deepcopy(sales_tax_template)).insert(ignore_permissions=True) - - # Purchase - purchase_tax_template = copy.deepcopy(sales_tax_template) - purchase_tax_template["doctype"] = "Purchase Taxes and Charges Template" - - 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': [] + """ + return { + 'chart_of_accounts': { + '*': { + 'item_tax_templates': [{ + 'title': title, + 'taxes': [{ + 'tax_type': { + 'account_name': data.get('account_name'), + 'tax_rate': data.get('tax_rate') + } + }] + } for title, data in templates.items()], + '*': [{ + 'title': title, + 'is_default': data.get('default', 0), + 'taxes': [{ + 'account_head': { + 'account_name': data.get('account_name'), + 'tax_rate': data.get('tax_rate') + } + }] + } for title, data in templates.items()] + } + } } - for account in accounts: - item_tax_template['taxes'].append({ - "tax_type": account.name, - "tax_rate": account.tax_rate - }) +def from_detailed_data(company_name, data): + """Create Taxes and Charges Templates from detailed data.""" + coa_name = frappe.db.get_value('Company', company_name, 'chart_of_accounts') + tax_templates = data.get(coa_name) or data.get('*') + sales_tax_templates = tax_templates.get('sales_tax_templates') or tax_templates.get('*') + purchase_tax_templates = tax_templates.get('purchase_tax_templates') or tax_templates.get('*') + item_tax_templates = tax_templates.get('item_tax_templates') or tax_templates.get('*') - # Items - frappe.get_doc(copy.deepcopy(item_tax_template)).insert(ignore_permissions=True) + if sales_tax_templates: + for template in sales_tax_templates: + make_taxes_and_charges_template(company_name, 'Sales Taxes and Charges Template', template) -def get_tax_account_group(company): - tax_group = frappe.db.get_value("Account", - {"account_name": "Duties and Taxes", "is_group": 1, "company": company}) - if not tax_group: - tax_group = frappe.db.get_value("Account", {"is_group": 1, "root_type": "Liability", - "account_type": "Tax", "company": company}) + if purchase_tax_templates: + for template in purchase_tax_templates: + make_taxes_and_charges_template(company_name, 'Purchase Taxes and Charges Template', template) - return tax_group + if item_tax_templates: + for template in item_tax_templates: + make_item_tax_template(company_name, template) -def get_country_wise_tax(country): - data = {} - with open (os.path.join(os.path.dirname(__file__), "..", "data", "country_wise_tax.json")) as countrywise_tax: - data = json.load(countrywise_tax).get(country) - return data +def make_taxes_and_charges_template(company_name, doctype, template): + template['company'] = company_name + template['doctype'] = doctype + + if frappe.db.exists(doctype, {'title': template.get('title'), 'company': company_name}): + return + + for tax_row in template.get('taxes'): + account_data = tax_row.get('account_head') + tax_row_defaults = { + 'category': 'Total', + 'charge_type': 'On Net Total' + } + + # if account_head is a dict, search or create the account and get it's name + if isinstance(account_data, dict): + tax_row_defaults['description'] = '{0} @ {1}'.format(account_data.get('account_name'), account_data.get('tax_rate')) + tax_row_defaults['rate'] = account_data.get('tax_rate') + account = get_or_create_account(company_name, account_data) + tax_row['account_head'] = account.name + + # use the default value if nothing other is specified + for fieldname, default_value in tax_row_defaults.items(): + if fieldname not in tax_row: + tax_row[fieldname] = default_value + + return frappe.get_doc(template).insert(ignore_permissions=True) + + +def make_item_tax_template(company_name, template): + """Create an Item Tax Template. + + This requires a separate method because Item Tax Template is structured + differently from Sales and Purchase Tax Templates. + """ + doctype = 'Item Tax Template' + template['company'] = company_name + template['doctype'] = doctype + + if frappe.db.exists(doctype, {'title': template.get('title'), 'company': company_name}): + return + + for tax_row in template.get('taxes'): + account_data = tax_row.get('tax_type') + + # if tax_type is a dict, search or create the account and get it's name + if isinstance(account_data, dict): + account = get_or_create_account(company_name, account_data) + tax_row['tax_type'] = account.name + if 'tax_rate' not in tax_row: + tax_row['tax_rate'] = account_data.get('tax_rate') + + return frappe.get_doc(template).insert(ignore_permissions=True) + + +def get_or_create_account(company_name, account): + """ + Check if account already exists. If not, create it. + Return a tax account or None. + """ + default_root_type = 'Liability' + root_type = account.get('root_type', default_root_type) + + existing_accounts = frappe.get_list('Account', + filters={ + 'company': company_name, + 'root_type': root_type + }, + or_filters={ + 'account_name': account.get('account_name'), + 'account_number': account.get('account_number') + } + ) + + if existing_accounts: + return frappe.get_doc('Account', existing_accounts[0].name) + + tax_group = get_or_create_tax_group(company_name, root_type) + + account['doctype'] = 'Account' + account['company'] = company_name + account['parent_account'] = tax_group + account['report_type'] = 'Balance Sheet' + account['account_type'] = 'Tax' + account['root_type'] = root_type + account['is_group'] = 0 + + return frappe.get_doc(account).insert(ignore_permissions=True, ignore_mandatory=True) + + +def get_or_create_tax_group(company_name, root_type): + # Look for a group account of type 'Tax' + tax_group_name = frappe.db.get_value('Account', { + 'is_group': 1, + 'root_type': root_type, + 'account_type': 'Tax', + 'company': company_name + }) + + if tax_group_name: + return tax_group_name + + # Look for a group account named 'Duties and Taxes' or 'Tax Assets' + account_name = _('Duties and Taxes') if root_type == 'Liability' else _('Tax Assets') + tax_group_name = frappe.db.get_value('Account', { + 'is_group': 1, + 'root_type': root_type, + 'account_name': account_name, + 'company': company_name + }) + + if tax_group_name: + return tax_group_name + + # Create a new group account named 'Duties and Taxes' or 'Tax Assets' just + # below the root account + root_account = frappe.get_list('Account', { + 'is_group': 1, + 'root_type': root_type, + 'company': company_name, + 'report_type': 'Balance Sheet', + 'parent_account': ('is', 'not set') + }, limit=1)[0] + + tax_group_account = frappe.get_doc({ + 'doctype': 'Account', + 'company': company_name, + 'is_group': 1, + 'report_type': 'Balance Sheet', + 'root_type': root_type, + 'account_type': 'Tax', + 'account_name': account_name, + 'parent_account': root_account.name + }).insert(ignore_permissions=True) + + tax_group_name = tax_group_account.name + + return tax_group_name diff --git a/erpnext/setup/setup_wizard/utils.py b/erpnext/setup/setup_wizard/utils.py index e82bc96d93..4223f000a6 100644 --- a/erpnext/setup/setup_wizard/utils.py +++ b/erpnext/setup/setup_wizard/utils.py @@ -9,5 +9,4 @@ def complete(): 'data', 'test_mfg.json'), 'r') as f: data = json.loads(f.read()) - #setup_wizard.create_sales_tax(data) setup_complete(data) diff --git a/erpnext/setup/workspace/home/home.json b/erpnext/setup/workspace/home/home.json index 69ca7cf9ad..305456b266 100644 --- a/erpnext/setup/workspace/home/home.json +++ b/erpnext/setup/workspace/home/home.json @@ -10,13 +10,14 @@ "hide_custom": 0, "icon": "getting-started", "idx": 0, + "is_default": 0, "is_standard": 1, "label": "Home", "links": [ { "hidden": 0, "is_query_report": 0, - "label": "Healthcare", + "label": "Accounting", "onboard": 0, "type": "Card Break" }, @@ -24,8 +25,8 @@ "dependencies": "", "hidden": 0, "is_query_report": 0, - "label": "Patient", - "link_to": "Patient", + "label": "Chart of Accounts", + "link_to": "Account", "link_type": "DocType", "onboard": 1, "type": "Link" @@ -34,25 +35,8 @@ "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": "Agriculture", - "onboard": 0, - "type": "Card Break" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Crop", - "link_to": "Crop", + "label": "Company", + "link_to": "Company", "link_type": "DocType", "onboard": 1, "type": "Link" @@ -61,8 +45,8 @@ "dependencies": "", "hidden": 0, "is_query_report": 0, - "label": "Crop Cycle", - "link_to": "Crop Cycle", + "label": "Customer", + "link_to": "Customer", "link_type": "DocType", "onboard": 1, "type": "Link" @@ -71,112 +55,8 @@ "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": "Fertilizer", - "link_to": "Fertilizer", - "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": "Course", - "link_to": "Course", - "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": "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": "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" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Donor", - "link_to": "Donor", + "label": "Supplier", + "link_to": "Supplier", "link_type": "DocType", "onboard": 1, "type": "Link" @@ -188,6 +68,16 @@ "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, @@ -302,73 +192,6 @@ "onboard": 1, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Accounting", - "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": "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" - }, - { - "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": "Opening Invoice Creation Tool", - "link_to": "Opening Invoice Creation Tool", - "link_type": "DocType", - "onboard": 1, - "type": "Link" - }, { "hidden": 0, "is_query_report": 0, @@ -386,6 +209,16 @@ "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, @@ -415,9 +248,177 @@ "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-01-01 12:13:16.055668", + "modified": "2021-03-16 15:59:58.416154", "modified_by": "Administrator", "module": "Setup", "name": "Home", diff --git a/erpnext/shopping_cart/cart.py b/erpnext/shopping_cart/cart.py index 8515db3300..56afe95efd 100644 --- a/erpnext/shopping_cart/cart.py +++ b/erpnext/shopping_cart/cart.py @@ -230,12 +230,12 @@ def update_cart_address(address_type, address_name): 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 + 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 + 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) diff --git a/erpnext/shopping_cart/test_shopping_cart.py b/erpnext/shopping_cart/test_shopping_cart.py index cf59a52b5b..d857bf5f5c 100644 --- a/erpnext/shopping_cart/test_shopping_cart.py +++ b/erpnext/shopping_cart/test_shopping_cart.py @@ -16,6 +16,11 @@ class TestShoppingCart(unittest.TestCase): Note: Shopping Cart == Quotation """ + + @classmethod + def tearDownClass(cls): + frappe.db.sql("delete from `tabTax Rule`") + def setUp(self): frappe.set_user("Administrator") create_test_contact_and_address() @@ -51,8 +56,8 @@ class TestShoppingCart(unittest.TestCase): def test_add_to_cart(self): self.login_as_customer() - # remove from cart - self.remove_all_items_from_cart() + # clear existing quotations + self.clear_existing_quotations() # add first item update_cart("_Test Item", 1) @@ -100,6 +105,7 @@ class TestShoppingCart(unittest.TestCase): self.assertEqual(len(quotation.get("items")), 1) def test_tax_rule(self): + self.create_tax_rule() self.login_as_customer() quotation = self.create_quotation() @@ -115,6 +121,13 @@ class TestShoppingCart(unittest.TestCase): self.remove_test_quotation(quotation) + def create_tax_rule(self): + tax_rule = frappe.get_test_records("Tax Rule")[0] + try: + frappe.get_doc(tax_rule).insert() + except frappe.DuplicateEntryError: + pass + def create_quotation(self): quotation = frappe.new_doc("Quotation") @@ -195,10 +208,15 @@ class TestShoppingCart(unittest.TestCase): "_Test Contact For _Test Customer") frappe.set_user("test_contact_customer@example.com") - def remove_all_items_from_cart(self): - quotation = _get_cart_quotation() - quotation.flags.ignore_permissions=True - quotation.delete() + def clear_existing_quotations(self): + quotations = frappe.get_all("Quotation", filters={ + "party_name": get_party().name, + "order_type": "Shopping Cart", + "docstatus": 0 + }, order_by="modified desc", pluck="name") + + for quotation in quotations: + frappe.delete_doc("Quotation", quotation, ignore_permissions=True, force=True) def create_user_if_not_exists(self, email, first_name = None): if frappe.db.exists("User", email): diff --git a/erpnext/stock/dashboard/item_dashboard.js b/erpnext/stock/dashboard/item_dashboard.js index 95cb92b1b3..933ca8ab3d 100644 --- a/erpnext/stock/dashboard/item_dashboard.js +++ b/erpnext/stock/dashboard/item_dashboard.js @@ -1,14 +1,14 @@ frappe.provide('erpnext.stock'); erpnext.stock.ItemDashboard = Class.extend({ - init: function(opts) { + init: function (opts) { $.extend(this, opts); this.make(); }, - make: function() { + make: function () { var me = this; this.start = 0; - if(!this.sort_by) { + if (!this.sort_by) { this.sort_by = 'projected_qty'; this.sort_order = 'asc'; } @@ -16,22 +16,25 @@ erpnext.stock.ItemDashboard = Class.extend({ this.content = $(frappe.render_template('item_dashboard')).appendTo(this.parent); this.result = this.content.find('.result'); - this.content.on('click', '.btn-move', function() { - handle_move_add($(this), "Move") + this.content.on('click', '.btn-move', function () { + handle_move_add($(this), "Move"); }); - this.content.on('click', '.btn-add', function() { - handle_move_add($(this), "Add") + this.content.on('click', '.btn-add', function () { + handle_move_add($(this), "Add"); }); - this.content.on('click', '.btn-edit', function() { + 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); - }); + 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) { @@ -39,23 +42,26 @@ erpnext.stock.ItemDashboard = Class.extend({ let warehouse = unescape(element.attr('data-warehouse')); let actual_qty = unescape(element.attr('data-actual_qty')); let disable_quick_entry = Number(unescape(element.attr('data-disable_quick_entry'))); - let entry_type = action === "Move" ? "Material Transfer": null; + let entry_type = action === "Move" ? "Material Transfer" : null; if (disable_quick_entry) { open_stock_entry(item, warehouse, entry_type); } else { if (action === "Add") { let rate = unescape($(this).attr('data-rate')); - erpnext.stock.move_item(item, null, warehouse, actual_qty, rate, function() { me.refresh(); }); - } - else { - erpnext.stock.move_item(item, warehouse, null, actual_qty, null, function() { me.refresh(); }); + erpnext.stock.move_item(item, null, warehouse, actual_qty, rate, function () { + me.refresh(); + }); + } else { + erpnext.stock.move_item(item, warehouse, null, actual_qty, null, function () { + me.refresh(); + }); } } } function open_stock_entry(item, warehouse, entry_type) { - frappe.model.with_doctype('Stock Entry', function() { + frappe.model.with_doctype('Stock Entry', function () { var doc = frappe.model.get_new_doc('Stock Entry'); if (entry_type) doc.stock_entry_type = entry_type; @@ -64,18 +70,18 @@ erpnext.stock.ItemDashboard = Class.extend({ row.s_warehouse = warehouse; frappe.set_route('Form', doc.doctype, doc.name); - }) + }); } // more - this.content.find('.btn-more').on('click', function() { + this.content.find('.btn-more').on('click', function () { me.start += me.page_length; me.refresh(); }); }, - refresh: function() { - if(this.before_refresh) { + refresh: function () { + if (this.before_refresh) { this.before_refresh(); } @@ -94,13 +100,13 @@ erpnext.stock.ItemDashboard = Class.extend({ frappe.call({ method: this.method, args: args, - callback: function(r) { + callback: function (r) { me.render(r.message); } }); }, - render: function(data) { - if (this.start===0) { + render: function (data) { + if (this.start === 0) { this.max_count = 0; this.result.empty(); } @@ -115,7 +121,7 @@ erpnext.stock.ItemDashboard = Class.extend({ this.max_count = this.max_count; // show more button - if (data && data.length===(this.page_length + 1)) { + if (data && data.length === (this.page_length + 1)) { this.content.find('.more').removeClass('hidden'); // remove the last element @@ -137,15 +143,15 @@ erpnext.stock.ItemDashboard = Class.extend({ } }, - get_item_dashboard_data: function(data, max_count, show_item) { - if(!max_count) max_count = 0; - if(!data) data = []; + get_item_dashboard_data: function (data, max_count, show_item) { + if (!max_count) max_count = 0; + if (!data) data = []; - data.forEach(function(d) { + data.forEach(function (d) { d.actual_or_pending = d.projected_qty + d.reserved_qty + d.reserved_qty_for_production + d.reserved_qty_for_sub_contract; d.pending_qty = 0; d.total_reserved = d.reserved_qty + d.reserved_qty_for_production + d.reserved_qty_for_sub_contract; - if(d.actual_or_pending > d.actual_qty) { + if (d.actual_or_pending > d.actual_qty) { d.pending_qty = d.actual_or_pending - d.actual_qty; } @@ -161,16 +167,16 @@ erpnext.stock.ItemDashboard = Class.extend({ return { data: data, max_count: max_count, - can_write:can_write, + can_write: can_write, show_item: show_item || false }; }, - get_capacity_dashboard_data: function(data) { + get_capacity_dashboard_data: function (data) { if (!data) data = []; - data.forEach(function(d) { - d.color = d.percent_occupied >=80 ? "#f8814f" : "#2490ef"; + data.forEach(function (d) { + d.color = d.percent_occupied >= 80 ? "#f8814f" : "#2490ef"; }); let can_write = 0; @@ -185,53 +191,77 @@ erpnext.stock.ItemDashboard = Class.extend({ } }); -erpnext.stock.move_item = function(item, source, target, actual_qty, rate, callback) { +erpnext.stock.move_item = function (item, source, target, actual_qty, rate, callback) { var dialog = new frappe.ui.Dialog({ title: target ? __('Add Item') : __('Move Item'), - fields: [ - {fieldname: 'item_code', label: __('Item'), - fieldtype: 'Link', options: 'Item', read_only: 1}, - {fieldname: 'source', label: __('Source Warehouse'), - fieldtype: 'Link', options: 'Warehouse', read_only: 1}, - {fieldname: 'target', label: __('Target Warehouse'), - fieldtype: 'Link', options: 'Warehouse', reqd: 1}, - {fieldname: 'qty', label: __('Quantity'), reqd: 1, - fieldtype: 'Float', description: __('Available {0}', [actual_qty]) }, - {fieldname: 'rate', label: __('Rate'), fieldtype: 'Currency', hidden: 1 }, + fields: [{ + fieldname: 'item_code', + label: __('Item'), + fieldtype: 'Link', + options: 'Item', + read_only: 1 + }, + { + fieldname: 'source', + label: __('Source Warehouse'), + fieldtype: 'Link', + options: 'Warehouse', + read_only: 1 + }, + { + fieldname: 'target', + label: __('Target Warehouse'), + fieldtype: 'Link', + options: 'Warehouse', + reqd: 1 + }, + { + fieldname: 'qty', + label: __('Quantity'), + reqd: 1, + fieldtype: 'Float', + description: __('Available {0}', [actual_qty]) + }, + { + fieldname: 'rate', + label: __('Rate'), + fieldtype: 'Currency', + hidden: 1 + }, ], - }) + }); dialog.show(); dialog.get_field('item_code').set_input(item); - if(source) { + if (source) { dialog.get_field('source').set_input(source); } else { dialog.get_field('source').df.hidden = 1; dialog.get_field('source').refresh(); } - if(rate) { + if (rate) { dialog.get_field('rate').set_value(rate); dialog.get_field('rate').df.hidden = 0; dialog.get_field('rate').refresh(); } - if(target) { + if (target) { dialog.get_field('target').df.read_only = 1; dialog.get_field('target').value = target; dialog.get_field('target').refresh(); } - dialog.set_primary_action(__('Submit'), function() { + dialog.set_primary_action(__('Submit'), function () { var values = dialog.get_values(); - if(!values) { + if (!values) { return; } - if(source && values.qty > actual_qty) { + if (source && values.qty > actual_qty) { frappe.msgprint(__('Quantity must be less than or equal to {0}', [actual_qty])); return; } - if(values.source === values.target) { + if (values.source === values.target) { frappe.msgprint(__('Source and target warehouse must be different')); } @@ -239,21 +269,21 @@ erpnext.stock.move_item = function(item, source, target, actual_qty, rate, callb method: 'erpnext.stock.doctype.stock_entry.stock_entry_utils.make_stock_entry', args: values, freeze: true, - callback: function(r) { + callback: function (r) { frappe.show_alert(__('Stock Entry {0} created', - ['' + r.message.name+ ''])); + ['' + r.message.name + ''])); dialog.hide(); callback(r); }, }); }); - $('

' - + __("Add more items or open full form") + '

') + $('

' + + __("Add more items or open full form") + '

') .appendTo(dialog.body) .find('.link-open') - .on('click', function() { - frappe.model.with_doctype('Stock Entry', function() { + .on('click', function () { + frappe.model.with_doctype('Stock Entry', function () { var doc = frappe.model.get_new_doc('Stock Entry'); doc.from_warehouse = dialog.get_value('source'); doc.to_warehouse = dialog.get_value('target'); @@ -266,6 +296,6 @@ erpnext.stock.move_item = function(item, source, target, actual_qty, rate, callb row.transfer_qty = dialog.get_value('qty'); row.basic_rate = dialog.get_value('rate'); frappe.set_route('Form', doc.doctype, doc.name); - }) + }); }); -} +}; diff --git a/erpnext/stock/dashboard/item_dashboard.py b/erpnext/stock/dashboard/item_dashboard.py index cafb5c3a0a..45e662807a 100644 --- a/erpnext/stock/dashboard/item_dashboard.py +++ b/erpnext/stock/dashboard/item_dashboard.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import frappe from frappe.model.db_query import DatabaseQuery +from frappe.utils import flt, cint @frappe.whitelist() def get_data(item_code=None, warehouse=None, item_group=None, @@ -42,11 +43,20 @@ def get_data(item_code=None, warehouse=None, item_group=None, limit_start=start, limit_page_length='21') + precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) + for item in items: item.update({ - 'item_name': frappe.get_cached_value("Item", item.item_code, 'item_name'), - 'disable_quick_entry': frappe.get_cached_value("Item", item.item_code, 'has_batch_no') - or frappe.get_cached_value("Item", item.item_code, 'has_serial_no'), + 'item_name': frappe.get_cached_value( + "Item", item.item_code, 'item_name'), + 'disable_quick_entry': frappe.get_cached_value( + "Item", item.item_code, 'has_batch_no') + or frappe.get_cached_value( + "Item", item.item_code, 'has_serial_no'), + 'projected_qty': flt(item.projected_qty, precision), + 'reserved_qty': flt(item.reserved_qty, precision), + 'reserved_qty_for_production': flt(item.reserved_qty_for_production, precision), + 'reserved_qty_for_sub_contract': flt(item.reserved_qty_for_sub_contract, precision), + 'actual_qty': flt(item.actual_qty, precision), }) - return items diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index f595aade91..280fde158f 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -99,6 +99,7 @@ "rounding_adjustment", "rounded_total", "in_words", + "disable_rounded_total", "terms_section_break", "tc_name", "terms", @@ -768,6 +769,7 @@ "width": "150px" }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment (Company Currency)", @@ -777,6 +779,7 @@ "read_only": 1 }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "base_rounded_total", "fieldtype": "Currency", "label": "Rounded Total (Company Currency)", @@ -819,6 +822,7 @@ "width": "150px" }, { + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounding_adjustment", "fieldtype": "Currency", "label": "Rounding Adjustment", @@ -829,6 +833,7 @@ }, { "bold": 1, + "depends_on": "eval:!doc.disable_rounded_total", "fieldname": "rounded_total", "fieldtype": "Currency", "label": "Rounded Total", @@ -1271,13 +1276,20 @@ "label": "Represents Company", "options": "Company", "read_only": 1 + }, + { + "default": "0", + "depends_on": "grand_total", + "fieldname": "disable_rounded_total", + "fieldtype": "Check", + "label": "Disable Rounded Total" } ], "icon": "fa fa-truck", "idx": 146, "is_submittable": 1, "links": [], - "modified": "2020-12-26 17:07:59.194403", + "modified": "2021-04-15 23:55:49.620641", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py index 28e9533186..de85bc3922 100644 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py @@ -90,6 +90,7 @@ class DeliveryTrip(Document): delivery_notes = [get_link_to_form("Delivery Note", note) for note in delivery_notes] frappe.msgprint(_("Delivery Notes {0} updated").format(", ".join(delivery_notes))) + @frappe.whitelist() def process_route(self, optimize): """ Estimate the arrival times for each stop in the Delivery Trip. diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 6886c1ba45..6fed9efa63 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -1054,6 +1054,7 @@ "read_only": 1 }, { + "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website", "fieldname": "website_image_alt", "fieldtype": "Data", "label": "Image Description" @@ -1066,7 +1067,7 @@ "index_web_pages_for_search": 1, "links": [], "max_attachments": 1, - "modified": "2021-02-18 14:00:19.668049", + "modified": "2021-03-18 14:04:38.575519", "modified_by": "Administrator", "module": "Stock", "name": "Item", @@ -1118,6 +1119,15 @@ { "read": 1, "role": "Manufacturing User" + }, + { + "email": 1, + "export": 1, + "print": 1, + "report": 1, + "role": "All", + "select": 1, + "share": 1 } ], "quick_entry": 1, @@ -1128,4 +1138,4 @@ "sort_order": "DESC", "title_field": "item_name", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 7b7d2da969..7cb84a69f0 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -50,6 +50,7 @@ class Item(WebsiteGenerator): self.set_onload('stock_exists', self.stock_ledger_created()) self.set_asset_naming_series() + @frappe.whitelist() def set_asset_naming_series(self): if not hasattr(self, '_asset_naming_series'): from erpnext.assets.doctype.asset.asset import get_asset_naming_series @@ -706,6 +707,7 @@ class Item(WebsiteGenerator): frappe.db.set_value("Stock Settings", None, "allow_negative_stock", existing_allow_negative_stock) frappe.db.auto_commit_on_many_writes = 0 + @frappe.whitelist() def copy_specification_from_item_group(self): self.set("website_specifications", []) if self.item_group: diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 36d0de1e5d..e0b89d8e45 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -494,7 +494,8 @@ def make_item_variant(): test_records = frappe.get_test_records('Item') -def create_item(item_code, is_stock_item=None, valuation_rate=0, warehouse=None, is_customer_provided_item=None, customer=None, is_purchase_item=None, opening_stock=None): +def create_item(item_code, is_stock_item=None, valuation_rate=0, warehouse=None, is_customer_provided_item=None, + customer=None, is_purchase_item=None, opening_stock=None, company=None): if not frappe.db.exists("Item", item_code): item = frappe.new_doc("Item") item.item_code = item_code @@ -509,7 +510,7 @@ def create_item(item_code, is_stock_item=None, valuation_rate=0, warehouse=None, item.customer = customer or '' item.append("item_defaults", { "default_warehouse": warehouse or '_Test Warehouse - _TC', - "company": "_Test Company" + "company": company or "_Test Company" }) item.save() else: diff --git a/erpnext/stock/doctype/item/test_records.json b/erpnext/stock/doctype/item/test_records.json index 909c4eeb90..6cec85288f 100644 --- a/erpnext/stock/doctype/item/test_records.json +++ b/erpnext/stock/doctype/item/test_records.json @@ -12,6 +12,7 @@ "item_name": "_Test Item", "apply_warehouse_wise_reorder_level": 1, "gst_hsn_code": "999800", + "opening_stock": 10, "valuation_rate": 100, "item_defaults": [{ "company": "_Test Company", @@ -58,6 +59,8 @@ "show_in_website": 1, "website_warehouse": "_Test Warehouse - _TC", "gst_hsn_code": "999800", + "opening_stock": 10, + "valuation_rate": 100, "item_defaults": [{ "company": "_Test Company", "default_warehouse": "_Test Warehouse - _TC", diff --git a/erpnext/stock/doctype/item_attribute/test_records.json b/erpnext/stock/doctype/item_attribute/test_records.json index d346979496..6aa6ffd6c9 100644 --- a/erpnext/stock/doctype/item_attribute/test_records.json +++ b/erpnext/stock/doctype/item_attribute/test_records.json @@ -4,10 +4,12 @@ "attribute_name": "Test Size", "priority": 1, "item_attribute_values": [ + {"attribute_value": "Extra Small", "abbr": "XSL"}, {"attribute_value": "Small", "abbr": "S"}, {"attribute_value": "Medium", "abbr": "M"}, {"attribute_value": "Large", "abbr": "L"}, - {"attribute_value": "Extra Small", "abbr": "XSL"} + {"attribute_value": "Extra Large", "abbr": "XL"}, + {"attribute_value": "2XL", "abbr": "2XL"} ] }, { diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js index 24f7e31a0c..e8fb34732f 100644 --- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js +++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js @@ -15,8 +15,9 @@ frappe.ui.form.on('Item Variant Settings', { } }); - const child = frappe.meta.get_docfield("Variant Field", "field_name", frm.doc.name); - child.options = allow_fields; + frm.fields_dict.fields.grid.update_docfield_property( + 'field_name', 'options', allow_fields + ); }); } }); diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 69a8bf19d3..83109469fc 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -12,6 +12,7 @@ from erpnext.accounts.doctype.account.account import get_account_currency from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals class LandedCostVoucher(Document): + @frappe.whitelist() def get_items_from_purchase_receipts(self): self.set("items", []) for pr in self.get("purchase_receipts"): diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 527b0d3ea9..7dfc5da50d 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -354,6 +354,10 @@ frappe.ui.form.on('Material Request', { }, material_request_type: function(frm) { frm.toggle_reqd('customer', frm.doc.material_request_type=="Customer Provided"); + + if (frm.doc.material_request_type !== 'Material Transfer' && frm.doc.set_from_warehouse) { + frm.set_value('set_from_warehouse', ''); + } }, }); diff --git a/erpnext/stock/doctype/material_request/material_request.json b/erpnext/stock/doctype/material_request/material_request.json index d73349dd39..8d7b238c17 100644 --- a/erpnext/stock/doctype/material_request/material_request.json +++ b/erpnext/stock/doctype/material_request/material_request.json @@ -20,9 +20,9 @@ "company", "amended_from", "warehouse_section", - "set_warehouse", - "column_break5", "set_from_warehouse", + "column_break5", + "set_warehouse", "items_section", "scan_barcode", "items", @@ -314,7 +314,7 @@ "idx": 70, "is_submittable": 1, "links": [], - "modified": "2020-09-19 01:04:09.285862", + "modified": "2021-03-31 23:52:55.392512", "modified_by": "Administrator", "module": "Stock", "name": "Material Request", diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.js b/erpnext/stock/doctype/packing_slip/packing_slip.js index bd14e5f616..40d46852d0 100644 --- a/erpnext/stock/doctype/packing_slip/packing_slip.js +++ b/erpnext/stock/doctype/packing_slip/packing_slip.js @@ -110,19 +110,4 @@ cur_frm.cscript.calc_net_total_pkg = function(doc, ps_detail) { refresh_many(['net_weight_pkg', 'net_weight_uom', 'gross_weight_uom', 'gross_weight_pkg']); } -var make_row = function(title,val,bold){ - var bstart = ''; var bend = ''; - return ''+(bold?bstart:'')+title+(bold?bend:'')+'' - +''+ val +'' - +'' -} - -cur_frm.pformat.net_weight_pkg= function(doc){ - return '' + make_row('Net Weight', doc.net_weight_pkg) + '
' -} - -cur_frm.pformat.gross_weight_pkg= function(doc){ - return '' + make_row('Gross Weight', doc.gross_weight_pkg) + '
' -} - // TODO: validate gross weight field diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.py b/erpnext/stock/doctype/packing_slip/packing_slip.py index a7a29cca7f..2008bffcd3 100644 --- a/erpnext/stock/doctype/packing_slip/packing_slip.py +++ b/erpnext/stock/doctype/packing_slip/packing_slip.py @@ -152,6 +152,7 @@ class PackingSlip(Document): return cint(recommended_case_no[0][0]) + 1 + @frappe.whitelist() def get_items(self): self.set("items", []) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 7d206cb4a9..6ab68e292a 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -25,14 +25,15 @@ class PickList(Document): if not frappe.get_cached_value('Item', item.item_code, 'has_serial_no'): continue if not item.serial_no: - frappe.throw(_("Row #{0}: {1} does not have any available serial numbers in {2}".format( - frappe.bold(item.idx), frappe.bold(item.item_code), frappe.bold(item.warehouse))), + frappe.throw(_("Row #{0}: {1} does not have any available serial numbers in {2}").format( + frappe.bold(item.idx), frappe.bold(item.item_code), frappe.bold(item.warehouse)), title=_("Serial Nos Required")) if len(item.serial_no.split('\n')) == item.picked_qty: continue frappe.throw(_('For item {0} at row {1}, count of serial numbers does not match with the picked quantity') .format(frappe.bold(item.item_code), frappe.bold(item.idx)), title=_("Quantity Mismatch")) + @frappe.whitelist() def set_item_locations(self, save=False): items = self.aggregate_item_qty() self.item_location_map = frappe._dict() @@ -378,9 +379,8 @@ def create_stock_entry(pick_list): else: stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry) - stock_entry.set_incoming_rate() stock_entry.set_actual_qty() - stock_entry.calculate_rate_and_amount(update_finished_item_rate=False) + stock_entry.calculate_rate_and_amount() return stock_entry.as_dict() diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index a762e9763e..c4da05a6d4 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -23,7 +23,7 @@ class TestPickList(unittest.TestCase): 'purpose': 'Opening Stock', 'expense_account': 'Temporary Opening - _TC', 'items': [{ - 'item_code': '_Test Item Home Desktop 100', + 'item_code': '_Test Item', 'warehouse': '_Test Warehouse - _TC', 'valuation_rate': 100, 'qty': 5 @@ -38,7 +38,7 @@ class TestPickList(unittest.TestCase): 'customer': '_Test Customer', 'items_based_on': 'Sales Order', 'locations': [{ - 'item_code': '_Test Item Home Desktop 100', + 'item_code': '_Test Item', 'qty': 5, 'stock_qty': 5, 'conversion_factor': 1, @@ -48,7 +48,7 @@ class TestPickList(unittest.TestCase): }) pick_list.set_item_locations() - self.assertEqual(pick_list.locations[0].item_code, '_Test Item Home Desktop 100') + self.assertEqual(pick_list.locations[0].item_code, '_Test Item') self.assertEqual(pick_list.locations[0].warehouse, '_Test Warehouse - _TC') self.assertEqual(pick_list.locations[0].qty, 5) @@ -238,7 +238,7 @@ class TestPickList(unittest.TestCase): 'purpose': 'Opening Stock', 'expense_account': 'Temporary Opening - _TC', 'items': [{ - 'item_code': '_Test Item Home Desktop 100', + 'item_code': '_Test Item', 'warehouse': '_Test Warehouse - _TC', 'valuation_rate': 100, 'qty': 10 @@ -252,7 +252,7 @@ class TestPickList(unittest.TestCase): 'customer': '_Test Customer', 'company': '_Test Company', 'items': [{ - 'item_code': '_Test Item Home Desktop 100', + 'item_code': '_Test Item', 'qty': 10, 'delivery_date': frappe.utils.today() }], @@ -265,14 +265,14 @@ class TestPickList(unittest.TestCase): 'customer': '_Test Customer', 'items_based_on': 'Sales Order', 'locations': [{ - 'item_code': '_Test Item Home Desktop 100', + 'item_code': '_Test Item', 'qty': 5, 'stock_qty': 5, 'conversion_factor': 1, 'sales_order': '_T-Sales Order-1', 'sales_order_item': '_T-Sales Order-1_item', }, { - 'item_code': '_Test Item Home Desktop 100', + 'item_code': '_Test Item', 'qty': 5, 'stock_qty': 5, 'conversion_factor': 1, @@ -282,12 +282,12 @@ class TestPickList(unittest.TestCase): }) pick_list.set_item_locations() - self.assertEqual(pick_list.locations[0].item_code, '_Test Item Home Desktop 100') + self.assertEqual(pick_list.locations[0].item_code, '_Test Item') self.assertEqual(pick_list.locations[0].warehouse, '_Test Warehouse - _TC') self.assertEqual(pick_list.locations[0].qty, 5) self.assertEqual(pick_list.locations[0].sales_order_item, '_T-Sales Order-1_item') - self.assertEqual(pick_list.locations[1].item_code, '_Test Item Home Desktop 100') + self.assertEqual(pick_list.locations[1].item_code, '_Test Item') self.assertEqual(pick_list.locations[1].warehouse, '_Test Warehouse - _TC') self.assertEqual(pick_list.locations[1].qty, 5) self.assertEqual(pick_list.locations[1].sales_order_item, sales_order.items[0].name) @@ -358,4 +358,4 @@ class TestPickList(unittest.TestCase): # pass # def test_pick_list_from_material_request(self): - # pass \ No newline at end of file + # pass diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 57cc3504a9..4d1a514c6b 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -248,13 +248,6 @@ cur_frm.fields_dict['items'].grid.get_field('project').get_query = function(doc, } } -cur_frm.cscript.select_print_heading = function(doc, cdt, cdn) { - if(doc.select_print_heading) - cur_frm.pformat.print_heading = doc.select_print_heading; - else - cur_frm.pformat.print_heading = "Purchase Receipt"; -} - cur_frm.fields_dict['select_print_heading'].get_query = function(doc, cdt, cdn) { return { filters: [ diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 70687bdac2..5d7597b2db 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -176,7 +176,7 @@ class PurchaseReceipt(BuyingController): if flt(self.per_billed) < 100: self.update_billing_status() else: - self.status = "Completed" + self.db_set("status", "Completed") # Updating stock ledger should always be called after updating prevdoc status, diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 7741ee7f60..16eea24f84 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -191,7 +191,7 @@ class TestPurchaseReceipt(unittest.TestCase): rm_supp_cost = sum([d.amount for d in pr.get("supplied_items")]) self.assertEqual(pr.get("items")[0].rm_supp_cost, flt(rm_supp_cost, 2)) - + pr.cancel() def test_subcontracting_gle_fg_item_rate_zero(self): @@ -582,6 +582,7 @@ class TestPurchaseReceipt(unittest.TestCase): serial_no=serial_no, basic_rate=100, do_not_submit=True) se.submit() + se.cancel() dn.cancel() pr1.cancel() @@ -912,6 +913,57 @@ class TestPurchaseReceipt(unittest.TestCase): ste1.cancel() po.cancel() + + def test_po_to_pi_and_po_to_pr_worflow_full(self): + """Test following behaviour: + - Create PO + - Create PI from PO and submit + - Create PR from PO and submit + """ + from erpnext.buying.doctype.purchase_order import test_purchase_order + from erpnext.buying.doctype.purchase_order import purchase_order + + po = test_purchase_order.create_purchase_order() + + pi = purchase_order.make_purchase_invoice(po.name) + pi.submit() + + pr = purchase_order.make_purchase_receipt(po.name) + pr.submit() + + pr.load_from_db() + + self.assertEqual(pr.status, "Completed") + self.assertEqual(pr.per_billed, 100) + + def test_po_to_pi_and_po_to_pr_worflow_partial(self): + """Test following behaviour: + - Create PO + - Create partial PI from PO and submit + - Create PR from PO and submit + """ + from erpnext.buying.doctype.purchase_order import test_purchase_order + from erpnext.buying.doctype.purchase_order import purchase_order + + po = test_purchase_order.create_purchase_order() + + pi = purchase_order.make_purchase_invoice(po.name) + pi.items[0].qty /= 2 # roughly 50%, ^ this function only creates PI with 1 item. + pi.submit() + + pr = purchase_order.make_purchase_receipt(po.name) + pr.save() + # per_billed is only updated after submission. + self.assertEqual(flt(pr.per_billed), 0) + + pr.submit() + + pi.load_from_db() + pr.load_from_db() + + self.assertEqual(pr.status, "To Bill") + self.assertAlmostEqual(pr.per_billed, 50.0, places=2) + def get_sl_entries(voucher_type, voucher_no): return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 264a673ba4..469511af60 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -18,6 +18,7 @@ class QualityInspection(Document): if self.readings: self.inspect_and_set_status() + @frappe.whitelist() def get_item_specification_details(self): if not self.quality_inspection_template: self.quality_inspection_template = frappe.db.get_value('Item', @@ -32,6 +33,7 @@ class QualityInspection(Document): child.update(d) child.status = "Accepted" + @frappe.whitelist() def get_quality_inspection_template(self): template = '' if self.bom_no: diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index a9556514f5..3f83780569 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -39,6 +39,7 @@ class RepostItemValuation(Document): frappe.enqueue(repost, timeout=1800, queue='long', job_name='repost_sle', now=frappe.flags.in_test, doc=self) + @frappe.whitelist() def restart_reposting(self): self.set_status('Queued') frappe.enqueue(repost, timeout=1800, queue='long', diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index c8d8ca9e17..c02dd2e518 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -14,6 +14,7 @@ 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 class SerialNoNotRequiredError(ValidationError): pass @@ -322,11 +323,35 @@ def validate_serial_no(sle, item_det): frappe.throw(_("Serial Nos Required for Serialized Item {0}").format(sle.item_code), SerialNoRequiredError) elif serial_nos: + # SLE is being cancelled and has 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)) + check_serial_no_validity_on_cancel(serial_no, sle) + +def check_serial_no_validity_on_cancel(serial_no, sle): + sr = frappe.db.get_value("Serial No", serial_no, ["name", "warehouse", "company", "status"], as_dict=1) + sr_link = frappe.utils.get_link_to_form("Serial No", serial_no) + doc_link = frappe.utils.get_link_to_form(sle.voucher_type, sle.voucher_no) + actual_qty = cint(sle.actual_qty) + is_stock_reco = sle.voucher_type == "Stock Reconciliation" + msg = None + + if sr and (actual_qty < 0 or is_stock_reco) and sr.warehouse != sle.warehouse: + # receipt(inward) is being cancelled + msg = _("Cannot cancel {0} {1} as Serial No {2} does not belong to the warehouse {3}").format( + sle.voucher_type, doc_link, sr_link, frappe.bold(sle.warehouse)) + elif sr and actual_qty > 0 and not is_stock_reco: + # delivery is being cancelled, check for warehouse. + if sr.warehouse: + # serial no is active in another warehouse/company. + msg = _("Cannot cancel {0} {1} as Serial No {2} is active in warehouse {3}").format( + sle.voucher_type, doc_link, sr_link, frappe.bold(sr.warehouse)) + elif sr.company != sle.company and sr.status == "Delivered": + # serial no is inactive (allowed) or delivered from another company (block). + msg = _("Cannot cancel {0} {1} as Serial No {2} does not belong to the company {3}").format( + sle.voucher_type, doc_link, sr_link, frappe.bold(sle.company)) + + if msg: + frappe.throw(msg, title=_("Cannot cancel")) def validate_material_transfer_entry(sle_doc): sle_doc.update({ diff --git a/erpnext/stock/doctype/serial_no/test_serial_no.py b/erpnext/stock/doctype/serial_no/test_serial_no.py index ed70790b2c..cde7fe07c6 100644 --- a/erpnext/stock/doctype/serial_no/test_serial_no.py +++ b/erpnext/stock/doctype/serial_no/test_serial_no.py @@ -40,16 +40,139 @@ class TestSerialNo(unittest.TestCase): se = make_serialized_item(target_warehouse="_Test Warehouse - _TC") serial_nos = get_serial_nos(se.get("items")[0].serial_no) - create_delivery_note(item_code="_Test Serialized Item With Series", qty=1, serial_no=serial_nos[0]) + dn = create_delivery_note(item_code="_Test Serialized Item With Series", qty=1, serial_no=serial_nos[0]) + + serial_no = frappe.get_doc("Serial No", serial_nos[0]) + + # check Serial No details after delivery + self.assertEqual(serial_no.status, "Delivered") + self.assertEqual(serial_no.warehouse, None) + self.assertEqual(serial_no.company, "_Test Company") + self.assertEqual(serial_no.delivery_document_type, "Delivery Note") + self.assertEqual(serial_no.delivery_document_no, dn.name) wh = create_warehouse("_Test Warehouse", company="_Test Company 1") - make_purchase_receipt(item_code="_Test Serialized Item With Series", qty=1, serial_no=serial_nos[0], + pr = make_purchase_receipt(item_code="_Test Serialized Item With Series", qty=1, serial_no=serial_nos[0], company="_Test Company 1", warehouse=wh) - serial_no = frappe.db.get_value("Serial No", serial_nos[0], ["warehouse", "company"], as_dict=1) + serial_no.reload() + # check Serial No details after purchase in second company + self.assertEqual(serial_no.status, "Active") self.assertEqual(serial_no.warehouse, wh) self.assertEqual(serial_no.company, "_Test Company 1") + self.assertEqual(serial_no.purchase_document_type, "Purchase Receipt") + self.assertEqual(serial_no.purchase_document_no, pr.name) + + def test_inter_company_transfer_intermediate_cancellation(self): + """ + Receive into and Deliver Serial No from one company. + Then Receive into and Deliver from second company. + Try to cancel intermediate receipts/deliveries to test if it is blocked. + """ + se = make_serialized_item(target_warehouse="_Test Warehouse - _TC") + serial_nos = get_serial_nos(se.get("items")[0].serial_no) + + sn_doc = frappe.get_doc("Serial No", serial_nos[0]) + + # check Serial No details after purchase in first company + self.assertEqual(sn_doc.status, "Active") + self.assertEqual(sn_doc.company, "_Test Company") + self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC") + self.assertEqual(sn_doc.purchase_document_no, se.name) + + dn = create_delivery_note(item_code="_Test Serialized Item With Series", + qty=1, serial_no=serial_nos[0]) + sn_doc.reload() + # check Serial No details after delivery from **first** company + self.assertEqual(sn_doc.status, "Delivered") + self.assertEqual(sn_doc.company, "_Test Company") + self.assertEqual(sn_doc.warehouse, None) + self.assertEqual(sn_doc.delivery_document_no, dn.name) + + # try cancelling the first Serial No Receipt, even though it is delivered + # block cancellation is Serial No is out of the warehouse + self.assertRaises(frappe.ValidationError, se.cancel) + + # receive serial no in second company + wh = create_warehouse("_Test Warehouse", company="_Test Company 1") + pr = make_purchase_receipt(item_code="_Test Serialized Item With Series", + qty=1, serial_no=serial_nos[0], company="_Test Company 1", warehouse=wh) + sn_doc.reload() + + self.assertEqual(sn_doc.warehouse, wh) + # try cancelling the delivery from the first company + # block cancellation as Serial No belongs to different company + self.assertRaises(frappe.ValidationError, dn.cancel) + + # deliver from second company + dn_2 = create_delivery_note(item_code="_Test Serialized Item With Series", + qty=1, serial_no=serial_nos[0], company="_Test Company 1", warehouse=wh) + sn_doc.reload() + + # check Serial No details after delivery from **second** company + self.assertEqual(sn_doc.status, "Delivered") + self.assertEqual(sn_doc.company, "_Test Company 1") + self.assertEqual(sn_doc.warehouse, None) + self.assertEqual(sn_doc.delivery_document_no, dn_2.name) + + # cannot cancel any intermediate document before last Delivery Note + self.assertRaises(frappe.ValidationError, se.cancel) + self.assertRaises(frappe.ValidationError, dn.cancel) + self.assertRaises(frappe.ValidationError, pr.cancel) + + def test_inter_company_transfer_fallback_on_cancel(self): + """ + Test Serial No state changes on cancellation. + If Delivery cancelled, it should fall back on last Receipt in the same company. + If Receipt is cancelled, it should be Inactive in the same company. + """ + # Receipt in **first** company + se = make_serialized_item(target_warehouse="_Test Warehouse - _TC") + serial_nos = get_serial_nos(se.get("items")[0].serial_no) + sn_doc = frappe.get_doc("Serial No", serial_nos[0]) + + # Delivery from first company + dn = create_delivery_note(item_code="_Test Serialized Item With Series", + qty=1, serial_no=serial_nos[0]) + + # Receipt in **second** company + wh = create_warehouse("_Test Warehouse", company="_Test Company 1") + pr = make_purchase_receipt(item_code="_Test Serialized Item With Series", + qty=1, serial_no=serial_nos[0], company="_Test Company 1", warehouse=wh) + + # Delivery from second company + dn_2 = create_delivery_note(item_code="_Test Serialized Item With Series", + qty=1, serial_no=serial_nos[0], company="_Test Company 1", warehouse=wh) + sn_doc.reload() + + self.assertEqual(sn_doc.status, "Delivered") + self.assertEqual(sn_doc.company, "_Test Company 1") + self.assertEqual(sn_doc.delivery_document_no, dn_2.name) + + dn_2.cancel() + sn_doc.reload() + # Fallback on Purchase Receipt if Delivery is cancelled + self.assertEqual(sn_doc.status, "Active") + self.assertEqual(sn_doc.company, "_Test Company 1") + self.assertEqual(sn_doc.warehouse, wh) + self.assertEqual(sn_doc.purchase_document_no, pr.name) + + pr.cancel() + sn_doc.reload() + # Inactive in same company if Receipt cancelled + self.assertEqual(sn_doc.status, "Inactive") + self.assertEqual(sn_doc.company, "_Test Company 1") + self.assertEqual(sn_doc.warehouse, None) + + dn.cancel() + sn_doc.reload() + # Fallback on Purchase Receipt in FIRST company if + # Delivery from FIRST company is cancelled + self.assertEqual(sn_doc.status, "Active") + self.assertEqual(sn_doc.company, "_Test Company") + self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC") + self.assertEqual(sn_doc.purchase_document_no, se.name) def tearDown(self): frappe.db.rollback() \ No newline at end of file diff --git a/erpnext/stock/doctype/shipment/shipment.js b/erpnext/stock/doctype/shipment/shipment.js index 7af16af898..ce2906ecbe 100644 --- a/erpnext/stock/doctype/shipment/shipment.js +++ b/erpnext/stock/doctype/shipment/shipment.js @@ -363,43 +363,6 @@ frappe.ui.form.on('Shipment', { 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"]; diff --git a/erpnext/stock/doctype/shipment/shipment.json b/erpnext/stock/doctype/shipment/shipment.json index 76c331c5c2..a33cbc288c 100644 --- a/erpnext/stock/doctype/shipment/shipment.json +++ b/erpnext/stock/doctype/shipment/shipment.json @@ -275,14 +275,16 @@ "default": "09:00", "fieldname": "pickup_from", "fieldtype": "Time", - "label": "Pickup from" + "label": "Pickup from", + "reqd": 1 }, { "allow_on_submit": 1, "default": "17:00", "fieldname": "pickup_to", "fieldtype": "Time", - "label": "Pickup to" + "label": "Pickup to", + "reqd": 1 }, { "fieldname": "column_break_36", @@ -431,7 +433,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2020-12-25 15:02:34.891976", + "modified": "2021-04-13 17:14:18.181818", "modified_by": "Administrator", "module": "Stock", "name": "Shipment", @@ -469,4 +471,4 @@ "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 index 4697a7b323..01fcee4cac 100644 --- a/erpnext/stock/doctype/shipment/shipment.py +++ b/erpnext/stock/doctype/shipment/shipment.py @@ -23,10 +23,10 @@ class Shipment(Document): frappe.throw(_('Please enter Shipment Parcel information')) if self.value_of_goods == 0: frappe.throw(_('Value of goods cannot be 0')) - self.status = 'Submitted' + self.db_set('status', 'Submitted') def on_cancel(self): - self.status = 'Cancelled' + self.db_set('status', 'Cancelled') def validate_weight(self): for parcel in self.shipment_parcel: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index af3c4e5aa0..ef7d54ac96 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -100,6 +100,13 @@ frappe.ui.form.on('Stock Entry', { frm.add_fetch("bom_no", "inspection_required", "inspection_required"); erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); + + frappe.db.get_single_value('Stock Settings', 'disable_serial_no_and_batch_selector') + .then((value) => { + if (value) { + frappe.flags.hide_serial_batch_dialog = true; + } + }); }, setup_quality_inspection: function(frm) { @@ -720,7 +727,7 @@ frappe.ui.form.on('Stock Entry Detail', { no_batch_serial_number_value = !d.batch_no; } - if (no_batch_serial_number_value) { + if (no_batch_serial_number_value && !frappe.flags.hide_serial_batch_dialog) { erpnext.stock.select_batch_and_serial_no(frm, d); } } diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index ea1b3873ea..f8ac400a8e 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -458,7 +458,7 @@ class StockEntry(StockController): 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) + 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 @@ -482,13 +482,13 @@ class StockEntry(StockController): 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): + 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) + rate = get_incoming_rate(args, raise_error_if_no_rate) if rate > 0: d.basic_rate = rate @@ -839,6 +839,7 @@ class StockEntry(StockController): if not pro_doc.operations: pro_doc.set_actual_dates() + @frappe.whitelist() def get_item_details(self, args=None, for_update=False): item = frappe.db.sql("""select i.name, i.stock_uom, i.description, i.image, i.item_name, i.item_group, i.has_batch_no, i.sample_quantity, i.has_serial_no, i.allow_alternative_item, @@ -913,6 +914,7 @@ class StockEntry(StockController): return ret + @frappe.whitelist() def set_items_for_stock_in(self): self.items = [] @@ -937,6 +939,7 @@ class StockEntry(StockController): 'batch_no': d.batch_no }) + @frappe.whitelist() def get_items(self): self.set('items', []) self.validate_work_order() @@ -1010,7 +1013,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"]: diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 123f0c8647..a0e70516d4 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -179,11 +179,15 @@ class TestStockEntry(unittest.TestCase): def test_material_transfer_gl_entry(self): company = frappe.db.get_value('Warehouse', 'Stores - TCP1', 'company') - mtn = make_stock_entry(item_code="_Test Item", source="Stores - TCP1", + item_code = 'Hand Sanitizer - 001' + create_item(item_code =item_code, is_stock_item = 1, + is_purchase_item=1, opening_stock=1000, valuation_rate=10, company=company, warehouse="Stores - TCP1") + + mtn = make_stock_entry(item_code=item_code, source="Stores - TCP1", 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]]) + [[item_code, "Stores - TCP1", -45.0], [item_code, "Finished Goods - TCP1", 45.0]]) source_warehouse_account = get_inventory_account(mtn.company, mtn.get("items")[0].s_warehouse) 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 ba01f70d1c..3296f5ba4a 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 @@ -312,29 +312,34 @@ class TestStockLedgerEntry(unittest.TestCase): "role_allowed_to_create_edit_back_dated_transactions", "Stock Manager") # Set User with Stock User role but not Stock Manager - frappe.set_user("test@example.com") - user = frappe.get_doc("User", "test@example.com") - user.add_roles("Stock User") - user.remove_roles("Stock Manager") + try: + user = frappe.get_doc("User", "test@example.com") + frappe.set_user(user.name) + 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) + 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) + # Block back-dated entry + self.assertRaises(BackDatedStockTransaction, back_dated_se_1.submit) - user.add_roles("Stock Manager") + frappe.set_user("Administrator") + user.add_roles("Stock Manager") + frappe.set_user(user.name) - # 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 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() + back_dated_se_2.cancel() + stock_entry_on_today.cancel() - frappe.db.set_value("Stock Settings", None, "role_allowed_to_create_edit_back_dated_transactions", None) - frappe.set_user("Administrator") + finally: + frappe.db.set_value("Stock Settings", None, "role_allowed_to_create_edit_back_dated_transactions", None) + frappe.set_user("Administrator") + user.remove_roles("Stock Manager") def create_repack_entry(**args): @@ -398,4 +403,4 @@ def create_items(): make_item(d, properties=properties) - return items \ No newline at end of file + return items diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index b452e96c5e..1396f19d3f 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -398,7 +398,7 @@ class StockReconciliation(StockController): merge_similar_entries = {} for d in sl_entries: - if not d.serial_no or d.actual_qty < 0: + if not d.serial_no or flt(d.get("actual_qty")) < 0: new_sl_entries.append(d) continue diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 6690c6a606..36380b838b 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -32,7 +32,7 @@ class TestStockReconciliation(unittest.TestCase): 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, "2012-12-26", "12:00"], [25, 900, "2012-12-26", "12:00"], @@ -86,7 +86,7 @@ class TestStockReconciliation(unittest.TestCase): se1.cancel() def test_get_items(self): - create_warehouse("_Test Warehouse 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", "company": "_Test Company"}) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 84af57b48d..f18eabc84b 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -13,6 +13,7 @@ "column_break_4", "valuation_method", "over_delivery_receipt_allowance", + "role_allowed_to_over_deliver_receive", "action_if_quality_inspection_is_not_submitted", "show_barcode_field", "clean_description_html", @@ -234,6 +235,13 @@ "fieldname": "disable_serial_no_and_batch_selector", "fieldtype": "Check", "label": "Disable Serial No And Batch Selector" + }, + { + "description": "Users with this role are allowed to over deliver/receive against orders above the allowance percentage", + "fieldname": "role_allowed_to_over_deliver_receive", + "fieldtype": "Link", + "label": "Role Allowed to Over Deliver/Receive", + "options": "Role" } ], "icon": "icon-cog", @@ -241,7 +249,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-01-18 13:15:38.352796", + "modified": "2021-03-11 18:48:14.513055", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/warehouse/warehouse.json b/erpnext/stock/doctype/warehouse/warehouse.json index bddb114c9d..9b9093261c 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.json +++ b/erpnext/stock/doctype/warehouse/warehouse.json @@ -70,6 +70,7 @@ "oldfieldname": "company", "oldfieldtype": "Link", "options": "Company", + "read_only_depends_on": "eval: !doc.__islocal", "remember_last_selected_value": 1, "reqd": 1, "search_index": 1 @@ -244,7 +245,7 @@ "idx": 1, "is_tree": 1, "links": [], - "modified": "2021-02-16 17:21:52.380098", + "modified": "2021-04-09 19:54:56.263965", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse", diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index e23f7d43d9..1a61f30b9a 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -309,8 +309,6 @@ def get_basic_details(args, item, overwrite_warehouse=True): "update_stock": args.get("update_stock") if args.get('doctype') in ['Sales Invoice', 'Purchase Invoice'] else 0, "delivered_by_supplier": item.delivered_by_supplier if args.get("doctype") in ["Sales Order", "Sales Invoice"] else 0, "is_fixed_asset": item.is_fixed_asset, - "weight_per_unit":item.weight_per_unit, - "weight_uom":item.weight_uom, "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"), @@ -611,8 +609,12 @@ def get_price_list_rate(args, item_doc, out): meta = frappe.get_meta(args.parenttype or args.doctype) if meta.get_field("currency") or args.get('currency'): - pl_details = get_price_list_currency_and_exchange_rate(args) - args.update(pl_details) + if not args.get("price_list_currency") or not args.get("plc_conversion_rate"): + # if currency and plc_conversion_rate exist then + # `get_price_list_currency_and_exchange_rate` has already been called + pl_details = get_price_list_currency_and_exchange_rate(args) + args.update(pl_details) + if meta.get_field("currency"): validate_conversion_rate(args, meta) @@ -922,10 +924,19 @@ def get_projected_qty(item_code, warehouse): {"item_code": item_code, "warehouse": warehouse}, "projected_qty")} @frappe.whitelist() -def get_bin_details(item_code, warehouse): - return frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, +def get_bin_details(item_code, warehouse, company=None): + bin_details = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, ["projected_qty", "actual_qty", "reserved_qty"], as_dict=True, cache=True) \ or {"projected_qty": 0, "actual_qty": 0, "reserved_qty": 0} + if company: + bin_details['company_total_stock'] = get_company_total_stock(item_code, company) + return bin_details + +def get_company_total_stock(item_code, company): + return frappe.db.sql("""SELECT sum(actual_qty) from + (`tabBin` INNER JOIN `tabWarehouse` ON `tabBin`.warehouse = `tabWarehouse`.name) + WHERE `tabWarehouse`.company = '{0}' and `tabBin`.item_code = '{1}'""" + .format(company, item_code))[0][0] @frappe.whitelist() def get_serial_no_details(item_code, warehouse, stock_qty, serial_no): @@ -993,6 +1004,8 @@ def apply_price_list(args, as_doc=False): args = process_args(args) parent = get_price_list_currency_and_exchange_rate(args) + args.update(parent) + children = [] if "items" in args: @@ -1057,7 +1070,7 @@ def get_price_list_currency_and_exchange_rate(args): return frappe._dict({ "price_list_currency": price_list_currency, "price_list_uom_dependant": price_list_uom_dependant, - "plc_conversion_rate": plc_conversion_rate + "plc_conversion_rate": plc_conversion_rate or 1 }) @frappe.whitelist() diff --git a/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py b/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py index 5df3fa8067..2f70523264 100644 --- a/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py +++ b/erpnext/stock/report/itemwise_recommended_reorder_level/itemwise_recommended_reorder_level.py @@ -55,19 +55,31 @@ def get_item_info(filters): def get_consumed_items(condition): + purpose_to_exclude = [ + "Material Transfer for Manufacture", + "Material Transfer", + "Send to Subcontractor" + ] + + condition += """ + and ( + purpose is NULL + or purpose not in ({}) + ) + """.format(', '.join([f"'{p}'" for p in purpose_to_exclude])) + condition = condition.replace("posting_date", "sle.posting_date") + consumed_items = frappe.db.sql(""" select item_code, abs(sum(actual_qty)) as consumed_qty - from `tabStock Ledger Entry` - where actual_qty < 0 + from `tabStock Ledger Entry` as sle left join `tabStock Entry` as se + on sle.voucher_no = se.name + where + actual_qty < 0 and voucher_type not in ('Delivery Note', 'Sales Invoice') %s - group by item_code - """ % condition, as_dict=1) - - consumed_items_map = {} - for item in consumed_items: - consumed_items_map.setdefault(item.item_code, item.consumed_qty) + group by item_code""" % condition, as_dict=1) + consumed_items_map = {item.item_code : item.consumed_qty for item in consumed_items} return consumed_items_map def get_delivered_items(condition): diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index ff603fcfb3..623dc2ffd9 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -49,7 +49,7 @@ def get_average_age(fifo_queue, to_date): for batch in fifo_queue: batch_age = date_diff(to_date, batch[1]) - if type(batch[0]) in ['int', 'float']: + if isinstance(batch[0], (int, float)): age_qty += batch_age * batch[0] total_qty += batch[0] else: @@ -302,4 +302,4 @@ def add_column(range_columns, label, fieldname, fieldtype='Float', width=140): fieldname=fieldname, fieldtype=fieldtype, width=width - )) \ No newline at end of file + )) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index df5f16fd41..985901fc44 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -604,7 +604,7 @@ class update_entries_after(object): batch = self.wh_data.stock_queue[index] if qty_to_pop >= batch[0]: # consume current batch - qty_to_pop = qty_to_pop - batch[0] + qty_to_pop = _round_off_if_near_zero(qty_to_pop - batch[0]) 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 @@ -618,8 +618,8 @@ 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.wh_data.stock_queue)) - stock_qty = sum((flt(batch[0]) for batch in self.wh_data.stock_queue)) + stock_value = _round_off_if_near_zero(sum((flt(batch[0]) * flt(batch[1]) for batch in self.wh_data.stock_queue))) + stock_qty = _round_off_if_near_zero(sum((flt(batch[0]) for batch in self.wh_data.stock_queue))) if stock_qty: self.wh_data.valuation_rate = stock_value / flt(stock_qty) @@ -858,3 +858,12 @@ def get_future_sle_with_negative_qty(args): order by timestamp(posting_date, posting_time) asc limit 1 """, args, as_dict=1) + +def _round_off_if_near_zero(number: float, precision: int = 6) -> float: + """ Rounds off the number to zero only if number is close to zero for decimal + specified in precision. Precision defaults to 6. + """ + if flt(number) < (1.0 / (10**precision)): + return 0 + + return flt(number) diff --git a/erpnext/support/doctype/issue/issue.js b/erpnext/support/doctype/issue/issue.js index 9fe12f9490..ecc9fcfe82 100644 --- a/erpnext/support/doctype/issue/issue.js +++ b/erpnext/support/doctype/issue/issue.js @@ -48,44 +48,62 @@ frappe.ui.form.on("Issue", { } }, - refresh: function (frm) { - if (frm.doc.status !== "Closed") { - if (frm.doc.service_level_agreement && frm.doc.agreement_status === "Ongoing") { - frappe.call({ - "method": "frappe.client.get", - args: { - doctype: "Service Level Agreement", - name: frm.doc.service_level_agreement - }, - callback: function(data) { - let statuses = data.message.pause_sla_on; - const hold_statuses = []; - $.each(statuses, (_i, entry) => { - hold_statuses.push(entry.status); - }); - if (hold_statuses.includes(frm.doc.status)) { - frm.dashboard.clear_headline(); - let message = {"indicator": "orange", "msg": __("SLA is on hold since {0}", [moment(frm.doc.on_hold_since).fromNow(true)])}; - frm.dashboard.set_headline_alert( - '
' + - '
' + - ''+ message.msg +' ' + - '
' + - '
' - ); - } else { - set_time_to_resolve_and_response(frm); - } - } - }); - } + refresh: function(frm) { - frm.add_custom_button(__("Close"), function () { + // alert messages + if (frm.doc.status !== "Closed" && frm.doc.service_level_agreement + && frm.doc.agreement_status === "Ongoing") { + frappe.call({ + "method": "frappe.client.get", + args: { + doctype: "Service Level Agreement", + name: frm.doc.service_level_agreement + }, + callback: function(data) { + let statuses = data.message.pause_sla_on; + const hold_statuses = []; + $.each(statuses, (_i, entry) => { + hold_statuses.push(entry.status); + }); + if (hold_statuses.includes(frm.doc.status)) { + frm.dashboard.clear_headline(); + let message = { "indicator": "orange", "msg": __("SLA is on hold since {0}", [moment(frm.doc.on_hold_since).fromNow(true)]) }; + frm.dashboard.set_headline_alert( + '
' + + '
' + + '' + message.msg + ' ' + + '
' + + '
' + ); + } else { + set_time_to_resolve_and_response(frm); + } + } + }); + } else if (frm.doc.service_level_agreement) { + frm.dashboard.clear_headline(); + + let agreement_status = (frm.doc.agreement_status == "Fulfilled") ? + { "indicator": "green", "msg": "Service Level Agreement has been fulfilled" } : + { "indicator": "red", "msg": "Service Level Agreement Failed" }; + + frm.dashboard.set_headline_alert( + '
' + + '
' + + ' ' + + '
' + + '
' + ); + } + + // buttons + if (frm.doc.status !== "Closed") { + frm.add_custom_button(__("Close"), function() { frm.set_value("status", "Closed"); frm.save(); }); - frm.add_custom_button(__("Task"), function () { + frm.add_custom_button(__("Task"), function() { frappe.model.open_mapped_doc({ method: "erpnext.support.doctype.issue.issue.make_task", frm: frm @@ -93,23 +111,7 @@ frappe.ui.form.on("Issue", { }, __("Create")); } else { - if (frm.doc.service_level_agreement) { - frm.dashboard.clear_headline(); - - let agreement_status = (frm.doc.agreement_status == "Fulfilled") ? - {"indicator": "green", "msg": "Service Level Agreement has been fulfilled"} : - {"indicator": "red", "msg": "Service Level Agreement Failed"}; - - frm.dashboard.set_headline_alert( - '
' + - '
' + - ' ' + - '
' + - '
' - ); - } - - frm.add_custom_button(__("Reopen"), function () { + frm.add_custom_button(__("Reopen"), function() { frm.set_value("status", "Open"); frm.save(); }); diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index bbbbc4a527..b068363f06 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 now_datetime, getdate, get_weekdays, add_to_date, get_time, get_datetime, time_diff_in_seconds +from frappe.utils import cint, 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 @@ -128,8 +128,8 @@ class Issue(Document): def update_agreement_status(self): if self.service_level_agreement and self.agreement_status == "Ongoing": - if frappe.db.get_value("Issue", self.name, "response_by_variance") < 0 or \ - frappe.db.get_value("Issue", self.name, "resolution_by_variance") < 0: + if cint(frappe.db.get_value("Issue", self.name, "response_by_variance")) < 0 or \ + cint(frappe.db.get_value("Issue", self.name, "resolution_by_variance")) < 0: self.agreement_status = "Failed" else: @@ -165,6 +165,7 @@ class Issue(Document): communication.ignore_mandatory = True communication.save() + @frappe.whitelist() def split_issue(self, subject, communication_id): # Bug: Pressing enter doesn't send subject from copy import deepcopy @@ -259,6 +260,7 @@ class Issue(Document): self.set_response_and_resolution_time(priority=self.priority, service_level_agreement=self.service_level_agreement) frappe.msgprint(_("Service Level Agreement has been changed to {0}.").format(self.service_level_agreement)) + @frappe.whitelist() def reset_service_level_agreement(self, reason, user): if not frappe.db.get_single_value("Support Settings", "allow_resetting_service_level_agreement"): frappe.throw(_("Allow Resetting Service Level Agreement from Support Settings.")) diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index 483bb155db..46d02d8bf2 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() diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js index 5346195a39..00060b9530 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js @@ -10,7 +10,9 @@ frappe.ui.form.on('Service Level Agreement', { let statuses = frappe.meta.get_docfield('Issue', 'status', frm.doc.name).options; statuses = statuses.split('\n'); allow_statuses = statuses.filter((status) => !exclude_statuses.includes(status)); - frappe.meta.get_docfield('Pause SLA On Status', 'status', frm.doc.name).options = [''].concat(allow_statuses); + frm.fields_dict.pause_sla_on.grid.update_docfield_property( + 'status', 'options', [''].concat(allow_statuses) + ); }); } -}); \ No newline at end of file +}); diff --git a/erpnext/support/report/issue_analytics/issue_analytics.js b/erpnext/support/report/issue_analytics/issue_analytics.js index f87b2c2ddd..746eee025a 100644 --- a/erpnext/support/report/issue_analytics/issue_analytics.js +++ b/erpnext/support/report/issue_analytics/issue_analytics.js @@ -52,6 +52,7 @@ frappe.query_reports["Issue Analytics"] = { label: __("Status"), fieldtype: "Select", options:[ + "", {label: __('Open'), value: 'Open'}, {label: __('Replied'), value: 'Replied'}, {label: __('Resolved'), value: 'Resolved'}, @@ -138,4 +139,4 @@ frappe.query_reports["Issue Analytics"] = { } }); } -}; \ No newline at end of file +}; diff --git a/erpnext/support/report/issue_summary/issue_summary.js b/erpnext/support/report/issue_summary/issue_summary.js index 684482ac8d..eb0e06cd08 100644 --- a/erpnext/support/report/issue_summary/issue_summary.js +++ b/erpnext/support/report/issue_summary/issue_summary.js @@ -39,6 +39,7 @@ frappe.query_reports["Issue Summary"] = { label: __("Status"), fieldtype: "Select", options:[ + "", {label: __('Open'), value: 'Open'}, {label: __('Replied'), value: 'Replied'}, {label: __('Resolved'), value: 'Resolved'}, @@ -70,4 +71,4 @@ frappe.query_reports["Issue Summary"] = { options: "User" } ] -}; \ No newline at end of file +}; diff --git a/erpnext/utilities/activation.py b/erpnext/utilities/activation.py index 7b17c8c464..50c4b255ce 100644 --- a/erpnext/utilities/activation.py +++ b/erpnext/utilities/activation.py @@ -18,7 +18,6 @@ def get_level(): "Delivery Note": 5, "Employee": 3, "Instructor": 5, - "Instructor": 5, "Issue": 5, "Item": 5, "Journal Entry": 3, diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index b575855aed..f99da58e46 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -120,11 +120,11 @@ class TransactionBase(StatusUpdater): buying_doctypes = ["Purchase Order", "Purchase Invoice", "Purchase Receipt"] if self.doctype in buying_doctypes: - to_disable = "Maintain same rate throughout Purchase cycle" - settings_page = "Buying Settings" + action = frappe.db.get_single_value("Buying Settings", "maintain_same_rate_action") + settings_doc = "Buying Settings" else: - to_disable = "Maintain same rate throughout Sales cycle" - settings_page = "Selling Settings" + action = frappe.db.get_single_value("Selling Settings", "maintain_same_rate_action") + settings_doc = "Selling Settings" for ref_dt, ref_dn_field, ref_link_field in ref_details: for d in self.get("items"): @@ -132,11 +132,16 @@ class TransactionBase(StatusUpdater): ref_rate = frappe.db.get_value(ref_dt + " Item", d.get(ref_link_field), "rate") if abs(flt(d.rate - ref_rate, d.precision("rate"))) >= .01: - frappe.msgprint(_("Row #{0}: Rate must be same as {1}: {2} ({3} / {4}) ") - .format(d.idx, ref_dt, d.get(ref_dn_field), d.rate, ref_rate)) - frappe.throw(_("To allow different rates, disable the {0} checkbox in {1}.") - .format(frappe.bold(_(to_disable)), - get_link_to_form(settings_page, settings_page, frappe.bold(settings_page)))) + if action == "Stop": + role_allowed_to_override = frappe.db.get_single_value(settings_doc, 'role_to_override_stop_action') + + if role_allowed_to_override not in frappe.get_roles(): + frappe.throw(_("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( + d.idx, ref_dt, d.get(ref_dn_field), d.rate, ref_rate)) + else: + frappe.msgprint(_("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format( + d.idx, ref_dt, d.get(ref_dn_field), d.rate, ref_rate), title=_("Warning"), indicator="orange") + def get_link_filters(self, for_doctype): if hasattr(self, "prev_link_mapper") and self.prev_link_mapper.get(for_doctype): diff --git a/erpnext/www/lms/content.html b/erpnext/www/lms/content.html index dc9b6d80fb..15afb097b9 100644 --- a/erpnext/www/lms/content.html +++ b/erpnext/www/lms/content.html @@ -62,7 +62,7 @@ {{_('Back to Course')}} -
+

{{ content.name }} ({{ position + 1 }}/{{length}})

{% endmacro %} @@ -169,14 +169,51 @@ const next_url = '/lms/course?name={{ course }}&program={{ program }}' {% endif %} frappe.ready(() => { - const quiz = new Quiz(document.getElementById('quiz-wrapper'), { - name: '{{ content.name }}', - course: '{{ course }}', - program: '{{ program }}', - quiz_exit_button: quiz_exit_button, - next_url: next_url - }) - window.quiz = quiz; + {% if content.is_time_bound %} + var duration = get_duration("{{content.duration}}") + var d = frappe.msgprint({ + title: __('Important Notice'), + indicator: "red", + message: __(`This is a Time-Bound Quiz.

+ A timer for ${duration} will start, once you click on Proceed.

+ If you fail to submit before the time is up, the Quiz will be submitted automatically.`), + primary_action: { + label: __("Proceed"), + action: () => { + create_quiz(); + d.hide(); + } + }, + secondary_action: { + action: () => { + d.hide(); + window.location.href = "/lms/course?name={{ course }}&program={{ program }}"; + }, + label: __("Go Back"), + } + }); + {% else %} + create_quiz(); + {% endif %} + function create_quiz() { + const quiz = new Quiz(document.getElementById('quiz-wrapper'), { + name: '{{ content.name }}', + course: '{{ course }}', + program: '{{ program }}', + quiz_exit_button: quiz_exit_button, + next_url: next_url + }) + window.quiz = quiz; + } + function get_duration(seconds){ + var hours = append_zero(Math.floor(seconds / 3600)); + var minutes = append_zero(Math.floor(seconds % 3600 / 60)); + var seconds = append_zero(Math.floor(seconds % 3600 % 60)); + return `${hours}:${minutes}:${seconds}`; + } + function append_zero(time) { + return time > 9 ? time : "0" + time; + } }) {% endif %} diff --git a/erpnext/www/lms/index.html b/erpnext/www/lms/index.html index 7b239acd56..c1e96205eb 100644 --- a/erpnext/www/lms/index.html +++ b/erpnext/www/lms/index.html @@ -42,7 +42,9 @@

{{ education_settings.portal_title }}

-

{{ education_settings.description }}

+ {% if education_settings.description %} +

{{ education_settings.description }}

+ {% endif %}

{% if frappe.session.user == 'Guest' %} {{_('Sign Up')}} @@ -51,13 +53,15 @@

- {% for program in featured_programs %} - {{ program_card(program.program, program.has_access) }} - {% endfor %} {% if featured_programs %} + {% for program in featured_programs %} + {{ program_card(program.program, program.has_access) }} + {% endfor %} {% for n in range( (3 - (featured_programs|length)) %3) %} {{ null_card() }} {% endfor %} + {% else %} +

You have not enrolled in any program. Contact your Instructor.

{% endif %}
diff --git a/erpnext/www/lms/topic.py b/erpnext/www/lms/topic.py index f75ae8e9b6..8abbc72e91 100644 --- a/erpnext/www/lms/topic.py +++ b/erpnext/www/lms/topic.py @@ -35,7 +35,7 @@ def get_contents(topic, course, program): progress.append({'content': content, 'content_type': content.doctype, 'completed': status}) elif content.doctype == 'Quiz': if student: - status, score, result = utils.check_quiz_completion(content, course_enrollment.name) + status, score, result, time_taken = utils.check_quiz_completion(content, course_enrollment.name) else: status = False score = None