diff --git a/.github/helper/.flake8_strict b/.github/helper/.flake8_strict index 4c7f5f82cfb6..a79137d7c324 100644 --- a/.github/helper/.flake8_strict +++ b/.github/helper/.flake8_strict @@ -1,6 +1,8 @@ [flake8] ignore = B007, + B009, + B010, B950, E101, E111, diff --git a/.github/helper/semgrep_rules/README.md b/.github/helper/semgrep_rules/README.md deleted file mode 100644 index 670d8d280f23..000000000000 --- a/.github/helper/semgrep_rules/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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.yml b/.github/helper/semgrep_rules/frappe_correctness.yml index d9603e89aa44..166e98a8a298 100644 --- a/.github/helper/semgrep_rules/frappe_correctness.yml +++ b/.github/helper/semgrep_rules/frappe_correctness.yml @@ -131,3 +131,21 @@ rules: key `$X` is uselessly assigned twice. This could be a potential bug. languages: [python] severity: ERROR + + +- id: frappe-manual-commit + patterns: + - pattern: frappe.db.commit() + - pattern-not-inside: | + try: + ... + except ...: + ... + message: | + Manually commiting a transaction is highly discouraged. Read about the transaction model implemented by Frappe Framework before adding manual commits: https://frappeframework.com/docs/user/en/api/database#database-transaction-model If you think manual commit is required then add a comment explaining why and `// nosemgrep` on the same line. + paths: + exclude: + - "**/patches/**" + - "**/demo/**" + languages: [python] + severity: ERROR diff --git a/.github/helper/semgrep_rules/report.yml b/.github/helper/semgrep_rules/report.yml deleted file mode 100644 index f2a9b167399c..000000000000 --- a/.github/helper/semgrep_rules/report.yml +++ /dev/null @@ -1,34 +0,0 @@ -rules: -- id: frappe-missing-translate-function-in-report-python - paths: - include: - - "**/report" - exclude: - - "**/regional" - pattern-either: - - patterns: - - pattern: | - {..., "label": "...", ...} - - pattern-not: | - {..., "label": _("..."), ...} - - patterns: - - pattern: dict(..., label="...", ...) - - pattern-not: dict(..., label=_("..."), ...) - 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] - severity: ERROR - -- id: frappe-translated-values-in-business-logic - paths: - include: - - "**/report" - patterns: - - pattern-inside: | - {..., filters: [...], ...} - - pattern: | - {..., options: [..., __("..."), ...], ...} - message: | - Using translated values in options field will require you to translate the values while comparing in business logic. Instead of passing translated labels provide objects that contain both label and value. e.g. { label: __("Option value"), value: "Option value"} - languages: [javascript] - severity: ERROR diff --git a/.github/helper/semgrep_rules/security.py b/.github/helper/semgrep_rules/security.py deleted file mode 100644 index f477d7c17680..000000000000 --- a/.github/helper/semgrep_rules/security.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 8b2197920807..000000000000 --- a/.github/helper/semgrep_rules/security.yml +++ /dev/null @@ -1,10 +0,0 @@ -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 diff --git a/.github/helper/semgrep_rules/translate.js b/.github/helper/semgrep_rules/translate.js deleted file mode 100644 index 9cdfb75d0be6..000000000000 --- a/.github/helper/semgrep_rules/translate.js +++ /dev/null @@ -1,44 +0,0 @@ -// 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]) - -// ok: frappe-translation-js-splitting -__("Ctrl+Enter to add comment") - -// 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 deleted file mode 100644 index 9de6aa94f011..000000000000 --- a/.github/helper/semgrep_rules/translate.py +++ /dev/null @@ -1,61 +0,0 @@ -# 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 -_('') - - -class Test: - # ok: frappe-translation-python-splitting - def __init__( - args - ): - pass diff --git a/.github/helper/semgrep_rules/translate.yml b/.github/helper/semgrep_rules/translate.yml deleted file mode 100644 index 5f03fb9fd000..000000000000 --- a/.github/helper/semgrep_rules/translate.yml +++ /dev/null @@ -1,64 +0,0 @@ -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\.]_\([^\)]*\\\s*' # lines broken by `\` - - pattern-regex: '[\s\.]_\(\s*\n' # line breaks allowed by python for using ( ) - 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.js b/.github/helper/semgrep_rules/ux.js deleted file mode 100644 index ae73f9cc6036..000000000000 --- a/.github/helper/semgrep_rules/ux.js +++ /dev/null @@ -1,9 +0,0 @@ - -// ok: frappe-missing-translate-function-js -frappe.msgprint('{{ _("Both login and password required") }}'); - -// ruleid: frappe-missing-translate-function-js -frappe.msgprint('What'); - -// ok: frappe-missing-translate-function-js -frappe.throw(' {{ _("Both login and password required") }}. '); diff --git a/.github/helper/semgrep_rules/ux.yml b/.github/helper/semgrep_rules/ux.yml deleted file mode 100644 index dd667f36c0fd..000000000000 --- a/.github/helper/semgrep_rules/ux.yml +++ /dev/null @@ -1,30 +0,0 @@ -rules: -- id: frappe-missing-translate-function-python - pattern-either: - - patterns: - - pattern: frappe.msgprint("...", ...) - - pattern-not: frappe.msgprint(_("..."), ...) - - patterns: - - pattern: 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] - severity: ERROR - -- id: frappe-missing-translate-function-js - pattern-either: - - patterns: - - pattern: frappe.msgprint("...", ...) - - pattern-not: frappe.msgprint(__("..."), ...) - # ignore microtemplating e.g. msgprint("{{ _("server side translation") }}") - - pattern-not: frappe.msgprint("=~/\{\{.*\_.*\}\}/i", ...) - - patterns: - - pattern: frappe.throw("...", ...) - - pattern-not: frappe.throw(__("..."), ...) - # ignore microtemplating - - pattern-not: frappe.throw("=~/\{\{.*\_.*\}\}/i", ...) - 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: [javascript] - severity: ERROR diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 4b1147e79f97..5b607a994061 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -11,4 +11,4 @@ jobs: - name: curl run: | apk add curl bash - curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "Travis-API-Version: 3" -H "Authorization: token ${{ secrets.TRAVIS_CI_TOKEN }}" -d '{"request":{"branch":"master"}}' https://api.travis-ci.com/repo/frappe%2Ffrappe_docker/requests + curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/build_stable.yml/dispatches -d '{"ref":"main"}' diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index c2363397c474..ebb88c9edac7 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -10,13 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: returntocorp/semgrep-action@v1 - env: - SEMGREP_TIMEOUT: 120 - with: - config: >- - r/python.lang.correctness - .github/helper/semgrep_rules - name: Set up Python 3.8 uses: actions/setup-python@v2 @@ -24,4 +17,15 @@ jobs: python-version: 3.8 - name: Install and Run Pre-commit - uses: pre-commit/action@v2.0.0 + uses: pre-commit/action@v2.0.3 + + - name: Download Semgrep rules + run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules + + - uses: returntocorp/semgrep-action@v1 + env: + SEMGREP_TIMEOUT: 120 + with: + config: >- + r/python.lang.correctness + ./frappe-semgrep-rules/rules diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index cc3c5ceb7d1f..90b5f28e9b77 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -99,6 +99,8 @@ jobs: - name: Build Assets run: cd ~/frappe-bench/ && bench build + env: + CI: Yes - name: UI Tests run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests erpnext --headless diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 000000000000..f3d04096cfc8 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,58 @@ +pull_request_rules: + - name: Auto-close PRs on stable branch + conditions: + - and: + - and: + - author!=surajshetty3416 + - author!=gavindsouza + - author!=rohitwaghchaure + - author!=nabinhait + - or: + - base=version-13 + - base=version-12 + actions: + close: + comment: + message: | + @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch. + https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch + + - name: backport to version-13-hotfix + conditions: + - label="backport version-13-hotfix" + actions: + backport: + branches: + - version-13-hotfix + assignees: + - "{{ author }}" + + - name: backport to version-13-pre-release + conditions: + - label="backport version-13-pre-release" + actions: + backport: + branches: + - version-13-pre-release + assignees: + - "{{ author }}" + + - name: backport to version-12-hotfix + conditions: + - label="backport version-12-hotfix" + actions: + backport: + branches: + - version-12-hotfix + assignees: + - "{{ author }}" + + - name: backport to version-12-pre-release + conditions: + - label="backport version-12-pre-release" + actions: + backport: + branches: + - version-12-pre-release + assignees: + - "{{ author }}" \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2b3a471f7745..b74d9a640da1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,9 @@ repos: rev: 3.9.2 hooks: - id: flake8 + additional_dependencies: [ + 'flake8-bugbear', + ] args: ['--config', '.github/helper/.flake8_strict'] exclude: ".*setup.py$" diff --git a/cypress/integration/test_organizational_chart_desktop.js b/cypress/integration/test_organizational_chart_desktop.js index 79e08b3bbad8..464cce48d031 100644 --- a/cypress/integration/test_organizational_chart_desktop.js +++ b/cypress/integration/test_organizational_chart_desktop.js @@ -24,7 +24,7 @@ context('Organizational Chart', () => { cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); cy.get('@input') .clear({ force: true }) - .type('Test Org Chart{enter}', { force: true }) + .type('Test Org Chart{downarrow}{enter}', { force: true }) .blur({ force: true }); }); }); diff --git a/cypress/integration/test_organizational_chart_mobile.js b/cypress/integration/test_organizational_chart_mobile.js index 161fae098a29..971ac6d3ef3b 100644 --- a/cypress/integration/test_organizational_chart_mobile.js +++ b/cypress/integration/test_organizational_chart_mobile.js @@ -25,7 +25,7 @@ context('Organizational Chart Mobile', () => { cy.get('.frappe-control[data-fieldname=company] input').focus().as('input'); cy.get('@input') .clear({ force: true }) - .type('Test Org Chart{enter}', { force: true }) + .type('Test Org Chart{downarrow}{enter}', { force: true }) .blur({ force: true }); }); }); diff --git a/erpnext/__init__.py b/erpnext/__init__.py index e4ba67017e6e..3a2e3fcdb41e 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -7,7 +7,7 @@ from erpnext.hooks import regional_overrides -__version__ = '13.12.1' +__version__ = '13.14.1' 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 f6198eb23baa..605262f7b3ec 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -8,6 +8,8 @@ from frappe.utils import cint, cstr from frappe.utils.nestedset import NestedSet, get_ancestors_of, get_descendants_of +import erpnext + class RootNotEditable(frappe.ValidationError): pass class BalanceMismatchError(frappe.ValidationError): pass @@ -196,7 +198,7 @@ def create_account_for_child_company(self, parent_acc_name_map, descendants, par "company": company, # parent account's currency should be passed down to child account's curreny # if it is None, it picks it up from default company currency, which might be unintended - "account_currency": self.account_currency, + "account_currency": erpnext.get_company_currency(company), "parent_account": parent_acc_name_map[company] }) @@ -207,8 +209,7 @@ def create_account_for_child_company(self, parent_acc_name_map, descendants, par # update the parent company's value in child companies doc = frappe.get_doc("Account", child_account) parent_value_changed = False - for field in ['account_type', 'account_currency', - 'freeze_account', 'balance_must_be']: + for field in ['account_type', 'freeze_account', 'balance_must_be']: if doc.get(field) != self.get(field): parent_value_changed = True doc.set(field, self.get(field)) diff --git a/erpnext/accounts/doctype/account/account_tree.js b/erpnext/accounts/doctype/account/account_tree.js index 7516134baf54..a4b6e0b45ae3 100644 --- a/erpnext/accounts/doctype/account/account_tree.js +++ b/erpnext/accounts/doctype/account/account_tree.js @@ -45,6 +45,49 @@ frappe.treeview_settings["Account"] = { ], root_label: "Accounts", get_tree_nodes: 'erpnext.accounts.utils.get_children', + on_get_node: function(nodes, deep=false) { + if (frappe.boot.user.can_read.indexOf("GL Entry") == -1) return; + + let accounts = []; + if (deep) { + // in case of `get_all_nodes` + accounts = nodes.reduce((acc, node) => [...acc, ...node.data], []); + } else { + accounts = nodes; + } + + const get_balances = frappe.call({ + method: 'erpnext.accounts.utils.get_account_balances', + args: { + accounts: accounts, + company: cur_tree.args.company + }, + }); + + get_balances.then(r => { + if (!r.message || r.message.length == 0) return; + + for (let account of r.message) { + + const node = cur_tree.nodes && cur_tree.nodes[account.value]; + if (!node || node.is_root) continue; + + // show Dr if positive since balance is calculated as debit - credit else show Cr + const balance = account.balance_in_account_currency || account.balance; + const dr_or_cr = balance > 0 ? "Dr": "Cr"; + const format = (value, currency) => format_currency(Math.abs(value), currency); + + if (account.balance!==undefined) { + $('' + + (account.balance_in_account_currency ? + (format(account.balance_in_account_currency, account.account_currency) + " / ") : "") + + format(account.balance, account.company_currency) + + " " + dr_or_cr + + '').insertBefore(node.$ul); + } + } + }); + }, add_tree_node: 'erpnext.accounts.utils.add_ac', menu_items:[ { @@ -122,24 +165,6 @@ frappe.treeview_settings["Account"] = { } }, "add"); }, - onrender: function(node) { - if (frappe.boot.user.can_read.indexOf("GL Entry") !== -1) { - - // show Dr if positive since balance is calculated as debit - credit else show Cr - let balance = node.data.balance_in_account_currency || node.data.balance; - let dr_or_cr = balance > 0 ? "Dr": "Cr"; - - if (node.data && node.data.balance!==undefined) { - $('' - + (node.data.balance_in_account_currency ? - (format_currency(Math.abs(node.data.balance_in_account_currency), - node.data.account_currency) + " / ") : "") - + format_currency(Math.abs(node.data.balance), node.data.company_currency) - + " " + dr_or_cr - + '').insertBefore(node.$ul); - } - } - }, toolbar: [ { label:__("Add Child"), diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py b/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py index dc9f28810049..18c2818e2151 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py +++ b/erpnext/accounts/doctype/account/chart_of_accounts/chart_of_accounts.py @@ -15,7 +15,7 @@ "is_group", "tax_rate", "account_currency"] -def create_charts(company, chart_template=None, existing_company=None, custom_chart=None): +def create_charts(company, chart_template=None, existing_company=None, custom_chart=None, from_coa_importer=None): chart = custom_chart or get_chart(chart_template, existing_company) if not chart: return @@ -39,7 +39,7 @@ def _import_accounts(children, parent, root_type, root_account=False): account = frappe.get_doc({ "doctype": "Account", - "account_name": account_name, + "account_name": child.get('account_name') if from_coa_importer else account_name, "company": company, "parent_account": parent, "is_group": child.get("is_group"), @@ -204,7 +204,7 @@ def _get_account_names(account_master): return (bank_account in accounts) @frappe.whitelist() -def build_tree_from_json(chart_template, chart_data=None): +def build_tree_from_json(chart_template, chart_data=None, from_coa_importer=False): ''' get chart template from its folder and parse the json to be rendered as tree ''' chart = chart_data or get_chart(chart_template) @@ -221,6 +221,9 @@ def _import_accounts(children, parent): if account_name in COA_FIELD_KEYS: continue + if from_coa_importer: + account_name = child['account_name'] + account['parent_account'] = parent account['expandable'] = child.get("is_group") account['value'] = (cstr(child.get('account_number')).strip() + ' - ' + account_name) \ diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 935e29a9d33e..baab628b210d 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -175,7 +175,7 @@ "default": "0", "fieldname": "automatically_fetch_payment_terms", "fieldtype": "Check", - "label": "Automatically Fetch Payment Terms" + "label": "Automatically Fetch Payment Terms from Order" }, { "description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ", @@ -283,7 +283,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-08-19 11:17:38.788054", + "modified": "2021-10-11 17:42:36.427699", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", 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 497fb7baf2e6..b6701dd0b47b 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 @@ -68,7 +68,7 @@ def import_coa(file_name, company): frappe.local.flags.ignore_root_company_validation = True forest = build_forest(data) - create_charts(company, custom_chart=forest) + create_charts(company, custom_chart=forest, from_coa_importer=True) # trigger on_update for company to reset default accounts set_default_accounts(company) @@ -147,7 +147,7 @@ def get_coa(doctype, parent, is_root=False, file_name=None, for_validate=0): if not for_validate: forest = build_forest(data) - accounts = build_tree_from_json("", chart_data=forest) # returns a list of dict in a tree render-able form + accounts = build_tree_from_json("", chart_data=forest, from_coa_importer=True) # returns a list of dict in a tree render-able form # filter out to show data for the selected node only accounts = [d for d in accounts if d['parent_account']==parent] @@ -213,10 +213,13 @@ def return_parent(data, child): if not account_name: error_messages.append("Row {0}: Please enter Account Name".format(line_no)) + name = account_name if account_number: account_number = cstr(account_number).strip() account_name = "{} - {}".format(account_number, account_name) + charts_map[account_name] = {} + charts_map[account_name]["account_name"] = name charts_map[account_name]["is_group"] = cint(is_group) if account_type: charts_map[account_name]["account_type"] = account_type diff --git a/erpnext/accounts/doctype/loyalty_point_entry/loyalty_point_entry.py b/erpnext/accounts/doctype/loyalty_point_entry/loyalty_point_entry.py index 0813926f5f29..003389e0b513 100644 --- a/erpnext/accounts/doctype/loyalty_point_entry/loyalty_point_entry.py +++ b/erpnext/accounts/doctype/loyalty_point_entry/loyalty_point_entry.py @@ -16,7 +16,7 @@ class LoyaltyPointEntry(Document): def get_loyalty_point_entries(customer, loyalty_program, company, expiry_date=None): if not expiry_date: - date = today() + expiry_date = today() return frappe.db.sql(''' select name, loyalty_points, expiry_date, loyalty_program_tier, invoice_type, invoice diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index 6f362c1fbb97..ee2e319a6fca 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -27,10 +27,12 @@ "payment_accounts_section", "party_balance", "paid_from", + "paid_from_account_type", "paid_from_account_currency", "paid_from_account_balance", "column_break_18", "paid_to", + "paid_to_account_type", "paid_to_account_currency", "paid_to_account_balance", "payment_amounts_section", @@ -440,7 +442,8 @@ "depends_on": "eval:(doc.paid_from && doc.paid_to)", "fieldname": "reference_no", "fieldtype": "Data", - "label": "Cheque/Reference No" + "label": "Cheque/Reference No", + "mandatory_depends_on": "eval:(doc.paid_from_account_type == 'Bank' || doc.paid_to_account_type == 'Bank')" }, { "fieldname": "column_break_23", @@ -452,6 +455,7 @@ "fieldname": "reference_date", "fieldtype": "Date", "label": "Cheque/Reference Date", + "mandatory_depends_on": "eval:(doc.paid_from_account_type == 'Bank' || doc.paid_to_account_type == 'Bank')", "search_index": 1 }, { @@ -707,15 +711,30 @@ "label": "Received Amount After Tax (Company Currency)", "options": "Company:company:default_currency", "read_only": 1 + }, + { + "fetch_from": "paid_from.account_type", + "fieldname": "paid_from_account_type", + "fieldtype": "Data", + "hidden": 1, + "label": "Paid From Account Type" + }, + { + "fetch_from": "paid_to.account_type", + "fieldname": "paid_to_account_type", + "fieldtype": "Data", + "hidden": 1, + "label": "Paid To Account Type" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-07-09 08:58:15.008761", + "modified": "2021-10-22 17:50:24.632806", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 607f85c87bd5..8bbe3db914fb 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -389,7 +389,10 @@ def update_payment_schedule(self, cancel=0): invoice_paid_amount_map[invoice_key]['outstanding'] = term.outstanding invoice_paid_amount_map[invoice_key]['discounted_amt'] = ref.total_amount * (term.discount / 100) - for key, allocated_amount in iteritems(invoice_payment_amount_map): + for idx, (key, allocated_amount) in enumerate(iteritems(invoice_payment_amount_map), 1): + if not invoice_paid_amount_map.get(key): + frappe.throw(_('Payment term {0} not used in {1}').format(key[0], key[1])) + outstanding = flt(invoice_paid_amount_map.get(key, {}).get('outstanding')) discounted_amt = flt(invoice_paid_amount_map.get(key, {}).get('discounted_amt')) @@ -404,7 +407,7 @@ def update_payment_schedule(self, cancel=0): (allocated_amount - discounted_amt, discounted_amt, allocated_amount, key[1], key[0])) else: if allocated_amount > outstanding: - frappe.throw(_('Cannot allocate more than {0} against payment term {1}').format(outstanding, key[0])) + frappe.throw(_('Row #{0}: Cannot allocate more than {1} against payment term {2}').format(idx, outstanding, key[0])) if allocated_amount and outstanding: frappe.db.sql(""" @@ -502,12 +505,13 @@ def validate_amounts(self): def validate_received_amount(self): if self.paid_from_account_currency == self.paid_to_account_currency: - if self.paid_amount != self.received_amount: - frappe.throw(_("Received Amount should be same as Paid Amount")) + if self.paid_amount < self.received_amount: + frappe.throw(_("Received Amount cannot be greater than Paid Amount")) def set_received_amount(self): self.base_received_amount = self.base_paid_amount - if self.paid_from_account_currency == self.paid_to_account_currency: + if self.paid_from_account_currency == self.paid_to_account_currency \ + and not self.payment_type == 'Internal Transfer': self.received_amount = self.paid_amount def set_amounts_after_tax(self): @@ -709,10 +713,14 @@ def add_party_gl_entries(self, gl_entries): dr_or_cr = "credit" if erpnext.get_party_account_type(self.party_type) == 'Receivable' else "debit" for d in self.get("references"): + cost_center = self.cost_center + if d.reference_doctype == "Sales Invoice" and not cost_center: + cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center") gle = party_gl_dict.copy() gle.update({ "against_voucher_type": d.reference_doctype, - "against_voucher": d.reference_name + "against_voucher": d.reference_name, + "cost_center": cost_center }) allocated_amount_in_company_currency = flt(flt(d.allocated_amount) * flt(d.exchange_rate), @@ -1045,12 +1053,6 @@ def get_outstanding_reference_documents(args): party_account_currency = get_account_currency(args.get("party_account")) company_currency = frappe.get_cached_value('Company', args.get("company"), "default_currency") - # Get negative outstanding sales /purchase invoices - negative_outstanding_invoices = [] - if args.get("party_type") not in ["Student", "Employee"] and not args.get("voucher_no"): - negative_outstanding_invoices = get_negative_outstanding_invoices(args.get("party_type"), args.get("party"), - args.get("party_account"), args.get("company"), party_account_currency, company_currency) - # Get positive outstanding sales /purchase invoices/ Fees condition = "" if args.get("voucher_type") and args.get("voucher_no"): @@ -1097,6 +1099,12 @@ def get_outstanding_reference_documents(args): orders_to_be_billed = get_orders_to_be_billed(args.get("posting_date"),args.get("party_type"), args.get("party"), args.get("company"), party_account_currency, company_currency, filters=args) + # Get negative outstanding sales /purchase invoices + negative_outstanding_invoices = [] + if args.get("party_type") not in ["Student", "Employee"] and not args.get("voucher_no"): + negative_outstanding_invoices = get_negative_outstanding_invoices(args.get("party_type"), args.get("party"), + args.get("party_account"), party_account_currency, company_currency, condition=condition) + data = negative_outstanding_invoices + outstanding_invoices + orders_to_be_billed if not data: @@ -1129,22 +1137,26 @@ def split_invoices_based_on_payment_terms(outstanding_invoices): 'invoice_amount': flt(d.invoice_amount), 'outstanding_amount': flt(d.outstanding_amount), 'payment_amount': payment_term.payment_amount, - 'payment_term': payment_term.payment_term, - 'allocated_amount': payment_term.outstanding + 'payment_term': payment_term.payment_term })) + outstanding_invoices_after_split = [] if invoice_ref_based_on_payment_terms: for idx, ref in invoice_ref_based_on_payment_terms.items(): - voucher_no = outstanding_invoices[idx]['voucher_no'] - voucher_type = outstanding_invoices[idx]['voucher_type'] + voucher_no = ref[0]['voucher_no'] + voucher_type = ref[0]['voucher_type'] - frappe.msgprint(_("Spliting {} {} into {} rows as per payment terms").format( + frappe.msgprint(_("Spliting {} {} into {} row(s) as per Payment Terms").format( voucher_type, voucher_no, len(ref)), alert=True) - outstanding_invoices.pop(idx - 1) - outstanding_invoices += invoice_ref_based_on_payment_terms[idx] + outstanding_invoices_after_split += invoice_ref_based_on_payment_terms[idx] + + existing_row = list(filter(lambda x: x.get('voucher_no') == voucher_no, outstanding_invoices)) + index = outstanding_invoices.index(existing_row[0]) + outstanding_invoices.pop(index) - return outstanding_invoices + outstanding_invoices_after_split += outstanding_invoices + return outstanding_invoices_after_split def get_orders_to_be_billed(posting_date, party_type, party, company, party_account_currency, company_currency, cost_center=None, filters=None): @@ -1211,7 +1223,7 @@ def get_orders_to_be_billed(posting_date, party_type, party, return order_list def get_negative_outstanding_invoices(party_type, party, party_account, - company, party_account_currency, company_currency, cost_center=None): + party_account_currency, company_currency, cost_center=None, condition=None): voucher_type = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice" supplier_condition = "" if voucher_type == "Purchase Invoice": @@ -1233,19 +1245,21 @@ def get_negative_outstanding_invoices(party_type, party, party_account, `tab{voucher_type}` where {party_type} = %s and {party_account} = %s and docstatus = 1 and - company = %s and outstanding_amount < 0 + outstanding_amount < 0 {supplier_condition} + {condition} order by posting_date, name """.format(**{ "supplier_condition": supplier_condition, + "condition": condition, "rounded_total_field": rounded_total_field, "grand_total_field": grand_total_field, "voucher_type": voucher_type, "party_type": scrub(party_type), "party_account": "debit_to" if party_type == "Customer" else "credit_to", "cost_center": cost_center - }), (party, party_account, company), as_dict=True) + }), (party, party_account), as_dict=True) @frappe.whitelist() diff --git a/erpnext/accounts/doctype/payment_order/payment_order.js b/erpnext/accounts/doctype/payment_order/payment_order.js index aa373bc2fcc0..9074defa577e 100644 --- a/erpnext/accounts/doctype/payment_order/payment_order.js +++ b/erpnext/accounts/doctype/payment_order/payment_order.js @@ -10,6 +10,9 @@ frappe.ui.form.on('Payment Order', { } } }); + + frm.set_df_property('references', 'cannot_add_rows', true); + frm.set_df_property('references', 'cannot_delete_rows', true); }, refresh: function(frm) { if (frm.doc.docstatus == 0) { diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index bcba13eb2882..648d2da754ee 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -4,9 +4,14 @@ frappe.provide("erpnext.accounts"); erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.extend({ onload: function() { - var me = this; + const default_company = frappe.defaults.get_default('company'); + this.frm.set_value('company', default_company); - this.frm.set_query("party_type", function() { + this.frm.set_value('party_type', ''); + this.frm.set_value('party', ''); + this.frm.set_value('receivable_payable_account', ''); + + this.frm.set_query("party_type", () => { return { "filters": { "name": ["in", Object.keys(frappe.boot.party_account_types)], @@ -14,133 +19,149 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext } }); - this.frm.set_query('receivable_payable_account', function() { - check_mandatory(me.frm); + this.frm.set_query('receivable_payable_account', () => { return { filters: { - "company": me.frm.doc.company, + "company": this.frm.doc.company, "is_group": 0, - "account_type": frappe.boot.party_account_types[me.frm.doc.party_type] + "account_type": frappe.boot.party_account_types[this.frm.doc.party_type] } }; }); - this.frm.set_query('bank_cash_account', function() { - check_mandatory(me.frm, true); + this.frm.set_query('bank_cash_account', () => { return { filters:[ - ['Account', 'company', '=', me.frm.doc.company], + ['Account', 'company', '=', this.frm.doc.company], ['Account', 'is_group', '=', 0], ['Account', 'account_type', 'in', ['Bank', 'Cash']] ] }; }); - - this.frm.set_value('party_type', ''); - this.frm.set_value('party', ''); - this.frm.set_value('receivable_payable_account', ''); - - var check_mandatory = (frm, only_company=false) => { - var title = __("Mandatory"); - if (only_company && !frm.doc.company) { - frappe.throw({message: __("Please Select a Company First"), title: title}); - } else if (!frm.doc.company || !frm.doc.party_type) { - frappe.throw({message: __("Please Select Both Company and Party Type First"), title: title}); - } - }; }, refresh: function() { this.frm.disable_save(); + this.frm.set_df_property('invoices', 'cannot_delete_rows', true); + this.frm.set_df_property('payments', 'cannot_delete_rows', true); + this.frm.set_df_property('allocation', 'cannot_delete_rows', true); + + this.frm.set_df_property('invoices', 'cannot_add_rows', true); + this.frm.set_df_property('payments', 'cannot_add_rows', true); + this.frm.set_df_property('allocation', 'cannot_add_rows', true); + + if (this.frm.doc.receivable_payable_account) { this.frm.add_custom_button(__('Get Unreconciled Entries'), () => this.frm.trigger("get_unreconciled_entries") ); + this.frm.change_custom_button_type('Get Unreconciled Entries', null, 'primary'); } if (this.frm.doc.invoices.length && this.frm.doc.payments.length) { this.frm.add_custom_button(__('Allocate'), () => this.frm.trigger("allocate") ); + this.frm.change_custom_button_type('Allocate', null, 'primary'); + this.frm.change_custom_button_type('Get Unreconciled Entries', null, 'default'); } if (this.frm.doc.allocation.length) { this.frm.add_custom_button(__('Reconcile'), () => this.frm.trigger("reconcile") ); + this.frm.change_custom_button_type('Reconcile', null, 'primary'); + this.frm.change_custom_button_type('Get Unreconciled Entries', null, 'default'); + this.frm.change_custom_button_type('Allocate', null, 'default'); } }, company: function() { - var me = this; + this.frm.set_value('party', ''); this.frm.set_value('receivable_payable_account', ''); - me.frm.clear_table("allocation"); - me.frm.clear_table("invoices"); - me.frm.clear_table("payments"); - me.frm.refresh_fields(); - me.frm.trigger('party'); + }, + + party_type: function() { + this.frm.set_value('party', ''); }, party: function() { - var me = this; - if (!me.frm.doc.receivable_payable_account && me.frm.doc.party_type && me.frm.doc.party) { + this.frm.set_value('receivable_payable_account', ''); + this.frm.trigger("clear_child_tables"); + + if (!this.frm.doc.receivable_payable_account && this.frm.doc.party_type && this.frm.doc.party) { return frappe.call({ method: "erpnext.accounts.party.get_party_account", args: { - company: me.frm.doc.company, - party_type: me.frm.doc.party_type, - party: me.frm.doc.party + company: this.frm.doc.company, + party_type: this.frm.doc.party_type, + party: this.frm.doc.party }, - callback: function(r) { + callback: (r) => { if (!r.exc && r.message) { - me.frm.set_value("receivable_payable_account", r.message); + this.frm.set_value("receivable_payable_account", r.message); } - me.frm.refresh(); + this.frm.refresh(); + } }); } }, + receivable_payable_account: function() { + this.frm.trigger("clear_child_tables"); + this.frm.refresh(); + }, + + clear_child_tables: function() { + this.frm.clear_table("invoices"); + this.frm.clear_table("payments"); + this.frm.clear_table("allocation"); + this.frm.refresh_fields(); + }, + get_unreconciled_entries: function() { - var me = this; + this.frm.clear_table("allocation"); return this.frm.call({ - doc: me.frm.doc, + doc: this.frm.doc, method: 'get_unreconciled_entries', - callback: function(r, rt) { - if (!(me.frm.doc.payments.length || me.frm.doc.invoices.length)) { - frappe.throw({message: __("No invoice and payment records found for this party")}); + callback: () => { + if (!(this.frm.doc.payments.length || this.frm.doc.invoices.length)) { + frappe.throw({message: __("No Unreconciled Invoices and Payments found for this party and account")}); + } else if (!(this.frm.doc.invoices.length)) { + frappe.throw({message: __("No Outstanding Invoices found for this party")}); + } else if (!(this.frm.doc.payments.length)) { + frappe.throw({message: __("No Unreconciled Payments found for this party")}); } - me.frm.refresh(); + this.frm.refresh(); } }); }, allocate: function() { - var me = this; - let payments = me.frm.fields_dict.payments.grid.get_selected_children(); + let payments = this.frm.fields_dict.payments.grid.get_selected_children(); if (!(payments.length)) { - payments = me.frm.doc.payments; + payments = this.frm.doc.payments; } - let invoices = me.frm.fields_dict.invoices.grid.get_selected_children(); + let invoices = this.frm.fields_dict.invoices.grid.get_selected_children(); if (!(invoices.length)) { - invoices = me.frm.doc.invoices; + invoices = this.frm.doc.invoices; } - return me.frm.call({ - doc: me.frm.doc, + return this.frm.call({ + doc: this.frm.doc, method: 'allocate_entries', args: { payments: payments, invoices: invoices }, - callback: function() { - me.frm.refresh(); + callback: () => { + this.frm.refresh(); } }); }, reconcile: function() { - var me = this; - var show_dialog = me.frm.doc.allocation.filter(d => d.difference_amount && !d.difference_account); + var show_dialog = this.frm.doc.allocation.filter(d => d.difference_amount && !d.difference_account); if (show_dialog && show_dialog.length) { @@ -172,10 +193,10 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext label: __("Difference Account"), fieldname: 'difference_account', reqd: 1, - get_query: function() { + get_query: () => { return { filters: { - company: me.frm.doc.company, + company: this.frm.doc.company, is_group: 0 } } @@ -189,7 +210,7 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext }] }, ], - primary_action: function() { + primary_action: () => { const args = dialog.get_values()["allocation"]; args.forEach(d => { @@ -197,7 +218,7 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext "difference_account", d.difference_account); }); - me.reconcile_payment_entries(); + this.reconcile_payment_entries(); dialog.hide(); }, primary_action_label: __('Reconcile Entries') @@ -223,15 +244,12 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext }, reconcile_payment_entries: function() { - var me = this; - return this.frm.call({ - doc: me.frm.doc, + doc: this.frm.doc, method: 'reconcile', - callback: function(r, rt) { - me.frm.clear_table("allocation"); - me.frm.refresh_fields(); - me.frm.refresh(); + callback: () => { + this.frm.clear_table("allocation"); + this.frm.refresh(); } }); } diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json index 9023b3646f2d..eb0c20f92d9c 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json @@ -12,15 +12,16 @@ "receivable_payable_account", "col_break1", "from_invoice_date", - "to_invoice_date", - "minimum_invoice_amount", - "maximum_invoice_amount", - "invoice_limit", - "column_break_13", "from_payment_date", - "to_payment_date", + "minimum_invoice_amount", "minimum_payment_amount", + "column_break_11", + "to_invoice_date", + "to_payment_date", + "maximum_invoice_amount", "maximum_payment_amount", + "column_break_13", + "invoice_limit", "payment_limit", "bank_cash_account", "sec_break1", @@ -79,6 +80,7 @@ }, { "depends_on": "eval:(doc.payments).length || (doc.invoices).length", + "description": "If you need to reconcile particular transactions against each other, then please select accordingly. If not, all the transactions will be allocated in FIFO order.", "fieldname": "sec_break1", "fieldtype": "Section Break", "label": "Unreconciled Entries" @@ -163,6 +165,7 @@ "label": "Maximum Payment Amount" }, { + "description": "System will fetch all the entries if limit value is zero.", "fieldname": "payment_limit", "fieldtype": "Int", "label": "Payment Limit" @@ -171,13 +174,17 @@ "fieldname": "maximum_invoice_amount", "fieldtype": "Currency", "label": "Maximum Invoice Amount" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" } ], "hide_toolbar": 1, "icon": "icon-resize-horizontal", "issingle": 1, "links": [], - "modified": "2021-08-30 13:05:51.977861", + "modified": "2021-10-04 20:27:11.114194", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation", diff --git a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json index b8c65eea847c..6a21692c6ac1 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json +++ b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json @@ -14,8 +14,8 @@ "section_break_6", "allocated_amount", "unreconciled_amount", - "amount", "column_break_8", + "amount", "is_advance", "section_break_5", "difference_amount", @@ -127,12 +127,13 @@ "fieldname": "reference_row", "fieldtype": "Data", "hidden": 1, - "label": "Reference Row" + "label": "Reference Row", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2021-09-20 17:23:09.455803", + "modified": "2021-10-06 11:48:59.616562", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Allocation", diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json index 4d6e4a2ba075..d6e35c6a50d6 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json @@ -180,8 +180,7 @@ "fieldname": "pos_transactions", "fieldtype": "Table", "label": "POS Transactions", - "options": "POS Invoice Reference", - "reqd": 1 + "options": "POS Invoice Reference" }, { "fieldname": "pos_opening_entry", @@ -229,7 +228,7 @@ "link_fieldname": "pos_closing_entry" } ], - "modified": "2021-05-05 16:59:49.723261", + "modified": "2021-10-20 16:19:25.340565", "modified_by": "Administrator", "module": "Accounts", "name": "POS Closing Entry", 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 9dae3a7b75ea..28bd10283e7c 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 @@ -114,6 +114,8 @@ def process_merging_into_credit_note(self, data): def merge_pos_invoice_into(self, invoice, data): items, payments, taxes = [], [], [] loyalty_amount_sum, loyalty_points_sum = 0, 0 + rounding_adjustment, base_rounding_adjustment = 0, 0 + rounded_total, base_rounded_total = 0, 0 for doc in data: map_doc(doc, invoice, table_map={ "doctype": invoice.doctype }) @@ -162,6 +164,11 @@ def merge_pos_invoice_into(self, invoice, data): found = True if not found: payments.append(payment) + rounding_adjustment += doc.rounding_adjustment + rounded_total += doc.rounded_total + base_rounding_adjustment += doc.rounding_adjustment + base_rounded_total += doc.rounded_total + if loyalty_points_sum: invoice.redeem_loyalty_points = 1 @@ -171,6 +178,10 @@ def merge_pos_invoice_into(self, invoice, data): invoice.set('items', items) invoice.set('payments', payments) invoice.set('taxes', taxes) + invoice.set('rounding_adjustment',rounding_adjustment) + invoice.set('rounding_adjustment',base_rounding_adjustment) + invoice.set('base_rounded_total',base_rounded_total) + invoice.set('rounded_total',rounded_total) invoice.additional_discount_percentage = 0 invoice.discount_amount = 0.0 invoice.taxes_and_charges = None @@ -246,7 +257,10 @@ def get_invoice_customer_map(pos_invoices): return pos_invoice_customer_map def consolidate_pos_invoices(pos_invoices=None, closing_entry=None): - invoices = pos_invoices or (closing_entry and closing_entry.get('pos_transactions')) or get_all_unconsolidated_invoices() + invoices = pos_invoices or (closing_entry and closing_entry.get('pos_transactions')) + if frappe.flags.in_test and not invoices: + invoices = get_all_unconsolidated_invoices() + invoice_by_customer = get_invoice_customer_map(invoices) if len(invoices) >= 10 and closing_entry: diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 8afa0abd36ce..9c9f37bba27f 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -120,6 +120,7 @@ { "fieldname": "payments", "fieldtype": "Table", + "label": "Payment Methods", "options": "POS Payment Method", "reqd": 1 }, @@ -377,7 +378,7 @@ "link_fieldname": "pos_profile" } ], - "modified": "2021-02-01 13:52:51.081311", + "modified": "2021-10-14 14:17:00.469298", "modified_by": "Administrator", "module": "Accounts", "name": "POS Profile", diff --git a/erpnext/accounts/doctype/pos_profile/test_pos_profile.py b/erpnext/accounts/doctype/pos_profile/test_pos_profile.py index 83ecfb47bb52..7c53f4a0b07f 100644 --- a/erpnext/accounts/doctype/pos_profile/test_pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/test_pos_profile.py @@ -33,7 +33,9 @@ def test_pos_profile(self): frappe.db.sql("delete from `tabPOS Profile`") -def get_customers_list(pos_profile={}): +def get_customers_list(pos_profile=None): + if pos_profile is None: + pos_profile = {} cond = "1=1" customer_groups = [] if pos_profile.get('customer_groups'): diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index e2b23704c884..2e2d425dab74 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -19,6 +19,7 @@ class TestPricingRule(unittest.TestCase): def setUp(self): delete_existing_pricing_rules() + setup_pricing_rule_data() def tearDown(self): delete_existing_pricing_rules() @@ -561,6 +562,8 @@ def test_pricing_rule_for_transaction(self): for doc in [si, si1]: doc.delete() +test_dependencies = ["Campaign"] + def make_pricing_rule(**args): args = frappe._dict(args) @@ -607,6 +610,13 @@ def make_pricing_rule(**args): if args.get(applicable_for): doc.db_set(applicable_for, args.get(applicable_for)) +def setup_pricing_rule_data(): + if not frappe.db.exists('Campaign', '_Test Campaign'): + frappe.get_doc({ + 'doctype': 'Campaign', + 'campaign_name': '_Test Campaign', + 'name': '_Test Campaign' + }).insert() def delete_existing_pricing_rules(): for doctype in ["Pricing Rule", "Pricing Rule Item Code", diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 12b486e45eb1..ef44b414761e 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -29,6 +29,9 @@ def get_pricing_rules(args, doc=None): pricing_rules = [] values = {} + if not frappe.db.exists('Pricing Rule', {'disable': 0, args.transaction_type: 1}): + return + for apply_on in ['Item Code', 'Item Group', 'Brand']: pricing_rules.extend(_get_pricing_rules(apply_on, args, values)) if pricing_rules and not apply_multiple_pricing_rules(pricing_rules): @@ -398,7 +401,9 @@ def get_qty_and_rate_for_other_item(doc, pr_doc, pricing_rules): pricing_rules[0].apply_rule_on_other_items = items return pricing_rules -def get_qty_amount_data_for_cumulative(pr_doc, doc, items=[]): +def get_qty_amount_data_for_cumulative(pr_doc, doc, items=None): + if items is None: + items = [] sum_qty, sum_amt = [0, 0] doctype = doc.get('parenttype') or doc.doctype diff --git a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py index d09f7dc2da20..f5391ca4cc98 100644 --- a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py +++ b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py @@ -69,7 +69,9 @@ def on_trash(self): {'promotional_scheme': self.name}): frappe.delete_doc('Pricing Rule', rule.name) -def get_pricing_rules(doc, rules = {}): +def get_pricing_rules(doc, rules=None): + if rules is None: + rules = {} new_doc = [] for child_doc, fields in {'price_discount_slabs': price_discount_fields, 'product_discount_slabs': product_discount_fields}.items(): @@ -78,7 +80,9 @@ def get_pricing_rules(doc, rules = {}): return new_doc -def _get_pricing_rules(doc, child_doc, discount_fields, rules = {}): +def _get_pricing_rules(doc, child_doc, discount_fields, rules=None): + if rules is None: + rules = {} new_doc = [] args = get_args_for_pricing_rule(doc) applicable_for = frappe.scrub(doc.get('applicable_for')) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index aef9243aad0e..19c0d8aaf3d8 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -590,5 +590,20 @@ frappe.ui.form.on("Purchase Invoice", { company: function(frm) { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + + if (frm.doc.company) { + frappe.call({ + method: + "erpnext.accounts.party.get_party_account", + args: { + party_type: 'Supplier', + party: frm.doc.supplier, + company: frm.doc.company + }, + callback: (response) => { + if (response) frm.set_value("credit_to", response.message); + }, + }); + } }, }) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 463901e6bc91..3059341c28de 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -150,16 +150,18 @@ "cb_17", "hold_comment", "more_info", + "status", + "inter_company_invoice_reference", + "represents_company", + "column_break_147", + "is_internal_supplier", + "accounting_details_section", "credit_to", "party_account_currency", "is_opening", "against_expense_account", "column_break_63", "unrealized_profit_loss_account", - "status", - "inter_company_invoice_reference", - "is_internal_supplier", - "represents_company", "remarks", "subscription_section", "from_date", @@ -1172,6 +1174,15 @@ "options": "fa fa-file-text", "print_hide": 1 }, + { + "default": "0", + "fetch_from": "supplier.is_internal_supplier", + "fieldname": "is_internal_supplier", + "fieldtype": "Check", + "ignore_user_permissions": 1, + "label": "Is Internal Supplier", + "read_only": 1 + }, { "fieldname": "credit_to", "fieldtype": "Link", @@ -1197,7 +1208,7 @@ "default": "No", "fieldname": "is_opening", "fieldtype": "Select", - "label": "Is Opening", + "label": "Is Opening Entry", "oldfieldname": "is_opening", "oldfieldtype": "Select", "options": "No\nYes", @@ -1299,15 +1310,6 @@ "fieldname": "dimension_col_break", "fieldtype": "Column Break" }, - { - "default": "0", - "fetch_from": "supplier.is_internal_supplier", - "fieldname": "is_internal_supplier", - "fieldtype": "Check", - "ignore_user_permissions": 1, - "label": "Is Internal Supplier", - "read_only": 1 - }, { "fieldname": "tax_withholding_category", "fieldtype": "Link", @@ -1405,13 +1407,24 @@ "fieldname": "filter_items_by_supplier", "fieldtype": "Check", "label": "Filter Items by Supplier" + }, + { + "collapsible": 1, + "fieldname": "accounting_details_section", + "fieldtype": "Section Break", + "label": "Accounting Details", + "print_hide": 1 + }, + { + "fieldname": "column_break_147", + "fieldtype": "Column Break" } ], "icon": "fa fa-file-text", "idx": 204, "is_submittable": 1, "links": [], - "modified": "2021-09-28 13:10:28.351810", + "modified": "2021-10-12 20:55:16.145651", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 16f06bd9176d..be0dd92f340f 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -15,6 +15,7 @@ from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( check_if_return_invoice_linked_with_payment_entry, + get_total_in_party_account_currency, is_overdue, unlink_inter_company_doc, update_linked_doc, @@ -1147,6 +1148,7 @@ def set_status(self, update=False, status=None, update_modified=True): return outstanding_amount = flt(self.outstanding_amount, self.precision("outstanding_amount")) + total = get_total_in_party_account_currency(self) if not status: if self.docstatus == 2: @@ -1154,9 +1156,9 @@ def set_status(self, update=False, status=None, update_modified=True): elif self.docstatus == 1: if self.is_internal_transfer(): self.status = 'Internal Transfer' - elif is_overdue(self): + elif is_overdue(self, total): self.status = "Overdue" - elif 0 < outstanding_amount < flt(self.grand_total, self.precision("grand_total")): + elif 0 < outstanding_amount < total: self.status = "Partly Paid" elif outstanding_amount > 0 and getdate(self.due_date) >= getdate(): self.status = "Unpaid" diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index f59f5748f350..fd949349ab33 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -10,9 +10,26 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte this.setup_posting_date_time_check(); this._super(doc); }, + company: function() { erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); + let me = this; + if (this.frm.doc.company) { + frappe.call({ + method: + "erpnext.accounts.party.get_party_account", + args: { + party_type: 'Customer', + party: this.frm.doc.customer, + company: this.frm.doc.company + }, + callback: (response) => { + if (response) me.frm.set_value("debit_to", response.message); + }, + }); + } }, + onload: function() { var me = this; this._super(); @@ -446,12 +463,15 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte }, currency() { + var me = this; this._super(); - $.each(cur_frm.doc.timesheets, function(i, d) { - let row = frappe.get_doc(d.doctype, d.name) - set_timesheet_detail_rate(row.doctype, row.name, cur_frm.doc.currency, row.timesheet_detail) - }); - calculate_total_billing_amount(cur_frm) + if (this.frm.doc.timesheets) { + this.frm.doc.timesheets.forEach((d) => { + let row = frappe.get_doc(d.doctype, d.name) + set_timesheet_detail_rate(row.doctype, row.name, me.frm.doc.currency, row.timesheet_detail) + }); + frm.trigger("calculate_timesheet_totals"); + } } }); @@ -999,7 +1019,7 @@ frappe.ui.form.on('Sales Invoice', { frappe.ui.form.on("Sales Invoice Timesheet", { - timesheets_remove(frm, cdt, cdn) { + timesheets_remove(frm) { frm.trigger("calculate_timesheet_totals"); } }); diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index a24164487e61..93e32f1a18c2 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -124,6 +124,13 @@ "total_advance", "outstanding_amount", "disable_rounded_total", + "column_break4", + "write_off_amount", + "base_write_off_amount", + "write_off_outstanding_amount_automatically", + "column_break_74", + "write_off_account", + "write_off_cost_center", "advances_section", "allocate_advances_automatically", "get_advances", @@ -144,13 +151,6 @@ "column_break_90", "change_amount", "account_for_change_amount", - "column_break4", - "write_off_amount", - "base_write_off_amount", - "write_off_outstanding_amount_automatically", - "column_break_74", - "write_off_account", - "write_off_cost_center", "terms_section_break", "tc_name", "terms", @@ -161,14 +161,14 @@ "column_break_84", "language", "more_information", + "status", "inter_company_invoice_reference", - "is_internal_customer", "represents_company", "customer_group", "campaign", - "is_discounted", "col_break23", - "status", + "is_internal_customer", + "is_discounted", "source", "more_info", "debit_to", @@ -1990,16 +1990,6 @@ "label": "Additional Discount Account", "options": "Account" }, - { - "default": "0", - "fieldname": "ignore_default_payment_terms_template", - "fieldtype": "Check", - "hidden": 1, - "label": "Ignore Default Payment Terms Template", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 - }, { "allow_on_submit": 1, "fieldname": "dispatch_address_name", @@ -2015,6 +2005,14 @@ "label": "Dispatch Address", "read_only": 1 }, + { + "default": "0", + "fieldname": "ignore_default_payment_terms_template", + "fieldtype": "Check", + "hidden": 1, + "label": "Ignore Default Payment Terms Template", + "read_only": 1 + }, { "fieldname": "total_billing_hours", "fieldtype": "Float", @@ -2033,7 +2031,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2021-09-28 13:09:34.391799", + "modified": "2021-10-11 20:19:38.667508", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", @@ -2088,4 +2086,4 @@ "title_field": "title", "track_changes": 1, "track_seen": 1 -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 65d7f46dea8e..2899a9199b1a 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1296,12 +1296,20 @@ def validate_serial_against_delivery_note(self): serial_nos = item.serial_no or "" si_serial_nos = set(get_serial_nos(serial_nos)) + serial_no_diff = si_serial_nos - dn_serial_nos - if si_serial_nos - dn_serial_nos: - frappe.throw(_("Serial Numbers in row {0} does not match with Delivery Note").format(item.idx)) + if serial_no_diff: + dn_link = frappe.utils.get_link_to_form("Delivery Note", item.delivery_note) + serial_no_msg = ", ".join(frappe.bold(d) for d in serial_no_diff) + + msg = _("Row #{0}: The following Serial Nos are not present in Delivery Note {1}:").format( + item.idx, dn_link) + msg += " " + serial_no_msg + + frappe.throw(msg=msg, title=_("Serial Nos Mismatch")) if item.serial_no and cint(item.qty) != len(si_serial_nos): - frappe.throw(_("Row {0}: {1} Serial numbers required for Item {2}. You have provided {3}.").format( + frappe.throw(_("Row #{0}: {1} Serial numbers required for Item {2}. You have provided {3}.").format( item.idx, item.qty, item.item_code, len(si_serial_nos))) def update_project(self): @@ -1470,6 +1478,7 @@ def set_status(self, update=False, status=None, update_modified=True): return outstanding_amount = flt(self.outstanding_amount, self.precision("outstanding_amount")) + total = get_total_in_party_account_currency(self) if not status: if self.docstatus == 2: @@ -1477,9 +1486,9 @@ def set_status(self, update=False, status=None, update_modified=True): elif self.docstatus == 1: if self.is_internal_transfer(): self.status = 'Internal Transfer' - elif is_overdue(self): + elif is_overdue(self, total): self.status = "Overdue" - elif 0 < outstanding_amount < flt(self.grand_total, self.precision("grand_total")): + elif 0 < outstanding_amount < total: self.status = "Partly Paid" elif outstanding_amount > 0 and getdate(self.due_date) >= getdate(): self.status = "Unpaid" @@ -1506,27 +1515,42 @@ def set_status(self, update=False, status=None, update_modified=True): if update: self.db_set('status', self.status, update_modified = update_modified) -def is_overdue(doc): - outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount")) +def get_total_in_party_account_currency(doc): + total_fieldname = ( + "grand_total" + if doc.disable_rounded_total + else "rounded_total" + ) + if doc.party_account_currency != doc.currency: + total_fieldname = "base_" + total_fieldname + + return flt(doc.get(total_fieldname), doc.precision(total_fieldname)) + +def is_overdue(doc, total): + outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount")) if outstanding_amount <= 0: return - grand_total = flt(doc.grand_total, doc.precision("grand_total")) - nowdate = getdate() - if doc.payment_schedule: - # calculate payable amount till date - payable_amount = sum( - payment.payment_amount - for payment in doc.payment_schedule - if getdate(payment.due_date) < nowdate - ) + today = getdate() + if doc.get('is_pos') or not doc.get('payment_schedule'): + return getdate(doc.due_date) < today - if (grand_total - outstanding_amount) < payable_amount: - return True + # calculate payable amount till date + payment_amount_field = ( + "base_payment_amount" + if doc.party_account_currency != doc.currency + else "payment_amount" + ) + + payable_amount = sum( + payment.get(payment_amount_field) + for payment in doc.payment_schedule + if getdate(payment.due_date) < today + ) + + return (total - outstanding_amount) < payable_amount - elif getdate(doc.due_date) < nowdate: - return True def get_discounting_status(sales_invoice): status = None @@ -2002,22 +2026,23 @@ def update_multi_mode_option(doc, pos_profile): def append_payment(payment_mode): payment = doc.append('payments', {}) payment.default = payment_mode.default - payment.mode_of_payment = payment_mode.parent + payment.mode_of_payment = payment_mode.mop payment.account = payment_mode.default_account payment.type = payment_mode.type doc.set('payments', []) invalid_modes = [] - for pos_payment_method in pos_profile.get('payments'): - pos_payment_method = pos_payment_method.as_dict() + mode_of_payments = [d.mode_of_payment for d in pos_profile.get('payments')] + mode_of_payments_info = get_mode_of_payments_info(mode_of_payments, doc.company) - payment_mode = get_mode_of_payment_info(pos_payment_method.mode_of_payment, doc.company) + for row in pos_profile.get('payments'): + payment_mode = mode_of_payments_info.get(row.mode_of_payment) if not payment_mode: - invalid_modes.append(get_link_to_form("Mode of Payment", pos_payment_method.mode_of_payment)) + invalid_modes.append(get_link_to_form("Mode of Payment", row.mode_of_payment)) continue - payment_mode[0].default = pos_payment_method.default - append_payment(payment_mode[0]) + payment_mode.default = row.default + append_payment(payment_mode) if invalid_modes: if invalid_modes == 1: @@ -2033,6 +2058,24 @@ def get_all_mode_of_payments(doc): where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""", {'company': doc.company}, as_dict=1) +def get_mode_of_payments_info(mode_of_payments, company): + data = frappe.db.sql( + """ + select + mpa.default_account, mpa.parent as mop, mp.type as type + from + `tabMode of Payment Account` mpa,`tabMode of Payment` mp + where + mpa.parent = mp.name and + mpa.company = %s and + mp.enabled = 1 and + mp.name in %s + group by + mp.name + """, (company, mode_of_payments), as_dict=1) + + return {row.get('mop'): row for row in data} + def get_mode_of_payment_info(mode_of_payment, company): return frappe.db.sql(""" select mpa.default_account, mpa.parent, mp.type as type diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 67465c51acda..b457a58b812b 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1085,8 +1085,6 @@ def test_return_sales_invoice(self): actual_qty_1 = get_qty_after_transaction(item_code = "_Test Item", warehouse = "Stores - TCP1") - frappe.db.commit() - self.assertEqual(actual_qty_0 - 5, actual_qty_1) # outgoing_rate @@ -2341,6 +2339,18 @@ def test_payment_statuses(self): si.reload() self.assertEqual(si.status, "Paid") + def test_sales_invoice_submission_post_account_freezing_date(self): + frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', add_days(getdate(), 1)) + si = create_sales_invoice(do_not_save=True) + si.posting_date = add_days(getdate(), 1) + si.save() + + self.assertRaises(frappe.ValidationError, si.submit) + si.posting_date = getdate() + si.submit() + + frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None) + def get_sales_invoice_for_e_invoice(): si = make_sales_invoice_for_ewaybill() si.naming_series = 'INV-2020-.#####' diff --git a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json index 2cddbbfceee9..69b7c129f09a 100644 --- a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json +++ b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json @@ -16,9 +16,9 @@ "column_break_9", "billing_amount", "section_break_11", - "timesheet_detail", - "column_break_5", "time_sheet", + "timesheet_detail", + "column_break_13", "project_name" ], "fields": [ @@ -91,7 +91,6 @@ "fieldtype": "Column Break" }, { - "fieldname": "section_break_7", "fieldtype": "Section Break", "label": "Totals" @@ -110,11 +109,15 @@ "fieldtype": "Data", "label": "Project Name", "read_only": 1 + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" } ], "istable": 1, "links": [], - "modified": "2021-08-15 18:37:08.084930", + "modified": "2021-10-02 03:48:44.979777", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Timesheet", diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index e12d2fe72773..5eb562e2b567 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -33,7 +33,7 @@ def before_insert(self): # update start just before the subscription doc is created self.update_subscription_period(self.start_date) - def update_subscription_period(self, date=None): + def update_subscription_period(self, date=None, return_date=False): """ Subscription period is the period to be billed. This method updates the beginning of the billing period and end of the billing period. @@ -41,28 +41,41 @@ def update_subscription_period(self, date=None): The beginning of the billing period is represented in the doctype as `current_invoice_start` and the end of the billing period is represented as `current_invoice_end`. + + If return_date is True, it wont update the start and end dates. + This is implemented to get the dates to check if is_current_invoice_generated """ - self.set_current_invoice_start(date) - self.set_current_invoice_end() + _current_invoice_start = self.get_current_invoice_start(date) + _current_invoice_end = self.get_current_invoice_end(_current_invoice_start) + + if return_date: + return _current_invoice_start, _current_invoice_end + + self.current_invoice_start = _current_invoice_start + self.current_invoice_end = _current_invoice_end - def set_current_invoice_start(self, date=None): + def get_current_invoice_start(self, date=None): """ - This sets the date of the beginning of the current billing period. + This returns the date of the beginning of the current billing period. If the `date` parameter is not given , it will be automatically set as today's date. """ + _current_invoice_start = None + if self.is_new_subscription() and self.trial_period_end and getdate(self.trial_period_end) > getdate(self.start_date): - self.current_invoice_start = add_days(self.trial_period_end, 1) + _current_invoice_start = add_days(self.trial_period_end, 1) elif self.trial_period_start and self.is_trialling(): - self.current_invoice_start = self.trial_period_start + _current_invoice_start = self.trial_period_start elif date: - self.current_invoice_start = date + _current_invoice_start = date else: - self.current_invoice_start = nowdate() + _current_invoice_start = nowdate() + + return _current_invoice_start - def set_current_invoice_end(self): + def get_current_invoice_end(self, date=None): """ - This sets the date of the end of the current billing period. + This returns the date of the end of the current billing period. If the subscription is in trial period, it will be set as the end of the trial period. @@ -71,44 +84,47 @@ def set_current_invoice_end(self): current billing period where `x` is the billing interval from the `Subscription Plan` in the `Subscription`. """ - if self.is_trialling() and getdate(self.current_invoice_start) < getdate(self.trial_period_end): - self.current_invoice_end = self.trial_period_end + _current_invoice_end = None + + if self.is_trialling() and getdate(date) < getdate(self.trial_period_end): + _current_invoice_end = self.trial_period_end else: billing_cycle_info = self.get_billing_cycle_data() if billing_cycle_info: - if self.is_new_subscription() and getdate(self.start_date) < getdate(self.current_invoice_start): - self.current_invoice_end = add_to_date(self.start_date, **billing_cycle_info) + if self.is_new_subscription() and getdate(self.start_date) < getdate(date): + _current_invoice_end = add_to_date(self.start_date, **billing_cycle_info) # For cases where trial period is for an entire billing interval - if getdate(self.current_invoice_end) < getdate(self.current_invoice_start): - self.current_invoice_end = add_to_date(self.current_invoice_start, **billing_cycle_info) + if getdate(self.current_invoice_end) < getdate(date): + _current_invoice_end = add_to_date(date, **billing_cycle_info) else: - self.current_invoice_end = add_to_date(self.current_invoice_start, **billing_cycle_info) + _current_invoice_end = add_to_date(date, **billing_cycle_info) else: - self.current_invoice_end = get_last_day(self.current_invoice_start) + _current_invoice_end = get_last_day(date) if self.follow_calendar_months: billing_info = self.get_billing_cycle_and_interval() billing_interval_count = billing_info[0]['billing_interval_count'] calendar_months = get_calendar_months(billing_interval_count) calendar_month = 0 - current_invoice_end_month = getdate(self.current_invoice_end).month - current_invoice_end_year = getdate(self.current_invoice_end).year + current_invoice_end_month = getdate(_current_invoice_end).month + current_invoice_end_year = getdate(_current_invoice_end).year for month in calendar_months: if month <= current_invoice_end_month: calendar_month = month if cint(calendar_month - billing_interval_count) <= 0 and \ - getdate(self.current_invoice_start).month != 1: + getdate(date).month != 1: calendar_month = 12 current_invoice_end_year -= 1 - self.current_invoice_end = get_last_day(cstr(current_invoice_end_year) + '-' \ - + cstr(calendar_month) + '-01') + _current_invoice_end = get_last_day(cstr(current_invoice_end_year) + '-' + cstr(calendar_month) + '-01') - if self.end_date and getdate(self.current_invoice_end) > getdate(self.end_date): - self.current_invoice_end = self.end_date + if self.end_date and getdate(_current_invoice_end) > getdate(self.end_date): + _current_invoice_end = self.end_date + + return _current_invoice_end @staticmethod def validate_plans_billing_cycle(billing_cycle_data): @@ -485,10 +501,13 @@ def is_prepaid_to_invoice(self): # Check invoice dates and make sure it doesn't have outstanding invoices return getdate() >= getdate(self.current_invoice_start) - def is_current_invoice_generated(self): + def is_current_invoice_generated(self, _current_start_date=None, _current_end_date=None): invoice = self.get_current_invoice() - if invoice and getdate(self.current_invoice_start) <= getdate(invoice.posting_date) <= getdate(self.current_invoice_end): + if not (_current_start_date and _current_end_date): + _current_start_date, _current_end_date = self.update_subscription_period(date=add_days(self.current_invoice_end, 1), return_date=True) + + if invoice and getdate(_current_start_date) <= getdate(invoice.posting_date) <= getdate(_current_end_date): return True return False @@ -502,13 +521,16 @@ def process_for_active(self): 2. Change the `Subscription` status to 'Past Due Date' 3. Change the `Subscription` status to 'Cancelled' """ - if getdate() > getdate(self.current_invoice_end) and self.is_prepaid_to_invoice(): - self.update_subscription_period(add_days(self.current_invoice_end, 1)) - if not self.is_current_invoice_generated() and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()): + if not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end) \ + and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()): + prorate = frappe.db.get_single_value('Subscription Settings', 'prorate') self.generate_invoice(prorate) + if getdate() > getdate(self.current_invoice_end) and self.is_prepaid_to_invoice(): + self.update_subscription_period(add_days(self.current_invoice_end, 1)) + if self.cancel_at_period_end and getdate() > getdate(self.current_invoice_end): self.cancel_subscription_at_period_end() @@ -541,15 +563,17 @@ def process_for_past_due_date(self): else: self.set_status_grace_period() - if getdate() > getdate(self.current_invoice_end): - self.update_subscription_period(add_days(self.current_invoice_end, 1)) - # Generate invoices periodically even if current invoice are unpaid - if self.generate_new_invoices_past_due_date and not self.is_current_invoice_generated() and (self.is_postpaid_to_invoice() - or self.is_prepaid_to_invoice()): + if self.generate_new_invoices_past_due_date and not \ + self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end) \ + and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()): + prorate = frappe.db.get_single_value('Subscription Settings', 'prorate') self.generate_invoice(prorate) + if getdate() > getdate(self.current_invoice_end): + self.update_subscription_period(add_days(self.current_invoice_end, 1)) + @staticmethod def is_paid(invoice): """ diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index e2cf4d5a4423..0f7a0a86a4d5 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -18,6 +18,7 @@ from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor +test_dependencies = ("UOM", "Item Group", "Item") def create_plan(): if not frappe.db.exists('Subscription Plan', '_Test Plan Name'): @@ -68,7 +69,6 @@ def create_plan(): supplier.insert() class TestSubscription(unittest.TestCase): - def setUp(self): create_plan() 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 16ef5fc97458..d018ee7d4396 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -49,15 +49,24 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None): pan_no = '' parties = [] party_type, party = get_party_details(inv) + has_pan_field = frappe.get_meta(party_type).has_field("pan") if not tax_withholding_category: - tax_withholding_category, pan_no = frappe.db.get_value(party_type, party, ['tax_withholding_category', 'pan']) + if has_pan_field: + fields = ['tax_withholding_category', 'pan'] + else: + fields = ['tax_withholding_category'] + + tax_withholding_details = frappe.db.get_value(party_type, party, fields, as_dict=1) + + tax_withholding_category = tax_withholding_details.get('tax_withholding_category') + pan_no = tax_withholding_details.get('pan') if not tax_withholding_category: return # if tax_withholding_category passed as an argument but not pan_no - if not pan_no: + if not pan_no and has_pan_field: pan_no = frappe.db.get_value(party_type, party, 'pan') # Get others suppliers with the same PAN No @@ -165,6 +174,7 @@ def get_lower_deduction_certificate(tax_details, pan_no): ldc_name = frappe.db.get_value('Lower Deduction Certificate', { 'pan_no': pan_no, + 'tax_withholding_category': tax_details.tax_withholding_category, 'valid_from': ('>=', tax_details.from_date), 'valid_upto': ('<=', tax_details.to_date) }, 'name') @@ -203,6 +213,9 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N # then chargeable value is "prev invoices + advances" value which cross the threshold tax_amount = get_tcs_amount(parties, inv, tax_details, vouchers, advance_vouchers) + if cint(tax_details.round_off_tax_amount): + tax_amount = round(tax_amount) + return tax_amount, tax_deducted def get_invoice_vouchers(parties, tax_details, company, party_type='Supplier'): @@ -322,9 +335,6 @@ def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers): else: tds_amount = supp_credit_amt * tax_details.rate / 100 if supp_credit_amt > 0 else 0 - if cint(tax_details.round_off_tax_amount): - tds_amount = round(tds_amount) - return tds_amount def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers): diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 0cee6f5b3aa1..0cae16bc51af 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -293,7 +293,7 @@ def check_freezing_date(posting_date, adv_adj=False): if acc_frozen_upto: frozen_accounts_modifier = frappe.db.get_value( 'Accounts Settings', None,'frozen_accounts_modifier') if getdate(posting_date) <= getdate(acc_frozen_upto) \ - and not frozen_accounts_modifier in frappe.get_roles() or frappe.session.user == 'Administrator': + and (frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == 'Administrator'): frappe.throw(_("You are not authorized to add or update entries before {0}").format(formatdate(acc_frozen_upto))) def set_as_cancel(voucher_type, voucher_no): diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 7ea6ccee5b4f..ecc8783df2c2 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -220,7 +220,7 @@ def set_account_and_due_date(party, account, party_type, company, posting_date, return out @frappe.whitelist() -def get_party_account(party_type, party, company=None): +def get_party_account(party_type, party=None, company=None): """Returns the account for the given `party`. Will first search in party (Customer / Supplier) record, if not found, will search in group (Customer Group / Supplier Group), @@ -228,8 +228,11 @@ def get_party_account(party_type, party, company=None): if not company: frappe.throw(_("Please select a Company")) - if not party: - return + if not party and party_type in ['Customer', 'Supplier']: + default_account_name = "default_receivable_account" \ + if party_type=="Customer" else "default_payable_account" + + return frappe.get_cached_value('Company', company, default_account_name) account = frappe.db.get_value("Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account") diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html index 7643eca7635d..e65804930951 100644 --- a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html +++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html @@ -68,7 +68,7 @@
Seller
{%- if einvoice.ShipDtls -%} {%- set shipping = einvoice.ShipDtls -%} -
Shipping
+
Shipped From

{{ shipping.Gstin }}

{{ shipping.LglNm }}

{{ shipping.Addr1 }}

@@ -86,6 +86,17 @@
Buyer
{%- if buyer.Addr2 -%}

{{ buyer.Addr2 }}

{% endif %}

{{ buyer.Loc }}

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

+ + {%- if einvoice.DispDtls -%} + {%- set dispatch = einvoice.DispDtls -%} +
Dispatched From
+ {%- if dispatch.Gstin -%}

{{ dispatch.Gstin }}

{% endif %} +

{{ dispatch.LglNm }}

+

{{ dispatch.Addr1 }}

+ {%- if dispatch.Addr2 -%}

{{ dispatch.Addr2 }}

{% endif %} +

{{ dispatch.Loc }}

+

{{ frappe.db.get_value("Address", doc.dispatch_address_name, "gst_state") }} - {{ dispatch.Pin }}

+ {% endif %}
diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index d5271885b7e1..bb8138bfc2e9 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -139,9 +139,9 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_ data["total"] = total return data -def get_account_type_based_gl_data(company, start_date, end_date, account_type, filters={}): +def get_account_type_based_gl_data(company, start_date, end_date, account_type, filters=None): cond = "" - filters = frappe._dict(filters) + filters = frappe._dict(filters or {}) if filters.include_default_book_entries: company_fb = frappe.db.get_value("Company", company, 'default_finance_book') diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js index 6a8301a6f91f..e24a5f991847 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.js @@ -103,8 +103,11 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() { column.is_tree = true; } - value = default_formatter(value, row, column, data); + if (data && data.account && column.apply_currency_formatter) { + data.currency = erpnext.get_currency(column.company_name); + } + value = default_formatter(value, row, column, data); if (!data.parent_account) { value = $(`${value}`); diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index b0cfbac9cb1e..0475231a9346 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -3,12 +3,14 @@ from __future__ import unicode_literals +from collections import defaultdict + import frappe from frappe import _ from frappe.utils import cint, flt, getdate +import erpnext from erpnext.accounts.report.balance_sheet.balance_sheet import ( - check_opening_balance, get_chart_data, get_provisional_profit_loss, ) @@ -31,7 +33,7 @@ from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement import ( get_report_summary as get_pl_summary, ) -from erpnext.accounts.report.utils import convert_to_presentation_currency +from erpnext.accounts.report.utils import convert, convert_to_presentation_currency def execute(filters=None): @@ -42,7 +44,7 @@ def execute(filters=None): fiscal_year = get_fiscal_year_data(filters.get('from_fiscal_year'), filters.get('to_fiscal_year')) companies_column, companies = get_companies(filters) - columns = get_columns(companies_column) + columns = get_columns(companies_column, filters) if filters.get('report') == "Balance Sheet": data, message, chart, report_summary = get_balance_sheet_data(fiscal_year, companies, columns, filters) @@ -73,21 +75,24 @@ def get_balance_sheet_data(fiscal_year, companies, columns, filters): provisional_profit_loss, total_credit = get_provisional_profit_loss(asset, liability, equity, companies, filters.get('company'), company_currency, True) - message, opening_balance = check_opening_balance(asset, liability, equity) + message, opening_balance = prepare_companywise_opening_balance(asset, liability, equity, companies) - if opening_balance and round(opening_balance,2) !=0: - unclosed ={ + if opening_balance: + unclosed = { "account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", "account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'", "warn_if_negative": True, "currency": company_currency } + for company in companies: - unclosed[company] = opening_balance - if provisional_profit_loss: - provisional_profit_loss[company] = provisional_profit_loss[company] - opening_balance + unclosed[company] = opening_balance.get(company) + if provisional_profit_loss and provisional_profit_loss.get(company): + provisional_profit_loss[company] = ( + flt(provisional_profit_loss[company]) - flt(opening_balance.get(company)) + ) - unclosed["total"]=opening_balance + unclosed["total"] = opening_balance.get(company) data.append(unclosed) if provisional_profit_loss: @@ -102,6 +107,38 @@ def get_balance_sheet_data(fiscal_year, companies, columns, filters): return data, message, chart, report_summary +def prepare_companywise_opening_balance(asset_data, liability_data, equity_data, companies): + opening_balance = {} + for company in companies: + opening_value = 0 + + # opening_value = Aseet - liability - equity + for data in [asset_data, liability_data, equity_data]: + if data: + account_name = get_root_account_name(data[0].root_type, company) + opening_value += (get_opening_balance(account_name, data, company) or 0.0) + + opening_balance[company] = opening_value + + if opening_balance: + return _("Previous Financial Year is not closed"), opening_balance + + return '', {} + +def get_opening_balance(account_name, data, company): + for row in data: + if row.get('account_name') == account_name: + return row.get('company_wise_opening_bal', {}).get(company, 0.0) + +def get_root_account_name(root_type, company): + return frappe.get_all( + 'Account', + fields=['account_name'], + filters = {'root_type': root_type, 'is_group': 1, + 'company': company, 'parent_account': ('is', 'not set')}, + as_list=1 + )[0][0] + def get_profit_loss_data(fiscal_year, companies, columns, filters): income, expense, net_profit_loss = get_income_expense_data(companies, fiscal_year, filters) company_currency = get_company_currency(filters) @@ -193,30 +230,37 @@ def get_account_type_based_data(account_type, companies, fiscal_year, filters): data["total"] = total return data -def get_columns(companies): - columns = [{ - "fieldname": "account", - "label": _("Account"), - "fieldtype": "Link", - "options": "Account", - "width": 300 - }] - - columns.append({ - "fieldname": "currency", - "label": _("Currency"), - "fieldtype": "Link", - "options": "Currency", - "hidden": 1 - }) +def get_columns(companies, filters): + columns = [ + { + "fieldname": "account", + "label": _("Account"), + "fieldtype": "Link", + "options": "Account", + "width": 300 + }, { + "fieldname": "currency", + "label": _("Currency"), + "fieldtype": "Link", + "options": "Currency", + "hidden": 1 + } + ] for company in companies: + apply_currency_formatter = 1 if not filters.presentation_currency else 0 + currency = filters.presentation_currency + if not currency: + currency = erpnext.get_company_currency(company) + columns.append({ "fieldname": company, - "label": company, + "label": f'{company} ({currency})', "fieldtype": "Currency", "options": "currency", - "width": 150 + "width": 150, + "apply_currency_formatter": apply_currency_formatter, + "company_name": company }) return columns @@ -236,6 +280,8 @@ def get_data(companies, root_type, balance_must_be, fiscal_year, filters=None, i start_date = filters.period_start_date if filters.report != 'Balance Sheet' else None end_date = filters.period_end_date + filters.end_date = end_date + gl_entries_by_account = {} for root in frappe.db.sql("""select lft, rgt from tabAccount where root_type=%s and ifnull(parent_account, '') = ''""", root_type, as_dict=1): @@ -244,9 +290,10 @@ def get_data(companies, root_type, balance_must_be, fiscal_year, filters=None, i end_date, root.lft, root.rgt, filters, gl_entries_by_account, accounts_by_name, accounts, ignore_closing_entries=False) - calculate_values(accounts_by_name, gl_entries_by_account, companies, start_date, filters) + calculate_values(accounts_by_name, gl_entries_by_account, companies, filters, fiscal_year) accumulate_values_into_parents(accounts, accounts_by_name, companies) - out = prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency) + + out = prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters) if out: add_total_row(out, root_type, balance_must_be, companies, company_currency) @@ -257,7 +304,10 @@ def get_company_currency(filters=None): return (filters.get('presentation_currency') or frappe.get_cached_value('Company', filters.company, "default_currency")) -def calculate_values(accounts_by_name, gl_entries_by_account, companies, start_date, filters): +def calculate_values(accounts_by_name, gl_entries_by_account, companies, filters, fiscal_year): + start_date = (fiscal_year.year_start_date + if filters.filter_based_on == 'Fiscal Year' else filters.period_start_date) + for entries in gl_entries_by_account.values(): for entry in entries: if entry.account_number: @@ -266,15 +316,32 @@ def calculate_values(accounts_by_name, gl_entries_by_account, companies, start_d account_name = entry.account_name d = accounts_by_name.get(account_name) + if d: + debit, credit = 0, 0 for company in companies: # check if posting date is within the period if (entry.company == company or (filters.get('accumulated_in_group_company')) and entry.company in companies.get(company)): - d[company] = d.get(company, 0.0) + flt(entry.debit) - flt(entry.credit) + parent_company_currency = erpnext.get_company_currency(d.company) + child_company_currency = erpnext.get_company_currency(entry.company) + + debit, credit = flt(entry.debit), flt(entry.credit) + + if (not filters.get('presentation_currency') + and entry.company != company + and parent_company_currency != child_company_currency + and filters.get('accumulated_in_group_company')): + debit = convert(debit, parent_company_currency, child_company_currency, filters.end_date) + credit = convert(credit, parent_company_currency, child_company_currency, filters.end_date) + + d[company] = d.get(company, 0.0) + flt(debit) - flt(credit) + + if entry.posting_date < getdate(start_date): + d['company_wise_opening_bal'][company] += (flt(debit) - flt(credit)) if entry.posting_date < getdate(start_date): - d["opening_balance"] = d.get("opening_balance", 0.0) + flt(entry.debit) - flt(entry.credit) + d["opening_balance"] = d.get("opening_balance", 0.0) + flt(debit) - flt(credit) def accumulate_values_into_parents(accounts, accounts_by_name, companies): """accumulate children's values in parent accounts""" @@ -282,17 +349,18 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies): if d.parent_account: account = d.parent_account_name - if not accounts_by_name.get(account): - continue + # if not accounts_by_name.get(account): + # continue for company in companies: accounts_by_name[account][company] = \ accounts_by_name[account].get(company, 0.0) + d.get(company, 0.0) + accounts_by_name[account]['company_wise_opening_bal'][company] += d.get('company_wise_opening_bal', {}).get(company, 0.0) + accounts_by_name[account]["opening_balance"] = \ accounts_by_name[account].get("opening_balance", 0.0) + d.get("opening_balance", 0.0) - def get_account_heads(root_type, companies, filters): accounts = get_accounts(root_type, filters) @@ -353,7 +421,7 @@ def get_accounts(root_type, filters): `tabAccount` where company = %s and root_type = %s """ , (filters.get('company'), root_type), as_dict=1) -def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency): +def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters): data = [] for d in accounts: @@ -367,10 +435,13 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com "parent_account": _(d.parent_account), "indent": flt(d.indent), "year_start_date": start_date, + "root_type": d.root_type, "year_end_date": end_date, - "currency": company_currency, + "currency": filters.presentation_currency, + "company_wise_opening_bal": d.company_wise_opening_bal, "opening_balance": d.get("opening_balance", 0.0) * (1 if balance_must_be == "Debit" else -1) }) + for company in companies: if d.get(company) and balance_must_be == "Credit": # change sign based on Debit or Credit, since calculation is done using (debit - credit) @@ -385,6 +456,7 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com row["has_value"] = has_value row["total"] = total + data.append(row) return data @@ -447,6 +519,7 @@ def get_account_details(account): 'is_group', 'account_name', 'account_number', 'parent_account', 'lft', 'rgt'], as_dict=1) def validate_entries(key, entry, accounts_by_name, accounts): + # If an account present in the child company and not in the parent company if key not in accounts_by_name: args = get_account_details(entry.account) @@ -456,12 +529,23 @@ def validate_entries(key, entry, accounts_by_name, accounts): args.update({ 'lft': parent_args.lft + 1, 'rgt': parent_args.rgt - 1, + 'indent': 3, 'root_type': parent_args.root_type, - 'report_type': parent_args.report_type + 'report_type': parent_args.report_type, + 'parent_account_name': parent_args.account_name, + 'company_wise_opening_bal': defaultdict(float) }) accounts_by_name.setdefault(key, args) - accounts.append(args) + + idx = len(accounts) + # To identify parent account index + for index, row in enumerate(accounts): + if row.parent_account_name == args.parent_account_name: + idx = index + break + + accounts.insert(idx+1, args) def get_additional_conditions(from_date, ignore_closing_entries, filters): additional_conditions = [] @@ -491,7 +575,6 @@ def add_total_row(out, root_type, balance_must_be, companies, company_currency): for company in companies: total_row.setdefault(company, 0.0) total_row[company] += row.get(company, 0.0) - row[company] = 0.0 total_row.setdefault("total", 0.0) total_row["total"] += flt(row["total"]) @@ -511,6 +594,7 @@ def filter_accounts(accounts, depth=10): account_name = d.account_number + ' - ' + d.account_name else: account_name = d.account_name + d['company_wise_opening_bal'] = defaultdict(float) accounts_by_name[account_name] = d parent_children_map.setdefault(d.parent_account or None, []).append(d) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 5bd6e583dbb7..31416da4ac41 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -155,6 +155,8 @@ def get_gl_entries(filters, accounting_dimensions): if filters.get("group_by") == "Group by Voucher": order_by_statement = "order by posting_date, voucher_type, voucher_no" + if filters.get("group_by") == "Group by Account": + order_by_statement = "order by account, posting_date, creation" if filters.get("include_default_book_entries"): filters['company_fb'] = frappe.db.get_value("Company", @@ -421,8 +423,6 @@ def update_value_in_dict(data, key, gle): update_value_in_dict(totals, 'closing', gle) elif gle.posting_date <= to_date: - update_value_in_dict(gle_map[gle.get(group_by)].totals, 'total', gle) - update_value_in_dict(totals, 'total', gle) if filters.get("group_by") != 'Group by Voucher (Consolidated)': gle_map[gle.get(group_by)].entries.append(gle) elif filters.get("group_by") == 'Group by Voucher (Consolidated)': @@ -436,10 +436,11 @@ def update_value_in_dict(data, key, gle): else: update_value_in_dict(consolidated_gle, key, gle) - update_value_in_dict(gle_map[gle.get(group_by)].totals, 'closing', gle) - update_value_in_dict(totals, 'closing', gle) - for key, value in consolidated_gle.items(): + update_value_in_dict(gle_map[value.get(group_by)].totals, 'total', value) + update_value_in_dict(totals, 'total', value) + update_value_in_dict(gle_map[value.get(group_by)].totals, 'closing', value) + update_value_in_dict(totals, 'closing', value) entries.append(value) return totals, entries diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index 621b697aca4b..6a7f2e5b535e 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -44,16 +44,16 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map): if rate and tds_deducted: row = { - 'pan' if frappe.db.has_column('Supplier', 'pan') else 'tax_id': supplier_map.get(supplier).pan, - 'supplier': supplier_map.get(supplier).name + 'pan' if frappe.db.has_column('Supplier', 'pan') else 'tax_id': supplier_map.get(supplier, {}).get('pan'), + 'supplier': supplier_map.get(supplier, {}).get('name') } if filters.naming_series == 'Naming Series': - row.update({'supplier_name': supplier_map.get(supplier).supplier_name}) + row.update({'supplier_name': supplier_map.get(supplier, {}).get('supplier_name')}) row.update({ 'section_code': tax_withholding_category, - 'entity_type': supplier_map.get(supplier).supplier_type, + 'entity_type': supplier_map.get(supplier, {}).get('supplier_type'), 'tds_rate': rate, 'total_amount_credited': total_amount_credited, 'tds_deducted': tds_deducted, diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 848a3a36b12e..da49ed9f4e04 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -4,11 +4,14 @@ from __future__ import unicode_literals +from json import loads + import frappe import frappe.defaults from frappe import _, throw from frappe.model.meta import get_field_precision from frappe.utils import cint, cstr, flt, formatdate, get_number_format_info, getdate, now, nowdate +from six import string_types import erpnext @@ -447,7 +450,8 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False): # new row with references new_row = journal_entry.append("accounts") - new_row.update(jv_detail.as_dict().copy()) + + new_row.update((frappe.copy_doc(jv_detail)).as_dict()) new_row.set(d["dr_or_cr"], d["allocated_amount"]) new_row.set('debit' if d['dr_or_cr'] == 'debit_in_account_currency' else 'credit', @@ -576,10 +580,10 @@ def remove_ref_doc_link_from_pe(ref_type, ref_no): frappe.msgprint(_("Payment Entries {0} are un-linked").format("\n".join(linked_pe))) @frappe.whitelist() -def get_company_default(company, fieldname): - value = frappe.get_cached_value('Company', company, fieldname) +def get_company_default(company, fieldname, ignore_validation=False): + value = frappe.get_cached_value('Company', company, fieldname) - if not value: + if not ignore_validation and not value: throw(_("Please set default {0} in Company {1}") .format(frappe.get_meta("Company").get_label(fieldname), company)) @@ -787,16 +791,28 @@ def get_children(doctype, parent, company, is_root=False): if doctype == 'Account': sort_accounts(acc, is_root, key="value") - company_currency = frappe.get_cached_value('Company', company, "default_currency") - for each in acc: - each["company_currency"] = company_currency - each["balance"] = flt(get_balance_on(each.get("value"), in_account_currency=False, company=company)) - - if each.account_currency != company_currency: - each["balance_in_account_currency"] = flt(get_balance_on(each.get("value"), company=company)) return acc +@frappe.whitelist() +def get_account_balances(accounts, company): + + if isinstance(accounts, string_types): + accounts = loads(accounts) + + if not accounts: + return [] + + company_currency = frappe.get_cached_value("Company", company, "default_currency") + + for account in accounts: + account["company_currency"] = company_currency + account["balance"] = flt(get_balance_on(account["value"], in_account_currency=False, company=company)) + if account["account_currency"] and account["account_currency"] != company_currency: + account["balance_in_account_currency"] = flt(get_balance_on(account["value"], company=company)) + + return accounts + def create_payment_gateway_account(gateway, payment_channel="Email"): from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json index 7e3ecaf3ab6a..236e47594d17 100644 --- a/erpnext/accounts/workspace/accounting/accounting.json +++ b/erpnext/accounts/workspace/accounting/accounting.json @@ -454,6 +454,17 @@ "onboard": 0, "type": "Link" }, + { + "dependencies": "GL Entry", + "hidden": 0, + "is_query_report": 1, + "label": "KSA VAT Report", + "link_to": "KSA VAT", + "link_type": "Report", + "onboard": 0, + "only_for": "Saudi Arabia", + "type": "Link" + }, { "hidden": 0, "is_query_report": 0, @@ -1034,6 +1045,16 @@ "onboard": 0, "type": "Link" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "KSA VAT Setting", + "link_to": "KSA VAT Setting", + "link_type": "DocType", + "onboard": 0, + "only_for": "Saudi Arabia", + "type": "Link" + }, { "hidden": 0, "is_query_report": 0, @@ -1082,7 +1103,7 @@ "type": "Link" } ], - "modified": "2021-08-23 16:06:34.167267", + "modified": "2021-08-26 13:15:52.872470", "modified_by": "Administrator", "module": "Accounts", "name": "Accounting", diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 7e135be30b7b..99a6cc35dbb2 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -194,7 +194,7 @@ def make_depreciation_schedule(self, date_of_sale): start = self.clear_depreciation_schedule() # value_after_depreciation - current Asset value - if d.value_after_depreciation: + if self.docstatus == 1 and d.value_after_depreciation: value_after_depreciation = (flt(d.value_after_depreciation) - flt(self.opening_accumulated_depreciation)) else: diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 7183ee7e3691..cf4581b4a168 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -682,6 +682,27 @@ def test_discounted_wdv_depreciation_rate_for_indian_region(self): # reset indian company frappe.flags.company = company_flag + def test_expected_value_change(self): + """ + tests if changing `expected_value_after_useful_life` + affects `value_after_depreciation` + """ + + asset = create_asset(calculate_depreciation=1) + asset.opening_accumulated_depreciation = 2000 + asset.number_of_depreciations_booked = 1 + + asset.finance_books[0].expected_value_after_useful_life = 100 + asset.save() + asset.reload() + self.assertEquals(asset.finance_books[0].value_after_depreciation, 98000.0) + + # changing expected_value_after_useful_life shouldn't affect value_after_depreciation + asset.finance_books[0].expected_value_after_useful_life = 200 + asset.save() + asset.reload() + self.assertEquals(asset.finance_books[0].value_after_depreciation, 98000.0) + def create_asset_data(): if not frappe.db.exists("Asset Category", "Computers"): create_asset_category() diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 9945a328cfcc..30e3a5296e04 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -22,7 +22,7 @@ def setUp(self): frappe.db.sql("delete from `tabTax Rule`") def test_update_status(self): - asset = create_asset() + asset = create_asset(submit=1) initial_status = asset.status asset_repair = create_asset_repair(asset = asset) @@ -76,7 +76,7 @@ def test_decrease_stock_quantity(self): self.assertEqual(stock_entry.items[0].qty, asset_repair.stock_items[0].consumed_quantity) def test_increase_in_asset_value_due_to_stock_consumption(self): - asset = create_asset(calculate_depreciation = 1) + asset = create_asset(calculate_depreciation = 1, submit=1) initial_asset_value = get_asset_value(asset) asset_repair = create_asset_repair(asset= asset, stock_consumption = 1, submit = 1) asset.reload() @@ -85,7 +85,7 @@ def test_increase_in_asset_value_due_to_stock_consumption(self): self.assertEqual(asset_repair.stock_items[0].total_value, increase_in_asset_value) def test_increase_in_asset_value_due_to_repair_cost_capitalisation(self): - asset = create_asset(calculate_depreciation = 1) + asset = create_asset(calculate_depreciation = 1, submit=1) initial_asset_value = get_asset_value(asset) asset_repair = create_asset_repair(asset= asset, capitalize_repair_cost = 1, submit = 1) asset.reload() @@ -103,7 +103,7 @@ def test_gl_entries(self): self.assertEqual(asset_repair.name, gl_entry.voucher_no) def test_increase_in_asset_life(self): - asset = create_asset(calculate_depreciation = 1) + asset = create_asset(calculate_depreciation = 1, submit=1) initial_num_of_depreciations = num_of_depreciations(asset) create_asset_repair(asset= asset, capitalize_repair_cost = 1, submit = 1) asset.reload() @@ -126,7 +126,7 @@ def create_asset_repair(**args): if args.asset: asset = args.asset else: - asset = create_asset(is_existing_asset = 1) + asset = create_asset(is_existing_asset = 1, submit=1) asset_repair = frappe.new_doc("Asset Repair") asset_repair.update({ "asset": asset.name, diff --git a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.js b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.js index 75f42a9f7830..06989a95da70 100644 --- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.js +++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.js @@ -16,9 +16,8 @@ frappe.query_reports["Fixed Asset Register"] = { fieldname:"status", label: __("Status"), fieldtype: "Select", - options: "In Location\nDisposed", - default: 'In Location', - reqd: 1 + options: "\nIn Location\nDisposed", + default: 'In Location' }, { "fieldname":"filter_based_on", diff --git a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py index e370b9d0cb31..63685fef4656 100644 --- a/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py +++ b/erpnext/assets/report/fixed_asset_register/fixed_asset_register.py @@ -45,12 +45,13 @@ def get_conditions(filters): if filters.get('cost_center'): conditions["cost_center"] = filters.get('cost_center') - # In Store assets are those that are not sold or scrapped - operand = 'not in' - if status not in 'In Location': - operand = 'in' + if status: + # In Store assets are those that are not sold or scrapped + operand = 'not in' + if status not in 'In Location': + operand = 'in' - conditions['status'] = (operand, ['Sold', 'Scrapped']) + conditions['status'] = (operand, ['Sold', 'Scrapped']) return conditions diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.js b/erpnext/buying/doctype/buying_settings/buying_settings.js index 944bb61cfeb8..32431fc39106 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.js +++ b/erpnext/buying/doctype/buying_settings/buying_settings.js @@ -11,7 +11,7 @@ frappe.tour['Buying Settings'] = [ { fieldname: "supp_master_name", title: "Supplier Naming By", - description: __("By default, the Supplier Name is set as per the Supplier Name entered. If you want Suppliers to be named by a ") + "Naming Series" + __(" choose the 'Naming Series' option."), + description: __("By default, the Supplier Name is set as per the Supplier Name entered. If you want Suppliers to be named by a Naming Series choose the 'Naming Series' option."), }, { fieldname: "buying_price_list", diff --git a/erpnext/buying/form_tour/buying_settings/buying_settings.json b/erpnext/buying/form_tour/buying_settings/buying_settings.json new file mode 100644 index 000000000000..fa8c80d6cdf0 --- /dev/null +++ b/erpnext/buying/form_tour/buying_settings/buying_settings.json @@ -0,0 +1,77 @@ +{ + "creation": "2021-07-28 11:51:42.319984", + "docstatus": 0, + "doctype": "Form Tour", + "idx": 0, + "is_standard": 1, + "modified": "2021-10-05 13:06:56.414584", + "modified_by": "Administrator", + "module": "Buying", + "name": "Buying Settings", + "owner": "Administrator", + "reference_doctype": "Buying Settings", + "save_on_complete": 0, + "steps": [ + { + "description": "When a Supplier is saved, system generates a unique identity or name for that Supplier which can be used to refer the Supplier in various Buying transactions.", + "field": "", + "fieldname": "supp_master_name", + "fieldtype": "Select", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Supplier Naming By", + "parent_field": "", + "position": "Bottom", + "title": "Supplier Naming By" + }, + { + "description": "Configure what should be the default value of Supplier Group when creating a new Supplier.", + "field": "", + "fieldname": "supplier_group", + "fieldtype": "Link", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Default Supplier Group", + "parent_field": "", + "position": "Right", + "title": "Default Supplier Group" + }, + { + "description": "Item prices will be fetched from this Price List.", + "field": "", + "fieldname": "buying_price_list", + "fieldtype": "Link", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Default Buying Price List", + "parent_field": "", + "position": "Bottom", + "title": "Default Buying Price List" + }, + { + "description": "If this option is configured \"Yes\", ERPNext will prevent you from creating a Purchase Invoice or a Purchase Receipt directly without creating a Purchase Order first.", + "field": "", + "fieldname": "po_required", + "fieldtype": "Select", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Is Purchase Order Required for Purchase Invoice & Receipt Creation?", + "parent_field": "", + "position": "Bottom", + "title": "Purchase Order Required" + }, + { + "description": "If this option is configured \"Yes\", ERPNext will prevent you from creating a Purchase Invoice without creating a Purchase Receipt first.", + "field": "", + "fieldname": "pr_required", + "fieldtype": "Select", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Is Purchase Receipt Required for Purchase Invoice Creation?", + "parent_field": "", + "position": "Bottom", + "title": "Purchase Receipt Required" + } + ], + "title": "Buying Settings" +} \ No newline at end of file diff --git a/erpnext/buying/form_tour/purchase_order/purchase_order.json b/erpnext/buying/form_tour/purchase_order/purchase_order.json new file mode 100644 index 000000000000..3cc88fbf4fe0 --- /dev/null +++ b/erpnext/buying/form_tour/purchase_order/purchase_order.json @@ -0,0 +1,82 @@ +{ + "creation": "2021-07-29 14:11:58.271113", + "docstatus": 0, + "doctype": "Form Tour", + "idx": 0, + "is_standard": 1, + "modified": "2021-10-05 13:11:31.436135", + "modified_by": "Administrator", + "module": "Buying", + "name": "Purchase Order", + "owner": "Administrator", + "reference_doctype": "Purchase Order", + "save_on_complete": 1, + "steps": [ + { + "description": "Select a Supplier", + "field": "", + "fieldname": "supplier", + "fieldtype": "Link", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Supplier", + "parent_field": "", + "position": "Right", + "title": "Supplier" + }, + { + "description": "Set the \"Required By\" date for the materials. This sets the \"Required By\" date for all the items.", + "field": "", + "fieldname": "schedule_date", + "fieldtype": "Date", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Required By", + "parent_field": "", + "position": "Left", + "title": "Required By" + }, + { + "description": "Items to be purchased can be added here.", + "field": "", + "fieldname": "items", + "fieldtype": "Table", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Items", + "parent_field": "", + "position": "Bottom", + "title": "Items Table" + }, + { + "child_doctype": "Purchase Order Item", + "description": "Enter the Item Code.", + "field": "", + "fieldname": "item_code", + "fieldtype": "Link", + "has_next_condition": 1, + "is_table_field": 1, + "label": "Item Code", + "next_step_condition": "eval: doc.item_code", + "parent_field": "", + "parent_fieldname": "items", + "position": "Right", + "title": "Item Code" + }, + { + "child_doctype": "Purchase Order Item", + "description": "Enter the required quantity for the material.", + "field": "", + "fieldname": "qty", + "fieldtype": "Float", + "has_next_condition": 0, + "is_table_field": 1, + "label": "Quantity", + "parent_field": "", + "parent_fieldname": "items", + "position": "Bottom", + "title": "Quantity" + } + ], + "title": "Purchase Order" +} \ No newline at end of file diff --git a/erpnext/buying/module_onboarding/buying/buying.json b/erpnext/buying/module_onboarding/buying/buying.json index 887f85b82d16..84e97a2d4d28 100644 --- a/erpnext/buying/module_onboarding/buying/buying.json +++ b/erpnext/buying/module_onboarding/buying/buying.json @@ -19,7 +19,7 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/buying", "idx": 0, "is_complete": 0, - "modified": "2020-07-08 14:05:28.273641", + "modified": "2021-08-24 18:13:42.463776", "modified_by": "Administrator", "module": "Buying", "name": "Buying", @@ -28,23 +28,11 @@ { "step": "Introduction to Buying" }, - { - "step": "Create a Supplier" - }, - { - "step": "Setup your Warehouse" - }, - { - "step": "Create a Product" - }, { "step": "Create a Material Request" }, { "step": "Create your first Purchase Order" - }, - { - "step": "Buying Settings" } ], "subtitle": "Products, Purchases, Analysis, and more.", diff --git a/erpnext/buying/onboarding_step/create_a_material_request/create_a_material_request.json b/erpnext/buying/onboarding_step/create_a_material_request/create_a_material_request.json index 9dc493dd499c..28e86ab06411 100644 --- a/erpnext/buying/onboarding_step/create_a_material_request/create_a_material_request.json +++ b/erpnext/buying/onboarding_step/create_a_material_request/create_a_material_request.json @@ -1,19 +1,21 @@ { - "action": "Create Entry", + "action": "Show Form Tour", + "action_label": "Let\u2019s create your first Material Request", "creation": "2020-05-15 14:39:09.818764", + "description": "# Track Material Request\n\n\nAlso known as Purchase Request or an Indent, is a document identifying a requirement of a set of items (products or services) for various purposes like procurement, transfer, issue, or manufacturing. Once the Material Request is validated, a purchase manager can take the next actions for purchasing items like requesting RFQ from a supplier or directly placing an order with an identified Supplier.\n\n", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 1, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-15 14:39:09.818764", + "modified": "2021-08-24 18:08:08.347501", "modified_by": "Administrator", "name": "Create a Material Request", "owner": "Administrator", "reference_document": "Material Request", + "show_form_tour": 1, "show_full_form": 1, - "title": "Create a Material Request", + "title": "Track Material Request", "validate_action": 1 } \ No newline at end of file diff --git a/erpnext/buying/onboarding_step/create_your_first_purchase_order/create_your_first_purchase_order.json b/erpnext/buying/onboarding_step/create_your_first_purchase_order/create_your_first_purchase_order.json index 9dbed239789a..18a393158612 100644 --- a/erpnext/buying/onboarding_step/create_your_first_purchase_order/create_your_first_purchase_order.json +++ b/erpnext/buying/onboarding_step/create_your_first_purchase_order/create_your_first_purchase_order.json @@ -1,19 +1,21 @@ { - "action": "Create Entry", + "action": "Show Form Tour", + "action_label": "Let\u2019s create your first Purchase Order", "creation": "2020-05-12 18:17:49.976035", + "description": "# Create first Purchase Order\n\nPurchase Order is at the heart of your buying transactions. In ERPNext, Purchase Order can can be created against a Purchase Material Request (indent) and Supplier Quotation as well. Purchase Orders is also linked to Purchase Receipt and Purchase Invoices, allowing you to keep a birds-eye view on your purchase deals.\n\n", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-12 18:31:56.856112", + "modified": "2021-08-24 18:08:08.936484", "modified_by": "Administrator", "name": "Create your first Purchase Order", "owner": "Administrator", "reference_document": "Purchase Order", + "show_form_tour": 0, "show_full_form": 0, - "title": "Create your first Purchase Order", + "title": "Create first Purchase Order", "validate_action": 1 } \ No newline at end of file diff --git a/erpnext/buying/onboarding_step/introduction_to_buying/introduction_to_buying.json b/erpnext/buying/onboarding_step/introduction_to_buying/introduction_to_buying.json index fd98fddafae6..01ac8b81760f 100644 --- a/erpnext/buying/onboarding_step/introduction_to_buying/introduction_to_buying.json +++ b/erpnext/buying/onboarding_step/introduction_to_buying/introduction_to_buying.json @@ -1,19 +1,22 @@ { - "action": "Watch Video", + "action": "Show Form Tour", + "action_label": "Let\u2019s walk-through few Buying Settings", "creation": "2020-05-06 15:37:09.477765", + "description": "# Buying Settings\n\n\nBuying module\u2019s features are highly configurable as per your business needs. Buying Settings is the place where you can set your preferences for:\n\n- Supplier naming and default values\n- Billing and shipping preference in buying transactions\n\n\n", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, - "is_single": 0, + "is_single": 1, "is_skipped": 0, - "modified": "2020-05-12 18:25:08.509900", + "modified": "2021-08-24 18:08:08.345735", "modified_by": "Administrator", "name": "Introduction to Buying", "owner": "Administrator", - "show_full_form": 0, - "title": "Introduction to Buying", + "reference_document": "Buying Settings", + "show_form_tour": 1, + "show_full_form": 1, + "title": "Buying Settings", "validate_action": 1, "video_url": "https://youtu.be/efFajTTQBa8" } \ No newline at end of file diff --git a/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py b/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py index a5b09473a059..fd23795287f4 100644 --- a/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py +++ b/erpnext/buying/report/procurement_tracker/test_procurement_tracker.py @@ -45,7 +45,6 @@ def generate_expected_data(self): pr = make_purchase_receipt(po.name) pr.get("items")[0].cost_center = "Main - _TPC" pr.submit() - frappe.db.commit() date_obj = datetime.date(datetime.now()) po.load_from_db() diff --git a/erpnext/change_log/v13/v13_13_0.md b/erpnext/change_log/v13/v13_13_0.md new file mode 100644 index 000000000000..3da2d7217443 --- /dev/null +++ b/erpnext/change_log/v13/v13_13_0.md @@ -0,0 +1,29 @@ +# Version 13.13.0 Release Notes + +### Features & Enhancements + +- HR Module onboarding ([#25741](https://github.com/frappe/erpnext/pull/25741)) +- Tracking multiple rounds for the interview ([#25482](https://github.com/frappe/erpnext/pull/25482)) +- HSN based tax breakup table check in GST Settings (India Localization) ([#27907](https://github.com/frappe/erpnext/pull/27907)) + +### Fixes + +- To improve stock transactions added indexes in stock queries and speed up bin updation ([#27758](https://github.com/frappe/erpnext/pull/27758)) +- Interstate internal transfer invoices not visible in GSTR-1 ([#27970](https://github.com/frappe/erpnext/pull/27970)) +- Account number and name incorrectly imported using COA importer ([#27967](https://github.com/frappe/erpnext/pull/27967)) +- Multiple fixes to timesheets ([#27775](https://github.com/frappe/erpnext/pull/27742)) +- Totals row incorrect value in GL Entry ([#27867](https://github.com/frappe/erpnext/pull/27867)) +- Sales Order delivery Date not getting set via data import ([#27862](https://github.com/frappe/erpnext/pull/27862)) +- Add cost center in gl entry for advance payment entry ([#27840](https://github.com/frappe/erpnext/pull/27840)) +- Item Variant selection empty popup on website ([#27924](https://github.com/frappe/erpnext/pull/27924)) +- Improve performance of fetching account balance in chart of accounts ([#27661](https://github.com/frappe/erpnext/pull/27661)) +- Chart Of Accounts import button not visible ([#27748](https://github.com/frappe/erpnext/pull/27748)) +- Website Items with same Item name unhandled, thumbnails missing ([#27720](https://github.com/frappe/erpnext/pull/27720)) +- Delete linked Transaction Deletion Record docs on deleting company ([#27785](https://github.com/frappe/erpnext/pull/27785)) +- Display appropriate message for Payment Term discrepancies in Payment Entry ([#27749](https://github.com/frappe/erpnext/pull/27749)) +- Updated buying onboarding tours. ([#27800](https://github.com/frappe/erpnext/pull/27800)) +- Fixed variant qty in BOM while making work order ([#27686](https://github.com/frappe/erpnext/pull/27686)) +- Availability slots display, disabled Practitioner Schedule ([#27812](https://github.com/frappe/erpnext/pull/27812)) +- Consolidated report not consider company currency ([#27863](https://github.com/frappe/erpnext/pull/27863)) +- Batch Number not copied from Purchase Receipt to Stock Entry ([#27794](https://github.com/frappe/erpnext/pull/27794)) +- Employee Leave Balance report should only consider ledgers of transaction type Leave Allocation ([#27728](https://github.com/frappe/erpnext/pull/27728)) diff --git a/erpnext/change_log/v13/v13_14_0.md b/erpnext/change_log/v13/v13_14_0.md new file mode 100644 index 000000000000..3769c7984a74 --- /dev/null +++ b/erpnext/change_log/v13/v13_14_0.md @@ -0,0 +1,42 @@ +# Version 13.14.0 Release Notes + +### Features & Enhancements + +- KSA E-Invoicing and VAT Report ([#27369](https://github.com/frappe/erpnext/pull/27369)) + - Added KSA VAT settings to setup KSA VAT accounts + - New report KSA VAT to check the vat amounts + - Print format for KSA VAT Invoice ([#28166](https://github.com/frappe/erpnext/pull/28166)) + +- Provision to setup tax for recurring additional salary in Salary Slip ([#27459](https://github.com/frappe/erpnext/pull/27459)) +- Add dispatch address in E-invoicing for India localization ([#28084](https://github.com/frappe/erpnext/pull/28084)) +- Employee initial work history updated when transfer is performed ([#27768](https://github.com/frappe/erpnext/pull/27768)) +- Provision to setup quality inspection teamplte in the operation which will be use in the Job Card([#28219](https://github.com/frappe/erpnext/pull/28219)) +- Improved sales invoice submission performance ([#27916](https://github.com/frappe/erpnext/pull/27916)) + + +### Fixes + +- Splitting outstanding rows as per payment terms ([#27946](https://github.com/frappe/erpnext/pull/27946)) + +- Make status filter in Fixed Asset Register optional ([#28126](https://github.com/frappe/erpnext/pull/28126)) +- Skip empty rows while updating unsaved BOM cost ([#28136](https://github.com/frappe/erpnext/pull/28136)) +- TDS round off not working from second transaction ([#27934](https://github.com/frappe/erpnext/pull/27934)) +- Update receivable/payable account on company change in the Sales / Purchase Invoice ([#28057](https://github.com/frappe/erpnext/pull/28057)) +- Changes in Maintenance Schedule gets overwritten on save ([#27990](https://github.com/frappe/erpnext/pull/27990)) +- Fetch thumbnail from Item master instead of regenerating ([#28005](https://github.com/frappe/erpnext/pull/28005)) +- Serial Nos not set in the row after scanning in popup ([#28202](https://github.com/frappe/erpnext/pull/28202)) +- Taxjar customer_address fix, currency fix ([#28262](https://github.com/frappe/erpnext/pull/28262)) +- TaxJar update - added nexus list, making api call only for nexus ([#27497](https://github.com/frappe/erpnext/pull/27497)) +- Don't reset rates in Timesheet Detail when Activity Type is cleared ([#28056](https://github.com/frappe/erpnext/pull/28056)) +- Show full item name in search widget ([#28283](https://github.com/frappe/erpnext/pull/28283)) +- Avoid automatic customer creation on website user login ([#27914](https://github.com/frappe/erpnext/pull/27914)) +- POS Closing Entry without linked invoices ([#28042](https://github.com/frappe/erpnext/pull/28042)) +- Added patch to fix production plan status ([#27567](https://github.com/frappe/erpnext/pull/27567)) +- Interstate internal transfer invoices was not displying in the GSTR-1 report ([#27970](https://github.com/frappe/erpnext/pull/27970)) +- Shows opening balance from filtered from date in the stock balance and stock ledger report ([#26877](https://github.com/frappe/erpnext/pull/26877)) +- Employee filter in YTD and MTD in salary slip ([#27997](https://github.com/frappe/erpnext/pull/27997)) +- Removed warehouse filter on Batch field for Material Receipt ([#28195](https://github.com/frappe/erpnext/pull/28195)) +- Account number and name incorrectly imported using COA importer ([#27967](https://github.com/frappe/erpnext/pull/27967)) +- Autoemail report not showing dynamic report filters ([#28114](https://github.com/frappe/erpnext/pull/28114)) +- Incorrect VAT Amount in UAE VAT 201 report ([#27994](https://github.com/frappe/erpnext/pull/27994)) +- Employee Leave Balance report should only consider ledgers of transaction type Leave Allocation([#27728](https://github.com/frappe/erpnext/pull/27728)) \ No newline at end of file diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 7f2ed6b99bc7..e07db6a6ce6f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -820,6 +820,38 @@ def on_cancel(self): if frappe.db.get_single_value('Accounts Settings', 'unlink_advance_payment_on_cancelation_of_order'): unlink_ref_doc_from_payment_entries(self) + if self.doctype == "Sales Order": + self.unlink_ref_doc_from_po() + + def unlink_ref_doc_from_po(self): + so_items = [] + for item in self.items: + so_items.append(item.name) + + linked_po = list(set(frappe.get_all( + 'Purchase Order Item', + filters = { + 'sales_order': self.name, + 'sales_order_item': ['in', so_items], + 'docstatus': ['<', 2] + }, + pluck='parent' + ))) + + if linked_po: + frappe.db.set_value( + 'Purchase Order Item', { + 'sales_order': self.name, + 'sales_order_item': ['in', so_items], + 'docstatus': ['<', 2] + },{ + 'sales_order': None, + 'sales_order_item': None + } + ) + + frappe.msgprint(_("Purchase Orders {0} are un-linked").format("\n".join(linked_po))) + def get_tax_map(self): tax_map = {} for tax in self.get('taxes'): @@ -1037,15 +1069,15 @@ def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on, parentfield): if role_allowed_to_over_bill in user_roles and total_overbilled_amt > 0.1: frappe.msgprint(_("Overbilling of {} ignored because you have {} role.") - .format(total_overbilled_amt, role_allowed_to_over_bill), title=_("Warning"), indicator="orange") + .format(total_overbilled_amt, role_allowed_to_over_bill), indicator="orange", alert=True) def throw_overbill_exception(self, item, max_allowed_amt): 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)) - def get_company_default(self, fieldname): + def get_company_default(self, fieldname, ignore_validation=False): from erpnext.accounts.utils import get_company_default - return get_company_default(self.company, fieldname) + return get_company_default(self.company, fieldname, ignore_validation=ignore_validation) def get_stock_items(self): stock_items = [] @@ -1359,8 +1391,8 @@ def validate_payment_schedule_amount(self): total = 0 base_total = 0 for d in self.get("payment_schedule"): - total += flt(d.payment_amount) - base_total += flt(d.base_payment_amount) + total += flt(d.payment_amount, d.precision("payment_amount")) + base_total += flt(d.base_payment_amount, d.precision("base_payment_amount")) base_grand_total = self.get("base_rounded_total") or self.base_grand_total grand_total = self.get("rounded_total") or self.grand_total @@ -1376,8 +1408,9 @@ def validate_payment_schedule_amount(self): else: grand_total -= self.get("total_advance") base_grand_total = flt(grand_total * self.get("conversion_rate"), self.precision("base_grand_total")) - if total != flt(grand_total, self.precision("grand_total")) or \ - base_total != flt(base_grand_total, self.precision("base_grand_total")): + + if flt(total, self.precision("grand_total")) - flt(grand_total, self.precision("grand_total")) > 0.1 or \ + flt(base_total, self.precision("base_grand_total")) - flt(base_grand_total, self.precision("base_grand_total")) > 0.1: frappe.throw(_("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total")) def is_rounded_total_disabled(self): @@ -1691,17 +1724,58 @@ def get_advance_payment_entries(party_type, party, party_account, order_doctype, def update_invoice_status(): """Updates status as Overdue for applicable invoices. Runs daily.""" + today = getdate() for doctype in ("Sales Invoice", "Purchase Invoice"): frappe.db.sql(""" - update `tab{}` as dt set dt.status = 'Overdue' - where dt.docstatus = 1 - and dt.status != 'Overdue' - and dt.outstanding_amount > 0 - and (dt.grand_total - dt.outstanding_amount) < - (select sum(payment_amount) from `tabPayment Schedule` as ps - where ps.parent = dt.name and ps.due_date < %s) - """.format(doctype), getdate()) + UPDATE `tab{doctype}` invoice SET invoice.status = 'Overdue' + WHERE invoice.docstatus = 1 + AND invoice.status REGEXP '^Unpaid|^Partly Paid' + AND invoice.outstanding_amount > 0 + AND ( + {or_condition} + ( + ( + CASE + WHEN invoice.party_account_currency = invoice.currency + THEN ( + CASE + WHEN invoice.disable_rounded_total + THEN invoice.grand_total + ELSE invoice.rounded_total + END + ) + ELSE ( + CASE + WHEN invoice.disable_rounded_total + THEN invoice.base_grand_total + ELSE invoice.base_rounded_total + END + ) + END + ) - invoice.outstanding_amount + ) < ( + SELECT SUM( + CASE + WHEN invoice.party_account_currency = invoice.currency + THEN ps.payment_amount + ELSE ps.base_payment_amount + END + ) + FROM `tabPayment Schedule` ps + WHERE ps.parent = invoice.name + AND ps.due_date < %(today)s + ) + ) + """.format( + doctype=doctype, + or_condition=( + "invoice.is_pos AND invoice.due_date < %(today)s OR" + if doctype == "Sales Invoice" + else "" + ) + ), {"today": today} + ) @frappe.whitelist() def get_payment_terms(terms_template, posting_date=None, grand_total=None, base_grand_total=None, bill_date=None): diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index f02de1fbcab2..85d0b9c31128 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -132,7 +132,8 @@ def supplier_query(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql("""select {field} from `tabSupplier` where docstatus < 2 and ({key} like %(txt)s - or supplier_name like %(txt)s) and disabled=0 + or supplier_name like %(txt)s) and disabled=0 + and (on_hold = 0 or (on_hold = 1 and CURDATE() > release_date)) {mcond} order by if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), @@ -210,12 +211,15 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals meta = frappe.get_meta("Item", cached=True) searchfields = meta.get_search_fields() - if "description" in searchfields: - searchfields.remove("description") + # these are handled separately + ignored_search_fields = ("item_name", "description") + for ignored_field in ignored_search_fields: + if ignored_field in searchfields: + searchfields.remove(ignored_field) columns = '' extra_searchfields = [field for field in searchfields - if not field in ["name", "item_group", "description"]] + if not field in ["name", "item_group", "description", "item_name"]] if extra_searchfields: columns = ", " + ", ".join(extra_searchfields) @@ -252,10 +256,8 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals if frappe.db.count('Item', cache=True) < 50000: # scan description only if items are less than 50000 description_cond = 'or tabItem.description LIKE %(txt)s' - return frappe.db.sql("""select tabItem.name, - if(length(tabItem.item_name) > 40, - concat(substr(tabItem.item_name, 1, 40), "..."), item_name) as item_name, - tabItem.item_group, + return frappe.db.sql("""select + tabItem.name, tabItem.item_name, tabItem.item_group, if(length(tabItem.description) > 40, \ concat(substr(tabItem.description, 1, 40), "..."), description) as description {columns} @@ -565,7 +567,7 @@ def get_filtered_dimensions(doctype, txt, searchfield, start, page_len, filters) query_filters.append(['name', query_selector, dimensions]) - output = frappe.get_all(doctype, filters=query_filters) + output = frappe.get_list(doctype, filters=query_filters) result = [d.name for d in output] return [(d,) for d in set(result)] diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 8738204ce092..49a76da69763 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -216,11 +216,14 @@ def check_overflow_with_allowance(self, item, args): 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(): + if overflow_percent - allowance > 0.01: item['max_allowed'] = flt(item[args['target_ref_field']] * (100+allowance)/100) item['reduce_by'] = item[args['target_field']] - item['max_allowed'] - self.limits_crossed_error(args, item, qty_or_amount) + if role not in frappe.get_roles(): + self.limits_crossed_error(args, item, qty_or_amount) + else: + self.warn_about_bypassing_with_role(item, qty_or_amount, role) def limits_crossed_error(self, args, item, qty_or_amount): '''Raise exception for limits crossed''' @@ -238,6 +241,19 @@ def limits_crossed_error(self, args, item, qty_or_amount): frappe.bold(item.get('item_code')) ) + '

' + action_msg, OverAllowanceError, title = _('Limit Crossed')) + def warn_about_bypassing_with_role(self, item, qty_or_amount, role): + action = _("Over Receipt/Delivery") if qty_or_amount == "qty" else _("Overbilling") + + msg = (_("{} of {} {} ignored for item {} because you have {} role.") + .format( + action, + _(item["target_ref_field"].title()), + frappe.bold(item["reduce_by"]), + frappe.bold(item.get('item_code')), + role) + ) + frappe.msgprint(msg, indicator="orange", alert=True) + def update_qty(self, update_modified=True): """Updates qty or amount at row level diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 78a6e52e4d7f..08d422d3bcdf 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -79,8 +79,15 @@ def validate_serialized_batch(self): def clean_serial_nos(self): for row in self.get("items"): if hasattr(row, "serial_no") and row.serial_no: - # replace commas by linefeed and remove all spaces in string - row.serial_no = row.serial_no.replace(",", "\n").replace(" ", "") + # replace commas by linefeed + row.serial_no = row.serial_no.replace(",", "\n") + + # strip preceeding and succeeding spaces for each SN + # (SN could have valid spaces in between e.g. SN - 123 - 2021) + serial_no_list = row.serial_no.split("\n") + serial_no_list = [sn.strip() for sn in serial_no_list] + + row.serial_no = "\n".join(serial_no_list) def get_gl_entries(self, warehouse_account=None, default_expense_account=None, default_cost_center=None): @@ -591,7 +598,7 @@ def future_sle_exists(args, sl_entries=None): data = frappe.db.sql(""" select item_code, warehouse, count(name) as total_row - from `tabStock Ledger Entry` + from `tabStock Ledger Entry` force index (item_warehouse) where ({}) and timestamp(posting_date, posting_time) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index fbfdfdfac89c..1c57a56371be 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -260,7 +260,9 @@ def calculate_net_total(self): self.doc.round_floats_in(self.doc, ["total", "base_total", "net_total", "base_net_total"]) def calculate_taxes(self): - self.doc.rounding_adjustment = 0 + if not self.doc.get('is_consolidated'): + self.doc.rounding_adjustment = 0 + # maintain actual tax rate based on idx actual_tax_dict = dict([[tax.idx, flt(tax.tax_amount, tax.precision("tax_amount"))] for tax in self.doc.get("taxes") if tax.charge_type == "Actual"]) @@ -312,7 +314,9 @@ def calculate_taxes(self): # adjust Discount Amount loss in last tax iteration if i == (len(self.doc.get("taxes")) - 1) and self.discount_amount_applied \ - and self.doc.discount_amount and self.doc.apply_discount_on == "Grand Total": + and self.doc.discount_amount \ + and self.doc.apply_discount_on == "Grand Total" \ + and not self.doc.get('is_consolidated'): self.doc.rounding_adjustment = flt(self.doc.grand_total - flt(self.doc.discount_amount) - tax.total, self.doc.precision("rounding_adjustment")) @@ -405,11 +409,16 @@ def manipulate_grand_total_for_inclusive_tax(self): self.doc.rounding_adjustment = diff def calculate_totals(self): - self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + flt(self.doc.rounding_adjustment) \ - if self.doc.get("taxes") else flt(self.doc.net_total) + if self.doc.get("taxes"): + self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + flt(self.doc.rounding_adjustment) + else: + self.doc.grand_total = flt(self.doc.net_total) - self.doc.total_taxes_and_charges = flt(self.doc.grand_total - self.doc.net_total + if self.doc.get("taxes"): + self.doc.total_taxes_and_charges = flt(self.doc.grand_total - self.doc.net_total - flt(self.doc.rounding_adjustment), self.doc.precision("total_taxes_and_charges")) + else: + self.doc.total_taxes_and_charges = 0.0 self._set_in_company_currency(self.doc, ["total_taxes_and_charges", "rounding_adjustment"]) @@ -446,19 +455,20 @@ def calculate_total_net_weight(self): self.doc.total_net_weight += d.total_weight def set_rounded_total(self): - if self.doc.meta.get_field("rounded_total"): - if self.doc.is_rounded_total_disabled(): - self.doc.rounded_total = self.doc.base_rounded_total = 0 - return + if not self.doc.get('is_consolidated'): + if self.doc.meta.get_field("rounded_total"): + if self.doc.is_rounded_total_disabled(): + self.doc.rounded_total = self.doc.base_rounded_total = 0 + return - self.doc.rounded_total = round_based_on_smallest_currency_fraction(self.doc.grand_total, - self.doc.currency, self.doc.precision("rounded_total")) + self.doc.rounded_total = round_based_on_smallest_currency_fraction(self.doc.grand_total, + self.doc.currency, self.doc.precision("rounded_total")) - #if print_in_rate is set, we would have already calculated rounding adjustment - self.doc.rounding_adjustment += flt(self.doc.rounded_total - self.doc.grand_total, - self.doc.precision("rounding_adjustment")) + #if print_in_rate is set, we would have already calculated rounding adjustment + self.doc.rounding_adjustment += flt(self.doc.rounded_total - self.doc.grand_total, + self.doc.precision("rounding_adjustment")) - self._set_in_company_currency(self.doc, ["rounding_adjustment", "rounded_total"]) + self._set_in_company_currency(self.doc, ["rounding_adjustment", "rounded_total"]) def _cleanup(self): if not self.doc.get('is_consolidated'): diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 0b3f50897abc..a304171ed490 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -34,6 +34,7 @@ def validate(self): self.validate_item_details() self.validate_uom_is_integer("uom", "qty") self.validate_cust_name() + self.map_fields() if not self.title: self.title = self.customer_name @@ -41,6 +42,15 @@ def validate(self): if not self.with_items: self.items = [] + def map_fields(self): + for field in self.meta.fields: + if not self.get(field.fieldname): + try: + value = frappe.db.get_value(self.opportunity_from, self.party_name, field.fieldname) + frappe.db.set(self, field.fieldname, value) + except Exception: + continue + def make_new_lead_if_required(self): """Set lead against new opportunity""" if (not self.get("party_name")) and self.contact_email: @@ -295,6 +305,8 @@ def update_item(obj, target, source_parent): @frappe.whitelist() def make_customer(source_name, target_doc=None): def set_missing_values(source, target): + target.opportunity_name = source.name + if source.opportunity_from == "Lead": target.lead_name = source.party_name diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py index e3a1e1a16a45..7020548838cd 100644 --- a/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py +++ b/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py @@ -41,7 +41,6 @@ def get_cart_settings(self): def test_tax_rule_validation(self): frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0") - frappe.db.commit() cart_settings = self.get_cart_settings() cart_settings.enabled = 1 diff --git a/erpnext/e_commerce/doctype/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py index c7c68cf09cf5..03b9834c1d17 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.py +++ b/erpnext/e_commerce/doctype/website_item/website_item.py @@ -147,7 +147,7 @@ def validate_website_image(self): def make_thumbnail(self): """Make a thumbnail of `website_image`""" - if frappe.flags.in_import: + if frappe.flags.in_import or frappe.flags.in_migrate: return import requests.exceptions diff --git a/erpnext/e_commerce/product_data_engine/filters.py b/erpnext/e_commerce/product_data_engine/filters.py index f9e3b6ae32e0..6d44b2cb9772 100644 --- a/erpnext/e_commerce/product_data_engine/filters.py +++ b/erpnext/e_commerce/product_data_engine/filters.py @@ -1,7 +1,6 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt import frappe -from frappe import _dict from frappe.utils import floor @@ -96,38 +95,32 @@ def get_attribute_filters(self): return attributes = [row.attribute for row in self.doc.filter_attributes] - attribute_docs = [ - frappe.get_doc('Item Attribute', attribute) for attribute in attributes - ] - - valid_attributes = [] - - for attr_doc in attribute_docs: - selected_attributes = [] - for attr in attr_doc.item_attribute_values: - or_filters = [] - filters= [ - ["Item Variant Attribute", "attribute", "=", attr.parent], - ["Item Variant Attribute", "attribute_value", "=", attr.attribute_value] - ] - if self.item_group: - or_filters.extend([ - ["item_group", "=", self.item_group], - ["Website Item Group", "item_group", "=", self.item_group] - ]) - - if frappe.db.get_all("Item", filters, or_filters=or_filters, limit=1): - selected_attributes.append(attr) - - if selected_attributes: - valid_attributes.append( - _dict( - item_attribute_values=selected_attributes, - name=attr_doc.name - ) - ) - return valid_attributes + if not attributes: + return [] + + result = frappe.db.sql( + """ + select + distinct attribute, attribute_value + from + `tabItem Variant Attribute` + where + attribute in %(attributes)s + and attribute_value is not null + """, + {"attributes": attributes}, + as_dict=1, + ) + + attribute_value_map = {} + for d in result: + attribute_value_map.setdefault(d.attribute, []).append(d.attribute_value) + + out = [] + for name, values in attribute_value_map.items(): + out.append(frappe._dict(name=name, item_attribute_values=values)) + return out def get_discount_filters(self, discounts): discount_filters = [] @@ -147,4 +140,4 @@ def get_discount_filters(self, discounts): label = f"{discount}% and below" discount_filters.append([discount, label]) - return discount_filters \ No newline at end of file + return discount_filters diff --git a/erpnext/e_commerce/product_data_engine/test_product_data_engine.py b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py index 925e6e7be3cd..b52e140fcc46 100644 --- a/erpnext/e_commerce/product_data_engine/test_product_data_engine.py +++ b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py @@ -175,9 +175,7 @@ def test_product_list_attribute_filter_builder(self): filter_engine = ProductFiltersBuilder() attribute_filter = filter_engine.get_attribute_filters()[0] - attributes = attribute_filter.item_attribute_values - - attribute_values = [d.attribute_value for d in attributes] + attribute_values = attribute_filter.item_attribute_values self.assertEqual(attribute_filter.name, "Test Size") self.assertGreater(len(attribute_values), 0) @@ -349,4 +347,4 @@ def create_variant_web_item(): variant.save() if not frappe.db.exists("Website Item", {"variant_of": "Test Web Item"}): - make_website_item(variant, save=True) \ No newline at end of file + make_website_item(variant, save=True) diff --git a/erpnext/e_commerce/shopping_cart/utils.py b/erpnext/e_commerce/shopping_cart/utils.py index 51398596fd8f..0cc0ab7c0028 100644 --- a/erpnext/e_commerce/shopping_cart/utils.py +++ b/erpnext/e_commerce/shopping_cart/utils.py @@ -1,8 +1,5 @@ # 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 frappe.defaults @@ -17,10 +14,19 @@ def show_cart_count(): return False def set_cart_count(login_manager): - role, parties = check_customer_or_supplier() - if role == 'Supplier': return + # since this is run only on hooks login event + # make sure user is already a customer + # before trying to set cart count + user_is_customer = is_customer() + if not user_is_customer: + return + if show_cart_count(): from erpnext.e_commerce.shopping_cart.cart import set_cart_count + + # set_cart_count will try to fetch existing cart quotation + # or create one if non existent (and create a customer too) + # cart count is calculated from this quotation's items set_cart_count() def clear_cart_count(login_manager): @@ -31,13 +37,13 @@ def update_website_context(context): cart_enabled = is_cart_enabled() context["shopping_cart_enabled"] = cart_enabled -def check_customer_or_supplier(): - if frappe.session.user: +def is_customer(): + if frappe.session.user and frappe.session.user != "Guest": contact_name = frappe.get_value("Contact", {"email_id": frappe.session.user}) if contact_name: contact = frappe.get_doc('Contact', contact_name) for link in contact.links: - if link.link_doctype in ('Customer', 'Supplier'): - return link.link_doctype, link.link_name + if link.link_doctype == 'Customer': + return True - return 'Customer', None + return False diff --git a/erpnext/education/doctype/student/student.py b/erpnext/education/doctype/student/student.py index ae498ba57dbb..be4ee560a514 100644 --- a/erpnext/education/doctype/student/student.py +++ b/erpnext/education/doctype/student/student.py @@ -138,7 +138,9 @@ def enroll_in_program(self, program_name): enrollment.submit() return enrollment - def enroll_in_course(self, course_name, program_enrollment, enrollment_date=frappe.utils.datetime.datetime.now()): + def enroll_in_course(self, course_name, program_enrollment, enrollment_date=None): + if enrollment_date is None: + enrollment_date = frappe.utils.datetime.datetime.now() try: enrollment = frappe.get_doc({ "doctype": "Course Enrollment", diff --git a/erpnext/erpnext_integrations/doctype/shopify_log/shopify_log.py b/erpnext/erpnext_integrations/doctype/shopify_log/shopify_log.py index b000c8319473..69ebaabc00b5 100644 --- a/erpnext/erpnext_integrations/doctype/shopify_log/shopify_log.py +++ b/erpnext/erpnext_integrations/doctype/shopify_log/shopify_log.py @@ -68,5 +68,8 @@ def dump_request_data(data, event="create/order"): @frappe.whitelist() def resync(method, name, request_data): frappe.db.set_value("Shopify Log", name, "status", "Queued", update_modified=False) + if not method.startswith("erpnext.erpnext_integrations.connectors.shopify_connection"): + return + frappe.enqueue(method=method, queue='short', timeout=300, is_async=True, **{"order": json.loads(request_data), "request_id": name}) diff --git a/erpnext/stock/report/process_loss_report/__init__.py b/erpnext/erpnext_integrations/doctype/taxjar_nexus/__init__.py similarity index 100% rename from erpnext/stock/report/process_loss_report/__init__.py rename to erpnext/erpnext_integrations/doctype/taxjar_nexus/__init__.py diff --git a/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.json b/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.json new file mode 100644 index 000000000000..d4d4a512b580 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-09-11 05:09:53.773838", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "region", + "region_code", + "country", + "country_code" + ], + "fields": [ + { + "fieldname": "region", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Region" + }, + { + "fieldname": "region_code", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Region Code" + }, + { + "fieldname": "country", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Country" + }, + { + "fieldname": "country_code", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Country Code" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-09-14 05:33:06.444710", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "TaxJar Nexus", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.py b/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.py new file mode 100644 index 000000000000..c24aa8ca7d44 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/taxjar_nexus/taxjar_nexus.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class TaxJarNexus(Document): + pass diff --git a/erpnext/regional/united_states/product_tax_category_data.json b/erpnext/erpnext_integrations/doctype/taxjar_settings/product_tax_category_data.json similarity index 100% rename from erpnext/regional/united_states/product_tax_category_data.json rename to erpnext/erpnext_integrations/doctype/taxjar_settings/product_tax_category_data.json diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.js b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.js index 62d5709f51f4..2925db82e330 100644 --- a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.js +++ b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.js @@ -5,5 +5,33 @@ frappe.ui.form.on('TaxJar Settings', { is_sandbox: (frm) => { frm.toggle_reqd("api_key", !frm.doc.is_sandbox); frm.toggle_reqd("sandbox_api_key", frm.doc.is_sandbox); - } + }, + + on_load: (frm) => { + frm.set_query('shipping_account_head', function() { + return { + filters: { + 'company': frm.doc.company + } + }; + }); + frm.set_query('tax_account_head', function() { + return { + filters: { + 'company': frm.doc.company + } + }; + }); + }, + + refresh: (frm) => { + frm.add_custom_button(__('Update Nexus List'), function() { + frm.call({ + doc: frm.doc, + method: 'update_nexus_list' + }); + }); + }, + + }); diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json index c0d60f7a317b..23ccb7e4dac8 100644 --- a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json +++ b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.json @@ -6,17 +6,22 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "is_sandbox", "taxjar_calculate_tax", + "is_sandbox", "taxjar_create_transactions", "credentials", "api_key", "cb_keys", "sandbox_api_key", "configuration", + "company", + "column_break_10", "tax_account_head", "configuration_cb", - "shipping_account_head" + "shipping_account_head", + "section_break_12", + "nexus_address", + "nexus" ], "fields": [ { @@ -54,6 +59,7 @@ }, { "default": "0", + "depends_on": "taxjar_calculate_tax", "fieldname": "is_sandbox", "fieldtype": "Check", "label": "Sandbox Mode" @@ -63,12 +69,9 @@ "fieldtype": "Password", "label": "Sandbox API Key" }, - { - "fieldname": "configuration_cb", - "fieldtype": "Column Break" - }, { "default": "0", + "depends_on": "taxjar_calculate_tax", "fieldname": "taxjar_create_transactions", "fieldtype": "Check", "label": "Create TaxJar Transaction" @@ -82,11 +85,42 @@ { "fieldname": "cb_keys", "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_12", + "fieldtype": "Section Break", + "label": "Nexus List" + }, + { + "fieldname": "nexus_address", + "fieldtype": "HTML", + "label": "Nexus Address" + }, + { + "fieldname": "nexus", + "fieldtype": "Table", + "label": "Nexus", + "options": "TaxJar Nexus", + "read_only": 1 + }, + { + "fieldname": "configuration_cb", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" } ], "issingle": 1, "links": [], - "modified": "2020-04-30 04:38:03.311089", + "modified": "2021-11-08 18:02:29.232090", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "TaxJar Settings", diff --git a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.py b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.py index 9dd481747ec7..edd4eacaf0cb 100644 --- a/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.py +++ b/erpnext/erpnext_integrations/doctype/taxjar_settings/taxjar_settings.py @@ -4,9 +4,98 @@ from __future__ import unicode_literals -# import frappe +import json +import os + +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.model.document import Document +from frappe.permissions import add_permission, update_permission_property + +from erpnext.erpnext_integrations.taxjar_integration import get_client class TaxJarSettings(Document): - pass + + def on_update(self): + TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions") + TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax") + TAXJAR_SANDBOX_MODE = frappe.db.get_single_value("TaxJar Settings", "is_sandbox") + + fields_already_exist = frappe.db.exists('Custom Field', {'dt': ('in', ['Item','Sales Invoice Item']), 'fieldname':'product_tax_category'}) + fields_hidden = frappe.get_value('Custom Field', {'dt': ('in', ['Sales Invoice Item'])}, 'hidden') + + if (TAXJAR_CREATE_TRANSACTIONS or TAXJAR_CALCULATE_TAX or TAXJAR_SANDBOX_MODE): + if not fields_already_exist: + add_product_tax_categories() + make_custom_fields() + add_permissions() + frappe.enqueue('erpnext.regional.united_states.setup.add_product_tax_categories', now=False) + + elif fields_already_exist and fields_hidden: + toggle_tax_category_fields(hidden='0') + + elif fields_already_exist: + toggle_tax_category_fields(hidden='1') + + def validate(self): + self.calculate_taxes_validation_for_create_transactions() + + @frappe.whitelist() + def update_nexus_list(self): + client = get_client() + nexus = client.nexus_regions() + + new_nexus_list = [frappe._dict(address) for address in nexus] + + self.set('nexus', []) + self.set('nexus', new_nexus_list) + self.save() + + def calculate_taxes_validation_for_create_transactions(self): + if not self.taxjar_calculate_tax and (self.taxjar_create_transactions or self.is_sandbox): + frappe.throw(frappe._('Before enabling Create Transaction or Sandbox Mode, you need to check the Enable Tax Calculation box')) + + +def toggle_tax_category_fields(hidden): + frappe.set_value('Custom Field', {'dt':'Sales Invoice Item', 'fieldname':'product_tax_category'}, 'hidden', hidden) + frappe.set_value('Custom Field', {'dt':'Item', 'fieldname':'product_tax_category'}, 'hidden', hidden) + + +def add_product_tax_categories(): + with open(os.path.join(os.path.dirname(__file__), 'product_tax_category_data.json'), 'r') as f: + tax_categories = json.loads(f.read()) + create_tax_categories(tax_categories['categories']) + +def create_tax_categories(data): + for d in data: + if not frappe.db.exists('Product Tax Category',{'product_tax_code':d.get('product_tax_code')}): + tax_category = frappe.new_doc('Product Tax Category') + tax_category.description = d.get("description") + tax_category.product_tax_code = d.get("product_tax_code") + tax_category.category_name = d.get("name") + tax_category.db_insert() + +def make_custom_fields(update=True): + custom_fields = { + 'Sales Invoice Item': [ + dict(fieldname='product_tax_category', fieldtype='Link', insert_after='description', options='Product Tax Category', + label='Product Tax Category', fetch_from='item_code.product_tax_category'), + dict(fieldname='tax_collectable', fieldtype='Currency', insert_after='net_amount', + label='Tax Collectable', read_only=1, options='currency'), + dict(fieldname='taxable_amount', fieldtype='Currency', insert_after='tax_collectable', + label='Taxable Amount', read_only=1, options='currency') + ], + 'Item': [ + dict(fieldname='product_tax_category', fieldtype='Link', insert_after='item_group', options='Product Tax Category', + label='Product Tax Category') + ] + } + create_custom_fields(custom_fields, update=update) + +def add_permissions(): + doctype = "Product Tax Category" + for role in ('Accounts Manager', 'Accounts User', 'System Manager','Item Manager', 'Stock Manager'): + add_permission(doctype, role, 0) + update_permission_property(doctype, role, 0, 'write', 1) + update_permission_property(doctype, role, 0, 'create', 1) diff --git a/erpnext/erpnext_integrations/taxjar_integration.py b/erpnext/erpnext_integrations/taxjar_integration.py index 870a4ef54cc4..a4e21579e329 100644 --- a/erpnext/erpnext_integrations/taxjar_integration.py +++ b/erpnext/erpnext_integrations/taxjar_integration.py @@ -4,9 +4,9 @@ import taxjar from frappe import _ from frappe.contacts.doctype.address.address import get_company_address -from frappe.utils import cint +from frappe.utils import cint, flt -from erpnext import get_default_company +from erpnext import get_default_company, get_region TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head") SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head") @@ -21,6 +21,7 @@ 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'] + def get_client(): taxjar_settings = frappe.get_single("TaxJar Settings") @@ -103,7 +104,7 @@ def get_tax_data(doc): shipping = sum([tax.tax_amount for tax in doc.taxes if tax.account_head == SHIP_ACCOUNT_HEAD]) - line_items = [get_line_item_dict(item) for item in doc.items] + line_items = [get_line_item_dict(item, doc.docstatus) for item in doc.items] if from_shipping_state not in SUPPORTED_STATE_CODES: from_shipping_state = get_state_code(from_address, 'Company') @@ -139,18 +140,28 @@ def get_state_code(address, location): return state_code -def get_line_item_dict(item): - return dict( +def get_line_item_dict(item, docstatus): + tax_dict = dict( id = item.get('idx'), quantity = item.get('qty'), unit_price = item.get('rate'), product_tax_code = item.get('product_tax_category') ) + if docstatus == 1: + tax_dict.update({ + 'sales_tax':item.get('tax_collectable') + }) + + return tax_dict + def set_sales_tax(doc, method): if not TAXJAR_CALCULATE_TAX: return + if get_region(doc.company) != 'United States': + return + if not doc.items: return @@ -164,6 +175,9 @@ def set_sales_tax(doc, method): setattr(doc, "taxes", [tax for tax in doc.taxes if tax.account_head != TAX_ACCOUNT_HEAD]) return + # check if delivering within a nexus + check_for_nexus(doc, tax_dict) + tax_data = validate_tax_request(tax_dict) if tax_data is not None: if not tax_data.amount_to_collect: @@ -191,6 +205,17 @@ def set_sales_tax(doc, method): doc.run_method("calculate_taxes_and_totals") +def check_for_nexus(doc, tax_dict): + if not frappe.db.get_value('TaxJar Nexus', {'region_code': tax_dict["to_state"]}): + for item in doc.get("items"): + item.tax_collectable = flt(0) + item.taxable_amount = flt(0) + + for tax in doc.taxes: + if tax.account_head == TAX_ACCOUNT_HEAD: + doc.taxes.remove(tax) + return + def check_sales_tax_exemption(doc): # if the party is exempt from sales tax, then set all tax account heads to zero sales_tax_exempted = hasattr(doc, "exempt_from_sales_tax") and doc.exempt_from_sales_tax \ @@ -241,7 +266,7 @@ def get_shipping_address_details(doc): if doc.shipping_address_name: shipping_address = frappe.get_doc("Address", doc.shipping_address_name) elif doc.customer_address: - shipping_address = frappe.get_doc("Address", doc.customer_address_name) + shipping_address = frappe.get_doc("Address", doc.customer_address) else: shipping_address = get_company_address_details(doc) diff --git a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py index 9cede8a2f694..d40e41091200 100644 --- a/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py +++ b/erpnext/healthcare/doctype/inpatient_record/inpatient_record.py @@ -62,7 +62,9 @@ def admit(self, service_unit, check_in, expected_discharge=None): admit_patient(self, service_unit, check_in, expected_discharge) @frappe.whitelist() - def discharge(self, check_out=now_datetime()): + def discharge(self, check_out=None): + if not check_out: + check_out = now_datetime() if (getdate(check_out) < getdate(self.admitted_datetime)): frappe.throw(_('Discharge date cannot be less than Admission date')) discharge_patient(self, check_out) diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js index 49847d5bc8a1..bba001c9c0e6 100644 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.js @@ -433,11 +433,12 @@ let check_and_set_availability = function(frm) { slot_html += `
${__('Maximum Capacity:')} ${slot_info.service_unit_capacity} `; } - slot_html += '


'; + slot_html += '
'; slot_html += slot_info.avail_slot.map(slot => { appointment_count = 0; disabled = false; + count_class = tool_tip = ''; start_str = slot.from_time; slot_start_time = moment(slot.from_time, 'HH:mm:ss'); slot_end_time = moment(slot.to_time, 'HH:mm:ss'); @@ -486,10 +487,11 @@ let check_and_set_availability = function(frm) { data-duration=${interval} data-service-unit="${slot_info.service_unit || ''}" style="margin: 0 10px 10px 0; width: auto;" ${disabled ? 'disabled="disabled"' : ""} - data-toggle="tooltip" title="${tool_tip}"> - ${start_str.substring(0, start_str.length - 3)}
- ${count} + data-toggle="tooltip" title="${tool_tip || ''}"> + ${start_str.substring(0, start_str.length - 3)} + ${slot_info.service_unit_capacity ? `
${count} ` : ''} `; + }).join(""); if (slot_info.service_unit_capacity) { diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py index dcbcda09d815..6d4cf4a5addb 100755 --- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py +++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py @@ -354,7 +354,7 @@ def get_available_slots(practitioner_doc, date): validate_practitioner_schedules(schedule_entry, practitioner) practitioner_schedule = frappe.get_doc('Practitioner Schedule', schedule_entry.schedule) - if practitioner_schedule: + if practitioner_schedule and not practitioner_schedule.disabled: available_slots = [] for time_slot in practitioner_schedule.time_slots: if weekday == time_slot.day: diff --git a/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py b/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py index be8d4021144c..2cc918af9e5b 100644 --- a/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py +++ b/erpnext/healthcare/doctype/patient_medical_record/test_patient_medical_record.py @@ -21,6 +21,7 @@ class TestPatientMedicalRecord(unittest.TestCase): def setUp(self): frappe.db.set_value('Healthcare Settings', None, 'enable_free_follow_ups', 0) frappe.db.set_value('Healthcare Settings', None, 'automate_appointment_invoicing', 1) + frappe.db.sql('delete from `tabPatient Appointment`') make_pos_profile() def test_medical_record(self): diff --git a/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py b/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py index 4f96f6a7066b..021ba9bb1c36 100644 --- a/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py +++ b/erpnext/healthcare/doctype/therapy_plan/test_therapy_plan.py @@ -6,7 +6,7 @@ import unittest import frappe -from frappe.utils import flt, getdate, nowdate +from frappe.utils import add_days, flt, getdate, nowdate from erpnext.healthcare.doctype.patient_appointment.test_patient_appointment import ( create_appointment, @@ -33,10 +33,12 @@ def test_status(self): self.assertEqual(plan.status, 'Not Started') session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company') + session.start_date = getdate() frappe.get_doc(session).submit() self.assertEqual(frappe.db.get_value('Therapy Plan', plan.name, 'status'), 'In Progress') session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company') + session.start_date = add_days(getdate(), 1) frappe.get_doc(session).submit() self.assertEqual(frappe.db.get_value('Therapy Plan', plan.name, 'status'), 'Completed') @@ -44,6 +46,7 @@ def test_status(self): appointment = create_appointment(patient, practitioner, nowdate()) session = make_therapy_session(plan.name, plan.patient, 'Basic Rehab', '_Test Company', appointment.name) + session.start_date = add_days(getdate(), 2) session = frappe.get_doc(session) session.submit() self.assertEqual(frappe.db.get_value('Patient Appointment', appointment.name, 'status'), 'Closed') diff --git a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py index 6d63f3918959..b31a9527a8ca 100644 --- a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py +++ b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py @@ -6,7 +6,7 @@ import frappe from frappe.model.document import Document -from frappe.utils import flt, today +from frappe.utils import flt class TherapyPlan(Document): @@ -63,8 +63,6 @@ def make_therapy_session(therapy_plan, patient, therapy_type, company, appointme therapy_session.exercises = therapy_type.exercises therapy_session.appointment = appointment - if frappe.flags.in_test: - therapy_session.start_date = today() return therapy_session.as_dict() diff --git a/erpnext/healthcare/utils.py b/erpnext/healthcare/utils.py index 0d2d89d6e0e5..2b60231bdb77 100644 --- a/erpnext/healthcare/utils.py +++ b/erpnext/healthcare/utils.py @@ -611,7 +611,7 @@ def render_docs_as_html(docs): @frappe.whitelist() -def render_doc_as_html(doctype, docname, exclude_fields = []): +def render_doc_as_html(doctype, docname, exclude_fields = None): """ Render document as HTML """ @@ -622,6 +622,9 @@ def render_doc_as_html(doctype, docname, exclude_fields = []): sec_on = has_data = False col_on = 0 + if exclude_fields is None: + exclude_fields = [] + for df in meta.fields: # on section break append previous section and html to doc html if df.fieldtype == "Section Break": diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 396e1c480411..6be3e25440a6 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -257,6 +257,7 @@ "validate": "erpnext.regional.india.utils.validate_tax_category" }, "Sales Invoice": { + "after_insert": "erpnext.regional.saudi_arabia.utils.create_qr_code", "on_submit": [ "erpnext.regional.create_transaction_log", "erpnext.regional.italy.utils.sales_invoice_on_submit", @@ -266,7 +267,10 @@ "erpnext.regional.italy.utils.sales_invoice_on_cancel", "erpnext.erpnext_integrations.taxjar_integration.delete_transaction" ], - "on_trash": "erpnext.regional.check_deletion_permission", + "on_trash": [ + "erpnext.regional.check_deletion_permission", + "erpnext.regional.saudi_arabia.utils.delete_qr_code_file" + ], "validate": [ "erpnext.regional.india.utils.validate_document_name", "erpnext.regional.india.utils.update_taxable_values" @@ -338,6 +342,7 @@ "all": [ "erpnext.projects.doctype.project.project.project_status_update_reminder", "erpnext.healthcare.doctype.patient_appointment.patient_appointment.send_appointment_reminder", + "erpnext.hr.doctype.interview.interview.send_interview_reminder", "erpnext.crm.doctype.social_media_post.social_media_post.process_scheduled_social_media_posts" ], "hourly": [ @@ -383,6 +388,7 @@ "erpnext.buying.doctype.supplier_quotation.supplier_quotation.set_expired_status", "erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_auto_email", "erpnext.non_profit.doctype.membership.membership.set_expired_status" + "erpnext.hr.doctype.interview.interview.send_daily_feedback_reminder" ], "daily_long": [ "erpnext.setup.doctype.email_digest.email_digest.send", diff --git a/erpnext/hr/doctype/attendance/attendance_list.js b/erpnext/hr/doctype/attendance/attendance_list.js index 9a3bac0eb23b..6b3c29a76b43 100644 --- a/erpnext/hr/doctype/attendance/attendance_list.js +++ b/erpnext/hr/doctype/attendance/attendance_list.js @@ -9,83 +9,86 @@ frappe.listview_settings['Attendance'] = { return [__(doc.status), "orange", "status,=," + doc.status]; } }, + onload: function(list_view) { let me = this; - const months = moment.months() - list_view.page.add_inner_button( __("Mark Attendance"), function() { + const months = moment.months(); + list_view.page.add_inner_button(__("Mark Attendance"), function() { let dialog = new frappe.ui.Dialog({ title: __("Mark Attendance"), - fields: [ - { - fieldname: 'employee', - label: __('For Employee'), - fieldtype: 'Link', - options: 'Employee', - get_query: () => { - return {query: "erpnext.controllers.queries.employee_query"} - }, - reqd: 1, - onchange: function() { - dialog.set_df_property("unmarked_days", "hidden", 1); - dialog.set_df_property("status", "hidden", 1); - dialog.set_df_property("month", "value", ''); + fields: [{ + fieldname: 'employee', + label: __('For Employee'), + fieldtype: 'Link', + options: 'Employee', + get_query: () => { + return {query: "erpnext.controllers.queries.employee_query"}; + }, + reqd: 1, + onchange: function() { + dialog.set_df_property("unmarked_days", "hidden", 1); + dialog.set_df_property("status", "hidden", 1); + dialog.set_df_property("month", "value", ''); + dialog.set_df_property("unmarked_days", "options", []); + dialog.no_unmarked_days_left = false; + } + }, + { + label: __("For Month"), + fieldtype: "Select", + fieldname: "month", + options: months, + reqd: 1, + onchange: function() { + if (dialog.fields_dict.employee.value && dialog.fields_dict.month.value) { + dialog.set_df_property("status", "hidden", 0); dialog.set_df_property("unmarked_days", "options", []); dialog.no_unmarked_days_left = false; + me.get_multi_select_options(dialog.fields_dict.employee.value, dialog.fields_dict.month.value).then(options => { + if (options.length > 0) { + dialog.set_df_property("unmarked_days", "hidden", 0); + dialog.set_df_property("unmarked_days", "options", options); + } else { + dialog.no_unmarked_days_left = true; + } + }); } - }, - { - label: __("For Month"), - fieldtype: "Select", - fieldname: "month", - options: months, - reqd: 1, - onchange: function() { - if(dialog.fields_dict.employee.value && dialog.fields_dict.month.value) { - dialog.set_df_property("status", "hidden", 0); - dialog.set_df_property("unmarked_days", "options", []); - dialog.no_unmarked_days_left = false; - me.get_multi_select_options(dialog.fields_dict.employee.value, dialog.fields_dict.month.value).then(options =>{ - if (options.length > 0) { - dialog.set_df_property("unmarked_days", "hidden", 0); - dialog.set_df_property("unmarked_days", "options", options); - } else { - dialog.no_unmarked_days_left = true; - } - }); - } - } - }, - { - label: __("Status"), - fieldtype: "Select", - fieldname: "status", - options: ["Present", "Absent", "Half Day", "Work From Home"], - hidden:1, - reqd: 1, + } + }, + { + label: __("Status"), + fieldtype: "Select", + fieldname: "status", + options: ["Present", "Absent", "Half Day", "Work From Home"], + hidden: 1, + reqd: 1, - }, - { - label: __("Unmarked Attendance for days"), - fieldname: "unmarked_days", - fieldtype: "MultiCheck", - options: [], - columns: 2, - hidden: 1 - }, - ], - primary_action(data) { + }, + { + label: __("Unmarked Attendance for days"), + fieldname: "unmarked_days", + fieldtype: "MultiCheck", + options: [], + columns: 2, + hidden: 1 + }], + primary_action(data) { if (cur_dialog.no_unmarked_days_left) { - frappe.msgprint(__("Attendance for the month of {0} , has already been marked for the Employee {1}",[dialog.fields_dict.month.value, dialog.fields_dict.employee.value])); + frappe.msgprint(__("Attendance for the month of {0} , has already been marked for the Employee {1}", + [dialog.fields_dict.month.value, dialog.fields_dict.employee.value])); } else { - frappe.confirm(__('Mark attendance as {0} for {1} on selected dates?', [data.status,data.month]), () => { + frappe.confirm(__('Mark attendance as {0} for {1} on selected dates?', [data.status, data.month]), () => { frappe.call({ method: "erpnext.hr.doctype.attendance.attendance.mark_bulk_attendance", args: { data: data }, - callback: function(r) { + callback: function (r) { if (r.message === 1) { - frappe.show_alert({message: __("Attendance Marked"), indicator: 'blue'}); + frappe.show_alert({ + message: __("Attendance Marked"), + indicator: 'blue' + }); cur_dialog.hide(); } } @@ -101,21 +104,26 @@ frappe.listview_settings['Attendance'] = { dialog.show(); }); }, - get_multi_select_options: function(employee, month){ + + get_multi_select_options: function(employee, month) { return new Promise(resolve => { frappe.call({ method: 'erpnext.hr.doctype.attendance.attendance.get_unmarked_days', async: false, - args:{ + args: { employee: employee, month: month, } }).then(r => { var options = []; - for(var d in r.message){ + for (var d in r.message) { var momentObj = moment(r.message[d], 'YYYY-MM-DD'); var date = momentObj.format('DD-MM-YYYY'); - options.push({ "label":date, "value": r.message[d] , "checked": 1}); + options.push({ + "label": date, + "value": r.message[d], + "checked": 1 + }); } resolve(options); }); diff --git a/erpnext/hr/doctype/daily_work_summary/test_daily_work_summary.py b/erpnext/hr/doctype/daily_work_summary/test_daily_work_summary.py index bed12e31eaa0..8a23682ad470 100644 --- a/erpnext/hr/doctype/daily_work_summary/test_daily_work_summary.py +++ b/erpnext/hr/doctype/daily_work_summary/test_daily_work_summary.py @@ -74,7 +74,6 @@ def setup_and_prepare_test(self, hour=None): from `tabEmail Queue` as q, `tabEmail Queue Recipient` as r \ where q.name = r.parent""", as_dict=1) - frappe.db.commit() def setup_groups(self, hour=None): # setup email to trigger at this hour diff --git a/erpnext/hr/doctype/employee/employee.js b/erpnext/hr/doctype/employee/employee.js index c21d4b893cc0..1ac40c6186d3 100755 --- a/erpnext/hr/doctype/employee/employee.js +++ b/erpnext/hr/doctype/employee/employee.js @@ -4,40 +4,46 @@ frappe.provide("erpnext.hr"); erpnext.hr.EmployeeController = frappe.ui.form.Controller.extend({ setup: function() { - this.frm.fields_dict.user_id.get_query = function(doc, cdt, cdn) { + this.frm.fields_dict.user_id.get_query = function() { return { query: "frappe.core.doctype.user.user.user_query", - filters: {ignore_user_type: 1} - } - } - this.frm.fields_dict.reports_to.get_query = function(doc, cdt, cdn) { - return { query: "erpnext.controllers.queries.employee_query"} } + filters: { + ignore_user_type: 1 + } + }; + }; + this.frm.fields_dict.reports_to.get_query = function() { + return { + query: "erpnext.controllers.queries.employee_query" + }; + }; }, refresh: function() { - var me = this; erpnext.toggle_naming_series(); }, date_of_birth: function() { return cur_frm.call({ method: "get_retirement_date", - args: {date_of_birth: this.frm.doc.date_of_birth} + args: { + date_of_birth: this.frm.doc.date_of_birth + } }); }, salutation: function() { - if(this.frm.doc.salutation) { + if (this.frm.doc.salutation) { this.frm.set_value("gender", { "Mr": "Male", "Ms": "Female" - }[this.frm.doc.salutation]); + } [this.frm.doc.salutation]); } }, }); -frappe.ui.form.on('Employee',{ - setup: function(frm) { +frappe.ui.form.on('Employee', { + setup: function (frm) { frm.set_query("leave_policy", function() { return { "filters": { @@ -46,7 +52,7 @@ frappe.ui.form.on('Employee',{ }; }); }, - onload:function(frm) { + onload: function (frm) { frm.set_query("department", function() { return { "filters": { @@ -55,23 +61,28 @@ frappe.ui.form.on('Employee',{ }; }); }, - prefered_contact_email:function(frm){ - frm.events.update_contact(frm) + prefered_contact_email: function(frm) { + frm.events.update_contact(frm); }, - personal_email:function(frm){ - frm.events.update_contact(frm) + + personal_email: function(frm) { + frm.events.update_contact(frm); }, - company_email:function(frm){ - frm.events.update_contact(frm) + + company_email: function(frm) { + frm.events.update_contact(frm); }, - user_id:function(frm){ - frm.events.update_contact(frm) + + user_id: function(frm) { + frm.events.update_contact(frm); }, - update_contact:function(frm){ + + update_contact: function(frm) { var prefered_email_fieldname = frappe.model.scrub(frm.doc.prefered_contact_email) || 'user_id'; frm.set_value("prefered_email", - frm.fields_dict[prefered_email_fieldname].value) + frm.fields_dict[prefered_email_fieldname].value); }, + status: function(frm) { return frm.call({ method: "deactivate_sales_person", @@ -81,19 +92,63 @@ frappe.ui.form.on('Employee',{ } }); }, + create_user: function(frm) { - if (!frm.doc.prefered_email) - { - frappe.throw(__("Please enter Preferred Contact Email")) + if (!frm.doc.prefered_email) { + frappe.throw(__("Please enter Preferred Contact Email")); } frappe.call({ method: "erpnext.hr.doctype.employee.employee.create_user", - args: { employee: frm.doc.name, email: frm.doc.prefered_email }, - callback: function(r) - { - frm.set_value("user_id", r.message) + args: { + employee: frm.doc.name, + email: frm.doc.prefered_email + }, + callback: function (r) { + frm.set_value("user_id", r.message); } }); } }); -cur_frm.cscript = new erpnext.hr.EmployeeController({frm: cur_frm}); + +cur_frm.cscript = new erpnext.hr.EmployeeController({ + frm: cur_frm +}); + + +frappe.tour['Employee'] = [ + { + fieldname: "first_name", + title: "First Name", + description: __("Enter First and Last name of Employee, based on Which Full Name will be updated. IN transactions, it will be Full Name which will be fetched.") + }, + { + fieldname: "company", + title: "Company", + description: __("Select a Company this Employee belongs to. Other HR features like Payroll. Expense Claims and Leaves for this Employee will be created for a given company only.") + }, + { + fieldname: "date_of_birth", + title: "Date of Birth", + description: __("Select Date of Birth. This will validate Employees age and prevent hiring of under-age staff.") + }, + { + fieldname: "date_of_joining", + title: "Date of Joining", + description: __("Select Date of joining. It will have impact on the first salary calculation, Leave allocation on pro-rata bases.") + }, + { + fieldname: "holiday_list", + title: "Holiday List", + description: __("Select a default Holiday List for this Employee. The days listed in Holiday List will not be counted in Leave Application.") + }, + { + fieldname: "reports_to", + title: "Reports To", + description: __("Here, you can select a senior of this Employee. Based on this, Organization Chart will be populated.") + }, + { + fieldname: "leave_approver", + title: "Leave Approver", + description: __("Select Leave Approver for an employee. The user one who will look after his/her Leave application") + }, +]; diff --git a/erpnext/hr/doctype/employee/employee_reminders.py b/erpnext/hr/doctype/employee/employee_reminders.py index 216d8f6bb3a2..559bd393e628 100644 --- a/erpnext/hr/doctype/employee/employee_reminders.py +++ b/erpnext/hr/doctype/employee/employee_reminders.py @@ -156,6 +156,8 @@ def get_employees_having_an_event_today(event_type): DAY({condition_column}) = DAY(%(today)s) AND MONTH({condition_column}) = MONTH(%(today)s) + AND + YEAR({condition_column}) < YEAR(%(today)s) AND `status` = 'Active' """, @@ -166,6 +168,8 @@ def get_employees_having_an_event_today(event_type): DATE_PART('day', {condition_column}) = date_part('day', %(today)s) AND DATE_PART('month', {condition_column}) = date_part('month', %(today)s) + AND + DATE_PART('year', {condition_column}) < date_part('year', %(today)s) AND "status" = 'Active' """, diff --git a/erpnext/hr/doctype/employee/test_employee.py b/erpnext/hr/doctype/employee/test_employee.py index 8d6dfa2c1d26..8a2da0866e9d 100644 --- a/erpnext/hr/doctype/employee/test_employee.py +++ b/erpnext/hr/doctype/employee/test_employee.py @@ -55,6 +55,7 @@ def make_employee(user, company=None, **kwargs): "email": user, "first_name": user, "new_password": "password", + "send_welcome_email": 0, "roles": [{"doctype": "Has Role", "role": "Employee"}] }).insert() diff --git a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py index 33cad7b7e9b3..bc369bc5b46b 100644 --- a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py +++ b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py @@ -72,6 +72,7 @@ def get_job_applicant(): applicant = frappe.new_doc('Job Applicant') applicant.applicant_name = 'Test Researcher' applicant.email_id = 'test@researcher.com' + applicant.designation = 'Researcher' applicant.status = 'Open' applicant.cover_letter = 'I am a great Researcher.' applicant.insert() diff --git a/erpnext/hr/doctype/employee_promotion/employee_promotion.py b/erpnext/hr/doctype/employee_promotion/employee_promotion.py index 164d48b89523..b05175200e98 100644 --- a/erpnext/hr/doctype/employee_promotion/employee_promotion.py +++ b/erpnext/hr/doctype/employee_promotion/employee_promotion.py @@ -9,7 +9,7 @@ from frappe.model.document import Document from frappe.utils import getdate -from erpnext.hr.utils import update_employee, validate_active_employee +from erpnext.hr.utils import update_employee_work_history, validate_active_employee class EmployeePromotion(Document): @@ -23,10 +23,10 @@ def before_submit(self): def on_submit(self): employee = frappe.get_doc("Employee", self.employee) - employee = update_employee(employee, self.promotion_details, date=self.promotion_date) + employee = update_employee_work_history(employee, self.promotion_details, date=self.promotion_date) employee.save() def on_cancel(self): employee = frappe.get_doc("Employee", self.employee) - employee = update_employee(employee, self.promotion_details, cancel=True) + employee = update_employee_work_history(employee, self.promotion_details, cancel=True) employee.save() diff --git a/erpnext/hr/doctype/employee_referral/employee_referral.py b/erpnext/hr/doctype/employee_referral/employee_referral.py index 5cb5bb5fd3a2..db356bf91f16 100644 --- a/erpnext/hr/doctype/employee_referral/employee_referral.py +++ b/erpnext/hr/doctype/employee_referral/employee_referral.py @@ -38,8 +38,10 @@ def create_job_applicant(source_name, target_doc=None): status = "Open" job_applicant = frappe.new_doc("Job Applicant") + job_applicant.source = "Employee Referral" job_applicant.employee_referral = emp_ref.name job_applicant.status = status + job_applicant.designation = emp_ref.for_designation job_applicant.applicant_name = emp_ref.full_name job_applicant.email_id = emp_ref.email job_applicant.phone_number = emp_ref.contact_no diff --git a/erpnext/hr/doctype/employee_referral/test_employee_referral.py b/erpnext/hr/doctype/employee_referral/test_employee_referral.py index d0ee2fcdea77..1340f62bbf45 100644 --- a/erpnext/hr/doctype/employee_referral/test_employee_referral.py +++ b/erpnext/hr/doctype/employee_referral/test_employee_referral.py @@ -17,6 +17,11 @@ class TestEmployeeReferral(unittest.TestCase): + + def setUp(self): + frappe.db.sql("DELETE FROM `tabJob Applicant`") + frappe.db.sql("DELETE FROM `tabEmployee Referral`") + def test_workflow_and_status_sync(self): emp_ref = create_employee_referral() @@ -50,6 +55,10 @@ def test_workflow_and_status_sync(self): add_sal = create_additional_salary(emp_ref) self.assertTrue(add_sal.ref_docname, emp_ref.name) + def tearDown(self): + frappe.db.sql("DELETE FROM `tabJob Applicant`") + frappe.db.sql("DELETE FROM `tabEmployee Referral`") + def create_employee_referral(): emp_ref = frappe.new_doc("Employee Referral") diff --git a/erpnext/hr/doctype/employee_transfer/employee_transfer.py b/erpnext/hr/doctype/employee_transfer/employee_transfer.py index b1f66098f0da..29d93f348cc0 100644 --- a/erpnext/hr/doctype/employee_transfer/employee_transfer.py +++ b/erpnext/hr/doctype/employee_transfer/employee_transfer.py @@ -9,7 +9,7 @@ from frappe.model.document import Document from frappe.utils import getdate -from erpnext.hr.utils import update_employee +from erpnext.hr.utils import update_employee_work_history class EmployeeTransfer(Document): @@ -24,7 +24,7 @@ def on_submit(self): new_employee = frappe.copy_doc(employee) new_employee.name = None new_employee.employee_number = None - new_employee = update_employee(new_employee, self.transfer_details, date=self.transfer_date) + new_employee = update_employee_work_history(new_employee, self.transfer_details, date=self.transfer_date) if self.new_company and self.company != self.new_company: new_employee.internal_work_history = [] new_employee.date_of_joining = self.transfer_date @@ -39,7 +39,7 @@ def on_submit(self): employee.db_set("relieving_date", self.transfer_date) employee.db_set("status", "Left") else: - employee = update_employee(employee, self.transfer_details, date=self.transfer_date) + employee = update_employee_work_history(employee, self.transfer_details, date=self.transfer_date) if self.new_company and self.company != self.new_company: employee.company = self.new_company employee.date_of_joining = self.transfer_date @@ -56,7 +56,7 @@ def on_cancel(self): employee.status = "Active" employee.relieving_date = '' else: - employee = update_employee(employee, self.transfer_details, cancel=True) + employee = update_employee_work_history(employee, self.transfer_details, date=self.transfer_date, cancel=True) if self.new_company != self.company: employee.company = self.company employee.save() diff --git a/erpnext/hr/doctype/employee_transfer/test_employee_transfer.py b/erpnext/hr/doctype/employee_transfer/test_employee_transfer.py index ad2f3ade0547..c0440d09e746 100644 --- a/erpnext/hr/doctype/employee_transfer/test_employee_transfer.py +++ b/erpnext/hr/doctype/employee_transfer/test_employee_transfer.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import unittest +from datetime import date import frappe from frappe.utils import add_days, getdate @@ -15,7 +16,12 @@ class TestEmployeeTransfer(unittest.TestCase): def setUp(self): make_employee("employee2@transfers.com") make_employee("employee3@transfers.com") - frappe.db.sql("""delete from `tabEmployee Transfer`""") + create_company() + create_employee() + create_employee_transfer() + + def tearDown(self): + frappe.db.rollback() def test_submit_before_transfer_date(self): transfer_obj = frappe.get_doc({ @@ -57,3 +63,77 @@ def test_new_employee_creation(self): self.assertTrue(transfer.new_employee_id) self.assertEqual(frappe.get_value("Employee", transfer.new_employee_id, "status"), "Active") self.assertEqual(frappe.get_value("Employee", transfer.employee, "status"), "Left") + + def test_employee_history(self): + name = frappe.get_value("Employee", {"first_name": "John", "company": "Test Company"}, "name") + doc = frappe.get_doc("Employee",name) + count = 0 + department = ["Accounts - TC", "Management - TC"] + designation = ["Accountant", "Manager"] + dt = [getdate("01-10-2021"), date.today()] + + for data in doc.internal_work_history: + self.assertEqual(data.department, department[count]) + self.assertEqual(data.designation, designation[count]) + self.assertEqual(data.from_date, dt[count]) + count = count + 1 + + data = frappe.db.get_list("Employee Transfer", filters={"employee":name}, fields=["*"]) + doc = frappe.get_doc("Employee Transfer", data[0]["name"]) + doc.cancel() + employee_doc = frappe.get_doc("Employee",name) + + for data in employee_doc.internal_work_history: + self.assertEqual(data.designation, designation[0]) + self.assertEqual(data.department, department[0]) + self.assertEqual(data.from_date, dt[0]) + +def create_employee(): + doc = frappe.get_doc({ + "doctype": "Employee", + "first_name": "John", + "company": "Test Company", + "gender": "Male", + "date_of_birth": getdate("30-09-1980"), + "date_of_joining": getdate("01-10-2021"), + "department": "Accounts - TC", + "designation": "Accountant" + }) + + doc.save() + +def create_company(): + exists = frappe.db.exists("Company", "Test Company") + if not exists: + doc = frappe.get_doc({ + "doctype": "Company", + "company_name": "Test Company", + "default_currency": "INR", + "country": "India" + }) + + doc.save() + +def create_employee_transfer(): + doc = frappe.get_doc({ + "doctype": "Employee Transfer", + "employee": frappe.get_value("Employee", {"first_name": "John", "company": "Test Company"}, "name"), + "transfer_date": date.today(), + "transfer_details": [ + { + "property": "Designation", + "current": "Accountant", + "new": "Manager", + "fieldname": "designation" + }, + { + "property": "Department", + "current": "Accounts - TC", + "new": "Management - TC", + "fieldname": "department" + } + ] + }) + + doc.save() + doc.submit() \ No newline at end of file diff --git a/erpnext/hr/doctype/expected_skill_set/__init__.py b/erpnext/hr/doctype/expected_skill_set/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/hr/doctype/expected_skill_set/expected_skill_set.json b/erpnext/hr/doctype/expected_skill_set/expected_skill_set.json new file mode 100644 index 000000000000..899f5bd0ff4d --- /dev/null +++ b/erpnext/hr/doctype/expected_skill_set/expected_skill_set.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "creation": "2021-04-12 13:05:06.741330", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "skill", + "description" + ], + "fields": [ + { + "fieldname": "skill", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Skill", + "options": "Skill", + "reqd": 1 + }, + { + "fetch_from": "skill.description", + "fieldname": "description", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Description" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-04-12 14:26:33.062549", + "modified_by": "Administrator", + "module": "HR", + "name": "Expected Skill Set", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/expected_skill_set/expected_skill_set.py b/erpnext/hr/doctype/expected_skill_set/expected_skill_set.py new file mode 100644 index 000000000000..27120c1fb375 --- /dev/null +++ b/erpnext/hr/doctype/expected_skill_set/expected_skill_set.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +# import frappe +from frappe.model.document import Document + + +class ExpectedSkillSet(Document): + pass diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.js b/erpnext/hr/doctype/expense_claim/expense_claim.js index 3c4c672816c9..218e97d7fc25 100644 --- a/erpnext/hr/doctype/expense_claim/expense_claim.js +++ b/erpnext/hr/doctype/expense_claim/expense_claim.js @@ -10,6 +10,26 @@ frappe.ui.form.on('Expense Claim', { }, company: function(frm) { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + var expenses = frm.doc.expenses; + for (var i = 0; i < expenses.length; i++) { + var expense = expenses[i]; + if (!expense.expense_type) { + continue; + } + frappe.call({ + method: "erpnext.hr.doctype.expense_claim.expense_claim.get_expense_claim_account_and_cost_center", + args: { + "expense_claim_type": expense.expense_type, + "company": frm.doc.company + }, + callback: function(r) { + if (r.message) { + expense.default_account = r.message.account; + expense.cost_center = r.message.cost_center; + } + } + }); + } }, }); diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py index e54c1457244a..86a8b877e4bd 100644 --- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py @@ -176,7 +176,7 @@ def generate_taxes(): account = create_account(company=company_name, account_name="Output Tax CGST", account_type="Tax", parent_account=parent_account) return {'taxes':[{ "account_head": account, - "rate": 0, + "rate": 9, "description": "CGST", "tax_amount": 10, "total": 210 diff --git a/erpnext/hr/doctype/expense_taxes_and_charges/expense_taxes_and_charges.json b/erpnext/hr/doctype/expense_taxes_and_charges/expense_taxes_and_charges.json index 020457d4ec6a..2f7b8fcf679e 100644 --- a/erpnext/hr/doctype/expense_taxes_and_charges/expense_taxes_and_charges.json +++ b/erpnext/hr/doctype/expense_taxes_and_charges/expense_taxes_and_charges.json @@ -56,8 +56,6 @@ }, { "columns": 2, - "fetch_from": "account_head.tax_rate", - "fetch_if_empty": 1, "fieldname": "rate", "fieldtype": "Float", "in_list_view": 1, @@ -102,7 +100,7 @@ ], "istable": 1, "links": [], - "modified": "2020-09-23 20:27:36.027728", + "modified": "2021-10-26 20:27:36.027728", "modified_by": "Administrator", "module": "HR", "name": "Expense Taxes and Charges", @@ -111,4 +109,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/hr/doctype/holiday_list/holiday_list.js b/erpnext/hr/doctype/holiday_list/holiday_list.js index 462bd8bb6718..ea033c7ed92f 100644 --- a/erpnext/hr/doctype/holiday_list/holiday_list.js +++ b/erpnext/hr/doctype/holiday_list/holiday_list.js @@ -1,10 +1,10 @@ // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -frappe.ui.form.on('Holiday List', { +frappe.ui.form.on("Holiday List", { refresh: function(frm) { if (frm.doc.holidays) { - frm.set_value('total_holidays', frm.doc.holidays.length); + frm.set_value("total_holidays", frm.doc.holidays.length); } }, from_date: function(frm) { @@ -14,3 +14,36 @@ frappe.ui.form.on('Holiday List', { } } }); + +frappe.tour["Holiday List"] = [ + { + fieldname: "holiday_list_name", + title: "Holiday List Name", + description: __("Enter a name for this Holiday List."), + }, + { + fieldname: "from_date", + title: "From Date", + description: __("Based on your HR Policy, select your leave allocation period's start date"), + }, + { + fieldname: "to_date", + title: "To Date", + description: __("Based on your HR Policy, select your leave allocation period's end date"), + }, + { + fieldname: "weekly_off", + title: "Weekly Off", + description: __("Select your weekly off day"), + }, + { + fieldname: "get_weekly_off_dates", + title: "Add Holidays", + description: __("Click on Add to Holidays. This will populate the holidays table with all the dates that fall on the selected weekly off. Repeat the process for populating the dates for all your weekly holidays"), + }, + { + fieldname: "holidays", + title: "Holidays", + description: __("Here, your weekly offs are pre-populated based on the previous selections. You can add more rows to also add public and national holidays individually.") + }, +]; diff --git a/erpnext/hr/doctype/holiday_list/holiday_list.py b/erpnext/hr/doctype/holiday_list/holiday_list.py index 8d91ac2ddd7b..89a8784a755f 100644 --- a/erpnext/hr/doctype/holiday_list/holiday_list.py +++ b/erpnext/hr/doctype/holiday_list/holiday_list.py @@ -1,4 +1,3 @@ - # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt @@ -94,9 +93,11 @@ def get_events(start, end, filters=None): update={"allDay": 1}) -def is_holiday(holiday_list, date=today()): +def is_holiday(holiday_list, date=None): """Returns true if the given date is a holiday in the given holiday list """ + if date is None: + date = today() if holiday_list: return bool(frappe.get_all('Holiday List', dict(name=holiday_list, holiday_date=date))) diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.js b/erpnext/hr/doctype/hr_settings/hr_settings.js index ec99472d9bc2..6e26a1fa71db 100644 --- a/erpnext/hr/doctype/hr_settings/hr_settings.js +++ b/erpnext/hr/doctype/hr_settings/hr_settings.js @@ -2,7 +2,22 @@ // For license information, please see license.txt frappe.ui.form.on('HR Settings', { - restrict_backdated_leave_application: function(frm) { - frm.toggle_reqd("role_allowed_to_create_backdated_leave_application", frm.doc.restrict_backdated_leave_application); - } }); + +frappe.tour['HR Settings'] = [ + { + fieldname: 'emp_created_by', + title: 'Employee Naming By', + description: __('Employee can be named by Employee ID if you assign one, or via Naming Series. Select your preference here.'), + }, + { + fieldname: 'standard_working_hours', + title: 'Standard Working Hours', + description: __('Enter the Standard Working Hours for a normal work day. These hours will be used in calculations of reports such as Employee Hours Utilization and Project Profitability analysis.'), + }, + { + fieldname: 'leave_and_expense_claim_settings', + title: 'Leave and Expense Clain Settings', + description: __('Review various other settings related to Employee Leaves and Expense Claim') + } +]; diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json index 8aa3c0ca9f11..5148435c1304 100644 --- a/erpnext/hr/doctype/hr_settings/hr_settings.json +++ b/erpnext/hr/doctype/hr_settings/hr_settings.json @@ -7,30 +7,36 @@ "engine": "InnoDB", "field_order": [ "employee_settings", - "retirement_age", "emp_created_by", - "column_break_4", "standard_working_hours", - "expense_approver_mandatory_in_expense_claim", + "column_break_9", + "retirement_age", "reminders_section", "send_birthday_reminders", - "column_break_9", - "send_work_anniversary_reminders", "column_break_11", + "send_work_anniversary_reminders", + "column_break_18", "send_holiday_reminders", "frequency", - "leave_settings", + "leave_and_expense_claim_settings", "send_leave_notification", "leave_approval_notification_template", "leave_status_notification_template", - "role_allowed_to_create_backdated_leave_application", - "column_break_18", "leave_approver_mandatory_in_leave_application", + "restrict_backdated_leave_application", + "role_allowed_to_create_backdated_leave_application", + "column_break_29", + "expense_approver_mandatory_in_expense_claim", "show_leaves_of_all_department_members_in_calendar", "auto_leave_encashment", - "restrict_backdated_leave_application", - "hiring_settings", - "check_vacancies" + "hiring_settings_section", + "check_vacancies", + "send_interview_reminder", + "interview_reminder_template", + "remind_before", + "column_break_4", + "send_interview_feedback_reminder", + "feedback_reminder_notification_template" ], "fields": [ { @@ -39,17 +45,16 @@ "label": "Employee Settings" }, { - "description": "Enter retirement age in years", "fieldname": "retirement_age", "fieldtype": "Data", - "label": "Retirement Age" + "label": "Retirement Age (In Years)" }, { "default": "Naming Series", - "description": "Employee records are created using the selected field", + "description": "Employee records are created using the selected option", "fieldname": "emp_created_by", "fieldtype": "Select", - "label": "Employee Records to be created by", + "label": "Employee Naming By", "options": "Naming Series\nEmployee Number\nFull Name" }, { @@ -62,28 +67,6 @@ "fieldtype": "Check", "label": "Expense Approver Mandatory In Expense Claim" }, - { - "collapsible": 1, - "fieldname": "leave_settings", - "fieldtype": "Section Break", - "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" - }, { "fieldname": "column_break_18", "fieldtype": "Column Break" @@ -100,35 +83,18 @@ "fieldtype": "Check", "label": "Show Leaves Of All Department Members In Calendar" }, - { - "collapsible": 1, - "fieldname": "hiring_settings", - "fieldtype": "Section Break", - "label": "Hiring Settings" - }, - { - "default": "0", - "fieldname": "check_vacancies", - "fieldtype": "Check", - "label": "Check Vacancies On Job Offer Creation" - }, { "default": "0", "fieldname": "auto_leave_encashment", "fieldtype": "Check", "label": "Auto Leave Encashment" }, - { - "default": "0", - "fieldname": "restrict_backdated_leave_application", - "fieldtype": "Check", - "label": "Restrict Backdated Leave Application" - }, { "depends_on": "eval:doc.restrict_backdated_leave_application == 1", "fieldname": "role_allowed_to_create_backdated_leave_application", "fieldtype": "Link", "label": "Role Allowed to Create Backdated Leave Application", + "mandatory_depends_on": "eval:doc.restrict_backdated_leave_application == 1", "options": "Role" }, { @@ -137,11 +103,40 @@ "fieldtype": "Check", "label": "Send Leave Notification" }, + { + "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" + }, { "fieldname": "standard_working_hours", "fieldtype": "Int", "label": "Standard Working Hours" }, + { + "collapsible": 1, + "fieldname": "leave_and_expense_claim_settings", + "fieldtype": "Section Break", + "label": "Leave and Expense Claim Settings" + }, + { + "default": "00:15:00", + "depends_on": "send_interview_reminder", + "fieldname": "remind_before", + "fieldtype": "Time", + "label": "Remind Before" + }, { "collapsible": 1, "fieldname": "reminders_section", @@ -166,6 +161,7 @@ "fieldname": "frequency", "fieldtype": "Select", "label": "Set the frequency for holiday reminders", + "mandatory_depends_on": "send_holiday_reminders", "options": "Weekly\nMonthly" }, { @@ -181,13 +177,62 @@ { "fieldname": "column_break_11", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "send_interview_reminder", + "fieldtype": "Check", + "label": "Send Interview Reminder" + }, + { + "default": "0", + "fieldname": "send_interview_feedback_reminder", + "fieldtype": "Check", + "label": "Send Interview Feedback Reminder" + }, + { + "fieldname": "column_break_29", + "fieldtype": "Column Break" + }, + { + "depends_on": "send_interview_feedback_reminder", + "fieldname": "feedback_reminder_notification_template", + "fieldtype": "Link", + "label": "Feedback Reminder Notification Template", + "mandatory_depends_on": "send_interview_feedback_reminder", + "options": "Email Template" + }, + { + "depends_on": "send_interview_reminder", + "fieldname": "interview_reminder_template", + "fieldtype": "Link", + "label": "Interview Reminder Notification Template", + "mandatory_depends_on": "send_interview_reminder", + "options": "Email Template" + }, + { + "default": "0", + "fieldname": "restrict_backdated_leave_application", + "fieldtype": "Check", + "label": "Restrict Backdated Leave Application" + }, + { + "fieldname": "hiring_settings_section", + "fieldtype": "Section Break", + "label": "Hiring Settings" + }, + { + "default": "0", + "fieldname": "check_vacancies", + "fieldtype": "Check", + "label": "Check Vacancies On Job Offer Creation" } ], "icon": "fa fa-cog", "idx": 1, "issingle": 1, "links": [], - "modified": "2021-08-24 14:54:12.834162", + "modified": "2021-10-01 23:46:11.098236", "modified_by": "Administrator", "module": "HR", "name": "HR Settings", diff --git a/erpnext/hr/doctype/interview/__init__.py b/erpnext/hr/doctype/interview/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/hr/doctype/interview/interview.js b/erpnext/hr/doctype/interview/interview.js new file mode 100644 index 000000000000..6341e3a62b47 --- /dev/null +++ b/erpnext/hr/doctype/interview/interview.js @@ -0,0 +1,237 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Interview', { + onload: function (frm) { + frm.events.set_job_applicant_query(frm); + + frm.set_query('interviewer', 'interview_details', function () { + return { + query: 'erpnext.hr.doctype.interview.interview.get_interviewer_list' + }; + }); + }, + + refresh: function (frm) { + if (frm.doc.docstatus != 2 && !frm.doc.__islocal) { + if (frm.doc.status === 'Pending') { + frm.add_custom_button(__('Reschedule Interview'), function() { + frm.events.show_reschedule_dialog(frm); + frm.refresh(); + }); + } + + let allowed_interviewers = []; + frm.doc.interview_details.forEach(values => { + allowed_interviewers.push(values.interviewer); + }); + + if ((allowed_interviewers.includes(frappe.session.user))) { + frappe.db.get_value('Interview Feedback', {'interviewer': frappe.session.user, 'interview': frm.doc.name, 'docstatus': 1}, 'name', (r) => { + if (Object.keys(r).length === 0) { + frm.add_custom_button(__('Submit Feedback'), function () { + frappe.call({ + method: 'erpnext.hr.doctype.interview.interview.get_expected_skill_set', + args: { + interview_round: frm.doc.interview_round + }, + callback: function (r) { + frm.events.show_feedback_dialog(frm, r.message); + frm.refresh(); + } + }); + }).addClass('btn-primary'); + } + }); + } + } + }, + + show_reschedule_dialog: function (frm) { + let d = new frappe.ui.Dialog({ + title: 'Reschedule Interview', + fields: [ + { + label: 'Schedule On', + fieldname: 'scheduled_on', + fieldtype: 'Date', + reqd: 1 + }, + { + label: 'From Time', + fieldname: 'from_time', + fieldtype: 'Time', + reqd: 1 + }, + { + label: 'To Time', + fieldname: 'to_time', + fieldtype: 'Time', + reqd: 1 + } + ], + primary_action_label: 'Reschedule', + primary_action(values) { + frm.call({ + method: 'reschedule_interview', + doc: frm.doc, + args: { + scheduled_on: values.scheduled_on, + from_time: values.from_time, + to_time: values.to_time + } + }).then(() => { + frm.refresh(); + d.hide(); + }); + } + }); + d.show(); + }, + + show_feedback_dialog: function (frm, data) { + let fields = frm.events.get_fields_for_feedback(); + + let d = new frappe.ui.Dialog({ + title: __('Submit Feedback'), + fields: [ + { + fieldname: 'skill_set', + fieldtype: 'Table', + label: __('Skill Assessment'), + cannot_add_rows: false, + in_editable_grid: true, + reqd: 1, + fields: fields, + data: data + }, + { + fieldname: 'result', + fieldtype: 'Select', + options: ['', 'Cleared', 'Rejected'], + label: __('Result') + }, + { + fieldname: 'feedback', + fieldtype: 'Small Text', + label: __('Feedback') + } + ], + size: 'large', + minimizable: true, + primary_action: function(values) { + frappe.call({ + method: 'erpnext.hr.doctype.interview.interview.create_interview_feedback', + args: { + data: values, + interview_name: frm.doc.name, + interviewer: frappe.session.user, + job_applicant: frm.doc.job_applicant + } + }).then(() => { + frm.refresh(); + }); + d.hide(); + } + }); + d.show(); + }, + + get_fields_for_feedback: function () { + return [{ + fieldtype: 'Link', + fieldname: 'skill', + options: 'Skill', + in_list_view: 1, + label: __('Skill') + }, { + fieldtype: 'Rating', + fieldname: 'rating', + label: __('Rating'), + in_list_view: 1, + reqd: 1, + }]; + }, + + set_job_applicant_query: function (frm) { + frm.set_query('job_applicant', function () { + let job_applicant_filters = { + status: ['!=', 'Rejected'] + }; + if (frm.doc.designation) { + job_applicant_filters.designation = frm.doc.designation; + } + return { + filters: job_applicant_filters + }; + }); + }, + + interview_round: async function (frm) { + frm.events.reset_values(frm); + frm.set_value('job_applicant', ''); + + let round_data = (await frappe.db.get_value('Interview Round', frm.doc.interview_round, 'designation')).message; + frm.set_value('designation', round_data.designation); + frm.events.set_job_applicant_query(frm); + + if (frm.doc.interview_round) { + frm.events.set_interview_details(frm); + } else { + frm.set_value('interview_details', []); + } + }, + + set_interview_details: function (frm) { + frappe.call({ + method: 'erpnext.hr.doctype.interview.interview.get_interviewers', + args: { + interview_round: frm.doc.interview_round + }, + callback: function (data) { + let interview_details = data.message; + frm.set_value('interview_details', []); + if (data.message.length) { + frm.set_value('interview_details', interview_details); + } + } + }); + }, + + job_applicant: function (frm) { + if (!frm.doc.interview_round) { + frm.doc.job_applicant = ''; + frm.refresh(); + frappe.throw(__('Select Interview Round First')); + } + + if (frm.doc.job_applicant) { + frm.events.set_designation_and_job_opening(frm); + } else { + frm.events.reset_values(frm); + } + }, + + set_designation_and_job_opening: async function (frm) { + let round_data = (await frappe.db.get_value('Interview Round', frm.doc.interview_round, 'designation')).message; + frm.set_value('designation', round_data.designation); + frm.events.set_job_applicant_query(frm); + + let job_applicant_data = (await frappe.db.get_value( + 'Job Applicant', frm.doc.job_applicant, ['designation', 'job_title', 'resume_link'], + )).message; + + if (!round_data.designation) { + frm.set_value('designation', job_applicant_data.designation); + } + + frm.set_value('job_opening', job_applicant_data.job_title); + frm.set_value('resume_link', job_applicant_data.resume_link); + }, + + reset_values: function (frm) { + frm.set_value('designation', ''); + frm.set_value('job_opening', ''); + frm.set_value('resume_link', ''); + } +}); diff --git a/erpnext/hr/doctype/interview/interview.json b/erpnext/hr/doctype/interview/interview.json new file mode 100644 index 000000000000..0d393e7556ff --- /dev/null +++ b/erpnext/hr/doctype/interview/interview.json @@ -0,0 +1,254 @@ +{ + "actions": [], + "autoname": "HR-INT-.YYYY.-.####", + "creation": "2021-04-12 15:03:11.524090", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "interview_details_section", + "interview_round", + "job_applicant", + "job_opening", + "designation", + "resume_link", + "column_break_4", + "status", + "scheduled_on", + "from_time", + "to_time", + "interview_feedback_section", + "interview_details", + "ratings_section", + "expected_average_rating", + "column_break_12", + "average_rating", + "section_break_13", + "interview_summary", + "reminded", + "amended_from" + ], + "fields": [ + { + "fieldname": "job_applicant", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Job Applicant", + "options": "Job Applicant", + "reqd": 1 + }, + { + "fieldname": "job_opening", + "fieldtype": "Link", + "label": "Job Opening", + "options": "Job Opening", + "read_only": 1 + }, + { + "fieldname": "interview_round", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Interview Round", + "options": "Interview Round", + "reqd": 1 + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Pending\nUnder Review\nCleared\nRejected", + "reqd": 1 + }, + { + "fieldname": "ratings_section", + "fieldtype": "Section Break", + "label": "Ratings" + }, + { + "allow_on_submit": 1, + "fieldname": "average_rating", + "fieldtype": "Rating", + "in_list_view": 1, + "label": "Obtained Average Rating", + "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "interview_summary", + "fieldtype": "Text" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "resume_link", + "fieldtype": "Data", + "label": "Resume link" + }, + { + "fieldname": "interview_details_section", + "fieldtype": "Section Break", + "label": "Details" + }, + { + "fetch_from": "interview_round.expected_average_rating", + "fieldname": "expected_average_rating", + "fieldtype": "Rating", + "label": "Expected Average Rating", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_13", + "fieldtype": "Section Break", + "label": "Interview Summary" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "fetch_from": "interview_round.designation", + "fieldname": "designation", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Designation", + "options": "Designation", + "read_only": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Interview", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "scheduled_on", + "fieldtype": "Date", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Scheduled On", + "reqd": 1, + "set_only_once": 1 + }, + { + "default": "0", + "fieldname": "reminded", + "fieldtype": "Check", + "hidden": 1, + "label": "Reminded" + }, + { + "allow_on_submit": 1, + "fieldname": "interview_details", + "fieldtype": "Table", + "options": "Interview Detail" + }, + { + "fieldname": "interview_feedback_section", + "fieldtype": "Section Break", + "label": "Feedback" + }, + { + "fieldname": "from_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "From Time", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "to_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "To Time", + "reqd": 1, + "set_only_once": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [ + { + "link_doctype": "Interview Feedback", + "link_fieldname": "interview" + } + ], + "modified": "2021-09-30 13:30:05.421035", + "modified_by": "Administrator", + "module": "HR", + "name": "Interview", + "owner": "Administrator", + "permissions": [ + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Interviewer", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "job_applicant", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/interview/interview.py b/erpnext/hr/doctype/interview/interview.py new file mode 100644 index 000000000000..955acca631d9 --- /dev/null +++ b/erpnext/hr/doctype/interview/interview.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +import datetime + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cstr, get_datetime, get_link_to_form + + +class DuplicateInterviewRoundError(frappe.ValidationError): + pass + +class Interview(Document): + def validate(self): + self.validate_duplicate_interview() + self.validate_designation() + self.validate_overlap() + + def on_submit(self): + if self.status not in ['Cleared', 'Rejected']: + frappe.throw(_('Only Interviews with Cleared or Rejected status can be submitted.'), title=_('Not Allowed')) + + def validate_duplicate_interview(self): + duplicate_interview = frappe.db.exists('Interview', { + 'job_applicant': self.job_applicant, + 'interview_round': self.interview_round, + 'docstatus': 1 + } + ) + + if duplicate_interview: + frappe.throw(_('Job Applicants are not allowed to appear twice for the same Interview round. Interview {0} already scheduled for Job Applicant {1}').format( + frappe.bold(get_link_to_form('Interview', duplicate_interview)), + frappe.bold(self.job_applicant) + )) + + def validate_designation(self): + applicant_designation = frappe.db.get_value('Job Applicant', self.job_applicant, 'designation') + if self.designation : + if self.designation != applicant_designation: + frappe.throw(_('Interview Round {0} is only for Designation {1}. Job Applicant has applied for the role {2}').format( + self.interview_round, frappe.bold(self.designation), applicant_designation), + exc=DuplicateInterviewRoundError) + else: + self.designation = applicant_designation + + def validate_overlap(self): + interviewers = [entry.interviewer for entry in self.interview_details] or [''] + + overlaps = frappe.db.sql(""" + SELECT interview.name + FROM `tabInterview` as interview + INNER JOIN `tabInterview Detail` as detail + WHERE + interview.scheduled_on = %s and interview.name != %s and interview.docstatus != 2 + and (interview.job_applicant = %s or detail.interviewer IN %s) and + ((from_time < %s and to_time > %s) or + (from_time > %s and to_time < %s) or + (from_time = %s)) + """, (self.scheduled_on, self.name, self.job_applicant, interviewers, + self.from_time, self.to_time, self.from_time, self.to_time, self.from_time)) + + if overlaps: + overlapping_details = _('Interview overlaps with {0}').format(get_link_to_form('Interview', overlaps[0][0])) + frappe.throw(overlapping_details, title=_('Overlap')) + + + @frappe.whitelist() + def reschedule_interview(self, scheduled_on, from_time, to_time): + original_date = self.scheduled_on + from_time = self.from_time + to_time = self.to_time + + self.db_set({ + 'scheduled_on': scheduled_on, + 'from_time': from_time, + 'to_time': to_time + }) + self.notify_update() + + recipients = get_recipients(self.name) + + try: + frappe.sendmail( + recipients= recipients, + subject=_('Interview: {0} Rescheduled').format(self.name), + message=_('Your Interview session is rescheduled from {0} {1} - {2} to {3} {4} - {5}').format( + original_date, from_time, to_time, self.scheduled_on, self.from_time, self.to_time), + reference_doctype=self.doctype, + reference_name=self.name + ) + except Exception: + frappe.msgprint(_('Failed to send the Interview Reschedule notification. Please configure your email account.')) + + frappe.msgprint(_('Interview Rescheduled successfully'), indicator='green') + + +def get_recipients(name, for_feedback=0): + interview = frappe.get_doc('Interview', name) + + if for_feedback: + recipients = [d.interviewer for d in interview.interview_details if not d.interview_feedback] + else: + recipients = [d.interviewer for d in interview.interview_details] + recipients.append(frappe.db.get_value('Job Applicant', interview.job_applicant, 'email_id')) + + return recipients + + +@frappe.whitelist() +def get_interviewers(interview_round): + return frappe.get_all('Interviewer', filters={'parent': interview_round}, fields=['user as interviewer']) + + +def send_interview_reminder(): + reminder_settings = frappe.db.get_value('HR Settings', 'HR Settings', + ['send_interview_reminder', 'interview_reminder_template'], as_dict=True) + + if not reminder_settings.send_interview_reminder: + return + + remind_before = cstr(frappe.db.get_single_value('HR Settings', 'remind_before')) or '01:00:00' + remind_before = datetime.datetime.strptime(remind_before, '%H:%M:%S') + reminder_date_time = datetime.datetime.now() + datetime.timedelta( + hours=remind_before.hour, minutes=remind_before.minute, seconds=remind_before.second) + + interviews = frappe.get_all('Interview', filters={ + 'scheduled_on': ['between', (datetime.datetime.now(), reminder_date_time)], + 'status': 'Pending', + 'reminded': 0, + 'docstatus': ['!=', 2] + }) + + interview_template = frappe.get_doc('Email Template', reminder_settings.interview_reminder_template) + + for d in interviews: + doc = frappe.get_doc('Interview', d.name) + context = doc.as_dict() + message = frappe.render_template(interview_template.response, context) + recipients = get_recipients(doc.name) + + frappe.sendmail( + recipients= recipients, + subject=interview_template.subject, + message=message, + reference_doctype=doc.doctype, + reference_name=doc.name + ) + + doc.db_set('reminded', 1) + + +def send_daily_feedback_reminder(): + reminder_settings = frappe.db.get_value('HR Settings', 'HR Settings', + ['send_interview_feedback_reminder', 'feedback_reminder_notification_template'], as_dict=True) + + if not reminder_settings.send_interview_feedback_reminder: + return + + interview_feedback_template = frappe.get_doc('Email Template', reminder_settings.feedback_reminder_notification_template) + interviews = frappe.get_all('Interview', filters={'status': ['in', ['Under Review', 'Pending']], 'docstatus': ['!=', 2]}) + + for entry in interviews: + recipients = get_recipients(entry.name, for_feedback=1) + + doc = frappe.get_doc('Interview', entry.name) + context = doc.as_dict() + + message = frappe.render_template(interview_feedback_template.response, context) + + if len(recipients): + frappe.sendmail( + recipients= recipients, + subject=interview_feedback_template.subject, + message=message, + reference_doctype='Interview', + reference_name=entry.name + ) + + +@frappe.whitelist() +def get_expected_skill_set(interview_round): + return frappe.get_all('Expected Skill Set', filters ={'parent': interview_round}, fields=['skill']) + + +@frappe.whitelist() +def create_interview_feedback(data, interview_name, interviewer, job_applicant): + import json + + from six import string_types + + if isinstance(data, string_types): + data = frappe._dict(json.loads(data)) + + if frappe.session.user != interviewer: + frappe.throw(_('Only Interviewer Are allowed to submit Interview Feedback')) + + interview_feedback = frappe.new_doc('Interview Feedback') + interview_feedback.interview = interview_name + interview_feedback.interviewer = interviewer + interview_feedback.job_applicant = job_applicant + + for d in data.skill_set: + d = frappe._dict(d) + interview_feedback.append('skill_assessment', {'skill': d.skill, 'rating': d.rating}) + + interview_feedback.feedback = data.feedback + interview_feedback.result = data.result + + interview_feedback.save() + interview_feedback.submit() + + frappe.msgprint(_('Interview Feedback {0} submitted successfully').format( + get_link_to_form('Interview Feedback', interview_feedback.name))) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_interviewer_list(doctype, txt, searchfield, start, page_len, filters): + filters = [ + ['Has Role', 'parent', 'like', '%{}%'.format(txt)], + ['Has Role', 'role', '=', 'interviewer'], + ['Has Role', 'parenttype', '=', 'User'] + ] + + if filters and isinstance(filters, list): + filters.extend(filters) + + return frappe.get_all('Has Role', limit_start=start, limit_page_length=page_len, + filters=filters, fields = ['parent'], as_list=1) + + +@frappe.whitelist() +def get_events(start, end, filters=None): + """Returns events for Gantt / Calendar view rendering. + + :param start: Start date-time. + :param end: End date-time. + :param filters: Filters (JSON). + """ + from frappe.desk.calendar import get_event_conditions + + events = [] + + event_color = { + "Pending": "#fff4f0", + "Under Review": "#d3e8fc", + "Cleared": "#eaf5ed", + "Rejected": "#fce7e7" + } + + conditions = get_event_conditions('Interview', filters) + + interviews = frappe.db.sql(""" + SELECT DISTINCT + `tabInterview`.name, `tabInterview`.job_applicant, `tabInterview`.interview_round, + `tabInterview`.scheduled_on, `tabInterview`.status, `tabInterview`.from_time as from_time, + `tabInterview`.to_time as to_time + from + `tabInterview` + where + (`tabInterview`.scheduled_on between %(start)s and %(end)s) + and docstatus != 2 + {conditions} + """.format(conditions=conditions), { + "start": start, + "end": end + }, as_dict=True, update={"allDay": 0}) + + for d in interviews: + subject_data = [] + for field in ["name", "job_applicant", "interview_round"]: + if not d.get(field): + continue + subject_data.append(d.get(field)) + + color = event_color.get(d.status) + interview_data = { + 'from': get_datetime('%s %s' % (d.scheduled_on, d.from_time or '00:00:00')), + 'to': get_datetime('%s %s' % (d.scheduled_on, d.to_time or '00:00:00')), + 'name': d.name, + 'subject': '\n'.join(subject_data), + 'color': color if color else "#89bcde" + } + + events.append(interview_data) + + return events \ No newline at end of file diff --git a/erpnext/hr/doctype/interview/interview_calendar.js b/erpnext/hr/doctype/interview/interview_calendar.js new file mode 100644 index 000000000000..b46b72ecb21b --- /dev/null +++ b/erpnext/hr/doctype/interview/interview_calendar.js @@ -0,0 +1,14 @@ + +frappe.views.calendar['Interview'] = { + field_map: { + 'start': 'from', + 'end': 'to', + 'id': 'name', + 'title': 'subject', + 'allDay': 'allDay', + 'color': 'color' + }, + order_by: 'scheduled_on', + gantt: true, + get_events_method: 'erpnext.hr.doctype.interview.interview.get_events' +}; diff --git a/erpnext/hr/doctype/interview/interview_feedback_reminder_template.html b/erpnext/hr/doctype/interview/interview_feedback_reminder_template.html new file mode 100644 index 000000000000..8d39fb54ef76 --- /dev/null +++ b/erpnext/hr/doctype/interview/interview_feedback_reminder_template.html @@ -0,0 +1,5 @@ +

Interview Feedback Reminder

+ +

+ Interview Feedback for Interview {{ name }} is not submitted yet. Please submit your feedback. Thank you, good day! +

diff --git a/erpnext/hr/doctype/interview/interview_list.js b/erpnext/hr/doctype/interview/interview_list.js new file mode 100644 index 000000000000..b1f072f0d4b8 --- /dev/null +++ b/erpnext/hr/doctype/interview/interview_list.js @@ -0,0 +1,12 @@ +frappe.listview_settings['Interview'] = { + has_indicator_for_draft: 1, + get_indicator: function(doc) { + let status_color = { + 'Pending': 'orange', + 'Under Review': 'blue', + 'Cleared': 'green', + 'Rejected': 'red', + }; + return [__(doc.status), status_color[doc.status], 'status,=,'+doc.status]; + } +}; diff --git a/erpnext/hr/doctype/interview/interview_reminder_notification_template.html b/erpnext/hr/doctype/interview/interview_reminder_notification_template.html new file mode 100644 index 000000000000..76de46e28db2 --- /dev/null +++ b/erpnext/hr/doctype/interview/interview_reminder_notification_template.html @@ -0,0 +1,5 @@ +

Interview Reminder

+ +

+ Interview: {{name}} is scheduled on {{scheduled_on}} from {{from_time}} to {{to_time}} +

diff --git a/erpnext/hr/doctype/interview/test_interview.py b/erpnext/hr/doctype/interview/test_interview.py new file mode 100644 index 000000000000..4612e17db038 --- /dev/null +++ b/erpnext/hr/doctype/interview/test_interview.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import datetime +import os +import unittest + +import frappe +from frappe import _ +from frappe.core.doctype.user_permission.test_user_permission import create_user +from frappe.utils import add_days, getdate, nowtime + +from erpnext.hr.doctype.designation.test_designation import create_designation +from erpnext.hr.doctype.interview.interview import DuplicateInterviewRoundError +from erpnext.hr.doctype.job_applicant.test_job_applicant import create_job_applicant + + +class TestInterview(unittest.TestCase): + def test_validations_for_designation(self): + job_applicant = create_job_applicant() + interview = create_interview_and_dependencies(job_applicant.name, designation='_Test_Sales_manager', save=0) + self.assertRaises(DuplicateInterviewRoundError, interview.save) + + def test_notification_on_rescheduling(self): + job_applicant = create_job_applicant() + interview = create_interview_and_dependencies(job_applicant.name, scheduled_on=add_days(getdate(), -4)) + + previous_scheduled_date = interview.scheduled_on + frappe.db.sql("DELETE FROM `tabEmail Queue`") + + interview.reschedule_interview(add_days(getdate(previous_scheduled_date), 2), + from_time=nowtime(), to_time=nowtime()) + interview.reload() + + self.assertEqual(interview.scheduled_on, add_days(getdate(previous_scheduled_date), 2)) + + notification = frappe.get_all("Email Queue", filters={"message": ("like", "%Your Interview session is rescheduled from%")}) + self.assertIsNotNone(notification) + + def test_notification_for_scheduling(self): + from erpnext.hr.doctype.interview.interview import send_interview_reminder + + setup_reminder_settings() + + job_applicant = create_job_applicant() + scheduled_on = datetime.datetime.now() + datetime.timedelta(minutes=10) + + interview = create_interview_and_dependencies(job_applicant.name, scheduled_on=scheduled_on) + + frappe.db.sql("DELETE FROM `tabEmail Queue`") + send_interview_reminder() + + interview.reload() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue("Subject: Interview Reminder" in email_queue[0].message) + + def test_notification_for_feedback_submission(self): + from erpnext.hr.doctype.interview.interview import send_daily_feedback_reminder + + setup_reminder_settings() + + job_applicant = create_job_applicant() + scheduled_on = add_days(getdate(), -4) + create_interview_and_dependencies(job_applicant.name, scheduled_on=scheduled_on) + + frappe.db.sql("DELETE FROM `tabEmail Queue`") + send_daily_feedback_reminder() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue("Subject: Interview Feedback Reminder" in email_queue[0].message) + + def tearDown(self): + frappe.db.rollback() + + +def create_interview_and_dependencies(job_applicant, scheduled_on=None, from_time=None, to_time=None, designation=None, save=1): + if designation: + designation=create_designation(designation_name = "_Test_Sales_manager").name + + interviewer_1 = create_user("test_interviewer1@example.com", "Interviewer") + interviewer_2 = create_user("test_interviewer2@example.com", "Interviewer") + + interview_round = create_interview_round( + "Technical Round", ["Python", "JS"], + designation=designation, save=True + ) + + interview = frappe.new_doc("Interview") + interview.interview_round = interview_round.name + interview.job_applicant = job_applicant + interview.scheduled_on = scheduled_on or getdate() + interview.from_time = from_time or nowtime() + interview.to_time = to_time or nowtime() + + interview.append("interview_details", {"interviewer": interviewer_1.name}) + interview.append("interview_details", {"interviewer": interviewer_2.name}) + + if save: + interview.save() + + return interview + +def create_interview_round(name, skill_set, interviewers=[], designation=None, save=True): + create_skill_set(skill_set) + interview_round = frappe.new_doc("Interview Round") + interview_round.round_name = name + interview_round.interview_type = create_interview_type() + interview_round.expected_average_rating = 4 + if designation: + interview_round.designation = designation + + for skill in skill_set: + interview_round.append("expected_skill_set", {"skill": skill}) + + for interviewer in interviewers: + interview_round.append("interviewer", { + "user": interviewer + }) + + if save: + interview_round.save() + + return interview_round + +def create_skill_set(skill_set): + for skill in skill_set: + if not frappe.db.exists("Skill", skill): + doc = frappe.new_doc("Skill") + doc.skill_name = skill + doc.save() + +def create_interview_type(name="test_interview_type"): + if frappe.db.exists("Interview Type", name): + return frappe.get_doc("Interview Type", name).name + else: + doc = frappe.new_doc("Interview Type") + doc.name = name + doc.description = "_Test_Description" + doc.save() + + return doc.name + +def setup_reminder_settings(): + if not frappe.db.exists('Email Template', _('Interview Reminder')): + base_path = frappe.get_app_path('erpnext', 'hr', 'doctype') + response = frappe.read_file(os.path.join(base_path, 'interview/interview_reminder_notification_template.html')) + + frappe.get_doc({ + 'doctype': 'Email Template', + 'name': _('Interview Reminder'), + 'response': response, + 'subject': _('Interview Reminder'), + 'owner': frappe.session.user, + }).insert(ignore_permissions=True) + + if not frappe.db.exists('Email Template', _('Interview Feedback Reminder')): + base_path = frappe.get_app_path('erpnext', 'hr', 'doctype') + response = frappe.read_file(os.path.join(base_path, 'interview/interview_feedback_reminder_template.html')) + + frappe.get_doc({ + 'doctype': 'Email Template', + 'name': _('Interview Feedback Reminder'), + 'response': response, + 'subject': _('Interview Feedback Reminder'), + 'owner': frappe.session.user, + }).insert(ignore_permissions=True) + + hr_settings = frappe.get_doc('HR Settings') + hr_settings.interview_reminder_template = _('Interview Reminder') + hr_settings.feedback_reminder_notification_template = _('Interview Feedback Reminder') + hr_settings.save() diff --git a/erpnext/hr/doctype/interview_detail/__init__.py b/erpnext/hr/doctype/interview_detail/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/hr/doctype/interview_detail/interview_detail.js b/erpnext/hr/doctype/interview_detail/interview_detail.js new file mode 100644 index 000000000000..88518ca4cc13 --- /dev/null +++ b/erpnext/hr/doctype/interview_detail/interview_detail.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('Interview Detail', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/hr/doctype/interview_detail/interview_detail.json b/erpnext/hr/doctype/interview_detail/interview_detail.json new file mode 100644 index 000000000000..b5b49c0993ab --- /dev/null +++ b/erpnext/hr/doctype/interview_detail/interview_detail.json @@ -0,0 +1,74 @@ +{ + "actions": [], + "creation": "2021-04-12 16:24:10.382863", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "interviewer", + "interview_feedback", + "average_rating", + "result", + "column_break_4", + "comments" + ], + "fields": [ + { + "fieldname": "interviewer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Interviewer", + "options": "User" + }, + { + "allow_on_submit": 1, + "fieldname": "interview_feedback", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Interview Feedback", + "options": "Interview Feedback", + "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "average_rating", + "fieldtype": "Rating", + "in_list_view": 1, + "label": "Average Rating", + "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "allow_on_submit": 1, + "fetch_from": "interview_feedback.feedback", + "fieldname": "comments", + "fieldtype": "Text", + "label": "Comments", + "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "result", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Result", + "options": "\nCleared\nRejected", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-09-29 13:13:25.865063", + "modified_by": "Administrator", + "module": "HR", + "name": "Interview Detail", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/interview_detail/interview_detail.py b/erpnext/hr/doctype/interview_detail/interview_detail.py new file mode 100644 index 000000000000..8be3d34fad33 --- /dev/null +++ b/erpnext/hr/doctype/interview_detail/interview_detail.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +# import frappe +from frappe.model.document import Document + + +class InterviewDetail(Document): + pass diff --git a/erpnext/hr/doctype/interview_detail/test_interview_detail.py b/erpnext/hr/doctype/interview_detail/test_interview_detail.py new file mode 100644 index 000000000000..a29dffff7793 --- /dev/null +++ b/erpnext/hr/doctype/interview_detail/test_interview_detail.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + + +class TestInterviewDetail(unittest.TestCase): + pass diff --git a/erpnext/hr/doctype/interview_feedback/__init__.py b/erpnext/hr/doctype/interview_feedback/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/hr/doctype/interview_feedback/interview_feedback.js b/erpnext/hr/doctype/interview_feedback/interview_feedback.js new file mode 100644 index 000000000000..dec559fceae4 --- /dev/null +++ b/erpnext/hr/doctype/interview_feedback/interview_feedback.js @@ -0,0 +1,54 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Interview Feedback', { + onload: function(frm) { + frm.ignore_doctypes_on_cancel_all = ['Interview']; + + frm.set_query('interview', function() { + return { + filters: { + docstatus: ['!=', 2] + } + }; + }); + }, + + interview_round: function(frm) { + frappe.call({ + method: 'erpnext.hr.doctype.interview.interview.get_expected_skill_set', + args: { + interview_round: frm.doc.interview_round + }, + callback: function(r) { + frm.set_value('skill_assessment', r.message); + } + }); + }, + + interview: function(frm) { + frappe.call({ + method: 'erpnext.hr.doctype.interview_feedback.interview_feedback.get_applicable_interviewers', + args: { + interview: frm.doc.interview || '' + }, + callback: function(r) { + frm.set_query('interviewer', function() { + return { + filters: { + name: ['in', r.message] + } + }; + }); + } + }); + + }, + + interviewer: function(frm) { + if (!frm.doc.interview) { + frappe.throw(__('Select Interview first')); + frm.set_value('interviewer', ''); + } + } +}); diff --git a/erpnext/hr/doctype/interview_feedback/interview_feedback.json b/erpnext/hr/doctype/interview_feedback/interview_feedback.json new file mode 100644 index 000000000000..6a2f7e869698 --- /dev/null +++ b/erpnext/hr/doctype/interview_feedback/interview_feedback.json @@ -0,0 +1,171 @@ +{ + "actions": [], + "autoname": "HR-INT-FEED-.####", + "creation": "2021-04-12 17:03:13.833285", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "details_section", + "interview", + "interview_round", + "job_applicant", + "column_break_3", + "interviewer", + "result", + "section_break_4", + "skill_assessment", + "average_rating", + "section_break_7", + "feedback", + "amended_from" + ], + "fields": [ + { + "allow_in_quick_entry": 1, + "fieldname": "interview", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Interview", + "options": "Interview", + "reqd": 1 + }, + { + "allow_in_quick_entry": 1, + "fetch_from": "interview.interview_round", + "fieldname": "interview_round", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Interview Round", + "options": "Interview Round", + "read_only": 1, + "reqd": 1 + }, + { + "allow_in_quick_entry": 1, + "fieldname": "interviewer", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Interviewer", + "options": "User", + "reqd": 1 + }, + { + "fieldname": "section_break_4", + "fieldtype": "Section Break", + "label": "Skill Assessment" + }, + { + "allow_in_quick_entry": 1, + "fieldname": "skill_assessment", + "fieldtype": "Table", + "options": "Skill Assessment", + "reqd": 1 + }, + { + "allow_in_quick_entry": 1, + "fieldname": "average_rating", + "fieldtype": "Rating", + "in_list_view": 1, + "label": "Average Rating", + "read_only": 1 + }, + { + "fieldname": "section_break_7", + "fieldtype": "Section Break", + "label": "Feedback" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Interview Feedback", + "print_hide": 1, + "read_only": 1 + }, + { + "allow_in_quick_entry": 1, + "fieldname": "feedback", + "fieldtype": "Text" + }, + { + "fieldname": "result", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Result", + "options": "\nCleared\nRejected", + "reqd": 1 + }, + { + "fieldname": "details_section", + "fieldtype": "Section Break", + "label": "Details" + }, + { + "fetch_from": "interview.job_applicant", + "fieldname": "job_applicant", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Job Applicant", + "options": "Job Applicant", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2021-09-30 13:30:49.955352", + "modified_by": "Administrator", + "module": "HR", + "name": "Interview Feedback", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Interviewer", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "interviewer", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/interview_feedback/interview_feedback.py b/erpnext/hr/doctype/interview_feedback/interview_feedback.py new file mode 100644 index 000000000000..1c5a4948f24f --- /dev/null +++ b/erpnext/hr/doctype/interview_feedback/interview_feedback.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import flt, get_link_to_form, getdate + + +class InterviewFeedback(Document): + def validate(self): + self.validate_interviewer() + self.validate_interview_date() + self.validate_duplicate() + self.calculate_average_rating() + + def on_submit(self): + self.update_interview_details() + + def on_cancel(self): + self.update_interview_details() + + def validate_interviewer(self): + applicable_interviewers = get_applicable_interviewers(self.interview) + if self.interviewer not in applicable_interviewers: + frappe.throw(_('{0} is not allowed to submit Interview Feedback for the Interview: {1}').format( + frappe.bold(self.interviewer), frappe.bold(self.interview))) + + def validate_interview_date(self): + scheduled_date = frappe.db.get_value('Interview', self.interview, 'scheduled_on') + + if getdate() < getdate(scheduled_date) and self.docstatus == 1: + frappe.throw(_('{0} submission before {1} is not allowed').format( + frappe.bold('Interview Feedback'), + frappe.bold('Interview Scheduled Date') + )) + + def validate_duplicate(self): + duplicate_feedback = frappe.db.exists('Interview Feedback', { + 'interviewer': self.interviewer, + 'interview': self.interview, + 'docstatus': 1 + }) + + if duplicate_feedback: + frappe.throw(_('Feedback already submitted for the Interview {0}. Please cancel the previous Interview Feedback {1} to continue.').format( + self.interview, get_link_to_form('Interview Feedback', duplicate_feedback))) + + def calculate_average_rating(self): + total_rating = 0 + for d in self.skill_assessment: + if d.rating: + total_rating += d.rating + + self.average_rating = flt(total_rating / len(self.skill_assessment) if len(self.skill_assessment) else 0) + + def update_interview_details(self): + doc = frappe.get_doc('Interview', self.interview) + total_rating = 0 + + if self.docstatus == 2: + for entry in doc.interview_details: + if entry.interview_feedback == self.name: + entry.average_rating = entry.interview_feedback = entry.comments = entry.result = None + break + else: + for entry in doc.interview_details: + if entry.interviewer == self.interviewer: + entry.average_rating = self.average_rating + entry.interview_feedback = self.name + entry.comments = self.feedback + entry.result = self.result + + if entry.average_rating: + total_rating += entry.average_rating + + doc.average_rating = flt(total_rating / len(doc.interview_details) if len(doc.interview_details) else 0) + doc.save() + doc.notify_update() + + +@frappe.whitelist() +def get_applicable_interviewers(interview): + data = frappe.get_all('Interview Detail', filters={'parent': interview}, fields=['interviewer']) + return [d.interviewer for d in data] diff --git a/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py b/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py new file mode 100644 index 000000000000..c4b7981833b5 --- /dev/null +++ b/erpnext/hr/doctype/interview_feedback/test_interview_feedback.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import unittest + +import frappe +from frappe.utils import add_days, flt, getdate + +from erpnext.hr.doctype.interview.test_interview import ( + create_interview_and_dependencies, + create_skill_set, +) +from erpnext.hr.doctype.job_applicant.test_job_applicant import create_job_applicant + + +class TestInterviewFeedback(unittest.TestCase): + def test_validation_for_skill_set(self): + frappe.set_user("Administrator") + job_applicant = create_job_applicant() + interview = create_interview_and_dependencies(job_applicant.name, scheduled_on=add_days(getdate(), -1)) + skill_ratings = get_skills_rating(interview.interview_round) + + interviewer = interview.interview_details[0].interviewer + create_skill_set(['Leadership']) + + interview_feedback = create_interview_feedback(interview.name, interviewer, skill_ratings) + interview_feedback.append("skill_assessment", {"skill": 'Leadership', 'rating': 4}) + frappe.set_user(interviewer) + + self.assertRaises(frappe.ValidationError, interview_feedback.save) + + frappe.set_user("Administrator") + + def test_average_ratings_on_feedback_submission_and_cancellation(self): + job_applicant = create_job_applicant() + interview = create_interview_and_dependencies(job_applicant.name, scheduled_on=add_days(getdate(), -1)) + skill_ratings = get_skills_rating(interview.interview_round) + + # For First Interviewer Feedback + interviewer = interview.interview_details[0].interviewer + frappe.set_user(interviewer) + + # calculating Average + feedback_1 = create_interview_feedback(interview.name, interviewer, skill_ratings) + + total_rating = 0 + for d in feedback_1.skill_assessment: + if d.rating: + total_rating += d.rating + + avg_rating = flt(total_rating / len(feedback_1.skill_assessment) if len(feedback_1.skill_assessment) else 0) + + self.assertEqual(flt(avg_rating, 3), feedback_1.average_rating) + + avg_on_interview_detail = frappe.db.get_value('Interview Detail', { + 'parent': feedback_1.interview, + 'interviewer': feedback_1.interviewer, + 'interview_feedback': feedback_1.name + }, 'average_rating') + + # 1. average should be reflected in Interview Detail. + self.assertEqual(avg_on_interview_detail, round(feedback_1.average_rating)) + + '''For Second Interviewer Feedback''' + interviewer = interview.interview_details[1].interviewer + frappe.set_user(interviewer) + + feedback_2 = create_interview_feedback(interview.name, interviewer, skill_ratings) + interview.reload() + + feedback_2.cancel() + interview.reload() + + frappe.set_user("Administrator") + + def tearDown(self): + frappe.db.rollback() + + +def create_interview_feedback(interview, interviewer, skills_ratings): + interview_feedback = frappe.new_doc("Interview Feedback") + interview_feedback.interview = interview + interview_feedback.interviewer = interviewer + interview_feedback.result = "Cleared" + + for rating in skills_ratings: + interview_feedback.append("skill_assessment", rating) + + interview_feedback.save() + interview_feedback.submit() + + return interview_feedback + + +def get_skills_rating(interview_round): + import random + + skills = frappe.get_all("Expected Skill Set", filters={"parent": interview_round}, fields = ["skill"]) + for d in skills: + d["rating"] = random.randint(1, 5) + return skills diff --git a/erpnext/hr/doctype/interview_round/__init__.py b/erpnext/hr/doctype/interview_round/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/hr/doctype/interview_round/interview_round.js b/erpnext/hr/doctype/interview_round/interview_round.js new file mode 100644 index 000000000000..6a608b03d251 --- /dev/null +++ b/erpnext/hr/doctype/interview_round/interview_round.js @@ -0,0 +1,24 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Interview Round", { + refresh: function(frm) { + if (!frm.doc.__islocal) { + frm.add_custom_button(__("Create Interview"), function() { + frm.events.create_interview(frm); + }); + } + }, + create_interview: function(frm) { + frappe.call({ + method: "erpnext.hr.doctype.interview_round.interview_round.create_interview", + args: { + doc: frm.doc + }, + callback: function (r) { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + } + }); + } +}); diff --git a/erpnext/hr/doctype/interview_round/interview_round.json b/erpnext/hr/doctype/interview_round/interview_round.json new file mode 100644 index 000000000000..9c95185e9cea --- /dev/null +++ b/erpnext/hr/doctype/interview_round/interview_round.json @@ -0,0 +1,118 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:round_name", + "creation": "2021-04-12 12:57:19.902866", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "round_name", + "interview_type", + "interviewers", + "column_break_3", + "designation", + "expected_average_rating", + "expected_skills_section", + "expected_skill_set" + ], + "fields": [ + { + "fieldname": "round_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Round Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "designation", + "fieldtype": "Link", + "label": "Designation", + "options": "Designation" + }, + { + "fieldname": "expected_skills_section", + "fieldtype": "Section Break", + "label": "Expected Skillset" + }, + { + "fieldname": "expected_skill_set", + "fieldtype": "Table", + "options": "Expected Skill Set", + "reqd": 1 + }, + { + "fieldname": "expected_average_rating", + "fieldtype": "Rating", + "label": "Expected Average Rating", + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "interview_type", + "fieldtype": "Link", + "label": "Interview Type", + "options": "Interview Type", + "reqd": 1 + }, + { + "fieldname": "interviewers", + "fieldtype": "Table MultiSelect", + "label": "Interviewers", + "options": "Interviewer" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-09-30 13:01:25.666660", + "modified_by": "Administrator", + "module": "HR", + "name": "Interview Round", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Interviewer", + "select": 1, + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/interview_round/interview_round.py b/erpnext/hr/doctype/interview_round/interview_round.py new file mode 100644 index 000000000000..8230c7858520 --- /dev/null +++ b/erpnext/hr/doctype/interview_round/interview_round.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +import json + +import frappe +from frappe.model.document import Document + + +class InterviewRound(Document): + pass + +@frappe.whitelist() +def create_interview(doc): + if isinstance(doc, str): + doc = json.loads(doc) + doc = frappe.get_doc(doc) + + interview = frappe.new_doc("Interview") + interview.interview_round = doc.name + interview.designation = doc.designation + + if doc.interviewers: + interview.interview_details = [] + for data in doc.interviewers: + interview.append("interview_details", { + "interviewer": data.user + }) + return interview + + + diff --git a/erpnext/hr/doctype/interview_round/test_interview_round.py b/erpnext/hr/doctype/interview_round/test_interview_round.py new file mode 100644 index 000000000000..932d3defc2cf --- /dev/null +++ b/erpnext/hr/doctype/interview_round/test_interview_round.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import unittest + +# import frappe + + +class TestInterviewRound(unittest.TestCase): + pass + diff --git a/erpnext/hr/doctype/interview_type/__init__.py b/erpnext/hr/doctype/interview_type/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/hr/doctype/interview_type/interview_type.js b/erpnext/hr/doctype/interview_type/interview_type.js new file mode 100644 index 000000000000..af77b527d4d0 --- /dev/null +++ b/erpnext/hr/doctype/interview_type/interview_type.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('Interview Type', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/hr/doctype/interview_type/interview_type.json b/erpnext/hr/doctype/interview_type/interview_type.json new file mode 100644 index 000000000000..14636a18cb3b --- /dev/null +++ b/erpnext/hr/doctype/interview_type/interview_type.json @@ -0,0 +1,73 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2021-04-12 14:44:40.664034", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "description" + ], + "fields": [ + { + "fieldname": "description", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Description" + } + ], + "index_web_pages_for_search": 1, + "links": [ + { + "link_doctype": "Interview Round", + "link_fieldname": "interview_type" + } + ], + "modified": "2021-09-30 13:00:16.471518", + "modified_by": "Administrator", + "module": "HR", + "name": "Interview Type", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/interview_type/interview_type.py b/erpnext/hr/doctype/interview_type/interview_type.py new file mode 100644 index 000000000000..ee5be54c7552 --- /dev/null +++ b/erpnext/hr/doctype/interview_type/interview_type.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +# import frappe +from frappe.model.document import Document + + +class InterviewType(Document): + pass diff --git a/erpnext/hr/doctype/interview_type/test_interview_type.py b/erpnext/hr/doctype/interview_type/test_interview_type.py new file mode 100644 index 000000000000..a5d3cf99229f --- /dev/null +++ b/erpnext/hr/doctype/interview_type/test_interview_type.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + + +class TestInterviewType(unittest.TestCase): + pass diff --git a/erpnext/hr/doctype/interviewer/__init__.py b/erpnext/hr/doctype/interviewer/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/hr/doctype/interviewer/interviewer.json b/erpnext/hr/doctype/interviewer/interviewer.json new file mode 100644 index 000000000000..a37b8b0e4e52 --- /dev/null +++ b/erpnext/hr/doctype/interviewer/interviewer.json @@ -0,0 +1,31 @@ +{ + "actions": [], + "creation": "2021-04-12 17:38:19.354734", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-04-13 13:41:35.817568", + "modified_by": "Administrator", + "module": "HR", + "name": "Interviewer", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/interviewer/interviewer.py b/erpnext/hr/doctype/interviewer/interviewer.py new file mode 100644 index 000000000000..1c8dbbed5915 --- /dev/null +++ b/erpnext/hr/doctype/interviewer/interviewer.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +# import frappe +from frappe.model.document import Document + + +class Interviewer(Document): + pass diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.js b/erpnext/hr/doctype/job_applicant/job_applicant.js index 7658bc93539e..d7b1c6c9df36 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant.js +++ b/erpnext/hr/doctype/job_applicant/job_applicant.js @@ -8,6 +8,24 @@ cur_frm.email_field = "email_id"; frappe.ui.form.on("Job Applicant", { refresh: function(frm) { + frm.set_query("job_title", function() { + return { + filters: { + 'status': 'Open' + } + }; + }); + frm.events.create_custom_buttons(frm); + frm.events.make_dashboard(frm); + }, + + create_custom_buttons: function(frm) { + if (!frm.doc.__islocal && frm.doc.status !== "Rejected" && frm.doc.status !== "Accepted") { + frm.add_custom_button(__("Create Interview"), function() { + frm.events.create_dialog(frm); + }); + } + if (!frm.doc.__islocal) { if (frm.doc.__onload && frm.doc.__onload.job_offer) { $('[data-doctype="Employee Onboarding"]').find("button").show(); @@ -28,14 +46,57 @@ frappe.ui.form.on("Job Applicant", { }); } } + }, - frm.set_query("job_title", function() { - return { - filters: { - 'status': 'Open' - } - }; + make_dashboard: function(frm) { + frappe.call({ + method: "erpnext.hr.doctype.job_applicant.job_applicant.get_interview_details", + args: { + job_applicant: frm.doc.name + }, + callback: function(r) { + $("div").remove(".form-dashboard-section.custom"); + frm.dashboard.add_section( + frappe.render_template('job_applicant_dashboard', { + data: r.message + }), + __("Interview Summary") + ); + } }); + }, + create_dialog: function(frm) { + let d = new frappe.ui.Dialog({ + title: 'Enter Interview Round', + fields: [ + { + label: 'Interview Round', + fieldname: 'interview_round', + fieldtype: 'Link', + options: 'Interview Round' + }, + ], + primary_action_label: 'Create Interview', + primary_action(values) { + frm.events.create_interview(frm, values); + d.hide(); + } + }); + d.show(); + }, + + create_interview: function (frm, values) { + frappe.call({ + method: "erpnext.hr.doctype.job_applicant.job_applicant.create_interview", + args: { + doc: frm.doc, + interview_round: values.interview_round + }, + callback: function (r) { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + } + }); } }); diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.json b/erpnext/hr/doctype/job_applicant/job_applicant.json index bcea5f50d935..200f675221b3 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant.json +++ b/erpnext/hr/doctype/job_applicant/job_applicant.json @@ -9,16 +9,20 @@ "email_append_to": 1, "engine": "InnoDB", "field_order": [ + "details_section", "applicant_name", "email_id", "phone_number", "country", - "status", "column_break_3", "job_title", + "designation", + "status", + "source_and_rating_section", "source", "source_name", "employee_referral", + "column_break_13", "applicant_rating", "section_break_6", "notes", @@ -84,7 +88,8 @@ }, { "fieldname": "section_break_6", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Resume" }, { "fieldname": "cover_letter", @@ -160,13 +165,34 @@ "label": "Employee Referral", "options": "Employee Referral", "read_only": 1 + }, + { + "fieldname": "details_section", + "fieldtype": "Section Break", + "label": "Details" + }, + { + "fieldname": "source_and_rating_section", + "fieldtype": "Section Break", + "label": "Source and Rating" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "fetch_from": "job_opening.designation", + "fieldname": "designation", + "fieldtype": "Link", + "label": "Designation", + "options": "Designation" } ], "icon": "fa fa-user", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-03-24 15:51:11.117517", + "modified": "2021-09-29 23:06:10.904260", "modified_by": "Administrator", "module": "HR", "name": "Job Applicant", diff --git a/erpnext/hr/doctype/job_applicant/job_applicant.py b/erpnext/hr/doctype/job_applicant/job_applicant.py index 6971e5b4fef6..151f49248fd5 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant.py +++ b/erpnext/hr/doctype/job_applicant/job_applicant.py @@ -8,7 +8,9 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import comma_and, validate_email_address +from frappe.utils import validate_email_address + +from erpnext.hr.doctype.interview.interview import get_interviewers class DuplicationError(frappe.ValidationError): pass @@ -26,7 +28,6 @@ def autoname(self): self.name = " - ".join(keys) def validate(self): - self.check_email_id_is_unique() if self.email_id: validate_email_address(self.email_id, True) @@ -44,11 +45,44 @@ def set_status_for_employee_referral(self): elif self.status in ["Accepted", "Rejected"]: emp_ref.db_set("status", self.status) +@frappe.whitelist() +def create_interview(doc, interview_round): + import json - def check_email_id_is_unique(self): - if self.email_id: - names = frappe.db.sql_list("""select name from `tabJob Applicant` - where email_id=%s and name!=%s and job_title=%s""", (self.email_id, self.name, self.job_title)) + from six import string_types + + if isinstance(doc, string_types): + doc = json.loads(doc) + doc = frappe.get_doc(doc) + + round_designation = frappe.db.get_value("Interview Round", interview_round, "designation") + + if round_designation and doc.designation and round_designation != doc.designation: + frappe.throw(_("Interview Round {0} is only applicable for the Designation {1}").format(interview_round, round_designation)) + + interview = frappe.new_doc("Interview") + interview.interview_round = interview_round + interview.job_applicant = doc.name + interview.designation = doc.designation + interview.resume_link = doc.resume_link + interview.job_opening = doc.job_title + interviewer_detail = get_interviewers(interview_round) + + for d in interviewer_detail: + interview.append("interview_details", { + "interviewer": d.interviewer + }) + return interview + +@frappe.whitelist() +def get_interview_details(job_applicant): + interview_details = frappe.db.get_all("Interview", + filters={"job_applicant":job_applicant, "docstatus": ["!=", 2]}, + fields=["name", "interview_round", "expected_average_rating", "average_rating", "status"] + ) + interview_detail_map = {} + + for detail in interview_details: + interview_detail_map[detail.name] = detail - if names: - frappe.throw(_("Email Address must be unique, already exists for {0}").format(comma_and(names)), frappe.DuplicateEntryError) + return interview_detail_map diff --git a/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html new file mode 100644 index 000000000000..c286787a5563 --- /dev/null +++ b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.html @@ -0,0 +1,44 @@ + +{% if not jQuery.isEmptyObject(data) %} + + + + + + + + + + + + + {% for(const [key, value] of Object.entries(data)) { %} + + + + + + + + {% } %} + +
{{ __("Interview") }}{{ __("Interview Round") }}{{ __("Status") }}{{ __("Expected Rating") }}{{ __("Rating") }}
{%= key %} {%= value["interview_round"] %} {%= value["status"] %} + {% for (i = 0; i < value["expected_average_rating"]; i++) { %} + + {% } %} + {% for (i = 0; i < (5-value["expected_average_rating"]); i++) { %} + + {% } %} + + {% if(value["average_rating"]){ %} + {% for (i = 0; i < value["average_rating"]; i++) { %} + + {% } %} + {% for (i = 0; i < (5-value["average_rating"]); i++) { %} + + {% } %} + {% } %} +
+{% else %} +

No Interview has been scheduled.

+{% endif %} diff --git a/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.py b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.py index c0059431cfc7..2f7795fc089c 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.py +++ b/erpnext/hr/doctype/job_applicant/job_applicant_dashboard.py @@ -2,14 +2,17 @@ def get_data(): - return { - 'fieldname': 'job_applicant', - 'transactions': [ - { - 'items': ['Employee', 'Employee Onboarding'] - }, - { - 'items': ['Job Offer'] - }, - ], - } + return { + 'fieldname': 'job_applicant', + 'transactions': [ + { + 'items': ['Employee', 'Employee Onboarding'] + }, + { + 'items': ['Job Offer', 'Appointment Letter'] + }, + { + 'items': ['Interview'] + } + ], + } diff --git a/erpnext/hr/doctype/job_applicant/test_job_applicant.py b/erpnext/hr/doctype/job_applicant/test_job_applicant.py index e583e25eae0a..8fc129074218 100644 --- a/erpnext/hr/doctype/job_applicant/test_job_applicant.py +++ b/erpnext/hr/doctype/job_applicant/test_job_applicant.py @@ -7,7 +7,8 @@ import frappe -# test_records = frappe.get_test_records('Job Applicant') +from erpnext.hr.doctype.designation.test_designation import create_designation + class TestJobApplicant(unittest.TestCase): pass @@ -25,7 +26,8 @@ def create_job_applicant(**args): job_applicant = frappe.get_doc({ "doctype": "Job Applicant", - "status": args.status or "Open" + "status": args.status or "Open", + "designation": create_designation().name }) job_applicant.update(filters) diff --git a/erpnext/hr/doctype/job_offer/test_job_offer.py b/erpnext/hr/doctype/job_offer/test_job_offer.py index 3f3eca17e628..162b245d13ce 100644 --- a/erpnext/hr/doctype/job_offer/test_job_offer.py +++ b/erpnext/hr/doctype/job_offer/test_job_offer.py @@ -32,6 +32,7 @@ def test_job_offer_creation_against_vacancies(self): self.assertTrue(frappe.db.exists("Job Offer", job_offer.name)) def test_job_applicant_update(self): + frappe.db.set_value("HR Settings", None, "check_vacancies", 0) create_staffing_plan() job_applicant = create_job_applicant(email_id="test_job_applicants@example.com") job_offer = create_job_offer(job_applicant=job_applicant.name) @@ -43,7 +44,11 @@ def test_job_applicant_update(self): job_offer.status = "Rejected" job_offer.submit() job_applicant.reload() - self.assertEqual(job_applicant.status, "Rejected") + self.assertEquals(job_applicant.status, "Rejected") + frappe.db.set_value("HR Settings", None, "check_vacancies", 1) + + def tearDown(self): + frappe.db.sql("DELETE FROM `tabJob Offer` WHERE 1") def create_job_offer(**args): args = frappe._dict(args) diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.js b/erpnext/hr/doctype/leave_allocation/leave_allocation.js index d94764104d08..9742387c16a6 100755 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.js +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.js @@ -1,14 +1,14 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -cur_frm.add_fetch('employee','employee_name','employee_name'); +cur_frm.add_fetch('employee', 'employee_name', 'employee_name'); frappe.ui.form.on("Leave Allocation", { onload: function(frm) { // Ignore cancellation of doctype on cancel all. frm.ignore_doctypes_on_cancel_all = ["Leave Ledger Entry"]; - if(!frm.doc.from_date) frm.set_value("from_date", frappe.datetime.get_today()); + if (!frm.doc.from_date) frm.set_value("from_date", frappe.datetime.get_today()); frm.set_query("employee", function() { return { @@ -25,9 +25,9 @@ frappe.ui.form.on("Leave Allocation", { }, refresh: function(frm) { - if(frm.doc.docstatus === 1 && frm.doc.expired) { + if (frm.doc.docstatus === 1 && frm.doc.expired) { var valid_expiry = moment(frappe.datetime.get_today()).isBetween(frm.doc.from_date, frm.doc.to_date); - if(valid_expiry) { + if (valid_expiry) { // expire current allocation frm.add_custom_button(__('Expire Allocation'), function() { frm.trigger("expire_allocation"); @@ -44,8 +44,8 @@ frappe.ui.form.on("Leave Allocation", { 'expiry_date': frappe.datetime.get_today() }, freeze: true, - callback: function(r){ - if(!r.exc){ + callback: function(r) { + if (!r.exc) { frappe.msgprint(__("Allocation Expired!")); } frm.refresh(); @@ -77,8 +77,8 @@ frappe.ui.form.on("Leave Allocation", { }, leave_policy: function(frm) { - if(frm.doc.leave_policy && frm.doc.leave_type) { - frappe.db.get_value("Leave Policy Detail",{ + if (frm.doc.leave_policy && frm.doc.leave_type) { + frappe.db.get_value("Leave Policy Detail", { 'parent': frm.doc.leave_policy, 'leave_type': frm.doc.leave_type }, 'annual_allocation', (r) => { @@ -91,13 +91,41 @@ frappe.ui.form.on("Leave Allocation", { return frappe.call({ method: "set_total_leaves_allocated", doc: frm.doc, - callback: function(r) { + callback: function() { frm.refresh_fields(); } - }) + }); } else if (cint(frm.doc.carry_forward) == 0) { frm.set_value("unused_leaves", 0); frm.set_value("total_leaves_allocated", flt(frm.doc.new_leaves_allocated)); } } }); + +frappe.tour["Leave Allocation"] = [ + { + fieldname: "employee", + title: "Employee", + description: __("Select the Employee for which you want to allocate leaves.") + }, + { + fieldname: "leave_type", + title: "Leave Type", + description: __("Select the Leave Type like Sick leave, Privilege Leave, Casual Leave, etc.") + }, + { + fieldname: "from_date", + title: "From Date", + description: __("Select the date from which this Leave Allocation will be valid.") + }, + { + fieldname: "to_date", + title: "To Date", + description: __("Select the date after which this Leave Allocation will expire.") + }, + { + fieldname: "new_leaves_allocated", + title: "New Leaves Allocated", + description: __("Enter the number of leaves you want to allocate for the period.") + } +]; diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.json b/erpnext/hr/doctype/leave_allocation/leave_allocation.json index 3a6539ece9ec..52ee463db027 100644 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.json +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.json @@ -219,7 +219,8 @@ "fieldname": "leave_policy_assignment", "fieldtype": "Link", "label": "Leave Policy Assignment", - "options": "Leave Policy Assignment" + "options": "Leave Policy Assignment", + "read_only": 1 }, { "fetch_from": "employee.company", @@ -236,7 +237,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-06-03 15:28:26.335104", + "modified": "2021-10-01 15:28:26.335104", "modified_by": "Administrator", "module": "HR", "name": "Leave Allocation", diff --git a/erpnext/hr/doctype/leave_application/leave_application.js b/erpnext/hr/doctype/leave_application/leave_application.js index 9ccb915908ff..9e8cb5516f3b 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.js +++ b/erpnext/hr/doctype/leave_application/leave_application.js @@ -1,8 +1,8 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -cur_frm.add_fetch('employee','employee_name','employee_name'); -cur_frm.add_fetch('employee','company','company'); +cur_frm.add_fetch('employee', 'employee_name', 'employee_name'); +cur_frm.add_fetch('employee', 'company', 'company'); frappe.ui.form.on("Leave Application", { setup: function(frm) { @@ -19,7 +19,6 @@ frappe.ui.form.on("Leave Application", { frm.set_query("employee", erpnext.queries.employee); }, onload: function(frm) { - // Ignore cancellation of doctype on cancel all. frm.ignore_doctypes_on_cancel_all = ["Leave Ledger Entry"]; @@ -42,9 +41,9 @@ frappe.ui.form.on("Leave Application", { }, validate: function(frm) { - if (frm.doc.from_date == frm.doc.to_date && frm.doc.half_day == 1){ + if (frm.doc.from_date == frm.doc.to_date && frm.doc.half_day == 1) { frm.doc.half_day_date = frm.doc.from_date; - }else if (frm.doc.half_day == 0){ + } else if (frm.doc.half_day == 0) { frm.doc.half_day_date = ""; } frm.toggle_reqd("half_day_date", frm.doc.half_day == 1); @@ -79,14 +78,14 @@ frappe.ui.form.on("Leave Application", { __("Allocated Leaves") ); frm.dashboard.show(); - let allowed_leave_types = Object.keys(leave_details); + let allowed_leave_types = Object.keys(leave_details); // lwps should be allowed, lwps don't have any allocation allowed_leave_types = allowed_leave_types.concat(lwps); - frm.set_query('leave_type', function(){ + frm.set_query('leave_type', function() { return { - filters : [ + filters: [ ['leave_type_name', 'in', allowed_leave_types] ] }; @@ -99,7 +98,7 @@ frappe.ui.form.on("Leave Application", { frm.trigger("calculate_total_days"); } cur_frm.set_intro(""); - if(frm.doc.__islocal && !in_list(frappe.user_roles, "Employee")) { + if (frm.doc.__islocal && !in_list(frappe.user_roles, "Employee")) { frm.set_intro(__("Fill the form and save it")); } @@ -118,7 +117,7 @@ frappe.ui.form.on("Leave Application", { }, leave_approver: function(frm) { - if(frm.doc.leave_approver){ + if (frm.doc.leave_approver) { frm.set_value("leave_approver_name", frappe.user.full_name(frm.doc.leave_approver)); } }, @@ -131,12 +130,10 @@ frappe.ui.form.on("Leave Application", { if (frm.doc.half_day) { if (frm.doc.from_date == frm.doc.to_date) { frm.set_value("half_day_date", frm.doc.from_date); - } - else { + } else { frm.trigger("half_day_datepicker"); } - } - else { + } else { frm.set_value("half_day_date", ""); } frm.trigger("calculate_total_days"); @@ -163,11 +160,11 @@ frappe.ui.form.on("Leave Application", { half_day_datepicker.update({ minDate: frappe.datetime.str_to_obj(frm.doc.from_date), maxDate: frappe.datetime.str_to_obj(frm.doc.to_date) - }) + }); }, get_leave_balance: function(frm) { - if(frm.doc.docstatus==0 && frm.doc.employee && frm.doc.leave_type && frm.doc.from_date && frm.doc.to_date) { + if (frm.doc.docstatus === 0 && frm.doc.employee && frm.doc.leave_type && frm.doc.from_date && frm.doc.to_date) { return frappe.call({ method: "erpnext.hr.doctype.leave_application.leave_application.get_leave_balance_on", args: { @@ -177,11 +174,10 @@ frappe.ui.form.on("Leave Application", { leave_type: frm.doc.leave_type, consider_all_leaves_in_the_allocation_period: true }, - callback: function(r) { + callback: function (r) { if (!r.exc && r.message) { frm.set_value('leave_balance', r.message); - } - else { + } else { frm.set_value('leave_balance', "0"); } } @@ -190,12 +186,12 @@ frappe.ui.form.on("Leave Application", { }, calculate_total_days: function(frm) { - if(frm.doc.from_date && frm.doc.to_date && frm.doc.employee && frm.doc.leave_type) { + if (frm.doc.from_date && frm.doc.to_date && frm.doc.employee && frm.doc.leave_type) { var from_date = Date.parse(frm.doc.from_date); var to_date = Date.parse(frm.doc.to_date); - if(to_date < from_date){ + if (to_date < from_date) { frappe.msgprint(__("To Date cannot be less than From Date")); frm.set_value('to_date', ''); return; @@ -222,7 +218,7 @@ frappe.ui.form.on("Leave Application", { }, set_leave_approver: function(frm) { - if(frm.doc.employee) { + if (frm.doc.employee) { // server call is done to include holidays in leave days calculations return frappe.call({ method: 'erpnext.hr.doctype.leave_application.leave_application.get_leave_approver', @@ -238,3 +234,36 @@ frappe.ui.form.on("Leave Application", { } } }); + +frappe.tour["Leave Application"] = [ + { + fieldname: "employee", + title: "Employee", + description: __("Select the Employee.") + }, + { + fieldname: "leave_type", + title: "Leave Type", + description: __("Select type of leave the employee wants to apply for, like Sick Leave, Privilege Leave, Casual Leave, etc.") + }, + { + fieldname: "from_date", + title: "From Date", + description: __("Select the start date for your Leave Application.") + }, + { + fieldname: "to_date", + title: "To Date", + description: __("Select the end date for your Leave Application.") + }, + { + fieldname: "half_day", + title: "Half Day", + description: __("To apply for a Half Day check 'Half Day' and select the Half Day Date") + }, + { + fieldname: "leave_approver", + title: "Leave Approver", + description: __("Select your Leave Approver i.e. the person who approves or rejects your leaves.") + } +]; diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py index 9e6fc6d0f144..349ed7ad2276 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.py +++ b/erpnext/hr/doctype/leave_application/leave_application.py @@ -76,6 +76,7 @@ def on_submit(self): # notify leave applier about approval if frappe.db.get_single_value("HR Settings", "send_leave_notification"): self.notify_employee() + self.create_leave_ledger_entry() self.reload() @@ -108,7 +109,13 @@ def validate_dates(self): if frappe.db.get_single_value("HR Settings", "restrict_backdated_leave_application"): if self.from_date and getdate(self.from_date) < getdate(): allowed_role = frappe.db.get_single_value("HR Settings", "role_allowed_to_create_backdated_leave_application") - if allowed_role not in frappe.get_roles(): + user = frappe.get_doc("User", frappe.session.user) + user_roles = [d.role for d in user.roles] + if not allowed_role: + frappe.throw(_("Backdated Leave Application is restricted. Please set the {} in {}").format( + frappe.bold("Role Allowed to Create Backdated Leave Application"), get_link_to_form("HR Settings", "HR Settings"))) + + if (allowed_role and allowed_role not in user_roles): frappe.throw(_("Only users with the {0} role can create backdated leave applications").format(allowed_role)) if self.from_date and self.to_date and (getdate(self.to_date) < getdate(self.from_date)): diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index b9c785a8a9c9..629b20e768ee 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -121,6 +121,7 @@ def test_block_list(self): application = self.get_application(_test_records[0]) application.insert() + application.reload() application.status = "Approved" self.assertRaises(LeaveDayBlockedError, application.submit) diff --git a/erpnext/hr/doctype/leave_type/leave_type.js b/erpnext/hr/doctype/leave_type/leave_type.js index 8622309848a1..b930dedaca85 100644 --- a/erpnext/hr/doctype/leave_type/leave_type.js +++ b/erpnext/hr/doctype/leave_type/leave_type.js @@ -2,3 +2,37 @@ frappe.ui.form.on("Leave Type", { refresh: function(frm) { } }); + + +frappe.tour["Leave Type"] = [ + { + fieldname: "max_leaves_allowed", + title: "Maximum Leave Allocation Allowed", + description: __("This field allows you to set the maximum number of leaves that can be allocated annually for this Leave Type while creating the Leave Policy") + }, + { + fieldname: "max_continuous_days_allowed", + title: "Maximum Consecutive Leaves Allowed", + description: __("This field allows you to set the maximum number of consecutive leaves an Employee can apply for.") + }, + { + fieldname: "is_optional_leave", + title: "Is Optional Leave", + description: __("Optional Leaves are holidays that Employees can choose to avail from a list of holidays published by the company.") + }, + { + fieldname: "is_compensatory", + title: "Is Compensatory Leave", + description: __("Leaves you can avail against a holiday you worked on. You can claim Compensatory Off Leave using Compensatory Leave request. Click") + " here " + __('to know more') + }, + { + fieldname: "allow_encashment", + title: "Allow Encashment", + description: __("From here, you can enable encashment for the balance leaves.") + }, + { + fieldname: "is_earned_leave", + title: "Is Earned Leaves", + description: __("Earned Leaves are leaves earned by an Employee after working with the company for a certain amount of time. Enabling this will allocate leaves on pro-rata basis by automatically updating Leave Allocation for leaves of this type at intervals set by 'Earned Leave Frequency.") + } +]; \ No newline at end of file diff --git a/erpnext/hr/doctype/leave_type/leave_type.json b/erpnext/hr/doctype/leave_type/leave_type.json index 8f2ae6eb15d8..06ca4cdedbcc 100644 --- a/erpnext/hr/doctype/leave_type/leave_type.json +++ b/erpnext/hr/doctype/leave_type/leave_type.json @@ -50,7 +50,7 @@ { "fieldname": "max_leaves_allowed", "fieldtype": "Int", - "label": "Max Leaves Allowed" + "label": "Maximum Leave Allocation Allowed" }, { "fieldname": "applicable_after", @@ -61,7 +61,7 @@ "fieldname": "max_continuous_days_allowed", "fieldtype": "Int", "in_list_view": 1, - "label": "Maximum Continuous Days Applicable", + "label": "Maximum Consecutive Leaves Allowed", "oldfieldname": "max_days_allowed", "oldfieldtype": "Data" }, @@ -87,6 +87,7 @@ }, { "default": "0", + "description": "These leaves are holidays permitted by the company however, availing it is optional for an Employee.", "fieldname": "is_optional_leave", "fieldtype": "Check", "label": "Is Optional Leave" @@ -205,6 +206,7 @@ }, { "depends_on": "eval:doc.is_ppl == 1", + "description": "For a day of leave taken, if you still pay (say) 50% of the daily salary, then enter 0.50 in this field.", "fieldname": "fraction_of_daily_salary_per_leave", "fieldtype": "Float", "label": "Fraction of Daily Salary per Leave", @@ -214,7 +216,7 @@ "icon": "fa fa-flag", "idx": 1, "links": [], - "modified": "2021-08-12 16:10:36.464690", + "modified": "2021-10-02 11:59:40.503359", "modified_by": "Administrator", "module": "HR", "name": "Leave Type", diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 69af5c54c3b6..05b74a0dde9b 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -139,7 +139,7 @@ def get_shift_type_timing(shift_types): return shift_timing_map -def get_employee_shift(employee, for_date=nowdate(), consider_default_shift=False, next_shift_direction=None): +def get_employee_shift(employee, for_date=None, consider_default_shift=False, next_shift_direction=None): """Returns a Shift Type for the given employee on the given date. (excluding the holidays) :param employee: Employee for which shift is required. @@ -147,6 +147,8 @@ def get_employee_shift(employee, for_date=nowdate(), consider_default_shift=Fals :param consider_default_shift: If set to true, default shift is taken when no shift assignment is found. :param next_shift_direction: One of: None, 'forward', 'reverse'. Direction to look for next shift if shift not found on given date. """ + if for_date is None: + for_date = nowdate() default_shift = frappe.db.get_value('Employee', employee, 'default_shift') shift_type_name = None shift_assignment_details = frappe.db.get_value('Shift Assignment', {'employee':employee, 'start_date':('<=', for_date), 'docstatus': '1', 'status': "Active"}, ['shift_type', 'end_date']) @@ -200,9 +202,11 @@ def get_employee_shift(employee, for_date=nowdate(), consider_default_shift=Fals return get_shift_details(shift_type_name, for_date) -def get_employee_shift_timings(employee, for_timestamp=now_datetime(), consider_default_shift=False): +def get_employee_shift_timings(employee, for_timestamp=None, consider_default_shift=False): """Returns previous shift, current/upcoming shift, next_shift for the given timestamp and employee """ + if for_timestamp is None: + for_timestamp = now_datetime() # write and verify a test case for midnight shift. prev_shift = curr_shift = next_shift = None curr_shift = get_employee_shift(employee, for_timestamp.date(), consider_default_shift, 'forward') @@ -220,7 +224,7 @@ def get_employee_shift_timings(employee, for_timestamp=now_datetime(), consider_ return prev_shift, curr_shift, next_shift -def get_shift_details(shift_type_name, for_date=nowdate()): +def get_shift_details(shift_type_name, for_date=None): """Returns Shift Details which contain some additional information as described below. 'shift_details' contains the following keys: 'shift_type' - Object of DocType Shift Type, @@ -234,6 +238,8 @@ def get_shift_details(shift_type_name, for_date=nowdate()): """ if not shift_type_name: return None + if not for_date: + for_date = nowdate() shift_type = frappe.get_doc('Shift Type', shift_type_name) start_datetime = datetime.combine(for_date, datetime.min.time()) + shift_type.start_time for_date = for_date + timedelta(days=1) if shift_type.start_time > shift_type.end_time else for_date diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index e53373df2796..7a35b28ac439 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -97,7 +97,7 @@ def get_assigned_employee(self, from_date=None, consider_default_shift=False): assigned_employees = [x[0] for x in assigned_employees] if consider_default_shift: - filters = {'default_shift': self.name} + filters = {'default_shift': self.name, 'status': ['!=', 'Inactive']} default_shift_employees = frappe.get_all('Employee', 'name', filters, as_list=True) default_shift_employees = [x[0] for x in default_shift_employees] return list(set(assigned_employees+default_shift_employees)) diff --git a/erpnext/hr/doctype/skill_assessment/__init__.py b/erpnext/hr/doctype/skill_assessment/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/hr/doctype/skill_assessment/skill_assessment.json b/erpnext/hr/doctype/skill_assessment/skill_assessment.json new file mode 100644 index 000000000000..8b935c4073ab --- /dev/null +++ b/erpnext/hr/doctype/skill_assessment/skill_assessment.json @@ -0,0 +1,41 @@ +{ + "actions": [], + "creation": "2021-04-12 17:07:39.656289", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "skill", + "rating" + ], + "fields": [ + { + "fieldname": "skill", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Skill", + "options": "Skill", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "rating", + "fieldtype": "Rating", + "in_list_view": 1, + "label": "Rating", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-04-12 17:18:14.032298", + "modified_by": "Administrator", + "module": "HR", + "name": "Skill Assessment", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/skill_assessment/skill_assessment.py b/erpnext/hr/doctype/skill_assessment/skill_assessment.py new file mode 100644 index 000000000000..3b74c4ed5f96 --- /dev/null +++ b/erpnext/hr/doctype/skill_assessment/skill_assessment.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +# import frappe +from frappe.model.document import Document + + +class SkillAssessment(Document): + pass diff --git a/erpnext/hr/doctype/staffing_plan/staffing_plan.py b/erpnext/hr/doctype/staffing_plan/staffing_plan.py index 57a92b058718..93cd4e1f629e 100644 --- a/erpnext/hr/doctype/staffing_plan/staffing_plan.py +++ b/erpnext/hr/doctype/staffing_plan/staffing_plan.py @@ -155,7 +155,11 @@ def get_designation_counts(designation, company): return employee_counts @frappe.whitelist() -def get_active_staffing_plan_details(company, designation, from_date=getdate(nowdate()), to_date=getdate(nowdate())): +def get_active_staffing_plan_details(company, designation, from_date=None, to_date=None): + if from_date is None: + from_date = getdate(nowdate()) + if to_date is None: + to_date = getdate(nowdate()) if not company or not designation: frappe.throw(_("Please select Company and Designation")) diff --git a/erpnext/hr/module_onboarding/human_resource/human_resource.json b/erpnext/hr/module_onboarding/human_resource/human_resource.json index 518c002bcaa4..cd11bd1102ea 100644 --- a/erpnext/hr/module_onboarding/human_resource/human_resource.json +++ b/erpnext/hr/module_onboarding/human_resource/human_resource.json @@ -13,17 +13,14 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/human-resources", "idx": 0, "is_complete": 0, - "modified": "2020-07-08 14:05:47.018799", + "modified": "2021-05-19 05:32:01.794628", "modified_by": "Administrator", "module": "HR", "name": "Human Resource", "owner": "Administrator", "steps": [ { - "step": "Create Department" - }, - { - "step": "Create Designation" + "step": "HR Settings" }, { "step": "Create Holiday list" @@ -31,6 +28,9 @@ { "step": "Create Employee" }, + { + "step": "Data import" + }, { "step": "Create Leave Type" }, @@ -39,9 +39,6 @@ }, { "step": "Create Leave Application" - }, - { - "step": "HR Settings" } ], "subtitle": "Employee, Leaves, and more.", diff --git a/erpnext/hr/onboarding_step/create_employee/create_employee.json b/erpnext/hr/onboarding_step/create_employee/create_employee.json index 3aa33c6d862a..47828186bf31 100644 --- a/erpnext/hr/onboarding_step/create_employee/create_employee.json +++ b/erpnext/hr/onboarding_step/create_employee/create_employee.json @@ -1,18 +1,20 @@ { - "action": "Create Entry", + "action": "Show Form Tour", + "action_label": "Show Tour", "creation": "2020-05-14 11:43:25.561152", + "description": "

Employee

\n\nAn individual who works and is recognized for his rights and duties in your company is your Employee. You can manage the Employee master. It captures the demographic, personal and professional details, joining and leave details, etc.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 1, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 12:26:28.629074", + "modified": "2021-05-19 04:50:02.240321", "modified_by": "Administrator", "name": "Create Employee", "owner": "Administrator", "reference_document": "Employee", + "show_form_tour": 0, "show_full_form": 0, "title": "Create Employee", "validate_action": 0 diff --git a/erpnext/hr/onboarding_step/create_holiday_list/create_holiday_list.json b/erpnext/hr/onboarding_step/create_holiday_list/create_holiday_list.json index 32472b4b3fab..a08e85fff01e 100644 --- a/erpnext/hr/onboarding_step/create_holiday_list/create_holiday_list.json +++ b/erpnext/hr/onboarding_step/create_holiday_list/create_holiday_list.json @@ -1,18 +1,20 @@ { - "action": "Create Entry", + "action": "Show Form Tour", + "action_label": "Show Tour", "creation": "2020-05-28 11:47:34.700174", + "description": "

Holiday List.

\n\nHoliday List is a list which contains the dates of holidays. Most organizations have a standard Holiday List for their employees. However, some of them may have different holiday lists based on different Locations or Departments. In ERPNext, you can configure multiple Holiday Lists.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 1, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 12:25:38.068582", + "modified": "2021-05-19 04:19:52.305199", "modified_by": "Administrator", "name": "Create Holiday list", "owner": "Administrator", "reference_document": "Holiday List", + "show_form_tour": 0, "show_full_form": 1, "title": "Create Holiday List", "validate_action": 0 diff --git a/erpnext/hr/onboarding_step/create_leave_allocation/create_leave_allocation.json b/erpnext/hr/onboarding_step/create_leave_allocation/create_leave_allocation.json index fa9941e6b97e..0b0ce3fc8bbe 100644 --- a/erpnext/hr/onboarding_step/create_leave_allocation/create_leave_allocation.json +++ b/erpnext/hr/onboarding_step/create_leave_allocation/create_leave_allocation.json @@ -1,18 +1,20 @@ { - "action": "Create Entry", + "action": "Show Form Tour", + "action_label": "Show Tour", "creation": "2020-05-14 11:48:56.123718", + "description": "

Leave Allocation

\n\nLeave Allocation enables you to allocate a specific number of leaves of a particular type to an Employee so that, an employee will be able to create a Leave Application only if Leaves are allocated. ", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 1, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 11:48:56.123718", + "modified": "2021-05-19 04:22:34.220238", "modified_by": "Administrator", "name": "Create Leave Allocation", "owner": "Administrator", "reference_document": "Leave Allocation", + "show_form_tour": 0, "show_full_form": 0, "title": "Create Leave Allocation", "validate_action": 0 diff --git a/erpnext/hr/onboarding_step/create_leave_application/create_leave_application.json b/erpnext/hr/onboarding_step/create_leave_application/create_leave_application.json index 1ed074e9a1d8..af63aa59ed61 100644 --- a/erpnext/hr/onboarding_step/create_leave_application/create_leave_application.json +++ b/erpnext/hr/onboarding_step/create_leave_application/create_leave_application.json @@ -1,18 +1,20 @@ { - "action": "Create Entry", + "action": "Show Form Tour", + "action_label": "Show Tour", "creation": "2020-05-14 11:49:45.400764", + "description": "

Leave Application

\n\nLeave Application is a formal document created by an Employee to apply for Leaves for a particular time period based on there leave allocation and leave type according to there need.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 1, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-14 11:49:45.400764", + "modified": "2021-05-19 04:39:09.893474", "modified_by": "Administrator", "name": "Create Leave Application", "owner": "Administrator", "reference_document": "Leave Application", + "show_form_tour": 0, "show_full_form": 0, "title": "Create Leave Application", "validate_action": 0 diff --git a/erpnext/hr/onboarding_step/create_leave_type/create_leave_type.json b/erpnext/hr/onboarding_step/create_leave_type/create_leave_type.json index 8cbfc5c81f94..397f5cde49cc 100644 --- a/erpnext/hr/onboarding_step/create_leave_type/create_leave_type.json +++ b/erpnext/hr/onboarding_step/create_leave_type/create_leave_type.json @@ -1,18 +1,20 @@ { - "action": "Create Entry", + "action": "Show Form Tour", + "action_label": "Show Tour", "creation": "2020-05-27 11:17:31.119312", + "description": "

Leave Type

\n\nLeave type is defined based on many factors and features like encashment, earned leaves, partially paid, without pay and, a lot more. To check other options and to define your leave type click on Show Tour.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 1, "is_single": 0, "is_skipped": 0, - "modified": "2020-05-20 11:17:31.119312", + "modified": "2021-05-19 04:32:48.135406", "modified_by": "Administrator", "name": "Create Leave Type", "owner": "Administrator", "reference_document": "Leave Type", + "show_form_tour": 0, "show_full_form": 1, "title": "Create Leave Type", "validate_action": 0 diff --git a/erpnext/hr/onboarding_step/data_import/data_import.json b/erpnext/hr/onboarding_step/data_import/data_import.json new file mode 100644 index 000000000000..ac343c677592 --- /dev/null +++ b/erpnext/hr/onboarding_step/data_import/data_import.json @@ -0,0 +1,21 @@ +{ + "action": "Watch Video", + "action_label": "", + "creation": "2021-05-19 05:29:16.809610", + "description": "

Data Import

\n\nData import is the tool to migrate your existing data like Employee, Customer, Supplier, and a lot more to our ERPNext system.\nGo through the video for a detailed explanation of this tool.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-05-19 05:29:16.809610", + "modified_by": "Administrator", + "name": "Data import", + "owner": "Administrator", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Data Import", + "validate_action": 1, + "video_url": "https://www.youtube.com/watch?v=DQyqeurPI64" +} \ No newline at end of file diff --git a/erpnext/hr/onboarding_step/hr_settings/hr_settings.json b/erpnext/hr/onboarding_step/hr_settings/hr_settings.json index 0a1d0baf8aad..355664fbc593 100644 --- a/erpnext/hr/onboarding_step/hr_settings/hr_settings.json +++ b/erpnext/hr/onboarding_step/hr_settings/hr_settings.json @@ -1,18 +1,20 @@ { - "action": "Update Settings", + "action": "Show Form Tour", + "action_label": "Explore", "creation": "2020-05-28 13:13:52.427711", + "description": "

HR Settings

\n\nHr Settings consists of major settings related to Employee Lifecycle, Leave Management, etc. Click on Explore, to explore Hr Settings.", "docstatus": 0, "doctype": "Onboarding Step", "idx": 0, "is_complete": 0, - "is_mandatory": 0, "is_single": 1, "is_skipped": 0, - "modified": "2020-05-20 11:16:42.430974", + "modified": "2021-05-18 07:02:05.747548", "modified_by": "Administrator", "name": "HR Settings", "owner": "Administrator", "reference_document": "HR Settings", + "show_form_tour": 0, "show_full_form": 0, "title": "HR Settings", "validate_action": 0 diff --git a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py index 6bca1368d3fe..d463b9b62a87 100644 --- a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py +++ b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py @@ -182,10 +182,11 @@ def get_allocated_and_expired_leaves(from_date, to_date, employee, leave_type): records= frappe.db.sql(""" SELECT employee, leave_type, from_date, to_date, leaves, transaction_name, - is_carry_forward, is_expired + transaction_type, is_carry_forward, is_expired FROM `tabLeave Ledger Entry` WHERE employee=%(employee)s AND leave_type=%(leave_type)s AND docstatus=1 + AND transaction_type = 'Leave Allocation' AND (from_date between %(from_date)s AND %(to_date)s OR to_date between %(from_date)s AND %(to_date)s OR (from_date < %(from_date)s AND to_date > %(to_date)s)) diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index 8df5cb582e35..0b2f99c358e1 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -145,7 +145,15 @@ def set_employee_name(doc): if doc.employee and not doc.employee_name: doc.employee_name = frappe.db.get_value("Employee", doc.employee, "employee_name") -def update_employee(employee, details, date=None, cancel=False): +def update_employee_work_history(employee, details, date=None, cancel=False): + if not employee.internal_work_history and not cancel: + employee.append("internal_work_history", { + "branch": employee.branch, + "designation": employee.designation, + "department": employee.department, + "from_date": employee.date_of_joining + }) + internal_work_history = {} for item in details: field = frappe.get_meta("Employee").get_field(item.fieldname) @@ -160,11 +168,35 @@ def update_employee(employee, details, date=None, cancel=False): setattr(employee, item.fieldname, new_data) if item.fieldname in ["department", "designation", "branch"]: internal_work_history[item.fieldname] = item.new + if internal_work_history and not cancel: internal_work_history["from_date"] = date employee.append("internal_work_history", internal_work_history) + + if cancel: + delete_employee_work_history(details, employee, date) + return employee +def delete_employee_work_history(details, employee, date): + filters = {} + for d in details: + for history in employee.internal_work_history: + if d.property == "Department" and history.department == d.new: + department = d.new + filters["department"] = department + if d.property == "Designation" and history.designation == d.new: + designation = d.new + filters["designation"] = designation + if d.property == "Branch" and history.branch == d.new: + branch = d.new + filters["branch"] = branch + if date and date == history.from_date: + filters["from_date"] = date + if filters: + frappe.db.delete("Employee Internal Work History", filters) + + @frappe.whitelist() def get_employee_fields_label(): fields = [] diff --git a/erpnext/loan_management/doctype/loan/loan.json b/erpnext/loan_management/doctype/loan/loan.json index c9f23ca4df3b..5979992bbe83 100644 --- a/erpnext/loan_management/doctype/loan/loan.json +++ b/erpnext/loan_management/doctype/loan/loan.json @@ -334,7 +334,6 @@ }, { "depends_on": "eval:doc.is_secured_loan", - "fetch_from": "loan_application.maximum_loan_amount", "fieldname": "maximum_loan_amount", "fieldtype": "Currency", "label": "Maximum Loan Amount", @@ -360,7 +359,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-19 18:10:32.360818", + "modified": "2021-10-12 18:10:32.360818", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan", diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py index 7dbd42297e1f..0f2c3cfdfc02 100644 --- a/erpnext/loan_management/doctype/loan/loan.py +++ b/erpnext/loan_management/doctype/loan/loan.py @@ -137,16 +137,23 @@ def validate_loan_amount(self): frappe.throw(_("Loan amount is mandatory")) def link_loan_security_pledge(self): - if self.is_secured_loan: - loan_security_pledge = frappe.db.get_value('Loan Security Pledge', {'loan_application': self.loan_application}, - 'name') - - if loan_security_pledge: - frappe.db.set_value('Loan Security Pledge', loan_security_pledge, { - 'loan': self.name, - 'status': 'Pledged', - 'pledge_time': now_datetime() - }) + if self.is_secured_loan and self.loan_application: + maximum_loan_value = frappe.db.get_value('Loan Security Pledge', + { + 'loan_application': self.loan_application, + 'status': 'Requested' + }, + 'sum(maximum_loan_value)' + ) + + if maximum_loan_value: + frappe.db.sql(""" + UPDATE `tabLoan Security Pledge` + SET loan = %s, pledge_time = %s, status = 'Pledged' + WHERE status = 'Requested' and loan_application = %s + """, (self.name, now_datetime(), self.loan_application)) + + self.db_set('maximum_loan_amount', maximum_loan_value) def unlink_loan_security_pledge(self): pledges = frappe.get_all('Loan Security Pledge', fields=['name'], filters={'loan': self.name}) diff --git a/erpnext/loan_management/doctype/loan_application/loan_application.py b/erpnext/loan_management/doctype/loan_application/loan_application.py index e492920abb31..ede0467b0e72 100644 --- a/erpnext/loan_management/doctype/loan_application/loan_application.py +++ b/erpnext/loan_management/doctype/loan_application/loan_application.py @@ -130,10 +130,11 @@ def set_loan_amount(self): def create_loan(source_name, target_doc=None, submit=0): def update_accounts(source_doc, target_doc, source_parent): account_details = frappe.get_all("Loan Type", - fields=["mode_of_payment", "payment_account","loan_account", "interest_income_account", "penalty_income_account"], - filters = {'name': source_doc.loan_type} - )[0] + fields=["mode_of_payment", "payment_account","loan_account", "interest_income_account", "penalty_income_account"], + filters = {'name': source_doc.loan_type})[0] + if source_doc.is_secured_loan: + target_doc.maximum_loan_amount = 0 target_doc.mode_of_payment = account_details.mode_of_payment target_doc.payment_account = account_details.payment_account diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py index 6d9d4f490d35..99f0d259246d 100644 --- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py +++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py @@ -198,7 +198,7 @@ def get_disbursal_amount(loan, on_current_security_price=0): security_value = get_total_pledged_security_value(loan) if loan_details.is_secured_loan and not on_current_security_price: - security_value = flt(loan_details.maximum_loan_amount) + security_value = get_maximum_amount_as_per_pledged_security(loan) if not security_value and not loan_details.is_secured_loan: security_value = flt(loan_details.loan_amount) @@ -209,3 +209,6 @@ def get_disbursal_amount(loan, on_current_security_price=0): disbursal_amount = loan_details.loan_amount - loan_details.disbursed_amount return disbursal_amount + +def get_maximum_amount_as_per_pledged_security(loan): + return flt(frappe.db.get_value('Loan Security Pledge', {'loan': loan}, 'sum(maximum_loan_value)')) diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index 13b73573274b..40bb581165b0 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -411,7 +411,7 @@ def get_amounts(amounts, against_loan, posting_date): if due_date and not final_due_date: final_due_date = add_days(due_date, loan_type_details.grace_period_in_days) - if against_loan_doc.status in ('Disbursed', 'Loan Closure Requested', 'Closed'): + if against_loan_doc.status in ('Disbursed', 'Closed') or against_loan_doc.disbursed_amount >= against_loan_doc.loan_amount: pending_principal_amount = against_loan_doc.total_payment - against_loan_doc.total_principal_paid \ - against_loan_doc.total_interest_payable - against_loan_doc.written_off_amount else: diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py index a1df9cfd0eb9..adb57f9f3977 100644 --- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py +++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py @@ -199,12 +199,16 @@ def validate_sales_order(self): if chk: throw(_("Maintenance Schedule {0} exists against {1}").format(chk[0][0], d.sales_order)) + def validate_no_of_visits(self): + return len(self.schedules) != sum(d.no_of_visits for d in self.items) + def validate(self): self.validate_end_date_visits() self.validate_maintenance_detail() self.validate_dates_with_periodicity() self.validate_sales_order() - self.generate_schedule() + if not self.schedules or self.validate_no_of_visits(): + self.generate_schedule() def on_update(self): frappe.db.set(self, 'status', 'Draft') diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index ef074052620c..23004ee41d76 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -215,7 +215,32 @@ frappe.ui.form.on("BOM", { label: __('Qty To Manufacture'), fieldname: 'qty', reqd: 1, - default: 1 + default: 1, + onchange: () => { + const { quantity, items: rm } = frm.doc; + const variant_items_map = rm.reduce((acc, item) => { + acc[item.item_code] = item.qty; + return acc; + }, {}); + const mf_qty = cur_dialog.fields_list.filter( + (f) => f.df.fieldname === "qty" + )[0]?.value; + const items = cur_dialog.fields.filter( + (f) => f.fieldname === "items" + )[0]?.data; + + if (!items) { + return; + } + + items.forEach((item) => { + item.qty = + (variant_items_map[item.item_code] * mf_qty) / + quantity; + }); + + cur_dialog.refresh(); + } }); } diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index 7e539183b0c5..62187077f3d0 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -436,7 +436,7 @@ "description": "Item Image (if not slideshow)", "fieldname": "website_image", "fieldtype": "Attach Image", - "label": "Image" + "label": "Website Image" }, { "allow_on_submit": 1, @@ -539,7 +539,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2021-05-16 12:25:09.081968", + "modified": "2021-10-27 14:52:04.500251", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 651e64614946..0ac64c2cfcaf 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -308,6 +308,9 @@ def update_cost(self, update_parent=True, from_child_bom=False, update_hour_rate existing_bom_cost = self.total_cost for d in self.get("items"): + if not d.item_code: + continue + rate = self.get_rm_rate({ "company": self.company, "item_code": d.item_code, @@ -600,7 +603,7 @@ def get_exploded_items(self): for d in self.get('items'): if d.bom_no: self.get_child_exploded_items(d.bom_no, d.stock_qty) - else: + elif d.item_code: self.add_to_cur_exploded_items(frappe._dict({ 'item_code' : d.item_code, 'item_name' : d.item_name, @@ -1135,8 +1138,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): query_filters["has_variants"] = 0 if filters and filters.get("is_stock_item"): - or_cond_filters["is_stock_item"] = 1 - or_cond_filters["has_variants"] = 1 + query_filters["is_stock_item"] = 1 return frappe.get_list("Item", fields = fields, filters=query_filters, diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 8338fa30ddc4..6126c95cd457 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -4,12 +4,14 @@ import unittest from collections import deque +from functools import partial import frappe from frappe.test_runner import make_test_records from frappe.utils import cstr, flt from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.manufacturing.doctype.bom.bom import item_query from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( @@ -344,6 +346,16 @@ def test_bom_tree_representation(self): for reqd_item, created_item in zip(reqd_order, created_order): self.assertEqual(reqd_item, created_item.item_code) + def test_bom_item_query(self): + query = partial(item_query, doctype="Item", txt="", searchfield="name", start=0, page_len=20, filters={"is_stock_item": 1}) + + test_items = query(txt="_Test") + filtered = query(txt="_Test Item 2") + + self.assertNotEqual(len(test_items), len(filtered), msg="Item filtering showing excessive results") + self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results") + + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 35be38813e5b..bb258812b215 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -28,6 +28,11 @@ frappe.ui.form.on('Job Card', { frappe.flags.resume_job = 0; let has_items = frm.doc.items && frm.doc.items.length; + if (frm.doc.__onload.work_order_stopped) { + frm.disable_save(); + return; + } + if (!frm.doc.__islocal && has_items && frm.doc.docstatus < 2) { let to_request = frm.doc.for_quantity > frm.doc.transferred_qty; let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; @@ -70,6 +75,23 @@ frappe.ui.form.on('Job Card', { && (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty)) { frm.trigger("prepare_timer_buttons"); } + frm.trigger("setup_quality_inspection"); + }, + + setup_quality_inspection: function(frm) { + let quality_inspection_field = frm.get_docfield("quality_inspection"); + quality_inspection_field.get_route_options_for_new_doc = function(frm) { + return { + "inspection_type": "In Process", + "reference_type": "Job Card", + "reference_name": frm.doc.name, + "item_code": frm.doc.production_item, + "item_name": frm.doc.item_name, + "item_serial_no": frm.doc.serial_no, + "batch_no": frm.doc.batch_no, + "quality_inspection_template": frm.doc.quality_inspection_template, + }; + }; }, setup_corrective_job_card: function(frm) { diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 7dd38f4673da..8095e66eac0f 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -19,6 +19,7 @@ "serial_no", "column_break_12", "wip_warehouse", + "quality_inspection_template", "quality_inspection", "project", "batch_no", @@ -407,11 +408,18 @@ "no_copy": 1, "options": "Job Card Scrap Item", "print_hide": 1 + }, + { + "fetch_from": "operation.quality_inspection_template", + "fieldname": "quality_inspection_template", + "fieldtype": "Link", + "label": "Quality Inspection Template", + "options": "Quality Inspection Template" } ], "is_submittable": 1, "links": [], - "modified": "2021-09-14 00:38:46.873105", + "modified": "2021-11-09 14:07:20.290306", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index e1d79be81c4f..dd1df20216bd 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -37,6 +37,7 @@ class JobCard(Document): def onload(self): excess_transfer = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer") self.set_onload("job_card_excess_transfer", excess_transfer) + self.set_onload("work_order_stopped", self.is_work_order_stopped()) def validate(self): self.validate_time_logs() @@ -45,6 +46,7 @@ def validate(self): self.validate_sequence_id() self.set_sub_operations() self.update_sub_operation_status() + self.validate_work_order() def set_sub_operations(self): if self.operation: @@ -549,6 +551,18 @@ def validate_sequence_id(self): frappe.throw(_("{0}, complete the operation {1} before the operation {2}.") .format(message, bold(row.operation), bold(self.operation)), OperationSequenceError) + def validate_work_order(self): + if self.is_work_order_stopped(): + frappe.throw(_("You can't make any changes to Job Card since Work Order is stopped.")) + + def is_work_order_stopped(self): + if self.work_order: + status = frappe.get_value('Work Order', self.work_order) + + if status == "Closed": + return True + + return False @frappe.whitelist() def make_time_log(args): diff --git a/erpnext/manufacturing/doctype/operation/operation.json b/erpnext/manufacturing/doctype/operation/operation.json index 10a97eda7639..3da3f03b3f15 100644 --- a/erpnext/manufacturing/doctype/operation/operation.json +++ b/erpnext/manufacturing/doctype/operation/operation.json @@ -13,6 +13,7 @@ "is_corrective_operation", "job_card_section", "create_job_card_based_on_batch_size", + "quality_inspection_template", "column_break_6", "batch_size", "sub_operations_section", @@ -92,12 +93,18 @@ "fieldname": "is_corrective_operation", "fieldtype": "Check", "label": "Is Corrective Operation" + }, + { + "fieldname": "quality_inspection_template", + "fieldtype": "Link", + "label": "Quality Inspection Template", + "options": "Quality Inspection Template" } ], "icon": "fa fa-wrench", "index_web_pages_for_search": 1, "links": [], - "modified": "2021-01-12 15:09:23.593338", + "modified": "2021-11-03 13:49:39.114976", "modified_by": "Administrator", "module": "Manufacturing", "name": "Operation", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index b9efe9b41ea2..2424ef9a71c1 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -311,7 +311,7 @@ def set_status(self, close=None): if self.total_produced_qty > 0: self.status = "In Process" - if self.total_produced_qty >= self.total_planned_qty: + if self.check_have_work_orders_completed(): self.status = "Completed" if self.status != 'Completed': @@ -424,7 +424,7 @@ def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders): po = frappe.new_doc('Purchase Order') po.supplier = supplier po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate() - po.is_subcontracted_item = 'Yes' + po.is_subcontracted = 'Yes' for row in po_list: args = { 'item_code': row.production_item, @@ -575,6 +575,15 @@ def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_typ self.append("sub_assembly_items", data) + def check_have_work_orders_completed(self): + wo_status = frappe.db.get_list( + "Work Order", + filters={"production_plan": self.name}, + fields="status", + pluck="status" + ) + return all(s == "Completed" for s in wo_status) + @frappe.whitelist() def download_raw_materials(doc, warehouses=None): if isinstance(doc, str): diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 85b5bfb9bfcc..f4a88dc45982 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -12,6 +12,7 @@ ItemHasVariantError, OverProductionError, StockOverProductionError, + close_work_order, make_stock_entry, stop_unstop, ) @@ -800,6 +801,46 @@ def test_job_card_scrap_item(self): if row.is_scrap_item: self.assertEqual(row.qty, 1) + def test_close_work_order(self): + items = ['Test FG Item for Closed WO', 'Test RM Item 1 for Closed WO', + 'Test RM Item 2 for Closed WO'] + + company = '_Test Company with perpetual inventory' + for item_code in items: + create_item(item_code = item_code, is_stock_item = 1, + is_purchase_item=1, opening_stock=100, valuation_rate=10, company=company, warehouse='Stores - TCP1') + + item = 'Test FG Item for Closed WO' + raw_materials = ['Test RM Item 1 for Closed WO', 'Test RM Item 2 for Closed WO'] + if not frappe.db.get_value('BOM', {'item': item}): + bom = make_bom(item=item, source_warehouse='Stores - TCP1', raw_materials=raw_materials, do_not_save=True) + bom.with_operations = 1 + bom.append('operations', { + 'operation': '_Test Operation 1', + 'workstation': '_Test Workstation 1', + 'hour_rate': 20, + 'time_in_mins': 60 + }) + + bom.submit() + + wo_order = make_wo_order_test_record(item=item, company=company, planned_start_date=now(), qty=20, skip_transfer=1) + job_cards = frappe.db.get_value('Job Card', {'work_order': wo_order.name}, 'name') + + if len(job_cards) == len(bom.operations): + for jc in job_cards: + job_card_doc = frappe.get_doc('Job Card', jc) + job_card_doc.append('time_logs', { + 'from_time': now(), + 'time_in_mins': 60, + 'completed_qty': job_card_doc.for_quantity + }) + + job_card_doc.submit() + + close_work_order(wo_order, "Closed") + self.assertEqual(wo_order.get('status'), "Closed") + def update_job_card(job_card): job_card_doc = frappe.get_doc('Job Card', job_card) job_card_doc.set('scrap_items', [ diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 512048512ed1..21209cab962d 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -135,24 +135,26 @@ frappe.ui.form.on("Work Order", { frm.set_intro(__("Submit this Work Order for further processing.")); } - if (frm.doc.docstatus===1) { - frm.trigger('show_progress_for_items'); - frm.trigger('show_progress_for_operations'); - } + if (frm.doc.status != "Closed") { + if (frm.doc.docstatus===1) { + frm.trigger('show_progress_for_items'); + frm.trigger('show_progress_for_operations'); + } - if (frm.doc.docstatus === 1 - && frm.doc.operations && frm.doc.operations.length) { + if (frm.doc.docstatus === 1 + && frm.doc.operations && frm.doc.operations.length) { - const not_completed = frm.doc.operations.filter(d => { - if(d.status != 'Completed') { - return true; - } - }); + const not_completed = frm.doc.operations.filter(d => { + if (d.status != 'Completed') { + return true; + } + }); - if(not_completed && not_completed.length) { - frm.add_custom_button(__('Create Job Card'), () => { - frm.trigger("make_job_card"); - }).addClass('btn-primary'); + if (not_completed && not_completed.length) { + frm.add_custom_button(__('Create Job Card'), () => { + frm.trigger("make_job_card"); + }).addClass('btn-primary'); + } } } @@ -517,14 +519,22 @@ frappe.ui.form.on("Work Order Operation", { erpnext.work_order = { set_custom_buttons: function(frm) { var doc = frm.doc; - if (doc.docstatus === 1) { + if (doc.docstatus === 1 && doc.status != "Closed") { + frm.add_custom_button(__('Close'), function() { + frappe.confirm(__("Once the Work Order is Closed. It can't be resumed."), + () => { + erpnext.work_order.change_work_order_status(frm, "Closed"); + } + ); + }, __("Status")); + if (doc.status != 'Stopped' && doc.status != 'Completed') { frm.add_custom_button(__('Stop'), function() { - erpnext.work_order.stop_work_order(frm, "Stopped"); + erpnext.work_order.change_work_order_status(frm, "Stopped"); }, __("Status")); } else if (doc.status == 'Stopped') { frm.add_custom_button(__('Re-open'), function() { - erpnext.work_order.stop_work_order(frm, "Resumed"); + erpnext.work_order.change_work_order_status(frm, "Resumed"); }, __("Status")); } @@ -713,9 +723,10 @@ erpnext.work_order = { }); }, - stop_work_order: function(frm, status) { + change_work_order_status: function(frm, status) { + let method_name = status=="Closed" ? "close_work_order" : "stop_unstop"; frappe.call({ - method: "erpnext.manufacturing.doctype.work_order.work_order.stop_unstop", + method: `erpnext.manufacturing.doctype.work_order.work_order.${method_name}`, freeze: true, freeze_message: __("Updating Work Order status"), args: { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 913fc85af610..df7ee53b92de 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -99,7 +99,7 @@ "no_copy": 1, "oldfieldname": "status", "oldfieldtype": "Select", - "options": "\nDraft\nSubmitted\nNot Started\nIn Process\nCompleted\nStopped\nCancelled", + "options": "\nDraft\nSubmitted\nNot Started\nIn Process\nCompleted\nStopped\nClosed\nCancelled", "read_only": 1, "reqd": 1, "search_index": 1 @@ -182,6 +182,7 @@ "reqd": 1 }, { + "default": "1.0", "fieldname": "qty", "fieldtype": "Float", "label": "Qty To Manufacture", @@ -572,10 +573,12 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2021-08-24 15:14:03.844937", + "migration_hash": "a18118963f4fcdb7f9d326de5f4063ba", + "modified": "2021-10-29 15:12:32.203605", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", + "naming_rule": "By \"Naming Series\" field", "nsm_parent_field": "parent_work_order", "owner": "Administrator", "permissions": [ diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index e282dd3ecba5..0090f4d04eef 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -175,7 +175,7 @@ def validate_work_order_against_so(self): def update_status(self, status=None): '''Update status of work order if unknown''' - if status != "Stopped": + if status != "Stopped" and status != "Closed": status = self.get_status(status) if status != self.status: @@ -624,7 +624,6 @@ def validate_qty(self): def validate_operation_time(self): for d in self.operations: if not d.time_in_mins > 0: - print(self.bom_no, self.production_item) frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation)) def update_required_items(self): @@ -685,9 +684,7 @@ def set_required_items(self, reset_only_qty=False): if not d.operation: d.operation = operation else: - # Attribute a big number (999) to idx for sorting putpose in case idx is NULL - # For instance in BOM Explosion Item child table, the items coming from sub assembly items - for item in sorted(item_dict.values(), key=lambda d: d['idx'] or 9999): + for item in sorted(item_dict.values(), key=lambda d: d['idx'] or float('inf')): self.append('required_items', { 'rate': item.rate, 'amount': item.rate * item.qty, @@ -969,6 +966,10 @@ def stop_unstop(work_order, status): frappe.throw(_("Not permitted"), frappe.PermissionError) pro_order = frappe.get_doc("Work Order", work_order) + + if pro_order.status == "Closed": + frappe.throw(_("Closed Work Order can not be stopped or Re-opened")) + pro_order.update_status(status) pro_order.update_planned_qty() frappe.msgprint(_("Work Order has been {0}").format(status)) @@ -1003,6 +1004,29 @@ def make_job_card(work_order, operations): if row.job_card_qty > 0: create_job_card(work_order, row, auto_create=True) +@frappe.whitelist() +def close_work_order(work_order, status): + if not frappe.has_permission("Work Order", "write"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + work_order = frappe.get_doc("Work Order", work_order) + if work_order.get("operations"): + job_cards = frappe.get_list("Job Card", + filters={ + "work_order": work_order.name, + "status": "Work In Progress" + }, pluck='name') + + if job_cards: + job_cards = ", ".join(job_cards) + frappe.throw(_("Can not close Work Order. Since {0} Job Cards are in Work In Progress state.").format(job_cards)) + + work_order.update_status(status) + work_order.update_planned_qty() + frappe.msgprint(_("Work Order has been {0}").format(status)) + work_order.notify_update() + return work_order.status + def split_qty_based_on_batch_size(wo_doc, row, qty): if not cint(frappe.db.get_value("Operation", row.operation, "create_job_card_based_on_batch_size")): diff --git a/erpnext/manufacturing/report/job_card_summary/job_card_summary.py b/erpnext/manufacturing/report/job_card_summary/job_card_summary.py index a7aec315ff2a..74bd685b7998 100644 --- a/erpnext/manufacturing/report/job_card_summary/job_card_summary.py +++ b/erpnext/manufacturing/report/job_card_summary/job_card_summary.py @@ -24,7 +24,7 @@ def get_data(filters): } fields = ["name", "status", "work_order", "production_item", "item_name", "posting_date", - "total_completed_qty", "workstation", "operation", "employee_name", "total_time_in_mins"] + "total_completed_qty", "workstation", "operation", "total_time_in_mins"] for field in ["work_order", "workstation", "operation", "company"]: if filters.get(field): @@ -45,7 +45,7 @@ def get_data(filters): job_card_time_details = {} for job_card_data in frappe.get_all("Job Card Time Log", fields=["min(from_time) as from_time", "max(to_time) as to_time", "parent"], - filters=job_card_time_filter, group_by="parent", debug=1): + filters=job_card_time_filter, group_by="parent"): job_card_time_details[job_card_data.parent] = job_card_data res = [] @@ -172,12 +172,6 @@ def get_columns(filters): "options": "Operation", "width": 110 }, - { - "label": _("Employee Name"), - "fieldname": "employee_name", - "fieldtype": "Data", - "width": 110 - }, { "label": _("Total Completed Qty"), "fieldname": "total_completed_qty", diff --git a/erpnext/manufacturing/report/process_loss_report/__init__.py b/erpnext/manufacturing/report/process_loss_report/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/stock/report/process_loss_report/process_loss_report.js b/erpnext/manufacturing/report/process_loss_report/process_loss_report.js similarity index 100% rename from erpnext/stock/report/process_loss_report/process_loss_report.js rename to erpnext/manufacturing/report/process_loss_report/process_loss_report.js diff --git a/erpnext/stock/report/process_loss_report/process_loss_report.json b/erpnext/manufacturing/report/process_loss_report/process_loss_report.json similarity index 83% rename from erpnext/stock/report/process_loss_report/process_loss_report.json rename to erpnext/manufacturing/report/process_loss_report/process_loss_report.json index afe4aff7f1c1..7d3d13d98cfd 100644 --- a/erpnext/stock/report/process_loss_report/process_loss_report.json +++ b/erpnext/manufacturing/report/process_loss_report/process_loss_report.json @@ -9,9 +9,9 @@ "filters": [], "idx": 0, "is_standard": "Yes", - "modified": "2021-08-24 16:38:15.233395", + "modified": "2021-10-20 22:03:57.606612", "modified_by": "Administrator", - "module": "Stock", + "module": "Manufacturing", "name": "Process Loss Report", "owner": "Administrator", "prepared_report": 0, @@ -21,9 +21,6 @@ "roles": [ { "role": "Manufacturing User" - }, - { - "role": "Stock User" } ] } \ No newline at end of file diff --git a/erpnext/stock/report/process_loss_report/process_loss_report.py b/erpnext/manufacturing/report/process_loss_report/process_loss_report.py similarity index 98% rename from erpnext/stock/report/process_loss_report/process_loss_report.py rename to erpnext/manufacturing/report/process_loss_report/process_loss_report.py index 5c6a3bb45668..d3dfd52b7739 100644 --- a/erpnext/stock/report/process_loss_report/process_loss_report.py +++ b/erpnext/manufacturing/report/process_loss_report/process_loss_report.py @@ -111,7 +111,7 @@ def run_query(query_args: QueryArgs) -> Data: {work_order_filter} GROUP BY se.work_order - """.format(**query_args), query_args, as_dict=1, debug=1) + """.format(**query_args), query_args, as_dict=1) def update_data_with_total_pl_value(data: Data) -> None: for row in data: diff --git a/erpnext/manufacturing/report/test_reports.py b/erpnext/manufacturing/report/test_reports.py new file mode 100644 index 000000000000..1de472659eb7 --- /dev/null +++ b/erpnext/manufacturing/report/test_reports.py @@ -0,0 +1,64 @@ +import unittest +from typing import List, Tuple + +import frappe + +from erpnext.tests.utils import ReportFilters, ReportName, execute_script_report + +DEFAULT_FILTERS = { + "company": "_Test Company", + "from_date": "2010-01-01", + "to_date": "2030-01-01", + "warehouse": "_Test Warehouse - _TC", +} + + +REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [ + ("BOM Explorer", {"bom": frappe.get_last_doc("BOM").name}), + ("BOM Operations Time", {}), + ("BOM Stock Calculated", {"bom": frappe.get_last_doc("BOM").name, "qty_to_make": 2}), + ("BOM Stock Report", {"bom": frappe.get_last_doc("BOM").name, "qty_to_produce": 2}), + ("Cost of Poor Quality Report", {}), + ("Downtime Analysis", {}), + ( + "Exponential Smoothing Forecasting", + { + "based_on_document": "Sales Order", + "based_on_field": "Qty", + "no_of_years": 3, + "periodicity": "Yearly", + "smoothing_constant": 0.3, + }, + ), + ("Job Card Summary", {"fiscal_year": "2021-2022"}), + ("Production Analytics", {"range": "Monthly"}), + ("Quality Inspection Summary", {}), + ("Process Loss Report", {}), + ("Work Order Stock Report", {}), + ("Work Order Summary", {"fiscal_year": "2021-2022", "age": 0}), +] + + +if frappe.db.a_row_exists("Production Plan"): + REPORT_FILTER_TEST_CASES.append( + ("Production Plan Summary", {"production_plan": frappe.get_last_doc("Production Plan").name}) + ) + +OPTIONAL_FILTERS = { + "warehouse": "_Test Warehouse - _TC", + "item": "_Test Item", + "item_group": "_Test Item Group", +} + + +class TestManufacturingReports(unittest.TestCase): + def test_execute_all_manufacturing_reports(self): + """Test that all script report in manufacturing modules are executable with supported filters""" + for report, filter in REPORT_FILTER_TEST_CASES: + execute_script_report( + report_name=report, + module="Manufacturing", + filters=filter, + default_filters=DEFAULT_FILTERS, + optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None, + ) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9606c257b392..bd4f628af2a5 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -262,7 +262,7 @@ erpnext.patches.v13_0.update_payment_terms_outstanding erpnext.patches.v12_0.add_state_code_for_ladakh erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl erpnext.patches.v13_0.delete_old_bank_reconciliation_doctypes -erpnext.patches.v12_0.update_vehicle_no_reqd_condition +erpnext.patches.v13_0.update_vehicle_no_reqd_condition erpnext.patches.v12_0.add_einvoice_status_field #2021-03-17 erpnext.patches.v12_0.add_einvoice_summary_report_permissions erpnext.patches.v13_0.setup_fields_for_80g_certificate_and_donation @@ -307,18 +307,29 @@ erpnext.patches.v13_0.add_custom_field_for_south_africa #2 erpnext.patches.v13_0.rename_discharge_ordered_date_in_ip_record erpnext.patches.v13_0.migrate_stripe_api erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries -erpnext.patches.v13_0.custom_fields_for_taxjar_integration +execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings") +execute:frappe.reload_doc("erpnext_integrations", "doctype", "Product Tax Category") +erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021 erpnext.patches.v13_0.set_operation_time_based_on_operating_cost erpnext.patches.v13_0.validate_options_for_data_field erpnext.patches.v13_0.create_website_items #30-09-2021 erpnext.patches.v13_0.populate_e_commerce_settings erpnext.patches.v13_0.make_homepage_products_website_items erpnext.patches.v13_0.update_dates_in_tax_withholding_category +erpnext.patches.v13_0.fix_invoice_statuses erpnext.patches.v13_0.replace_supplier_item_group_with_party_specific_item erpnext.patches.v13_0.gst_fields_for_pos_invoice erpnext.patches.v13_0.create_accounting_dimensions_in_pos_doctypes erpnext.patches.v13_0.create_custom_field_for_finance_book -erpnext.patches.v13_0.modify_invalid_gain_loss_gl_entries +erpnext.patches.v13_0.modify_invalid_gain_loss_gl_entries #2 erpnext.patches.v13_0.fix_additional_cost_in_mfg_stock_entry erpnext.patches.v13_0.shopping_cart_to_ecommerce erpnext.patches.v13_0.set_status_in_maintenance_schedule_table +erpnext.patches.v13_0.add_default_interview_notification_templates +erpnext.patches.v13_0.trim_sales_invoice_custom_field_length +erpnext.patches.v13_0.enable_scheduler_job_for_item_reposting +erpnext.patches.v13_0.requeue_failed_reposts +erpnext.patches.v13_0.fetch_thumbnail_in_website_items +erpnext.patches.v12_0.update_production_plan_status +erpnext.patches.v13_0.update_category_in_ltds_certificate +erpnext.patches.v13_0.create_ksa_vat_custom_fields diff --git a/erpnext/patches/v12_0/update_production_plan_status.py b/erpnext/patches/v12_0/update_production_plan_status.py new file mode 100644 index 000000000000..06fc503a33f6 --- /dev/null +++ b/erpnext/patches/v12_0/update_production_plan_status.py @@ -0,0 +1,31 @@ +# Copyright (c) 2021, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe + + +def execute(): + frappe.reload_doc("manufacturing", "doctype", "production_plan") + frappe.db.sql(""" + UPDATE `tabProduction Plan` ppl + SET status = "Completed" + WHERE ppl.name IN ( + SELECT ss.name FROM ( + SELECT + ( + count(wo.status = "Completed") = + count(pp.name) + ) = + ( + pp.status != "Completed" + AND pp.total_produced_qty >= pp.total_planned_qty + ) AS should_set, + pp.name AS name + FROM + `tabWork Order` wo INNER JOIN`tabProduction Plan` pp + ON wo.production_plan = pp.name + GROUP BY pp.name + HAVING should_set = 1 + ) ss + ) + """) diff --git a/erpnext/patches/v13_0/add_default_interview_notification_templates.py b/erpnext/patches/v13_0/add_default_interview_notification_templates.py new file mode 100644 index 000000000000..5e8a27fa40ad --- /dev/null +++ b/erpnext/patches/v13_0/add_default_interview_notification_templates.py @@ -0,0 +1,37 @@ +from __future__ import unicode_literals + +import os + +import frappe +from frappe import _ + + +def execute(): + if not frappe.db.exists('Email Template', _('Interview Reminder')): + base_path = frappe.get_app_path('erpnext', 'hr', 'doctype') + response = frappe.read_file(os.path.join(base_path, 'interview/interview_reminder_notification_template.html')) + + frappe.get_doc({ + 'doctype': 'Email Template', + 'name': _('Interview Reminder'), + 'response': response, + 'subject': _('Interview Reminder'), + 'owner': frappe.session.user, + }).insert(ignore_permissions=True) + + if not frappe.db.exists('Email Template', _('Interview Feedback Reminder')): + base_path = frappe.get_app_path('erpnext', 'hr', 'doctype') + response = frappe.read_file(os.path.join(base_path, 'interview/interview_feedback_reminder_template.html')) + + frappe.get_doc({ + 'doctype': 'Email Template', + 'name': _('Interview Feedback Reminder'), + 'response': response, + 'subject': _('Interview Feedback Reminder'), + 'owner': frappe.session.user, + }).insert(ignore_permissions=True) + + hr_settings = frappe.get_doc('HR Settings') + hr_settings.interview_reminder_template = _('Interview Reminder') + hr_settings.feedback_reminder_notification_template = _('Interview Feedback Reminder') + hr_settings.save() diff --git a/erpnext/patches/v13_0/create_ksa_vat_custom_fields.py b/erpnext/patches/v13_0/create_ksa_vat_custom_fields.py new file mode 100644 index 000000000000..f33b4b3ea0dd --- /dev/null +++ b/erpnext/patches/v13_0/create_ksa_vat_custom_fields.py @@ -0,0 +1,12 @@ +import frappe + +from erpnext.regional.saudi_arabia.setup import make_custom_fields + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'Saudi Arabia'}) + if not company: + return + + make_custom_fields() + diff --git a/erpnext/patches/v13_0/create_website_items.py b/erpnext/patches/v13_0/create_website_items.py index 5c245ca29fca..3baa34b71c03 100644 --- a/erpnext/patches/v13_0/create_website_items.py +++ b/erpnext/patches/v13_0/create_website_items.py @@ -14,8 +14,9 @@ def execute(): item_fields = ["item_code", "item_name", "item_group", "stock_uom", "brand", "image", "has_variants", "variant_of", "description", "weightage"] web_fields_to_map = ["route", "slideshow", "website_image_alt", - "website_warehouse", "web_long_description", "website_content"] + "website_warehouse", "web_long_description", "website_content", "thumbnail"] + # get all valid columns (fields) from Item master DB schema item_table_fields = frappe.db.sql("desc `tabItem`", as_dict=1) item_table_fields = [d.get('Field') for d in item_table_fields] @@ -42,37 +43,30 @@ def execute(): fields=item_fields, or_filters=or_filters ) + total_count = len(items) - count = 0 - for item in items: + for count, item in enumerate(items, start=1): if frappe.db.exists("Website Item", {"item_code": item.item_code}): - # if website item already exists check for empty thumbnail - web_item_doc = frappe.get_doc("Website Item", {"item_code": item.item_code}) - if web_item_doc.website_image and not web_item_doc.thumbnail: - web_item_doc.make_thumbnail() - web_item_doc.save() - else: - # else make new website item from item (publish item) - website_item = make_website_item(item, save=False) - website_item.ranking = item.get("weightage") - for field in web_fields_to_map: - website_item.update({field: item.get(field)}) - website_item.save() - - # move Website Item Group & Website Specification table to Website Item - for doctype in ("Website Item Group", "Item Website Specification"): - web_item, item_code = website_item.name, item.item_code - frappe.db.sql(f""" - Update - `tab{doctype}` - set - parenttype = 'Website Item', - parent = '{web_item}' - where - parenttype = 'Item' - and parent = '{item_code}' - """) - - count += 1 + continue + + # make new website item from item (publish item) + website_item = make_website_item(item, save=False) + website_item.ranking = item.get("weightage") + + for field in web_fields_to_map: + website_item.update({field: item.get(field)}) + + website_item.save() + + # move Website Item Group & Website Specification table to Website Item + for doctype in ("Website Item Group", "Item Website Specification"): + frappe.db.set_value( + doctype, + {"parenttype": "Item", "parent": item.item_code}, # filters + {"parenttype": "Website Item", "parent": website_item.name} # value dict + ) + if count % 20 == 0: # commit after every 20 items - frappe.db.commit() \ No newline at end of file + frappe.db.commit() + + frappe.utils.update_progress_bar('Creating Website Items', count, total_count) \ No newline at end of file diff --git a/erpnext/patches/v13_0/custom_fields_for_taxjar_integration.py b/erpnext/patches/v13_0/custom_fields_for_taxjar_integration.py index 43a9aeb6fe64..f8b145c6d023 100644 --- a/erpnext/patches/v13_0/custom_fields_for_taxjar_integration.py +++ b/erpnext/patches/v13_0/custom_fields_for_taxjar_integration.py @@ -3,7 +3,7 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields -from erpnext.regional.united_states.setup import add_permissions +from erpnext.erpnext_integrations.doctype.taxjar_settings.taxjar_settings import add_permissions def execute(): @@ -11,22 +11,31 @@ def execute(): if not company: return - frappe.reload_doc("regional", "doctype", "product_tax_category") + TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions") + TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax") + TAXJAR_SANDBOX_MODE = frappe.db.get_single_value("TaxJar Settings", "is_sandbox") + + if (not TAXJAR_CREATE_TRANSACTIONS and not TAXJAR_CALCULATE_TAX and not TAXJAR_SANDBOX_MODE): + return custom_fields = { 'Sales Invoice Item': [ dict(fieldname='product_tax_category', fieldtype='Link', insert_after='description', options='Product Tax Category', label='Product Tax Category', fetch_from='item_code.product_tax_category'), dict(fieldname='tax_collectable', fieldtype='Currency', insert_after='net_amount', - label='Tax Collectable', read_only=1), + label='Tax Collectable', read_only=1, options='currency'), dict(fieldname='taxable_amount', fieldtype='Currency', insert_after='tax_collectable', - label='Taxable Amount', read_only=1) + label='Taxable Amount', read_only=1, options='currency') ], 'Item': [ dict(fieldname='product_tax_category', fieldtype='Link', insert_after='item_group', options='Product Tax Category', label='Product Tax Category') + ], + 'TaxJar Settings': [ + dict(fieldname='company', fieldtype='Link', insert_after='configuration', options='Company', + label='Company') ] } create_custom_fields(custom_fields, update=True) add_permissions() - frappe.enqueue('erpnext.regional.united_states.setup.add_product_tax_categories', now=True) \ No newline at end of file + frappe.enqueue('erpnext.erpnext_integrations.doctype.taxjar_settings.taxjar_settings.add_product_tax_categories', now=True) diff --git a/erpnext/patches/v13_0/enable_scheduler_job_for_item_reposting.py b/erpnext/patches/v13_0/enable_scheduler_job_for_item_reposting.py new file mode 100644 index 000000000000..7a51b4321175 --- /dev/null +++ b/erpnext/patches/v13_0/enable_scheduler_job_for_item_reposting.py @@ -0,0 +1,8 @@ +import frappe + + +def execute(): + frappe.reload_doc('core', 'doctype', 'scheduled_job_type') + if frappe.db.exists('Scheduled Job Type', 'repost_item_valuation.repost_entries'): + frappe.db.set_value('Scheduled Job Type', + 'repost_item_valuation.repost_entries', 'stopped', 0) diff --git a/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py b/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py new file mode 100644 index 000000000000..32ad542cf887 --- /dev/null +++ b/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py @@ -0,0 +1,16 @@ +import frappe + + +def execute(): + if frappe.db.has_column("Item", "thumbnail"): + website_item = frappe.qb.DocType("Website Item").as_("wi") + item = frappe.qb.DocType("Item") + + frappe.qb.update(website_item).inner_join(item).on( + website_item.item_code == item.item_code + ).set( + website_item.thumbnail, item.thumbnail + ).where( + website_item.website_image.notnull() + & website_item.thumbnail.isnull() + ).run() diff --git a/erpnext/patches/v13_0/fix_invoice_statuses.py b/erpnext/patches/v13_0/fix_invoice_statuses.py new file mode 100644 index 000000000000..4395757159f2 --- /dev/null +++ b/erpnext/patches/v13_0/fix_invoice_statuses.py @@ -0,0 +1,113 @@ +import frappe +from frappe.utils import flt, getdate + +from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( + get_total_in_party_account_currency, + is_overdue, +) + +TODAY = getdate() + +def execute(): + # This fix is not related to Party Specific Item, + # but it is needed for code introduced after Party Specific Item was + # If your DB doesn't have this doctype yet, you should be fine + if not frappe.db.exists("DocType", "Party Specific Item"): + return + + for doctype in ("Purchase Invoice", "Sales Invoice"): + fields = [ + "name", + "status", + "due_date", + "outstanding_amount", + "grand_total", + "base_grand_total", + "rounded_total", + "base_rounded_total", + "disable_rounded_total", + ] + if doctype == "Sales Invoice": + fields.append("is_pos") + + invoices_to_update = frappe.get_all( + doctype, + fields=fields, + filters={ + "docstatus": 1, + "status": ("in", ( + "Overdue", + "Overdue and Discounted", + "Partly Paid", + "Partly Paid and Discounted" + )), + "outstanding_amount": (">", 0), + "modified": (">", "2021-01-01") + # an assumption is being made that only invoices modified + # after 2021 got affected as incorrectly overdue. + # required for performance reasons. + } + ) + + invoices_to_update = { + invoice.name: invoice for invoice in invoices_to_update + } + + payment_schedule_items = frappe.get_all( + "Payment Schedule", + fields=( + "due_date", + "payment_amount", + "base_payment_amount", + "parent" + ), + filters={"parent": ("in", invoices_to_update)} + ) + + for item in payment_schedule_items: + invoices_to_update[item.parent].setdefault( + "payment_schedule", [] + ).append(item) + + status_map = {} + + for invoice in invoices_to_update.values(): + invoice.doctype = doctype + doc = frappe.get_doc(invoice) + correct_status = get_correct_status(doc) + if not correct_status or doc.status == correct_status: + continue + + status_map.setdefault(correct_status, []).append(doc.name) + + for status, docs in status_map.items(): + frappe.db.set_value( + doctype, {"name": ("in", docs)}, + "status", + status, + update_modified=False + ) + + + +def get_correct_status(doc): + outstanding_amount = flt( + doc.outstanding_amount, doc.precision("outstanding_amount") + ) + total = get_total_in_party_account_currency(doc) + + status = "" + if is_overdue(doc, total): + status = "Overdue" + elif 0 < outstanding_amount < total: + status = "Partly Paid" + elif outstanding_amount > 0 and getdate(doc.due_date) >= TODAY: + status = "Unpaid" + + if not status: + return + + if doc.status.endswith(" and Discounted"): + status += " and Discounted" + + return status diff --git a/erpnext/patches/v13_0/modify_invalid_gain_loss_gl_entries.py b/erpnext/patches/v13_0/modify_invalid_gain_loss_gl_entries.py index fa8a86437d06..3af7dac34226 100644 --- a/erpnext/patches/v13_0/modify_invalid_gain_loss_gl_entries.py +++ b/erpnext/patches/v13_0/modify_invalid_gain_loss_gl_entries.py @@ -17,7 +17,7 @@ def execute(): where ref_exchange_rate = 1 and docstatus = 1 - and ifnull(exchange_gain_loss, '') != '' + and ifnull(exchange_gain_loss, 0) != 0 group by parent """, as_dict=1) @@ -30,7 +30,7 @@ def execute(): where ref_exchange_rate = 1 and docstatus = 1 - and ifnull(exchange_gain_loss, '') != '' + and ifnull(exchange_gain_loss, 0) != 0 group by parent """, as_dict=1) @@ -38,12 +38,24 @@ def execute(): if purchase_invoices + sales_invoices: frappe.log_error(json.dumps(purchase_invoices + sales_invoices, indent=2), title="Patch Log") + acc_frozen_upto = frappe.db.get_value('Accounts Settings', None, 'acc_frozen_upto') + if acc_frozen_upto: + frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None) + for invoice in purchase_invoices + sales_invoices: - doc = frappe.get_doc(invoice.type, invoice.name) - doc.docstatus = 2 - doc.make_gl_entries() - for advance in doc.advances: - if advance.ref_exchange_rate == 1: - advance.db_set('exchange_gain_loss', 0, False) - doc.docstatus = 1 - doc.make_gl_entries() \ No newline at end of file + try: + doc = frappe.get_doc(invoice.type, invoice.name) + doc.docstatus = 2 + doc.make_gl_entries() + for advance in doc.advances: + if advance.ref_exchange_rate == 1: + advance.db_set('exchange_gain_loss', 0, False) + doc.docstatus = 1 + doc.make_gl_entries() + frappe.db.commit() + except Exception: + frappe.db.rollback() + print(f'Failed to correct gl entries of {invoice.name}') + + if acc_frozen_upto: + frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', acc_frozen_upto) \ No newline at end of file diff --git a/erpnext/patches/v13_0/requeue_failed_reposts.py b/erpnext/patches/v13_0/requeue_failed_reposts.py new file mode 100644 index 000000000000..213cb9e26e49 --- /dev/null +++ b/erpnext/patches/v13_0/requeue_failed_reposts.py @@ -0,0 +1,13 @@ +import frappe +from frappe.utils import cstr + + +def execute(): + + reposts = frappe.get_all("Repost Item Valuation", + {"status": "Failed", "modified": [">", "2021-10-05"] }, + ["name", "modified", "error_log"]) + + for repost in reposts: + if "check_freezing_date" in cstr(repost.error_log): + frappe.db.set_value("Repost Item Valuation", repost.name, "status", "Queued") diff --git a/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py b/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py index d336c929751e..35710a9bb4a2 100644 --- a/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py +++ b/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py @@ -26,4 +26,4 @@ def notify_users(): note.public = 1 note.notify_on_login = 1 note.content = """

You are seeing this message because Shopping Cart is enabled on your site.


Shopping Cart Settings and Products settings are now merged into "E Commerce Settings".


You can learn about new and improved E-Commerce features in the official documentation.

  1. https://docs.erpnext.com/docs/v13/user/manual/en/e_commerce/set_up_e_commerce


""" - note.save() + note.insert(ignore_mandatory=True) diff --git a/erpnext/patches/v13_0/trim_sales_invoice_custom_field_length.py b/erpnext/patches/v13_0/trim_sales_invoice_custom_field_length.py new file mode 100644 index 000000000000..fd48c0d902d6 --- /dev/null +++ b/erpnext/patches/v13_0/trim_sales_invoice_custom_field_length.py @@ -0,0 +1,18 @@ +# Copyright (c) 2020, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe + +from erpnext.regional.india.setup import create_custom_fields, get_custom_fields + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + custom_fields = { + 'Sales Invoice': get_custom_fields().get('Sales Invoice') + } + + create_custom_fields(custom_fields, update=True) diff --git a/erpnext/patches/v13_0/update_category_in_ltds_certificate.py b/erpnext/patches/v13_0/update_category_in_ltds_certificate.py new file mode 100644 index 000000000000..a5f5a23449ad --- /dev/null +++ b/erpnext/patches/v13_0/update_category_in_ltds_certificate.py @@ -0,0 +1,20 @@ +import frappe + + +def execute(): + company = frappe.get_all('Company', filters = {'country': 'India'}) + if not company: + return + + frappe.reload_doc('regional', 'doctype', 'lower_deduction_certificate') + + ldc = frappe.qb.DocType("Lower Deduction Certificate").as_("ldc") + supplier = frappe.qb.DocType("Supplier") + + frappe.qb.update(ldc).inner_join(supplier).on( + ldc.supplier == supplier.name + ).set( + ldc.tax_withholding_category, supplier.tax_withholding_category + ).where( + ldc.tax_withholding_category.isnull() + ).run() \ No newline at end of file diff --git a/erpnext/patches/v12_0/update_vehicle_no_reqd_condition.py b/erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py similarity index 81% rename from erpnext/patches/v12_0/update_vehicle_no_reqd_condition.py rename to erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py index 69bfaaa2cb16..902707b4b663 100644 --- a/erpnext/patches/v12_0/update_vehicle_no_reqd_condition.py +++ b/erpnext/patches/v13_0/update_vehicle_no_reqd_condition.py @@ -2,7 +2,7 @@ def execute(): - frappe.reload_doc('custom', 'doctype', 'custom_field') + frappe.reload_doc('custom', 'doctype', 'custom_field', force=True) company = frappe.get_all('Company', filters = {'country': 'India'}) if not company: return diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.py b/erpnext/payroll/doctype/additional_salary/additional_salary.py index 7c0a8eac99c4..b6377f400660 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.py +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.py @@ -125,27 +125,28 @@ def get_amount(self, sal_start_date, sal_end_date): no_of_days = date_diff(getdate(end_date), getdate(start_date)) + 1 return amount_per_day * no_of_days +@frappe.whitelist() def get_additional_salaries(employee, start_date, end_date, component_type): - additional_salary_list = frappe.db.sql(""" - select name, salary_component as component, type, amount, - overwrite_salary_structure_amount as overwrite, - deduct_full_tax_on_selected_payroll_date - from `tabAdditional Salary` - where employee=%(employee)s - and docstatus = 1 - and ( - payroll_date between %(from_date)s and %(to_date)s - or - from_date <= %(to_date)s and to_date >= %(to_date)s - ) - and type = %(component_type)s - order by salary_component, overwrite ASC - """, { - 'employee': employee, - 'from_date': start_date, - 'to_date': end_date, - 'component_type': "Earning" if component_type == "earnings" else "Deduction" - }, as_dict=1) + comp_type = 'Earning' if component_type == 'earnings' else 'Deduction' + + additional_sal = frappe.qb.DocType('Additional Salary') + component_field = additional_sal.salary_component.as_('component') + overwrite_field = additional_sal.overwrite_salary_structure_amount.as_('overwrite') + + additional_salary_list = frappe.qb.from_( + additional_sal + ).select( + additional_sal.name, component_field, additional_sal.type, + additional_sal.amount, additional_sal.is_recurring, overwrite_field, + additional_sal.deduct_full_tax_on_selected_payroll_date + ).where( + (additional_sal.employee == employee) + & (additional_sal.docstatus == 1) + & (additional_sal.type == comp_type) + ).where( + additional_sal.payroll_date[start_date: end_date] + | ((additional_sal.from_date <= end_date) & (additional_sal.to_date >= end_date)) + ).run(as_dict=True) additional_salaries = [] components_to_overwrite = [] diff --git a/erpnext/payroll/doctype/salary_detail/salary_detail.json b/erpnext/payroll/doctype/salary_detail/salary_detail.json index 393f647cc88c..665f0a8297ee 100644 --- a/erpnext/payroll/doctype/salary_detail/salary_detail.json +++ b/erpnext/payroll/doctype/salary_detail/salary_detail.json @@ -12,6 +12,7 @@ "year_to_date", "section_break_5", "additional_salary", + "is_recurring_additional_salary", "statistical_component", "depends_on_payment_days", "exempted_from_income_tax", @@ -235,11 +236,19 @@ "label": "Year To Date", "options": "currency", "read_only": 1 - } + }, + { + "default": "0", + "depends_on": "eval:doc.parenttype=='Salary Slip' && doc.additional_salary", + "fieldname": "is_recurring_additional_salary", + "fieldtype": "Check", + "label": "Is Recurring Additional Salary", + "read_only": 1 + } ], "istable": 1, "links": [], - "modified": "2021-01-14 13:39:15.847158", + "modified": "2021-08-30 13:39:15.847158", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Detail", diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.json b/erpnext/payroll/doctype/salary_slip/salary_slip.json index fbbf86c4a98a..ad00d6d323f1 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.json +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.json @@ -329,7 +329,7 @@ { "fieldname": "earning_deduction", "fieldtype": "Section Break", - "label": "Earning & Deduction", + "label": "Earnings & Deductions", "oldfieldtype": "Section Break" }, { @@ -380,7 +380,7 @@ "depends_on": "total_loan_repayment", "fieldname": "loan_repayment", "fieldtype": "Section Break", - "label": "Loan repayment" + "label": "Loan Repayment" }, { "fieldname": "loans", @@ -425,7 +425,7 @@ { "fieldname": "net_pay_info", "fieldtype": "Section Break", - "label": "net pay info" + "label": "Net Pay Info" }, { "fieldname": "net_pay", @@ -647,7 +647,7 @@ "idx": 9, "is_submittable": 1, "links": [], - "modified": "2021-09-01 10:22:52.374549", + "modified": "2021-10-08 11:48:47.098248", "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 888150f0ae3b..28e6a3ccbba1 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -172,7 +172,6 @@ def check_existing(self): and employee = %s and name != %s {0}""".format(cond), (self.start_date, self.end_date, self.employee, self.name)) if ret_exist: - self.employee = '' frappe.throw(_("Salary Slip of employee {0} already created for this period").format(self.employee)) else: for data in self.timesheets: @@ -630,7 +629,8 @@ def add_additional_salary_components(self, component_type): get_salary_component_data(additional_salary.component), additional_salary.amount, component_type, - additional_salary + additional_salary, + is_recurring = additional_salary.is_recurring ) def add_tax_components(self, payroll_period): @@ -651,7 +651,7 @@ def add_tax_components(self, payroll_period): tax_row = get_salary_component_data(d) self.update_component_row(tax_row, tax_amount, "deductions") - def update_component_row(self, component_data, amount, component_type, additional_salary=None): + def update_component_row(self, component_data, amount, component_type, additional_salary=None, is_recurring = 0): component_row = None for d in self.get(component_type): if d.salary_component != component_data.salary_component: @@ -702,6 +702,7 @@ def update_component_row(self, component_data, amount, component_type, additiona component_row.default_amount = 0 component_row.additional_amount = amount + component_row.is_recurring_additional_salary = is_recurring component_row.additional_salary = additional_salary.name component_row.deduct_full_tax_on_selected_payroll_date = \ additional_salary.deduct_full_tax_on_selected_payroll_date @@ -898,25 +899,33 @@ def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days= amount, additional_amount = earning.default_amount, earning.additional_amount if earning.is_tax_applicable: - if additional_amount: + if earning.is_flexible_benefit: + flexi_benefits += amount + else: taxable_earnings += (amount - additional_amount) additional_income += additional_amount + + # Get additional amount based on future recurring additional salary + if additional_amount and earning.is_recurring_additional_salary: + additional_income += self.get_future_recurring_additional_amount(earning.additional_salary, + earning.additional_amount) # Used earning.additional_amount to consider the amount for the full month + if earning.deduct_full_tax_on_selected_payroll_date: additional_income_with_full_tax += additional_amount - continue - - if earning.is_flexible_benefit: - flexi_benefits += amount - else: - taxable_earnings += amount if allow_tax_exemption: for ded in self.deductions: if ded.exempted_from_income_tax: - amount = ded.amount + amount, additional_amount = ded.amount, ded.additional_amount if based_on_payment_days: - amount = self.get_amount_based_on_payment_days(ded, joining_date, relieving_date)[0] - taxable_earnings -= flt(amount) + amount, additional_amount = self.get_amount_based_on_payment_days(ded, joining_date, relieving_date) + + taxable_earnings -= flt(amount - additional_amount) + additional_income -= additional_amount + + if additional_amount and ded.is_recurring_additional_salary: + additional_income -= self.get_future_recurring_additional_amount(ded.additional_salary, + ded.additional_amount) # Used ded.additional_amount to consider the amount for the full month return frappe._dict({ "taxable_earnings": taxable_earnings, @@ -925,11 +934,21 @@ def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days= "flexi_benefits": flexi_benefits }) + def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount): + future_recurring_additional_amount = 0 + to_date = frappe.db.get_value("Additional Salary", additional_salary, 'to_date') + # future month count excluding current + future_recurring_period = (getdate(to_date).month - getdate(self.start_date).month) + if future_recurring_period > 0: + future_recurring_additional_amount = monthly_additional_amount * future_recurring_period # Used earning.additional_amount to consider the amount for the full month + return future_recurring_additional_amount + def get_amount_based_on_payment_days(self, row, joining_date, relieving_date): amount, additional_amount = row.amount, row.additional_amount if (self.salary_structure and - cint(row.depends_on_payment_days) and cint(self.total_working_days) and - (not self.salary_slip_based_on_timesheet or + cint(row.depends_on_payment_days) and cint(self.total_working_days) + and not (row.additional_salary and row.default_amount) # to identify overwritten additional salary + and (not self.salary_slip_based_on_timesheet or getdate(self.start_date) < joining_date or (relieving_date and getdate(self.end_date) > relieving_date) )): @@ -1248,7 +1267,7 @@ def compute_year_to_date(self): salary_slip_sum = frappe.get_list('Salary Slip', fields = ['sum(net_pay) as net_sum', 'sum(gross_pay) as gross_sum'], - filters = {'employee_name' : self.employee_name, + filters = {'employee' : self.employee, 'start_date' : ['>=', period_start_date], 'end_date' : ['<', period_end_date], 'name': ['!=', self.name], @@ -1268,7 +1287,7 @@ def compute_month_to_date(self): first_day_of_the_month = get_first_day(self.start_date) salary_slip_sum = frappe.get_list('Salary Slip', fields = ['sum(net_pay) as sum'], - filters = {'employee_name' : self.employee_name, + filters = {'employee' : self.employee, 'start_date' : ['>=', first_day_of_the_month], 'end_date' : ['<', self.start_date], 'name': ['!=', self.name], @@ -1292,13 +1311,13 @@ def compute_component_wise_year_to_date(self): INNER JOIN `tabSalary Slip` as salary_slip ON detail.parent = salary_slip.name WHERE - salary_slip.employee_name = %(employee_name)s + salary_slip.employee = %(employee)s AND detail.salary_component = %(component)s AND salary_slip.start_date >= %(period_start_date)s AND salary_slip.end_date < %(period_end_date)s AND salary_slip.name != %(docname)s AND salary_slip.docstatus = 1""", - {'employee_name': self.employee_name, 'component': component.salary_component, 'period_start_date': period_start_date, + {'employee': self.employee, 'component': component.salary_component, 'period_start_date': period_start_date, 'period_end_date': period_end_date, 'docname': self.name} ) diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 81582cecae3b..a44af09b7933 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -171,8 +171,6 @@ def test_component_amount_dependent_on_another_payment_days_based_component(self days_in_month = no_of_days[0] no_of_holidays = no_of_days[1] - self.assertEqual(ss.payment_days, days_in_month - no_of_holidays - 1) - ss.reload() payment_days_based_comp_amount = 0 for component in ss.earnings: @@ -542,6 +540,61 @@ def test_tax_for_payroll_period(self): # undelete fixture data frappe.db.rollback() + def test_tax_for_recurring_additional_salary(self): + frappe.db.sql("""delete from `tabPayroll Period`""") + frappe.db.sql("""delete from `tabSalary Component`""") + + payroll_period = create_payroll_period() + + create_tax_slab(payroll_period, allow_tax_exemption=True) + + employee = make_employee("test_tax@salary.slip") + delete_docs = [ + "Salary Slip", + "Additional Salary", + "Employee Tax Exemption Declaration", + "Employee Tax Exemption Proof Submission", + "Employee Benefit Claim", + "Salary Structure Assignment" + ] + for doc in delete_docs: + frappe.db.sql("delete from `tab%s` where employee='%s'" % (doc, employee)) + + from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + + salary_structure = make_salary_structure("Stucture to test tax", "Monthly", + other_details={"max_benefits": 100000}, test_tax=True, + employee=employee, payroll_period=payroll_period) + + + create_salary_slips_for_payroll_period(employee, salary_structure.name, + payroll_period, deduct_random=False, num=3) + + tax_paid = get_tax_paid_in_period(employee) + + annual_tax = 23196.0 + self.assertEqual(tax_paid, annual_tax) + + frappe.db.sql("""delete from `tabSalary Slip` where employee=%s""", (employee)) + + #------------------------------------ + # Recurring additional salary + start_date = add_months(payroll_period.start_date, 3) + end_date = add_months(payroll_period.start_date, 5) + create_recurring_additional_salary(employee, "Performance Bonus", 20000, start_date, end_date) + + frappe.db.sql("""delete from `tabSalary Slip` where employee=%s""", (employee)) + + create_salary_slips_for_payroll_period(employee, salary_structure.name, + payroll_period, deduct_random=False, num=4) + + tax_paid = get_tax_paid_in_period(employee) + + annual_tax = 32315.0 + self.assertEqual(tax_paid, annual_tax) + + frappe.db.rollback() + def make_activity_for_employee(self): activity_type = frappe.get_doc("Activity Type", "_Test Activity Type") activity_type.billing_rate = 50 @@ -1012,4 +1065,18 @@ def make_salary_slip_for_payment_days_dependency_test(employee, salary_structure else: salary_slip = frappe.get_doc("Salary Slip", salary_slip_name) - return salary_slip \ No newline at end of file + return salary_slip + +def create_recurring_additional_salary(employee, salary_component, amount, from_date, to_date, company=None): + frappe.get_doc({ + "doctype": "Additional Salary", + "employee": employee, + "company": company or erpnext.get_default_company(), + "salary_component": salary_component, + "is_recurring": 1, + "from_date": from_date, + "to_date": to_date, + "amount": amount, + "type": "Earning", + "currency": erpnext.get_default_currency() + }).submit() diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js index 1655b76b9882..f615f051f0c3 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.js +++ b/erpnext/projects/doctype/timesheet/timesheet.js @@ -32,12 +32,12 @@ frappe.ui.form.on("Timesheet", { }; }, - onload: function(frm){ + onload: function(frm) { if (frm.doc.__islocal && frm.doc.time_logs) { calculate_time_and_amount(frm); } - if (frm.is_new()) { + if (frm.is_new() && !frm.doc.employee) { set_employee_and_company(frm); } }, @@ -283,7 +283,9 @@ frappe.ui.form.on("Timesheet Detail", { calculate_time_and_amount(frm); }, - activity_type: function(frm, cdt, cdn) { + activity_type: function (frm, cdt, cdn) { + if (!frappe.get_doc(cdt, cdn).activity_type) return; + frappe.call({ method: "erpnext.projects.doctype.timesheet.timesheet.get_activity_cost", args: { @@ -291,10 +293,10 @@ frappe.ui.form.on("Timesheet Detail", { activity_type: frm.selected_doc.activity_type, currency: frm.doc.currency }, - callback: function(r){ - if(r.message){ - frappe.model.set_value(cdt, cdn, 'billing_rate', r.message['billing_rate']); - frappe.model.set_value(cdt, cdn, 'costing_rate', r.message['costing_rate']); + callback: function (r) { + if (r.message) { + frappe.model.set_value(cdt, cdn, "billing_rate", r.message["billing_rate"]); + frappe.model.set_value(cdt, cdn, "costing_rate", r.message["costing_rate"]); calculate_billing_costing_amount(frm, cdt, cdn); } } diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index a20c70a6dacf..363c3b6a3caa 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -225,7 +225,6 @@ def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to query = f""" SELECT - tsd.name as name, tsd.parent as time_sheet, tsd.from_time as from_time, @@ -235,21 +234,16 @@ def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to tsd.activity_type as activity_type, tsd.description as description, ts.currency as currency, - tsd.project_name as project_name - + tsd.project_name as project_name FROM `tabTimesheet Detail` tsd - INNER JOIN `tabTimesheet` ts ON ts.name = tsd.parent - WHERE - tsd.parenttype = 'Timesheet' AND tsd.docstatus = 1 AND tsd.is_billable = 1 AND tsd.sales_invoice is NULL {condition} - ORDER BY tsd.from_time ASC """ diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 90cb5559394a..019f3fbec121 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -137,7 +137,9 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ var me = this; $.each(this.frm.doc["taxes"] || [], function(i, tax) { - tax.item_wise_tax_detail = {}; + if (!tax.dont_recompute_tax) { + tax.item_wise_tax_detail = {}; + } var 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"]; @@ -419,7 +421,9 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ current_tax_amount = tax_rate * item.qty; } - this.set_item_wise_tax(item, tax, tax_rate, current_tax_amount); + if (!tax.dont_recompute_tax) { + this.set_item_wise_tax(item, tax, tax_rate, current_tax_amount); + } return current_tax_amount; }, @@ -587,7 +591,9 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ delete tax[fieldname]; }); - tax.item_wise_tax_detail = JSON.stringify(tax.item_wise_tax_detail); + if (!tax.dont_recompute_tax) { + tax.item_wise_tax_detail = JSON.stringify(tax.item_wise_tax_detail); + } }); } }, diff --git a/erpnext/public/js/financial_statements.js b/erpnext/public/js/financial_statements.js index 0d79b10c041b..1a309ba01568 100644 --- a/erpnext/public/js/financial_statements.js +++ b/erpnext/public/js/financial_statements.js @@ -113,15 +113,15 @@ function get_filters() { "fieldname":"period_start_date", "label": __("Start Date"), "fieldtype": "Date", - "hidden": 1, - "reqd": 1 + "reqd": 1, + "depends_on": "eval:doc.filter_based_on == 'Date Range'" }, { "fieldname":"period_end_date", "label": __("End Date"), "fieldtype": "Date", - "hidden": 1, - "reqd": 1 + "reqd": 1, + "depends_on": "eval:doc.filter_based_on == 'Date Range'" }, { "fieldname":"from_fiscal_year", @@ -129,7 +129,8 @@ function get_filters() { "fieldtype": "Link", "options": "Fiscal Year", "default": frappe.defaults.get_user_default("fiscal_year"), - "reqd": 1 + "reqd": 1, + "depends_on": "eval:doc.filter_based_on == 'Fiscal Year'" }, { "fieldname":"to_fiscal_year", @@ -137,7 +138,8 @@ function get_filters() { "fieldtype": "Link", "options": "Fiscal Year", "default": frappe.defaults.get_user_default("fiscal_year"), - "reqd": 1 + "reqd": 1, + "depends_on": "eval:doc.filter_based_on == 'Fiscal Year'" }, { "fieldname": "periodicity", diff --git a/erpnext/public/js/help_links.js b/erpnext/public/js/help_links.js index d0c935f48871..b643ccae9475 100644 --- a/erpnext/public/js/help_links.js +++ b/erpnext/public/js/help_links.js @@ -5,7 +5,7 @@ const docsUrl = "https://erpnext.com/docs/"; frappe.help.help_links["Form/Rename Tool"] = [ { label: "Bulk Rename", - url: docsUrl + "user/manual/en/setting-up/data/bulk-rename", + url: docsUrl + "user/manual/en/using-erpnext/articles/bulk-rename", }, ]; @@ -59,10 +59,23 @@ frappe.help.help_links["Form/System Settings"] = [ }, ]; -frappe.help.help_links["data-import-tool"] = [ +frappe.help.help_links["Form/Data Import"] = [ { label: "Importing and Exporting Data", - url: docsUrl + "user/manual/en/setting-up/data/data-import-tool", + url: docsUrl + "user/manual/en/setting-up/data/data-import", + }, + { + label: "Overwriting Data from Data Import Tool", + url: + docsUrl + + "user/manual/en/setting-up/articles/overwriting-data-from-data-import-tool", + }, +]; + +frappe.help.help_links["List/Data Import"] = [ + { + label: "Importing and Exporting Data", + url: docsUrl + "user/manual/en/setting-up/data/data-import", }, { label: "Overwriting Data from Data Import Tool", @@ -101,14 +114,14 @@ frappe.help.help_links["Form/Global Defaults"] = [ }, ]; -frappe.help.help_links["Form/Email Digest"] = [ +frappe.help.help_links["List/Print Heading"] = [ { - label: "Email Digest", - url: docsUrl + "user/manual/en/setting-up/email/email-digest", + label: "Print Heading", + url: docsUrl + "user/manual/en/setting-up/print/print-headings", }, ]; -frappe.help.help_links["List/Print Heading"] = [ +frappe.help.help_links["Form/Print Heading"] = [ { label: "Print Heading", url: docsUrl + "user/manual/en/setting-up/print/print-headings", @@ -153,18 +166,25 @@ frappe.help.help_links["List/Email Account"] = [ frappe.help.help_links["List/Notification"] = [ { label: "Notification", - url: docsUrl + "user/manual/en/setting-up/email/notifications", + url: docsUrl + "user/manual/en/setting-up/notifications", }, ]; frappe.help.help_links["Form/Notification"] = [ { label: "Notification", - url: docsUrl + "user/manual/en/setting-up/email/notifications", + url: docsUrl + "user/manual/en/setting-up/notifications", + }, +]; + +frappe.help.help_links["Form/Email Digest"] = [ + { + label: "Email Digest", + url: docsUrl + "user/manual/en/setting-up/email/email-digest", }, ]; -frappe.help.help_links["List/Email Digest"] = [ +frappe.help.help_links["Form/Email Digest"] = [ { label: "Email Digest", url: docsUrl + "user/manual/en/setting-up/email/email-digest", @@ -174,7 +194,7 @@ frappe.help.help_links["List/Email Digest"] = [ frappe.help.help_links["List/Auto Email Report"] = [ { label: "Auto Email Reports", - url: docsUrl + "user/manual/en/setting-up/email/email-reports", + url: docsUrl + "user/manual/en/setting-up/email/auto-email-reports", }, ]; @@ -188,14 +208,7 @@ frappe.help.help_links["Form/Print Settings"] = [ frappe.help.help_links["print-format-builder"] = [ { label: "Print Format Builder", - url: docsUrl + "user/manual/en/setting-up/print/print-settings", - }, -]; - -frappe.help.help_links["List/Print Heading"] = [ - { - label: "Print Heading", - url: docsUrl + "user/manual/en/setting-up/print/print-headings", + url: docsUrl + "user/manual/en/setting-up/print/print-format-builder", }, ]; @@ -300,7 +313,7 @@ frappe.help.help_links["List/Sales Order"] = [ }, { label: "Recurring Sales Order", - url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", }, { label: "Applying Discount", @@ -315,7 +328,7 @@ frappe.help.help_links["Form/Sales Order"] = [ }, { label: "Recurring Sales Order", - url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", }, { label: "Applying Discount", @@ -344,14 +357,14 @@ frappe.help.help_links["Form/Sales Order"] = [ frappe.help.help_links["Form/Product Bundle"] = [ { label: "Product Bundle", - url: docsUrl + "user/manual/en/selling/setup/product-bundle", + url: docsUrl + "user/manual/en/selling/product-bundle", }, ]; frappe.help.help_links["Form/Selling Settings"] = [ { label: "Selling Settings", - url: docsUrl + "user/manual/en/selling/setup/selling-settings", + url: docsUrl + "user/manual/en/selling/selling-settings", }, ]; @@ -397,7 +410,7 @@ frappe.help.help_links["List/Purchase Order"] = [ }, { label: "Recurring Purchase Order", - url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", }, ]; @@ -420,7 +433,7 @@ frappe.help.help_links["Form/Purchase Order"] = [ }, { label: "Recurring Purchase Order", - url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", }, { label: "Subcontracting", @@ -435,24 +448,17 @@ frappe.help.help_links["List/Purchase Taxes and Charges Template"] = [ }, ]; -frappe.help.help_links["List/POS Profile"] = [ - { - label: "POS Profile", - url: docsUrl + "user/manual/en/setting-up/pos-setting", - }, -]; - frappe.help.help_links["List/Price List"] = [ { label: "Price List", - url: docsUrl + "user/manual/en/setting-up/price-lists", + url: docsUrl + "user/manual/en/stock/price-lists", }, ]; frappe.help.help_links["List/Authorization Rule"] = [ { label: "Authorization Rule", - url: docsUrl + "user/manual/en/setting-up/authorization-rule", + url: docsUrl + "user/manual/en/customize-erpnext/authorization-rule", }, ]; @@ -468,27 +474,14 @@ frappe.help.help_links["List/Stock Reconciliation"] = [ label: "Stock Reconciliation", url: docsUrl + - "user/manual/en/setting-up/stock-reconciliation-for-non-serialized-item", + "user/manual/en/stock/stock-reconciliation", }, ]; frappe.help.help_links["Tree/Territory"] = [ { label: "Territory", - url: docsUrl + "user/manual/en/setting-up/territory", - }, -]; - -frappe.help.help_links["Form/Dropbox Backup"] = [ - { - label: "Dropbox Backup", - url: docsUrl + "user/manual/en/setting-up/third-party-backups", - }, - { - label: "Setting Up Dropbox Backup", - url: - docsUrl + - "user/manual/en/setting-up/articles/setting-up-dropbox-backups", + url: docsUrl + "user/manual/en/selling/territory", }, ]; @@ -501,12 +494,6 @@ frappe.help.help_links["List/Company"] = [ label: "Company", url: docsUrl + "user/manual/en/setting-up/company-setup", }, - { - label: "Managing Multiple Companies", - url: - docsUrl + - "user/manual/en/setting-up/articles/managing-multiple-companies", - }, { label: "Delete All Related Transactions for a Company", url: @@ -517,21 +504,6 @@ frappe.help.help_links["List/Company"] = [ //Accounts -frappe.help.help_links["modules/Accounts"] = [ - { - label: "Introduction to Accounts", - url: docsUrl + "user/manual/en/accounts/", - }, - { - label: "Chart of Accounts", - url: docsUrl + "user/manual/en/accounts/chart-of-accounts.html", - }, - { - label: "Multi Currency Accounting", - url: docsUrl + "user/manual/en/accounts/multi-currency-accounting", - }, -]; - frappe.help.help_links["Tree/Account"] = [ { label: "Chart of Accounts", @@ -552,7 +524,7 @@ frappe.help.help_links["Form/Sales Invoice"] = [ }, { label: "Accounts Opening Balance", - url: docsUrl + "user/manual/en/accounts/opening-accounts", + url: docsUrl + "user/manual/en/accounts/opening-balance", }, { label: "Sales Return", @@ -560,7 +532,7 @@ frappe.help.help_links["Form/Sales Invoice"] = [ }, { label: "Recurring Sales Invoice", - url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", }, ]; @@ -571,7 +543,7 @@ frappe.help.help_links["List/Sales Invoice"] = [ }, { label: "Accounts Opening Balance", - url: docsUrl + "user/manual/en/accounts/opening-accounts", + url: docsUrl + "user/manual/en/accounts/opening-balances", }, { label: "Sales Return", @@ -579,21 +551,28 @@ frappe.help.help_links["List/Sales Invoice"] = [ }, { label: "Recurring Sales Invoice", - url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", }, ]; -frappe.help.help_links["pos"] = [ +frappe.help.help_links["point-of-sale"] = [ { label: "Point of Sale Invoice", - url: docsUrl + "user/manual/en/accounts/point-of-sale-pos-invoice", + url: docsUrl + "user/manual/en/accounts/point-of-sales", }, ]; frappe.help.help_links["List/POS Profile"] = [ { label: "Point of Sale Profile", - url: docsUrl + "user/manual/en/setting-up/pos-setting", + url: docsUrl + "user/manual/en/accounts/pos-profile", + }, +]; + +frappe.help.help_links["Form/POS Profile"] = [ + { + label: "POS Profile", + url: docsUrl + "user/manual/en/accounts/pos-profile", }, ]; @@ -604,11 +583,11 @@ frappe.help.help_links["List/Purchase Invoice"] = [ }, { label: "Accounts Opening Balance", - url: docsUrl + "user/manual/en/accounts/opening-accounts", + url: docsUrl + "user/manual/en/accounts/opening-balance", }, { label: "Recurring Purchase Invoice", - url: docsUrl + "user/manual/en/accounts/recurring-orders-and-invoices", + url: docsUrl + "user/manual/en/accounts/articles/recurring-orders-and-invoices", }, ]; @@ -623,7 +602,7 @@ frappe.help.help_links["List/Journal Entry"] = [ }, { label: "Accounts Opening Balance", - url: docsUrl + "user/manual/en/accounts/opening-accounts", + url: docsUrl + "user/manual/en/accounts/opening-balance", }, ]; @@ -644,7 +623,7 @@ frappe.help.help_links["List/Payment Request"] = [ frappe.help.help_links["List/Asset"] = [ { label: "Managing Fixed Assets", - url: docsUrl + "user/manual/en/accounts/opening-balance/fixed_assets", + url: docsUrl + "user/manual/en/asset", }, ]; @@ -659,6 +638,8 @@ frappe.help.help_links["Tree/Cost Center"] = [ { label: "Budgeting", url: docsUrl + "user/manual/en/accounts/budgeting" }, ]; +//Stock + frappe.help.help_links["List/Item"] = [ { label: "Item", url: docsUrl + "user/manual/en/stock/item" }, { @@ -676,7 +657,7 @@ frappe.help.help_links["List/Item"] = [ }, { label: "Managing Fixed Assets", - url: docsUrl + "user/manual/en/accounts/opening-balance/fixed_assets", + url: docsUrl + "user/manual/en/asset", }, { label: "Item Codification", @@ -711,7 +692,7 @@ frappe.help.help_links["Form/Item"] = [ }, { label: "Managing Fixed Assets", - url: docsUrl + "user/manual/en/accounts/opening-balance/fixed_assets", + url: docsUrl + "user/manual/en/asset", }, { label: "Item Codification", @@ -771,10 +752,6 @@ frappe.help.help_links["Form/Delivery Note"] = [ url: docsUrl + "user/manual/en/stock/articles/track-items-using-barcode", }, - { - label: "Subcontracting", - url: docsUrl + "user/manual/en/manufacturing/subcontracting", - }, ]; frappe.help.help_links["List/Installation Note"] = [ @@ -784,21 +761,10 @@ frappe.help.help_links["List/Installation Note"] = [ }, ]; -frappe.help.help_links["Tree"] = [ - { - label: "Managing Tree Structure Masters", - url: - docsUrl + - "user/manual/en/setting-up/articles/managing-tree-structure-masters", - }, -]; - frappe.help.help_links["List/Budget"] = [ { label: "Budgeting", url: docsUrl + "user/manual/en/accounts/budgeting" }, ]; -//Stock - frappe.help.help_links["List/Material Request"] = [ { label: "Material Request", @@ -861,6 +827,10 @@ frappe.help.help_links["Form/Serial No"] = [ { label: "Serial No", url: docsUrl + "user/manual/en/stock/serial-no" }, ]; +frappe.help.help_links["List/Batch"] = [ + { label: "Batch", url: docsUrl + "user/manual/en/stock/batch" }, +]; + frappe.help.help_links["Form/Batch"] = [ { label: "Batch", url: docsUrl + "user/manual/en/stock/batch" }, ]; @@ -868,35 +838,35 @@ frappe.help.help_links["Form/Batch"] = [ frappe.help.help_links["Form/Packing Slip"] = [ { label: "Packing Slip", - url: docsUrl + "user/manual/en/stock/tools/packing-slip", + url: docsUrl + "user/manual/en/stock/packing-slip", }, ]; frappe.help.help_links["Form/Quality Inspection"] = [ { label: "Quality Inspection", - url: docsUrl + "user/manual/en/stock/tools/quality-inspection", + url: docsUrl + "user/manual/en/stock/quality-inspection", }, ]; frappe.help.help_links["Form/Landed Cost Voucher"] = [ { label: "Landed Cost Voucher", - url: docsUrl + "user/manual/en/stock/tools/landed-cost-voucher", + url: docsUrl + "user/manual/en/stock/landed-cost-voucher", }, ]; frappe.help.help_links["Tree/Item Group"] = [ { label: "Item Group", - url: docsUrl + "user/manual/en/stock/setup/item-group", + url: docsUrl + "user/manual/en/stock/item-group", }, ]; frappe.help.help_links["Form/Item Attribute"] = [ { label: "Item Attribute", - url: docsUrl + "user/manual/en/stock/setup/item-attribute", + url: docsUrl + "user/manual/en/stock/item-attribute", }, ]; @@ -911,7 +881,7 @@ frappe.help.help_links["Form/UOM"] = [ frappe.help.help_links["Form/Stock Reconciliation"] = [ { label: "Opening Stock Entry", - url: docsUrl + "user/manual/en/stock/opening-stock", + url: docsUrl + "user/manual/en/stock/stock-reconciliation", }, ]; @@ -938,13 +908,13 @@ frappe.help.help_links["Form/Newsletter"] = [ ]; frappe.help.help_links["Form/Campaign"] = [ - { label: "Campaign", url: docsUrl + "user/manual/en/CRM/setup/campaign" }, + { label: "Campaign", url: docsUrl + "user/manual/en/CRM/campaign" }, ]; frappe.help.help_links["Tree/Sales Person"] = [ { label: "Sales Person", - url: docsUrl + "user/manual/en/CRM/setup/sales-person", + url: docsUrl + "user/manual/en/CRM/sales-person", }, ]; @@ -953,30 +923,13 @@ frappe.help.help_links["Form/Sales Person"] = [ label: "Sales Person Target", url: docsUrl + - "user/manual/en/selling/setup/sales-person-target-allocation", + "user/manual/en/selling/sales-person-target-allocation", }, -]; - -//Support - -frappe.help.help_links["List/Feedback Trigger"] = [ { - label: "Feedback Trigger", - url: docsUrl + "user/manual/en/setting-up/feedback/setting-up-feedback", - }, -]; - -frappe.help.help_links["List/Feedback Request"] = [ - { - label: "Feedback Request", - url: docsUrl + "user/manual/en/setting-up/feedback/submit-feedback", - }, -]; - -frappe.help.help_links["List/Feedback Request"] = [ - { - label: "Feedback Request", - url: docsUrl + "user/manual/en/setting-up/feedback/submit-feedback", + label: "Sales Person in Transactions", + url: + docsUrl + + "user/manual/en/selling/articles/sales-persons-in-the-sales-transactions", }, ]; @@ -1019,7 +972,7 @@ frappe.help.help_links["Form/Operation"] = [ frappe.help.help_links["Form/BOM Update Tool"] = [ { label: "BOM Update Tool", - url: docsUrl + "user/manual/en/manufacturing/tools/bom-update-tool", + url: docsUrl + "user/manual/en/manufacturing/bom-update-tool", }, ]; @@ -1036,7 +989,7 @@ frappe.help.help_links["Form/Customize Form"] = [ }, ]; -frappe.help.help_links["Form/Custom Field"] = [ +frappe.help.help_links["List/Custom Field"] = [ { label: "Custom Field", url: docsUrl + "user/manual/en/customize-erpnext/custom-field", diff --git a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js index 7b358195c3e2..831626aa9157 100644 --- a/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js +++ b/erpnext/public/js/hierarchy_chart/hierarchy_chart_desktop.js @@ -334,10 +334,12 @@ erpnext.HierarchyChart = class { if (child_nodes) { $.each(child_nodes, (_i, data) => { - this.add_node(node, data); - setTimeout(() => { - this.add_connector(node.id, data.id); - }, 250); + if (!$(`[id="${data.id}"]`).length) { + this.add_node(node, data); + setTimeout(() => { + this.add_connector(node.id, data.id); + }, 250); + } }); } } diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index e1cef614a22c..c363d931cb1e 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -712,6 +712,7 @@ erpnext.utils.map_current_doc = function(opts) { allow_child_item_selection: opts.allow_child_item_selection, child_fieldname: opts.child_fielname, child_columns: opts.child_columns, + size: opts.size, action: function(selections, args) { let values = selections; if (values.length === 0) { diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index e07bcbd28ece..429f4ca35dfb 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -459,6 +459,7 @@ body.product-page { min-height: 0px; .r-item-image { + min-height: 100px; width: 40%; .r-product-image { @@ -480,6 +481,7 @@ body.product-page { .r-item-info { font-size: 14px; padding-right: 0; + padding-left: 10px; width: 60%; a { @@ -672,18 +674,6 @@ body.product-page { img { max-height: 112px; } - - .no-image-cart-item { - max-height: 112px; - display: flex; justify-content: center; - background-color: var(--gray-200); - align-items: center; - color: var(--gray-400); - margin-top: .15rem; - border-radius: 6px; - height: 100%; - font-size: 24px; - } } .cart-items { @@ -862,6 +852,18 @@ body.product-page { } } +.no-image-cart-item { + max-height: 112px; + display: flex; justify-content: center; + background-color: var(--gray-200); + align-items: center; + color: var(--gray-400); + margin-top: .15rem; + border-radius: 6px; + height: 100%; + font-size: 24px; +} + .cart-empty.frappe-card { min-height: 76vh; @include flex(flex, center, center, column); diff --git a/erpnext/regional/__init__.py b/erpnext/regional/__init__.py index 45a689efa8ba..d7dcbf4fe184 100644 --- a/erpnext/regional/__init__.py +++ b/erpnext/regional/__init__.py @@ -31,3 +31,4 @@ def create_transaction_log(doc, method): "document_name": doc.name, "data": data }).insert(ignore_permissions=True) + diff --git a/erpnext/regional/doctype/gst_settings/gst_settings.json b/erpnext/regional/doctype/gst_settings/gst_settings.json index 95b930c4c86c..fc579d4b38cc 100644 --- a/erpnext/regional/doctype/gst_settings/gst_settings.json +++ b/erpnext/regional/doctype/gst_settings/gst_settings.json @@ -6,8 +6,10 @@ "engine": "InnoDB", "field_order": [ "gst_summary", - "column_break_2", + "gst_tax_settings_section", "round_off_gst_values", + "column_break_4", + "hsn_wise_tax_breakup", "gstin_email_sent_on", "section_break_4", "gst_accounts", @@ -17,37 +19,23 @@ { "fieldname": "gst_summary", "fieldtype": "HTML", - "label": "GST Summary", - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "column_break_2", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "label": "GST Summary" }, { "fieldname": "gstin_email_sent_on", "fieldtype": "Date", "label": "GSTIN Email Sent On", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "fieldname": "section_break_4", - "fieldtype": "Section Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Section Break" }, { "fieldname": "gst_accounts", "fieldtype": "Table", "label": "GST Accounts", - "options": "GST Account", - "show_days": 1, - "show_seconds": 1 + "options": "GST Account" }, { "default": "250000", @@ -56,24 +44,35 @@ "fieldtype": "Data", "in_list_view": 1, "label": "B2C Limit", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "default": "0", "description": "Enabling this option will round off individual GST components in all the Invoices", "fieldname": "round_off_gst_values", "fieldtype": "Check", - "label": "Round Off GST Values", - "show_days": 1, - "show_seconds": 1 + "label": "Round Off GST Values" + }, + { + "default": "0", + "fieldname": "hsn_wise_tax_breakup", + "fieldtype": "Check", + "label": "Tax Breakup Table Based On HSN Code" + }, + { + "fieldname": "gst_tax_settings_section", + "fieldtype": "Section Break", + "label": "GST Tax Settings" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-01-28 17:19:47.969260", + "modified": "2021-10-11 18:10:14.242614", "modified_by": "Administrator", "module": "Regional", "name": "GST Settings", @@ -83,4 +82,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 - } \ No newline at end of file +} \ No newline at end of file diff --git a/erpnext/regional/doctype/ksa_vat_purchase_account/__init__.py b/erpnext/regional/doctype/ksa_vat_purchase_account/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/regional/doctype/ksa_vat_purchase_account/ksa_vat_purchase_account.json b/erpnext/regional/doctype/ksa_vat_purchase_account/ksa_vat_purchase_account.json new file mode 100644 index 000000000000..89ba3e977afd --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_purchase_account/ksa_vat_purchase_account.json @@ -0,0 +1,49 @@ +{ + "actions": [], + "creation": "2021-07-13 09:17:09.862163", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "item_tax_template", + "account" + ], + "fields": [ + { + "fieldname": "account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Account", + "options": "Account", + "reqd": 1 + }, + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "item_tax_template", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Tax Template", + "options": "Item Tax Template", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-08-04 06:42:38.205597", + "modified_by": "Administrator", + "module": "Regional", + "name": "KSA VAT Purchase Account", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/regional/doctype/ksa_vat_purchase_account/ksa_vat_purchase_account.py b/erpnext/regional/doctype/ksa_vat_purchase_account/ksa_vat_purchase_account.py new file mode 100644 index 000000000000..3920bc546c1a --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_purchase_account/ksa_vat_purchase_account.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Havenir Solutions and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class KSAVATPurchaseAccount(Document): + pass diff --git a/erpnext/regional/doctype/ksa_vat_sales_account/__init__.py b/erpnext/regional/doctype/ksa_vat_sales_account/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.js b/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.js new file mode 100644 index 000000000000..72613f4064ff --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Havenir Solutions and contributors +// For license information, please see license.txt + +frappe.ui.form.on('KSA VAT Sales Account', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.json b/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.json new file mode 100644 index 000000000000..df2747891dcd --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.json @@ -0,0 +1,49 @@ +{ + "actions": [], + "creation": "2021-07-13 08:46:33.820968", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "item_tax_template", + "account" + ], + "fields": [ + { + "fieldname": "account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Account", + "options": "Account", + "reqd": 1 + }, + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "item_tax_template", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Tax Template", + "options": "Item Tax Template", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-08-04 06:42:00.081407", + "modified_by": "Administrator", + "module": "Regional", + "name": "KSA VAT Sales Account", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.py b/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.py new file mode 100644 index 000000000000..7c2689f530e8 --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_sales_account/ksa_vat_sales_account.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Havenir Solutions and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class KSAVATSalesAccount(Document): + pass diff --git a/erpnext/regional/doctype/ksa_vat_sales_account/test_ksa_vat_sales_account.py b/erpnext/regional/doctype/ksa_vat_sales_account/test_ksa_vat_sales_account.py new file mode 100644 index 000000000000..1d6a6a793dc8 --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_sales_account/test_ksa_vat_sales_account.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Havenir Solutions and Contributors +# See license.txt + +# import frappe +import unittest + + +class TestKSAVATSalesAccount(unittest.TestCase): + pass diff --git a/erpnext/regional/doctype/ksa_vat_setting/__init__.py b/erpnext/regional/doctype/ksa_vat_setting/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting.js b/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting.js new file mode 100644 index 000000000000..00b62b9adfb9 --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Havenir Solutions and contributors +// For license information, please see license.txt + +frappe.ui.form.on('KSA VAT Setting', { + onload: function () { + frappe.breadcrumbs.add('Accounts', 'KSA VAT Setting'); + } +}); diff --git a/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting.json b/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting.json new file mode 100644 index 000000000000..33619467ed0d --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting.json @@ -0,0 +1,49 @@ +{ + "actions": [], + "autoname": "field:company", + "creation": "2021-07-13 08:49:01.100356", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "ksa_vat_sales_accounts", + "ksa_vat_purchase_accounts" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "ksa_vat_sales_accounts", + "fieldtype": "Table", + "label": "KSA VAT Sales Accounts", + "options": "KSA VAT Sales Account", + "reqd": 1 + }, + { + "fieldname": "ksa_vat_purchase_accounts", + "fieldtype": "Table", + "label": "KSA VAT Purchase Accounts", + "options": "KSA VAT Purchase Account", + "reqd": 1 + } + ], + "links": [], + "modified": "2021-08-26 04:29:06.499378", + "modified_by": "Administrator", + "module": "Regional", + "name": "KSA VAT Setting", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "company", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting.py b/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting.py new file mode 100644 index 000000000000..bdae1161fd7c --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Havenir Solutions and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class KSAVATSetting(Document): + pass diff --git a/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting_list.js b/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting_list.js new file mode 100644 index 000000000000..269cbec5fb4c --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_setting/ksa_vat_setting_list.js @@ -0,0 +1,5 @@ +frappe.listview_settings['KSA VAT Setting'] = { + onload () { + frappe.breadcrumbs.add('Accounts'); + } +} \ No newline at end of file diff --git a/erpnext/regional/doctype/ksa_vat_setting/test_ksa_vat_setting.py b/erpnext/regional/doctype/ksa_vat_setting/test_ksa_vat_setting.py new file mode 100644 index 000000000000..7207901fd438 --- /dev/null +++ b/erpnext/regional/doctype/ksa_vat_setting/test_ksa_vat_setting.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Havenir Solutions and Contributors +# See license.txt + +# import frappe +import unittest + + +class TestKSAVATSetting(unittest.TestCase): + pass diff --git a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json index f48fe6f47638..c32ab6bec247 100644 --- a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json +++ b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json @@ -7,7 +7,7 @@ "engine": "InnoDB", "field_order": [ "certificate_details_section", - "section_code", + "tax_withholding_category", "fiscal_year", "column_break_3", "certificate_no", @@ -33,13 +33,6 @@ "reqd": 1, "unique": 1 }, - { - "fieldname": "section_code", - "fieldtype": "Select", - "label": "Section Code", - "options": "192\n193\n194\n194A\n194C\n194D\n194H\n194I\n194J\n194LA\n194LBB\n194LBC\n195", - "reqd": 1 - }, { "fieldname": "section_break_3", "fieldtype": "Section Break", @@ -123,13 +116,22 @@ "label": "Fiscal Year", "options": "Fiscal Year", "reqd": 1 + }, + { + "fieldname": "tax_withholding_category", + "fieldtype": "Link", + "label": "Tax Withholding Category", + "options": "Tax Withholding Category", + "reqd": 1 } ], + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-04-23 23:04:41.203721", + "modified": "2021-10-23 18:33:38.962622", "modified_by": "Administrator", "module": "Regional", "name": "Lower Deduction Certificate", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [], "sort_field": "modified", diff --git a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py index d8553f1d9130..821e0171bbdf 100644 --- a/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py +++ b/erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.py @@ -15,7 +15,7 @@ class LowerDeductionCertificate(Document): def validate(self): self.validate_dates() - self.validate_supplier_against_section_code() + self.validate_supplier_against_tax_category() def validate_dates(self): if getdate(self.valid_upto) < getdate(self.valid_from): @@ -31,12 +31,14 @@ def validate_dates(self): <= fiscal_year.year_end_date): frappe.throw(_("Valid Upto date not in Fiscal Year {0}").format(frappe.bold(self.fiscal_year))) - def validate_supplier_against_section_code(self): - duplicate_certificate = frappe.db.get_value('Lower Deduction Certificate', {'supplier': self.supplier, 'section_code': self.section_code}, ['name', 'valid_from', 'valid_upto'], as_dict=True) + def validate_supplier_against_tax_category(self): + duplicate_certificate = frappe.db.get_value('Lower Deduction Certificate', + {'supplier': self.supplier, 'tax_withholding_category': self.tax_withholding_category, 'name': ("!=", self.name)}, + ['name', 'valid_from', 'valid_upto'], as_dict=True) if duplicate_certificate and self.are_dates_overlapping(duplicate_certificate): certificate_link = get_link_to_form('Lower Deduction Certificate', duplicate_certificate.name) - frappe.throw(_("There is already a valid Lower Deduction Certificate {0} for Supplier {1} against Section Code {2} for this time period.") - .format(certificate_link, frappe.bold(self.supplier), frappe.bold(self.section_code))) + frappe.throw(_("There is already a valid Lower Deduction Certificate {0} for Supplier {1} against category {2} for this time period.") + .format(certificate_link, frappe.bold(self.supplier), frappe.bold(self.tax_withholding_category))) def are_dates_overlapping(self,duplicate_certificate): valid_from = duplicate_certificate.valid_from diff --git a/erpnext/regional/india/e_invoice/einv_template.json b/erpnext/regional/india/e_invoice/einv_template.json index 60f490d61669..c2a28f20494c 100644 --- a/erpnext/regional/india/e_invoice/einv_template.json +++ b/erpnext/regional/india/e_invoice/einv_template.json @@ -38,7 +38,7 @@ "Pos": "{buyer_details.place_of_supply}" }}, "DispDtls": {{ - "Nm": "{dispatch_details.company_name}", + "Nm": "{dispatch_details.legal_name}", "Addr1": "{dispatch_details.address_line1}", "Addr2": "{dispatch_details.address_line2}", "Loc": "{dispatch_details.location}", diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 19699243f9e6..cd2c8a26b1c7 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -138,8 +138,8 @@ def get_doc_details(invoice): invoice_date=invoice_date )) -def validate_address_fields(address, is_shipping_address): - if ((not address.gstin and not is_shipping_address) +def validate_address_fields(address, skip_gstin_validation): + if ((not address.gstin and not skip_gstin_validation) or not address.city or not address.pincode or not address.address_title @@ -151,10 +151,10 @@ def validate_address_fields(address, is_shipping_address): title=_('Missing Address Fields') ) -def get_party_details(address_name, is_shipping_address=False): +def get_party_details(address_name, skip_gstin_validation=False): addr = frappe.get_doc('Address', address_name) - validate_address_fields(addr, is_shipping_address) + validate_address_fields(addr, skip_gstin_validation) if addr.gst_state_number == 97: # according to einvoice standard @@ -443,7 +443,11 @@ def make_einvoice(invoice): if invoice.gst_category == 'Overseas': shipping_details = get_overseas_address_details(invoice.shipping_address_name) else: - shipping_details = get_party_details(invoice.shipping_address_name, is_shipping_address=True) + shipping_details = get_party_details(invoice.shipping_address_name, skip_gstin_validation=True) + + dispatch_details = frappe._dict({}) + if invoice.dispatch_address_name: + dispatch_details = get_party_details(invoice.dispatch_address_name, skip_gstin_validation=True) if invoice.is_pos and invoice.base_paid_amount: payment_details = get_payment_details(invoice) @@ -455,7 +459,7 @@ def make_einvoice(invoice): eway_bill_details = get_eway_bill_details(invoice) # not yet implemented - dispatch_details = period_details = export_details = frappe._dict({}) + period_details = export_details = frappe._dict({}) einvoice = schema.format( transaction_details=transaction_details, doc_details=doc_details, dispatch_details=dispatch_details, @@ -927,7 +931,7 @@ def sanitize_error_message(self, message): return errors - def raise_error(self, raise_exception=False, errors=[]): + def raise_error(self, raise_exception=False, errors=None): title = _('E Invoice Request Failed') if errors: frappe.throw(errors, title=title, as_list=1) diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index ded0dae0d804..58280070795f 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -18,7 +18,7 @@ def setup(company=None, patch=True): # Company independent fixtures should be called only once at the first company setup - if frappe.db.count('Company', {'country': 'India'}) <=1: + if patch or frappe.db.count('Company', {'country': 'India'}) <=1: setup_company_independent_fixtures(patch=patch) if not patch: @@ -139,6 +139,10 @@ def make_property_setters(patch=False): make_property_setter('Journal Entry', 'voucher_type', 'options', '\n'.join(journal_entry_types), '') def make_custom_fields(update=True): + custom_fields = get_custom_fields() + create_custom_fields(custom_fields, update=update) + +def get_custom_fields(): hsn_sac_field = dict(fieldname='gst_hsn_code', label='HSN/SAC', fieldtype='Data', fetch_from='item_code.gst_hsn_code', insert_after='description', allow_on_submit=1, print_hide=1, fetch_if_empty=1) @@ -172,12 +176,12 @@ def make_custom_fields(update=True): dict(fieldname='gst_category', label='GST Category', fieldtype='Select', insert_after='gst_section', 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), + fetch_from='customer.gst_category', fetch_if_empty=1, length=25), dict(fieldname='export_type', label='Export Type', fieldtype='Select', insert_after='gst_category', print_hide=1, depends_on='eval:in_list(["SEZ", "Overseas", "Deemed Export"], doc.gst_category)', options='\nWith Payment of Tax\nWithout Payment of Tax', fetch_from='customer.export_type', - fetch_if_empty=1), + fetch_if_empty=1, length=25), ] delivery_note_gst_category = [ @@ -188,18 +192,18 @@ def make_custom_fields(update=True): ] invoice_gst_fields = [ - dict(fieldname='invoice_copy', label='Invoice Copy', + dict(fieldname='invoice_copy', label='Invoice Copy', length=30, fieldtype='Select', insert_after='export_type', print_hide=1, allow_on_submit=1, options='Original for Recipient\nDuplicate for Transporter\nDuplicate for Supplier\nTriplicate for Supplier'), - dict(fieldname='reverse_charge', label='Reverse Charge', + dict(fieldname='reverse_charge', label='Reverse Charge', length=2, fieldtype='Select', insert_after='invoice_copy', print_hide=1, options='Y\nN', default='N'), - dict(fieldname='ecommerce_gstin', label='E-commerce GSTIN', + dict(fieldname='ecommerce_gstin', label='E-commerce GSTIN', length=15, fieldtype='Data', insert_after='export_type', print_hide=1), dict(fieldname='gst_col_break', fieldtype='Column Break', insert_after='ecommerce_gstin'), dict(fieldname='reason_for_issuing_document', label='Reason For Issuing document', fieldtype='Select', insert_after='gst_col_break', print_hide=1, - depends_on='eval:doc.is_return==1', + depends_on='eval:doc.is_return==1', length=45, options='\n01-Sales Return\n02-Post Sale Discount\n03-Deficiency in services\n04-Correction in Invoice\n05-Change in POS\n06-Finalization of Provisional assessment\n07-Others') ] @@ -237,25 +241,25 @@ def make_custom_fields(update=True): sales_invoice_gst_fields = [ dict(fieldname='billing_address_gstin', label='Billing Address GSTIN', fieldtype='Data', insert_after='customer_address', read_only=1, - fetch_from='customer_address.gstin', print_hide=1), + fetch_from='customer_address.gstin', print_hide=1, length=15), dict(fieldname='customer_gstin', label='Customer GSTIN', fieldtype='Data', insert_after='shipping_address_name', - fetch_from='shipping_address_name.gstin', print_hide=1), + fetch_from='shipping_address_name.gstin', print_hide=1, length=15), dict(fieldname='place_of_supply', label='Place of Supply', fieldtype='Data', insert_after='customer_gstin', - print_hide=1, read_only=1), + print_hide=1, read_only=1, length=50), dict(fieldname='company_gstin', label='Company GSTIN', fieldtype='Data', insert_after='company_address', - fetch_from='company_address.gstin', print_hide=1, read_only=1), + fetch_from='company_address.gstin', print_hide=1, read_only=1, length=15), ] sales_invoice_shipping_fields = [ dict(fieldname='port_code', label='Port Code', fieldtype='Data', insert_after='reason_for_issuing_document', print_hide=1, - depends_on="eval:doc.gst_category=='Overseas' "), + depends_on="eval:doc.gst_category=='Overseas' ", length=15), dict(fieldname='shipping_bill_number', label=' Shipping Bill Number', fieldtype='Data', insert_after='port_code', print_hide=1, - depends_on="eval:doc.gst_category=='Overseas' "), + depends_on="eval:doc.gst_category=='Overseas' ", length=50), dict(fieldname='shipping_bill_date', label='Shipping Bill Date', fieldtype='Date', insert_after='shipping_bill_number', print_hide=1, depends_on="eval:doc.gst_category=='Overseas' "), @@ -361,7 +365,8 @@ def make_custom_fields(update=True): 'insert_after': 'transporter', 'fetch_from': 'transporter.gst_transporter_id', 'print_hide': 1, - 'translatable': 0 + 'translatable': 0, + 'length': 20 }, { 'fieldname': 'driver', @@ -377,7 +382,8 @@ def make_custom_fields(update=True): 'fieldtype': 'Data', 'insert_after': 'driver', 'print_hide': 1, - 'translatable': 0 + 'translatable': 0, + 'length': 30 }, { 'fieldname': 'vehicle_no', @@ -385,7 +391,8 @@ def make_custom_fields(update=True): 'fieldtype': 'Data', 'insert_after': 'lr_no', 'print_hide': 1, - 'translatable': 0 + 'translatable': 0, + 'length': 10 }, { 'fieldname': 'distance', @@ -402,7 +409,7 @@ def make_custom_fields(update=True): { 'fieldname': 'transporter_name', 'label': 'Transporter Name', - 'fieldtype': 'Data', + 'fieldtype': 'Small Text', 'insert_after': 'transporter_col_break', 'fetch_from': 'transporter.name', 'read_only': 1, @@ -416,12 +423,13 @@ def make_custom_fields(update=True): 'options': '\nRoad\nAir\nRail\nShip', 'insert_after': 'transporter_name', 'print_hide': 1, - 'translatable': 0 + 'translatable': 0, + 'length': 5 }, { 'fieldname': 'driver_name', 'label': 'Driver Name', - 'fieldtype': 'Data', + 'fieldtype': 'Small Text', 'insert_after': 'mode_of_transport', 'fetch_from': 'driver.full_name', 'print_hide': 1, @@ -444,7 +452,8 @@ def make_custom_fields(update=True): 'default': 'Regular', 'insert_after': 'lr_date', 'print_hide': 1, - 'translatable': 0 + 'translatable': 0, + 'length': 30 }, { 'fieldname': 'ewaybill', @@ -453,7 +462,8 @@ def make_custom_fields(update=True): 'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)', 'allow_on_submit': 1, 'insert_after': 'tax_id', - 'translatable': 0 + 'translatable': 0, + 'length': 20 } ] @@ -719,7 +729,8 @@ def make_custom_fields(update=True): } ] } - create_custom_fields(custom_fields, update=update) + + return custom_fields def make_fixtures(company=None): docs = [] diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py index 38e17705a6a5..254a293dbf92 100644 --- a/erpnext/regional/india/utils.py +++ b/erpnext/regional/india/utils.py @@ -112,7 +112,11 @@ def validate_gstin_check_digit(gstin, label='GSTIN'): frappe.throw(_("""Invalid {0}! The check digit validation has failed. Please ensure you've typed the {0} correctly.""").format(label)) def get_itemised_tax_breakup_header(item_doctype, tax_accounts): - return [_("Item"), _("Taxable Amount")] + tax_accounts + hsn_wise_in_gst_settings = frappe.db.get_single_value('GST Settings','hsn_wise_tax_breakup') + if frappe.get_meta(item_doctype).has_field('gst_hsn_code') and hsn_wise_in_gst_settings: + return [_("HSN/SAC"), _("Taxable Amount")] + tax_accounts + else: + return [_("Item"), _("Taxable Amount")] + tax_accounts def get_itemised_tax_breakup_data(doc, account_wise=False, hsn_wise=False): itemised_tax = get_itemised_tax(doc.taxes, with_tax_account=account_wise) @@ -122,14 +126,17 @@ def get_itemised_tax_breakup_data(doc, account_wise=False, hsn_wise=False): if not frappe.get_meta(doc.doctype + " Item").has_field('gst_hsn_code'): return itemised_tax, itemised_taxable_amount - if hsn_wise: + hsn_wise_in_gst_settings = frappe.db.get_single_value('GST Settings','hsn_wise_tax_breakup') + + tax_breakup_hsn_wise = hsn_wise or hsn_wise_in_gst_settings + if tax_breakup_hsn_wise: item_hsn_map = frappe._dict() for d in doc.items: item_hsn_map.setdefault(d.item_code or d.item_name, d.get("gst_hsn_code")) hsn_tax = {} for item, taxes in itemised_tax.items(): - item_or_hsn = item if not hsn_wise else item_hsn_map.get(item) + item_or_hsn = item if not tax_breakup_hsn_wise else item_hsn_map.get(item) hsn_tax.setdefault(item_or_hsn, frappe._dict()) for tax_desc, tax_detail in taxes.items(): key = tax_desc @@ -142,7 +149,7 @@ def get_itemised_tax_breakup_data(doc, account_wise=False, hsn_wise=False): # set taxable amount hsn_taxable_amount = frappe._dict() for item in itemised_taxable_amount: - item_or_hsn = item if not hsn_wise else item_hsn_map.get(item) + item_or_hsn = item if not tax_breakup_hsn_wise else item_hsn_map.get(item) hsn_taxable_amount.setdefault(item_or_hsn, 0) hsn_taxable_amount[item_or_hsn] += itemised_taxable_amount.get(item) diff --git a/erpnext/regional/print_format/ksa_vat_invoice/__init__.py b/erpnext/regional/print_format/ksa_vat_invoice/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/regional/print_format/ksa_vat_invoice/ksa_vat_invoice.json b/erpnext/regional/print_format/ksa_vat_invoice/ksa_vat_invoice.json new file mode 100644 index 000000000000..36d653616ba4 --- /dev/null +++ b/erpnext/regional/print_format/ksa_vat_invoice/ksa_vat_invoice.json @@ -0,0 +1,32 @@ +{ + "absolute_value": 0, + "align_labels_right": 0, + "creation": "2021-10-29 22:46:26.039023", + "css": ".qr-code{\n float:right;\n}\n\n.invoice-heading {\n margin: 0;\n}\n\n.ksa-invoice-table {\n border: 1px solid #888a8e;\n border-collapse: collapse;\n width: 100%;\n margin: 20px 0;\n color: #888a8e;\n font-size: 16px;\n}\n\n.ksa-invoice-table.two-columns td:nth-child(2) {\n direction: rtl;\n}\n\n.ksa-invoice-table th {\n border: 1px solid #888a8e;\n max-width: 50%;\n background-color: #265e4a !important;\n color: #fff;\n padding: 8px;\n}\n\n.ksa-invoice-table td {\n padding: 5px;\n border: 1px solid #888a8e;\n max-width: 50%;\n}\n\n.ksa-invoice-table thead,\n.ksa-invoice-table tfoot {\n text-transform: uppercase;\n}\n\n.qr-rtl {\n direction: rtl;\n}\n\n.qr-flex{\n display: flex;\n justify-content: space-between;\n}", + "custom_format": 1, + "default_print_language": "en", + "disabled": 0, + "doc_type": "Sales Invoice", + "docstatus": 0, + "doctype": "Print Format", + "font_size": 14, + "html": "
\n
\n
\n

TAX INVOICE

\n

\u0641\u0627\u062a\u0648\u0631\u0629 \u0636\u0631\u064a\u0628\u064a\u0629

\n
\n \n \n
\n {% set company = frappe.get_doc(\"Company\", doc.company)%}\n {% if (doc.company_address) %}\n {% set supplier_address_doc = frappe.get_doc('Address', doc.company_address) %}\n {% endif %}\n \n {% if(doc.customer_address) %}\n {% set customer_address = frappe.get_doc('Address', doc.customer_address ) %}\n {% endif %}\n \n {% if(doc.shipping_address_name) %}\n {% set customer_shipping_address = frappe.get_doc('Address', doc.shipping_address_name ) %}\n {% endif %} \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\t\t{% if (company.tax_id) %}\n \n \n \n \n \n \n \n \n {% endif %}\n \n \n \n \n \n \n {% if(supplier_address_doc) %}\n \n \n \n \n \n \n \n \n \n \n \n \n {% endif %}\n \n \n \n \n \n \n\t\t{% set customer_tax_id = frappe.db.get_value('Customer', doc.customer, 'tax_id') %}\n\t\t{% if customer_tax_id %}\n \n \n \n \n \n \n \n \n {% endif %}\n \n \n \n \n \n {% if(customer_address) %}\n \n \n \n \n {% endif %}\n \n {% if(customer_shipping_address) %}\n \n \n \n \n \n \n \n \n \n {% endif %}\n \n\t\t{% if(doc.po_no) %}\n \n \n \n \n \n \n \n \n \n {% endif %}\n \n \n \n \n \n \n
{{ company.name }}{{ company.company_name_in_arabic }}
Invoice#: {{doc.name}}\u0631\u0642\u0645 \u0627\u0644\u0641\u0627\u062a\u0648\u0631\u0629: {{doc.name}}
Invoice Date: {{doc.posting_date}}\u062a\u0627\u0631\u064a\u062e \u0627\u0644\u0641\u0627\u062a\u0648\u0631\u0629: {{doc.posting_date}}
Date of Supply:{{doc.posting_date}}\u062a\u0627\u0631\u064a\u062e \u0627\u0644\u062a\u0648\u0631\u064a\u062f: {{doc.posting_date}}
Supplier:\u0627\u0644\u0645\u0648\u0631\u062f:
Supplier Tax Identification Number:\u0631\u0642\u0645 \u0627\u0644\u062a\u0639\u0631\u064a\u0641 \u0627\u0644\u0636\u0631\u064a\u0628\u064a \u0644\u0644\u0645\u0648\u0631\u062f:
{{ company.tax_id }}{{ company.tax_id }}
{{ company.name }}{{ company.company_name_in_arabic }}
{{ supplier_address_doc.address_line1}} {{ supplier_address_doc.address_in_arabic}}
Phone: {{ supplier_address_doc.phone }}\u0647\u0627\u062a\u0641: {{ supplier_address_doc.phone }}
Email: {{ supplier_address_doc.email_id }}\u0628\u0631\u064a\u062f \u0627\u0644\u0643\u062a\u0631\u0648\u0646\u064a: {{ supplier_address_doc.email_id }}
CUSTOMER:\u0639\u0645\u064a\u0644:
Customer Tax Identification Number:\u0631\u0642\u0645 \u0627\u0644\u062a\u0639\u0631\u064a\u0641 \u0627\u0644\u0636\u0631\u064a\u0628\u064a \u0644\u0644\u0639\u0645\u064a\u0644:
{{ customer_tax_id }}{{ customer_tax_id }}
{{ doc.customer }} {{ doc.customer_name_in_arabic }}
{{ customer_address.address_line1}} {{ customer_address.address_in_arabic}}
SHIPPING ADDRESS:\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0634\u062d\u0646:
{{ customer_shipping_address.address_line1}} {{ customer_shipping_address.address_in_arabic}}
OTHER INFORMATION\u0645\u0639\u0644\u0648\u0645\u0627\u062a \u0623\u062e\u0631\u0649
Purchase Order Number: {{ doc.po_no }}\u0631\u0642\u0645 \u0623\u0645\u0631 \u0627\u0644\u0634\u0631\u0627\u0621: {{ doc.po_no }}
Payment Due Date: {{ doc.due_date}} \u062a\u0627\u0631\u064a\u062e \u0627\u0633\u062a\u062d\u0642\u0627\u0642 \u0627\u0644\u062f\u0641\u0639: {{ doc.due_date}}
\n\n \n {% set col = namespace(one = 2, two = 1) %}\n {% set length = doc.taxes | length %}\n {% set length = length / 2 | round %}\n {% set col.one = col.one + length %}\n {% set col.two = col.two + length %}\n \n {%- if(doc.taxes | length % 2 > 0 ) -%}\n {% set col.two = col.two + 1 %}\n {% endif %}\n \n \n {% set total = namespace(amount = 0) %}\n \n \n \n \n \n \n \n \n {% for row in doc.taxes %}\n \n {% endfor %}\n \n \n \n \n \n {%- for item in doc.items -%}\n {% set total.amount = item.amount %}\n \n \n \n \n \n {% for row in doc.taxes %}\n {% set data_object = json.loads(row.item_wise_tax_detail) %}\n \n {% endfor %}\n \n \n {%- endfor -%}\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
Nature of goods or services
\u0637\u0628\u064a\u0639\u0629 \u0627\u0644\u0633\u0644\u0639 \u0623\u0648 \u0627\u0644\u062e\u062f\u0645\u0627\u062a
\n Unit price
\n \u0633\u0639\u0631 \u0627\u0644\u0648\u062d\u062f\u0629\n
\n Quantity
\n \u0627\u0644\u0643\u0645\u064a\u0629\n
\n Taxable Amount
\n \u0627\u0644\u0645\u0628\u0644\u063a \u0627\u0644\u062e\u0627\u0636\u0639 \u0644\u0644\u0636\u0631\u064a\u0628\u0629\n
{{row.description}}\n Total
\n \u0627\u0644\u0645\u062c\u0645\u0648\u0639\n
{{ item.item_code }}{{ item.get_formatted(\"rate\") }}{{ item.qty }}{{ item.get_formatted(\"amount\") }}\n
\n {%- if(data_object[item.item_code][0])-%}\n {{ frappe.format(data_object[item.item_code][0], {'fieldtype': 'Percent'}) }}\n {%- endif -%}\n \n {%- if(data_object[item.item_code][1])-%}\n {{ frappe.format(data_object[item.item_code][1], {'fieldtype': 'Currency'}) }}\n {% set total.amount = total.amount + data_object[item.item_code][1] %}\n {%- endif -%}\n
\n
{{ frappe.format(total.amount, {'fieldtype': 'Currency'}) }}
\n {{ doc.get_formatted(\"total\") }}
\n {{ doc.get_formatted(\"total_taxes_and_charges\") }}\n
\n \u0627\u0644\u0625\u062c\u0645\u0627\u0644\u064a \u0628\u0627\u0633\u062a\u062b\u0646\u0627\u0621 \u0636\u0631\u064a\u0628\u0629 \u0627\u0644\u0642\u064a\u0645\u0629 \u0627\u0644\u0645\u0636\u0627\u0641\u0629\n
\n \u0625\u062c\u0645\u0627\u0644\u064a \u0636\u0631\u064a\u0628\u0629 \u0627\u0644\u0642\u064a\u0645\u0629 \u0627\u0644\u0645\u0636\u0627\u0641\u0629\n
\n Total (Excluding VAT)\n
\n Total VAT\n
\n {{ doc.get_formatted(\"total\") }}
\n {{ doc.get_formatted(\"total_taxes_and_charges\") }}\n
{{ doc.get_formatted(\"grand_total\") }}\n \u0625\u062c\u0645\u0627\u0644\u064a \u0627\u0644\u0645\u0628\u0644\u063a \u0627\u0644\u0645\u0633\u062a\u062d\u0642Total Amount Due{{ doc.get_formatted(\"grand_total\") }}
\n\n\t{%- if doc.terms -%}\n

\n {{doc.terms}}\n

\n\t{%- endif -%}\n
\n", + "idx": 0, + "line_breaks": 0, + "margin_bottom": 15.0, + "margin_left": 15.0, + "margin_right": 15.0, + "margin_top": 15.0, + "modified": "2021-11-08 09:19:18.660806", + "modified_by": "Administrator", + "module": "Regional", + "name": "KSA VAT Invoice", + "owner": "Administrator", + "page_number": "Hide", + "print_format_builder": 0, + "print_format_builder_beta": 0, + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 0, + "standard": "Yes" +} \ No newline at end of file diff --git a/erpnext/regional/report/datev/datev.py b/erpnext/regional/report/datev/datev.py index dcabe368aa58..54c3526d2e3e 100644 --- a/erpnext/regional/report/datev/datev.py +++ b/erpnext/regional/report/datev/datev.py @@ -351,7 +351,7 @@ def run_query(filters, extra_fields, extra_joins, extra_filters, as_dict=1): gl.posting_date as 'Belegdatum', gl.voucher_no as 'Belegfeld 1', - LEFT(gl.remarks, 60) as 'Buchungstext', + REPLACE(LEFT(gl.remarks, 60), '\n', ' ') as 'Buchungstext', gl.voucher_type as 'Beleginfo - Art 1', gl.voucher_no as 'Beleginfo - Inhalt 1', gl.against_voucher_type as 'Beleginfo - Art 2', diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py index a068a3807763..9b593eebc69c 100644 --- a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py +++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.py @@ -15,8 +15,8 @@ def execute(filters=None): return columns, data -def validate_filters(filters={}): - filters = frappe._dict(filters) +def validate_filters(filters=None): + filters = frappe._dict(filters or {}) if not filters.company: frappe.throw(_('{} is mandatory for generating E-Invoice Summary Report').format(_('Company')), title=_('Invalid Filter')) @@ -28,7 +28,9 @@ def validate_filters(filters={}): if filters.from_date > filters.to_date: frappe.throw(_('From Date must be before To Date'), title=_('Invalid Filter')) -def get_data(filters={}): +def get_data(filters=None): + if not filters: + filters = {} query_filters = { 'posting_date': ['between', [filters.from_date, filters.to_date]], 'einvoice_status': ['is', 'set'], diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index 23924c5fb66a..7d401bab6692 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -172,13 +172,6 @@ def get_invoice_data(self): self.invoices = frappe._dict() conditions = self.get_conditions() - company_gstins = get_company_gstin_number(self.filters.get('company'), all_gstins=True) - - if company_gstins: - self.filters.update({ - 'company_gstins': company_gstins - }) - invoice_data = frappe.db.sql(""" select {select_columns} @@ -242,7 +235,7 @@ def get_conditions(self): elif self.filters.get("type_of_business") == "EXPORT": conditions += """ AND is_return !=1 and gst_category = 'Overseas' """ - conditions += " AND IFNULL(billing_address_gstin, '') NOT IN %(company_gstins)s" + conditions += " AND IFNULL(billing_address_gstin, '') != company_gstin" return conditions diff --git a/erpnext/regional/report/ksa_vat/__init__.py b/erpnext/regional/report/ksa_vat/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/regional/report/ksa_vat/ksa_vat.js b/erpnext/regional/report/ksa_vat/ksa_vat.js new file mode 100644 index 000000000000..59e72c3e6384 --- /dev/null +++ b/erpnext/regional/report/ksa_vat/ksa_vat.js @@ -0,0 +1,59 @@ +// Copyright (c) 2016, Havenir Solutions and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["KSA VAT"] = { + onload() { + frappe.breadcrumbs.add('Accounts'); + }, + "filters": [ + { + "fieldname": "company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "reqd": 1, + "default": frappe.defaults.get_user_default("Company") + }, + { + "fieldname": "from_date", + "label": __("From Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1), + }, + { + "fieldname": "to_date", + "label": __("To Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.get_today() + } + ], + "formatter": function(value, row, column, data, default_formatter) { + if (data + && (data.title=='VAT on Sales' || data.title=='VAT on Purchases') + && data.title==value) { + value = $(`${value}`); + var $value = $(value).css("font-weight", "bold"); + value = $value.wrap("

").parent().html(); + return value + }else if (data.title=='Grand Total'){ + if (data.title==value) { + value = $(`${value}`); + var $value = $(value).css("font-weight", "bold"); + value = $value.wrap("

").parent().html(); + return value + }else{ + value = default_formatter(value, row, column, data); + value = $(`${value}`); + var $value = $(value).css("font-weight", "bold"); + value = $value.wrap("

").parent().html(); + return value + } + }else{ + value = default_formatter(value, row, column, data); + return value; + } + }, +}; diff --git a/erpnext/regional/report/ksa_vat/ksa_vat.json b/erpnext/regional/report/ksa_vat/ksa_vat.json new file mode 100644 index 000000000000..036e26031032 --- /dev/null +++ b/erpnext/regional/report/ksa_vat/ksa_vat.json @@ -0,0 +1,32 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-07-13 08:54:38.000949", + "disable_prepared_report": 1, + "disabled": 1, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-08-26 04:14:37.202594", + "modified_by": "Administrator", + "module": "Regional", + "name": "KSA VAT", + "owner": "Administrator", + "prepared_report": 1, + "ref_doctype": "GL Entry", + "report_name": "KSA VAT", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Accounts User" + } + ] +} \ No newline at end of file diff --git a/erpnext/regional/report/ksa_vat/ksa_vat.py b/erpnext/regional/report/ksa_vat/ksa_vat.py new file mode 100644 index 000000000000..9a0e7b1cd207 --- /dev/null +++ b/erpnext/regional/report/ksa_vat/ksa_vat.py @@ -0,0 +1,176 @@ +# Copyright (c) 2013, Havenir Solutions and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +import json + +import frappe +from frappe import _ +from frappe.utils import get_url_to_list + + +def execute(filters=None): + columns = columns = get_columns() + data = get_data(filters) + return columns, data + +def get_columns(): + return [ + { + "fieldname": "title", + "label": _("Title"), + "fieldtype": "Data", + "width": 300 + }, + { + "fieldname": "amount", + "label": _("Amount (SAR)"), + "fieldtype": "Currency", + "width": 150, + }, + { + "fieldname": "adjustment_amount", + "label": _("Adjustment (SAR)"), + "fieldtype": "Currency", + "width": 150, + }, + { + "fieldname": "vat_amount", + "label": _("VAT Amount (SAR)"), + "fieldtype": "Currency", + "width": 150, + } + ] + +def get_data(filters): + data = [] + + # Validate if vat settings exist + company = filters.get('company') + if frappe.db.exists('KSA VAT Setting', company) is None: + url = get_url_to_list('KSA VAT Setting') + frappe.msgprint(_('Create KSA VAT Setting for this company').format(url)) + return data + + ksa_vat_setting = frappe.get_doc('KSA VAT Setting', company) + + # Sales Heading + append_data(data, 'VAT on Sales', '', '', '') + + grand_total_taxable_amount = 0 + grand_total_taxable_adjustment_amount = 0 + grand_total_tax = 0 + + for vat_setting in ksa_vat_setting.ksa_vat_sales_accounts: + total_taxable_amount, total_taxable_adjustment_amount, \ + total_tax = get_tax_data_for_each_vat_setting(vat_setting, filters, 'Sales Invoice') + + # Adding results to data + append_data(data, vat_setting.title, total_taxable_amount, + total_taxable_adjustment_amount, total_tax) + + grand_total_taxable_amount += total_taxable_amount + grand_total_taxable_adjustment_amount += total_taxable_adjustment_amount + grand_total_tax += total_tax + + # Sales Grand Total + append_data(data, 'Grand Total', grand_total_taxable_amount, + grand_total_taxable_adjustment_amount, grand_total_tax) + + # Blank Line + append_data(data, '', '', '', '') + + # Purchase Heading + append_data(data, 'VAT on Purchases', '', '', '') + + grand_total_taxable_amount = 0 + grand_total_taxable_adjustment_amount = 0 + grand_total_tax = 0 + + for vat_setting in ksa_vat_setting.ksa_vat_purchase_accounts: + total_taxable_amount, total_taxable_adjustment_amount, \ + total_tax = get_tax_data_for_each_vat_setting(vat_setting, filters, 'Purchase Invoice') + + # Adding results to data + append_data(data, vat_setting.title, total_taxable_amount, + total_taxable_adjustment_amount, total_tax) + + grand_total_taxable_amount += total_taxable_amount + grand_total_taxable_adjustment_amount += total_taxable_adjustment_amount + grand_total_tax += total_tax + + # Purchase Grand Total + append_data(data, 'Grand Total', grand_total_taxable_amount, + grand_total_taxable_adjustment_amount, grand_total_tax) + + return data + +def get_tax_data_for_each_vat_setting(vat_setting, filters, doctype): + ''' + (KSA, {filters}, 'Sales Invoice') => 500, 153, 10 \n + calculates and returns \n + total_taxable_amount, total_taxable_adjustment_amount, total_tax''' + from_date = filters.get('from_date') + to_date = filters.get('to_date') + + # Initiate variables + total_taxable_amount = 0 + total_taxable_adjustment_amount = 0 + total_tax = 0 + # Fetch All Invoices + invoices = frappe.get_all(doctype, + filters ={ + 'docstatus': 1, + 'posting_date': ['between', [from_date, to_date]] + }, fields =['name', 'is_return']) + + for invoice in invoices: + invoice_items = frappe.get_all(f'{doctype} Item', + filters ={ + 'docstatus': 1, + 'parent': invoice.name, + 'item_tax_template': vat_setting.item_tax_template + }, fields =['item_code', 'net_amount']) + + for item in invoice_items: + # Summing up total taxable amount + if invoice.is_return == 0: + total_taxable_amount += item.net_amount + + if invoice.is_return == 1: + total_taxable_adjustment_amount += item.net_amount + + # Summing up total tax + total_tax += get_tax_amount(item.item_code, vat_setting.account, doctype, invoice.name) + + return total_taxable_amount, total_taxable_adjustment_amount, total_tax + + + +def append_data(data, title, amount, adjustment_amount, vat_amount): + """Returns data with appended value.""" + data.append({"title": _(title), "amount": amount, "adjustment_amount": adjustment_amount, "vat_amount": vat_amount}) + +def get_tax_amount(item_code, account_head, doctype, parent): + if doctype == 'Sales Invoice': + tax_doctype = 'Sales Taxes and Charges' + + elif doctype == 'Purchase Invoice': + tax_doctype = 'Purchase Taxes and Charges' + + item_wise_tax_detail = frappe.get_value(tax_doctype, { + 'docstatus': 1, + 'parent': parent, + 'account_head': account_head + }, 'item_wise_tax_detail') + + tax_amount = 0 + if item_wise_tax_detail and len(item_wise_tax_detail) > 0: + item_wise_tax_detail = json.loads(item_wise_tax_detail) + for key, value in item_wise_tax_detail.items(): + if key == item_code: + tax_amount = value[1] + break + + return tax_amount diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index f4c049d16232..2b5ecc3b18cb 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -122,7 +122,7 @@ def get_total_emiratewise(filters): try: return frappe.db.sql(""" select - s.vat_emirate as emirate, sum(i.base_amount) as total, sum(s.total_taxes_and_charges) + s.vat_emirate as emirate, sum(i.base_amount) as total, sum(i.tax_amount) from `tabSales Invoice Item` i inner join `tabSales Invoice` s on diff --git a/erpnext/regional/saudi_arabia/setup.py b/erpnext/regional/saudi_arabia/setup.py index 3ccaae9e6a53..41142bd59fb2 100644 --- a/erpnext/regional/saudi_arabia/setup.py +++ b/erpnext/regional/saudi_arabia/setup.py @@ -2,10 +2,63 @@ # License: GNU General Public License v3. See license.txt from __future__ import unicode_literals - -from erpnext.regional.united_arab_emirates.setup import add_print_formats, make_custom_fields - +import frappe +from frappe.permissions import add_permission, update_permission_property +from erpnext.regional.united_arab_emirates.setup import make_custom_fields as uae_custom_fields, add_print_formats +from erpnext.regional.saudi_arabia.wizard.operations.setup_ksa_vat_setting import create_ksa_vat_setting +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields def setup(company=None, patch=True): - make_custom_fields() + uae_custom_fields() add_print_formats() + add_permissions() + make_custom_fields() + +def add_permissions(): + """Add Permissions for KSA VAT Setting.""" + add_permission('KSA VAT Setting', 'All', 0) + for role in ('Accounts Manager', 'Accounts User', 'System Manager'): + add_permission('KSA VAT Setting', role, 0) + update_permission_property('KSA VAT Setting', role, 0, 'write', 1) + update_permission_property('KSA VAT Setting', role, 0, 'create', 1) + + """Enable KSA VAT Report""" + frappe.db.set_value('Report', 'KSA VAT', 'disabled', 0) + +def make_custom_fields(): + """Create Custom fields + - QR code Image file + - Company Name in Arabic + - Address in Arabic + """ + custom_fields = { + 'Sales Invoice': [ + dict( + fieldname='qr_code', + label='QR Code', + fieldtype='Attach Image', + read_only=1, no_copy=1, hidden=1 + ) + ], + 'Address': [ + dict( + fieldname='address_in_arabic', + label='Address in Arabic', + fieldtype='Data', + insert_after='address_line2' + ) + ], + 'Company': [ + dict( + fieldname='company_name_in_arabic', + label='Company Name In Arabic', + fieldtype='Data', + insert_after='company_name' + ) + ] + } + + create_custom_fields(custom_fields, update=True) + +def update_regional_tax_settings(country, company): + create_ksa_vat_setting(company) diff --git a/erpnext/regional/saudi_arabia/utils.py b/erpnext/regional/saudi_arabia/utils.py new file mode 100644 index 000000000000..cc6c0af7a56e --- /dev/null +++ b/erpnext/regional/saudi_arabia/utils.py @@ -0,0 +1,77 @@ +import io +import os + +import frappe +from pyqrcode import create as qr_create + +from erpnext import get_region + + +def create_qr_code(doc, method): + """Create QR Code after inserting Sales Inv + """ + + region = get_region(doc.company) + if region not in ['Saudi Arabia']: + return + + # if QR Code field not present, do nothing + if not hasattr(doc, 'qr_code'): + return + + # Don't create QR Code if it already exists + qr_code = doc.get("qr_code") + if qr_code and frappe.db.exists({"doctype": "File", "file_url": qr_code}): + return + + meta = frappe.get_meta('Sales Invoice') + + for field in meta.get_image_fields(): + if field.fieldname == 'qr_code': + # Creating public url to print format + default_print_format = frappe.db.get_value('Property Setter', dict(property='default_print_format', doc_type=doc.doctype), "value") + + # System Language + language = frappe.get_system_settings('language') + + # creating qr code for the url + url = f"{ frappe.utils.get_url() }/{ doc.doctype }/{ doc.name }?format={ default_print_format or 'Standard' }&_lang={ language }&key={ doc.get_signature() }" + qr_image = io.BytesIO() + url = qr_create(url, error='L') + url.png(qr_image, scale=2, quiet_zone=1) + + # making file + filename = f"QR-CODE-{doc.name}.png".replace(os.path.sep, "__") + _file = frappe.get_doc({ + "doctype": "File", + "file_name": filename, + "is_private": 0, + "content": qr_image.getvalue(), + "attached_to_doctype": doc.get("doctype"), + "attached_to_name": doc.get("name"), + "attached_to_field": "qr_code" + }) + + _file.save() + + # assigning to document + doc.db_set('qr_code', _file.file_url) + doc.notify_update() + + break + + +def delete_qr_code_file(doc, method): + """Delete QR Code on deleted sales invoice""" + + region = get_region(doc.company) + if region not in ['Saudi Arabia']: + return + + if hasattr(doc, 'qr_code'): + if doc.get('qr_code'): + file_doc = frappe.get_list('File', { + 'file_url': doc.get('qr_code') + }) + if len(file_doc): + frappe.delete_doc('File', file_doc[0].name) \ No newline at end of file diff --git a/erpnext/regional/saudi_arabia/wizard/__init__.py b/erpnext/regional/saudi_arabia/wizard/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/regional/saudi_arabia/wizard/data/__init__.py b/erpnext/regional/saudi_arabia/wizard/data/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/regional/saudi_arabia/wizard/data/ksa_vat_settings.json b/erpnext/regional/saudi_arabia/wizard/data/ksa_vat_settings.json new file mode 100644 index 000000000000..60951a9ceca7 --- /dev/null +++ b/erpnext/regional/saudi_arabia/wizard/data/ksa_vat_settings.json @@ -0,0 +1,47 @@ +[ + { + "type": "Sales Account", + "accounts": [ + { + "title": "Standard rated Sales", + "item_tax_template": "KSA VAT 5%", + "account": "VAT 5%" + }, + { + "title": "Zero rated domestic sales", + "item_tax_template": "KSA VAT Zero", + "account": "VAT Zero" + }, + { + "title": "Exempted sales", + "item_tax_template": "KSA VAT Exempted", + "account": "VAT Exempted" + } + ] + }, + { + "type": "Purchase Account", + "accounts": [ + { + "title": "Standard rated domestic purchases", + "item_tax_template": "KSA VAT 5%", + "account": "VAT 5%" + }, + { + "title": "Imports subject to VAT paid at customs", + "item_tax_template": "KSA Excise 50%", + "account": "Excise 50%" + }, + { + "title": "Zero rated purchases", + "item_tax_template": "KSA VAT Zero", + "account": "VAT Zero" + }, + { + "title": "Exempted purchases", + "item_tax_template": "KSA VAT Exempted", + "account": "VAT Exempted" + } + ] + } +] \ No newline at end of file diff --git a/erpnext/regional/saudi_arabia/wizard/operations/__init__.py b/erpnext/regional/saudi_arabia/wizard/operations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/regional/saudi_arabia/wizard/operations/setup_ksa_vat_setting.py b/erpnext/regional/saudi_arabia/wizard/operations/setup_ksa_vat_setting.py new file mode 100644 index 000000000000..97300dc3782f --- /dev/null +++ b/erpnext/regional/saudi_arabia/wizard/operations/setup_ksa_vat_setting.py @@ -0,0 +1,43 @@ +import json +import os + +import frappe + + +def create_ksa_vat_setting(company): + """On creation of first company. Creates KSA VAT Setting""" + + company = frappe.get_doc('Company', company) + + file_path = os.path.join(os.path.dirname(__file__), '..', 'data', 'ksa_vat_settings.json') + with open(file_path, 'r') as json_file: + account_data = json.load(json_file) + + # Creating KSA VAT Setting + ksa_vat_setting = frappe.get_doc({ + 'doctype': 'KSA VAT Setting', + 'company': company.name + }) + + for data in account_data: + if data['type'] == 'Sales Account': + for row in data['accounts']: + item_tax_template = row['item_tax_template'] + account = row['account'] + ksa_vat_setting.append('ksa_vat_sales_accounts', { + 'title': row['title'], + 'item_tax_template': f'{item_tax_template} - {company.abbr}', + 'account': f'{account} - {company.abbr}' + }) + + elif data['type'] == 'Purchase Account': + for row in data['accounts']: + item_tax_template = row['item_tax_template'] + account = row['account'] + ksa_vat_setting.append('ksa_vat_purchase_accounts', { + 'title': row['title'], + 'item_tax_template': f'{item_tax_template} - {company.abbr}', + 'account': f'{account} - {company.abbr}' + }) + + ksa_vat_setting.save() diff --git a/erpnext/regional/united_states/setup.py b/erpnext/regional/united_states/setup.py index 25982b81227e..f7b921a491bf 100644 --- a/erpnext/regional/united_states/setup.py +++ b/erpnext/regional/united_states/setup.py @@ -16,30 +16,9 @@ def setup(company=None, patch=True): setup_company_independent_fixtures(patch=patch) def setup_company_independent_fixtures(company=None, patch=True): - add_product_tax_categories() make_custom_fields() - add_permissions() - frappe.enqueue('erpnext.regional.united_states.setup.add_product_tax_categories', now=False) add_print_formats() -# Product Tax categories imported from taxjar api -def add_product_tax_categories(): - with open(os.path.join(os.path.dirname(__file__), 'product_tax_category_data.json'), 'r') as f: - tax_categories = json.loads(f.read()) - create_tax_categories(tax_categories['categories']) - -def create_tax_categories(data): - for d in data: - tax_category = frappe.new_doc('Product Tax Category') - tax_category.description = d.get("description") - tax_category.product_tax_code = d.get("product_tax_code") - tax_category.category_name = d.get("name") - try: - tax_category.db_insert() - except frappe.DuplicateEntryError: - pass - - def make_custom_fields(update=True): custom_fields = { 'Supplier': [ @@ -61,29 +40,10 @@ def make_custom_fields(update=True): 'Quotation': [ dict(fieldname='exempt_from_sales_tax', fieldtype='Check', insert_after='taxes_and_charges', label='Is customer exempted from sales tax?') - ], - 'Sales Invoice Item': [ - dict(fieldname='product_tax_category', fieldtype='Link', insert_after='description', options='Product Tax Category', - label='Product Tax Category', fetch_from='item_code.product_tax_category'), - dict(fieldname='tax_collectable', fieldtype='Currency', insert_after='net_amount', - label='Tax Collectable', read_only=1), - dict(fieldname='taxable_amount', fieldtype='Currency', insert_after='tax_collectable', - label='Taxable Amount', read_only=1) - ], - 'Item': [ - dict(fieldname='product_tax_category', fieldtype='Link', insert_after='item_group', options='Product Tax Category', - label='Product Tax Category') ] } create_custom_fields(custom_fields, update=update) -def add_permissions(): - doctype = "Product Tax Category" - for role in ('Accounts Manager', 'Accounts User', 'System Manager','Item Manager', 'Stock Manager'): - add_permission(doctype, role, 0) - update_permission_property(doctype, role, 0, 'write', 1) - update_permission_property(doctype, role, 0, 'create', 1) - def add_print_formats(): frappe.reload_doc("regional", "print_format", "irs_1099_form") frappe.db.set_value("Print Format", "IRS 1099 Form", "disabled", 0) diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js index b33c6e5c6185..ea840e2ce4c2 100644 --- a/erpnext/selling/doctype/customer/customer.js +++ b/erpnext/selling/doctype/customer/customer.js @@ -116,14 +116,15 @@ frappe.ui.form.on("Customer", { frappe.contacts.render_address_and_contact(frm); // custom buttons - frm.add_custom_button(__('Accounting Ledger'), function() { - frappe.set_route('query-report', 'General Ledger', - {party_type:'Customer', party:frm.doc.name}); - }); - frm.add_custom_button(__('Accounts Receivable'), function() { + frm.add_custom_button(__('Accounts Receivable'), function () { frappe.set_route('query-report', 'Accounts Receivable', {customer:frm.doc.name}); - }); + }, __('View')); + + frm.add_custom_button(__('Accounting Ledger'), function () { + frappe.set_route('query-report', 'General Ledger', + {party_type: 'Customer', party: frm.doc.name}); + }, __('View')); frm.add_custom_button(__('Pricing Rule'), function () { erpnext.utils.make_pricing_rule(frm.doc.doctype, frm.doc.name); diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 1961371d0b84..b8b023307f26 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -78,6 +78,8 @@ frappe.ui.form.on("Sales Order", { }); erpnext.queries.setup_warehouse_query(frm); + + frm.ignore_doctypes_on_cancel_all = ['Purchase Order']; }, delivery_date: function(frm) { diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 93676094218b..dcf478bda6ea 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -110,7 +110,7 @@ def validate_delivery_date(self): if self.order_type == 'Sales' and not self.skip_delivery_note: delivery_date_list = [d.delivery_date for d in self.get("items") if d.delivery_date] max_delivery_date = max(delivery_date_list) if delivery_date_list else None - if not self.delivery_date: + if (max_delivery_date and not self.delivery_date) or (max_delivery_date and getdate(self.delivery_date) != getdate(max_delivery_date)): self.delivery_date = max_delivery_date if self.delivery_date: for d in self.get("items"): @@ -119,8 +119,6 @@ def validate_delivery_date(self): if getdate(self.transaction_date) > getdate(d.delivery_date): frappe.msgprint(_("Expected Delivery Date should be after Sales Order Date"), indicator='orange', title=_('Warning')) - if getdate(self.delivery_date) != getdate(max_delivery_date): - self.delivery_date = max_delivery_date else: frappe.throw(_("Please enter Delivery Date")) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 62ea44df4359..804bc6a95e7b 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1350,7 +1350,6 @@ def make_sales_order_workflow(): frappe.get_doc(dict(doctype='Role', role_name='Test Junior Approver')).insert(ignore_if_duplicate=True) frappe.get_doc(dict(doctype='Role', role_name='Test Approver')).insert(ignore_if_duplicate=True) - frappe.db.commit() frappe.cache().hdel('roles', frappe.session.user) workflow = frappe.get_doc({ diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 419c8e237d01..a0c1c85dd525 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -193,12 +193,6 @@ "fieldname": "sales_transactions_settings_section", "fieldtype": "Section Break", "label": "Transaction Settings" - }, - { - "default": "0", - "fieldname": "editable_bundle_item_rates", - "fieldtype": "Check", - "label": "Calculate Product Bundle Price based on Child Items' Rates" } ], "icon": "fa fa-cog", diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py index 805c3d804fa9..5c4d8b601f4b 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py @@ -73,7 +73,7 @@ def get_data(conditions, filters): `tabSales Order` so, `tabSales Order Item` soi LEFT JOIN `tabSales Invoice Item` sii - ON sii.so_detail = soi.name + ON sii.so_detail = soi.name and sii.docstatus = 1 WHERE soi.parent = so.name and so.status not in ('Stopped', 'Closed', 'On Hold') diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js index d982dfe27a31..fdc55f3ab5e6 100644 --- a/erpnext/selling/sales_common.js +++ b/erpnext/selling/sales_common.js @@ -202,8 +202,10 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({ var me = this; var item = frappe.get_doc(cdt, cdn); - if (item.serial_no && item.qty === item.serial_no.split(`\n`).length) { - return; + // check if serial nos entered are as much as qty in row + if (item.serial_no) { + let serial_nos = item.serial_no.split(`\n`).filter(sn => sn.trim()); // filter out whitespaces + if (item.qty === serial_nos.length) return; } if (item.serial_no && !item.batch_no) { diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index f9f70264f6dc..2e78168b8b60 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -388,6 +388,7 @@ def on_trash(self): frappe.db.sql("delete from tabEmployee where company=%s", self.name) frappe.db.sql("delete from tabDepartment where company=%s", self.name) frappe.db.sql("delete from `tabTax Withholding Account` where company=%s", self.name) + frappe.db.sql("delete from `tabTransaction Deletion Record` where company=%s", self.name) # delete tax templates frappe.db.sql("delete from `tabSales Taxes and Charges Template` where company=%s", self.name) diff --git a/erpnext/setup/doctype/uom/uom.json b/erpnext/setup/doctype/uom/uom.json index 3a4e7f6dc4bc..844a11f1397b 100644 --- a/erpnext/setup/doctype/uom/uom.json +++ b/erpnext/setup/doctype/uom/uom.json @@ -1,164 +1,82 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "field:uom_name", - "beta": 0, "creation": "2013-01-10 16:34:24", - "custom": 0, - "docstatus": 0, "doctype": "DocType", "document_type": "Setup", - "editable_grid": 0, + "engine": "InnoDB", + "field_order": [ + "enabled", + "uom_name", + "must_be_whole_number" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "uom_name", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "UOM Name", - "length": 0, - "no_copy": 0, "oldfieldname": "uom_name", "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, "unique": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "description": "Check this to disallow fractions. (for Nos)", "fieldname": "must_be_whole_number", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Must be Whole Number", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Must be Whole Number" + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, "icon": "fa fa-compass", "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-08-29 06:35:56.143361", + "links": [], + "modified": "2021-10-18 14:07:43.722144", "modified_by": "Administrator", "module": "Setup", "name": "UOM", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, "export": 1, - "if_owner": 0, "import": 1, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "Item Manager", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, - "role": "Stock Manager", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "role": "Stock Manager" }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, - "role": "Stock User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "role": "Stock User" } ], "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, "show_name_in_global_search": 1, - "sort_order": "ASC", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + "sort_field": "modified", + "sort_order": "ASC" } \ No newline at end of file diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index b7e895db363a..08fef32d8494 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -2120,6 +2120,10 @@ "account_name": "VAT 15%", "tax_rate": 15.00 }, + "KSA VAT 5%": { + "account_name": "VAT 5%", + "tax_rate": 5.00 + }, "KSA VAT Zero": { "account_name": "VAT Zero", "tax_rate": 0.00 diff --git a/erpnext/setup/setup_wizard/operations/defaults_setup.py b/erpnext/setup/setup_wizard/operations/defaults_setup.py index 3f90f72b608b..92e4985f509a 100644 --- a/erpnext/setup/setup_wizard/operations/defaults_setup.py +++ b/erpnext/setup/setup_wizard/operations/defaults_setup.py @@ -64,6 +64,13 @@ def set_default_settings(args): hr_settings.emp_created_by = "Naming Series" hr_settings.leave_approval_notification_template = _("Leave Approval Notification") hr_settings.leave_status_notification_template = _("Leave Status Notification") + + hr_settings.send_interview_reminder = 1 + hr_settings.interview_reminder_template = _("Interview Reminder") + hr_settings.remind_before = "00:15:00" + + hr_settings.send_interview_feedback_reminder = 1 + hr_settings.feedback_reminder_notification_template = _("Interview Feedback Reminder") hr_settings.save() def set_no_copy_fields_in_variant_settings(): diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index fc89e3ccf1f0..fbfcb102dd27 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -264,16 +264,26 @@ def install(country=None): base_path = frappe.get_app_path("erpnext", "hr", "doctype") response = frappe.read_file(os.path.join(base_path, "leave_application/leave_application_email_template.html")) - records += [{'doctype': 'Email Template', 'name': _("Leave Approval Notification"), 'response': response,\ + records += [{'doctype': 'Email Template', 'name': _("Leave Approval Notification"), 'response': response, 'subject': _("Leave Approval Notification"), 'owner': frappe.session.user}] - records += [{'doctype': 'Email Template', 'name': _("Leave Status Notification"), 'response': response,\ + records += [{'doctype': 'Email Template', 'name': _("Leave Status Notification"), 'response': response, 'subject': _("Leave Status Notification"), 'owner': frappe.session.user}] + response = frappe.read_file(os.path.join(base_path, "interview/interview_reminder_notification_template.html")) + + records += [{'doctype': 'Email Template', 'name': _('Interview Reminder'), 'response': response, + 'subject': _('Interview Reminder'), 'owner': frappe.session.user}] + + response = frappe.read_file(os.path.join(base_path, "interview/interview_feedback_reminder_template.html")) + + records += [{'doctype': 'Email Template', 'name': _('Interview Feedback Reminder'), 'response': response, + 'subject': _('Interview Feedback Reminder'), 'owner': frappe.session.user}] + base_path = frappe.get_app_path("erpnext", "stock", "doctype") response = frappe.read_file(os.path.join(base_path, "delivery_trip/dispatch_notification_template.html")) - records += [{'doctype': 'Email Template', 'name': _("Dispatch Notification"), 'response': response,\ + records += [{'doctype': 'Email Template', 'name': _("Dispatch Notification"), 'response': response, 'subject': _("Your order is out for delivery!"), 'owner': frappe.session.user}] # Records for the Supplier Scorecard @@ -317,6 +327,14 @@ def update_hr_defaults(): hr_settings.emp_created_by = "Naming Series" hr_settings.leave_approval_notification_template = _("Leave Approval Notification") hr_settings.leave_status_notification_template = _("Leave Status Notification") + + hr_settings.send_interview_reminder = 1 + hr_settings.interview_reminder_template = _("Interview Reminder") + hr_settings.remind_before = "00:15:00" + + hr_settings.send_interview_feedback_reminder = 1 + hr_settings.feedback_reminder_notification_template = _("Interview Feedback Reminder") + hr_settings.save() def update_item_variant_settings(): diff --git a/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json index 6ca3d637da40..db171fa9627e 100644 --- a/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json +++ b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json @@ -1,6 +1,7 @@ { "category": "Modules", "charts": [], + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Projects Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Accounts Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Stock Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"HR Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Selling Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Buying Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Support Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Shopping Cart Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Portal Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Domain Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Products Settings\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Naming Series\",\"col\":4}}]", "creation": "2020-03-12 14:47:51.166455", "developer_mode_only": 0, "disable_user_customization": 0, @@ -15,7 +16,7 @@ "is_standard": 1, "label": "ERPNext Settings", "links": [], - "modified": "2021-06-12 01:58:11.399566", + "modified": "2021-10-26 21:32:55.323591", "modified_by": "Administrator", "module": "Setup", "name": "ERPNext Settings", @@ -29,6 +30,14 @@ "link_to": "Projects Settings", "type": "DocType" }, + { + "color": "Grey", + "doc_view": "", + "icon": "dot-horizontal", + "label": "Naming Series", + "link_to": "Naming Series", + "type": "DocType" + }, { "icon": "accounting", "label": "Accounts Settings", diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 79989307efce..0a663c2a188f 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -1,8 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals - -import unittest import frappe from frappe.exceptions import ValidationError @@ -11,9 +8,10 @@ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty from erpnext.stock.get_item_details import get_item_details +from erpnext.tests.utils import ERPNextTestCase -class TestBatch(unittest.TestCase): +class TestBatch(ERPNextTestCase): def test_item_has_batch_enabled(self): self.assertRaises(ValidationError, frappe.get_doc({ "doctype": "Batch", diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 5fbc2d8dee1f..4be0415564d9 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -14,51 +14,6 @@ def before_save(self): self.stock_uom = frappe.get_cached_value('Item', self.item_code, 'stock_uom') self.set_projected_qty() - def update_stock(self, args, allow_negative_stock=False, via_landed_cost_voucher=False): - '''Called from erpnext.stock.utils.update_bin''' - self.update_qty(args) - - if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation": - from erpnext.stock.stock_ledger import update_entries_after, update_qty_in_future_sle - - if not args.get("posting_date"): - args["posting_date"] = nowdate() - - if args.get("is_cancelled") and via_landed_cost_voucher: - return - - # Reposts only current voucher SL Entries - # Updates valuation rate, stock value, stock queue for current transaction - update_entries_after({ - "item_code": self.item_code, - "warehouse": self.warehouse, - "posting_date": args.get("posting_date"), - "posting_time": args.get("posting_time"), - "voucher_type": args.get("voucher_type"), - "voucher_no": args.get("voucher_no"), - "sle_id": args.name, - "creation": args.creation - }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher) - - # update qty in future ale and Validate negative qty - update_qty_in_future_sle(args, allow_negative_stock) - - - def update_qty(self, args): - # update the stock values (for current quantities) - if args.get("voucher_type")=="Stock Reconciliation": - self.actual_qty = args.get("qty_after_transaction") - else: - self.actual_qty = flt(self.actual_qty) + flt(args.get("actual_qty")) - - self.ordered_qty = flt(self.ordered_qty) + flt(args.get("ordered_qty")) - self.reserved_qty = flt(self.reserved_qty) + flt(args.get("reserved_qty")) - self.indented_qty = flt(self.indented_qty) + flt(args.get("indented_qty")) - self.planned_qty = flt(self.planned_qty) + flt(args.get("planned_qty")) - - self.set_projected_qty() - self.db_update() - def set_projected_qty(self): self.projected_qty = (flt(self.actual_qty) + flt(self.ordered_qty) + flt(self.indented_qty) + flt(self.planned_qty) - flt(self.reserved_qty) @@ -143,3 +98,67 @@ def update_reserved_qty_for_sub_contracting(self): def on_doctype_update(): frappe.db.add_index("Bin", ["item_code", "warehouse"]) + + +def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_voucher=False): + '''Called from erpnext.stock.utils.update_bin''' + update_qty(bin_name, args) + + if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation": + from erpnext.stock.stock_ledger import update_entries_after, update_qty_in_future_sle + + if not args.get("posting_date"): + args["posting_date"] = nowdate() + + if args.get("is_cancelled") and via_landed_cost_voucher: + return + + # Reposts only current voucher SL Entries + # Updates valuation rate, stock value, stock queue for current transaction + update_entries_after({ + "item_code": args.get('item_code'), + "warehouse": args.get('warehouse'), + "posting_date": args.get("posting_date"), + "posting_time": args.get("posting_time"), + "voucher_type": args.get("voucher_type"), + "voucher_no": args.get("voucher_no"), + "sle_id": args.get('name'), + "creation": args.get('creation') + }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher) + + # update qty in future sle and Validate negative qty + update_qty_in_future_sle(args, allow_negative_stock) + +def get_bin_details(bin_name): + return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty', + 'reserved_qty', 'indented_qty', 'planned_qty', 'reserved_qty_for_production', + 'reserved_qty_for_sub_contract'], as_dict=1) + +def update_qty(bin_name, args): + bin_details = get_bin_details(bin_name) + + # update the stock values (for current quantities) + if args.get("voucher_type")=="Stock Reconciliation": + actual_qty = args.get('qty_after_transaction') + else: + actual_qty = bin_details.actual_qty + flt(args.get("actual_qty")) + + ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty")) + reserved_qty = flt(bin_details.reserved_qty) + flt(args.get("reserved_qty")) + indented_qty = flt(bin_details.indented_qty) + flt(args.get("indented_qty")) + planned_qty = flt(bin_details.planned_qty) + flt(args.get("planned_qty")) + + + # compute projected qty + projected_qty = (flt(actual_qty) + flt(ordered_qty) + + flt(indented_qty) + flt(planned_qty) - flt(reserved_qty) + - flt(bin_details.reserved_qty_for_production) - flt(bin_details.reserved_qty_for_sub_contract)) + + frappe.db.set_value('Bin', bin_name, { + 'actual_qty': actual_qty, + 'ordered_qty': ordered_qty, + 'reserved_qty': reserved_qty, + 'indented_qty': indented_qty, + 'planned_qty': planned_qty, + 'projected_qty': projected_qty + }) \ No newline at end of file diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 9bf142c4b449..ad1b3b43aee3 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -395,8 +395,7 @@ "fieldtype": "Link", "label": "Billing Address Name", "options": "Address", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "fieldname": "tax_id", @@ -1309,7 +1308,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2021-09-28 13:10:09.761714", + "modified": "2021-10-08 14:29:13.428984", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 7fda94b269d7..f58b586ab20d 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -5,7 +5,6 @@ from __future__ import unicode_literals import json -import unittest import frappe from frappe.utils import cstr, flt, nowdate, nowtime @@ -37,9 +36,10 @@ ) from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse from erpnext.stock.stock_ledger import get_previous_sle +from erpnext.tests.utils import ERPNextTestCase -class TestDeliveryNote(unittest.TestCase): +class TestDeliveryNote(ERPNextTestCase): def test_over_billing_against_dn(self): frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) diff --git a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py index c9081c908f77..c6ff73e633b5 100644 --- a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py @@ -14,11 +14,12 @@ make_expense_claim, notify_customers, ) -from erpnext.tests.utils import create_test_contact_and_address +from erpnext.tests.utils import ERPNextTestCase, create_test_contact_and_address -class TestDeliveryTrip(unittest.TestCase): +class TestDeliveryTrip(ERPNextTestCase): def setUp(self): + super().setUp() driver = create_driver() create_vehicle() create_delivery_notification() @@ -32,6 +33,7 @@ def tearDown(self): frappe.db.sql("delete from `tabVehicle`") frappe.db.sql("delete from `tabEmail Template`") frappe.db.sql("delete from `tabDelivery Trip`") + return super().tearDown() def test_expense_claim_fields_are_fetched_properly(self): expense_claim = make_expense_claim(self.delivery_trip.name) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index f3b69371066b..ba7ace20cbf3 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -159,6 +159,8 @@ def add_price(self, price_list=None): "doctype": "Item Price", "price_list": price_list, "item_code": self.name, + "uom": self.stock_uom, + "brand": self.brand, "currency": erpnext.get_default_currency(), "price_list_rate": self.standard_rate }) @@ -382,9 +384,21 @@ def validate_name_with_item_group(self): _("An Item Group exists with same name, please change the item name or rename the item group")) def update_item_price(self): - frappe.db.sql("""update `tabItem Price` set item_name=%s, - item_description=%s, brand=%s where item_code=%s""", - (self.item_name, self.description, self.brand, self.name)) + frappe.db.sql(""" + UPDATE `tabItem Price` + SET + item_name=%(item_name)s, + item_description=%(item_description)s, + brand=%(brand)s + WHERE item_code=%(item_code)s + """, + dict( + item_name=self.item_name, + item_description=self.description, + brand=self.brand, + item_code=self.name + ) + ) def on_trash(self): frappe.db.sql("""delete from tabBin where item_code=%s""", self.name) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index d4e7e940e3d7..a8aecc4a8982 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals import json -import unittest import frappe from frappe.test_runner import make_test_objects @@ -25,7 +24,7 @@ ) from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.get_item_details import get_item_details -from erpnext.tests.utils import change_settings +from erpnext.tests.utils import ERPNextTestCase, change_settings test_ignore = ["BOM"] test_dependencies = ["Warehouse", "Item Group", "Item Tax Template", "Brand", "Item Attribute"] @@ -53,8 +52,9 @@ def make_item(item_code, properties=None): return item -class TestItem(unittest.TestCase): +class TestItem(ERPNextTestCase): def setUp(self): + super().setUp() frappe.flags.attribute_values = None def get_item(self, idx): diff --git a/erpnext/stock/doctype/item_alternative/item_alternative.py b/erpnext/stock/doctype/item_alternative/item_alternative.py index 6080fb4a5fa6..6f2a389b707d 100644 --- a/erpnext/stock/doctype/item_alternative/item_alternative.py +++ b/erpnext/stock/doctype/item_alternative/item_alternative.py @@ -25,19 +25,29 @@ def validate_alternative_item(self): frappe.throw(_("Alternative item must not be same as item code")) item_meta = frappe.get_meta("Item") - fields = ["is_stock_item", "include_item_in_manufacturing","has_serial_no","has_batch_no"] - item_data = frappe.db.get_values("Item", self.item_code, fields, as_dict=1) - alternative_item_data = frappe.db.get_values("Item", self.alternative_item_code, fields, as_dict=1) + fields = ["is_stock_item", "include_item_in_manufacturing","has_serial_no", "has_batch_no", "allow_alternative_item"] + item_data = frappe.db.get_value("Item", self.item_code, fields, as_dict=1) + alternative_item_data = frappe.db.get_value("Item", self.alternative_item_code, fields, as_dict=1) for field in fields: - if item_data[0].get(field) != alternative_item_data[0].get(field): + if item_data.get(field) != alternative_item_data.get(field): raise_exception, alert = [1, False] if field == "is_stock_item" else [0, True] frappe.msgprint(_("The value of {0} differs between Items {1} and {2}") \ .format(frappe.bold(item_meta.get_label(field)), frappe.bold(self.alternative_item_code), frappe.bold(self.item_code)), - alert=alert, raise_exception=raise_exception) + alert=alert, raise_exception=raise_exception, indicator="Orange") + + alternate_item_check_msg = _("Allow Alternative Item must be checked on Item {}") + + if not item_data.allow_alternative_item: + frappe.throw(alternate_item_check_msg.format(self.item_code)) + if self.two_way and not alternative_item_data.allow_alternative_item: + frappe.throw(alternate_item_check_msg.format(self.item_code)) + + + def validate_duplicate(self): if frappe.db.get_value("Item Alternative", {'item_code': self.item_code, diff --git a/erpnext/stock/doctype/item_alternative/test_item_alternative.py b/erpnext/stock/doctype/item_alternative/test_item_alternative.py index 2be8ef740a47..af6cc472e346 100644 --- a/erpnext/stock/doctype/item_alternative/test_item_alternative.py +++ b/erpnext/stock/doctype/item_alternative/test_item_alternative.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals import json -import unittest import frappe from frappe.utils import flt @@ -21,10 +20,12 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( create_stock_reconciliation, ) +from erpnext.tests.utils import ERPNextTestCase -class TestItemAlternative(unittest.TestCase): +class TestItemAlternative(ERPNextTestCase): def setUp(self): + super().setUp() make_items() def test_alternative_item_for_subcontract_rm(self): diff --git a/erpnext/stock/doctype/item_attribute/test_item_attribute.py b/erpnext/stock/doctype/item_attribute/test_item_attribute.py index fc809f443e69..2cd711bbb194 100644 --- a/erpnext/stock/doctype/item_attribute/test_item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/test_item_attribute.py @@ -3,17 +3,17 @@ from __future__ import unicode_literals -import unittest - import frappe test_records = frappe.get_test_records('Item Attribute') from erpnext.stock.doctype.item_attribute.item_attribute import ItemAttributeIncrementError +from erpnext.tests.utils import ERPNextTestCase -class TestItemAttribute(unittest.TestCase): +class TestItemAttribute(ERPNextTestCase): def setUp(self): + super().setUp() if frappe.db.exists("Item Attribute", "_Test_Length"): frappe.delete_doc("Item Attribute", "_Test_Length") diff --git a/erpnext/stock/doctype/item_price/test_item_price.py b/erpnext/stock/doctype/item_price/test_item_price.py index 5ed80921660a..3a51fbbe17ea 100644 --- a/erpnext/stock/doctype/item_price/test_item_price.py +++ b/erpnext/stock/doctype/item_price/test_item_price.py @@ -3,17 +3,17 @@ from __future__ import unicode_literals -import unittest - import frappe from frappe.test_runner import make_test_records_for_doctype from erpnext.stock.doctype.item_price.item_price import ItemPriceDuplicateItem from erpnext.stock.get_item_details import get_price_list_rate_for, process_args +from erpnext.tests.utils import ERPNextTestCase -class TestItemPrice(unittest.TestCase): +class TestItemPrice(ERPNextTestCase): def setUp(self): + super().setUp() frappe.db.sql("delete from `tabItem Price`") make_test_records_for_doctype("Item Price", force=True) diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 58a72f72dd1f..339eaaaf7a5d 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -4,8 +4,6 @@ from __future__ import unicode_literals -import unittest - import frappe from frappe.utils import flt @@ -16,9 +14,10 @@ get_gl_entries, make_purchase_receipt, ) +from erpnext.tests.utils import ERPNextTestCase -class TestLandedCostVoucher(unittest.TestCase): +class TestLandedCostVoucher(ERPNextTestCase): def test_landed_cost_voucher(self): frappe.db.set_value("Buying Settings", None, "allow_multiple_items", 1) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index cf98b19e7a11..17df9777b19d 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -296,7 +296,7 @@ def select_item(d): return d.ordered_qty < d.stock_qty and child_filter - doclist = get_mapped_doc("Material Request", source_name, { + doclist = get_mapped_doc("Material Request", source_name, { "Material Request": { "doctype": "Purchase Order", "validation": { @@ -323,7 +323,7 @@ def select_item(d): @frappe.whitelist() def make_request_for_quotation(source_name, target_doc=None): - doclist = get_mapped_doc("Material Request", source_name, { + doclist = get_mapped_doc("Material Request", source_name, { "Material Request": { "doctype": "Request for Quotation", "validation": { diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 5c2ac2584f7f..f66a228e35e4 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -6,8 +6,6 @@ from __future__ import unicode_literals -import unittest - import frappe from frappe.utils import flt, today @@ -18,9 +16,10 @@ make_supplier_quotation, raise_work_orders, ) +from erpnext.tests.utils import ERPNextTestCase -class TestMaterialRequest(unittest.TestCase): +class TestMaterialRequest(ERPNextTestCase): def test_make_purchase_order(self): mr = frappe.copy_doc(test_records[0]).insert() diff --git a/erpnext/stock/doctype/packing_slip/test_packing_slip.py b/erpnext/stock/doctype/packing_slip/test_packing_slip.py index 193adfcf1cbb..c70cba67f2b3 100644 --- a/erpnext/stock/doctype/packing_slip/test_packing_slip.py +++ b/erpnext/stock/doctype/packing_slip/test_packing_slip.py @@ -6,6 +6,8 @@ import unittest # test_records = frappe.get_test_records('Packing Slip') +from erpnext.tests.utils import ERPNextTestCase + class TestPackingSlip(unittest.TestCase): pass diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index 21467935370d..c604c711ef51 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -18,7 +18,9 @@ "get_item_locations", "section_break_6", "locations", - "amended_from" + "amended_from", + "print_settings_section", + "group_same_items" ], "fields": [ { @@ -110,14 +112,28 @@ "options": "STO-PICK-.YYYY.-", "reqd": 1, "set_only_once": 1 + }, + { + "fieldname": "print_settings_section", + "fieldtype": "Section Break", + "label": "Print Settings" + }, + { + "allow_on_submit": 1, + "default": "0", + "fieldname": "group_same_items", + "fieldtype": "Check", + "label": "Group Same Items", + "print_hide": 1 } ], "is_submittable": 1, "links": [], - "modified": "2020-03-17 11:38:41.932875", + "modified": "2021-10-05 15:08:40.369957", "modified_by": "Administrator", "module": "Stock", "name": "Pick List", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -184,4 +200,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index dffbe80fa39b..4c02f3db43b2 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -2,10 +2,8 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -from __future__ import unicode_literals - import json -from collections import OrderedDict +from collections import OrderedDict, defaultdict import frappe from frappe import _ @@ -121,6 +119,34 @@ def validate_for_qty(self): and (self.for_qty is None or self.for_qty == 0): frappe.throw(_("Qty of Finished Goods Item should be greater than 0.")) + def before_print(self, settings=None): + if self.get("group_same_items"): + self.group_similar_items() + + def group_similar_items(self): + group_item_qty = defaultdict(float) + group_picked_qty = defaultdict(float) + + for item in self.locations: + group_item_qty[(item.item_code, item.warehouse)] += item.qty + group_picked_qty[(item.item_code, item.warehouse)] += item.picked_qty + + duplicate_list = [] + for item in self.locations: + if (item.item_code, item.warehouse) in group_item_qty: + item.qty = group_item_qty[(item.item_code, item.warehouse)] + item.picked_qty = group_picked_qty[(item.item_code, item.warehouse)] + item.stock_qty = group_item_qty[(item.item_code, item.warehouse)] + del group_item_qty[(item.item_code, item.warehouse)] + else: + duplicate_list.append(item) + + for item in duplicate_list: + self.remove(item) + + for idx, item in enumerate(self.locations, start=1): + item.idx = idx + def validate_item_locations(pick_list): if not pick_list.locations: diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index aa710ad0e976..58b46e1eefcc 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -3,9 +3,8 @@ # See license.txt from __future__ import unicode_literals -import unittest - import frappe +from frappe import _dict test_dependencies = ['Item', 'Sales Invoice', 'Stock Entry', 'Batch'] @@ -15,9 +14,10 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( EmptyStockReconciliationItemsError, ) +from erpnext.tests.utils import ERPNextTestCase -class TestPickList(unittest.TestCase): +class TestPickList(ERPNextTestCase): def test_pick_list_picks_warehouse_for_each_item(self): try: @@ -357,6 +357,39 @@ def test_pick_list_for_items_with_multiple_UOM(self): sales_order.cancel() purchase_receipt.cancel() + def test_pick_list_grouping_before_print(self): + def _compare_dicts(a, b): + "compare dicts but ignore missing keys in `a`" + for key, value in a.items(): + self.assertEqual(b.get(key), value, msg=f"{key} doesn't match") + + # nothing should be grouped + pl = frappe.get_doc(doctype="Pick List", group_same_items=True, locations=[ + _dict(item_code="A", warehouse="X", qty=1, picked_qty=2), + _dict(item_code="B", warehouse="X", qty=1, picked_qty=2), + _dict(item_code="A", warehouse="Y", qty=1, picked_qty=2), + _dict(item_code="B", warehouse="Y", qty=1, picked_qty=2), + ]) + pl.before_print() + self.assertEqual(len(pl.locations), 4) + + # grouping should halve the number of items + pl = frappe.get_doc(doctype="Pick List", group_same_items=True, locations=[ + _dict(item_code="A", warehouse="X", qty=5, picked_qty=1), + _dict(item_code="B", warehouse="Y", qty=4, picked_qty=2), + _dict(item_code="A", warehouse="X", qty=3, picked_qty=2), + _dict(item_code="B", warehouse="Y", qty=2, picked_qty=2), + ]) + pl.before_print() + self.assertEqual(len(pl.locations), 2) + + expected_items = [ + _dict(item_code="A", warehouse="X", qty=8, picked_qty=3), + _dict(item_code="B", warehouse="Y", qty=6, picked_qty=4), + ] + for expected_item, created_item in zip(expected_items, pl.locations): + _compare_dicts(expected_item, created_item) + # def test_pick_list_skips_items_in_expired_batch(self): # pass diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 475ac2f06f06..233ea1f70456 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -362,7 +362,7 @@ def make_item_gl_entries(self, gl_entries, warehouse_account=None): if self.is_return or flt(d.item_tax_amount): loss_account = expenses_included_in_valuation else: - loss_account = self.get_company_default("default_expense_account") + loss_account = self.get_company_default("default_expense_account", ignore_validation=True) or stock_rbnb cost_center = d.cost_center or frappe.get_cached_value("Company", self.company, "cost_center") @@ -803,7 +803,8 @@ def set_missing_values(source, target): "doctype": "Stock Entry Detail", "field_map": { "warehouse": "s_warehouse", - "parent": "reference_purchase_receipt" + "parent": "reference_purchase_receipt", + "batch_no": "batch_no" }, }, }, target_doc, set_missing_values) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index c61304098f4b..f06c12359da3 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -17,9 +17,10 @@ from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction +from erpnext.tests.utils import ERPNextTestCase -class TestPurchaseReceipt(unittest.TestCase): +class TestPurchaseReceipt(ERPNextTestCase): def setUp(self): frappe.db.set_value("Buying Settings", None, "allow_multiple_items", 1) @@ -331,7 +332,21 @@ def test_subcontracting_over_receipt(self): pr1.submit() self.assertRaises(frappe.ValidationError, pr2.submit) - frappe.db.rollback() + + pr1.cancel() + se.cancel() + se1.cancel() + se2.cancel() + se3.cancel() + po.reload() + pr2.load_from_db() + + if pr2.docstatus == 1 and frappe.db.get_value('Stock Ledger Entry', + {'voucher_no': pr2.name, 'is_cancelled': 0}, 'name'): + pr2.cancel() + + po.load_from_db() + po.cancel() def test_serial_no_supplier(self): pr = make_purchase_receipt(item_code="_Test Serialized Item With Series", qty=1) diff --git a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py index 0aa7610575e6..c25bca94dbb8 100644 --- a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py @@ -3,8 +3,6 @@ # See license.txt from __future__ import unicode_literals -import unittest - import frappe from erpnext.stock.doctype.batch.test_batch import make_new_batch @@ -13,9 +11,10 @@ from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.get_item_details import get_conversion_factor +from erpnext.tests.utils import ERPNextTestCase -class TestPutawayRule(unittest.TestCase): +class TestPutawayRule(ERPNextTestCase): def setUp(self): if not frappe.db.exists("Item", "_Rice"): make_item("_Rice", { diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.js b/erpnext/stock/doctype/quality_inspection/quality_inspection.js index d08dc3e8b762..eea28791a9ff 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.js +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.js @@ -59,7 +59,7 @@ frappe.ui.form.on("Quality Inspection", { }, item_code: function(frm) { - if (frm.doc.item_code) { + if (frm.doc.item_code && !frm.doc.quality_inspection_template) { return frm.call({ method: "get_quality_inspection_template", doc: frm.doc, diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 8b2f8da9dfdf..2fa5b72f431f 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -19,6 +19,15 @@ def validate(self): if not self.readings and self.item_code: self.get_item_specification_details() + if self.inspection_type=="In Process" and self.reference_type=="Job Card": + item_qi_template = frappe.db.get_value("Item", self.item_code, 'quality_inspection_template') + parameters = get_template_details(item_qi_template) + for reading in self.readings: + for d in parameters: + if reading.specification == d.specification: + reading.update(d) + reading.status = "Accepted" + if self.readings: self.inspect_and_set_status() diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index f5d076a077a4..308c62875d54 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -1,8 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors # See license.txt -import unittest - import frappe from frappe.utils import nowdate @@ -15,12 +13,14 @@ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.tests.utils import ERPNextTestCase # test_records = frappe.get_test_records('Quality Inspection') -class TestQualityInspection(unittest.TestCase): +class TestQualityInspection(ERPNextTestCase): def setUp(self): + super().setUp() create_item("_Test Item with QA") frappe.db.set_value( "Item", "_Test Item with QA", "inspection_required_before_delivery", 1 diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 82d8aaed5b3b..a9254fb9ecf1 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -611,7 +611,9 @@ def get_pos_reserved_serial_nos(filters): return reserved_sr_nos -def fetch_serial_numbers(filters, qty, do_not_include=[]): +def fetch_serial_numbers(filters, qty, do_not_include=None): + if do_not_include is None: + do_not_include = [] batch_join_selection = "" batch_no_condition = "" batch_nos = filters.get("batch_no") diff --git a/erpnext/stock/doctype/serial_no/test_serial_no.py b/erpnext/stock/doctype/serial_no/test_serial_no.py index 818c163c681c..570f22e6af35 100644 --- a/erpnext/stock/doctype/serial_no/test_serial_no.py +++ b/erpnext/stock/doctype/serial_no/test_serial_no.py @@ -6,8 +6,6 @@ from __future__ import unicode_literals -import unittest - import frappe from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note @@ -20,9 +18,10 @@ test_records = frappe.get_test_records('Serial No') from erpnext.stock.doctype.serial_no.serial_no import * +from erpnext.tests.utils import ERPNextTestCase -class TestSerialNo(unittest.TestCase): +class TestSerialNo(ERPNextTestCase): def test_cannot_create_direct(self): frappe.delete_doc_if_exists("Serial No", "_TCSER0001") @@ -185,14 +184,14 @@ def test_serial_no_sanitation(self): se = frappe.copy_doc(test_records[0]) se.get("items")[0].item_code = item_code - se.get("items")[0].qty = 3 - se.get("items")[0].serial_no = " _TS1, _TS2 , _TS3 " - se.get("items")[0].transfer_qty = 3 + se.get("items")[0].qty = 4 + se.get("items")[0].serial_no = " _TS1, _TS2 , _TS3 , _TS4 - 2021" + se.get("items")[0].transfer_qty = 4 se.set_stock_entry_type() se.insert() se.submit() - self.assertEqual(se.get("items")[0].serial_no, "_TS1\n_TS2\n_TS3") + self.assertEqual(se.get("items")[0].serial_no, "_TS1\n_TS2\n_TS3\n_TS4 - 2021") frappe.db.rollback() diff --git a/erpnext/stock/doctype/shipment/test_shipment.py b/erpnext/stock/doctype/shipment/test_shipment.py index 9914cf80158b..dcd0b7c17c7d 100644 --- a/erpnext/stock/doctype/shipment/test_shipment.py +++ b/erpnext/stock/doctype/shipment/test_shipment.py @@ -3,15 +3,15 @@ # See license.txt from __future__ import unicode_literals -import unittest from datetime import date, timedelta import frappe from erpnext.stock.doctype.delivery_note.delivery_note import make_shipment +from erpnext.tests.utils import ERPNextTestCase -class TestShipment(unittest.TestCase): +class TestShipment(ERPNextTestCase): def test_shipment_from_delivery_note(self): delivery_note = create_test_delivery_note() delivery_note.submit() @@ -47,7 +47,6 @@ def create_test_delivery_note(): } ) delivery_note.insert() - frappe.db.commit() return delivery_note @@ -91,7 +90,6 @@ def create_test_shipment(delivery_notes = None): } ) shipment.insert() - frappe.db.commit() return shipment diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 6d1df8fb599b..fc9d1ed98f74 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -88,7 +88,11 @@ frappe.ui.form.on('Stock Entry', { } } - filters["warehouse"] = item.s_warehouse || item.t_warehouse; + // User could want to select a manually created empty batch (no warehouse) + // or a pre-existing batch + if (frm.doc.purpose != "Material Receipt") { + filters["warehouse"] = item.s_warehouse || item.t_warehouse; + } return { query : "erpnext.controllers.queries.get_batch_no", @@ -323,6 +327,12 @@ frappe.ui.form.on('Stock Entry', { attach_bom_items(frm.doc.bom_no) }, + before_save: function(frm) { + frm.doc.items.forEach((item) => { + item.uom = item.uom || item.stock_uom; + }) + }, + stock_entry_type: function(frm){ frm.remove_custom_button('Bill of Materials', "Get Items From"); frm.events.show_bom_custom_button(frm); diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json index 2463a21ed619..6de40984cd7b 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json @@ -142,6 +142,7 @@ "oldfieldtype": "Data", "print_width": "150px", "read_only": 1, + "search_index": 1, "width": "150px" }, { @@ -316,7 +317,7 @@ "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-09-07 11:10:35.318872", + "modified": "2021-10-08 13:44:51.857631", "modified_by": "Administrator", "module": "Stock", "name": "Stock Ledger Entry", @@ -338,4 +339,4 @@ ], "sort_field": "modified", "sort_order": "DESC" -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index caa1d42b6620..2cf71accf832 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -181,4 +181,4 @@ def on_doctype_update(): frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"]) frappe.db.add_index("Stock Ledger Entry", ["batch_no", "item_code", "warehouse"]) - frappe.db.add_index("Stock Ledger Entry", ["voucher_detail_no"]) + frappe.db.add_index("Stock Ledger Entry", ["warehouse", "item_code"], "item_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 61bae49b0bda..ff33c2789b72 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 @@ -3,8 +3,6 @@ # See license.txt from __future__ import unicode_literals -import unittest - import frappe from frappe.core.page.permission_manager.permission_manager import reset from frappe.utils import add_days, today @@ -21,9 +19,10 @@ create_stock_reconciliation, ) from erpnext.stock.stock_ledger import get_previous_sle +from erpnext.tests.utils import ERPNextTestCase -class TestStockLedgerEntry(unittest.TestCase): +class TestStockLedgerEntry(ERPNextTestCase): def setUp(self): items = create_items() reset('Stock Entry') diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index 99694690bbce..26bfcb116b14 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -56,25 +56,40 @@ frappe.ui.form.on("Stock Reconciliation", { }, get_items: function(frm) { - let fields = [{ - label: 'Warehouse', fieldname: 'warehouse', fieldtype: 'Link', options: 'Warehouse', reqd: 1, - "get_query": function() { - return { - "filters": { - "company": frm.doc.company, - } - }; - } - }, { - label: "Item Code", fieldname: "item_code", fieldtype: "Link", options: "Item", - "get_query": function() { - return { - "filters": { - "disabled": 0, - } - }; + let fields = [ + { + label: 'Warehouse', + fieldname: 'warehouse', + fieldtype: 'Link', + options: 'Warehouse', + reqd: 1, + "get_query": function() { + return { + "filters": { + "company": frm.doc.company, + } + }; + } + }, + { + label: "Item Code", + fieldname: "item_code", + fieldtype: "Link", + options: "Item", + "get_query": function() { + return { + "filters": { + "disabled": 0, + } + }; + } + }, + { + label: __("Ignore Empty Stock"), + fieldname: "ignore_empty_stock", + fieldtype: "Check" } - }]; + ]; frappe.prompt(fields, function(data) { frappe.call({ @@ -84,22 +99,21 @@ frappe.ui.form.on("Stock Reconciliation", { posting_date: frm.doc.posting_date, posting_time: frm.doc.posting_time, company: frm.doc.company, - item_code: data.item_code + item_code: data.item_code, + ignore_empty_stock: data.ignore_empty_stock }, callback: function(r) { + if (r.exc || !r.message || !r.message.length) return; + frm.clear_table("items"); - for (var i=0; i { + let item = frm.add_child("items"); + $.extend(item, row); - if (!d.valuation_rate) { - d.valuation_rate = 0; - } - } + item.qty = item.qty || 0; + item.valuation_rate = item.valuation_rate || 0; + }); frm.refresh_field("items"); } }); diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 6676acf87e09..1769a4e36631 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -490,7 +490,8 @@ def cancel(self): self._cancel() @frappe.whitelist() -def get_items(warehouse, posting_date, posting_time, company, item_code=None): +def get_items(warehouse, posting_date, posting_time, company, item_code=None, ignore_empty_stock=False): + ignore_empty_stock = cint(ignore_empty_stock) items = [frappe._dict({ 'item_code': item_code, 'warehouse': warehouse @@ -504,18 +505,24 @@ def get_items(warehouse, posting_date, posting_time, company, item_code=None): for d in items: if d.item_code in itemwise_batch_data: - stock_bal = get_stock_balance(d.item_code, d.warehouse, - posting_date, posting_time, with_valuation_rate=True) + valuation_rate = get_stock_balance(d.item_code, d.warehouse, + posting_date, posting_time, with_valuation_rate=True)[1] for row in itemwise_batch_data.get(d.item_code): - args = get_item_data(row, row.qty, stock_bal[1]) + if ignore_empty_stock and not row.qty: + continue + + args = get_item_data(row, row.qty, valuation_rate) res.append(args) else: stock_bal = get_stock_balance(d.item_code, d.warehouse, posting_date, posting_time, with_valuation_rate=True , with_serial_no=cint(d.has_serial_no)) + qty, valuation_rate, serial_no = stock_bal[0], stock_bal[1], stock_bal[2] if cint(d.has_serial_no) else '' + + if ignore_empty_stock and not stock_bal[0]: + continue - args = get_item_data(d, stock_bal[0], stock_bal[1], - stock_bal[2] if cint(d.has_serial_no) else '') + args = get_item_data(d, qty, valuation_rate, serial_no) res.append(args) @@ -523,24 +530,44 @@ def get_items(warehouse, posting_date, posting_time, company, item_code=None): def get_items_for_stock_reco(warehouse, company): lft, rgt = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"]) - items = frappe.db.sql(""" - select i.name as item_code, i.item_name, bin.warehouse as warehouse, i.has_serial_no, i.has_batch_no - from tabBin bin, tabItem i - where i.name=bin.item_code and IFNULL(i.disabled, 0) = 0 and i.is_stock_item = 1 - and i.has_variants = 0 and exists( - select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=bin.warehouse - ) - """, (lft, rgt), as_dict=1) + items = frappe.db.sql(f""" + select + i.name as item_code, i.item_name, bin.warehouse as warehouse, i.has_serial_no, i.has_batch_no + from + tabBin bin, tabItem i + where + i.name = bin.item_code + and IFNULL(i.disabled, 0) = 0 + and i.is_stock_item = 1 + and i.has_variants = 0 + and exists( + select name from `tabWarehouse` where lft >= {lft} and rgt <= {rgt} and name = bin.warehouse + ) + """, as_dict=1) items += frappe.db.sql(""" - select i.name as item_code, i.item_name, id.default_warehouse as warehouse, i.has_serial_no, i.has_batch_no - from tabItem i, `tabItem Default` id - where i.name = id.parent - and exists(select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=id.default_warehouse) - and i.is_stock_item = 1 and i.has_variants = 0 and IFNULL(i.disabled, 0) = 0 and id.company=%s + select + i.name as item_code, i.item_name, id.default_warehouse as warehouse, i.has_serial_no, i.has_batch_no + from + tabItem i, `tabItem Default` id + where + i.name = id.parent + and exists( + select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=id.default_warehouse + ) + and i.is_stock_item = 1 + and i.has_variants = 0 + and IFNULL(i.disabled, 0) = 0 + and id.company = %s group by i.name """, (lft, rgt, company), as_dict=1) + # remove duplicates + # check if item-warehouse key extracted from each entry exists in set iw_keys + # and update iw_keys + iw_keys = set() + items = [item for item in items if [(item.item_code, item.warehouse) not in iw_keys, iw_keys.add((item.item_code, item.warehouse))][0]] + return items def get_item_data(row, qty, valuation_rate, serial_no=None): diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 8647bee40ec1..415ac5eb267a 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -6,8 +6,6 @@ from __future__ import unicode_literals -import unittest - import frappe from frappe.utils import add_days, flt, nowdate, nowtime, random_string @@ -22,12 +20,13 @@ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method -from erpnext.tests.utils import change_settings +from erpnext.tests.utils import ERPNextTestCase, change_settings -class TestStockReconciliation(unittest.TestCase): +class TestStockReconciliation(ERPNextTestCase): @classmethod def setUpClass(self): + super().setUpClass() create_batch_or_serial_no_items() frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) @@ -372,7 +371,6 @@ def test_backdated_stock_reco_cancellation_future_negative_stock(self): """ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.stock_ledger import NegativeStockError - frappe.db.commit() item_code = "Backdated-Reco-Cancellation-Item" warehouse = "_Test Warehouse - _TC" @@ -395,10 +393,6 @@ def test_backdated_stock_reco_cancellation_future_negative_stock(self): repost_exists = bool(frappe.db.exists("Repost Item Valuation", {"voucher_no": sr.name})) self.assertFalse(repost_exists, msg="Negative stock validation not working on reco cancellation") - # teardown - frappe.db.rollback() - - def test_valid_batch(self): create_batch_item_with_batch("Testing Batch Item 1", "001") create_batch_item_with_batch("Testing Batch Item 2", "002") diff --git a/erpnext/stock/doctype/stock_settings/test_stock_settings.py b/erpnext/stock/doctype/stock_settings/test_stock_settings.py index 7e8090499fb8..bf8ac5dc79a2 100644 --- a/erpnext/stock/doctype/stock_settings/test_stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/test_stock_settings.py @@ -7,9 +7,12 @@ import frappe +from erpnext.tests.utils import ERPNextTestCase -class TestStockSettings(unittest.TestCase): + +class TestStockSettings(ERPNextTestCase): def setUp(self): + super().setUp() frappe.db.set_value("Stock Settings", None, "clean_description_html", 0) def test_settings(self): diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 1ca7181f2792..98317ec9c575 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -2,8 +2,6 @@ # License: GNU General Public License v3. See license.txt from __future__ import unicode_literals -import unittest - import frappe from frappe.test_runner import make_test_records from frappe.utils import cint @@ -12,11 +10,13 @@ from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.tests.utils import ERPNextTestCase test_records = frappe.get_test_records('Warehouse') -class TestWarehouse(unittest.TestCase): +class TestWarehouse(ERPNextTestCase): def setUp(self): + super().setUp() if not frappe.get_value('Item', '_Test Item'): make_test_records('Item') diff --git a/erpnext/stock/form_tour/material_request/material_request.json b/erpnext/stock/form_tour/material_request/material_request.json new file mode 100644 index 000000000000..145b4a06c2be --- /dev/null +++ b/erpnext/stock/form_tour/material_request/material_request.json @@ -0,0 +1,97 @@ +{ + "creation": "2021-07-29 12:32:08.929900", + "docstatus": 0, + "doctype": "Form Tour", + "idx": 0, + "is_standard": 1, + "modified": "2021-10-05 13:11:13.119453", + "modified_by": "Administrator", + "module": "Stock", + "name": "Material Request", + "owner": "Administrator", + "reference_doctype": "Material Request", + "save_on_complete": 1, + "steps": [ + { + "description": "The purpose of the material request can be selected here. For now select \"Purchase\" as the purpose.", + "field": "", + "fieldname": "material_request_type", + "fieldtype": "Select", + "has_next_condition": 1, + "is_table_field": 0, + "label": "Purpose", + "next_step_condition": "eval: doc.material_request_type == \"Purchase\"", + "parent_field": "", + "position": "Bottom", + "title": "Purpose" + }, + { + "description": "Set the \"Required By\" date for the materials. This sets the \"Required By\" date for all the items.", + "field": "", + "fieldname": "schedule_date", + "fieldtype": "Date", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Required By", + "next_step_condition": "", + "parent_field": "", + "position": "Left", + "title": "Required By" + }, + { + "description": "Setting the target warehouse sets it for all the items.", + "field": "", + "fieldname": "set_warehouse", + "fieldtype": "Link", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Set Target Warehouse", + "next_step_condition": "", + "parent_field": "", + "position": "Left", + "title": "Target Warehouse" + }, + { + "description": "Items table", + "field": "", + "fieldname": "items", + "fieldtype": "Table", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Items", + "parent_field": "", + "position": "Bottom", + "title": "Items" + }, + { + "child_doctype": "Material Request Item", + "description": "Select an Item code. Item details will be fetched automatically.", + "field": "", + "fieldname": "item_code", + "fieldtype": "Link", + "has_next_condition": 1, + "is_table_field": 1, + "label": "Item Code", + "next_step_condition": "eval: doc.item_code", + "parent_field": "", + "parent_fieldname": "items", + "position": "Right", + "title": "Item Code" + }, + { + "child_doctype": "Material Request Item", + "description": "Enter the required quantity for the material.", + "field": "", + "fieldname": "qty", + "fieldtype": "Float", + "has_next_condition": 0, + "is_table_field": 1, + "label": "Quantity", + "parent_field": "", + "parent_fieldname": "items", + "position": "Bottom", + "title": "Quantity" + } + ], + "title": "Material Request" +} \ No newline at end of file diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 19597c3d993c..e0190b64a75c 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -89,7 +89,13 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru out.update(get_bin_details(args.item_code, args.get("from_warehouse"))) elif out.get("warehouse"): - out.update(get_bin_details(args.item_code, out.warehouse, args.company)) + if doc and doc.get('doctype') == 'Purchase Order': + # calculate company_total_stock only for po + bin_details = get_bin_details(args.item_code, out.warehouse, args.company) + else: + bin_details = get_bin_details(args.item_code, out.warehouse) + + out.update(bin_details) # update args with out, if key or value not exists for key, value in iteritems(out): @@ -382,7 +388,7 @@ def get_basic_details(args, item, overwrite_warehouse=True): return out -def get_item_warehouse(item, args, overwrite_warehouse, defaults={}): +def get_item_warehouse(item, args, overwrite_warehouse, defaults=None): if not defaults: defaults = frappe._dict({ 'item_defaults' : get_item_defaults(item.name, args.company), @@ -485,8 +491,9 @@ def get_item_tax_template(args, item, out): "item_tax_template": None } """ - item_tax_template = args.get("item_tax_template") - item_tax_template = _get_item_tax_template(args, item.taxes, out) + item_tax_template = None + if item.taxes: + item_tax_template = _get_item_tax_template(args, item.taxes, out) if not item_tax_template: item_group = item.item_group @@ -502,17 +509,17 @@ def _get_item_tax_template(args, taxes, out=None, for_validate=False): taxes_with_no_validity = [] for tax in taxes: - tax_company = frappe.get_value("Item Tax Template", tax.item_tax_template, 'company') - if (tax.valid_from or tax.maximum_net_rate) and tax_company == args['company']: - # In purchase Invoice first preference will be given to supplier invoice date - # if supplier date is not present then posting date - validation_date = args.get('transaction_date') or args.get('bill_date') or args.get('posting_date') - - if getdate(tax.valid_from) <= getdate(validation_date) \ - and is_within_valid_range(args, tax): - taxes_with_validity.append(tax) - else: - if tax_company == args['company']: + tax_company = frappe.get_cached_value("Item Tax Template", tax.item_tax_template, 'company') + if tax_company == args['company']: + if (tax.valid_from or tax.maximum_net_rate): + # In purchase Invoice first preference will be given to supplier invoice date + # if supplier date is not present then posting date + validation_date = args.get('transaction_date') or args.get('bill_date') or args.get('posting_date') + + if getdate(tax.valid_from) <= getdate(validation_date) \ + and is_within_valid_range(args, tax): + taxes_with_validity.append(tax) + else: taxes_with_no_validity.append(tax) if taxes_with_validity: @@ -890,8 +897,7 @@ def get_pos_profile_item_details(company, args, pos_profile=None, update_data=Fa res[fieldname] = pos_profile.get(fieldname) if res.get("warehouse"): - res.actual_qty = get_bin_details(args.item_code, - res.warehouse).get("actual_qty") + res.actual_qty = get_bin_details(args.item_code, res.warehouse).get("actual_qty") return res diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index 3cd4cd27617b..7c6fbfd9cd14 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import json +from math import ceil import frappe from frappe import _ @@ -149,11 +150,16 @@ def _log_exception(): conversion_factor = frappe.db.get_value("UOM Conversion Detail", {'parent': item.name, 'uom': uom}, 'conversion_factor') or 1.0 + must_be_whole_number = frappe.db.get_value("UOM", uom, "must_be_whole_number", cache=True) + qty = d.reorder_qty / conversion_factor + if must_be_whole_number: + qty = ceil(qty) + mr.append("items", { "doctype": "Material Request Item", "item_code": d.item_code, "schedule_date": add_days(nowdate(),cint(item.lead_time_days)), - "qty": d.reorder_qty / conversion_factor, + "qty": qty, "uom": uom, "stock_uom": item.stock_uom, "warehouse": d.warehouse, diff --git a/erpnext/stock/report/stock_analytics/test_stock_analytics.py b/erpnext/stock/report/stock_analytics/test_stock_analytics.py index 21e1205bfccc..32df58593756 100644 --- a/erpnext/stock/report/stock_analytics/test_stock_analytics.py +++ b/erpnext/stock/report/stock_analytics/test_stock_analytics.py @@ -5,9 +5,10 @@ from erpnext.accounts.utils import get_fiscal_year from erpnext.stock.report.stock_analytics.stock_analytics import get_period_date_ranges +from erpnext.tests.utils import ERPNextTestCase -class TestStockAnalyticsReport(unittest.TestCase): +class TestStockAnalyticsReport(ERPNextTestCase): def test_get_period_date_ranges(self): filters = _dict(range="Monthly", from_date="2020-12-28", to_date="2021-02-06") diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index fc5d5c12da47..bb53c5573710 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -202,7 +202,9 @@ def get_item_warehouse_map(filters, sle): value_diff = flt(d.stock_value_difference) - if d.posting_date < from_date: + if d.posting_date < from_date or (d.posting_date == from_date + and d.voucher_type == "Stock Reconciliation" and + frappe.db.get_value("Stock Reconciliation", d.voucher_no, "purpose") == "Opening Stock"): qty_dict.opening_qty += qty_diff qty_dict.opening_val += value_diff diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 1ea58fed191c..4e20b4726170 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -21,7 +21,7 @@ def execute(filters=None): items = get_items(filters) sl_entries = get_stock_ledger_entries(filters, items) item_details = get_item_details(items, sl_entries, include_uom) - opening_row = get_opening_balance(filters, columns) + opening_row = get_opening_balance(filters, columns, sl_entries) precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) data = [] @@ -218,7 +218,7 @@ def get_sle_conditions(filters): return "and {}".format(" and ".join(conditions)) if conditions else "" -def get_opening_balance(filters, columns): +def get_opening_balance(filters, columns, sl_entries): if not (filters.item_code and filters.warehouse and filters.from_date): return @@ -230,6 +230,15 @@ def get_opening_balance(filters, columns): "posting_time": "00:00:00" }) + # check if any SLEs are actually Opening Stock Reconciliation + for sle in sl_entries: + if (sle.get("voucher_type") == "Stock Reconciliation" + and sle.get("date").split()[0] == filters.from_date + and frappe.db.get_value("Stock Reconciliation", sle.voucher_no, "purpose") == "Opening Stock" + ): + last_entry = sle + sl_entries.remove(sle) + row = { "item_code": _("'Opening'"), "qty_after_transaction": last_entry.get("qty_after_transaction", 0), diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index 1cb0f0d0a498..51c20b02506e 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -161,7 +161,7 @@ def get_ordered_qty(item_code, warehouse): def get_planned_qty(item_code, warehouse): planned_qty = frappe.db.sql(""" select sum(qty - produced_qty) from `tabWork Order` - where production_item = %s and fg_warehouse = %s and status not in ("Stopped", "Completed") + where production_item = %s and fg_warehouse = %s and status not in ("Stopped", "Completed", "Closed") and docstatus=1 and qty > produced_qty""", (item_code, warehouse)) return flt(planned_qty[0][0]) if planned_qty else 0 diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 8e364a5062ec..29f14111e026 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -13,8 +13,8 @@ import erpnext from erpnext.stock.utils import ( - get_bin, get_incoming_outgoing_rate_for_cancel, + get_or_make_bin, get_valuation_method, ) @@ -123,12 +123,11 @@ def set_as_cancel(voucher_type, voucher_no): (now(), frappe.session.user, voucher_type, voucher_no)) def make_entry(args, allow_negative_stock=False, via_landed_cost_voucher=False): - args.update({"doctype": "Stock Ledger Entry"}) + args["doctype"] = "Stock Ledger Entry" sle = frappe.get_doc(args) sle.flags.ignore_permissions = 1 sle.allow_negative_stock=allow_negative_stock sle.via_landed_cost_voucher = via_landed_cost_voucher - sle.insert() sle.submit() return sle @@ -593,7 +592,7 @@ def get_serialized_values(self, sle): if not allow_zero_rate: self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company)) + currency=erpnext.get_company_currency(sle.company), company=sle.company) def get_incoming_value_for_serial_nos(self, sle, serial_nos): # get rate from serial nos within same company @@ -660,7 +659,7 @@ def get_moving_average_values(self, sle): if not allow_zero_valuation_rate: self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company)) + currency=erpnext.get_company_currency(sle.company), company=sle.company) def get_fifo_values(self, sle): incoming_rate = flt(sle.incoming_rate) @@ -693,7 +692,7 @@ def get_fifo_values(self, sle): if not allow_zero_valuation_rate: _rate = get_valuation_rate(sle.item_code, sle.warehouse, sle.voucher_type, sle.voucher_no, self.allow_zero_rate, - currency=erpnext.get_company_currency(sle.company)) + currency=erpnext.get_company_currency(sle.company), company=sle.company) else: _rate = 0 @@ -797,14 +796,13 @@ def raise_exceptions(self): def update_bin(self): # update bin for each warehouse for warehouse, data in iteritems(self.data): - bin_doc = get_bin(self.item_code, warehouse) - bin_doc.update({ + bin_record = get_or_make_bin(self.item_code, warehouse) + + frappe.db.set_value('Bin', bin_record, { "valuation_rate": data.valuation_rate, "actual_qty": data.qty_after_transaction, "stock_value": data.stock_value }) - bin_doc.flags.via_stock_ledger_entry = True - bin_doc.save(ignore_permissions=True) def get_previous_sle_of_current_voucher(args, exclude_current_voucher=False): @@ -905,12 +903,13 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True): - # Get valuation rate from last sle for the same item and warehouse + if not company: - company = erpnext.get_default_company() + company = frappe.get_cached_value("Warehouse", warehouse, "company") + # Get valuation rate from last sle for the same item and warehouse last_valuation_rate = frappe.db.sql("""select valuation_rate - from `tabStock Ledger Entry` + from `tabStock Ledger Entry` force index (item_warehouse) where item_code = %s AND warehouse = %s @@ -921,7 +920,7 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, if not last_valuation_rate: # Get valuation rate from last sle for the item against any warehouse last_valuation_rate = frappe.db.sql("""select valuation_rate - from `tabStock Ledger Entry` + from `tabStock Ledger Entry` force index (item_code) where item_code = %s AND valuation_rate > 0 diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index aeb06e987f8a..e1d5a89082e0 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -101,11 +101,7 @@ def get_stock_balance(item_code, warehouse, posting_date=None, posting_time=None if with_valuation_rate: if with_serial_no: - serial_nos = last_entry.get("serial_no") - - if (serial_nos and - len(get_serial_nos_data(serial_nos)) < last_entry.qty_after_transaction): - serial_nos = get_serial_nos_data_after_transactions(args) + serial_nos = get_serial_nos_data_after_transactions(args) return ((last_entry.qty_after_transaction, last_entry.valuation_rate, serial_nos) if last_entry else (0.0, 0.0, 0.0)) @@ -115,19 +111,32 @@ def get_stock_balance(item_code, warehouse, posting_date=None, posting_time=None return last_entry.qty_after_transaction if last_entry else 0.0 def get_serial_nos_data_after_transactions(args): - serial_nos = [] - data = frappe.db.sql(""" SELECT serial_no, actual_qty - FROM `tabStock Ledger Entry` - WHERE - item_code = %(item_code)s and warehouse = %(warehouse)s - and timestamp(posting_date, posting_time) < timestamp(%(posting_date)s, %(posting_time)s) - order by posting_date, posting_time asc """, args, as_dict=1) - - for d in data: - if d.actual_qty > 0: - serial_nos.extend(get_serial_nos_data(d.serial_no)) + from pypika import CustomFunction + + serial_nos = set() + args = frappe._dict(args) + sle = frappe.qb.DocType('Stock Ledger Entry') + Timestamp = CustomFunction('timestamp', ['date', 'time']) + + stock_ledger_entries = frappe.qb.from_( + sle + ).select( + 'serial_no','actual_qty' + ).where( + (sle.item_code == args.item_code) + & (sle.warehouse == args.warehouse) + & (Timestamp(sle.posting_date, sle.posting_time) < Timestamp(args.posting_date, args.posting_time)) + & (sle.is_cancelled == 0) + ).orderby( + sle.posting_date, sle.posting_time, sle.creation + ).run(as_dict=1) + + for stock_ledger_entry in stock_ledger_entries: + changed_serial_no = get_serial_nos_data(stock_ledger_entry.serial_no) + if stock_ledger_entry.actual_qty > 0: + serial_nos.update(changed_serial_no) else: - serial_nos = list(set(serial_nos) - set(get_serial_nos_data(d.serial_no))) + serial_nos.difference_update(changed_serial_no) return '\n'.join(serial_nos) @@ -180,12 +189,27 @@ def get_bin(item_code, warehouse): bin_obj.flags.ignore_permissions = True return bin_obj +def get_or_make_bin(item_code, warehouse) -> str: + bin_record = frappe.db.get_value('Bin', {'item_code': item_code, 'warehouse': warehouse}) + + if not bin_record: + bin_obj = frappe.get_doc({ + "doctype": "Bin", + "item_code": item_code, + "warehouse": warehouse, + }) + bin_obj.flags.ignore_permissions = 1 + bin_obj.insert() + bin_record = bin_obj.name + + return bin_record + def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False): + from erpnext.stock.doctype.bin.bin import update_stock is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item') if is_stock_item: - bin = get_bin(args.get("item_code"), args.get("warehouse")) - bin.update_stock(args, allow_negative_stock, via_landed_cost_voucher) - return bin + bin_record = get_or_make_bin(args.get("item_code"), args.get("warehouse")) + update_stock(bin_record, args, allow_negative_stock, via_landed_cost_voucher) else: frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code"))) diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index 272e3be26484..91ef68ae001f 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -539,7 +539,7 @@ def get_time_in_timedelta(time): def set_first_response_time(communication, method): if communication.get('reference_doctype') == "Issue": issue = get_parent_doc(communication) - if is_first_response(issue): + if is_first_response(issue) and issue.service_level_agreement: first_response_time = calculate_first_response_time(issue, get_datetime(issue.first_responded_on)) issue.db_set("first_response_time", first_response_time) diff --git a/erpnext/support/doctype/support_settings/support_settings.json b/erpnext/support/doctype/support_settings/support_settings.json index 5d3d3ace59d7..bf1daa16f860 100644 --- a/erpnext/support/doctype/support_settings/support_settings.json +++ b/erpnext/support/doctype/support_settings/support_settings.json @@ -37,7 +37,6 @@ }, { "default": "7", - "description": "Auto close Issue after 7 days", "fieldname": "close_issue_after_days", "fieldtype": "Int", "label": "Close Issue After Days" @@ -164,7 +163,7 @@ ], "issingle": 1, "links": [], - "modified": "2020-06-11 13:08:38.473616", + "modified": "2021-10-14 13:08:38.473616", "modified_by": "Administrator", "module": "Support", "name": "Support Settings", @@ -185,4 +184,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/templates/generators/item/item_configure.html b/erpnext/templates/generators/item/item_configure.html index fcab594402b4..e97a275fbd8e 100644 --- a/erpnext/templates/generators/item/item_configure.html +++ b/erpnext/templates/generators/item/item_configure.html @@ -4,8 +4,8 @@
{% if cart_settings.enable_variants | int %} diff --git a/erpnext/templates/includes/macros.html b/erpnext/templates/includes/macros.html index 889cf1ab38d1..892d62513e22 100644 --- a/erpnext/templates/includes/macros.html +++ b/erpnext/templates/includes/macros.html @@ -339,15 +339,15 @@

{% for attr_value in attribute.item_attribute_values %}
-
{% endfor %} diff --git a/erpnext/templates/includes/order/order_macros.html b/erpnext/templates/includes/order/order_macros.html index 7b3c9a413182..3f2c1f2a1dfc 100644 --- a/erpnext/templates/includes/order/order_macros.html +++ b/erpnext/templates/includes/order/order_macros.html @@ -1,43 +1,49 @@ -{% from "erpnext/templates/includes/macros.html" import product_image_square %} +{% from "erpnext/templates/includes/macros.html" import product_image %} {% macro item_name_and_description(d) %} -
-
-
- {{ product_image_square(d.thumbnail or d.image) }} -
-
-
- {{ d.item_code }} -
+
+
+
+ {% if d.thumbnail or d.image %} + {{ product_image(d.thumbnail or d.image, no_border=True) }} + {% else %} +
+ {{ frappe.utils.get_abbr(d.item_name) or "NA" }} +
+ {% endif %} +
+
+
+ {{ d.item_code }} +
{{ html2text(d.description) | truncate(140) }}
-
-
+
+
{% endmacro %} {% macro item_name_and_description_cart(d) %} -
-
-
- {{ product_image_square(d.thumbnail or d.image) }} -
-
-
- {{ d.item_name|truncate(25) }} +
+
+
+ {{ product_image_square(d.thumbnail or d.image) }} +
+
+
+ {{ d.item_name|truncate(25) }}
- - - - - - - + + + + + + +
-
-
+
+
{% endmacro %} diff --git a/erpnext/templates/pages/order.html b/erpnext/templates/pages/order.html index 19191d890961..a10870db2787 100644 --- a/erpnext/templates/pages/order.html +++ b/erpnext/templates/pages/order.html @@ -12,175 +12,173 @@

{{ doc.name }}

{% endblock %} {% block header_actions %} - - + {% endblock %} {% block page_content %} - -
-
- - {% if doc.doctype == "Quotation" and not doc.docstatus %} - {{ _("Pending") }} - {% else %} - {{ _(doc.get('indicator_title')) or _(doc.status) or _("Submitted") }} +
+
+ + {% if doc.doctype == "Quotation" and not doc.docstatus %} + {{ _("Pending") }} + {% else %} + {{ _(doc.get('indicator_title')) or _(doc.status) or _("Submitted") }} + {% endif %} + +
+
+ {{ frappe.utils.format_date(doc.transaction_date, 'medium') }} + {% if doc.valid_till %} +

+ {{ _("Valid Till") }}: {{ frappe.utils.format_date(doc.valid_till, 'medium') }} +

{% endif %} - -
-
- {{ frappe.utils.format_date(doc.transaction_date, 'medium') }} - {% if doc.valid_till %} -

- {{ _("Valid Till") }}: {{ frappe.utils.format_date(doc.valid_till, 'medium') }} -

- {% endif %} +
-
-

- {%- set party_name = doc.supplier_name if doc.doctype in ['Supplier Quotation', 'Purchase Invoice', 'Purchase Order'] else doc.customer_name %} - {{ party_name }} +

+ {%- set party_name = doc.supplier_name if doc.doctype in ['Supplier Quotation', 'Purchase Invoice', 'Purchase Order'] else doc.customer_name %} + {{ party_name }} - {% if doc.contact_display and doc.contact_display != party_name %} -
- {{ doc.contact_display }} + {% if doc.contact_display and doc.contact_display != party_name %} +
+ {{ doc.contact_display }} + {% endif %} +

+ + {% if doc._header %} + {{ doc._header }} {% endif %} -

- -{% if doc._header %} -{{ doc._header }} -{% endif %} - -
- - - - - - - - - {% for d in doc.items %} - - - - - - {% endfor %} - -
- {{ _("Item") }} - - {{ _("Quantity") }} - - {{ _("Amount") }} -
- {{ item_name_and_description(d) }} - - {{ d.qty }} - {% if d.delivered_qty is defined and d.delivered_qty != None %} -

{{ _("Delivered") }} {{ d.delivered_qty }}

- {% endif %} -
- {{ d.get_formatted("amount") }} -

{{ _("Rate:") }} {{ d.get_formatted("rate") }}

-
- -
- - {% include "erpnext/templates/includes/order/order_taxes.html" %} + +
+ +
+ + + + + + + {% for d in doc.items %} + + + + + + {% endfor %} +
+ {{ _("Item") }} + + {{ _("Quantity") }} + + {{ _("Amount") }} +
+ {{ item_name_and_description(d) }} + + {{ d.qty }} + {% if d.delivered_qty is defined and d.delivered_qty != None %} +

{{ _("Delivered") }} {{ d.delivered_qty }}

+ {% endif %} +
+ {{ d.get_formatted("amount") }} +

{{ _("Rate:") }} {{ d.get_formatted("rate") }}

+
+ +
+ + {% include "erpnext/templates/includes/order/order_taxes.html" %} +
+
-
- -{% if enabled_checkout and ((doc.doctype=="Sales Order" and doc.per_billed <= 0) - or (doc.doctype=="Sales Invoice" and doc.outstanding_amount > 0)) %} -
-
-
-
- Payment + {% if enabled_checkout and ((doc.doctype=="Sales Order" and doc.per_billed <= 0) + or (doc.doctype=="Sales Invoice" and doc.outstanding_amount > 0)) %} +
+
+
+
+ Payment +
+
-
-
-
-
-
-
- {% if available_loyalty_points %} -
-
Enter Loyalty Points
-
-
- +
+
+
+
+ {% if available_loyalty_points %} +
+
Enter Loyalty Points
+
+
+ +
+

Available Points: {{ available_loyalty_points }}

+
+
+ {% endif %} +
+ +
+
+ -

Available Points: {{ available_loyalty_points }}

-
- {% endif %} -
- +
-
-
-
-{% endif %} + {% endif %} -{% if attachments %} -
-
-
- {{ _("Attachments") }} + {% if attachments %} +
+
+
+ {{ _("Attachments") }} +
+
+
+
+ {% for attachment in attachments %} +

+ {{ attachment.file_name }} +

+ {% endfor %} +
+
+ {% endif %}
-
-
- {% for attachment in attachments %} -

- {{ attachment.file_name }} -

- {% endfor %} + + {% if doc.terms %} +
+

{{ doc.terms }}

-
-
-{% endif %} -
-{% if doc.terms %} -
-

{{ doc.terms }}

-
-{% endif %} + {% endif %} {% endblock %} {% block script %} diff --git a/erpnext/tests/test_woocommerce.py b/erpnext/tests/test_woocommerce.py index 881f286baebb..3ce68d89bcd9 100644 --- a/erpnext/tests/test_woocommerce.py +++ b/erpnext/tests/test_woocommerce.py @@ -12,12 +12,6 @@ class TestWoocommerce(unittest.TestCase): def setUp(self): - if not frappe.db.exists('Company', 'Woocommerce'): - company = frappe.new_doc("Company") - company.company_name = "Woocommerce" - company.abbr = "W" - company.default_currency = "INR" - company.save() woo_settings = frappe.get_doc("Woocommerce Settings") if not woo_settings.secret: @@ -26,14 +20,14 @@ def setUp(self): woo_settings.api_consumer_key = "ck_fd43ff5756a6abafd95fadb6677100ce95a758a1" woo_settings.api_consumer_secret = "cs_94360a1ad7bef7fa420a40cf284f7b3e0788454e" woo_settings.enable_sync = 1 - woo_settings.company = "Woocommerce" - woo_settings.tax_account = "Sales Expenses - W" - woo_settings.f_n_f_account = "Expenses - W" + woo_settings.company = "_Test Company" + woo_settings.tax_account = "Sales Expenses - _TC" + woo_settings.f_n_f_account = "Expenses - _TC" woo_settings.creation_user = "Administrator" woo_settings.save(ignore_permissions=True) def test_sales_order_for_woocommerce(self): - frappe.flags.woocomm_test_order_data = {"id":75,"parent_id":0,"number":"74","order_key":"wc_order_5aa1281c2dacb","created_via":"checkout","version":"3.3.3","status":"processing","currency":"INR","date_created":"2018-03-08T12:10:04","date_created_gmt":"2018-03-08T12:10:04","date_modified":"2018-03-08T12:10:04","date_modified_gmt":"2018-03-08T12:10:04","discount_total":"0.00","discount_tax":"0.00","shipping_total":"150.00","shipping_tax":"0.00","cart_tax":"0.00","total":"649.00","total_tax":"0.00","prices_include_tax":False,"customer_id":12,"customer_ip_address":"103.54.99.5","customer_user_agent":"mozilla\\/5.0 (x11; linux x86_64) applewebkit\\/537.36 (khtml, like gecko) chrome\\/64.0.3282.186 safari\\/537.36","customer_note":"","billing":{"first_name":"Tony","last_name":"Stark","company":"Woocommerce","address_1":"Mumbai","address_2":"","city":"Dadar","state":"MH","postcode":"123","country":"IN","email":"tony@gmail.com","phone":"123457890"},"shipping":{"first_name":"Tony","last_name":"Stark","company":"","address_1":"Mumbai","address_2":"","city":"Dadar","state":"MH","postcode":"123","country":"IN"},"payment_method":"cod","payment_method_title":"Cash on delivery","transaction_id":"","date_paid":"","date_paid_gmt":"","date_completed":"","date_completed_gmt":"","cart_hash":"8e76b020d5790066496f244860c4703f","meta_data":[],"line_items":[{"id":80,"name":"Marvel","product_id":56,"variation_id":0,"quantity":1,"tax_class":"","subtotal":"499.00","subtotal_tax":"0.00","total":"499.00","total_tax":"0.00","taxes":[],"meta_data":[],"sku":"","price":499}],"tax_lines":[],"shipping_lines":[{"id":81,"method_title":"Flat rate","method_id":"flat_rate:1","total":"150.00","total_tax":"0.00","taxes":[],"meta_data":[{"id":623,"key":"Items","value":"Marvel × 1"}]}],"fee_lines":[],"coupon_lines":[],"refunds":[]} + frappe.flags.woocomm_test_order_data = {"id":75,"parent_id":0,"number":"74","order_key":"wc_order_5aa1281c2dacb","created_via":"checkout","version":"3.3.3","status":"processing","currency":"INR","date_created":"2018-03-08T12:10:04","date_created_gmt":"2018-03-08T12:10:04","date_modified":"2018-03-08T12:10:04","date_modified_gmt":"2018-03-08T12:10:04","discount_total":"0.00","discount_tax":"0.00","shipping_total":"150.00","shipping_tax":"0.00","cart_tax":"0.00","total":"649.00","total_tax":"0.00","prices_include_tax":False,"customer_id":12,"customer_ip_address":"103.54.99.5","customer_user_agent":"mozilla\\/5.0 (x11; linux x86_64) applewebkit\\/537.36 (khtml, like gecko) chrome\\/64.0.3282.186 safari\\/537.36","customer_note":"","billing":{"first_name":"Tony","last_name":"Stark","company":"_Test Company","address_1":"Mumbai","address_2":"","city":"Dadar","state":"MH","postcode":"123","country":"IN","email":"tony@gmail.com","phone":"123457890"},"shipping":{"first_name":"Tony","last_name":"Stark","company":"","address_1":"Mumbai","address_2":"","city":"Dadar","state":"MH","postcode":"123","country":"IN"},"payment_method":"cod","payment_method_title":"Cash on delivery","transaction_id":"","date_paid":"","date_paid_gmt":"","date_completed":"","date_completed_gmt":"","cart_hash":"8e76b020d5790066496f244860c4703f","meta_data":[],"line_items":[{"id":80,"name":"Marvel","product_id":56,"variation_id":0,"quantity":1,"tax_class":"","subtotal":"499.00","subtotal_tax":"0.00","total":"499.00","total_tax":"0.00","taxes":[],"meta_data":[],"sku":"","price":499}],"tax_lines":[],"shipping_lines":[{"id":81,"method_title":"Flat rate","method_id":"flat_rate:1","total":"150.00","total_tax":"0.00","taxes":[],"meta_data":[{"id":623,"key":"Items","value":"Marvel × 1"}]}],"fee_lines":[],"coupon_lines":[],"refunds":[]} order() self.assertTrue(frappe.get_value("Customer",{"woocommerce_email":"tony@gmail.com"})) diff --git a/erpnext/tests/utils.py b/erpnext/tests/utils.py index a3cab4b59da7..91df5480e35e 100644 --- a/erpnext/tests/utils.py +++ b/erpnext/tests/utils.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt import copy +import unittest from contextlib import contextmanager from typing import Any, Dict, NewType, Optional @@ -12,6 +13,21 @@ ReportName = NewType("ReportName", str) +class ERPNextTestCase(unittest.TestCase): + """A sane default test class for ERPNext tests.""" + + + @classmethod + def setUpClass(cls) -> None: + frappe.db.commit() + return super().setUpClass() + + @classmethod + def tearDownClass(cls) -> None: + frappe.db.rollback() + return super().tearDownClass() + + def create_test_contact_and_address(): frappe.db.sql('delete from tabContact') frappe.db.sql('delete from `tabContact Email`')