Merge pull request #30721 from ruthra-kumar/bug_so_analysis_report
fix: SO's without delivery note will also be fetched
diff --git a/.flake8 b/.flake8
index 4ff8840..4b852ab 100644
--- a/.flake8
+++ b/.flake8
@@ -31,6 +31,7 @@
E124, # closing bracket, irritating while writing QB code
E131, # continuation line unaligned for hanging indent
E123, # closing bracket does not match indentation of opening bracket's line
+ E101, # ensured by use of black
max-line-length = 200
exclude=.github/helper/semgrep_rules
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index e9cb6cf..3bc22af 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -26,3 +26,6 @@
# bulk format python code with black
494bd9ef78313436f0424b918f200dab8fc7c20b
+
+# bulk format python code with black
+baec607ff5905b1c67531096a9cf50ec7ff00a5d
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..532485f
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,25 @@
+name: Generate Semantic Release
+on:
+ push:
+ branches:
+ - version-13
+jobs:
+ release:
+ name: Release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Entire Repository
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ - name: Setup Node.js v14
+ uses: actions/setup-node@v2
+ with:
+ node-version: 14
+ - name: Setup dependencies
+ run: |
+ npm install @semantic-release/git @semantic-release/exec --no-save
+ - name: Create Release
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: npx semantic-release
\ No newline at end of file
diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml
index 69be765..cdb6849 100644
--- a/.github/workflows/server-tests-mariadb.yml
+++ b/.github/workflows/server-tests-mariadb.yml
@@ -119,9 +119,25 @@
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
- name: Upload coverage data
+ uses: actions/upload-artifact@v3
+ with:
+ name: coverage-${{ matrix.container }}
+ path: /home/runner/frappe-bench/sites/coverage.xml
+
+ coverage:
+ name: Coverage Wrap Up
+ needs: test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Clone
+ uses: actions/checkout@v2
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v3
+
+ - name: Upload coverage data
uses: codecov/codecov-action@v2
with:
name: MariaDB
fail_ci_if_error: true
- files: /home/runner/frappe-bench/sites/coverage.xml
verbose: true
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
deleted file mode 100644
index ab6a53b..0000000
--- a/.github/workflows/ui-tests.yml
+++ /dev/null
@@ -1,117 +0,0 @@
-name: UI
-
-on:
- pull_request:
- paths-ignore:
- - '**.md'
- workflow_dispatch:
-
-concurrency:
- group: ui-develop-${{ github.event.number }}
- cancel-in-progress: true
-
-jobs:
- test:
- runs-on: ubuntu-latest
- timeout-minutes: 60
-
- strategy:
- fail-fast: false
-
- name: UI Tests (Cypress)
-
- services:
- mysql:
- image: mariadb:10.3
- env:
- MYSQL_ALLOW_EMPTY_PASSWORD: YES
- ports:
- - 3306:3306
- options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
-
- steps:
- - name: Clone
- uses: actions/checkout@v2
-
- - name: Setup Python
- uses: actions/setup-python@v2
- with:
- python-version: 3.8
-
- - uses: actions/setup-node@v2
- with:
- node-version: 14
- check-latest: true
-
- - name: Add to Hosts
- run: |
- echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
-
- - name: Cache pip
- uses: actions/cache@v2
- with:
- path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
- restore-keys: |
- ${{ runner.os }}-pip-
- ${{ runner.os }}-
-
- - name: Cache node modules
- uses: actions/cache@v2
- env:
- cache-name: cache-node-modules
- with:
- path: ~/.npm
- key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
- restore-keys: |
- ${{ runner.os }}-build-${{ env.cache-name }}-
- ${{ runner.os }}-build-
- ${{ runner.os }}-
-
- - name: Get yarn cache directory path
- id: yarn-cache-dir-path
- run: echo "::set-output name=dir::$(yarn cache dir)"
-
- - uses: actions/cache@v2
- id: yarn-cache
- with:
- path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
- key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- restore-keys: |
- ${{ runner.os }}-yarn-
-
- - name: Cache cypress binary
- uses: actions/cache@v2
- with:
- path: ~/.cache
- key: ${{ runner.os }}-cypress-
- restore-keys: |
- ${{ runner.os }}-cypress-
- ${{ runner.os }}-
-
- - name: Install
- run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
- env:
- DB: mariadb
- TYPE: ui
-
- - name: Site Setup
- run: cd ~/frappe-bench/ && bench --site test_site execute erpnext.setup.utils.before_tests
-
- - name: cypress pre-requisites
- run: cd ~/frappe-bench/apps/frappe && yarn add cypress-file-upload@^5 @testing-library/cypress@^8 --no-lockfile
-
-
- - 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
- env:
- CYPRESS_RECORD_KEY: 60a8e3bf-08f5-45b1-9269-2b207d7d30cd
-
- - name: Show bench console if tests failed
- if: ${{ failure() }}
- run: cat ~/frappe-bench/bench_run_logs.txt
diff --git a/.mergify.yml b/.mergify.yml
index 315d90f..cc8c080 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -88,3 +88,37 @@
- version-12-pre-release
assignees:
- "{{ author }}"
+
+ - name: Automatic merge on CI success and review
+ conditions:
+ - status-success=linters
+ - status-success=Sider
+ - status-success=Semantic Pull Request
+ - status-success=Patch Test
+ - status-success=Python Unit Tests (1)
+ - status-success=Python Unit Tests (2)
+ - status-success=Python Unit Tests (3)
+ - label!=dont-merge
+ - label!=squash
+ - "#approved-reviews-by>=1"
+ actions:
+ merge:
+ method: merge
+ - name: Automatic squash on CI success and review
+ conditions:
+ - status-success=linters
+ - status-success=Sider
+ - status-success=Patch Test
+ - status-success=Python Unit Tests (1)
+ - status-success=Python Unit Tests (2)
+ - status-success=Python Unit Tests (3)
+ - label!=dont-merge
+ - label=squash
+ - "#approved-reviews-by>=1"
+ actions:
+ merge:
+ method: squash
+ commit_message_template: |
+ {{ title }} (#{{ number }})
+
+ {{ body }}
diff --git a/.releaserc b/.releaserc
new file mode 100644
index 0000000..8a758ed
--- /dev/null
+++ b/.releaserc
@@ -0,0 +1,24 @@
+{
+ "branches": ["version-13"],
+ "plugins": [
+ "@semantic-release/commit-analyzer", {
+ "preset": "angular",
+ "releaseRules": [
+ {"breaking": true, "release": false}
+ ]
+ },
+ "@semantic-release/release-notes-generator",
+ [
+ "@semantic-release/exec", {
+ "prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" erpnext/__init__.py'
+ }
+ ],
+ [
+ "@semantic-release/git", {
+ "assets": ["erpnext/__init__.py"],
+ "message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}"
+ }
+ ],
+ "@semantic-release/github"
+ ]
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 9609353..c26660c 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@
</p>
[](https://github.com/frappe/erpnext/actions/workflows/server-tests.yml)
+[](https://github.com/erpnext/erpnext_ui_tests/actions/workflows/ui-tests.yml)
[](https://www.codetriage.com/frappe/erpnext)
[](https://codecov.io/gh/frappe/erpnext)
[](https://hub.docker.com/r/frappe/erpnext-worker)
diff --git a/codecov.yml b/codecov.yml
index 1fa602a..7d9c37d 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -21,7 +21,6 @@
comment:
layout: "diff, files"
require_changes: true
- after_n_builds: 3
ignore:
- "erpnext/demo"
diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py
index 0611f88..a8776fa 100644
--- a/erpnext/accounts/deferred_revenue.py
+++ b/erpnext/accounts/deferred_revenue.py
@@ -386,7 +386,6 @@
doc,
credit_account,
debit_account,
- against,
amount,
base_amount,
end_date,
@@ -570,7 +569,6 @@
doc,
credit_account,
debit_account,
- against,
amount,
base_amount,
posting_date,
@@ -591,6 +589,7 @@
journal_entry.voucher_type = (
"Deferred Revenue" if doc.doctype == "Sales Invoice" else "Deferred Expense"
)
+ journal_entry.process_deferred_accounting = deferred_process
debit_entry = {
"account": credit_account,
diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py
index 150f68b..c71ea36 100644
--- a/erpnext/accounts/doctype/account/account.py
+++ b/erpnext/accounts/doctype/account/account.py
@@ -204,7 +204,9 @@
if not self.account_currency:
self.account_currency = frappe.get_cached_value("Company", self.company, "default_currency")
- elif self.account_currency != frappe.db.get_value("Account", self.name, "account_currency"):
+ gl_currency = frappe.db.get_value("GL Entry", {"account": self.name}, "account_currency")
+
+ if gl_currency and self.account_currency != gl_currency:
if frappe.db.get_value("GL Entry", {"account": self.name}):
frappe.throw(_("Currency can not be changed after making entries using some other currency"))
diff --git a/erpnext/accounts/doctype/account/account_tree.js b/erpnext/accounts/doctype/account/account_tree.js
index a3ef384..8ae90ce 100644
--- a/erpnext/accounts/doctype/account/account_tree.js
+++ b/erpnext/accounts/doctype/account/account_tree.js
@@ -160,7 +160,7 @@
let root_company = treeview.page.fields_dict.root_company.get_value();
if(root_company) {
- frappe.throw(__("Please add the account to root level Company - ") + root_company);
+ frappe.throw(__("Please add the account to root level Company - {0}"), [root_company]);
} else {
treeview.new_node();
}
diff --git a/erpnext/accounts/doctype/account/test_account.py b/erpnext/accounts/doctype/account/test_account.py
index efc063d..f9c9173 100644
--- a/erpnext/accounts/doctype/account/test_account.py
+++ b/erpnext/accounts/doctype/account/test_account.py
@@ -241,6 +241,28 @@
for doc in to_delete:
frappe.delete_doc("Account", doc)
+ def test_validate_account_currency(self):
+ from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
+
+ if not frappe.db.get_value("Account", "Test Currency Account - _TC"):
+ acc = frappe.new_doc("Account")
+ acc.account_name = "Test Currency Account"
+ acc.parent_account = "Tax Assets - _TC"
+ acc.company = "_Test Company"
+ acc.insert()
+ else:
+ acc = frappe.get_doc("Account", "Test Currency Account - _TC")
+
+ self.assertEqual(acc.account_currency, "INR")
+
+ # Make a JV against this account
+ make_journal_entry(
+ "Test Currency Account - _TC", "Miscellaneous Expenses - _TC", 100, submit=True
+ )
+
+ acc.account_currency = "USD"
+ self.assertRaises(frappe.ValidationError, acc.save)
+
def _make_test_records(verbose=None):
from frappe.test_runner import make_test_objects
diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
index 897151a..4453783 100644
--- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
+++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py
@@ -205,10 +205,16 @@
return frappe.get_hooks("accounting_dimension_doctypes")
-def get_accounting_dimensions(as_list=True):
+def get_accounting_dimensions(as_list=True, filters=None):
+
+ if not filters:
+ filters = {"disabled": 0}
+
if frappe.flags.accounting_dimensions is None:
frappe.flags.accounting_dimensions = frappe.get_all(
- "Accounting Dimension", fields=["label", "fieldname", "disabled", "document_type"]
+ "Accounting Dimension",
+ fields=["label", "fieldname", "disabled", "document_type"],
+ filters=filters,
)
if as_list:
diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js
index 990d6d9..a964965 100644
--- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js
+++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js
@@ -200,7 +200,7 @@
})
.then((result) => {
if (result.length > 0) {
- frm.add_custom_button("Report Error", () => {
+ frm.add_custom_button(__("Report Error"), () => {
let fake_xhr = {
responseText: JSON.stringify({
exc: result[0].error,
diff --git a/erpnext/accounts/doctype/cash_flow_mapping/cash_flow_mapping.py b/erpnext/accounts/doctype/cash_flow_mapping/cash_flow_mapping.py
index 3bce4d5..402469f 100644
--- a/erpnext/accounts/doctype/cash_flow_mapping/cash_flow_mapping.py
+++ b/erpnext/accounts/doctype/cash_flow_mapping/cash_flow_mapping.py
@@ -3,6 +3,7 @@
import frappe
+from frappe import _
from frappe.model.document import Document
@@ -16,6 +17,6 @@
]
if len(checked_fields) > 1:
frappe.throw(
- frappe._("You can only select a maximum of one option from the list of check boxes."),
- title="Error",
+ _("You can only select a maximum of one option from the list of check boxes."),
+ title=_("Error"),
)
diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
index 04a8e8e..d618c5c 100644
--- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
+++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py
@@ -11,6 +11,8 @@
class CurrencyExchangeSettings(Document):
def validate(self):
self.set_parameters_and_result()
+ if frappe.flags.in_test or frappe.flags.in_install or frappe.flags.in_setup_wizard:
+ return
response, value = self.validate_parameters()
self.validate_result(response, value)
@@ -35,9 +37,6 @@
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
def validate_parameters(self):
- if frappe.flags.in_test:
- return None, None
-
params = {}
for row in self.req_params:
params[row.key] = row.value.format(
@@ -59,18 +58,14 @@
return response, value
def validate_result(self, response, value):
- if frappe.flags.in_test:
- return
-
try:
for key in self.result_key:
value = value[
str(key.key).format(transaction_date=nowdate(), to_currency="INR", from_currency="USD")
]
except Exception:
- frappe.throw("Invalid result key. Response: " + response.text)
+ frappe.throw(_("Invalid result key. Response:") + " " + response.text)
if not isinstance(value, (int, float)):
frappe.throw(_("Returned exchange rate is neither integer not float."))
self.url = response.url
- frappe.msgprint("Exchange rate of USD to INR is " + str(value))
diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.js b/erpnext/accounts/doctype/gl_entry/gl_entry.js
index 491cf4d..4d2a513 100644
--- a/erpnext/accounts/doctype/gl_entry/gl_entry.js
+++ b/erpnext/accounts/doctype/gl_entry/gl_entry.js
@@ -3,6 +3,6 @@
frappe.ui.form.on('GL Entry', {
refresh: function(frm) {
-
+ frm.page.btn_secondary.hide()
}
});
diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py
index aee7f0e..e5fa57d 100644
--- a/erpnext/accounts/doctype/gl_entry/gl_entry.py
+++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py
@@ -269,6 +269,11 @@
if not self.fiscal_year:
self.fiscal_year = get_fiscal_year(self.posting_date, company=self.company)[0]
+ def on_cancel(self):
+ msg = _("Individual GL Entry cannot be cancelled.")
+ msg += "<br>" + _("Please cancel related transaction.")
+ frappe.throw(msg)
+
def validate_balance_type(account, adv_adj=False):
if not adv_adj and account:
diff --git a/erpnext/accounts/doctype/gst_account/gst_account.json b/erpnext/accounts/doctype/gst_account/gst_account.json
index b6ec884..be5124c 100644
--- a/erpnext/accounts/doctype/gst_account/gst_account.json
+++ b/erpnext/accounts/doctype/gst_account/gst_account.json
@@ -10,6 +10,7 @@
"sgst_account",
"igst_account",
"cess_account",
+ "utgst_account",
"is_reverse_charge_account"
],
"fields": [
@@ -64,12 +65,18 @@
"fieldtype": "Check",
"in_list_view": 1,
"label": "Is Reverse Charge Account"
+ },
+ {
+ "fieldname": "utgst_account",
+ "fieldtype": "Link",
+ "label": "UTGST Account",
+ "options": "Account"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-04-09 12:30:25.889993",
+ "modified": "2022-04-07 12:59:14.039768",
"modified_by": "Administrator",
"module": "Accounts",
"name": "GST Account",
@@ -78,5 +85,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.json b/erpnext/accounts/doctype/journal_entry/journal_entry.json
index 335fd35..4493c72 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.json
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.json
@@ -3,7 +3,7 @@
"allow_auto_repeat": 1,
"allow_import": 1,
"autoname": "naming_series:",
- "creation": "2013-03-25 10:53:52",
+ "creation": "2022-01-25 10:29:58.717206",
"doctype": "DocType",
"document_type": "Document",
"engine": "InnoDB",
@@ -13,6 +13,7 @@
"voucher_type",
"naming_series",
"finance_book",
+ "process_deferred_accounting",
"reversal_of",
"tax_withholding_category",
"column_break1",
@@ -524,13 +525,20 @@
"label": "Reversal Of",
"options": "Journal Entry",
"read_only": 1
+ },
+ {
+ "fieldname": "process_deferred_accounting",
+ "fieldtype": "Link",
+ "label": "Process Deferred Accounting",
+ "options": "Process Deferred Accounting",
+ "read_only": 1
}
],
"icon": "fa fa-file-text",
"idx": 176,
"is_submittable": 1,
"links": [],
- "modified": "2022-01-04 13:39:36.485954",
+ "modified": "2022-04-06 17:18:46.865259",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",
@@ -578,6 +586,7 @@
"search_fields": "voucher_type,posting_date, due_date, cheque_no",
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "title",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index 920db5b..d28c3a8 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -18,7 +18,6 @@
)
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
- check_if_stock_and_account_balance_synced,
get_account_currency,
get_balance_on,
get_stock_accounts,
@@ -88,9 +87,6 @@
self.update_inter_company_jv()
self.update_invoice_discounting()
self.update_status_for_full_and_final_statement()
- check_if_stock_and_account_balance_synced(
- self.posting_date, self.company, self.doctype, self.name
- )
def on_cancel(self):
from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries
diff --git a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py
index d037302..ed35d1e 100644
--- a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py
+++ b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.py
@@ -42,12 +42,7 @@
pos_profiles = list(map(lambda x: x[0], pos_profiles))
if pos_profiles:
- message = (
- "POS Profile "
- + frappe.bold(", ".join(pos_profiles))
- + " contains \
- Mode of Payment "
- + frappe.bold(str(self.name))
- + ". Please remove them to disable this mode."
- )
- frappe.throw(_(message), title="Not Allowed")
+ message = _(
+ "POS Profile {} contains Mode of Payment {}. Please remove them to disable this mode."
+ ).format(frappe.bold(", ".join(pos_profiles)), frappe.bold(str(self.name)))
+ frappe.throw(message, title=_("Not Allowed"))
diff --git a/erpnext/accounts/doctype/party_account/party_account.json b/erpnext/accounts/doctype/party_account/party_account.json
index c9f15a6..6933057 100644
--- a/erpnext/accounts/doctype/party_account/party_account.json
+++ b/erpnext/accounts/doctype/party_account/party_account.json
@@ -3,6 +3,7 @@
"creation": "2014-08-29 16:02:39.740505",
"doctype": "DocType",
"editable_grid": 1,
+ "engine": "InnoDB",
"field_order": [
"company",
"account"
@@ -11,6 +12,7 @@
{
"fieldname": "company",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Company",
"options": "Company",
@@ -27,7 +29,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-04-07 18:13:08.833822",
+ "modified": "2022-04-04 12:31:02.994197",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Party Account",
@@ -35,5 +37,6 @@
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index b2b818a..403e2bd 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -224,10 +224,7 @@
(frm.doc.total_allocated_amount > party_amount)));
frm.toggle_display("set_exchange_gain_loss",
- (frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount &&
- ((frm.doc.paid_from_account_currency != company_currency ||
- frm.doc.paid_to_account_currency != company_currency) &&
- frm.doc.paid_from_account_currency != frm.doc.paid_to_account_currency)));
+ frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount);
frm.refresh_fields();
},
@@ -532,7 +529,8 @@
to_currency: to_currency
},
callback: function(r, rt) {
- frm.set_value(exchange_rate_field, r.message);
+ const ex_rate = flt(r.message, frm.get_field(exchange_rate_field).get_precision());
+ frm.set_value(exchange_rate_field, ex_rate);
}
})
},
diff --git a/erpnext/accounts/doctype/payment_order/payment_order.js b/erpnext/accounts/doctype/payment_order/payment_order.js
index 9074def..7d85d89 100644
--- a/erpnext/accounts/doctype/payment_order/payment_order.js
+++ b/erpnext/accounts/doctype/payment_order/payment_order.js
@@ -12,7 +12,6 @@
});
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.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index 907b769..b596df9 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -350,9 +350,13 @@
)
if self.minimum_invoice_amount:
- condition += " and `{0}` >= {1}".format(dr_or_cr, flt(self.minimum_invoice_amount))
+ condition += " and {dr_or_cr} >= {amount}".format(
+ dr_or_cr=dr_or_cr, amount=flt(self.minimum_invoice_amount)
+ )
if self.maximum_invoice_amount:
- condition += " and `{0}` <= {1}".format(dr_or_cr, flt(self.maximum_invoice_amount))
+ condition += " and {dr_or_cr} <= {amount}".format(
+ dr_or_cr=dr_or_cr, amount=flt(self.maximum_invoice_amount)
+ )
elif get_return_invoices:
condition = " and doc.company = '{0}' ".format(self.company)
@@ -367,15 +371,19 @@
else ""
)
dr_or_cr = (
- "gl.debit_in_account_currency"
+ "debit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable"
- else "gl.credit_in_account_currency"
+ else "credit_in_account_currency"
)
if self.minimum_invoice_amount:
- condition += " and `{0}` >= {1}".format(dr_or_cr, flt(self.minimum_payment_amount))
+ condition += " and gl.{dr_or_cr} >= {amount}".format(
+ dr_or_cr=dr_or_cr, amount=flt(self.minimum_payment_amount)
+ )
if self.maximum_invoice_amount:
- condition += " and `{0}` <= {1}".format(dr_or_cr, flt(self.maximum_payment_amount))
+ condition += " and gl.{dr_or_cr} <= {amount}".format(
+ dr_or_cr=dr_or_cr, amount=flt(self.maximum_payment_amount)
+ )
else:
condition += (
diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py
index 65fd4af..e83dc0f 100644
--- a/erpnext/accounts/doctype/pos_profile/pos_profile.py
+++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py
@@ -61,13 +61,13 @@
if len(item_groups) != len(set(item_groups)):
frappe.throw(
- _("Duplicate item group found in the item group table"), title="Duplicate Item Group"
+ _("Duplicate item group found in the item group table"), title=_("Duplicate Item Group")
)
if len(customer_groups) != len(set(customer_groups)):
frappe.throw(
_("Duplicate customer group found in the cutomer group table"),
- title="Duplicate Customer Group",
+ title=_("Duplicate Customer Group"),
)
def validate_payment_methods(self):
diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
index 08cec6a..2438f4b 100644
--- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
@@ -35,10 +35,11 @@
self.margin_rate_or_amount = 0.0
def validate_duplicate_apply_on(self):
- field = apply_on_dict.get(self.apply_on)
- values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field]
- if len(values) != len(set(values)):
- frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
+ if self.apply_on != "Transaction":
+ field = apply_on_dict.get(self.apply_on)
+ values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field]
+ if len(values) != len(set(values)):
+ frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
def validate_mandatory(self):
for apply_on, field in apply_on_dict.items():
@@ -375,12 +376,12 @@
def update_args_for_pricing_rule(args):
if not (args.item_group and args.brand):
- try:
- args.item_group, args.brand = frappe.get_cached_value(
- "Item", args.item_code, ["item_group", "brand"]
- )
- except frappe.DoesNotExistError:
+ item = frappe.get_cached_value("Item", args.item_code, ("item_group", "brand"))
+ if not item:
return
+
+ args.item_group, args.brand = item
+
if not args.item_group:
frappe.throw(_("Item Group not mentioned in item master for item {0}").format(args.item_code))
diff --git a/erpnext/accounts/doctype/process_deferred_accounting/process_deferred_accounting.py b/erpnext/accounts/doctype/process_deferred_accounting/process_deferred_accounting.py
index 08a7f41..8ec726b 100644
--- a/erpnext/accounts/doctype/process_deferred_accounting/process_deferred_accounting.py
+++ b/erpnext/accounts/doctype/process_deferred_accounting/process_deferred_accounting.py
@@ -11,7 +11,7 @@
convert_deferred_expense_to_expense,
convert_deferred_revenue_to_income,
)
-from erpnext.accounts.general_ledger import make_reverse_gl_entries
+from erpnext.accounts.general_ledger import make_gl_entries
class ProcessDeferredAccounting(Document):
@@ -34,4 +34,4 @@
filters={"against_voucher_type": self.doctype, "against_voucher": self.name},
)
- make_reverse_gl_entries(gl_entries=gl_entries)
+ make_gl_entries(gl_entries=gl_entries, cancel=1)
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js
index 29f2e98..7dd77fb 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.js
@@ -8,7 +8,7 @@
},
refresh: function(frm){
if(!frm.doc.__islocal) {
- frm.add_custom_button('Send Emails',function(){
+ frm.add_custom_button(__('Send Emails'), function(){
frappe.call({
method: "erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_emails",
args: {
@@ -24,7 +24,7 @@
}
});
});
- frm.add_custom_button('Download',function(){
+ frm.add_custom_button(__('Download'), function(){
var url = frappe.urllib.get_full_url(
'/api/method/erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.download_statements?'
+ 'document_name='+encodeURIComponent(frm.doc.name))
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
index fea55a3..01f716d 100644
--- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
+++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py
@@ -34,8 +34,9 @@
frappe.throw(_("Customers not selected."))
if self.enable_auto_email:
- self.to_date = self.start_date
- self.from_date = add_months(self.to_date, -1 * self.filter_duration)
+ if self.start_date and getdate(self.start_date) >= getdate(today()):
+ self.to_date = self.start_date
+ self.from_date = add_months(self.to_date, -1 * self.filter_duration)
def get_report_pdf(doc, consolidated=True):
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
index 1a398ab..42917f8 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
@@ -30,6 +30,9 @@
onload() {
super.onload();
+ // Ignore linked advances
+ this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry'];
+
if(!this.frm.doc.__islocal) {
// show credit_to in print format
if(!this.frm.doc.supplier && this.frm.doc.credit_to) {
@@ -141,7 +144,7 @@
})
}, __("Get Items From"));
}
- this.frm.toggle_reqd("supplier_warehouse", this.frm.doc.is_subcontracted==="Yes");
+ this.frm.toggle_reqd("supplier_warehouse", this.frm.doc.is_subcontracted);
if (doc.docstatus == 1 && !doc.inter_company_invoice_reference) {
frappe.model.with_doc("Supplier", me.frm.doc.supplier, function() {
@@ -276,6 +279,8 @@
if(this.frm.updating_party_details || this.frm.doc.inter_company_invoice_reference)
return;
+ if (this.frm.doc.__onload && this.frm.doc.__onload.load_after_mapping) return;
+
erpnext.utils.get_party_details(this.frm, "erpnext.accounts.party.get_party_details",
{
posting_date: this.frm.doc.posting_date,
@@ -569,10 +574,10 @@
},
is_subcontracted: function(frm) {
- if (frm.doc.is_subcontracted === "Yes") {
+ if (frm.doc.is_subcontracted) {
erpnext.buying.get_default_bom(frm);
}
- frm.toggle_reqd("supplier_warehouse", frm.doc.is_subcontracted==="Yes");
+ frm.toggle_reqd("supplier_warehouse", frm.doc.is_subcontracted);
},
update_stock: function(frm) {
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
index bd01164..9f87c5a 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json
@@ -543,11 +543,10 @@
"fieldtype": "Column Break"
},
{
- "default": "No",
+ "default": "0",
"fieldname": "is_subcontracted",
- "fieldtype": "Select",
- "label": "Raw Materials Supplied",
- "options": "No\nYes",
+ "fieldtype": "Check",
+ "label": "Is Subcontracted",
"print_hide": 1
},
{
@@ -1366,7 +1365,7 @@
"width": "50px"
},
{
- "depends_on": "eval:doc.update_stock && doc.is_subcontracted==\"Yes\"",
+ "depends_on": "eval:doc.update_stock && doc.is_subcontracted",
"fieldname": "supplier_warehouse",
"fieldtype": "Link",
"label": "Supplier Warehouse",
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index 57bc0a7..a5f9e24 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -249,8 +249,9 @@
def validate_warehouse(self, for_validate=True):
if self.update_stock and for_validate:
+ stock_items = self.get_stock_items()
for d in self.get("items"):
- if not d.warehouse:
+ if not d.warehouse and d.item_code in stock_items:
frappe.throw(
_(
"Row No {0}: Warehouse is required. Please set a Default Warehouse for Item {1} and Company {2}"
@@ -810,7 +811,9 @@
if provisional_accounting_for_non_stock_items:
if item.purchase_receipt:
- provisional_account = self.get_company_default("default_provisional_account")
+ provisional_account = frappe.db.get_value(
+ "Purchase Receipt Item", item.pr_detail, "provisional_expense_account"
+ ) or self.get_company_default("default_provisional_account")
purchase_receipt_doc = purchase_receipt_doc_map.get(item.purchase_receipt)
if not purchase_receipt_doc:
@@ -833,7 +836,7 @@
if expense_booked_in_pr:
# Intentionally passing purchase invoice item to handle partial billing
purchase_receipt_doc.add_provisional_gl_entry(
- item, gl_entries, self.posting_date, reverse=1
+ item, gl_entries, self.posting_date, provisional_account, reverse=1
)
if not self.is_internal_transfer():
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 843f66d..59bd637 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -901,7 +901,7 @@
)
pi = make_purchase_invoice(
- item_code="_Test FG Item", qty=10, rate=500, update_stock=1, is_subcontracted="Yes"
+ item_code="_Test FG Item", qty=10, rate=500, update_stock=1, is_subcontracted=1
)
self.assertEqual(len(pi.get("supplied_items")), 2)
@@ -1482,7 +1482,8 @@
self.assertEqual(payment_entry.taxes[0].allocated_amount, 0)
def test_provisional_accounting_entry(self):
- item = create_item("_Test Non Stock Item", is_stock_item=0)
+ create_item("_Test Non Stock Item", is_stock_item=0)
+
provisional_account = create_account(
account_name="Provision Account",
parent_account="Current Liabilities - _TC",
@@ -1505,6 +1506,8 @@
pi.save()
pi.submit()
+ self.assertEquals(pr.items[0].provisional_expense_account, "Provision Account - _TC")
+
# Check GLE for Purchase Invoice
expected_gle = [
["Cost of Goods Sold - _TC", 250, 0, add_days(pr.posting_date, -1)],
@@ -1611,7 +1614,7 @@
pi.conversion_rate = args.conversion_rate or 1
pi.is_return = args.is_return
pi.return_against = args.return_against
- pi.is_subcontracted = args.is_subcontracted or "No"
+ pi.is_subcontracted = args.is_subcontracted or 0
pi.supplier_warehouse = args.supplier_warehouse or "_Test Warehouse 1 - _TC"
pi.cost_center = args.parent_cost_center
@@ -1674,7 +1677,7 @@
pi.is_return = args.is_return
pi.is_return = args.is_return
pi.credit_to = args.return_against or "Creditors - _TC"
- pi.is_subcontracted = args.is_subcontracted or "No"
+ pi.is_subcontracted = args.is_subcontracted or 0
if args.supplier_warehouse:
pi.supplier_warehouse = "_Test Warehouse 1 - _TC"
diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
index f9b2efd..6651195 100644
--- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
+++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json
@@ -623,7 +623,7 @@
},
{
"default": "0",
- "depends_on": "eval:parent.is_subcontracted == 'Yes'",
+ "depends_on": "eval:parent.is_subcontracted",
"fieldname": "include_exploded_items",
"fieldtype": "Check",
"label": "Include Exploded Items",
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
index af6a52a..0f7c13f 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
@@ -33,7 +33,9 @@
var me = this;
super.onload();
- this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', 'POS Closing Entry'];
+ this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
+ 'POS Closing Entry', 'Journal Entry', 'Payment Entry'];
+
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format
this.frm.set_df_property("debit_to", "print_hide", 0);
@@ -280,6 +282,9 @@
}
var me = this;
if(this.frm.updating_party_details) return;
+
+ if (this.frm.doc.__onload && this.frm.doc.__onload.load_after_mapping) return;
+
erpnext.utils.get_party_details(this.frm,
"erpnext.accounts.party.get_party_details", {
posting_date: this.frm.doc.posting_date,
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 7d98c22..1efd3dc 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -1412,7 +1412,7 @@
)
)
else:
- frappe.throw(_("Select change amount account"), title="Mandatory Field")
+ frappe.throw(_("Select change amount account"), title=_("Mandatory Field"))
def make_write_off_gl_entry(self, gl_entries):
# write off entries, applicable if only pos
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 6c38a7e..caa70d0 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -2240,6 +2240,14 @@
check_gl_entries(self, si.name, expected_gle, "2019-01-30")
+ def test_deferred_revenue_missing_account(self):
+ si = create_sales_invoice(posting_date="2019-01-10", do_not_submit=True)
+ si.items[0].enable_deferred_revenue = 1
+ si.items[0].service_start_date = "2019-01-10"
+ si.items[0].service_end_date = "2019-03-15"
+
+ self.assertRaises(frappe.ValidationError, si.save)
+
def test_fixed_deferred_revenue(self):
deferred_account = create_account(
account_name="Deferred Revenue",
@@ -3104,7 +3112,7 @@
acc_settings = frappe.get_single("Accounts Settings")
acc_settings.book_deferred_entries_via_journal_entry = 0
- acc_settings.submit_journal_entriessubmit_journal_entries = 0
+ acc_settings.submit_journal_entries = 0
acc_settings.save()
frappe.db.set_value("Accounts Settings", None, "acc_frozen_upto", None)
@@ -3116,6 +3124,62 @@
si.reload()
self.assertTrue(si.items[0].serial_no)
+ def test_gain_loss_with_advance_entry(self):
+ from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
+
+ unlink_enabled = frappe.db.get_value(
+ "Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice"
+ )
+
+ frappe.db.set_value(
+ "Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", 1
+ )
+
+ jv = make_journal_entry("_Test Receivable USD - _TC", "_Test Bank - _TC", -7000, save=False)
+
+ jv.accounts[0].exchange_rate = 70
+ jv.accounts[0].credit_in_account_currency = 100
+ jv.accounts[0].party_type = "Customer"
+ jv.accounts[0].party = "_Test Customer USD"
+
+ jv.save()
+ jv.submit()
+
+ si = create_sales_invoice(
+ customer="_Test Customer USD",
+ debit_to="_Test Receivable USD - _TC",
+ currency="USD",
+ conversion_rate=75,
+ do_not_save=1,
+ rate=100,
+ )
+
+ si.append(
+ "advances",
+ {
+ "reference_type": "Journal Entry",
+ "reference_name": jv.name,
+ "reference_row": jv.accounts[0].name,
+ "advance_amount": 100,
+ "allocated_amount": 100,
+ "ref_exchange_rate": 70,
+ },
+ )
+ si.save()
+ si.submit()
+
+ expected_gle = [
+ ["_Test Receivable USD - _TC", 7500.0, 500],
+ ["Exchange Gain/Loss - _TC", 500.0, 0.0],
+ ["Sales - _TC", 0.0, 7500.0],
+ ]
+
+ check_gl_entries(self, si.name, expected_gle, nowdate())
+
+ frappe.db.set_value(
+ "Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled
+ )
+
def get_sales_invoice_for_e_invoice():
si = make_sales_invoice_for_ewaybill()
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 35c2f84..a519d8b 100644
--- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
+++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
@@ -34,7 +34,9 @@
def validate_thresholds(self):
for d in self.get("rates"):
- if d.cumulative_threshold and d.cumulative_threshold < d.single_threshold:
+ if (
+ d.cumulative_threshold and d.single_threshold and d.cumulative_threshold < d.single_threshold
+ ):
frappe.throw(
_("Row #{0}: Cumulative threshold cannot be less than Single Transaction threshold").format(
d.idx
diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py
index 50f37be..f52e517 100644
--- a/erpnext/accounts/general_ledger.py
+++ b/erpnext/accounts/general_ledger.py
@@ -253,7 +253,7 @@
if not from_repost:
validate_cwip_accounts(gl_map)
- round_off_debit_credit(gl_map)
+ process_debit_credit_difference(gl_map)
if gl_map:
check_freezing_date(gl_map[0]["posting_date"], adv_adj)
@@ -302,12 +302,29 @@
)
-def round_off_debit_credit(gl_map):
+def process_debit_credit_difference(gl_map):
precision = get_field_precision(
frappe.get_meta("GL Entry").get_field("debit"),
currency=frappe.get_cached_value("Company", gl_map[0].company, "default_currency"),
)
+ voucher_type = gl_map[0].voucher_type
+ voucher_no = gl_map[0].voucher_no
+ allowance = get_debit_credit_allowance(voucher_type, precision)
+
+ debit_credit_diff = get_debit_credit_difference(gl_map, precision)
+ if abs(debit_credit_diff) > allowance:
+ raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no)
+
+ elif abs(debit_credit_diff) >= (1.0 / (10**precision)):
+ make_round_off_gle(gl_map, debit_credit_diff, precision)
+
+ debit_credit_diff = get_debit_credit_difference(gl_map, precision)
+ if abs(debit_credit_diff) > allowance:
+ raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no)
+
+
+def get_debit_credit_difference(gl_map, precision):
debit_credit_diff = 0.0
for entry in gl_map:
entry.debit = flt(entry.debit, precision)
@@ -316,20 +333,24 @@
debit_credit_diff = flt(debit_credit_diff, precision)
- if gl_map[0]["voucher_type"] in ("Journal Entry", "Payment Entry"):
+ return debit_credit_diff
+
+
+def get_debit_credit_allowance(voucher_type, precision):
+ if voucher_type in ("Journal Entry", "Payment Entry"):
allowance = 5.0 / (10**precision)
else:
allowance = 0.5
- if abs(debit_credit_diff) > allowance:
- frappe.throw(
- _("Debit and Credit not equal for {0} #{1}. Difference is {2}.").format(
- gl_map[0].voucher_type, gl_map[0].voucher_no, debit_credit_diff
- )
- )
+ return allowance
- elif abs(debit_credit_diff) >= (1.0 / (10**precision)):
- make_round_off_gle(gl_map, debit_credit_diff, precision)
+
+def raise_debit_credit_not_equal_error(debit_credit_diff, voucher_type, voucher_no):
+ frappe.throw(
+ _("Debit and Credit not equal for {0} #{1}. Difference is {2}.").format(
+ voucher_type, voucher_no, debit_credit_diff
+ )
+ )
def make_round_off_gle(gl_map, debit_credit_diff, precision):
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 e658049..605ce83 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
@@ -1,7 +1,8 @@
{%- from "templates/print_formats/standard_macros.html" import add_header, render_field, print_value -%}
-{%- set einvoice = json.loads(doc.signed_einvoice) -%}
<div class="page-break">
+ {% if doc.signed_einvoice %}
+ {%- set einvoice = json.loads(doc.signed_einvoice) -%}
<div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}>
{% if letter_head and not no_letterhead %}
<div class="letter-head">{{ letter_head }}</div>
@@ -170,4 +171,10 @@
</tbody>
</table>
</div>
+ {% else %}
+ <div class="text-center" style="color: var(--gray-500); font-size: 14px;">
+ You must generate IRN before you can preview GST E-Invoice.
+ </div>
+ {% endif %}
</div>
+
diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js
index 81c60bb..f6961eb 100644
--- a/erpnext/accounts/report/accounts_payable/accounts_payable.js
+++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js
@@ -54,6 +54,22 @@
}
},
{
+ "fieldname": "party_account",
+ "label": __("Payable Account"),
+ "fieldtype": "Link",
+ "options": "Account",
+ get_query: () => {
+ var company = frappe.query_report.get_filter_value('company');
+ return {
+ filters: {
+ 'company': company,
+ 'account_type': 'Payable',
+ 'is_group': 0
+ }
+ };
+ }
+ },
+ {
"fieldname": "ageing_based_on",
"label": __("Ageing Based On"),
"fieldtype": "Select",
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
index 5700298..748bcde 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
@@ -67,6 +67,22 @@
}
},
{
+ "fieldname": "party_account",
+ "label": __("Receivable Account"),
+ "fieldtype": "Link",
+ "options": "Account",
+ get_query: () => {
+ var company = frappe.query_report.get_filter_value('company');
+ return {
+ filters: {
+ 'company': company,
+ 'account_type': 'Receivable',
+ 'is_group': 0
+ }
+ };
+ }
+ },
+ {
"fieldname": "ageing_based_on",
"label": __("Ageing Based On"),
"fieldtype": "Select",
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index 7bf9539..de9d63d 100755
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -111,6 +111,7 @@
voucher_type=gle.voucher_type,
voucher_no=gle.voucher_no,
party=gle.party,
+ party_account=gle.account,
posting_date=gle.posting_date,
account_currency=gle.account_currency,
remarks=gle.remarks if self.filters.get("show_remarks") else None,
@@ -777,18 +778,22 @@
conditions.append("party=%s")
values.append(self.filters.get(party_type_field))
- # get GL with "receivable" or "payable" account_type
- account_type = "Receivable" if self.party_type == "Customer" else "Payable"
- accounts = [
- d.name
- for d in frappe.get_all(
- "Account", filters={"account_type": account_type, "company": self.filters.company}
- )
- ]
+ if self.filters.party_account:
+ conditions.append("account =%s")
+ values.append(self.filters.party_account)
+ else:
+ # get GL with "receivable" or "payable" account_type
+ account_type = "Receivable" if self.party_type == "Customer" else "Payable"
+ accounts = [
+ d.name
+ for d in frappe.get_all(
+ "Account", filters={"account_type": account_type, "company": self.filters.company}
+ )
+ ]
- if accounts:
- conditions.append("account in (%s)" % ",".join(["%s"] * len(accounts)))
- values += accounts
+ if accounts:
+ conditions.append("account in (%s)" % ",".join(["%s"] * len(accounts)))
+ values += accounts
def add_customer_filters(self, conditions, values):
if self.filters.get("customer_group"):
@@ -888,6 +893,13 @@
options=self.party_type,
width=180,
)
+ self.add_column(
+ label="Receivable Account" if self.party_type == "Customer" else "Payable Account",
+ fieldname="party_account",
+ fieldtype="Link",
+ options="Account",
+ width=180,
+ )
if self.party_naming_by == "Naming Series":
self.add_column(
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index 7a6989f..f38890e 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -50,12 +50,19 @@
make_credit_note(name)
report = execute(filters)
- expected_data_after_credit_note = [100, 0, 0, 40, -40]
+ expected_data_after_credit_note = [100, 0, 0, 40, -40, "Debtors - _TC2"]
row = report[1][0]
self.assertEqual(
expected_data_after_credit_note,
- [row.invoice_grand_total, row.invoiced, row.paid, row.credit_note, row.outstanding],
+ [
+ row.invoice_grand_total,
+ row.invoiced,
+ row.paid,
+ row.credit_note,
+ row.outstanding,
+ row.party_account,
+ ],
)
diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py
index 7b1e979..07552e3 100644
--- a/erpnext/accounts/report/balance_sheet/balance_sheet.py
+++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py
@@ -201,17 +201,17 @@
net_provisional_profit_loss += provisional_profit_loss.get(key)
return [
- {"value": net_asset, "label": "Total Asset", "datatype": "Currency", "currency": currency},
+ {"value": net_asset, "label": _("Total Asset"), "datatype": "Currency", "currency": currency},
{
"value": net_liability,
- "label": "Total Liability",
+ "label": _("Total Liability"),
"datatype": "Currency",
"currency": currency,
},
- {"value": net_equity, "label": "Total Equity", "datatype": "Currency", "currency": currency},
+ {"value": net_equity, "label": _("Total Equity"), "datatype": "Currency", "currency": currency},
{
"value": net_provisional_profit_loss,
- "label": "Provisional Profit / Loss (Credit)",
+ "label": _("Provisional Profit / Loss (Credit)"),
"indicator": "Green" if net_provisional_profit_loss > 0 else "Red",
"datatype": "Currency",
"currency": currency,
diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
index ca341f4..7b774ba 100644
--- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
+++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py
@@ -97,8 +97,8 @@
if filters["period"] == "Yearly":
labels = [
_("Budget") + " " + str(year[0]),
- _("Actual ") + " " + str(year[0]),
- _("Variance ") + " " + str(year[0]),
+ _("Actual") + " " + str(year[0]),
+ _("Variance") + " " + str(year[0]),
]
for label in labels:
columns.append(
diff --git a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py
index 8e8465c..ecad9f1 100644
--- a/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py
+++ b/erpnext/accounts/report/dimension_wise_accounts_balance_report/dimension_wise_accounts_balance_report.py
@@ -230,7 +230,7 @@
columns.append(
{
"fieldname": "total",
- "label": "Total",
+ "label": _("Total"),
"fieldtype": "Currency",
"options": "currency",
"width": 150,
diff --git a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py
index 8db72de..1a00399 100644
--- a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py
+++ b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py
@@ -29,7 +29,7 @@
"options": "Item Group",
"width": 150,
},
- {"fieldname": "item", "fieldtype": "Link", "options": "Item", "label": "Item", "width": 150},
+ {"fieldname": "item", "fieldtype": "Link", "options": "Item", "label": _("Item"), "width": 150},
{"fieldname": "item_name", "fieldtype": "Data", "label": _("Item Name"), "width": 150},
{
"fieldname": "customer",
diff --git a/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py b/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py
index 00f5948..3f178f4 100644
--- a/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py
+++ b/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py
@@ -115,9 +115,9 @@
{"fieldname": "credit", "label": _("Credit"), "fieldtype": "Currency", "width": 140},
{"fieldname": "remarks", "label": _("Remarks"), "fieldtype": "Data", "width": 200},
{"fieldname": "age", "label": _("Age"), "fieldtype": "Int", "width": 50},
- {"fieldname": "range1", "label": "0-30", "fieldtype": "Currency", "width": 140},
- {"fieldname": "range2", "label": "30-60", "fieldtype": "Currency", "width": 140},
- {"fieldname": "range3", "label": "60-90", "fieldtype": "Currency", "width": 140},
+ {"fieldname": "range1", "label": _("0-30"), "fieldtype": "Currency", "width": 140},
+ {"fieldname": "range2", "label": _("30-60"), "fieldtype": "Currency", "width": 140},
+ {"fieldname": "range3", "label": _("60-90"), "fieldtype": "Currency", "width": 140},
{"fieldname": "range4", "label": _("90 Above"), "fieldtype": "Currency", "width": 140},
{
"fieldname": "delay_in_payment",
diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py
index c359959..a73c72c 100644
--- a/erpnext/accounts/report/purchase_register/purchase_register.py
+++ b/erpnext/accounts/report/purchase_register/purchase_register.py
@@ -124,11 +124,10 @@
_("Purchase Receipt") + ":Link/Purchase Receipt:100",
{"fieldname": "currency", "label": _("Currency"), "fieldtype": "Data", "width": 80},
]
- expense_accounts = (
- tax_accounts
- ) = (
- expense_columns
- ) = tax_columns = unrealized_profit_loss_accounts = unrealized_profit_loss_account_columns = []
+
+ expense_accounts = []
+ tax_accounts = []
+ unrealized_profit_loss_accounts = []
if invoice_list:
expense_accounts = frappe.db.sql_list(
@@ -163,10 +162,11 @@
unrealized_profit_loss_account_columns = [
(account + ":Currency/currency:120") for account in unrealized_profit_loss_accounts
]
-
- for account in tax_accounts:
- if account not in expense_accounts:
- tax_columns.append(account + ":Currency/currency:120")
+ tax_columns = [
+ (account + ":Currency/currency:120")
+ for account in tax_accounts
+ if account not in expense_accounts
+ ]
columns = (
columns
diff --git a/erpnext/accounts/report/tax_detail/test_tax_detail.json b/erpnext/accounts/report/tax_detail/test_tax_detail.json
index 3a4b175..e490316 100644
--- a/erpnext/accounts/report/tax_detail/test_tax_detail.json
+++ b/erpnext/accounts/report/tax_detail/test_tax_detail.json
@@ -302,7 +302,7 @@
"is_opening": "No",
"is_paid": 0,
"is_return": 0,
- "is_subcontracted": "No",
+ "is_subcontracted": 0,
"items": [
{
"allow_zero_valuation_rate": 0,
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index d17207a..eb09bdd 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -18,10 +18,6 @@
from erpnext.stock.utils import get_stock_value_on
-class StockValueAndAccountBalanceOutOfSync(frappe.ValidationError):
- pass
-
-
class FiscalYearError(frappe.ValidationError):
pass
@@ -1246,47 +1242,6 @@
return matched
-def check_if_stock_and_account_balance_synced(
- posting_date, company, voucher_type=None, voucher_no=None
-):
- if not cint(erpnext.is_perpetual_inventory_enabled(company)):
- return
-
- accounts = get_stock_accounts(company, voucher_type, voucher_no)
- stock_adjustment_account = frappe.db.get_value("Company", company, "stock_adjustment_account")
-
- for account in accounts:
- account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
- account, posting_date, company
- )
-
- if abs(account_bal - stock_bal) > 0.1:
- precision = get_field_precision(
- frappe.get_meta("GL Entry").get_field("debit"),
- currency=frappe.get_cached_value("Company", company, "default_currency"),
- )
-
- diff = flt(stock_bal - account_bal, precision)
-
- error_reason = _(
- "Stock Value ({0}) and Account Balance ({1}) are out of sync for account {2} and it's linked warehouses as on {3}."
- ).format(stock_bal, account_bal, frappe.bold(account), posting_date)
- error_resolution = _("Please create an adjustment Journal Entry for amount {0} on {1}").format(
- frappe.bold(diff), frappe.bold(posting_date)
- )
-
- frappe.msgprint(
- msg="""{0}<br></br>{1}<br></br>""".format(error_reason, error_resolution),
- raise_exception=StockValueAndAccountBalanceOutOfSync,
- title=_("Values Out Of Sync"),
- primary_action={
- "label": _("Make Journal Entry"),
- "client_action": "erpnext.route_to_adjustment_jv",
- "args": get_journal_entry(account, stock_adjustment_account, diff),
- },
- )
-
-
def get_stock_accounts(company, voucher_type=None, voucher_no=None):
stock_accounts = [
d.name
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index f681b34..e759ad0 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -68,7 +68,7 @@
def test_item_exists(self):
asset = create_asset(item_code="MacBook", do_not_save=1)
- self.assertRaises(frappe.DoesNotExistError, asset.save)
+ self.assertRaises(frappe.ValidationError, asset.save)
def test_validate_item(self):
asset = create_asset(item_code="MacBook Pro", do_not_save=1)
diff --git a/erpnext/assets/doctype/asset_category/asset_category.py b/erpnext/assets/doctype/asset_category/asset_category.py
index 7291daf..a4d2c82 100644
--- a/erpnext/assets/doctype/asset_category/asset_category.py
+++ b/erpnext/assets/doctype/asset_category/asset_category.py
@@ -87,7 +87,7 @@
missing_cwip_accounts_for_company.append(get_link_to_form("Company", d.company_name))
if missing_cwip_accounts_for_company:
- msg = _("""To enable Capital Work in Progress Accounting, """)
+ msg = _("""To enable Capital Work in Progress Accounting,""") + " "
msg += _("""you must select Capital Work in Progress Account in accounts table""")
msg += "<br><br>"
msg += _("You can also set default CWIP account in Company {}").format(
diff --git a/erpnext/assets/doctype/asset_movement/asset_movement.py b/erpnext/assets/doctype/asset_movement/asset_movement.py
index e61efad..143f215 100644
--- a/erpnext/assets/doctype/asset_movement/asset_movement.py
+++ b/erpnext/assets/doctype/asset_movement/asset_movement.py
@@ -46,10 +46,9 @@
if d.target_location:
frappe.throw(
_(
- "Issuing cannot be done to a location. \
- Please enter employee who has issued Asset {0}"
+ "Issuing cannot be done to a location. Please enter employee who has issued Asset {0}"
).format(d.asset),
- title="Incorrect Movement Purpose",
+ title=_("Incorrect Movement Purpose"),
)
if not d.to_employee:
frappe.throw(_("Employee is required while issuing Asset {0}").format(d.asset))
@@ -58,10 +57,9 @@
if d.to_employee:
frappe.throw(
_(
- "Transferring cannot be done to an Employee. \
- Please enter location where Asset {0} has to be transferred"
+ "Transferring cannot be done to an Employee. Please enter location where Asset {0} has to be transferred"
).format(d.asset),
- title="Incorrect Movement Purpose",
+ title=_("Incorrect Movement Purpose"),
)
if not d.target_location:
frappe.throw(_("Target Location is required while transferring Asset {0}").format(d.asset))
@@ -89,8 +87,7 @@
if d.to_employee and d.target_location:
frappe.throw(
_(
- "Asset {0} cannot be received at a location and \
- given to employee in a single movement"
+ "Asset {0} cannot be received at a location and given to employee in a single movement"
).format(d.asset)
)
diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js
index 3fe6b2d..f5e4e72 100644
--- a/erpnext/assets/doctype/asset_repair/asset_repair.js
+++ b/erpnext/assets/doctype/asset_repair/asset_repair.js
@@ -32,7 +32,7 @@
refresh: function(frm) {
if (frm.doc.docstatus) {
- frm.add_custom_button("View General Ledger", function() {
+ frm.add_custom_button(__("View General Ledger"), function() {
frappe.route_options = {
"voucher_no": frm.doc.name
};
diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py
index 9953c61..20865e8 100644
--- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py
+++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py
@@ -37,7 +37,7 @@
_("Asset Value Adjustment cannot be posted before Asset's purchase date <b>{0}</b>.").format(
formatdate(asset_purchase_date)
),
- title="Incorrect Date",
+ title=_("Incorrect Date"),
)
def set_difference_amount(self):
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js
index 2005dac..c9e6798 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.js
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.js
@@ -179,7 +179,7 @@
if (doc.status != "On Hold") {
if(flt(doc.per_received) < 100 && allow_receipt) {
cur_frm.add_custom_button(__('Purchase Receipt'), this.make_purchase_receipt, __('Create'));
- if(doc.is_subcontracted==="Yes" && me.has_unsupplied_items()) {
+ if(doc.is_subcontracted && me.has_unsupplied_items()) {
cur_frm.add_custom_button(__('Material to Supplier'),
function() { me.make_stock_entry(); }, __("Transfer"));
}
@@ -636,7 +636,7 @@
frappe.provide("erpnext.buying");
frappe.ui.form.on("Purchase Order", "is_subcontracted", function(frm) {
- if (frm.doc.is_subcontracted === "Yes") {
+ if (frm.doc.is_subcontracted) {
erpnext.buying.get_default_bom(frm);
}
});
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json
index 896208f..9a1f9d1 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.json
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.json
@@ -457,16 +457,15 @@
"fieldtype": "Column Break"
},
{
- "default": "No",
+ "default": "0",
"fieldname": "is_subcontracted",
- "fieldtype": "Select",
+ "fieldtype": "Check",
"in_standard_filter": 1,
- "label": "Supply Raw Materials",
- "options": "No\nYes",
+ "label": "Is Subcontracted",
"print_hide": 1
},
{
- "depends_on": "eval:doc.is_subcontracted==\"Yes\"",
+ "depends_on": "eval:doc.is_subcontracted",
"fieldname": "supplier_warehouse",
"fieldtype": "Link",
"label": "Supplier Warehouse",
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 582bd8d..5860c4c 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -194,7 +194,7 @@
)
def validate_bom_for_subcontracting_items(self):
- if self.is_subcontracted == "Yes":
+ if self.is_subcontracted:
for item in self.items:
if not item.bom:
frappe.throw(
@@ -294,7 +294,7 @@
self.set_status(update=True, status=status)
self.update_requested_qty()
self.update_ordered_qty()
- if self.is_subcontracted == "Yes":
+ if self.is_subcontracted:
self.update_reserved_qty_for_subcontract()
self.notify_update()
@@ -311,7 +311,7 @@
self.update_ordered_qty()
self.validate_budget()
- if self.is_subcontracted == "Yes":
+ if self.is_subcontracted:
self.update_reserved_qty_for_subcontract()
frappe.get_doc("Authorization Control").validate_approving_authority(
@@ -331,7 +331,7 @@
if self.has_drop_ship_item():
self.update_delivered_qty_in_sales_order()
- if self.is_subcontracted == "Yes":
+ if self.is_subcontracted:
self.update_reserved_qty_for_subcontract()
self.check_on_hold_or_closed_status()
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index e4fb970..1a7f2dd 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -390,7 +390,7 @@
frappe.get_doc("Item Tax Template", "Test Update Items Template - _TC").delete()
def test_update_child_uom_conv_factor_change(self):
- po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
+ po = create_purchase_order(item_code="_Test FG Item", is_subcontracted=1)
total_reqd_qty = sum([d.get("required_qty") for d in po.as_dict().get("supplied_items")])
trans_item = json.dumps(
@@ -573,7 +573,7 @@
automatically_fetch_payment_terms(enable=0)
def test_subcontracting(self):
- po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
+ po = create_purchase_order(item_code="_Test FG Item", is_subcontracted=1)
self.assertEqual(len(po.get("supplied_items")), 2)
def test_warehouse_company_validation(self):
@@ -617,7 +617,7 @@
"doctype": "Purchase Order",
"company": "_Test Company",
"supplier": "_Test Supplier",
- "is_subcontracted": "No",
+ "is_subcontracted": 0,
"schedule_date": add_days(nowdate(), 1),
"currency": frappe.get_cached_value("Company", "_Test Company", "default_currency"),
"conversion_factor": 1,
@@ -764,7 +764,7 @@
)
# Submit PO
- po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
+ po = create_purchase_order(item_code="_Test FG Item", is_subcontracted=1)
bin2 = frappe.db.get_value(
"Bin",
@@ -919,7 +919,7 @@
po = create_purchase_order(
item_code=item_code,
qty=1,
- is_subcontracted="Yes",
+ is_subcontracted=1,
supplier_warehouse="_Test Warehouse 1 - _TC",
include_exploded_items=1,
)
@@ -936,7 +936,7 @@
po1 = create_purchase_order(
item_code=item_code,
qty=1,
- is_subcontracted="Yes",
+ is_subcontracted=1,
supplier_warehouse="_Test Warehouse 1 - _TC",
include_exploded_items=0,
)
@@ -957,7 +957,7 @@
po = create_purchase_order(
item_code=item_code,
qty=order_qty,
- is_subcontracted="Yes",
+ is_subcontracted=1,
supplier_warehouse="_Test Warehouse 1 - _TC",
)
@@ -1050,7 +1050,7 @@
po = create_purchase_order(
item_code=item_code,
qty=order_qty,
- is_subcontracted="Yes",
+ is_subcontracted=1,
supplier_warehouse="_Test Warehouse 1 - _TC",
do_not_save=True,
)
@@ -1283,7 +1283,7 @@
po.schedule_date = add_days(nowdate(), 1)
po.company = args.company or "_Test Company"
po.supplier = args.supplier or "_Test Supplier"
- po.is_subcontracted = args.is_subcontracted or "No"
+ po.is_subcontracted = args.is_subcontracted or 0
po.currency = args.currency or frappe.get_cached_value("Company", po.company, "default_currency")
po.conversion_factor = args.conversion_factor or 1
po.supplier_warehouse = args.supplier_warehouse or None
@@ -1309,7 +1309,7 @@
if not args.do_not_save:
po.insert()
if not args.do_not_submit:
- if po.is_subcontracted == "Yes":
+ if po.is_subcontracted:
supp_items = po.get("supplied_items")
for d in supp_items:
if not d.reserve_warehouse:
diff --git a/erpnext/buying/doctype/purchase_order/test_records.json b/erpnext/buying/doctype/purchase_order/test_records.json
index 74b8f1b..896050c 100644
--- a/erpnext/buying/doctype/purchase_order/test_records.json
+++ b/erpnext/buying/doctype/purchase_order/test_records.json
@@ -8,7 +8,7 @@
"doctype": "Purchase Order",
"base_grand_total": 5000.0,
"grand_total": 5000.0,
- "is_subcontracted": "Yes",
+ "is_subcontracted": 1,
"naming_series": "_T-Purchase Order-",
"base_net_total": 5000.0,
"items": [
@@ -42,7 +42,7 @@
"doctype": "Purchase Order",
"base_grand_total": 5000.0,
"grand_total": 5000.0,
- "is_subcontracted": "No",
+ "is_subcontracted": 0,
"naming_series": "_T-Purchase Order-",
"base_net_total": 5000.0,
"items": [
diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
index a18c527..f72c598 100644
--- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
+++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
@@ -572,7 +572,7 @@
"read_only": 1
},
{
- "depends_on": "eval:parent.is_subcontracted == 'Yes'",
+ "depends_on": "eval:parent.is_subcontracted",
"fieldname": "bom",
"fieldtype": "Link",
"label": "BOM",
@@ -581,7 +581,7 @@
},
{
"default": "0",
- "depends_on": "eval:parent.is_subcontracted == 'Yes'",
+ "depends_on": "eval:parent.is_subcontracted",
"fieldname": "include_exploded_items",
"fieldtype": "Check",
"label": "Include Exploded Items",
diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
index dcdba09..064b806 100644
--- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
+++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
@@ -65,7 +65,6 @@
)
sq.submit()
- frappe.form_dict = frappe.local("form_dict")
frappe.form_dict.name = rfq.name
self.assertEqual(check_supplier_has_docname_access(supplier_wt_appos[0].get("supplier")), True)
diff --git a/erpnext/buying/doctype/supplier/supplier.json b/erpnext/buying/doctype/supplier/supplier.json
index a57d9a9..e0ee658 100644
--- a/erpnext/buying/doctype/supplier/supplier.json
+++ b/erpnext/buying/doctype/supplier/supplier.json
@@ -18,16 +18,16 @@
"tax_id",
"tax_category",
"tax_withholding_category",
- "is_transporter",
- "is_internal_supplier",
- "represents_company",
"image",
"column_break0",
"supplier_group",
"supplier_type",
"allow_purchase_invoice_creation_without_purchase_order",
"allow_purchase_invoice_creation_without_purchase_receipt",
+ "is_internal_supplier",
+ "represents_company",
"disabled",
+ "is_transporter",
"warn_rfqs",
"warn_pos",
"prevent_rfqs",
@@ -38,12 +38,6 @@
"default_currency",
"column_break_10",
"default_price_list",
- "section_credit_limit",
- "payment_terms",
- "cb_21",
- "on_hold",
- "hold_type",
- "release_date",
"address_contacts",
"address_html",
"column_break1",
@@ -57,6 +51,12 @@
"primary_address",
"default_payable_accounts",
"accounts",
+ "section_credit_limit",
+ "payment_terms",
+ "cb_21",
+ "on_hold",
+ "hold_type",
+ "release_date",
"default_tax_withholding_config",
"column_break2",
"website",
@@ -258,7 +258,7 @@
"collapsible": 1,
"fieldname": "section_credit_limit",
"fieldtype": "Section Break",
- "label": "Credit Limit"
+ "label": "Payment Terms"
},
{
"fieldname": "payment_terms",
@@ -432,7 +432,7 @@
"link_fieldname": "party"
}
],
- "modified": "2021-10-20 22:03:33.147249",
+ "modified": "2022-04-16 18:02:27.838623",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier",
@@ -497,6 +497,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"title_field": "supplier_name",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
index 567e41f..8d1939a 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
@@ -773,11 +773,10 @@
"fieldtype": "Column Break"
},
{
- "default": "No",
+ "default": "0",
"fieldname": "is_subcontracted",
- "fieldtype": "Select",
+ "fieldtype": "Check",
"label": "Is Subcontracted",
- "options": "\nYes\nNo",
"print_hide": 1
},
{
diff --git a/erpnext/buying/doctype/supplier_quotation/test_records.json b/erpnext/buying/doctype/supplier_quotation/test_records.json
index 0f835d2..8acac32 100644
--- a/erpnext/buying/doctype/supplier_quotation/test_records.json
+++ b/erpnext/buying/doctype/supplier_quotation/test_records.json
@@ -7,7 +7,7 @@
"doctype": "Supplier Quotation",
"base_grand_total": 5000.0,
"grand_total": 5000.0,
- "is_subcontracted": "No",
+ "is_subcontracted": 0,
"naming_series": "_T-Supplier Quotation-",
"base_net_total": 5000.0,
"items": [
diff --git a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py
index 992bc80..486bf23 100644
--- a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py
+++ b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py
@@ -213,7 +213,8 @@
end_date = get_scorecard_date(sc.period, start_date)
if scp_count > 0:
frappe.msgprint(
- _("Created {0} scorecards for {1} between: ").format(scp_count, sc.supplier)
+ _("Created {0} scorecards for {1} between:").format(scp_count, sc.supplier)
+ + " "
+ str(first_start_date)
+ " - "
+ str(last_end_date)
diff --git a/erpnext/buying/doctype/supplier_scorecard_criteria/supplier_scorecard_criteria.py b/erpnext/buying/doctype/supplier_scorecard_criteria/supplier_scorecard_criteria.py
index 130adc9..ab7d487 100644
--- a/erpnext/buying/doctype/supplier_scorecard_criteria/supplier_scorecard_criteria.py
+++ b/erpnext/buying/doctype/supplier_scorecard_criteria/supplier_scorecard_criteria.py
@@ -80,6 +80,6 @@
)[0]
my_variables.append(var)
except Exception:
- frappe.throw(_("Unable to find variable: ") + str(match.group(1)), InvalidFormulaVariable)
+ frappe.throw(_("Unable to find variable:") + " " + str(match.group(1)), InvalidFormulaVariable)
return my_variables
diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py
index 11a7449..dbdc62e 100644
--- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py
+++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py
@@ -48,7 +48,7 @@
"data": {
"labels": labels,
"datasets": [
- {"name": _("{0}").format(filters.get("period")) + _(" Purchase Value"), "values": datapoints}
+ {"name": _(filters.get("period")) + " " + _("Purchase Value"), "values": datapoints}
],
},
"type": "line",
diff --git a/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.js b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.js
index 5ba52f1..6889322 100644
--- a/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.js
+++ b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.js
@@ -35,7 +35,7 @@
return {
filters: {
docstatus: 1,
- is_subcontracted: 'Yes',
+ is_subcontracted: 1,
company: frappe.query_report.get_filter_value('company')
}
}
diff --git a/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.py b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.py
index 1b2705a..3d66637 100644
--- a/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.py
+++ b/erpnext/buying/report/subcontract_order_summary/subcontract_order_summary.py
@@ -45,7 +45,7 @@
def get_filters(report_filters):
filters = [
["Purchase Order", "docstatus", "=", 1],
- ["Purchase Order", "is_subcontracted", "=", "Yes"],
+ ["Purchase Order", "is_subcontracted", "=", 1],
[
"Purchase Order",
"transaction_date",
diff --git a/erpnext/buying/report/subcontracted_item_to_be_received/subcontracted_item_to_be_received.py b/erpnext/buying/report/subcontracted_item_to_be_received/subcontracted_item_to_be_received.py
index 004657b..2e90de6 100644
--- a/erpnext/buying/report/subcontracted_item_to_be_received/subcontracted_item_to_be_received.py
+++ b/erpnext/buying/report/subcontracted_item_to_be_received/subcontracted_item_to_be_received.py
@@ -78,7 +78,7 @@
def get_po(filters):
record_filters = [
- ["is_subcontracted", "=", "Yes"],
+ ["is_subcontracted", "=", 1],
["supplier", "=", filters.supplier],
["transaction_date", "<=", filters.to_date],
["transaction_date", ">=", filters.from_date],
diff --git a/erpnext/buying/report/subcontracted_item_to_be_received/test_subcontracted_item_to_be_received.py b/erpnext/buying/report/subcontracted_item_to_be_received/test_subcontracted_item_to_be_received.py
index 26e4243..57f8741 100644
--- a/erpnext/buying/report/subcontracted_item_to_be_received/test_subcontracted_item_to_be_received.py
+++ b/erpnext/buying/report/subcontracted_item_to_be_received/test_subcontracted_item_to_be_received.py
@@ -17,7 +17,7 @@
class TestSubcontractedItemToBeReceived(FrappeTestCase):
def test_pending_and_received_qty(self):
- po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
+ po = create_purchase_order(item_code="_Test FG Item", is_subcontracted=1)
transfer_param = []
make_stock_entry(
item_code="_Test Item", target="_Test Warehouse 1 - _TC", qty=100, basic_rate=100
diff --git a/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.py b/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.py
index 98b18da..6b8a3b1 100644
--- a/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.py
+++ b/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/subcontracted_raw_materials_to_be_transferred.py
@@ -72,7 +72,7 @@
],
filters=[
["Purchase Order", "per_received", "<", "100"],
- ["Purchase Order", "is_subcontracted", "=", "Yes"],
+ ["Purchase Order", "is_subcontracted", "=", 1],
["Purchase Order", "supplier", "=", filters.supplier],
["Purchase Order", "transaction_date", "<=", filters.to_date],
["Purchase Order", "transaction_date", ">=", filters.from_date],
diff --git a/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/test_subcontracted_raw_materials_to_be_transferred.py b/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/test_subcontracted_raw_materials_to_be_transferred.py
index 401176d..2791a26 100644
--- a/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/test_subcontracted_raw_materials_to_be_transferred.py
+++ b/erpnext/buying/report/subcontracted_raw_materials_to_be_transferred/test_subcontracted_raw_materials_to_be_transferred.py
@@ -19,7 +19,7 @@
class TestSubcontractedItemToBeTransferred(FrappeTestCase):
def test_pending_and_transferred_qty(self):
po = create_purchase_order(
- item_code="_Test FG Item", is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ item_code="_Test FG Item", is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
# Material Receipt of RMs
diff --git a/erpnext/buying/utils.py b/erpnext/buying/utils.py
index f97cd5e..e904af0 100644
--- a/erpnext/buying/utils.py
+++ b/erpnext/buying/utils.py
@@ -3,19 +3,19 @@
import json
+from typing import Dict
import frappe
from frappe import _
-from frappe.utils import cint, cstr, flt
+from frappe.utils import cint, cstr, flt, getdate
from erpnext.stock.doctype.item.item import get_last_purchase_details, validate_end_of_life
-def update_last_purchase_rate(doc, is_submit):
+def update_last_purchase_rate(doc, is_submit) -> None:
"""updates last_purchase_rate in item table for each item"""
- import frappe.utils
- this_purchase_date = frappe.utils.getdate(doc.get("posting_date") or doc.get("transaction_date"))
+ this_purchase_date = getdate(doc.get("posting_date") or doc.get("transaction_date"))
for d in doc.get("items"):
# get last purchase details
@@ -41,7 +41,7 @@
frappe.db.set_value("Item", d.item_code, "last_purchase_rate", flt(last_purchase_rate))
-def validate_for_items(doc):
+def validate_for_items(doc) -> None:
items = []
for d in doc.get("items"):
if not d.qty:
@@ -49,40 +49,11 @@
continue
frappe.throw(_("Please enter quantity for Item {0}").format(d.item_code))
- # update with latest quantities
- bin = frappe.db.sql(
- """select projected_qty from `tabBin` where
- item_code = %s and warehouse = %s""",
- (d.item_code, d.warehouse),
- as_dict=1,
- )
-
- f_lst = {
- "projected_qty": bin and flt(bin[0]["projected_qty"]) or 0,
- "ordered_qty": 0,
- "received_qty": 0,
- }
- if d.doctype in ("Purchase Receipt Item", "Purchase Invoice Item"):
- f_lst.pop("received_qty")
- for x in f_lst:
- if d.meta.get_field(x):
- d.set(x, f_lst[x])
-
- item = frappe.db.sql(
- """select is_stock_item,
- is_sub_contracted_item, end_of_life, disabled from `tabItem` where name=%s""",
- d.item_code,
- as_dict=1,
- )[0]
-
+ set_stock_levels(row=d) # update with latest quantities
+ item = validate_item_and_get_basic_data(row=d)
+ validate_stock_item_warehouse(row=d, item=item)
validate_end_of_life(d.item_code, item.end_of_life, item.disabled)
- # validate stock item
- if item.is_stock_item == 1 and d.qty and not d.warehouse and not d.get("delivered_by_supplier"):
- frappe.throw(
- _("Warehouse is mandatory for stock Item {0} in row {1}").format(d.item_code, d.idx)
- )
-
items.append(cstr(d.item_code))
if (
@@ -93,7 +64,57 @@
frappe.throw(_("Same item cannot be entered multiple times."))
-def check_on_hold_or_closed_status(doctype, docname):
+def set_stock_levels(row) -> None:
+ projected_qty = frappe.db.get_value(
+ "Bin",
+ {
+ "item_code": row.item_code,
+ "warehouse": row.warehouse,
+ },
+ "projected_qty",
+ )
+
+ qty_data = {
+ "projected_qty": flt(projected_qty),
+ "ordered_qty": 0,
+ "received_qty": 0,
+ }
+ if row.doctype in ("Purchase Receipt Item", "Purchase Invoice Item"):
+ qty_data.pop("received_qty")
+
+ for field in qty_data:
+ if row.meta.get_field(field):
+ row.set(field, qty_data[field])
+
+
+def validate_item_and_get_basic_data(row) -> Dict:
+ item = frappe.db.get_values(
+ "Item",
+ filters={"name": row.item_code},
+ fieldname=["is_stock_item", "is_sub_contracted_item", "end_of_life", "disabled"],
+ as_dict=1,
+ )
+ if not item:
+ frappe.throw(_("Row #{0}: Item {1} does not exist").format(row.idx, frappe.bold(row.item_code)))
+
+ return item[0]
+
+
+def validate_stock_item_warehouse(row, item) -> None:
+ if (
+ item.is_stock_item == 1
+ and row.qty
+ and not row.warehouse
+ and not row.get("delivered_by_supplier")
+ ):
+ frappe.throw(
+ _("Row #{1}: Warehouse is mandatory for stock Item {0}").format(
+ frappe.bold(row.item_code), row.idx
+ )
+ )
+
+
+def check_on_hold_or_closed_status(doctype, docname) -> None:
status = frappe.db.get_value(doctype, docname, "status")
if status in ("Closed", "On Hold"):
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 72ac1b3..f20df09 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -180,6 +180,7 @@
else:
self.validate_deferred_start_and_end_date()
+ self.validate_deferred_income_expense_account()
self.set_inter_company_account()
if self.doctype == "Purchase Invoice":
@@ -208,6 +209,27 @@
(self.doctype, self.name),
)
+ def validate_deferred_income_expense_account(self):
+ field_map = {
+ "Sales Invoice": "deferred_revenue_account",
+ "Purchase Invoice": "deferred_expense_account",
+ }
+
+ for item in self.get("items"):
+ if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"):
+ if not item.get(field_map.get(self.doctype)):
+ default_deferred_account = frappe.db.get_value(
+ "Company", self.company, "default_" + field_map.get(self.doctype)
+ )
+ if not default_deferred_account:
+ frappe.throw(
+ _(
+ "Row #{0}: Please update deferred revenue/expense account in item row or default account in company master"
+ ).format(item.idx)
+ )
+ else:
+ item.set(field_map.get(self.doctype), default_deferred_account)
+
def validate_deferred_start_and_end_date(self):
for d in self.items:
if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"):
@@ -1267,17 +1289,9 @@
stock_items = []
item_codes = list(set(item.item_code for item in self.get("items")))
if item_codes:
- stock_items = [
- r[0]
- for r in frappe.db.sql(
- """
- select name from `tabItem`
- where name in (%s) and is_stock_item=1
- """
- % (", ".join(["%s"] * len(item_codes)),),
- item_codes,
- )
- ]
+ stock_items = frappe.db.get_values(
+ "Item", {"name": ["in", item_codes], "is_stock_item": 1}, pluck="name", cache=True
+ )
return stock_items
@@ -1983,12 +1997,13 @@
reference_condition = " and (" + " or ".join(conditions) + ")" if conditions else ""
+ # nosemgrep
journal_entries = frappe.db.sql(
"""
select
"Journal Entry" as reference_type, t1.name as reference_name,
t1.remark as remarks, t2.{0} as amount, t2.name as reference_row,
- t2.reference_name as against_order
+ t2.reference_name as against_order, t2.exchange_rate
from
`tabJournal Entry` t1, `tabJournal Entry Account` t2
where
@@ -2594,7 +2609,7 @@
parent.update_ordered_qty()
parent.update_ordered_and_reserved_qty()
parent.update_receiving_percentage()
- if parent.is_subcontracted == "Yes":
+ if parent.is_subcontracted:
parent.update_reserved_qty_for_subcontract()
parent.create_raw_materials_supplied("supplied_items")
parent.save()
diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py
index 4789207..233b476 100644
--- a/erpnext/controllers/buying_controller.py
+++ b/erpnext/controllers/buying_controller.py
@@ -22,6 +22,9 @@
class BuyingController(StockController, Subcontracting):
+ def __setup__(self):
+ self.flags.ignore_permlevel_for_fields = ["buying_price_list", "price_list_currency"]
+
def get_feed(self):
if self.get("supplier_name"):
return _("From {0} | {1} {2}").format(self.supplier_name, self.currency, self.grand_total)
@@ -167,7 +170,7 @@
_("Row #{0}: Accepted Warehouse and Supplier Warehouse cannot be same").format(item.idx)
)
- if item.get("from_warehouse") and self.get("is_subcontracted") == "Yes":
+ if item.get("from_warehouse") and self.get("is_subcontracted"):
frappe.throw(
_(
"Row #{0}: Cannot select Supplier Warehouse while suppling raw materials to subcontractor"
@@ -339,10 +342,7 @@
return supplied_items_cost
def validate_for_subcontracting(self):
- if not self.is_subcontracted and self.sub_contracted_items:
- frappe.throw(_("Please enter 'Is Subcontracted' as Yes or No"))
-
- if self.is_subcontracted == "Yes":
+ if self.is_subcontracted:
if self.doctype in ["Purchase Receipt", "Purchase Invoice"] and not self.supplier_warehouse:
frappe.throw(_("Supplier Warehouse mandatory for sub-contracted {0}").format(self.doctype))
@@ -363,14 +363,14 @@
item.bom = None
def create_raw_materials_supplied(self, raw_material_table):
- if self.is_subcontracted == "Yes":
+ if self.is_subcontracted:
self.set_materials_for_subcontracted_items(raw_material_table)
elif self.doctype in ["Purchase Receipt", "Purchase Invoice"]:
for item in self.get("items"):
item.rm_supp_cost = 0.0
- if self.is_subcontracted == "No" and self.get("supplied_items"):
+ if not self.is_subcontracted and self.get("supplied_items"):
self.set("supplied_items", [])
@property
@@ -466,7 +466,10 @@
stock_items = self.get_stock_items()
for d in self.get("items"):
- if d.item_code in stock_items and d.warehouse:
+ if d.item_code not in stock_items:
+ continue
+
+ if d.warehouse:
pr_qty = flt(d.qty) * flt(d.conversion_factor)
if pr_qty:
@@ -491,6 +494,7 @@
sle = self.get_sl_entries(
d, {"actual_qty": flt(pr_qty), "serial_no": cstr(d.serial_no).strip()}
)
+
if self.is_return:
outgoing_rate = get_rate_for_return(
self.doctype, self.name, d.item_code, self.return_against, item_row=d
@@ -520,18 +524,18 @@
sl_entries.append(from_warehouse_sle)
- if flt(d.rejected_qty) != 0:
- sl_entries.append(
- self.get_sl_entries(
- d,
- {
- "warehouse": d.rejected_warehouse,
- "actual_qty": flt(d.rejected_qty) * flt(d.conversion_factor),
- "serial_no": cstr(d.rejected_serial_no).strip(),
- "incoming_rate": 0.0,
- },
- )
+ if flt(d.rejected_qty) != 0:
+ sl_entries.append(
+ self.get_sl_entries(
+ d,
+ {
+ "warehouse": d.rejected_warehouse,
+ "actual_qty": flt(d.rejected_qty) * flt(d.conversion_factor),
+ "serial_no": cstr(d.rejected_serial_no).strip(),
+ "incoming_rate": 0.0,
+ },
)
+ )
self.make_sl_entries_for_supplier_warehouse(sl_entries)
self.make_sl_entries(
@@ -803,7 +807,7 @@
if self.doctype == "Material Request":
return
- if hasattr(self, "is_subcontracted") and self.is_subcontracted == "Yes":
+ if hasattr(self, "is_subcontracted") and self.is_subcontracted:
validate_item_type(self, "is_sub_contracted_item", "subcontracted")
else:
validate_item_type(self, "is_purchase_item", "purchase")
diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py
index bdde3a1..bd4b59b 100644
--- a/erpnext/controllers/sales_and_purchase_return.py
+++ b/erpnext/controllers/sales_and_purchase_return.py
@@ -330,7 +330,6 @@
doc = frappe.get_doc(target)
doc.is_return = 1
doc.return_against = source.name
- doc.ignore_pricing_rule = 1
doc.set_warehouse = ""
if doctype == "Sales Invoice" or doctype == "POS Invoice":
doc.is_pos = source.is_pos
diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py
index 7877827..19fedb3 100644
--- a/erpnext/controllers/selling_controller.py
+++ b/erpnext/controllers/selling_controller.py
@@ -16,6 +16,9 @@
class SellingController(StockController):
+ def __setup__(self):
+ self.flags.ignore_permlevel_for_fields = ["selling_price_list", "price_list_currency"]
+
def get_feed(self):
return _("To {0} | {1} {2}").format(self.customer_name, self.currency, self.grand_total)
diff --git a/erpnext/controllers/subcontracting.py b/erpnext/controllers/subcontracting.py
index 7083088..4bce06f 100644
--- a/erpnext/controllers/subcontracting.py
+++ b/erpnext/controllers/subcontracting.py
@@ -407,7 +407,7 @@
def set_consumed_qty_in_po(self):
# Update consumed qty back in the purchase order
- if self.is_subcontracted != "Yes":
+ if not self.is_subcontracted:
return
self.__get_purchase_orders()
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index 8183b6e..2144055 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -307,6 +307,11 @@
self.doc.round_floats_in(self.doc, ["total", "base_total", "net_total", "base_net_total"])
def calculate_shipping_charges(self):
+
+ # Do not apply shipping rule for POS
+ if self.doc.get("is_pos"):
+ return
+
if hasattr(self.doc, "shipping_rule") and self.doc.shipping_rule:
shipping_rule = frappe.get_doc("Shipping Rule", self.doc.shipping_rule)
shipping_rule.apply(self.doc)
diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js
index 7aa0b77..d532236 100644
--- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js
+++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.js
@@ -37,7 +37,7 @@
let msg,color;
if (days>0){
- msg = __("Your Session will be expire in ") + days + __(" days.");
+ msg = __("Your Session will be expire in {0} days.", [days]);
color = "green";
}
else {
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index 03ff269..96c730c 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -126,7 +126,8 @@
def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None):
if not self.has_active_quotation():
self.status = "Lost"
- self.lost_reasons = self.competitors = []
+ self.lost_reasons = []
+ self.competitors = []
if detailed_reason:
self.order_lost_reason = detailed_reason
diff --git a/erpnext/crm/doctype/social_media_post/social_media_post.js b/erpnext/crm/doctype/social_media_post/social_media_post.js
index 6874caa..d4ac0ba 100644
--- a/erpnext/crm/doctype/social_media_post/social_media_post.js
+++ b/erpnext/crm/doctype/social_media_post/social_media_post.js
@@ -86,7 +86,7 @@
frm.trigger('add_post_btn');
}
if (frm.doc.post_status !='Deleted') {
- frm.add_custom_button(('Delete Post'), function() {
+ frm.add_custom_button(__('Delete Post'), function() {
frappe.confirm(__('Are you sure want to delete the Post from Social Media platforms?'),
function() {
frappe.call({
diff --git a/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.py b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.py
index 9dae1d5..db36581 100644
--- a/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.py
+++ b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.py
@@ -3,11 +3,12 @@
import frappe
+from frappe import _
def execute(filters=None):
columns = [
- {"fieldname": "creation_date", "label": "Date", "fieldtype": "Date", "width": 300},
+ {"fieldname": "creation_date", "label": _("Date"), "fieldtype": "Date", "width": 300},
{
"fieldname": "first_response_time",
"fieldtype": "Duration",
diff --git a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py
index 77e6ae2..3a46fb0 100644
--- a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py
+++ b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py
@@ -1,9 +1,9 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
+from itertools import groupby
import frappe
-import pandas
from frappe import _
from frappe.utils import flt
@@ -101,18 +101,19 @@
self.convert_to_base_currency()
- dataframe = pandas.DataFrame.from_records(self.query_result)
- dataframe.replace(to_replace=[None], value="Not Assigned", inplace=True)
- result = dataframe.groupby(["sales_stage", based_on], as_index=False)["amount"].sum()
+ for row in self.query_result:
+ if not row.get(based_on):
+ row[based_on] = "Not Assigned"
self.grouped_data = []
- for i in range(len(result["amount"])):
+ grouping_key = lambda o: (o["sales_stage"], o[based_on]) # noqa
+ for (sales_stage, _based_on), rows in groupby(self.query_result, grouping_key):
self.grouped_data.append(
{
- "sales_stage": result["sales_stage"][i],
- based_on: result[based_on][i],
- "amount": result["amount"][i],
+ "sales_stage": sales_stage,
+ based_on: _based_on,
+ "amount": sum(flt(r["amount"]) for r in rows),
}
)
diff --git a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py
index b0c174b..d23a22a 100644
--- a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py
+++ b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py
@@ -3,9 +3,9 @@
import json
from datetime import date
+from itertools import groupby
import frappe
-import pandas
from dateutil.relativedelta import relativedelta
from frappe import _
from frappe.utils import cint, flt
@@ -109,18 +109,15 @@
self.convert_to_base_currency()
- dataframe = pandas.DataFrame.from_records(self.query_result)
- dataframe.replace(to_replace=[None], value="Not Assigned", inplace=True)
- result = dataframe.groupby([self.pipeline_by, self.period_by], as_index=False)["amount"].sum()
-
self.grouped_data = []
- for i in range(len(result["amount"])):
+ grouping_key = lambda o: (o.get(self.pipeline_by) or "Not Assigned", o[self.period_by]) # noqa
+ for (pipeline_by, period_by), rows in groupby(self.query_result, grouping_key):
self.grouped_data.append(
{
- self.pipeline_by: result[self.pipeline_by][i],
- self.period_by: result[self.period_by][i],
- "amount": result["amount"][i],
+ self.pipeline_by: pipeline_by,
+ self.period_by: period_by,
+ "amount": sum(flt(r["amount"]) for r in rows),
}
)
diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json
index d5fb969..e6f08f7 100644
--- a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json
+++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json
@@ -47,7 +47,7 @@
"item_search_settings_section",
"redisearch_warning",
"search_index_fields",
- "show_categories_in_search_autocomplete",
+ "is_redisearch_enabled",
"is_redisearch_loaded",
"shop_by_category_section",
"slideshow",
@@ -293,6 +293,7 @@
"fieldname": "search_index_fields",
"fieldtype": "Small Text",
"label": "Search Index Fields",
+ "mandatory_depends_on": "is_redisearch_enabled",
"read_only_depends_on": "eval:!doc.is_redisearch_loaded"
},
{
@@ -302,13 +303,6 @@
"label": "Item Search Settings"
},
{
- "default": "1",
- "fieldname": "show_categories_in_search_autocomplete",
- "fieldtype": "Check",
- "label": "Show Categories in Search Autocomplete",
- "read_only_depends_on": "eval:!doc.is_redisearch_loaded"
- },
- {
"default": "0",
"fieldname": "is_redisearch_loaded",
"fieldtype": "Check",
@@ -365,12 +359,19 @@
"fieldname": "show_price_in_quotation",
"fieldtype": "Check",
"label": "Show Price in Quotation"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_redisearch_enabled",
+ "fieldtype": "Check",
+ "label": "Enable Redisearch",
+ "read_only_depends_on": "eval:!doc.is_redisearch_loaded"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-09-02 14:02:44.785824",
+ "modified": "2022-04-01 18:35:56.106756",
"modified_by": "Administrator",
"module": "E-commerce",
"name": "E Commerce Settings",
@@ -389,5 +390,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py
index b5cd067..f85667e 100644
--- a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py
+++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py
@@ -9,6 +9,7 @@
from erpnext.e_commerce.redisearch_utils import (
create_website_items_index,
+ define_autocomplete_dictionary,
get_indexable_web_fields,
is_search_module_loaded,
)
@@ -21,6 +22,8 @@
class ECommerceSettings(Document):
def onload(self):
self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series")
+
+ # flag >> if redisearch is installed and loaded
self.is_redisearch_loaded = is_search_module_loaded()
def validate(self):
@@ -34,6 +37,20 @@
frappe.clear_document_cache("E Commerce Settings", "E Commerce Settings")
+ self.is_redisearch_enabled_pre_save = frappe.db.get_single_value(
+ "E Commerce Settings", "is_redisearch_enabled"
+ )
+
+ def after_save(self):
+ self.create_redisearch_indexes()
+
+ def create_redisearch_indexes(self):
+ # if redisearch is enabled (value changed) create indexes and dictionary
+ value_changed = self.is_redisearch_enabled != self.is_redisearch_enabled_pre_save
+ if self.is_redisearch_loaded and self.is_redisearch_enabled and value_changed:
+ define_autocomplete_dictionary()
+ create_website_items_index()
+
def validate_field_filters(self):
if not (self.enable_field_filters and self.filter_fields):
return
@@ -146,12 +163,7 @@
def get_shopping_cart_settings():
- if not getattr(frappe.local, "shopping_cart_settings", None):
- frappe.local.shopping_cart_settings = frappe.get_doc(
- "E Commerce Settings", "E Commerce Settings"
- )
-
- return frappe.local.shopping_cart_settings
+ return frappe.get_cached_doc("E Commerce Settings")
@frappe.whitelist(allow_guest=True)
diff --git a/erpnext/e_commerce/doctype/website_item/website_item.js b/erpnext/e_commerce/doctype/website_item/website_item.js
index 7108cab..7295e4b 100644
--- a/erpnext/e_commerce/doctype/website_item/website_item.js
+++ b/erpnext/e_commerce/doctype/website_item/website_item.js
@@ -2,7 +2,7 @@
// For license information, please see license.txt
frappe.ui.form.on('Website Item', {
- onload: function(frm) {
+ onload: (frm) => {
// should never check Private
frm.fields_dict["website_image"].df.is_private = 0;
@@ -13,18 +13,35 @@
});
},
- image: function() {
+ refresh: (frm) => {
+ frm.add_custom_button(__("Prices"), function() {
+ frappe.set_route("List", "Item Price", {"item_code": frm.doc.item_code});
+ }, __("View"));
+
+ frm.add_custom_button(__("Stock"), function() {
+ frappe.route_options = {
+ "item_code": frm.doc.item_code
+ };
+ frappe.set_route("query-report", "Stock Balance");
+ }, __("View"));
+
+ frm.add_custom_button(__("E Commerce Settings"), function() {
+ frappe.set_route("Form", "E Commerce Settings");
+ }, __("View"));
+ },
+
+ image: () => {
refresh_field("image_view");
},
- copy_from_item_group: function(frm) {
+ copy_from_item_group: (frm) => {
return frm.call({
doc: frm.doc,
method: "copy_specification_from_item_group"
});
},
- set_meta_tags(frm) {
+ set_meta_tags: (frm) => {
frappe.utils.set_meta_tag(frm.doc.route);
}
});
diff --git a/erpnext/e_commerce/redisearch_utils.py b/erpnext/e_commerce/redisearch_utils.py
index 82829bf..f2dd796 100644
--- a/erpnext/e_commerce/redisearch_utils.py
+++ b/erpnext/e_commerce/redisearch_utils.py
@@ -1,8 +1,12 @@
-# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
+import json
+
import frappe
+from frappe import _
from frappe.utils.redis_wrapper import RedisWrapper
+from redis import ResponseError
from redisearch import AutoCompleter, Client, IndexDefinition, Suggestion, TagField, TextField
WEBSITE_ITEM_INDEX = "website_items_index"
@@ -22,6 +26,12 @@
return [df.fieldname for df in valid_fields]
+def is_redisearch_enabled():
+ "Return True only if redisearch is loaded and enabled."
+ is_redisearch_enabled = frappe.db.get_single_value("E Commerce Settings", "is_redisearch_enabled")
+ return is_search_module_loaded() and is_redisearch_enabled
+
+
def is_search_module_loaded():
try:
cache = frappe.cache()
@@ -32,14 +42,14 @@
)
return "search" in parsed_output
except Exception:
- return False
+ return False # handling older redis versions
-def if_redisearch_loaded(function):
- "Decorator to check if Redisearch is loaded."
+def if_redisearch_enabled(function):
+ "Decorator to check if Redisearch is enabled."
def wrapper(*args, **kwargs):
- if is_search_module_loaded():
+ if is_redisearch_enabled():
func = function(*args, **kwargs)
return func
return
@@ -51,22 +61,25 @@
return "{0}|{1}".format(frappe.conf.db_name, key).encode("utf-8")
-@if_redisearch_loaded
+@if_redisearch_enabled
def create_website_items_index():
"Creates Index Definition."
# CREATE index
client = Client(make_key(WEBSITE_ITEM_INDEX), conn=frappe.cache())
- # DROP if already exists
try:
- client.drop_index()
- except Exception:
+ client.drop_index() # drop if already exists
+ except ResponseError:
+ # will most likely raise a ResponseError if index does not exist
+ # ignore and create index
pass
+ except Exception:
+ raise_redisearch_error()
idx_def = IndexDefinition([make_key(WEBSITE_ITEM_KEY_PREFIX)])
- # Based on e-commerce settings
+ # Index fields mentioned in e-commerce settings
idx_fields = frappe.db.get_single_value("E Commerce Settings", "search_index_fields")
idx_fields = idx_fields.split(",") if idx_fields else []
@@ -91,20 +104,20 @@
return TextField(field)
-@if_redisearch_loaded
+@if_redisearch_enabled
def insert_item_to_index(website_item_doc):
# Insert item to index
key = get_cache_key(website_item_doc.name)
cache = frappe.cache()
web_item = create_web_item_map(website_item_doc)
- for k, v in web_item.items():
- super(RedisWrapper, cache).hset(make_key(key), k, v)
+ for field, value in web_item.items():
+ super(RedisWrapper, cache).hset(make_key(key), field, value)
insert_to_name_ac(website_item_doc.web_item_name, website_item_doc.name)
-@if_redisearch_loaded
+@if_redisearch_enabled
def insert_to_name_ac(web_name, doc_name):
ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=frappe.cache())
ac.add_suggestions(Suggestion(web_name, payload=doc_name))
@@ -114,20 +127,20 @@
fields_to_index = get_fields_indexed()
web_item = {}
- for f in fields_to_index:
- web_item[f] = website_item_doc.get(f) or ""
+ for field in fields_to_index:
+ web_item[field] = website_item_doc.get(field) or ""
return web_item
-@if_redisearch_loaded
+@if_redisearch_enabled
def update_index_for_item(website_item_doc):
# Reinsert to Cache
insert_item_to_index(website_item_doc)
define_autocomplete_dictionary()
-@if_redisearch_loaded
+@if_redisearch_enabled
def delete_item_from_index(website_item_doc):
cache = frappe.cache()
key = get_cache_key(website_item_doc.name)
@@ -135,13 +148,13 @@
try:
cache.delete(key)
except Exception:
- return False
+ raise_redisearch_error()
delete_from_ac_dict(website_item_doc)
return True
-@if_redisearch_loaded
+@if_redisearch_enabled
def delete_from_ac_dict(website_item_doc):
"""Removes this items's name from autocomplete dictionary"""
cache = frappe.cache()
@@ -149,40 +162,60 @@
name_ac.delete(website_item_doc.web_item_name)
-@if_redisearch_loaded
+@if_redisearch_enabled
def define_autocomplete_dictionary():
- """Creates an autocomplete search dictionary for `name`.
- Also creats autocomplete dictionary for `categories` if
- checked in E Commerce Settings"""
+ """
+ Defines/Redefines an autocomplete search dictionary for Website Item Name.
+ Also creats autocomplete dictionary for Published Item Groups.
+ """
cache = frappe.cache()
- name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache)
- cat_ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=cache)
-
- ac_categories = frappe.db.get_single_value(
- "E Commerce Settings", "show_categories_in_search_autocomplete"
- )
+ item_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache)
+ item_group_ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=cache)
# Delete both autocomplete dicts
try:
cache.delete(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE))
cache.delete(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE))
except Exception:
- return False
+ raise_redisearch_error()
+ create_items_autocomplete_dict(autocompleter=item_ac)
+ create_item_groups_autocomplete_dict(autocompleter=item_group_ac)
+
+
+@if_redisearch_enabled
+def create_items_autocomplete_dict(autocompleter):
+ "Add items as suggestions in Autocompleter."
items = frappe.get_all(
"Website Item", fields=["web_item_name", "item_group"], filters={"published": 1}
)
for item in items:
- name_ac.add_suggestions(Suggestion(item.web_item_name))
- if ac_categories and item.item_group:
- cat_ac.add_suggestions(Suggestion(item.item_group))
-
- return True
+ autocompleter.add_suggestions(Suggestion(item.web_item_name))
-@if_redisearch_loaded
+@if_redisearch_enabled
+def create_item_groups_autocomplete_dict(autocompleter):
+ "Add item groups with weightage as suggestions in Autocompleter."
+ published_item_groups = frappe.get_all(
+ "Item Group", fields=["name", "route", "weightage"], filters={"show_in_website": 1}
+ )
+ if not published_item_groups:
+ return
+
+ for item_group in published_item_groups:
+ payload = json.dumps({"name": item_group.name, "route": item_group.route})
+ autocompleter.add_suggestions(
+ Suggestion(
+ string=item_group.name,
+ score=frappe.utils.flt(item_group.weightage) or 1.0,
+ payload=payload, # additional info that can be retrieved later
+ )
+ )
+
+
+@if_redisearch_enabled
def reindex_all_web_items():
items = frappe.get_all("Website Item", fields=get_fields_indexed(), filters={"published": True})
@@ -191,8 +224,8 @@
web_item = create_web_item_map(item)
key = make_key(get_cache_key(item.name))
- for k, v in web_item.items():
- super(RedisWrapper, cache).hset(key, k, v)
+ for field, value in web_item.items():
+ super(RedisWrapper, cache).hset(key, field, value)
def get_cache_key(name):
@@ -210,7 +243,12 @@
return fields_to_index
-# TODO: Remove later
-# # Figure out a way to run this at startup
-define_autocomplete_dictionary()
-create_website_items_index()
+def raise_redisearch_error():
+ "Create an Error Log and raise error."
+ traceback = frappe.get_traceback()
+ log = frappe.log_error(traceback, frappe._("Redisearch Error"))
+ log_link = frappe.utils.get_link_to_form("Error Log", log.name)
+
+ frappe.throw(
+ msg=_("Something went wrong. Check {0}").format(log_link), title=_("Redisearch Error")
+ )
diff --git a/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py b/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py
index 4db6f98..b3072c2 100644
--- a/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py
+++ b/erpnext/education/doctype/course_scheduling_tool/course_scheduling_tool.py
@@ -41,10 +41,8 @@
if self.day == calendar.day_name[getdate(date).weekday()]:
course_schedule = self.make_course_schedule(date)
try:
- print("pass")
course_schedule.save()
except OverlapError:
- print("fail")
course_schedules_errors.append(date)
else:
course_schedules.append(course_schedule)
diff --git a/erpnext/education/doctype/education_settings/education_settings.py b/erpnext/education/doctype/education_settings/education_settings.py
index cde5089..295aa3a 100644
--- a/erpnext/education/doctype/education_settings/education_settings.py
+++ b/erpnext/education/doctype/education_settings/education_settings.py
@@ -41,4 +41,4 @@
def update_website_context(context):
- context["lms_enabled"] = frappe.get_doc("Education Settings").enable_lms
+ context["lms_enabled"] = frappe.get_cached_doc("Education Settings").enable_lms
diff --git a/erpnext/education/doctype/student_admission/templates/student_admission_row.html b/erpnext/education/doctype/student_admission/templates/student_admission_row.html
index 529d651..dc4587b 100644
--- a/erpnext/education/doctype/student_admission/templates/student_admission_row.html
+++ b/erpnext/education/doctype/student_admission/templates/student_admission_row.html
@@ -1,6 +1,6 @@
<div class="web-list-item transaction-list-item">
{% set today = frappe.utils.getdate(frappe.utils.nowdate()) %}
- <a href = "{{ doc.route }}/" class="no-underline">
+ <a href = "{{ doc.route }}" class="no-underline">
<div class="row">
<div class="col-sm-4 bold">
<span class="indicator
diff --git a/erpnext/education/doctype/student_group_creation_tool/student_group_creation_tool.py b/erpnext/education/doctype/student_group_creation_tool/student_group_creation_tool.py
index 0fb2550..bbeb654 100644
--- a/erpnext/education/doctype/student_group_creation_tool/student_group_creation_tool.py
+++ b/erpnext/education/doctype/student_group_creation_tool/student_group_creation_tool.py
@@ -69,13 +69,13 @@
l = len(self.courses)
for d in self.courses:
if not d.student_group_name:
- frappe.throw(_("""Student Group Name is mandatory in row {0}""".format(d.idx)))
+ frappe.throw(_("Student Group Name is mandatory in row {0}").format(d.idx))
if d.group_based_on == "Course" and not d.course:
- frappe.throw(_("""Course is mandatory in row {0}""".format(d.idx)))
+ frappe.throw(_("Course is mandatory in row {0}").format(d.idx))
if d.group_based_on == "Batch" and not d.batch:
- frappe.throw(_("""Batch is mandatory in row {0}""".format(d.idx)))
+ frappe.throw(_("Batch is mandatory in row {0}").format(d.idx))
frappe.publish_realtime(
"student_group_creation_progress", {"progress": [d.idx, l]}, user=frappe.session.user
diff --git a/erpnext/erpnext_integrations/exotel_integration.py b/erpnext/erpnext_integrations/exotel_integration.py
index 1f5df67..522de9e 100644
--- a/erpnext/erpnext_integrations/exotel_integration.py
+++ b/erpnext/erpnext_integrations/exotel_integration.py
@@ -37,11 +37,26 @@
@frappe.whitelist(allow_guest=True)
def handle_missed_call(**kwargs):
- update_call_log(kwargs, "Missed")
+ status = ""
+ call_type = kwargs.get("CallType")
+ dial_call_status = kwargs.get("DialCallStatus")
+
+ if call_type == "incomplete" and dial_call_status == "no-answer":
+ status = "No Answer"
+ elif call_type == "client-hangup" and dial_call_status == "canceled":
+ status = "Canceled"
+ elif call_type == "incomplete" and dial_call_status == "failed":
+ status = "Failed"
+
+ update_call_log(kwargs, status)
def update_call_log(call_payload, status="Ringing", call_log=None):
call_log = call_log or get_call_log(call_payload)
+
+ # for a new sid, call_log and get_call_log will be empty so create a new log
+ if not call_log:
+ call_log = create_call_log(call_payload)
if call_log:
call_log.status = status
call_log.to = call_payload.get("DialWhomNumber")
@@ -53,16 +68,9 @@
def get_call_log(call_payload):
- call_log = frappe.get_all(
- "Call Log",
- {
- "id": call_payload.get("CallSid"),
- },
- limit=1,
- )
-
- if call_log:
- return frappe.get_doc("Call Log", call_log[0].name)
+ call_log_id = call_payload.get("CallSid")
+ if frappe.db.exists("Call Log", call_log_id):
+ return frappe.get_doc("Call Log", call_log_id)
def create_call_log(call_payload):
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index a2b1c41..fe0a89a 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -59,6 +59,7 @@
"Warehouse",
"Item Group",
"Customer Group",
+ "Supplier Group",
"Sales Person",
"Territory",
"Assessment Group",
@@ -469,7 +470,7 @@
],
"daily_long": [
"erpnext.setup.doctype.email_digest.email_digest.send",
- "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.update_latest_price_in_all_boms",
+ "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms",
"erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry.process_expired_allocation",
"erpnext.hr.utils.generate_leave_encashment",
"erpnext.hr.utils.allocate_earned_leaves",
diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py
index 7f4bd83..e43d40e 100644
--- a/erpnext/hr/doctype/attendance/attendance.py
+++ b/erpnext/hr/doctype/attendance/attendance.py
@@ -5,11 +5,21 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cint, cstr, formatdate, get_datetime, getdate, nowdate
+from frappe.query_builder import Criterion
+from frappe.utils import cint, cstr, formatdate, get_datetime, get_link_to_form, getdate, nowdate
+from erpnext.hr.doctype.shift_assignment.shift_assignment import has_overlapping_timings
from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_employee
+class DuplicateAttendanceError(frappe.ValidationError):
+ pass
+
+
+class OverlappingShiftAttendanceError(frappe.ValidationError):
+ pass
+
+
class Attendance(Document):
def validate(self):
from erpnext.controllers.status_updater import validate_status
@@ -18,6 +28,7 @@
validate_active_employee(self.employee)
self.validate_attendance_date()
self.validate_duplicate_record()
+ self.validate_overlapping_shift_attendance()
self.validate_employee_status()
self.check_leave_record()
@@ -35,21 +46,35 @@
frappe.throw(_("Attendance date can not be less than employee's joining date"))
def validate_duplicate_record(self):
- res = frappe.db.sql(
- """
- select name from `tabAttendance`
- where employee = %s
- and attendance_date = %s
- and name != %s
- and docstatus != 2
- """,
- (self.employee, getdate(self.attendance_date), self.name),
+ duplicate = get_duplicate_attendance_record(
+ self.employee, self.attendance_date, self.shift, self.name
)
- if res:
+
+ if duplicate:
frappe.throw(
- _("Attendance for employee {0} is already marked for the date {1}").format(
- frappe.bold(self.employee), frappe.bold(self.attendance_date)
- )
+ _("Attendance for employee {0} is already marked for the date {1}: {2}").format(
+ frappe.bold(self.employee),
+ frappe.bold(self.attendance_date),
+ get_link_to_form("Attendance", duplicate[0].name),
+ ),
+ title=_("Duplicate Attendance"),
+ exc=DuplicateAttendanceError,
+ )
+
+ def validate_overlapping_shift_attendance(self):
+ attendance = get_overlapping_shift_attendance(
+ self.employee, self.attendance_date, self.shift, self.name
+ )
+
+ if attendance:
+ frappe.throw(
+ _("Attendance for employee {0} is already marked for an overlapping shift {1}: {2}").format(
+ frappe.bold(self.employee),
+ frappe.bold(attendance.shift),
+ get_link_to_form("Attendance", attendance.name),
+ ),
+ title=_("Overlapping Shift Attendance"),
+ exc=OverlappingShiftAttendanceError,
)
def validate_employee_status(self):
@@ -103,6 +128,69 @@
frappe.throw(_("Employee {0} is not active or does not exist").format(self.employee))
+def get_duplicate_attendance_record(employee, attendance_date, shift, name=None):
+ attendance = frappe.qb.DocType("Attendance")
+ query = (
+ frappe.qb.from_(attendance)
+ .select(attendance.name)
+ .where((attendance.employee == employee) & (attendance.docstatus < 2))
+ )
+
+ if shift:
+ query = query.where(
+ Criterion.any(
+ [
+ Criterion.all(
+ [
+ ((attendance.shift.isnull()) | (attendance.shift == "")),
+ (attendance.attendance_date == attendance_date),
+ ]
+ ),
+ Criterion.all(
+ [
+ ((attendance.shift.isnotnull()) | (attendance.shift != "")),
+ (attendance.attendance_date == attendance_date),
+ (attendance.shift == shift),
+ ]
+ ),
+ ]
+ )
+ )
+ else:
+ query = query.where((attendance.attendance_date == attendance_date))
+
+ if name:
+ query = query.where(attendance.name != name)
+
+ return query.run(as_dict=True)
+
+
+def get_overlapping_shift_attendance(employee, attendance_date, shift, name=None):
+ if not shift:
+ return {}
+
+ attendance = frappe.qb.DocType("Attendance")
+ query = (
+ frappe.qb.from_(attendance)
+ .select(attendance.name, attendance.shift)
+ .where(
+ (attendance.employee == employee)
+ & (attendance.docstatus < 2)
+ & (attendance.attendance_date == attendance_date)
+ & (attendance.shift != shift)
+ )
+ )
+
+ if name:
+ query = query.where(attendance.name != name)
+
+ overlapping_attendance = query.run(as_dict=True)
+
+ if overlapping_attendance and has_overlapping_timings(shift, overlapping_attendance[0].shift):
+ return overlapping_attendance[0]
+ return {}
+
+
@frappe.whitelist()
def get_events(start, end, filters=None):
events = []
@@ -141,28 +229,39 @@
def mark_attendance(
- employee, attendance_date, status, shift=None, leave_type=None, ignore_validate=False
+ employee,
+ attendance_date,
+ status,
+ shift=None,
+ leave_type=None,
+ ignore_validate=False,
+ late_entry=False,
+ early_exit=False,
):
- if not frappe.db.exists(
- "Attendance",
- {"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")},
- ):
- company = frappe.db.get_value("Employee", employee, "company")
- attendance = frappe.get_doc(
- {
- "doctype": "Attendance",
- "employee": employee,
- "attendance_date": attendance_date,
- "status": status,
- "company": company,
- "shift": shift,
- "leave_type": leave_type,
- }
- )
- attendance.flags.ignore_validate = ignore_validate
- attendance.insert()
- attendance.submit()
- return attendance.name
+ if get_duplicate_attendance_record(employee, attendance_date, shift):
+ return
+
+ if get_overlapping_shift_attendance(employee, attendance_date, shift):
+ return
+
+ company = frappe.db.get_value("Employee", employee, "company")
+ attendance = frappe.get_doc(
+ {
+ "doctype": "Attendance",
+ "employee": employee,
+ "attendance_date": attendance_date,
+ "status": status,
+ "company": company,
+ "shift": shift,
+ "leave_type": leave_type,
+ "late_entry": late_entry,
+ "early_exit": early_exit,
+ }
+ )
+ attendance.flags.ignore_validate = ignore_validate
+ attendance.insert()
+ attendance.submit()
+ return attendance.name
@frappe.whitelist()
diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py
index 058bc93..762d0f7 100644
--- a/erpnext/hr/doctype/attendance/test_attendance.py
+++ b/erpnext/hr/doctype/attendance/test_attendance.py
@@ -6,6 +6,8 @@
from frappe.utils import add_days, get_year_ending, get_year_start, getdate, now_datetime, nowdate
from erpnext.hr.doctype.attendance.attendance import (
+ DuplicateAttendanceError,
+ OverlappingShiftAttendanceError,
get_month_map,
get_unmarked_days,
mark_attendance,
@@ -23,11 +25,112 @@
from_date = get_year_start(getdate())
to_date = get_year_ending(getdate())
self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
+ frappe.db.delete("Attendance")
+
+ def test_duplicate_attendance(self):
+ employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company")
+ date = nowdate()
+
+ mark_attendance(employee, date, "Present")
+ attendance = frappe.get_doc(
+ {
+ "doctype": "Attendance",
+ "employee": employee,
+ "attendance_date": date,
+ "status": "Absent",
+ "company": "_Test Company",
+ }
+ )
+
+ self.assertRaises(DuplicateAttendanceError, attendance.insert)
+
+ def test_duplicate_attendance_with_shift(self):
+ from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
+
+ employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company")
+ date = nowdate()
+
+ shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
+ mark_attendance(employee, date, "Present", shift=shift_1.name)
+
+ # attendance record with shift
+ attendance = frappe.get_doc(
+ {
+ "doctype": "Attendance",
+ "employee": employee,
+ "attendance_date": date,
+ "status": "Absent",
+ "company": "_Test Company",
+ "shift": shift_1.name,
+ }
+ )
+
+ self.assertRaises(DuplicateAttendanceError, attendance.insert)
+
+ # attendance record without any shift
+ attendance = frappe.get_doc(
+ {
+ "doctype": "Attendance",
+ "employee": employee,
+ "attendance_date": date,
+ "status": "Absent",
+ "company": "_Test Company",
+ }
+ )
+
+ self.assertRaises(DuplicateAttendanceError, attendance.insert)
+
+ def test_overlapping_shift_attendance_validation(self):
+ from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
+
+ employee = make_employee("test_overlap_attendance@example.com", company="_Test Company")
+ date = nowdate()
+
+ shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
+ shift_2 = setup_shift_type(shift_type="Shift 2", start_time="09:30:00", end_time="11:00:00")
+
+ mark_attendance(employee, date, "Present", shift=shift_1.name)
+
+ # attendance record with overlapping shift
+ attendance = frappe.get_doc(
+ {
+ "doctype": "Attendance",
+ "employee": employee,
+ "attendance_date": date,
+ "status": "Absent",
+ "company": "_Test Company",
+ "shift": shift_2.name,
+ }
+ )
+
+ self.assertRaises(OverlappingShiftAttendanceError, attendance.insert)
+
+ def test_allow_attendance_with_different_shifts(self):
+ # allows attendance with 2 different non-overlapping shifts
+ from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
+
+ employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company")
+ date = nowdate()
+
+ shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
+ shift_2 = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="12:00:00")
+
+ mark_attendance(employee, date, "Present", shift_1.name)
+ frappe.get_doc(
+ {
+ "doctype": "Attendance",
+ "employee": employee,
+ "attendance_date": date,
+ "status": "Absent",
+ "company": "_Test Company",
+ "shift": shift_2.name,
+ }
+ ).insert()
def test_mark_absent(self):
employee = make_employee("test_mark_absent@example.com")
date = nowdate()
- frappe.db.delete("Attendance", {"employee": employee, "attendance_date": date})
+
attendance = mark_attendance(employee, date, "Absent")
fetch_attendance = frappe.get_value(
"Attendance", {"employee": employee, "attendance_date": date, "status": "Absent"}
@@ -42,7 +145,6 @@
employee = make_employee(
"test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
)
- frappe.db.delete("Attendance", {"employee": employee})
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
@@ -67,8 +169,6 @@
employee = make_employee(
"test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
)
- frappe.db.delete("Attendance", {"employee": employee})
-
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
@@ -95,7 +195,6 @@
employee = make_employee(
"test_unmarked_days_as_per_doj@example.com", date_of_joining=doj, relieving_date=relieving_date
)
- frappe.db.delete("Attendance", {"employee": employee})
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
diff --git a/erpnext/hr/doctype/department_approver/department_approver.py b/erpnext/hr/doctype/department_approver/department_approver.py
index d849900..87bdddd 100644
--- a/erpnext/hr/doctype/department_approver/department_approver.py
+++ b/erpnext/hr/doctype/department_approver/department_approver.py
@@ -87,7 +87,7 @@
field_name, frappe.bold(employee.employee_name)
)
if department_list:
- error_msg += _(" or for Department: {0}").format(frappe.bold(employee_department))
+ error_msg += " " + _("or for Department: {0}").format(frappe.bold(employee_department))
frappe.throw(error_msg, title=_(field_name + " Missing"))
return set(tuple(approver) for approver in approvers)
diff --git a/erpnext/hr/doctype/employee/employee.json b/erpnext/hr/doctype/employee/employee.json
index d592a9c..8a12f3b 100644
--- a/erpnext/hr/doctype/employee/employee.json
+++ b/erpnext/hr/doctype/employee/employee.json
@@ -4,7 +4,7 @@
"allow_import": 1,
"allow_rename": 1,
"autoname": "naming_series:",
- "creation": "2013-03-07 09:04:18",
+ "creation": "2022-02-21 11:54:09.632218",
"doctype": "DocType",
"document_type": "Setup",
"editable_grid": 1,
@@ -813,11 +813,12 @@
"idx": 24,
"image_field": "image",
"links": [],
- "modified": "2021-06-17 11:31:37.730760",
+ "modified": "2022-03-22 13:44:37.088519",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee",
"name_case": "Title Case",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -857,7 +858,9 @@
],
"search_fields": "employee_name",
"show_name_in_global_search": 1,
+ "show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "employee_name"
}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/employee_checkin/employee_checkin.py b/erpnext/hr/doctype/employee_checkin/employee_checkin.py
index 87f48b7..64eb019 100644
--- a/erpnext/hr/doctype/employee_checkin/employee_checkin.py
+++ b/erpnext/hr/doctype/employee_checkin/employee_checkin.py
@@ -7,6 +7,10 @@
from frappe.model.document import Document
from frappe.utils import cint, get_datetime
+from erpnext.hr.doctype.attendance.attendance import (
+ get_duplicate_attendance_record,
+ get_overlapping_shift_attendance,
+)
from erpnext.hr.doctype.shift_assignment.shift_assignment import (
get_actual_start_end_datetime_of_shift,
)
@@ -33,24 +37,24 @@
shift_actual_timings = get_actual_start_end_datetime_of_shift(
self.employee, get_datetime(self.time), True
)
- if shift_actual_timings[0] and shift_actual_timings[1]:
+ if shift_actual_timings:
if (
- shift_actual_timings[2].shift_type.determine_check_in_and_check_out
+ shift_actual_timings.shift_type.determine_check_in_and_check_out
== "Strictly based on Log Type in Employee Checkin"
and not self.log_type
and not self.skip_auto_attendance
):
frappe.throw(
_("Log Type is required for check-ins falling in the shift: {0}.").format(
- shift_actual_timings[2].shift_type.name
+ shift_actual_timings.shift_type.name
)
)
if not self.attendance:
- self.shift = shift_actual_timings[2].shift_type.name
- self.shift_actual_start = shift_actual_timings[0]
- self.shift_actual_end = shift_actual_timings[1]
- self.shift_start = shift_actual_timings[2].start_datetime
- self.shift_end = shift_actual_timings[2].end_datetime
+ self.shift = shift_actual_timings.shift_type.name
+ self.shift_actual_start = shift_actual_timings.actual_start
+ self.shift_actual_end = shift_actual_timings.actual_end
+ self.shift_start = shift_actual_timings.start_datetime
+ self.shift_end = shift_actual_timings.end_datetime
else:
self.shift = None
@@ -136,10 +140,10 @@
return None
elif attendance_status in ("Present", "Absent", "Half Day"):
employee_doc = frappe.get_doc("Employee", employee)
- if not frappe.db.exists(
- "Attendance",
- {"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")},
- ):
+ duplicate = get_duplicate_attendance_record(employee, attendance_date, shift)
+ overlapping = get_overlapping_shift_attendance(employee, attendance_date, shift)
+
+ if not duplicate and not overlapping:
doc_dict = {
"doctype": "Attendance",
"employee": employee,
@@ -232,7 +236,7 @@
def time_diff_in_hours(start, end):
- return round((end - start).total_seconds() / 3600, 1)
+ return round(float((end - start).total_seconds()) / 3600, 2)
def find_index_in_dict(dict_list, key, value):
diff --git a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py
index 97f76b0..81b44f8 100644
--- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py
+++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py
@@ -2,10 +2,19 @@
# See license.txt
import unittest
-from datetime import timedelta
+from datetime import datetime, timedelta
import frappe
-from frappe.utils import now_datetime, nowdate
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import (
+ add_days,
+ get_time,
+ get_year_ending,
+ get_year_start,
+ getdate,
+ now_datetime,
+ nowdate,
+)
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.employee_checkin.employee_checkin import (
@@ -13,9 +22,22 @@
calculate_working_hours,
mark_attendance_and_link_log,
)
+from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
+from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
+from erpnext.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
-class TestEmployeeCheckin(unittest.TestCase):
+class TestEmployeeCheckin(FrappeTestCase):
+ def setUp(self):
+ frappe.db.delete("Shift Type")
+ frappe.db.delete("Shift Assignment")
+ frappe.db.delete("Employee Checkin")
+
+ from_date = get_year_start(getdate())
+ to_date = get_year_ending(getdate())
+ self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
+
def test_add_log_based_on_employee_field(self):
employee = make_employee("test_add_log_based_on_employee_field@example.com")
employee = frappe.get_doc("Employee", employee)
@@ -103,6 +125,163 @@
)
self.assertEqual(working_hours, (4.5, logs_type_2[1].time, logs_type_2[-1].time))
+ def test_fetch_shift(self):
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+
+ # shift setup for 8-12
+ shift_type = setup_shift_type()
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ # within shift time
+ timestamp = datetime.combine(date, get_time("08:45:00"))
+ log = make_checkin(employee, timestamp)
+ self.assertEqual(log.shift, shift_type.name)
+
+ # "begin checkin before shift time" = 60 mins, so should work for 7:00:00
+ timestamp = datetime.combine(date, get_time("07:00:00"))
+ log = make_checkin(employee, timestamp)
+ self.assertEqual(log.shift, shift_type.name)
+
+ # "allow checkout after shift end time" = 60 mins, so should work for 13:00:00
+ timestamp = datetime.combine(date, get_time("13:00:00"))
+ log = make_checkin(employee, timestamp)
+ self.assertEqual(log.shift, shift_type.name)
+
+ # should not fetch this shift beyond allowed time
+ timestamp = datetime.combine(date, get_time("13:01:00"))
+ log = make_checkin(employee, timestamp)
+ self.assertIsNone(log.shift)
+
+ def test_shift_start_and_end_timings(self):
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+
+ # shift setup for 8-12
+ shift_type = setup_shift_type()
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ timestamp = datetime.combine(date, get_time("08:45:00"))
+ log = make_checkin(employee, timestamp)
+
+ self.assertEqual(log.shift, shift_type.name)
+ self.assertEqual(log.shift_start, datetime.combine(date, get_time("08:00:00")))
+ self.assertEqual(log.shift_end, datetime.combine(date, get_time("12:00:00")))
+ self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("07:00:00")))
+ self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("13:00:00")))
+
+ def test_fetch_shift_based_on_default_shift(self):
+ employee = make_employee("test_default_shift@example.com", company="_Test Company")
+ default_shift = setup_shift_type(
+ shift_type="Default Shift", start_time="14:00:00", end_time="16:00:00"
+ )
+
+ date = getdate()
+ frappe.db.set_value("Employee", employee, "default_shift", default_shift.name)
+
+ timestamp = datetime.combine(date, get_time("14:45:00"))
+ log = make_checkin(employee, timestamp)
+
+ # should consider default shift
+ self.assertEqual(log.shift, default_shift.name)
+
+ def test_fetch_shift_spanning_over_two_days(self):
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+ shift_type = setup_shift_type(
+ shift_type="Midnight Shift", start_time="23:00:00", end_time="01:00:00"
+ )
+ date = getdate()
+ next_day = add_days(date, 1)
+ make_shift_assignment(shift_type.name, employee, date)
+
+ # log falls in the first day
+ timestamp = datetime.combine(date, get_time("23:00:00"))
+ log = make_checkin(employee, timestamp)
+
+ self.assertEqual(log.shift, shift_type.name)
+ self.assertEqual(log.shift_start, datetime.combine(date, get_time("23:00:00")))
+ self.assertEqual(log.shift_end, datetime.combine(next_day, get_time("01:00:00")))
+ self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("22:00:00")))
+ self.assertEqual(log.shift_actual_end, datetime.combine(next_day, get_time("02:00:00")))
+
+ log.delete()
+
+ # log falls in the second day
+ prev_day = add_days(date, -1)
+ timestamp = datetime.combine(date, get_time("01:30:00"))
+ log = make_checkin(employee, timestamp)
+ self.assertEqual(log.shift, shift_type.name)
+ self.assertEqual(log.shift_start, datetime.combine(prev_day, get_time("23:00:00")))
+ self.assertEqual(log.shift_end, datetime.combine(date, get_time("01:00:00")))
+ self.assertEqual(log.shift_actual_start, datetime.combine(prev_day, get_time("22:00:00")))
+ self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("02:00:00")))
+
+ def test_no_shift_fetched_on_holiday_as_per_shift_holiday_list(self):
+ date = getdate()
+ from_date = get_year_start(date)
+ to_date = get_year_ending(date)
+ holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
+
+ employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company")
+ setup_shift_type(shift_type="Test Holiday Shift", holiday_list=holiday_list)
+
+ first_sunday = get_first_sunday(holiday_list, for_date=date)
+ timestamp = datetime.combine(first_sunday, get_time("08:00:00"))
+ log = make_checkin(employee, timestamp)
+
+ self.assertIsNone(log.shift)
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_no_shift_fetched_on_holiday_as_per_employee_holiday_list(self):
+ employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company")
+ shift_type = setup_shift_type(shift_type="Test Holiday Shift")
+ shift_type.holiday_list = None
+ shift_type.save()
+
+ date = getdate()
+
+ first_sunday = get_first_sunday(self.holiday_list, for_date=date)
+ timestamp = datetime.combine(first_sunday, get_time("08:00:00"))
+ log = make_checkin(employee, timestamp)
+
+ self.assertIsNone(log.shift)
+
+ def test_consecutive_shift_assignments_overlapping_within_grace_period(self):
+ # test adjustment for start and end times if they are overlapping
+ # within "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time" periods
+ employee = make_employee("test_shift@example.com", company="_Test Company")
+
+ # 8 - 12
+ shift1 = setup_shift_type()
+ # 12:30 - 16:30
+ shift2 = setup_shift_type(
+ shift_type="Consecutive Shift", start_time="12:30:00", end_time="16:30:00"
+ )
+
+ # the actual start and end times (with grace) for these shifts are 7 - 13 and 11:30 - 17:30
+ date = getdate()
+ make_shift_assignment(shift1.name, employee, date)
+ make_shift_assignment(shift2.name, employee, date)
+
+ # log at 12:30 should set shift2 and actual start as 12 and not 11:30
+ timestamp = datetime.combine(date, get_time("12:30:00"))
+ log = make_checkin(employee, timestamp)
+ self.assertEqual(log.shift, shift2.name)
+ self.assertEqual(log.shift_start, datetime.combine(date, get_time("12:30:00")))
+ self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("12:00:00")))
+
+ # log at 12:00 should set shift1 and actual end as 12 and not 1 since the next shift's grace starts
+ timestamp = datetime.combine(date, get_time("12:00:00"))
+ log = make_checkin(employee, timestamp)
+ self.assertEqual(log.shift, shift1.name)
+ self.assertEqual(log.shift_end, datetime.combine(date, get_time("12:00:00")))
+ self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("12:00:00")))
+
+ # log at 12:01 should set shift2
+ timestamp = datetime.combine(date, get_time("12:01:00"))
+ log = make_checkin(employee, timestamp)
+ self.assertEqual(log.shift, shift2.name)
+
def make_n_checkins(employee, n, hours_to_reverse=1):
logs = [make_checkin(employee, now_datetime() - timedelta(hours=hours_to_reverse, minutes=n + 1))]
diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.js b/erpnext/hr/doctype/leave_allocation/leave_allocation.js
index 9742387..aef4412 100755
--- a/erpnext/hr/doctype/leave_allocation/leave_allocation.js
+++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.js
@@ -34,6 +34,15 @@
});
}
}
+
+ // make new leaves allocated field read only if allocation is created via leave policy assignment
+ // and leave type is earned leave, since these leaves would be allocated via the scheduler
+ if (frm.doc.leave_policy_assignment) {
+ frappe.db.get_value("Leave Type", frm.doc.leave_type, "is_earned_leave", (r) => {
+ if (r && cint(r.is_earned_leave))
+ frm.set_df_property("new_leaves_allocated", "read_only", 1);
+ });
+ }
},
expire_allocation: function(frm) {
diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.json b/erpnext/hr/doctype/leave_allocation/leave_allocation.json
index 9ecbe01..9d1db9b 100644
--- a/erpnext/hr/doctype/leave_allocation/leave_allocation.json
+++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.json
@@ -237,7 +237,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-01-18 19:15:53.262536",
+ "modified": "2022-04-07 09:50:33.145825",
"modified_by": "Administrator",
"module": "HR",
"name": "Leave Allocation",
@@ -281,5 +281,6 @@
"sort_order": "DESC",
"states": [],
"timeline_field": "employee",
- "title_field": "employee_name"
+ "title_field": "employee_name",
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.py b/erpnext/hr/doctype/leave_allocation/leave_allocation.py
index 98408af..27479a5 100755
--- a/erpnext/hr/doctype/leave_allocation/leave_allocation.py
+++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.py
@@ -39,11 +39,15 @@
def validate(self):
self.validate_period()
self.validate_allocation_overlap()
- self.validate_back_dated_allocation()
- self.set_total_leaves_allocated()
- self.validate_total_leaves_allocated()
self.validate_lwp()
set_employee_name(self)
+ self.set_total_leaves_allocated()
+ self.validate_leave_days_and_dates()
+
+ def validate_leave_days_and_dates(self):
+ # all validations that should run on save as well as on update after submit
+ self.validate_back_dated_allocation()
+ self.validate_total_leaves_allocated()
self.validate_leave_allocation_days()
def validate_leave_allocation_days(self):
@@ -56,14 +60,19 @@
leave_allocated = 0
if leave_period:
leave_allocated = get_leave_allocation_for_period(
- self.employee, self.leave_type, leave_period[0].from_date, leave_period[0].to_date
+ self.employee,
+ self.leave_type,
+ leave_period[0].from_date,
+ leave_period[0].to_date,
+ exclude_allocation=self.name,
)
leave_allocated += flt(self.new_leaves_allocated)
if leave_allocated > max_leaves_allowed:
frappe.throw(
_(
- "Total allocated leaves are more days than maximum allocation of {0} leave type for employee {1} in the period"
- ).format(self.leave_type, self.employee)
+ "Total allocated leaves are more than maximum allocation allowed for {0} leave type for employee {1} in the period"
+ ).format(self.leave_type, self.employee),
+ OverAllocationError,
)
def on_submit(self):
@@ -84,6 +93,12 @@
def on_update_after_submit(self):
if self.has_value_changed("new_leaves_allocated"):
self.validate_against_leave_applications()
+
+ # recalculate total leaves allocated
+ self.total_leaves_allocated = flt(self.unused_leaves) + flt(self.new_leaves_allocated)
+ # run required validations again since total leaves are being updated
+ self.validate_leave_days_and_dates()
+
leaves_to_be_added = self.new_leaves_allocated - self.get_existing_leave_count()
args = {
"leaves": leaves_to_be_added,
@@ -92,6 +107,7 @@
"is_carry_forward": 0,
}
create_leave_ledger_entry(self, args, True)
+ self.db_update()
def get_existing_leave_count(self):
ledger_entries = frappe.get_all(
@@ -279,27 +295,27 @@
)
-def get_leave_allocation_for_period(employee, leave_type, from_date, to_date):
- leave_allocated = 0
- leave_allocations = frappe.db.sql(
- """
- select employee, leave_type, from_date, to_date, total_leaves_allocated
- from `tabLeave Allocation`
- where employee=%(employee)s and leave_type=%(leave_type)s
- and docstatus=1
- 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))
- """,
- {"from_date": from_date, "to_date": to_date, "employee": employee, "leave_type": leave_type},
- as_dict=1,
- )
+def get_leave_allocation_for_period(
+ employee, leave_type, from_date, to_date, exclude_allocation=None
+):
+ from frappe.query_builder.functions import Sum
- if leave_allocations:
- for leave_alloc in leave_allocations:
- leave_allocated += leave_alloc.total_leaves_allocated
-
- return leave_allocated
+ Allocation = frappe.qb.DocType("Leave Allocation")
+ return (
+ frappe.qb.from_(Allocation)
+ .select(Sum(Allocation.total_leaves_allocated).as_("total_allocated_leaves"))
+ .where(
+ (Allocation.employee == employee)
+ & (Allocation.leave_type == leave_type)
+ & (Allocation.docstatus == 1)
+ & (Allocation.name != exclude_allocation)
+ & (
+ (Allocation.from_date.between(from_date, to_date))
+ | (Allocation.to_date.between(from_date, to_date))
+ | ((Allocation.from_date < from_date) & (Allocation.to_date > to_date))
+ )
+ )
+ ).run()[0][0] or 0.0
@frappe.whitelist()
diff --git a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
index a53d4a8..dde52d7 100644
--- a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
+++ b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
@@ -1,24 +1,26 @@
import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, add_months, getdate, nowdate
import erpnext
from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.leave_allocation.leave_allocation import (
+ BackDatedAllocationError,
+ OverAllocationError,
+)
from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation
from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type
-class TestLeaveAllocation(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
- frappe.db.sql("delete from `tabLeave Period`")
+class TestLeaveAllocation(FrappeTestCase):
+ def setUp(self):
+ frappe.db.delete("Leave Period")
+ frappe.db.delete("Leave Allocation")
- emp_id = make_employee("test_emp_leave_allocation@salary.com")
- cls.employee = frappe.get_doc("Employee", emp_id)
-
- def tearDown(self):
- frappe.db.rollback()
+ emp_id = make_employee("test_emp_leave_allocation@salary.com", company="_Test Company")
+ self.employee = frappe.get_doc("Employee", emp_id)
def test_overlapping_allocation(self):
leaves = [
@@ -65,7 +67,7 @@
# invalid period
self.assertRaises(frappe.ValidationError, doc.save)
- def test_allocated_leave_days_over_period(self):
+ def test_validation_for_over_allocation(self):
doc = frappe.get_doc(
{
"doctype": "Leave Allocation",
@@ -80,7 +82,135 @@
)
# allocated leave more than period
- self.assertRaises(frappe.ValidationError, doc.save)
+ self.assertRaises(OverAllocationError, doc.save)
+
+ def test_validation_for_over_allocation_post_submission(self):
+ allocation = frappe.get_doc(
+ {
+ "doctype": "Leave Allocation",
+ "__islocal": 1,
+ "employee": self.employee.name,
+ "employee_name": self.employee.employee_name,
+ "leave_type": "_Test Leave Type",
+ "from_date": getdate("2015-09-1"),
+ "to_date": getdate("2015-09-30"),
+ "new_leaves_allocated": 15,
+ }
+ ).submit()
+ allocation.reload()
+ # allocated leaves more than period after submission
+ allocation.new_leaves_allocated = 35
+ self.assertRaises(OverAllocationError, allocation.save)
+
+ def test_validation_for_over_allocation_based_on_leave_setup(self):
+ frappe.delete_doc_if_exists("Leave Period", "Test Allocation Period")
+ leave_period = frappe.get_doc(
+ dict(
+ name="Test Allocation Period",
+ doctype="Leave Period",
+ from_date=add_months(nowdate(), -6),
+ to_date=add_months(nowdate(), 6),
+ company="_Test Company",
+ is_active=1,
+ )
+ ).insert()
+
+ leave_type = create_leave_type(leave_type_name="_Test Allocation Validation", is_carry_forward=1)
+ leave_type.max_leaves_allowed = 25
+ leave_type.save()
+
+ # 15 leaves allocated in this period
+ allocation = create_leave_allocation(
+ leave_type=leave_type.name,
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
+ from_date=leave_period.from_date,
+ to_date=nowdate(),
+ )
+ allocation.submit()
+
+ # trying to allocate additional 15 leaves
+ allocation = create_leave_allocation(
+ leave_type=leave_type.name,
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
+ from_date=add_days(nowdate(), 1),
+ to_date=leave_period.to_date,
+ )
+ self.assertRaises(OverAllocationError, allocation.save)
+
+ def test_validation_for_over_allocation_based_on_leave_setup_post_submission(self):
+ frappe.delete_doc_if_exists("Leave Period", "Test Allocation Period")
+ leave_period = frappe.get_doc(
+ dict(
+ name="Test Allocation Period",
+ doctype="Leave Period",
+ from_date=add_months(nowdate(), -6),
+ to_date=add_months(nowdate(), 6),
+ company="_Test Company",
+ is_active=1,
+ )
+ ).insert()
+
+ leave_type = create_leave_type(leave_type_name="_Test Allocation Validation", is_carry_forward=1)
+ leave_type.max_leaves_allowed = 30
+ leave_type.save()
+
+ # 15 leaves allocated
+ allocation = create_leave_allocation(
+ leave_type=leave_type.name,
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
+ from_date=leave_period.from_date,
+ to_date=nowdate(),
+ )
+ allocation.submit()
+ allocation.reload()
+
+ # allocate additional 15 leaves
+ allocation = create_leave_allocation(
+ leave_type=leave_type.name,
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
+ from_date=add_days(nowdate(), 1),
+ to_date=leave_period.to_date,
+ )
+ allocation.submit()
+ allocation.reload()
+
+ # trying to allocate 25 leaves in 2nd alloc within leave period
+ # total leaves = 40 which is more than `max_leaves_allowed` setting i.e. 30
+ allocation.new_leaves_allocated = 25
+ self.assertRaises(OverAllocationError, allocation.save)
+
+ def test_validate_back_dated_allocation_update(self):
+ leave_type = create_leave_type(leave_type_name="_Test_CF_leave", is_carry_forward=1)
+ leave_type.save()
+
+ # initial leave allocation = 15
+ leave_allocation = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
+ leave_type="_Test_CF_leave",
+ from_date=add_months(nowdate(), -12),
+ to_date=add_months(nowdate(), -1),
+ carry_forward=0,
+ )
+ leave_allocation.submit()
+
+ # new_leaves = 15, carry_forwarded = 10
+ leave_allocation_1 = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
+ leave_type="_Test_CF_leave",
+ carry_forward=1,
+ )
+ leave_allocation_1.submit()
+
+ # try updating initial leave allocation
+ leave_allocation.reload()
+ leave_allocation.new_leaves_allocated = 20
+ self.assertRaises(BackDatedAllocationError, leave_allocation.save)
def test_carry_forward_calculation(self):
leave_type = create_leave_type(leave_type_name="_Test_CF_leave", is_carry_forward=1)
@@ -108,8 +238,10 @@
carry_forward=1,
)
leave_allocation_1.submit()
+ leave_allocation_1.reload()
self.assertEqual(leave_allocation_1.unused_leaves, 10)
+ self.assertEqual(leave_allocation_1.total_leaves_allocated, 25)
leave_allocation_1.cancel()
@@ -197,9 +329,12 @@
employee=self.employee.name, employee_name=self.employee.employee_name
)
leave_allocation.submit()
+ leave_allocation.reload()
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
+
leave_allocation.new_leaves_allocated = 40
leave_allocation.submit()
+ leave_allocation.reload()
self.assertTrue(leave_allocation.total_leaves_allocated, 40)
def test_leave_subtraction_after_submit(self):
@@ -207,9 +342,12 @@
employee=self.employee.name, employee_name=self.employee.employee_name
)
leave_allocation.submit()
+ leave_allocation.reload()
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
+
leave_allocation.new_leaves_allocated = 10
leave_allocation.submit()
+ leave_allocation.reload()
self.assertTrue(leave_allocation.total_leaves_allocated, 10)
def test_validation_against_leave_application_after_submit(self):
diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py
index 18c69f7..cd6b168 100755
--- a/erpnext/hr/doctype/leave_application/leave_application.py
+++ b/erpnext/hr/doctype/leave_application/leave_application.py
@@ -735,9 +735,9 @@
(Based on the include_holiday setting in Leave Type)"""
number_of_days = 0
if cint(half_day) == 1:
- if from_date == to_date:
+ if getdate(from_date) == getdate(to_date):
number_of_days = 0.5
- elif half_day_date and half_day_date <= to_date:
+ elif half_day_date and getdate(from_date) <= getdate(half_day_date) <= getdate(to_date):
number_of_days = date_diff(to_date, from_date) + 0.5
else:
number_of_days = date_diff(to_date, from_date) + 1
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index f33d0af..4c39e15 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -205,7 +205,12 @@
# creates separate leave ledger entries
frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1)
leave_type = frappe.get_doc(
- dict(leave_type_name="Test Leave Validation", doctype="Leave Type", allow_negative=True)
+ dict(
+ leave_type_name="Test Leave Validation",
+ doctype="Leave Type",
+ allow_negative=True,
+ include_holiday=True,
+ )
).insert()
employee = get_employee()
@@ -217,8 +222,14 @@
# application across allocations
# CASE 1: from date has no allocation, to date has an allocation / both dates have allocation
+ start_date = add_days(year_start, -10)
application = make_leave_application(
- employee.name, add_days(year_start, -10), add_days(year_start, 3), leave_type.name
+ employee.name,
+ start_date,
+ add_days(year_start, 3),
+ leave_type.name,
+ half_day=1,
+ half_day_date=start_date,
)
# 2 separate leave ledger entries
@@ -828,6 +839,7 @@
leave_type_name="_Test_CF_leave_expiry",
is_carry_forward=1,
expire_carry_forwarded_leaves_after_days=90,
+ include_holiday=True,
)
leave_type.submit()
@@ -840,6 +852,8 @@
leave_type=leave_type.name,
from_date=add_days(nowdate(), -3),
to_date=add_days(nowdate(), 7),
+ half_day=1,
+ half_day_date=add_days(nowdate(), -3),
description="_Test Reason",
company="_Test Company",
docstatus=1,
@@ -855,7 +869,7 @@
self.assertEqual(len(leave_ledger_entry), 2)
self.assertEqual(leave_ledger_entry[0].employee, leave_application.employee)
self.assertEqual(leave_ledger_entry[0].leave_type, leave_application.leave_type)
- self.assertEqual(leave_ledger_entry[0].leaves, -9)
+ self.assertEqual(leave_ledger_entry[0].leaves, -8.5)
self.assertEqual(leave_ledger_entry[1].leaves, -2)
def test_leave_application_creation_after_expiry(self):
diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py
index 5a12486..0b21c00 100644
--- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py
+++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py
@@ -3,83 +3,120 @@
from datetime import datetime, timedelta
+from typing import Dict, List
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cstr, getdate, now_datetime, nowdate
+from frappe.query_builder import Criterion
+from frappe.utils import cstr, get_datetime, get_link_to_form, get_time, getdate, now_datetime
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
from erpnext.hr.utils import validate_active_employee
+class OverlappingShiftError(frappe.ValidationError):
+ pass
+
+
class ShiftAssignment(Document):
def validate(self):
validate_active_employee(self.employee)
- self.validate_overlapping_dates()
+ self.validate_overlapping_shifts()
if self.end_date:
self.validate_from_to_dates("start_date", "end_date")
- def validate_overlapping_dates(self):
+ def validate_overlapping_shifts(self):
+ overlapping_dates = self.get_overlapping_dates()
+ if len(overlapping_dates):
+ # if dates are overlapping, check if timings are overlapping, else allow
+ overlapping_timings = has_overlapping_timings(self.shift_type, overlapping_dates[0].shift_type)
+ if overlapping_timings:
+ self.throw_overlap_error(overlapping_dates[0])
+
+ def get_overlapping_dates(self):
if not self.name:
self.name = "New Shift Assignment"
- condition = """and (
- end_date is null
- or
- %(start_date)s between start_date and end_date
- """
-
- if self.end_date:
- condition += """ or
- %(end_date)s between start_date and end_date
- or
- start_date between %(start_date)s and %(end_date)s
- ) """
- else:
- condition += """ ) """
-
- assigned_shifts = frappe.db.sql(
- """
- select name, shift_type, start_date ,end_date, docstatus, status
- from `tabShift Assignment`
- where
- employee=%(employee)s and docstatus = 1
- and name != %(name)s
- and status = "Active"
- {0}
- """.format(
- condition
- ),
- {
- "employee": self.employee,
- "shift_type": self.shift_type,
- "start_date": self.start_date,
- "end_date": self.end_date,
- "name": self.name,
- },
- as_dict=1,
+ shift = frappe.qb.DocType("Shift Assignment")
+ query = (
+ frappe.qb.from_(shift)
+ .select(shift.name, shift.shift_type, shift.docstatus, shift.status)
+ .where(
+ (shift.employee == self.employee)
+ & (shift.docstatus == 1)
+ & (shift.name != self.name)
+ & (shift.status == "Active")
+ )
)
- if len(assigned_shifts):
- self.throw_overlap_error(assigned_shifts[0])
+ if self.end_date:
+ query = query.where(
+ Criterion.any(
+ [
+ Criterion.any(
+ [
+ shift.end_date.isnull(),
+ ((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date)),
+ ]
+ ),
+ Criterion.any(
+ [
+ ((self.end_date >= shift.start_date) & (self.end_date <= shift.end_date)),
+ shift.start_date.between(self.start_date, self.end_date),
+ ]
+ ),
+ ]
+ )
+ )
+ else:
+ query = query.where(
+ shift.end_date.isnull()
+ | ((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date))
+ )
+
+ return query.run(as_dict=True)
def throw_overlap_error(self, shift_details):
shift_details = frappe._dict(shift_details)
if shift_details.docstatus == 1 and shift_details.status == "Active":
- msg = _("Employee {0} already has Active Shift {1}: {2}").format(
- frappe.bold(self.employee), frappe.bold(self.shift_type), frappe.bold(shift_details.name)
+ msg = _(
+ "Employee {0} already has an active Shift {1}: {2} that overlaps within this period."
+ ).format(
+ frappe.bold(self.employee),
+ frappe.bold(shift_details.shift_type),
+ get_link_to_form("Shift Assignment", shift_details.name),
)
- if shift_details.start_date:
- msg += _(" from {0}").format(getdate(self.start_date).strftime("%d-%m-%Y"))
- title = "Ongoing Shift"
- if shift_details.end_date:
- msg += _(" to {0}").format(getdate(self.end_date).strftime("%d-%m-%Y"))
- title = "Active Shift"
- if msg:
- frappe.throw(msg, title=title)
+ frappe.throw(msg, title=_("Overlapping Shifts"), exc=OverlappingShiftError)
+
+
+def has_overlapping_timings(shift_1: str, shift_2: str) -> bool:
+ """
+ Accepts two shift types and checks whether their timings are overlapping
+ """
+ curr_shift = frappe.db.get_value("Shift Type", shift_1, ["start_time", "end_time"], as_dict=True)
+ overlapping_shift = frappe.db.get_value(
+ "Shift Type", shift_2, ["start_time", "end_time"], as_dict=True
+ )
+
+ if (
+ (
+ curr_shift.start_time > overlapping_shift.start_time
+ and curr_shift.start_time < overlapping_shift.end_time
+ )
+ or (
+ curr_shift.end_time > overlapping_shift.start_time
+ and curr_shift.end_time < overlapping_shift.end_time
+ )
+ or (
+ curr_shift.start_time <= overlapping_shift.start_time
+ and curr_shift.end_time >= overlapping_shift.end_time
+ )
+ ):
+ return True
+ return False
@frappe.whitelist()
@@ -155,102 +192,195 @@
return shift_timing_map
+def get_shift_for_time(shifts: List[Dict], for_timestamp: datetime) -> Dict:
+ """Returns shift with details for given timestamp"""
+ valid_shifts = []
+
+ for entry in shifts:
+ shift_details = get_shift_details(entry.shift_type, for_timestamp=for_timestamp)
+
+ if (
+ get_datetime(shift_details.actual_start)
+ <= get_datetime(for_timestamp)
+ <= get_datetime(shift_details.actual_end)
+ ):
+ valid_shifts.append(shift_details)
+
+ valid_shifts.sort(key=lambda x: x["actual_start"])
+
+ if len(valid_shifts) > 1:
+ for i in range(len(valid_shifts) - 1):
+ # comparing 2 consecutive shifts and adjusting start and end times
+ # if they are overlapping within grace period
+ curr_shift = valid_shifts[i]
+ next_shift = valid_shifts[i + 1]
+
+ if curr_shift and next_shift:
+ next_shift.actual_start = (
+ curr_shift.end_datetime
+ if next_shift.actual_start < curr_shift.end_datetime
+ else next_shift.actual_start
+ )
+ curr_shift.actual_end = (
+ next_shift.actual_start
+ if curr_shift.actual_end > next_shift.actual_start
+ else curr_shift.actual_end
+ )
+
+ valid_shifts[i] = curr_shift
+ valid_shifts[i + 1] = next_shift
+
+ return get_exact_shift(valid_shifts, for_timestamp) or {}
+
+ return (valid_shifts and valid_shifts[0]) or {}
+
+
+def get_shifts_for_date(employee: str, for_timestamp: datetime) -> List[Dict[str, str]]:
+ """Returns list of shifts with details for given date"""
+ assignment = frappe.qb.DocType("Shift Assignment")
+
+ return (
+ frappe.qb.from_(assignment)
+ .select(assignment.name, assignment.shift_type)
+ .where(
+ (assignment.employee == employee)
+ & (assignment.docstatus == 1)
+ & (assignment.status == "Active")
+ & (assignment.start_date <= getdate(for_timestamp.date()))
+ & (
+ Criterion.any(
+ [
+ assignment.end_date.isnull(),
+ (assignment.end_date.isnotnull() & (getdate(for_timestamp.date()) >= assignment.end_date)),
+ ]
+ )
+ )
+ )
+ ).run(as_dict=True)
+
+
+def get_shift_for_timestamp(employee: str, for_timestamp: datetime) -> Dict:
+ shifts = get_shifts_for_date(employee, for_timestamp)
+ if shifts:
+ return get_shift_for_time(shifts, for_timestamp)
+ return {}
+
+
def get_employee_shift(
- employee, for_date=None, consider_default_shift=False, next_shift_direction=None
-):
+ employee: str,
+ for_timestamp: datetime = None,
+ consider_default_shift: bool = False,
+ next_shift_direction: str = None,
+) -> Dict:
"""Returns a Shift Type for the given employee on the given date. (excluding the holidays)
:param employee: Employee for which shift is required.
- :param for_date: Date on which shift are required
+ :param for_timestamp: DateTime on which shift is required
: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()
+ if for_timestamp is None:
+ for_timestamp = now_datetime()
+
+ shift_details = get_shift_for_timestamp(employee, for_timestamp)
+
+ # if shift assignment is not found, consider default shift
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"],
+ if not shift_details and consider_default_shift:
+ shift_details = get_shift_details(default_shift, for_timestamp)
+
+ # if its a holiday, reset
+ if shift_details and is_holiday_date(employee, shift_details):
+ shift_details = None
+
+ # if no shift is found, find next or prev shift assignment based on direction
+ if not shift_details and next_shift_direction:
+ shift_details = get_prev_or_next_shift(
+ employee, for_timestamp, consider_default_shift, default_shift, next_shift_direction
+ )
+
+ return shift_details or {}
+
+
+def get_prev_or_next_shift(
+ employee: str,
+ for_timestamp: datetime,
+ consider_default_shift: bool,
+ default_shift: str,
+ next_shift_direction: str,
+) -> Dict:
+ """Returns a dict of shift details for the next or prev shift based on the next_shift_direction"""
+ MAX_DAYS = 366
+ shift_details = {}
+
+ if consider_default_shift and default_shift:
+ direction = -1 if next_shift_direction == "reverse" else 1
+ for i in range(MAX_DAYS):
+ date = for_timestamp + timedelta(days=direction * (i + 1))
+ shift_details = get_employee_shift(employee, date, consider_default_shift, None)
+ if shift_details:
+ break
+ else:
+ direction = "<" if next_shift_direction == "reverse" else ">"
+ sort_order = "desc" if next_shift_direction == "reverse" else "asc"
+ dates = frappe.db.get_all(
+ "Shift Assignment",
+ ["start_date", "end_date"],
+ {
+ "employee": employee,
+ "start_date": (direction, for_timestamp.date()),
+ "docstatus": 1,
+ "status": "Active",
+ },
+ as_list=True,
+ limit=MAX_DAYS,
+ order_by="start_date " + sort_order,
+ )
+
+ if dates:
+ for date in dates:
+ if date[1] and date[1] < for_timestamp.date():
+ continue
+ shift_details = get_employee_shift(
+ employee, datetime.combine(date[0], for_timestamp.time()), consider_default_shift, None
+ )
+ if shift_details:
+ break
+
+ return shift_details or {}
+
+
+def is_holiday_date(employee: str, shift_details: Dict) -> bool:
+ holiday_list_name = frappe.db.get_value(
+ "Shift Type", shift_details.shift_type.name, "holiday_list"
)
- if shift_assignment_details:
- shift_type_name = shift_assignment_details[0]
+ if not holiday_list_name:
+ holiday_list_name = get_holiday_list_for_employee(employee, False)
- # if end_date present means that shift is over after end_date else it is a ongoing shift.
- if shift_assignment_details[1] and for_date >= shift_assignment_details[1]:
- shift_type_name = None
-
- if not shift_type_name and consider_default_shift:
- shift_type_name = default_shift
- if shift_type_name:
- holiday_list_name = frappe.db.get_value("Shift Type", shift_type_name, "holiday_list")
- if not holiday_list_name:
- holiday_list_name = get_holiday_list_for_employee(employee, False)
- if holiday_list_name and is_holiday(holiday_list_name, for_date):
- shift_type_name = None
-
- if not shift_type_name and next_shift_direction:
- MAX_DAYS = 366
- if consider_default_shift and default_shift:
- direction = -1 if next_shift_direction == "reverse" else +1
- for i in range(MAX_DAYS):
- date = for_date + timedelta(days=direction * (i + 1))
- shift_details = get_employee_shift(employee, date, consider_default_shift, None)
- if shift_details:
- shift_type_name = shift_details.shift_type.name
- for_date = date
- break
- else:
- direction = "<" if next_shift_direction == "reverse" else ">"
- sort_order = "desc" if next_shift_direction == "reverse" else "asc"
- dates = frappe.db.get_all(
- "Shift Assignment",
- ["start_date", "end_date"],
- {
- "employee": employee,
- "start_date": (direction, for_date),
- "docstatus": "1",
- "status": "Active",
- },
- as_list=True,
- limit=MAX_DAYS,
- order_by="start_date " + sort_order,
- )
-
- if dates:
- for date in dates:
- if date[1] and date[1] < for_date:
- continue
- shift_details = get_employee_shift(employee, date[0], consider_default_shift, None)
- if shift_details:
- shift_type_name = shift_details.shift_type.name
- for_date = date[0]
- break
-
- return get_shift_details(shift_type_name, for_date)
+ return holiday_list_name and is_holiday(holiday_list_name, shift_details.start_datetime.date())
-def get_employee_shift_timings(employee, for_timestamp=None, consider_default_shift=False):
+def get_employee_shift_timings(
+ employee: str, for_timestamp: datetime = None, consider_default_shift: bool = False
+) -> List[Dict]:
"""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")
+ curr_shift = get_employee_shift(employee, for_timestamp, consider_default_shift, "forward")
if curr_shift:
next_shift = get_employee_shift(
- employee,
- curr_shift.start_datetime.date() + timedelta(days=1),
- consider_default_shift,
- "forward",
+ employee, curr_shift.start_datetime + timedelta(days=1), consider_default_shift, "forward"
)
prev_shift = get_employee_shift(
- employee, for_timestamp.date() + timedelta(days=-1), consider_default_shift, "reverse"
+ employee, for_timestamp + timedelta(days=-1), consider_default_shift, "reverse"
)
if curr_shift:
+ # adjust actual start and end times if they are overlapping with grace period (before start and after end)
if prev_shift:
curr_shift.actual_start = (
prev_shift.end_datetime
@@ -273,31 +403,102 @@
if curr_shift.actual_end > next_shift.actual_start
else curr_shift.actual_end
)
+
return prev_shift, curr_shift, next_shift
-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,
- 'start_datetime' - Date and Time of shift start on given date,
- 'end_datetime' - Date and Time of shift end on given date,
- 'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time',
- 'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time'(None is returned if this is zero)
+def get_actual_start_end_datetime_of_shift(
+ employee: str, for_timestamp: datetime, consider_default_shift: bool = False
+) -> Dict:
+ """Returns a Dict containing shift details with actual_start and actual_end datetime values
+ Here 'actual' means taking into account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time".
+ Empty Dict is returned if the timestamp is outside any actual shift timings.
- :param shift_type_name: shift type name for which shift_details is required.
- :param for_date: Date on which shift_details are required
+ :param employee (str): Employee name
+ :param for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime
+ :param consider_default_shift (bool, optional): Flag (defaults to False) to specify whether to consider
+ default shift in employee master if no shift assignment is found
+ """
+ shift_timings_as_per_timestamp = get_employee_shift_timings(
+ employee, for_timestamp, consider_default_shift
+ )
+ return get_exact_shift(shift_timings_as_per_timestamp, for_timestamp)
+
+
+def get_exact_shift(shifts: List, for_timestamp: datetime) -> Dict:
+ """Returns the shift details (dict) for the exact shift in which the 'for_timestamp' value falls among multiple shifts"""
+ shift_details = dict()
+ timestamp_list = []
+
+ for shift in shifts:
+ if shift:
+ timestamp_list.extend([shift.actual_start, shift.actual_end])
+ else:
+ timestamp_list.extend([None, None])
+
+ timestamp_index = None
+ for index, timestamp in enumerate(timestamp_list):
+ if not timestamp:
+ continue
+
+ if for_timestamp < timestamp:
+ timestamp_index = index
+ elif for_timestamp == timestamp:
+ # on timestamp boundary
+ if index % 2 == 1:
+ timestamp_index = index
+ else:
+ timestamp_index = index + 1
+
+ if timestamp_index:
+ break
+
+ if timestamp_index and timestamp_index % 2 == 1:
+ shift_details = shifts[int((timestamp_index - 1) / 2)]
+
+ return shift_details
+
+
+def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> Dict:
+ """Returns a Dict containing shift details with the following data:
+ 'shift_type' - Object of DocType Shift Type,
+ 'start_datetime' - datetime of shift start on given timestamp,
+ 'end_datetime' - datetime of shift end on given timestamp,
+ 'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time',
+ 'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time' (None is returned if this is zero)
+
+ :param shift_type_name (str): shift type name for which shift_details are required.
+ :param for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime
"""
if not shift_type_name:
- return None
- if not for_date:
- for_date = nowdate()
+ return {}
+
+ if for_timestamp is None:
+ for_timestamp = now_datetime()
+
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
+ shift_actual_start = shift_type.start_time - timedelta(
+ minutes=shift_type.begin_check_in_before_shift_start_time
)
- end_datetime = datetime.combine(for_date, datetime.min.time()) + shift_type.end_time
+
+ if shift_type.start_time > shift_type.end_time:
+ # shift spans accross 2 different days
+ if get_time(for_timestamp.time()) >= get_time(shift_actual_start):
+ # if for_timestamp is greater than start time, it's within the first day
+ start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time
+ for_timestamp = for_timestamp + timedelta(days=1)
+ end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time
+
+ elif get_time(for_timestamp.time()) < get_time(shift_actual_start):
+ # if for_timestamp is less than start time, it's within the second day
+ end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time
+ for_timestamp = for_timestamp + timedelta(days=-1)
+ start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time
+ else:
+ # start and end timings fall on the same day
+ start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time
+ end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time
+
actual_start = start_datetime - timedelta(
minutes=shift_type.begin_check_in_before_shift_start_time
)
@@ -312,34 +513,3 @@
"actual_end": actual_end,
}
)
-
-
-def get_actual_start_end_datetime_of_shift(employee, for_datetime, consider_default_shift=False):
- """Takes a datetime and returns the 'actual' start datetime and end datetime of the shift in which the timestamp belongs.
- Here 'actual' means - taking in to account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time".
- None is returned if the timestamp is outside any actual shift timings.
- Shift Details is also returned(current/upcoming i.e. if timestamp not in any actual shift then details of next shift returned)
- """
- actual_shift_start = actual_shift_end = shift_details = None
- shift_timings_as_per_timestamp = get_employee_shift_timings(
- employee, for_datetime, consider_default_shift
- )
- timestamp_list = []
- for shift in shift_timings_as_per_timestamp:
- if shift:
- timestamp_list.extend([shift.actual_start, shift.actual_end])
- else:
- timestamp_list.extend([None, None])
- timestamp_index = None
- for index, timestamp in enumerate(timestamp_list):
- if timestamp and for_datetime <= timestamp:
- timestamp_index = index
- break
- if timestamp_index and timestamp_index % 2 == 1:
- shift_details = shift_timings_as_per_timestamp[int((timestamp_index - 1) / 2)]
- actual_shift_start = shift_details.actual_start
- actual_shift_end = shift_details.actual_end
- elif timestamp_index:
- shift_details = shift_timings_as_per_timestamp[int(timestamp_index / 2)]
-
- return actual_shift_start, actual_shift_end, shift_details
diff --git a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py
index 4a1ec29..0fe9108 100644
--- a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py
+++ b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py
@@ -4,16 +4,23 @@
import unittest
import frappe
-from frappe.utils import add_days, nowdate
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, getdate, nowdate
+
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.shift_assignment.shift_assignment import OverlappingShiftError
+from erpnext.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type
test_dependencies = ["Shift Type"]
-class TestShiftAssignment(unittest.TestCase):
+class TestShiftAssignment(FrappeTestCase):
def setUp(self):
- frappe.db.sql("delete from `tabShift Assignment`")
+ frappe.db.delete("Shift Assignment")
+ frappe.db.delete("Shift Type")
def test_make_shift_assignment(self):
+ setup_shift_type(shift_type="Day Shift")
shift_assignment = frappe.get_doc(
{
"doctype": "Shift Assignment",
@@ -29,7 +36,7 @@
def test_overlapping_for_ongoing_shift(self):
# shift should be Ongoing if Only start_date is present and status = Active
-
+ setup_shift_type(shift_type="Day Shift")
shift_assignment_1 = frappe.get_doc(
{
"doctype": "Shift Assignment",
@@ -54,11 +61,11 @@
}
)
- self.assertRaises(frappe.ValidationError, shift_assignment.save)
+ self.assertRaises(OverlappingShiftError, shift_assignment.save)
def test_overlapping_for_fixed_period_shift(self):
# shift should is for Fixed period if Only start_date and end_date both are present and status = Active
-
+ setup_shift_type(shift_type="Day Shift")
shift_assignment_1 = frappe.get_doc(
{
"doctype": "Shift Assignment",
@@ -85,4 +92,65 @@
}
)
- self.assertRaises(frappe.ValidationError, shift_assignment_3.save)
+ self.assertRaises(OverlappingShiftError, shift_assignment_3.save)
+
+ def test_overlapping_for_a_fixed_period_shift_and_ongoing_shift(self):
+ employee = make_employee("test_shift_assignment@example.com", company="_Test Company")
+
+ # shift setup for 8-12
+ shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
+ date = getdate()
+ # shift with end date
+ make_shift_assignment(shift_type.name, employee, date, add_days(date, 30))
+
+ # shift setup for 11-15
+ shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00")
+ date = getdate()
+
+ # shift assignment without end date
+ shift2 = frappe.get_doc(
+ {
+ "doctype": "Shift Assignment",
+ "shift_type": shift_type.name,
+ "company": "_Test Company",
+ "employee": employee,
+ "start_date": date,
+ }
+ )
+ self.assertRaises(OverlappingShiftError, shift2.insert)
+
+ def test_overlap_validation_for_shifts_on_same_day_with_overlapping_timeslots(self):
+ employee = make_employee("test_shift_assignment@example.com", company="_Test Company")
+
+ # shift setup for 8-12
+ shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ # shift setup for 11-15
+ shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00")
+ date = getdate()
+
+ shift2 = frappe.get_doc(
+ {
+ "doctype": "Shift Assignment",
+ "shift_type": shift_type.name,
+ "company": "_Test Company",
+ "employee": employee,
+ "start_date": date,
+ }
+ )
+ self.assertRaises(OverlappingShiftError, shift2.insert)
+
+ def test_multiple_shift_assignments_for_same_day(self):
+ employee = make_employee("test_shift_assignment@example.com", company="_Test Company")
+
+ # shift setup for 8-12
+ shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ # shift setup for 13-15
+ shift_type = setup_shift_type(shift_type="Shift 2", start_time="13:00:00", end_time="15:00:00")
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
diff --git a/erpnext/hr/doctype/shift_request/shift_request.py b/erpnext/hr/doctype/shift_request/shift_request.py
index 1e3e8ff..2bee240 100644
--- a/erpnext/hr/doctype/shift_request/shift_request.py
+++ b/erpnext/hr/doctype/shift_request/shift_request.py
@@ -5,12 +5,14 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import formatdate, getdate
+from frappe.query_builder import Criterion
+from frappe.utils import get_link_to_form, getdate
+from erpnext.hr.doctype.shift_assignment.shift_assignment import has_overlapping_timings
from erpnext.hr.utils import share_doc_with_approver, validate_active_employee
-class OverlapError(frappe.ValidationError):
+class OverlappingShiftRequestError(frappe.ValidationError):
pass
@@ -18,7 +20,7 @@
def validate(self):
validate_active_employee(self.employee)
self.validate_dates()
- self.validate_shift_request_overlap_dates()
+ self.validate_overlapping_shift_requests()
self.validate_approver()
self.validate_default_shift()
@@ -79,37 +81,60 @@
if self.from_date and self.to_date and (getdate(self.to_date) < getdate(self.from_date)):
frappe.throw(_("To date cannot be before from date"))
- def validate_shift_request_overlap_dates(self):
+ def validate_overlapping_shift_requests(self):
+ overlapping_dates = self.get_overlapping_dates()
+ if len(overlapping_dates):
+ # if dates are overlapping, check if timings are overlapping, else allow
+ overlapping_timings = has_overlapping_timings(self.shift_type, overlapping_dates[0].shift_type)
+ if overlapping_timings:
+ self.throw_overlap_error(overlapping_dates[0])
+
+ def get_overlapping_dates(self):
if not self.name:
self.name = "New Shift Request"
- d = frappe.db.sql(
- """
- select
- name, shift_type, from_date, to_date
- from `tabShift Request`
- where employee = %(employee)s and docstatus < 2
- and ((%(from_date)s >= from_date
- and %(from_date)s <= to_date) or
- ( %(to_date)s >= from_date
- and %(to_date)s <= to_date ))
- and name != %(name)s""",
- {
- "employee": self.employee,
- "shift_type": self.shift_type,
- "from_date": self.from_date,
- "to_date": self.to_date,
- "name": self.name,
- },
- as_dict=1,
+ shift = frappe.qb.DocType("Shift Request")
+ query = (
+ frappe.qb.from_(shift)
+ .select(shift.name, shift.shift_type)
+ .where((shift.employee == self.employee) & (shift.docstatus < 2) & (shift.name != self.name))
)
- for date_overlap in d:
- if date_overlap["name"]:
- self.throw_overlap_error(date_overlap)
+ if self.to_date:
+ query = query.where(
+ Criterion.any(
+ [
+ Criterion.any(
+ [
+ shift.to_date.isnull(),
+ ((self.from_date >= shift.from_date) & (self.from_date <= shift.to_date)),
+ ]
+ ),
+ Criterion.any(
+ [
+ ((self.to_date >= shift.from_date) & (self.to_date <= shift.to_date)),
+ shift.from_date.between(self.from_date, self.to_date),
+ ]
+ ),
+ ]
+ )
+ )
+ else:
+ query = query.where(
+ shift.to_date.isnull()
+ | ((self.from_date >= shift.from_date) & (self.from_date <= shift.to_date))
+ )
- def throw_overlap_error(self, d):
- msg = _("Employee {0} has already applied for {1} between {2} and {3} : ").format(
- self.employee, d["shift_type"], formatdate(d["from_date"]), formatdate(d["to_date"])
- ) + """ <b><a href="/app/Form/Shift Request/{0}">{0}</a></b>""".format(d["name"])
- frappe.throw(msg, OverlapError)
+ return query.run(as_dict=True)
+
+ def throw_overlap_error(self, shift_details):
+ shift_details = frappe._dict(shift_details)
+ msg = _(
+ "Employee {0} has already applied for Shift {1}: {2} that overlaps within this period"
+ ).format(
+ frappe.bold(self.employee),
+ frappe.bold(shift_details.shift_type),
+ get_link_to_form("Shift Request", shift_details.name),
+ )
+
+ frappe.throw(msg, title=_("Overlapping Shift Requests"), exc=OverlappingShiftRequestError)
diff --git a/erpnext/hr/doctype/shift_request/test_shift_request.py b/erpnext/hr/doctype/shift_request/test_shift_request.py
index b4f5177..c47418c 100644
--- a/erpnext/hr/doctype/shift_request/test_shift_request.py
+++ b/erpnext/hr/doctype/shift_request/test_shift_request.py
@@ -4,23 +4,24 @@
import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, nowdate
from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.shift_request.shift_request import OverlappingShiftRequestError
+from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type
test_dependencies = ["Shift Type"]
-class TestShiftRequest(unittest.TestCase):
+class TestShiftRequest(FrappeTestCase):
def setUp(self):
- for doctype in ["Shift Request", "Shift Assignment"]:
- frappe.db.sql("delete from `tab{doctype}`".format(doctype=doctype))
-
- def tearDown(self):
- frappe.db.rollback()
+ for doctype in ["Shift Request", "Shift Assignment", "Shift Type"]:
+ frappe.db.delete(doctype)
def test_make_shift_request(self):
"Test creation/updation of Shift Assignment from Shift Request."
+ setup_shift_type(shift_type="Day Shift")
department = frappe.get_value("Employee", "_T-Employee-00001", "department")
set_shift_approver(department)
approver = frappe.db.sql(
@@ -48,6 +49,7 @@
self.assertEqual(shift_assignment_docstatus, 2)
def test_shift_request_approver_perms(self):
+ setup_shift_type(shift_type="Day Shift")
employee = frappe.get_doc("Employee", "_T-Employee-00001")
user = "test_approver_perm_emp@example.com"
make_employee(user, "_Test Company")
@@ -87,6 +89,145 @@
employee.shift_request_approver = ""
employee.save()
+ def test_overlap_for_request_without_to_date(self):
+ # shift should be Ongoing if Only from_date is present
+ user = "test_shift_request@example.com"
+ employee = make_employee(user, company="_Test Company", shift_request_approver=user)
+ setup_shift_type(shift_type="Day Shift")
+
+ shift_request = frappe.get_doc(
+ {
+ "doctype": "Shift Request",
+ "shift_type": "Day Shift",
+ "company": "_Test Company",
+ "employee": employee,
+ "from_date": nowdate(),
+ "approver": user,
+ "status": "Approved",
+ }
+ ).submit()
+
+ shift_request = frappe.get_doc(
+ {
+ "doctype": "Shift Request",
+ "shift_type": "Day Shift",
+ "company": "_Test Company",
+ "employee": employee,
+ "from_date": add_days(nowdate(), 2),
+ "approver": user,
+ "status": "Approved",
+ }
+ )
+
+ self.assertRaises(OverlappingShiftRequestError, shift_request.save)
+
+ def test_overlap_for_request_with_from_and_to_dates(self):
+ user = "test_shift_request@example.com"
+ employee = make_employee(user, company="_Test Company", shift_request_approver=user)
+ setup_shift_type(shift_type="Day Shift")
+
+ shift_request = frappe.get_doc(
+ {
+ "doctype": "Shift Request",
+ "shift_type": "Day Shift",
+ "company": "_Test Company",
+ "employee": employee,
+ "from_date": nowdate(),
+ "to_date": add_days(nowdate(), 30),
+ "approver": user,
+ "status": "Approved",
+ }
+ ).submit()
+
+ shift_request = frappe.get_doc(
+ {
+ "doctype": "Shift Request",
+ "shift_type": "Day Shift",
+ "company": "_Test Company",
+ "employee": employee,
+ "from_date": add_days(nowdate(), 10),
+ "to_date": add_days(nowdate(), 35),
+ "approver": user,
+ "status": "Approved",
+ }
+ )
+
+ self.assertRaises(OverlappingShiftRequestError, shift_request.save)
+
+ def test_overlapping_for_a_fixed_period_shift_and_ongoing_shift(self):
+ user = "test_shift_request@example.com"
+ employee = make_employee(user, company="_Test Company", shift_request_approver=user)
+
+ # shift setup for 8-12
+ shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
+ date = nowdate()
+
+ # shift with end date
+ frappe.get_doc(
+ {
+ "doctype": "Shift Request",
+ "shift_type": shift_type.name,
+ "company": "_Test Company",
+ "employee": employee,
+ "from_date": date,
+ "to_date": add_days(date, 30),
+ "approver": user,
+ "status": "Approved",
+ }
+ ).submit()
+
+ # shift setup for 11-15
+ shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00")
+ shift2 = frappe.get_doc(
+ {
+ "doctype": "Shift Request",
+ "shift_type": shift_type.name,
+ "company": "_Test Company",
+ "employee": employee,
+ "from_date": date,
+ "approver": user,
+ "status": "Approved",
+ }
+ )
+
+ self.assertRaises(OverlappingShiftRequestError, shift2.insert)
+
+ def test_allow_non_overlapping_shift_requests_for_same_day(self):
+ user = "test_shift_request@example.com"
+ employee = make_employee(user, company="_Test Company", shift_request_approver=user)
+
+ # shift setup for 8-12
+ shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00")
+ date = nowdate()
+
+ # shift with end date
+ frappe.get_doc(
+ {
+ "doctype": "Shift Request",
+ "shift_type": shift_type.name,
+ "company": "_Test Company",
+ "employee": employee,
+ "from_date": date,
+ "to_date": add_days(date, 30),
+ "approver": user,
+ "status": "Approved",
+ }
+ ).submit()
+
+ # shift setup for 13-15
+ shift_type = setup_shift_type(shift_type="Shift 2", start_time="13:00:00", end_time="15:00:00")
+ frappe.get_doc(
+ {
+ "doctype": "Shift Request",
+ "shift_type": shift_type.name,
+ "company": "_Test Company",
+ "employee": employee,
+ "from_date": date,
+ "approver": user,
+ "status": "Approved",
+ }
+ ).submit()
+
def set_shift_approver(department):
department_doc = frappe.get_doc("Department", department)
diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py
index 3f5cb22..5e214cf 100644
--- a/erpnext/hr/doctype/shift_type/shift_type.py
+++ b/erpnext/hr/doctype/shift_type/shift_type.py
@@ -3,21 +3,23 @@
import itertools
-from datetime import timedelta
+from datetime import datetime, timedelta
import frappe
from frappe.model.document import Document
-from frappe.utils import cint, get_datetime, getdate
+from frappe.utils import cint, get_datetime, get_time, getdate
+from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard import daterange
from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
from erpnext.hr.doctype.employee_checkin.employee_checkin import (
calculate_working_hours,
mark_attendance_and_link_log,
)
+from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
from erpnext.hr.doctype.shift_assignment.shift_assignment import (
- get_actual_start_end_datetime_of_shift,
get_employee_shift,
+ get_shift_details,
)
@@ -30,8 +32,9 @@
or not self.last_sync_of_checkin
):
return
+
filters = {
- "skip_auto_attendance": "0",
+ "skip_auto_attendance": 0,
"attendance": ("is", "not set"),
"time": (">=", self.process_attendance_after),
"shift_actual_end": ("<", self.last_sync_of_checkin),
@@ -40,6 +43,7 @@
logs = frappe.db.get_list(
"Employee Checkin", fields="*", filters=filters, order_by="employee,time"
)
+
for key, group in itertools.groupby(
logs, key=lambda x: (x["employee"], x["shift_actual_start"])
):
@@ -52,6 +56,7 @@
in_time,
out_time,
) = self.get_attendance(single_shift_logs)
+
mark_attendance_and_link_log(
single_shift_logs,
attendance_status,
@@ -63,15 +68,16 @@
out_time,
self.name,
)
+
for employee in self.get_assigned_employee(self.process_attendance_after, True):
self.mark_absent_for_dates_with_no_attendance(employee)
def get_attendance(self, logs):
"""Return attendance_status, working_hours, late_entry, early_exit, in_time, out_time
for a set of logs belonging to a single shift.
- Assumtion:
- 1. These logs belongs to an single shift, single employee and is not in a holiday date.
- 2. Logs are in chronological order
+ Assumptions:
+ 1. These logs belongs to a single shift, single employee and it's not in a holiday date.
+ 2. Logs are in chronological order
"""
late_entry = early_exit = False
total_working_hours, in_time, out_time = calculate_working_hours(
@@ -92,38 +98,67 @@
early_exit = True
if (
- self.working_hours_threshold_for_absent
- and total_working_hours < self.working_hours_threshold_for_absent
- ):
- return "Absent", total_working_hours, late_entry, early_exit, in_time, out_time
- if (
self.working_hours_threshold_for_half_day
and total_working_hours < self.working_hours_threshold_for_half_day
):
return "Half Day", total_working_hours, late_entry, early_exit, in_time, out_time
+ if (
+ self.working_hours_threshold_for_absent
+ and total_working_hours < self.working_hours_threshold_for_absent
+ ):
+ return "Absent", total_working_hours, late_entry, early_exit, in_time, out_time
return "Present", total_working_hours, late_entry, early_exit, in_time, out_time
def mark_absent_for_dates_with_no_attendance(self, employee):
"""Marks Absents for the given employee on working days in this shift which have no attendance marked.
The Absent is marked starting from 'process_attendance_after' or employee creation date.
"""
+ start_date, end_date = self.get_start_and_end_dates(employee)
+
+ # no shift assignment found, no need to process absent attendance records
+ if start_date is None:
+ return
+
+ holiday_list_name = self.holiday_list
+ if not holiday_list_name:
+ holiday_list_name = get_holiday_list_for_employee(employee, False)
+
+ start_time = get_time(self.start_time)
+
+ for date in daterange(getdate(start_date), getdate(end_date)):
+ if is_holiday(holiday_list_name, date):
+ # skip marking absent on a holiday
+ continue
+
+ timestamp = datetime.combine(date, start_time)
+ shift_details = get_employee_shift(employee, timestamp, True)
+
+ if shift_details and shift_details.shift_type.name == self.name:
+ mark_attendance(employee, date, "Absent", self.name)
+
+ def get_start_and_end_dates(self, employee):
+ """Returns start and end dates for checking attendance and marking absent
+ return: start date = max of `process_attendance_after` and DOJ
+ return: end date = min of shift before `last_sync_of_checkin` and Relieving Date
+ """
date_of_joining, relieving_date, employee_creation = frappe.db.get_value(
"Employee", employee, ["date_of_joining", "relieving_date", "creation"]
)
+
if not date_of_joining:
date_of_joining = employee_creation.date()
+
start_date = max(getdate(self.process_attendance_after), date_of_joining)
- actual_shift_datetime = get_actual_start_end_datetime_of_shift(
- employee, get_datetime(self.last_sync_of_checkin), True
- )
+ end_date = None
+
+ shift_details = get_shift_details(self.name, get_datetime(self.last_sync_of_checkin))
last_shift_time = (
- actual_shift_datetime[0]
- if actual_shift_datetime[0]
- else get_datetime(self.last_sync_of_checkin)
+ shift_details.actual_start if shift_details else get_datetime(self.last_sync_of_checkin)
)
- prev_shift = get_employee_shift(
- employee, last_shift_time.date() - timedelta(days=1), True, "reverse"
- )
+
+ # check if shift is found for 1 day before the last sync of checkin
+ # absentees are auto-marked 1 day after the shift to wait for any manual attendance records
+ prev_shift = get_employee_shift(employee, last_shift_time - timedelta(days=1), True, "reverse")
if prev_shift:
end_date = (
min(prev_shift.start_datetime.date(), relieving_date)
@@ -131,28 +166,21 @@
else prev_shift.start_datetime.date()
)
else:
- return
- holiday_list_name = self.holiday_list
- if not holiday_list_name:
- holiday_list_name = get_holiday_list_for_employee(employee, False)
- dates = get_filtered_date_list(employee, start_date, end_date, holiday_list=holiday_list_name)
- for date in dates:
- shift_details = get_employee_shift(employee, date, True)
- if shift_details and shift_details.shift_type.name == self.name:
- mark_attendance(employee, date, "Absent", self.name)
+ # no shift found
+ return None, None
+ return start_date, end_date
def get_assigned_employee(self, from_date=None, consider_default_shift=False):
- filters = {"start_date": (">", from_date), "shift_type": self.name, "docstatus": "1"}
- if not from_date:
- del filters["start_date"]
+ filters = {"shift_type": self.name, "docstatus": "1"}
+ if from_date:
+ filters["start_date"] = (">", from_date)
- assigned_employees = frappe.get_all("Shift Assignment", "employee", filters, as_list=True)
- assigned_employees = [x[0] for x in assigned_employees]
+ assigned_employees = frappe.get_all("Shift Assignment", filters=filters, pluck="employee")
if consider_default_shift:
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]
+ default_shift_employees = frappe.get_all("Employee", filters=filters, pluck="name")
+
return list(set(assigned_employees + default_shift_employees))
return assigned_employees
@@ -162,42 +190,3 @@
for shift in shift_list:
doc = frappe.get_doc("Shift Type", shift[0])
doc.process_auto_attendance()
-
-
-def get_filtered_date_list(
- employee, start_date, end_date, filter_attendance=True, holiday_list=None
-):
- """Returns a list of dates after removing the dates with attendance and holidays"""
- base_dates_query = """select adddate(%(start_date)s, t2.i*100 + t1.i*10 + t0.i) selected_date from
- (select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t0,
- (select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t1,
- (select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t2"""
- condition_query = ""
- if filter_attendance:
- condition_query += """ and a.selected_date not in (
- select attendance_date from `tabAttendance`
- where docstatus = 1 and employee = %(employee)s
- and attendance_date between %(start_date)s and %(end_date)s)"""
- if holiday_list:
- condition_query += """ and a.selected_date not in (
- select holiday_date from `tabHoliday` where parenttype = 'Holiday List' and
- parentfield = 'holidays' and parent = %(holiday_list)s
- and holiday_date between %(start_date)s and %(end_date)s)"""
-
- dates = frappe.db.sql(
- """select * from
- ({base_dates_query}) as a
- where a.selected_date <= %(end_date)s {condition_query}
- """.format(
- base_dates_query=base_dates_query, condition_query=condition_query
- ),
- {
- "employee": employee,
- "start_date": start_date,
- "end_date": end_date,
- "holiday_list": holiday_list,
- },
- as_list=True,
- )
-
- return [getdate(date[0]) for date in dates]
diff --git a/erpnext/hr/doctype/shift_type/test_shift_type.py b/erpnext/hr/doctype/shift_type/test_shift_type.py
index 7d2f29c..0d75292 100644
--- a/erpnext/hr/doctype/shift_type/test_shift_type.py
+++ b/erpnext/hr/doctype/shift_type/test_shift_type.py
@@ -2,7 +2,381 @@
# See license.txt
import unittest
+from datetime import datetime, timedelta
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, get_time, get_year_ending, get_year_start, getdate, now_datetime
+
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
+from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
-class TestShiftType(unittest.TestCase):
- pass
+class TestShiftType(FrappeTestCase):
+ def setUp(self):
+ frappe.db.delete("Shift Type")
+ frappe.db.delete("Shift Assignment")
+ frappe.db.delete("Employee Checkin")
+ frappe.db.delete("Attendance")
+
+ from_date = get_year_start(getdate())
+ to_date = get_year_ending(getdate())
+ self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
+
+ def test_mark_attendance(self):
+ from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
+
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+
+ shift_type = setup_shift_type()
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ timestamp = datetime.combine(date, get_time("08:00:00"))
+ log_in = make_checkin(employee, timestamp)
+ self.assertEqual(log_in.shift, shift_type.name)
+
+ timestamp = datetime.combine(date, get_time("12:00:00"))
+ log_out = make_checkin(employee, timestamp)
+ self.assertEqual(log_out.shift, shift_type.name)
+
+ shift_type.process_auto_attendance()
+
+ attendance = frappe.db.get_value(
+ "Attendance", {"shift": shift_type.name}, ["status", "name"], as_dict=True
+ )
+ self.assertEqual(attendance.status, "Present")
+
+ def test_entry_and_exit_grace(self):
+ from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
+
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+
+ # doesn't mark late entry until 60 mins after shift start i.e. till 9
+ # doesn't mark late entry until 60 mins before shift end i.e. 11
+ shift_type = setup_shift_type(
+ enable_entry_grace_period=1,
+ enable_exit_grace_period=1,
+ late_entry_grace_period=60,
+ early_exit_grace_period=60,
+ )
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ timestamp = datetime.combine(date, get_time("09:30:00"))
+ log_in = make_checkin(employee, timestamp)
+ self.assertEqual(log_in.shift, shift_type.name)
+
+ timestamp = datetime.combine(date, get_time("10:30:00"))
+ log_out = make_checkin(employee, timestamp)
+ self.assertEqual(log_out.shift, shift_type.name)
+
+ shift_type.process_auto_attendance()
+
+ attendance = frappe.db.get_value(
+ "Attendance",
+ {"shift": shift_type.name},
+ ["status", "name", "late_entry", "early_exit"],
+ as_dict=True,
+ )
+ self.assertEqual(attendance.status, "Present")
+ self.assertEqual(attendance.late_entry, 1)
+ self.assertEqual(attendance.early_exit, 1)
+
+ def test_working_hours_threshold_for_half_day(self):
+ from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
+
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+ shift_type = setup_shift_type(shift_type="Half Day Test", working_hours_threshold_for_half_day=2)
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ timestamp = datetime.combine(date, get_time("08:00:00"))
+ log_in = make_checkin(employee, timestamp)
+ self.assertEqual(log_in.shift, shift_type.name)
+
+ timestamp = datetime.combine(date, get_time("09:30:00"))
+ log_out = make_checkin(employee, timestamp)
+ self.assertEqual(log_out.shift, shift_type.name)
+
+ shift_type.process_auto_attendance()
+
+ attendance = frappe.db.get_value(
+ "Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True
+ )
+ self.assertEqual(attendance.status, "Half Day")
+ self.assertEqual(attendance.working_hours, 1.5)
+
+ def test_working_hours_threshold_for_absent(self):
+ from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
+
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+ shift_type = setup_shift_type(shift_type="Absent Test", working_hours_threshold_for_absent=2)
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ timestamp = datetime.combine(date, get_time("08:00:00"))
+ log_in = make_checkin(employee, timestamp)
+ self.assertEqual(log_in.shift, shift_type.name)
+
+ timestamp = datetime.combine(date, get_time("09:30:00"))
+ log_out = make_checkin(employee, timestamp)
+ self.assertEqual(log_out.shift, shift_type.name)
+
+ shift_type.process_auto_attendance()
+
+ attendance = frappe.db.get_value(
+ "Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True
+ )
+ self.assertEqual(attendance.status, "Absent")
+ self.assertEqual(attendance.working_hours, 1.5)
+
+ def test_working_hours_threshold_for_absent_and_half_day_1(self):
+ # considers half day over absent
+ from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
+
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+ shift_type = setup_shift_type(
+ shift_type="Half Day + Absent Test",
+ working_hours_threshold_for_half_day=1,
+ working_hours_threshold_for_absent=2,
+ )
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ timestamp = datetime.combine(date, get_time("08:00:00"))
+ log_in = make_checkin(employee, timestamp)
+ self.assertEqual(log_in.shift, shift_type.name)
+
+ timestamp = datetime.combine(date, get_time("08:45:00"))
+ log_out = make_checkin(employee, timestamp)
+ self.assertEqual(log_out.shift, shift_type.name)
+
+ shift_type.process_auto_attendance()
+
+ attendance = frappe.db.get_value(
+ "Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True
+ )
+ self.assertEqual(attendance.status, "Half Day")
+ self.assertEqual(attendance.working_hours, 0.75)
+
+ def test_working_hours_threshold_for_absent_and_half_day_2(self):
+ # considers absent over half day
+ from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
+
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+ shift_type = setup_shift_type(
+ shift_type="Half Day + Absent Test",
+ working_hours_threshold_for_half_day=1,
+ working_hours_threshold_for_absent=2,
+ )
+ date = getdate()
+ make_shift_assignment(shift_type.name, employee, date)
+
+ timestamp = datetime.combine(date, get_time("08:00:00"))
+ log_in = make_checkin(employee, timestamp)
+ self.assertEqual(log_in.shift, shift_type.name)
+
+ timestamp = datetime.combine(date, get_time("09:30:00"))
+ log_out = make_checkin(employee, timestamp)
+ self.assertEqual(log_out.shift, shift_type.name)
+
+ shift_type.process_auto_attendance()
+
+ attendance = frappe.db.get_value("Attendance", {"shift": shift_type.name}, "status")
+ self.assertEqual(attendance, "Absent")
+
+ def test_mark_absent_for_dates_with_no_attendance(self):
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+ shift_type = setup_shift_type(shift_type="Test Absent with no Attendance")
+
+ # absentees are auto-marked one day after to wait for any manual attendance records
+ date = add_days(getdate(), -1)
+ make_shift_assignment(shift_type.name, employee, date)
+
+ shift_type.process_auto_attendance()
+
+ attendance = frappe.db.get_value(
+ "Attendance", {"attendance_date": date, "employee": employee}, "status"
+ )
+ self.assertEqual(attendance, "Absent")
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_skip_marking_absent_on_a_holiday(self):
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+ shift_type = setup_shift_type(shift_type="Test Absent with no Attendance")
+ shift_type.holiday_list = None
+ shift_type.save()
+
+ # should not mark any attendance if no shift assignment is created
+ shift_type.process_auto_attendance()
+ attendance = frappe.db.get_value("Attendance", {"employee": employee}, "status")
+ self.assertIsNone(attendance)
+
+ first_sunday = get_first_sunday(self.holiday_list, for_date=getdate())
+ make_shift_assignment(shift_type.name, employee, first_sunday)
+
+ shift_type.process_auto_attendance()
+
+ attendance = frappe.db.get_value(
+ "Attendance", {"attendance_date": first_sunday, "employee": employee}, "status"
+ )
+ self.assertIsNone(attendance)
+
+ def test_get_start_and_end_dates(self):
+ date = getdate()
+
+ doj = add_days(date, -30)
+ relieving_date = add_days(date, -5)
+ employee = make_employee(
+ "test_employee_dates@example.com",
+ company="_Test Company",
+ date_of_joining=doj,
+ relieving_date=relieving_date,
+ )
+ shift_type = setup_shift_type(
+ shift_type="Test Absent with no Attendance", process_attendance_after=add_days(doj, 2)
+ )
+
+ make_shift_assignment(shift_type.name, employee, add_days(date, -25))
+
+ shift_type.process_auto_attendance()
+
+ # should not mark absent before shift assignment/process attendance after date
+ attendance = frappe.db.get_value(
+ "Attendance", {"attendance_date": doj, "employee": employee}, "name"
+ )
+ self.assertIsNone(attendance)
+
+ # mark absent on Relieving Date
+ attendance = frappe.db.get_value(
+ "Attendance", {"attendance_date": relieving_date, "employee": employee}, "status"
+ )
+ self.assertEquals(attendance, "Absent")
+
+ # should not mark absent after Relieving Date
+ attendance = frappe.db.get_value(
+ "Attendance", {"attendance_date": add_days(relieving_date, 1), "employee": employee}, "name"
+ )
+ self.assertIsNone(attendance)
+
+ def test_skip_auto_attendance_for_duplicate_record(self):
+ # Skip auto attendance in case of duplicate attendance record
+ from erpnext.hr.doctype.attendance.attendance import mark_attendance
+ from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
+
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+
+ shift_type = setup_shift_type()
+ date = getdate()
+
+ # mark attendance
+ mark_attendance(employee, date, "Present")
+ make_shift_assignment(shift_type.name, employee, date)
+
+ timestamp = datetime.combine(date, get_time("08:00:00"))
+ log_in = make_checkin(employee, timestamp)
+ self.assertEqual(log_in.shift, shift_type.name)
+
+ timestamp = datetime.combine(date, get_time("12:00:00"))
+ log_out = make_checkin(employee, timestamp)
+ self.assertEqual(log_out.shift, shift_type.name)
+
+ # auto attendance should skip marking
+ shift_type.process_auto_attendance()
+
+ log_in.reload()
+ log_out.reload()
+ self.assertEqual(log_in.skip_auto_attendance, 1)
+ self.assertEqual(log_out.skip_auto_attendance, 1)
+
+ def test_skip_auto_attendance_for_overlapping_shift(self):
+ # Skip auto attendance in case of overlapping shift attendance record
+ # this case won't occur in case of shift assignment, since it will not allow overlapping shifts to be assigned
+ # can happen if manual attendance records are created
+ from erpnext.hr.doctype.attendance.attendance import mark_attendance
+ from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin
+
+ employee = make_employee("test_employee_checkin@example.com", company="_Test Company")
+ shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00")
+ shift_2 = setup_shift_type(shift_type="Shift 2", start_time="09:30:00", end_time="11:00:00")
+
+ date = getdate()
+
+ # mark attendance
+ mark_attendance(employee, date, "Present", shift=shift_1.name)
+ make_shift_assignment(shift_2.name, employee, date)
+
+ timestamp = datetime.combine(date, get_time("09:30:00"))
+ log_in = make_checkin(employee, timestamp)
+ self.assertEqual(log_in.shift, shift_2.name)
+
+ timestamp = datetime.combine(date, get_time("11:00:00"))
+ log_out = make_checkin(employee, timestamp)
+ self.assertEqual(log_out.shift, shift_2.name)
+
+ # auto attendance should be skipped for shift 2
+ # since it is already marked for overlapping shift 1
+ shift_2.process_auto_attendance()
+
+ log_in.reload()
+ log_out.reload()
+ self.assertEqual(log_in.skip_auto_attendance, 1)
+ self.assertEqual(log_out.skip_auto_attendance, 1)
+
+
+def setup_shift_type(**args):
+ args = frappe._dict(args)
+ date = getdate()
+
+ shift_type = frappe.get_doc(
+ {
+ "doctype": "Shift Type",
+ "__newname": args.shift_type or "_Test Shift",
+ "start_time": "08:00:00",
+ "end_time": "12:00:00",
+ "enable_auto_attendance": 1,
+ "determine_check_in_and_check_out": "Alternating entries as IN and OUT during the same shift",
+ "working_hours_calculation_based_on": "First Check-in and Last Check-out",
+ "begin_check_in_before_shift_start_time": 60,
+ "allow_check_out_after_shift_end_time": 60,
+ "process_attendance_after": add_days(date, -2),
+ "last_sync_of_checkin": now_datetime() + timedelta(days=1),
+ }
+ )
+
+ holiday_list = "Employee Checkin Test Holiday List"
+ if not frappe.db.exists("Holiday List", "Employee Checkin Test Holiday List"):
+ holiday_list = frappe.get_doc(
+ {
+ "doctype": "Holiday List",
+ "holiday_list_name": "Employee Checkin Test Holiday List",
+ "from_date": get_year_start(date),
+ "to_date": get_year_ending(date),
+ }
+ ).insert()
+ holiday_list = holiday_list.name
+
+ shift_type.holiday_list = holiday_list
+ shift_type.update(args)
+ shift_type.save()
+
+ return shift_type
+
+
+def make_shift_assignment(shift_type, employee, start_date, end_date=None):
+ shift_assignment = frappe.get_doc(
+ {
+ "doctype": "Shift Assignment",
+ "shift_type": shift_type,
+ "company": "_Test Company",
+ "employee": employee,
+ "start_date": start_date,
+ "end_date": end_date,
+ }
+ ).insert()
+ shift_assignment.submit()
+
+ return shift_assignment
diff --git a/erpnext/hr/doctype/staffing_plan/staffing_plan.py b/erpnext/hr/doctype/staffing_plan/staffing_plan.py
index 93a493c..ce7e50f 100644
--- a/erpnext/hr/doctype/staffing_plan/staffing_plan.py
+++ b/erpnext/hr/doctype/staffing_plan/staffing_plan.py
@@ -91,8 +91,7 @@
) > flt(parent_plan_details[0].total_estimated_cost):
frappe.throw(
_(
- "You can only plan for upto {0} vacancies and budget {1} \
- for {2} as per staffing plan {3} for parent company {4}."
+ "You can only plan for upto {0} vacancies and budget {1} for {2} as per staffing plan {3} for parent company {4}."
).format(
cint(parent_plan_details[0].vacancies),
parent_plan_details[0].total_estimated_cost,
@@ -128,8 +127,7 @@
):
frappe.throw(
_(
- "{0} vacancies and {1} budget for {2} already planned for subsidiary companies of {3}. \
- You can only plan for upto {4} vacancies and and budget {5} as per staffing plan {6} for parent company {3}."
+ "{0} vacancies and {1} budget for {2} already planned for subsidiary companies of {3}. You can only plan for upto {4} vacancies and and budget {5} as per staffing plan {6} for parent company {3}."
).format(
cint(all_sibling_details.vacancies),
all_sibling_details.total_estimated_cost,
@@ -162,8 +160,7 @@
):
frappe.throw(
_(
- "Subsidiary companies have already planned for {1} vacancies at a budget of {2}. \
- Staffing Plan for {0} should allocate more vacancies and budget for {3} than planned for its subsidiary companies"
+ "Subsidiary companies have already planned for {1} vacancies at a budget of {2}. Staffing Plan for {0} should allocate more vacancies and budget for {3} than planned for its subsidiary companies"
).format(
self.company,
cint(children_details.vacancies),
diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js
index 42f7cdb..6f4bbd5 100644
--- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js
+++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js
@@ -66,8 +66,7 @@
"Default": 0,
}
],
-
- "onload": function() {
+ onload: function() {
return frappe.call({
method: "erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet.get_attendance_years",
callback: function(r) {
@@ -78,5 +77,25 @@
year_filter.set_input(year_filter.df.default);
}
});
+ },
+ formatter: function(value, row, column, data, default_formatter) {
+ value = default_formatter(value, row, column, data);
+ const summarized_view = frappe.query_report.get_filter_value('summarized_view');
+ const group_by = frappe.query_report.get_filter_value('group_by');
+
+ if (!summarized_view) {
+ if ((group_by && column.colIndex > 3) || (!group_by && column.colIndex > 2)) {
+ if (value == 'P' || value == 'WFH')
+ value = "<span style='color:green'>" + value + "</span>";
+ else if (value == 'A')
+ value = "<span style='color:red'>" + value + "</span>";
+ else if (value == 'HD')
+ value = "<span style='color:orange'>" + value + "</span>";
+ else if (value == 'L')
+ value = "<span style='color:#318AD8'>" + value + "</span>";
+ }
+ }
+
+ return value;
}
}
diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py
index 8ea4989..efd2d38 100644
--- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py
+++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py
@@ -3,365 +3,618 @@
from calendar import monthrange
+from itertools import groupby
+from typing import Dict, List, Optional, Tuple
import frappe
-from frappe import _, msgprint
+from frappe import _
+from frappe.query_builder.functions import Count, Extract, Sum
from frappe.utils import cint, cstr, getdate
+Filters = frappe._dict
+
status_map = {
+ "Present": "P",
"Absent": "A",
"Half Day": "HD",
- "Holiday": "<b>H</b>",
- "Weekly Off": "<b>WO</b>",
- "On Leave": "L",
- "Present": "P",
"Work From Home": "WFH",
+ "On Leave": "L",
+ "Holiday": "H",
+ "Weekly Off": "WO",
}
day_abbr = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
-def execute(filters=None):
- if not filters:
- filters = {}
+def execute(filters: Optional[Filters] = None) -> Tuple:
+ filters = frappe._dict(filters or {})
- if filters.hide_year_field == 1:
- filters.year = 2020
+ if not (filters.month and filters.year):
+ frappe.throw(_("Please select month and year."))
- conditions, filters = get_conditions(filters)
- columns, days = get_columns(filters)
- att_map = get_attendance_list(conditions, filters)
- if not att_map:
+ attendance_map = get_attendance_map(filters)
+ if not attendance_map:
+ frappe.msgprint(_("No attendance records found."), alert=True, indicator="orange")
+ return [], [], None, None
+
+ columns = get_columns(filters)
+ data = get_data(filters, attendance_map)
+
+ if not data:
+ frappe.msgprint(
+ _("No attendance records found for this criteria."), alert=True, indicator="orange"
+ )
return columns, [], None, None
- if filters.group_by:
- emp_map, group_by_parameters = get_employee_details(filters.group_by, filters.company)
- holiday_list = []
- for parameter in group_by_parameters:
- h_list = [
- emp_map[parameter][d]["holiday_list"]
- for d in emp_map[parameter]
- if emp_map[parameter][d]["holiday_list"]
- ]
- holiday_list += h_list
- else:
- emp_map = get_employee_details(filters.group_by, filters.company)
- holiday_list = [emp_map[d]["holiday_list"] for d in emp_map if emp_map[d]["holiday_list"]]
+ message = get_message() if not filters.summarized_view else ""
+ chart = get_chart_data(attendance_map, filters)
- default_holiday_list = frappe.get_cached_value(
- "Company", filters.get("company"), "default_holiday_list"
- )
- holiday_list.append(default_holiday_list)
- holiday_list = list(set(holiday_list))
- holiday_map = get_holiday(holiday_list, filters["month"])
-
- data = []
-
- leave_types = frappe.db.get_list("Leave Type")
- leave_list = None
- if filters.summarized_view:
- leave_list = [d.name + ":Float:120" for d in leave_types]
- columns.extend(leave_list)
- columns.extend([_("Total Late Entries") + ":Float:120", _("Total Early Exits") + ":Float:120"])
-
- if filters.group_by:
- emp_att_map = {}
- for parameter in group_by_parameters:
- emp_map_set = set([key for key in emp_map[parameter].keys()])
- att_map_set = set([key for key in att_map.keys()])
- if att_map_set & emp_map_set:
- parameter_row = ["<b>" + parameter + "</b>"] + [
- "" for day in range(filters["total_days_in_month"] + 2)
- ]
- data.append(parameter_row)
- record, emp_att_data = add_data(
- emp_map[parameter],
- att_map,
- filters,
- holiday_map,
- conditions,
- default_holiday_list,
- leave_types=leave_types,
- )
- emp_att_map.update(emp_att_data)
- data += record
- else:
- record, emp_att_map = add_data(
- emp_map,
- att_map,
- filters,
- holiday_map,
- conditions,
- default_holiday_list,
- leave_types=leave_types,
- )
- data += record
-
- chart_data = get_chart_data(emp_att_map, days)
-
- return columns, data, None, chart_data
+ return columns, data, message, chart
-def get_chart_data(emp_att_map, days):
- labels = []
- datasets = [
- {"name": "Absent", "values": []},
- {"name": "Present", "values": []},
- {"name": "Leave", "values": []},
- ]
- for idx, day in enumerate(days, start=0):
- p = day.replace("::65", "")
- labels.append(day.replace("::65", ""))
- total_absent_on_day = 0
- total_leave_on_day = 0
- total_present_on_day = 0
- total_holiday = 0
- for emp in emp_att_map.keys():
- if emp_att_map[emp][idx]:
- if emp_att_map[emp][idx] == "A":
- total_absent_on_day += 1
- if emp_att_map[emp][idx] in ["P", "WFH"]:
- total_present_on_day += 1
- if emp_att_map[emp][idx] == "HD":
- total_present_on_day += 0.5
- total_leave_on_day += 0.5
- if emp_att_map[emp][idx] == "L":
- total_leave_on_day += 1
+def get_message() -> str:
+ message = ""
+ colors = ["green", "red", "orange", "green", "#318AD8", "", ""]
- datasets[0]["values"].append(total_absent_on_day)
- datasets[1]["values"].append(total_present_on_day)
- datasets[2]["values"].append(total_leave_on_day)
+ count = 0
+ for status, abbr in status_map.items():
+ message += f"""
+ <span style='border-left: 2px solid {colors[count]}; padding-right: 12px; padding-left: 5px; margin-right: 3px;'>
+ {status} - {abbr}
+ </span>
+ """
+ count += 1
- chart = {"data": {"labels": labels, "datasets": datasets}}
-
- chart["type"] = "line"
-
- return chart
+ return message
-def add_data(
- employee_map, att_map, filters, holiday_map, conditions, default_holiday_list, leave_types=None
-):
-
- record = []
- emp_att_map = {}
- for emp in employee_map:
- emp_det = employee_map.get(emp)
- if not emp_det or emp not in att_map:
- continue
-
- row = []
- if filters.group_by:
- row += [" "]
- row += [emp, emp_det.employee_name]
-
- total_p = total_a = total_l = total_h = total_um = 0.0
- emp_status_map = []
- for day in range(filters["total_days_in_month"]):
- status = None
- status = att_map.get(emp).get(day + 1)
-
- if status is None and holiday_map:
- emp_holiday_list = emp_det.holiday_list if emp_det.holiday_list else default_holiday_list
-
- if emp_holiday_list in holiday_map:
- for idx, ele in enumerate(holiday_map[emp_holiday_list]):
- if day + 1 == holiday_map[emp_holiday_list][idx][0]:
- if holiday_map[emp_holiday_list][idx][1]:
- status = "Weekly Off"
- else:
- status = "Holiday"
- total_h += 1
-
- abbr = status_map.get(status, "")
- emp_status_map.append(abbr)
-
- if filters.summarized_view:
- if status == "Present" or status == "Work From Home":
- total_p += 1
- elif status == "Absent":
- total_a += 1
- elif status == "On Leave":
- total_l += 1
- elif status == "Half Day":
- total_p += 0.5
- total_a += 0.5
- total_l += 0.5
- elif not status:
- total_um += 1
-
- if not filters.summarized_view:
- row += emp_status_map
-
- if filters.summarized_view:
- row += [total_p, total_l, total_a, total_h, total_um]
-
- if not filters.get("employee"):
- filters.update({"employee": emp})
- conditions += " and employee = %(employee)s"
- elif not filters.get("employee") == emp:
- filters.update({"employee": emp})
-
- if filters.summarized_view:
- leave_details = frappe.db.sql(
- """select leave_type, status, count(*) as count from `tabAttendance`\
- where leave_type is not NULL %s group by leave_type, status"""
- % conditions,
- filters,
- as_dict=1,
- )
-
- time_default_counts = frappe.db.sql(
- """select (select count(*) from `tabAttendance` where \
- late_entry = 1 %s) as late_entry_count, (select count(*) from tabAttendance where \
- early_exit = 1 %s) as early_exit_count"""
- % (conditions, conditions),
- filters,
- )
-
- leaves = {}
- for d in leave_details:
- if d.status == "Half Day":
- d.count = d.count * 0.5
- if d.leave_type in leaves:
- leaves[d.leave_type] += d.count
- else:
- leaves[d.leave_type] = d.count
-
- for d in leave_types:
- if d.name in leaves:
- row.append(leaves[d.name])
- else:
- row.append("0.0")
-
- row.extend([time_default_counts[0][0], time_default_counts[0][1]])
- emp_att_map[emp] = emp_status_map
- record.append(row)
-
- return record, emp_att_map
-
-
-def get_columns(filters):
-
+def get_columns(filters: Filters) -> List[Dict]:
columns = []
if filters.group_by:
- columns = [_(filters.group_by) + ":Link/Branch:120"]
+ columns.append(
+ {
+ "label": _(filters.group_by),
+ "fieldname": frappe.scrub(filters.group_by),
+ "fieldtype": "Link",
+ "options": "Branch",
+ "width": 120,
+ }
+ )
- columns += [_("Employee") + ":Link/Employee:120", _("Employee Name") + ":Data/:120"]
- days = []
- for day in range(filters["total_days_in_month"]):
- date = str(filters.year) + "-" + str(filters.month) + "-" + str(day + 1)
- day_name = day_abbr[getdate(date).weekday()]
- days.append(cstr(day + 1) + " " + day_name + "::65")
- if not filters.summarized_view:
- columns += days
-
- if filters.summarized_view:
- columns += [
- _("Total Present") + ":Float:120",
- _("Total Leaves") + ":Float:120",
- _("Total Absent") + ":Float:120",
- _("Total Holidays") + ":Float:120",
- _("Unmarked Days") + ":Float:120",
+ columns.extend(
+ [
+ {
+ "label": _("Employee"),
+ "fieldname": "employee",
+ "fieldtype": "Link",
+ "options": "Employee",
+ "width": 135,
+ },
+ {"label": _("Employee Name"), "fieldname": "employee_name", "fieldtype": "Data", "width": 120},
]
- return columns, days
-
-
-def get_attendance_list(conditions, filters):
- attendance_list = frappe.db.sql(
- """select employee, day(attendance_date) as day_of_month,
- status from tabAttendance where docstatus = 1 %s order by employee, attendance_date"""
- % conditions,
- filters,
- as_dict=1,
)
- if not attendance_list:
- msgprint(_("No attendance record found"), alert=True, indicator="orange")
+ if filters.summarized_view:
+ columns.extend(
+ [
+ {
+ "label": _("Total Present"),
+ "fieldname": "total_present",
+ "fieldtype": "Float",
+ "width": 110,
+ },
+ {"label": _("Total Leaves"), "fieldname": "total_leaves", "fieldtype": "Float", "width": 110},
+ {"label": _("Total Absent"), "fieldname": "total_absent", "fieldtype": "Float", "width": 110},
+ {
+ "label": _("Total Holidays"),
+ "fieldname": "total_holidays",
+ "fieldtype": "Float",
+ "width": 120,
+ },
+ {
+ "label": _("Unmarked Days"),
+ "fieldname": "unmarked_days",
+ "fieldtype": "Float",
+ "width": 130,
+ },
+ ]
+ )
+ columns.extend(get_columns_for_leave_types())
+ columns.extend(
+ [
+ {
+ "label": _("Total Late Entries"),
+ "fieldname": "total_late_entries",
+ "fieldtype": "Float",
+ "width": 140,
+ },
+ {
+ "label": _("Total Early Exits"),
+ "fieldname": "total_early_exits",
+ "fieldtype": "Float",
+ "width": 140,
+ },
+ ]
+ )
+ else:
+ columns.append({"label": _("Shift"), "fieldname": "shift", "fieldtype": "Data", "width": 120})
+ columns.extend(get_columns_for_days(filters))
- att_map = {}
+ return columns
+
+
+def get_columns_for_leave_types() -> List[Dict]:
+ leave_types = frappe.db.get_all("Leave Type", pluck="name")
+ types = []
+ for entry in leave_types:
+ types.append(
+ {"label": entry, "fieldname": frappe.scrub(entry), "fieldtype": "Float", "width": 120}
+ )
+
+ return types
+
+
+def get_columns_for_days(filters: Filters) -> List[Dict]:
+ total_days = get_total_days_in_month(filters)
+ days = []
+
+ for day in range(1, total_days + 1):
+ # forms the dates from selected year and month from filters
+ date = "{}-{}-{}".format(cstr(filters.year), cstr(filters.month), cstr(day))
+ # gets abbr from weekday number
+ weekday = day_abbr[getdate(date).weekday()]
+ # sets days as 1 Mon, 2 Tue, 3 Wed
+ label = "{} {}".format(cstr(day), weekday)
+ days.append({"label": label, "fieldtype": "Data", "fieldname": day, "width": 65})
+
+ return days
+
+
+def get_total_days_in_month(filters: Filters) -> int:
+ return monthrange(cint(filters.year), cint(filters.month))[1]
+
+
+def get_data(filters: Filters, attendance_map: Dict) -> List[Dict]:
+ employee_details, group_by_param_values = get_employee_related_details(
+ filters.group_by, filters.company
+ )
+ holiday_map = get_holiday_map(filters)
+ data = []
+
+ if filters.group_by:
+ group_by_column = frappe.scrub(filters.group_by)
+
+ for value in group_by_param_values:
+ if not value:
+ continue
+
+ records = get_rows(employee_details[value], filters, holiday_map, attendance_map)
+
+ if records:
+ data.append({group_by_column: frappe.bold(value)})
+ data.extend(records)
+ else:
+ data = get_rows(employee_details, filters, holiday_map, attendance_map)
+
+ return data
+
+
+def get_attendance_map(filters: Filters) -> Dict:
+ """Returns a dictionary of employee wise attendance map as per shifts for all the days of the month like
+ {
+ 'employee1': {
+ 'Morning Shift': {1: 'Present', 2: 'Absent', ...}
+ 'Evening Shift': {1: 'Absent', 2: 'Present', ...}
+ },
+ 'employee2': {
+ 'Afternoon Shift': {1: 'Present', 2: 'Absent', ...}
+ 'Night Shift': {1: 'Absent', 2: 'Absent', ...}
+ }
+ }
+ """
+ Attendance = frappe.qb.DocType("Attendance")
+ query = (
+ frappe.qb.from_(Attendance)
+ .select(
+ Attendance.employee,
+ Extract("day", Attendance.attendance_date).as_("day_of_month"),
+ Attendance.status,
+ Attendance.shift,
+ )
+ .where(
+ (Attendance.docstatus == 1)
+ & (Attendance.company == filters.company)
+ & (Extract("month", Attendance.attendance_date) == filters.month)
+ & (Extract("year", Attendance.attendance_date) == filters.year)
+ )
+ )
+ if filters.employee:
+ query = query.where(Attendance.employee == filters.employee)
+ query = query.orderby(Attendance.employee, Attendance.attendance_date)
+
+ attendance_list = query.run(as_dict=1)
+ attendance_map = {}
+
for d in attendance_list:
- att_map.setdefault(d.employee, frappe._dict()).setdefault(d.day_of_month, "")
- att_map[d.employee][d.day_of_month] = d.status
+ attendance_map.setdefault(d.employee, frappe._dict()).setdefault(d.shift, frappe._dict())
+ attendance_map[d.employee][d.shift][d.day_of_month] = d.status
- return att_map
+ return attendance_map
-def get_conditions(filters):
- if not (filters.get("month") and filters.get("year")):
- msgprint(_("Please select month and year"), raise_exception=1)
-
- filters["total_days_in_month"] = monthrange(cint(filters.year), cint(filters.month))[1]
-
- conditions = " and month(attendance_date) = %(month)s and year(attendance_date) = %(year)s"
-
- if filters.get("company"):
- conditions += " and company = %(company)s"
- if filters.get("employee"):
- conditions += " and employee = %(employee)s"
-
- return conditions, filters
-
-
-def get_employee_details(group_by, company):
- emp_map = {}
- query = """select name, employee_name, designation, department, branch, company,
- holiday_list from `tabEmployee` where company = %s """ % frappe.db.escape(
- company
+def get_employee_related_details(group_by: str, company: str) -> Tuple[Dict, List]:
+ """Returns
+ 1. nested dict for employee details
+ 2. list of values for the group by filter
+ """
+ Employee = frappe.qb.DocType("Employee")
+ query = (
+ frappe.qb.from_(Employee)
+ .select(
+ Employee.name,
+ Employee.employee_name,
+ Employee.designation,
+ Employee.grade,
+ Employee.department,
+ Employee.branch,
+ Employee.company,
+ Employee.holiday_list,
+ )
+ .where(Employee.company == company)
)
if group_by:
group_by = group_by.lower()
- query += " order by " + group_by + " ASC"
+ query = query.orderby(group_by)
- employee_details = frappe.db.sql(query, as_dict=1)
+ employee_details = query.run(as_dict=True)
- group_by_parameters = []
+ group_by_param_values = []
+ emp_map = {}
+
if group_by:
+ for parameter, employees in groupby(employee_details, key=lambda d: d[group_by]):
+ group_by_param_values.append(parameter)
+ emp_map.setdefault(parameter, frappe._dict())
- group_by_parameters = list(
- set(detail.get(group_by, "") for detail in employee_details if detail.get(group_by, ""))
- )
- for parameter in group_by_parameters:
- emp_map[parameter] = {}
-
- for d in employee_details:
- if group_by and len(group_by_parameters):
- if d.get(group_by, None):
-
- emp_map[d.get(group_by)][d.name] = d
- else:
- emp_map[d.name] = d
-
- if not group_by:
- return emp_map
+ for emp in employees:
+ emp_map[parameter][emp.name] = emp
else:
- return emp_map, group_by_parameters
+ for emp in employee_details:
+ emp_map[emp.name] = emp
+
+ return emp_map, group_by_param_values
-def get_holiday(holiday_list, month):
+def get_holiday_map(filters: Filters) -> Dict[str, List[Dict]]:
+ """
+ Returns a dict of holidays falling in the filter month and year
+ with list name as key and list of holidays as values like
+ {
+ 'Holiday List 1': [
+ {'day_of_month': '0' , 'weekly_off': 1},
+ {'day_of_month': '1', 'weekly_off': 0}
+ ],
+ 'Holiday List 2': [
+ {'day_of_month': '0' , 'weekly_off': 1},
+ {'day_of_month': '1', 'weekly_off': 0}
+ ]
+ }
+ """
+ # add default holiday list too
+ holiday_lists = frappe.db.get_all("Holiday List", pluck="name")
+ default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list")
+ holiday_lists.append(default_holiday_list)
+
holiday_map = frappe._dict()
- for d in holiday_list:
- if d:
- holiday_map.setdefault(
- d,
- frappe.db.sql(
- """select day(holiday_date), weekly_off from `tabHoliday`
- where parent=%s and month(holiday_date)=%s""",
- (d, month),
- ),
+ Holiday = frappe.qb.DocType("Holiday")
+
+ for d in holiday_lists:
+ if not d:
+ continue
+
+ holidays = (
+ frappe.qb.from_(Holiday)
+ .select(Extract("day", Holiday.holiday_date).as_("day_of_month"), Holiday.weekly_off)
+ .where(
+ (Holiday.parent == d)
+ & (Extract("month", Holiday.holiday_date) == filters.month)
+ & (Extract("year", Holiday.holiday_date) == filters.year)
)
+ ).run(as_dict=True)
+
+ holiday_map.setdefault(d, holidays)
return holiday_map
-@frappe.whitelist()
-def get_attendance_years():
- year_list = frappe.db.sql_list(
- """select distinct YEAR(attendance_date) from tabAttendance ORDER BY YEAR(attendance_date) DESC"""
+def get_rows(
+ employee_details: Dict, filters: Filters, holiday_map: Dict, attendance_map: Dict
+) -> List[Dict]:
+ records = []
+ default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list")
+
+ for employee, details in employee_details.items():
+ emp_holiday_list = details.holiday_list or default_holiday_list
+ holidays = holiday_map.get(emp_holiday_list)
+
+ if filters.summarized_view:
+ attendance = get_attendance_status_for_summarized_view(employee, filters, holidays)
+ if not attendance:
+ continue
+
+ leave_summary = get_leave_summary(employee, filters)
+ entry_exits_summary = get_entry_exits_summary(employee, filters)
+
+ row = {"employee": employee, "employee_name": details.employee_name}
+ set_defaults_for_summarized_view(filters, row)
+ row.update(attendance)
+ row.update(leave_summary)
+ row.update(entry_exits_summary)
+
+ records.append(row)
+ else:
+ employee_attendance = attendance_map.get(employee)
+ if not employee_attendance:
+ continue
+
+ attendance_for_employee = get_attendance_status_for_detailed_view(
+ employee, filters, employee_attendance, holidays
+ )
+ # set employee details in the first row
+ attendance_for_employee[0].update(
+ {"employee": employee, "employee_name": details.employee_name}
+ )
+
+ records.extend(attendance_for_employee)
+
+ return records
+
+
+def set_defaults_for_summarized_view(filters, row):
+ for entry in get_columns(filters):
+ if entry.get("fieldtype") == "Float":
+ row[entry.get("fieldname")] = 0.0
+
+
+def get_attendance_status_for_summarized_view(
+ employee: str, filters: Filters, holidays: List
+) -> Dict:
+ """Returns dict of attendance status for employee like
+ {'total_present': 1.5, 'total_leaves': 0.5, 'total_absent': 13.5, 'total_holidays': 8, 'unmarked_days': 5}
+ """
+ summary, attendance_days = get_attendance_summary_and_days(employee, filters)
+ if not any(summary.values()):
+ return {}
+
+ total_days = get_total_days_in_month(filters)
+ total_holidays = total_unmarked_days = 0
+
+ for day in range(1, total_days + 1):
+ if day in attendance_days:
+ continue
+
+ status = get_holiday_status(day, holidays)
+ if status in ["Weekly Off", "Holiday"]:
+ total_holidays += 1
+ elif not status:
+ total_unmarked_days += 1
+
+ return {
+ "total_present": summary.total_present + summary.total_half_days,
+ "total_leaves": summary.total_leaves + summary.total_half_days,
+ "total_absent": summary.total_absent + summary.total_half_days,
+ "total_holidays": total_holidays,
+ "unmarked_days": total_unmarked_days,
+ }
+
+
+def get_attendance_summary_and_days(employee: str, filters: Filters) -> Tuple[Dict, List]:
+ Attendance = frappe.qb.DocType("Attendance")
+
+ present_case = (
+ frappe.qb.terms.Case()
+ .when(((Attendance.status == "Present") | (Attendance.status == "Work From Home")), 1)
+ .else_(0)
)
- if not year_list:
+ sum_present = Sum(present_case).as_("total_present")
+
+ absent_case = frappe.qb.terms.Case().when(Attendance.status == "Absent", 1).else_(0)
+ sum_absent = Sum(absent_case).as_("total_absent")
+
+ leave_case = frappe.qb.terms.Case().when(Attendance.status == "On Leave", 1).else_(0)
+ sum_leave = Sum(leave_case).as_("total_leaves")
+
+ half_day_case = frappe.qb.terms.Case().when(Attendance.status == "Half Day", 0.5).else_(0)
+ sum_half_day = Sum(half_day_case).as_("total_half_days")
+
+ summary = (
+ frappe.qb.from_(Attendance)
+ .select(
+ sum_present,
+ sum_absent,
+ sum_leave,
+ sum_half_day,
+ )
+ .where(
+ (Attendance.docstatus == 1)
+ & (Attendance.employee == employee)
+ & (Attendance.company == filters.company)
+ & (Extract("month", Attendance.attendance_date) == filters.month)
+ & (Extract("year", Attendance.attendance_date) == filters.year)
+ )
+ ).run(as_dict=True)
+
+ days = (
+ frappe.qb.from_(Attendance)
+ .select(Extract("day", Attendance.attendance_date).as_("day_of_month"))
+ .distinct()
+ .where(
+ (Attendance.docstatus == 1)
+ & (Attendance.employee == employee)
+ & (Attendance.company == filters.company)
+ & (Extract("month", Attendance.attendance_date) == filters.month)
+ & (Extract("year", Attendance.attendance_date) == filters.year)
+ )
+ ).run(pluck=True)
+
+ return summary[0], days
+
+
+def get_attendance_status_for_detailed_view(
+ employee: str, filters: Filters, employee_attendance: Dict, holidays: List
+) -> List[Dict]:
+ """Returns list of shift-wise attendance status for employee
+ [
+ {'shift': 'Morning Shift', 1: 'A', 2: 'P', 3: 'A'....},
+ {'shift': 'Evening Shift', 1: 'P', 2: 'A', 3: 'P'....}
+ ]
+ """
+ total_days = get_total_days_in_month(filters)
+ attendance_values = []
+
+ for shift, status_dict in employee_attendance.items():
+ row = {"shift": shift}
+
+ for day in range(1, total_days + 1):
+ status = status_dict.get(day)
+ if status is None and holidays:
+ status = get_holiday_status(day, holidays)
+
+ abbr = status_map.get(status, "")
+ row[day] = abbr
+
+ attendance_values.append(row)
+
+ return attendance_values
+
+
+def get_holiday_status(day: int, holidays: List) -> str:
+ status = None
+ for holiday in holidays:
+ if day == holiday.get("day_of_month"):
+ if holiday.get("weekly_off"):
+ status = "Weekly Off"
+ else:
+ status = "Holiday"
+ break
+ return status
+
+
+def get_leave_summary(employee: str, filters: Filters) -> Dict[str, float]:
+ """Returns a dict of leave type and corresponding leaves taken by employee like:
+ {'leave_without_pay': 1.0, 'sick_leave': 2.0}
+ """
+ Attendance = frappe.qb.DocType("Attendance")
+ day_case = frappe.qb.terms.Case().when(Attendance.status == "Half Day", 0.5).else_(1)
+ sum_leave_days = Sum(day_case).as_("leave_days")
+
+ leave_details = (
+ frappe.qb.from_(Attendance)
+ .select(Attendance.leave_type, sum_leave_days)
+ .where(
+ (Attendance.employee == employee)
+ & (Attendance.docstatus == 1)
+ & (Attendance.company == filters.company)
+ & ((Attendance.leave_type.isnotnull()) | (Attendance.leave_type != ""))
+ & (Extract("month", Attendance.attendance_date) == filters.month)
+ & (Extract("year", Attendance.attendance_date) == filters.year)
+ )
+ .groupby(Attendance.leave_type)
+ ).run(as_dict=True)
+
+ leaves = {}
+ for d in leave_details:
+ leave_type = frappe.scrub(d.leave_type)
+ leaves[leave_type] = d.leave_days
+
+ return leaves
+
+
+def get_entry_exits_summary(employee: str, filters: Filters) -> Dict[str, float]:
+ """Returns total late entries and total early exits for employee like:
+ {'total_late_entries': 5, 'total_early_exits': 2}
+ """
+ Attendance = frappe.qb.DocType("Attendance")
+
+ late_entry_case = frappe.qb.terms.Case().when(Attendance.late_entry == "1", "1")
+ count_late_entries = Count(late_entry_case).as_("total_late_entries")
+
+ early_exit_case = frappe.qb.terms.Case().when(Attendance.early_exit == "1", "1")
+ count_early_exits = Count(early_exit_case).as_("total_early_exits")
+
+ entry_exits = (
+ frappe.qb.from_(Attendance)
+ .select(count_late_entries, count_early_exits)
+ .where(
+ (Attendance.docstatus == 1)
+ & (Attendance.employee == employee)
+ & (Attendance.company == filters.company)
+ & (Extract("month", Attendance.attendance_date) == filters.month)
+ & (Extract("year", Attendance.attendance_date) == filters.year)
+ )
+ ).run(as_dict=True)
+
+ return entry_exits[0]
+
+
+@frappe.whitelist()
+def get_attendance_years() -> str:
+ """Returns all the years for which attendance records exist"""
+ Attendance = frappe.qb.DocType("Attendance")
+ year_list = (
+ frappe.qb.from_(Attendance)
+ .select(Extract("year", Attendance.attendance_date).as_("year"))
+ .distinct()
+ ).run(as_dict=True)
+
+ if year_list:
+ year_list.sort(key=lambda d: d.year, reverse=True)
+ else:
year_list = [getdate().year]
- return "\n".join(str(year) for year in year_list)
+ return "\n".join(cstr(entry.year) for entry in year_list)
+
+
+def get_chart_data(attendance_map: Dict, filters: Filters) -> Dict:
+ days = get_columns_for_days(filters)
+ labels = []
+ absent = []
+ present = []
+ leave = []
+
+ for day in days:
+ labels.append(day["label"])
+ total_absent_on_day = total_leaves_on_day = total_present_on_day = 0
+
+ for employee, attendance_dict in attendance_map.items():
+ for shift, attendance in attendance_dict.items():
+ attendance_on_day = attendance.get(day["fieldname"])
+
+ if attendance_on_day == "Absent":
+ total_absent_on_day += 1
+ elif attendance_on_day in ["Present", "Work From Home"]:
+ total_present_on_day += 1
+ elif attendance_on_day == "Half Day":
+ total_present_on_day += 0.5
+ total_leaves_on_day += 0.5
+ elif attendance_on_day == "On Leave":
+ total_leaves_on_day += 1
+
+ absent.append(total_absent_on_day)
+ present.append(total_present_on_day)
+ leave.append(total_leaves_on_day)
+
+ return {
+ "data": {
+ "labels": labels,
+ "datasets": [
+ {"name": "Absent", "values": absent},
+ {"name": "Present", "values": present},
+ {"name": "Leave", "values": leave},
+ ],
+ },
+ "type": "line",
+ "colors": ["red", "green", "blue"],
+ }
diff --git a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py
index 91da08e..cde7dd3 100644
--- a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py
+++ b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py
@@ -1,18 +1,32 @@
import frappe
from dateutil.relativedelta import relativedelta
from frappe.tests.utils import FrappeTestCase
-from frappe.utils import now_datetime
+from frappe.utils import get_year_ending, get_year_start, getdate, now_datetime
from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
+from erpnext.hr.doctype.leave_application.test_leave_application import make_allocation_record
from erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet import execute
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
+ make_holiday_list,
+ make_leave_application,
+)
+
+test_dependencies = ["Shift Type"]
class TestMonthlyAttendanceSheet(FrappeTestCase):
def setUp(self):
- self.employee = make_employee("test_employee@example.com")
- frappe.db.delete("Attendance", {"employee": self.employee})
+ self.employee = make_employee("test_employee@example.com", company="_Test Company")
+ frappe.db.delete("Attendance")
+ date = getdate()
+ from_date = get_year_start(date)
+ to_date = get_year_ending(date)
+ make_holiday_list(from_date=from_date, to_date=to_date)
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_monthly_attendance_sheet_report(self):
now = now_datetime()
previous_month = now.month - 1
@@ -33,14 +47,203 @@
}
)
report = execute(filters=filters)
- employees = report[1][0]
+
+ record = report[1][0]
datasets = report[3]["data"]["datasets"]
absent = datasets[0]["values"]
present = datasets[1]["values"]
leaves = datasets[2]["values"]
- # ensure correct attendance is reflect on the report
- self.assertIn(self.employee, employees)
+ # ensure correct attendance is reflected on the report
+ self.assertEqual(self.employee, record.get("employee"))
self.assertEqual(absent[0], 1)
self.assertEqual(present[1], 1)
self.assertEqual(leaves[2], 1)
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_monthly_attendance_sheet_with_detailed_view(self):
+ now = now_datetime()
+ previous_month = now.month - 1
+ previous_month_first = now.replace(day=1).replace(month=previous_month).date()
+
+ company = frappe.db.get_value("Employee", self.employee, "company")
+
+ # attendance with shift
+ mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift")
+ mark_attendance(
+ self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift"
+ )
+
+ # attendance without shift
+ mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave")
+ mark_attendance(self.employee, previous_month_first + relativedelta(days=3), "Present")
+
+ filters = frappe._dict(
+ {
+ "month": previous_month,
+ "year": now.year,
+ "company": company,
+ }
+ )
+ report = execute(filters=filters)
+
+ day_shift_row = report[1][0]
+ row_without_shift = report[1][1]
+
+ self.assertEqual(day_shift_row["shift"], "Day Shift")
+ self.assertEqual(day_shift_row[1], "A") # absent on the 1st day of the month
+ self.assertEqual(day_shift_row[2], "P") # present on the 2nd day
+
+ self.assertEqual(row_without_shift["shift"], None)
+ self.assertEqual(row_without_shift[3], "L") # on leave on the 3rd day
+ self.assertEqual(row_without_shift[4], "P") # present on the 4th day
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_monthly_attendance_sheet_with_summarized_view(self):
+ now = now_datetime()
+ previous_month = now.month - 1
+ previous_month_first = now.replace(day=1).replace(month=previous_month).date()
+
+ company = frappe.db.get_value("Employee", self.employee, "company")
+
+ # attendance with shift
+ mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift")
+ mark_attendance(
+ self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift"
+ )
+ mark_attendance(
+ self.employee, previous_month_first + relativedelta(days=2), "Half Day"
+ ) # half day
+
+ mark_attendance(
+ self.employee, previous_month_first + relativedelta(days=3), "Present"
+ ) # attendance without shift
+ mark_attendance(
+ self.employee, previous_month_first + relativedelta(days=4), "Present", late_entry=1
+ ) # late entry
+ mark_attendance(
+ self.employee, previous_month_first + relativedelta(days=5), "Present", early_exit=1
+ ) # early exit
+
+ leave_application = get_leave_application(self.employee)
+
+ filters = frappe._dict(
+ {"month": previous_month, "year": now.year, "company": company, "summarized_view": 1}
+ )
+ report = execute(filters=filters)
+
+ row = report[1][0]
+ self.assertEqual(row["employee"], self.employee)
+
+ # 4 present + half day absent 0.5
+ self.assertEqual(row["total_present"], 4.5)
+ # 1 present + half day absent 0.5
+ self.assertEqual(row["total_absent"], 1.5)
+ # leave days + half day leave 0.5
+ self.assertEqual(row["total_leaves"], leave_application.total_leave_days + 0.5)
+
+ self.assertEqual(row["_test_leave_type"], leave_application.total_leave_days)
+ self.assertEqual(row["total_late_entries"], 1)
+ self.assertEqual(row["total_early_exits"], 1)
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_attendance_with_group_by_filter(self):
+ now = now_datetime()
+ previous_month = now.month - 1
+ previous_month_first = now.replace(day=1).replace(month=previous_month).date()
+
+ company = frappe.db.get_value("Employee", self.employee, "company")
+
+ # attendance with shift
+ mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift")
+ mark_attendance(
+ self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift"
+ )
+
+ # attendance without shift
+ mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave")
+ mark_attendance(self.employee, previous_month_first + relativedelta(days=3), "Present")
+
+ filters = frappe._dict(
+ {"month": previous_month, "year": now.year, "company": company, "group_by": "Department"}
+ )
+ report = execute(filters=filters)
+
+ department = frappe.db.get_value("Employee", self.employee, "department")
+ department_row = report[1][0]
+ self.assertIn(department, department_row["department"])
+
+ day_shift_row = report[1][1]
+ row_without_shift = report[1][2]
+
+ self.assertEqual(day_shift_row["shift"], "Day Shift")
+ self.assertEqual(day_shift_row[1], "A") # absent on the 1st day of the month
+ self.assertEqual(day_shift_row[2], "P") # present on the 2nd day
+
+ self.assertEqual(row_without_shift["shift"], None)
+ self.assertEqual(row_without_shift[3], "L") # on leave on the 3rd day
+ self.assertEqual(row_without_shift[4], "P") # present on the 4th day
+
+ def test_attendance_with_employee_filter(self):
+ now = now_datetime()
+ previous_month = now.month - 1
+ previous_month_first = now.replace(day=1).replace(month=previous_month).date()
+
+ company = frappe.db.get_value("Employee", self.employee, "company")
+
+ # mark different attendance status on first 3 days of previous month
+ mark_attendance(self.employee, previous_month_first, "Absent")
+ mark_attendance(self.employee, previous_month_first + relativedelta(days=1), "Present")
+ mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave")
+
+ filters = frappe._dict(
+ {"month": previous_month, "year": now.year, "company": company, "employee": self.employee}
+ )
+ report = execute(filters=filters)
+
+ record = report[1][0]
+ datasets = report[3]["data"]["datasets"]
+ absent = datasets[0]["values"]
+ present = datasets[1]["values"]
+ leaves = datasets[2]["values"]
+
+ # ensure correct attendance is reflected on the report
+ self.assertEqual(self.employee, record.get("employee"))
+ self.assertEqual(absent[0], 1)
+ self.assertEqual(present[1], 1)
+ self.assertEqual(leaves[2], 1)
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_validations(self):
+ # validation error for filters without month and year
+ self.assertRaises(frappe.ValidationError, execute_report_with_invalid_filters)
+
+ # execute report without attendance record
+ now = now_datetime()
+ previous_month = now.month - 1
+
+ company = frappe.db.get_value("Employee", self.employee, "company")
+ filters = frappe._dict(
+ {"month": previous_month, "year": now.year, "company": company, "group_by": "Department"}
+ )
+ report = execute(filters=filters)
+ self.assertEqual(report, ([], [], None, None))
+
+
+def get_leave_application(employee):
+ now = now_datetime()
+ previous_month = now.month - 1
+
+ date = getdate()
+ year_start = getdate(get_year_start(date))
+ year_end = getdate(get_year_ending(date))
+ make_allocation_record(employee=employee, from_date=year_start, to_date=year_end)
+
+ from_date = now.replace(day=7).replace(month=previous_month).date()
+ to_date = now.replace(day=8).replace(month=previous_month).date()
+ return make_leave_application(employee, from_date, to_date, "_Test Leave Type")
+
+
+def execute_report_with_invalid_filters():
+ filters = frappe._dict({"company": "_Test Company", "group_by": "Department"})
+ execute(filters=filters)
diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py
index fd69a9b..40ab805 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -353,6 +353,17 @@
allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
+ if e_leave_type.based_on_date_of_joining:
+ text = _("allocated {0} leave(s) via scheduler on {1} based on the date of joining").format(
+ frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
+ )
+ else:
+ text = _("allocated {0} leave(s) via scheduler on {1}").format(
+ frappe.bold(earned_leaves), frappe.bold(formatdate(today_date))
+ )
+
+ allocation.add_comment(comment_type="Info", text=text)
+
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
earned_leaves = 0.0
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index 8cffe88..2535180 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -387,13 +387,13 @@
gle_map = []
if self.shortfall_amount and self.amount_paid > self.shortfall_amount:
- remarks = _("Shortfall Repayment of {0}.\nRepayment against Loan: {1}").format(
+ remarks = _("Shortfall Repayment of {0}.<br>Repayment against Loan: {1}").format(
self.shortfall_amount, self.against_loan
)
elif self.shortfall_amount:
remarks = _("Shortfall Repayment of {0}").format(self.shortfall_amount)
else:
- remarks = _("Repayment against Loan: ") + self.against_loan
+ remarks = _("Repayment against Loan:") + " " + self.against_loan
if self.repay_from_salary:
payment_account = self.payroll_payable_account
@@ -584,9 +584,10 @@
balance_amount / len(loan_doc.get("repayment_schedule")) - accrued_entries
)
else:
- if not cancel:
+ repayment_period = loan_doc.repayment_periods - accrued_entries
+ if not cancel and repayment_period > 0:
monthly_repayment_amount = get_monthly_repayment_amount(
- balance_amount, loan_doc.rate_of_interest, loan_doc.repayment_periods - accrued_entries
+ balance_amount, loan_doc.rate_of_interest, repayment_period
)
else:
monthly_repayment_amount = last_repayment_amount
@@ -745,6 +746,8 @@
if payment_type == "Loan Closure":
amounts["payable_principal_amount"] = amounts["pending_principal_amount"]
amounts["interest_amount"] += amounts["unaccrued_interest"]
- amounts["payable_amount"] = amounts["payable_principal_amount"] + amounts["interest_amount"]
+ amounts["payable_amount"] = (
+ amounts["payable_principal_amount"] + amounts["interest_amount"] + amounts["penalty_amount"]
+ )
return amounts
diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js
index 035290d..5252798 100644
--- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js
+++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js
@@ -140,26 +140,6 @@
}
}
- start_date(doc, cdt, cdn) {
- this.set_no_of_visits(doc, cdt, cdn);
- }
-
- end_date(doc, cdt, cdn) {
- this.set_no_of_visits(doc, cdt, cdn);
- }
-
- periodicity(doc, cdt, cdn) {
- this.set_no_of_visits(doc, cdt, cdn);
- }
-
- set_no_of_visits(doc, cdt, cdn) {
- var item = frappe.get_doc(cdt, cdn);
- let me = this;
- if (item.start_date && item.periodicity) {
- me.frm.call('validate_end_date_visits');
-
- }
- }
};
extend_cscript(cur_frm.cscript, new erpnext.maintenance.MaintenanceSchedule({frm: cur_frm}));
diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py
index 256f660..04c080c 100644
--- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py
+++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py
@@ -213,6 +213,26 @@
if chk:
throw(_("Maintenance Schedule {0} exists against {1}").format(chk[0][0], d.sales_order))
+ def validate_items_table_change(self):
+ doc_before_save = self.get_doc_before_save()
+ if not doc_before_save:
+ return
+ for prev_item, item in zip(doc_before_save.items, self.items):
+ fields = [
+ "item_code",
+ "start_date",
+ "end_date",
+ "periodicity",
+ "sales_person",
+ "no_of_visits",
+ "serial_no",
+ ]
+ for field in fields:
+ b_doc = prev_item.as_dict()
+ doc = item.as_dict()
+ if cstr(b_doc[field]) != cstr(doc[field]):
+ return True
+
def validate_no_of_visits(self):
return len(self.schedules) != sum(d.no_of_visits for d in self.items)
@@ -221,7 +241,7 @@
self.validate_maintenance_detail()
self.validate_dates_with_periodicity()
self.validate_sales_order()
- if not self.schedules or self.validate_no_of_visits():
+ if not self.schedules or self.validate_items_table_change() or self.validate_no_of_visits():
self.generate_schedule()
def on_update(self):
@@ -250,7 +270,7 @@
_("Serial No {0} does not belong to Item {1}").format(
frappe.bold(serial_no), frappe.bold(item_code)
),
- title="Invalid",
+ title=_("Invalid"),
)
if sr_details.warranty_expiry_date and getdate(sr_details.warranty_expiry_date) >= getdate(
diff --git a/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py
index a98cd10..2268e0f 100644
--- a/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py
+++ b/erpnext/maintenance/doctype/maintenance_schedule/test_maintenance_schedule.py
@@ -123,6 +123,36 @@
frappe.db.rollback()
+ def test_schedule_with_serials(self):
+ # Checks whether serials are automatically updated when changing in items table.
+ # Also checks if other fields trigger generate schdeule if changed in items table.
+ item_code = "_Test Serial Item"
+ make_serial_item_with_serial(item_code)
+ ms = make_maintenance_schedule(item_code=item_code, serial_no="TEST001, TEST002")
+ ms.save()
+
+ # Before Save
+ self.assertEqual(ms.schedules[0].serial_no, "TEST001, TEST002")
+ self.assertEqual(ms.schedules[0].sales_person, "Sales Team")
+ self.assertEqual(len(ms.schedules), 4)
+ self.assertFalse(ms.validate_items_table_change())
+ # After Save
+ ms.items[0].serial_no = "TEST001"
+ ms.items[0].sales_person = "_Test Sales Person"
+ ms.items[0].no_of_visits = 2
+ self.assertTrue(ms.validate_items_table_change())
+ ms.save()
+ self.assertEqual(ms.schedules[0].serial_no, "TEST001")
+ self.assertEqual(ms.schedules[0].sales_person, "_Test Sales Person")
+ self.assertEqual(len(ms.schedules), 2)
+ # When user manually deleted a row from schedules table.
+ ms.schedules.pop()
+ self.assertEqual(len(ms.schedules), 1)
+ ms.save()
+ self.assertEqual(len(ms.schedules), 2)
+
+ frappe.db.rollback()
+
def make_serial_item_with_serial(item_code):
serial_item_doc = create_item(item_code, is_stock_item=1)
diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
index 72686e7..e2f6cb3 100644
--- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
+++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
@@ -12,6 +12,9 @@
// filters for serial no based on item code
if (frm.doc.maintenance_type === "Scheduled") {
let item_code = frm.doc.purposes[0].item_code;
+ if (!item_code) {
+ return;
+ }
frappe.call({
method: "erpnext.maintenance.doctype.maintenance_schedule.maintenance_schedule.get_serial_nos_from_schedule",
args: {
diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py
index 29a1784..66f4426 100644
--- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py
+++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.py
@@ -20,7 +20,7 @@
def validate_purpose_table(self):
if not self.purposes:
- frappe.throw(_("Add Items in the Purpose Table"), title="Purposes Required")
+ frappe.throw(_("Add Items in the Purpose Table"), title=_("Purposes Required"))
def validate_maintenance_date(self):
if self.maintenance_type == "Scheduled" and self.maintenance_schedule_detail:
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index bf29474..fefb2e5 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -697,15 +697,6 @@
self.scrap_material_cost = total_sm_cost
self.base_scrap_material_cost = base_total_sm_cost
- def update_new_bom(self, old_bom, new_bom, rate):
- for d in self.get("items"):
- if d.bom_no != old_bom:
- continue
-
- d.bom_no = new_bom
- d.rate = rate
- d.amount = (d.stock_qty or d.qty) * rate
-
def update_exploded_items(self, save=True):
"""Update Flat BOM, following will be correct data"""
self.get_exploded_items()
@@ -1025,7 +1016,7 @@
query = query.format(
table="BOM Scrap Item",
where_conditions="",
- select_columns=", bom_item.idx, item.description, is_process_loss",
+ select_columns=", item.description, is_process_loss",
is_stock_item=is_stock_item,
qty_field="stock_qty",
)
@@ -1038,7 +1029,7 @@
is_stock_item=is_stock_item,
qty_field="stock_qty" if fetch_qty_in_stock_uom else "qty",
select_columns=""", bom_item.uom, bom_item.conversion_factor, bom_item.source_warehouse,
- bom_item.idx, bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier,
+ bom_item.operation, bom_item.include_item_in_manufacturing, bom_item.sourced_by_supplier,
bom_item.description, bom_item.base_rate as rate """,
)
items = frappe.db.sql(query, {"qty": qty, "bom": bom, "company": company}, as_dict=True)
diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py
index 524f45b..62fc072 100644
--- a/erpnext/manufacturing/doctype/bom/test_bom.py
+++ b/erpnext/manufacturing/doctype/bom/test_bom.py
@@ -251,7 +251,7 @@
self.assertEqual(bom.items[2].rate, 0)
# test in Purchase Order sourced_by_supplier is not added to Supplied Item
po = create_purchase_order(
- item_code=item_code, qty=1, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ item_code=item_code, qty=1, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
bom_items = sorted([d.item_code for d in bom.items if d.sourced_by_supplier != 1])
supplied_items = sorted([d.rm_item_code for d in po.supplied_items])
diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
index 341f969..b965a43 100644
--- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
+++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json
@@ -109,7 +109,6 @@
"read_only": 1
},
{
- "default": "5",
"depends_on": "eval:parent.doctype == 'BOM'",
"fieldname": "base_operating_cost",
"fieldtype": "Currency",
@@ -187,7 +186,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-03-10 06:19:08.462027",
+ "modified": "2022-04-08 01:18:33.547481",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",
diff --git a/erpnext/regional/doctype/datev_settings/__init__.py b/erpnext/manufacturing/doctype/bom_update_log/__init__.py
similarity index 100%
rename from erpnext/regional/doctype/datev_settings/__init__.py
rename to erpnext/manufacturing/doctype/bom_update_log/__init__.py
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.js b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.js
new file mode 100644
index 0000000..6da808e
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('BOM Update Log', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
new file mode 100644
index 0000000..98c1acb
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.json
@@ -0,0 +1,109 @@
+{
+ "actions": [],
+ "autoname": "BOM-UPDT-LOG-.#####",
+ "creation": "2022-03-16 14:23:35.210155",
+ "description": "BOM Update Tool Log with job status maintained",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "current_bom",
+ "new_bom",
+ "column_break_3",
+ "update_type",
+ "status",
+ "error_log",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "current_bom",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Current BOM",
+ "options": "BOM"
+ },
+ {
+ "fieldname": "new_bom",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "New BOM",
+ "options": "BOM"
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "update_type",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Update Type",
+ "options": "Replace BOM\nUpdate Cost"
+ },
+ {
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "label": "Status",
+ "options": "Queued\nIn Progress\nCompleted\nFailed"
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "BOM Update Log",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "error_log",
+ "fieldtype": "Link",
+ "label": "Error Log",
+ "options": "Error Log"
+ }
+ ],
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2022-03-31 12:51:44.885102",
+ "modified_by": "Administrator",
+ "module": "Manufacturing",
+ "name": "BOM Update Log",
+ "naming_rule": "Expression (old style)",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Manufacturing Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py
new file mode 100644
index 0000000..139dcbc
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log.py
@@ -0,0 +1,164 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+from typing import Dict, List, Literal, Optional
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils import cstr, flt
+
+from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
+
+
+class BOMMissingError(frappe.ValidationError):
+ pass
+
+
+class BOMUpdateLog(Document):
+ def validate(self):
+ if self.update_type == "Replace BOM":
+ self.validate_boms_are_specified()
+ self.validate_same_bom()
+ self.validate_bom_items()
+
+ self.status = "Queued"
+
+ def validate_boms_are_specified(self):
+ if self.update_type == "Replace BOM" and not (self.current_bom and self.new_bom):
+ frappe.throw(
+ msg=_("Please mention the Current and New BOM for replacement."),
+ title=_("Mandatory"),
+ exc=BOMMissingError,
+ )
+
+ def validate_same_bom(self):
+ if cstr(self.current_bom) == cstr(self.new_bom):
+ frappe.throw(_("Current BOM and New BOM can not be same"))
+
+ def validate_bom_items(self):
+ current_bom_item = frappe.db.get_value("BOM", self.current_bom, "item")
+ new_bom_item = frappe.db.get_value("BOM", self.new_bom, "item")
+
+ if current_bom_item != new_bom_item:
+ frappe.throw(_("The selected BOMs are not for the same item"))
+
+ def on_submit(self):
+ if frappe.flags.in_test:
+ return
+
+ if self.update_type == "Replace BOM":
+ boms = {"current_bom": self.current_bom, "new_bom": self.new_bom}
+ frappe.enqueue(
+ method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job",
+ doc=self,
+ boms=boms,
+ timeout=40000,
+ )
+ else:
+ frappe.enqueue(
+ method="erpnext.manufacturing.doctype.bom_update_log.bom_update_log.run_bom_job",
+ doc=self,
+ update_type="Update Cost",
+ timeout=40000,
+ )
+
+
+def replace_bom(boms: Dict) -> None:
+ """Replace current BOM with new BOM in parent BOMs."""
+ current_bom = boms.get("current_bom")
+ new_bom = boms.get("new_bom")
+
+ unit_cost = get_new_bom_unit_cost(new_bom)
+ update_new_bom_in_bom_items(unit_cost, current_bom, new_bom)
+
+ frappe.cache().delete_key("bom_children")
+ parent_boms = get_parent_boms(new_bom)
+
+ for bom in parent_boms:
+ bom_obj = frappe.get_doc("BOM", bom)
+ # this is only used for versioning and we do not want
+ # to make separate db calls by using load_doc_before_save
+ # which proves to be expensive while doing bulk replace
+ bom_obj._doc_before_save = bom_obj
+ bom_obj.update_exploded_items()
+ bom_obj.calculate_cost()
+ bom_obj.update_parent_cost()
+ bom_obj.db_update()
+ if bom_obj.meta.get("track_changes") and not bom_obj.flags.ignore_version:
+ bom_obj.save_version()
+
+
+def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:
+ bom_item = frappe.qb.DocType("BOM Item")
+ (
+ frappe.qb.update(bom_item)
+ .set(bom_item.bom_no, new_bom)
+ .set(bom_item.rate, unit_cost)
+ .set(bom_item.amount, (bom_item.stock_qty * unit_cost))
+ .where(
+ (bom_item.bom_no == current_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM")
+ )
+ ).run()
+
+
+def get_parent_boms(new_bom: str, bom_list: Optional[List] = None) -> List:
+ bom_list = bom_list or []
+ bom_item = frappe.qb.DocType("BOM Item")
+
+ parents = (
+ frappe.qb.from_(bom_item)
+ .select(bom_item.parent)
+ .where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
+ .run(as_dict=True)
+ )
+
+ for d in parents:
+ if new_bom == d.parent:
+ frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
+
+ bom_list.append(d.parent)
+ get_parent_boms(d.parent, bom_list)
+
+ return list(set(bom_list))
+
+
+def get_new_bom_unit_cost(new_bom: str) -> float:
+ bom = frappe.qb.DocType("BOM")
+ new_bom_unitcost = (
+ frappe.qb.from_(bom).select(bom.total_cost / bom.quantity).where(bom.name == new_bom).run()
+ )
+
+ return flt(new_bom_unitcost[0][0])
+
+
+def run_bom_job(
+ doc: "BOMUpdateLog",
+ boms: Optional[Dict[str, str]] = None,
+ update_type: Literal["Replace BOM", "Update Cost"] = "Replace BOM",
+) -> None:
+ try:
+ doc.db_set("status", "In Progress")
+ if not frappe.flags.in_test:
+ frappe.db.commit()
+
+ frappe.db.auto_commit_on_many_writes = 1
+
+ boms = frappe._dict(boms or {})
+
+ if update_type == "Replace BOM":
+ replace_bom(boms)
+ else:
+ update_cost()
+
+ doc.db_set("status", "Completed")
+
+ except Exception:
+ frappe.db.rollback()
+ error_log = frappe.log_error(message=frappe.get_traceback(), title=_("BOM Update Tool Error"))
+
+ doc.db_set("status", "Failed")
+ doc.db_set("error_log", error_log.name)
+
+ finally:
+ frappe.db.auto_commit_on_many_writes = 0
+ frappe.db.commit() # nosemgrep
diff --git a/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js
new file mode 100644
index 0000000..e39b563
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_log/bom_update_log_list.js
@@ -0,0 +1,13 @@
+frappe.listview_settings['BOM Update Log'] = {
+ add_fields: ["status"],
+ get_indicator: function(doc) {
+ let status_map = {
+ "Queued": "orange",
+ "In Progress": "blue",
+ "Completed": "green",
+ "Failed": "red"
+ };
+
+ return [__(doc.status), status_map[doc.status], "status,=," + doc.status];
+ }
+};
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py
new file mode 100644
index 0000000..47efea9
--- /dev/null
+++ b/erpnext/manufacturing/doctype/bom_update_log/test_bom_update_log.py
@@ -0,0 +1,96 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import (
+ BOMMissingError,
+ run_bom_job,
+)
+from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import enqueue_replace_bom
+
+test_records = frappe.get_test_records("BOM")
+
+
+class TestBOMUpdateLog(FrappeTestCase):
+ "Test BOM Update Tool Operations via BOM Update Log."
+
+ def setUp(self):
+ bom_doc = frappe.copy_doc(test_records[0])
+ bom_doc.items[1].item_code = "_Test Item"
+ bom_doc.insert()
+
+ self.boms = frappe._dict(
+ current_bom="BOM-_Test Item Home Desktop Manufactured-001",
+ new_bom=bom_doc.name,
+ )
+
+ self.new_bom_doc = bom_doc
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ if self._testMethodName == "test_bom_update_log_completion":
+ # clear logs and delete BOM created via setUp
+ frappe.db.delete("BOM Update Log")
+ self.new_bom_doc.cancel()
+ self.new_bom_doc.delete()
+
+ # explicitly commit and restore to original state
+ frappe.db.commit() # nosemgrep
+
+ def test_bom_update_log_validate(self):
+ "Test if BOM presence is validated."
+
+ with self.assertRaises(BOMMissingError):
+ enqueue_replace_bom(boms={})
+
+ with self.assertRaises(frappe.ValidationError):
+ enqueue_replace_bom(boms=frappe._dict(current_bom=self.boms.new_bom, new_bom=self.boms.new_bom))
+
+ with self.assertRaises(frappe.ValidationError):
+ enqueue_replace_bom(boms=frappe._dict(current_bom=self.boms.new_bom, new_bom="Dummy BOM"))
+
+ def test_bom_update_log_queueing(self):
+ "Test if BOM Update Log is created and queued."
+
+ log = enqueue_replace_bom(
+ boms=self.boms,
+ )
+
+ self.assertEqual(log.docstatus, 1)
+ self.assertEqual(log.status, "Queued")
+
+ def test_bom_update_log_completion(self):
+ "Test if BOM Update Log handles job completion correctly."
+
+ log = enqueue_replace_bom(
+ boms=self.boms,
+ )
+
+ # Explicitly commits log, new bom (setUp) and replacement impact.
+ # Is run via background jobs IRL
+ run_bom_job(
+ doc=log,
+ boms=self.boms,
+ update_type="Replace BOM",
+ )
+ log.reload()
+
+ self.assertEqual(log.status, "Completed")
+
+ # teardown (undo replace impact) due to commit
+ boms = frappe._dict(
+ current_bom=self.boms.new_bom,
+ new_bom=self.boms.current_bom,
+ )
+ log2 = enqueue_replace_bom(
+ boms=self.boms,
+ )
+ run_bom_job( # Explicitly commits
+ doc=log2,
+ boms=boms,
+ update_type="Replace BOM",
+ )
+ self.assertEqual(log2.status, "Completed")
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.js b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.js
index bf5fe2e..7ba6517 100644
--- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.js
+++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.js
@@ -20,30 +20,67 @@
refresh: function(frm) {
frm.disable_save();
+ frm.events.disable_button(frm, "replace");
+
+ frm.add_custom_button(__("View BOM Update Log"), () => {
+ frappe.set_route("List", "BOM Update Log");
+ });
},
- replace: function(frm) {
+ disable_button: (frm, field, disable=true) => {
+ frm.get_field(field).input.disabled = disable;
+ },
+
+ current_bom: (frm) => {
+ if (frm.doc.current_bom && frm.doc.new_bom) {
+ frm.events.disable_button(frm, "replace", false);
+ }
+ },
+
+ new_bom: (frm) => {
+ if (frm.doc.current_bom && frm.doc.new_bom) {
+ frm.events.disable_button(frm, "replace", false);
+ }
+ },
+
+ replace: (frm) => {
if (frm.doc.current_bom && frm.doc.new_bom) {
frappe.call({
method: "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.enqueue_replace_bom",
freeze: true,
args: {
- args: {
+ boms: {
"current_bom": frm.doc.current_bom,
"new_bom": frm.doc.new_bom
}
+ },
+ callback: result => {
+ if (result && result.message && !result.exc) {
+ frm.events.confirm_job_start(frm, result.message);
+ }
}
});
}
},
- update_latest_price_in_all_boms: function() {
+ update_latest_price_in_all_boms: (frm) => {
frappe.call({
method: "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.enqueue_update_cost",
freeze: true,
- callback: function() {
- frappe.msgprint(__("Latest price updated in all BOMs"));
+ callback: result => {
+ if (result && result.message && !result.exc) {
+ frm.events.confirm_job_start(frm, result.message);
+ }
}
});
+ },
+
+ confirm_job_start: (frm, log_data) => {
+ let log_link = frappe.utils.get_form_link("BOM Update Log", log_data.name, true);
+ frappe.msgprint({
+ "message": __("BOM Updation is queued and may take a few minutes. Check {0} for progress.", [log_link]),
+ "title": __("BOM Update Initiated"),
+ "indicator": "blue"
+ });
}
});
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
index 00711ca..b0e7da1 100644
--- a/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
+++ b/erpnext/manufacturing/doctype/bom_update_tool/bom_update_tool.py
@@ -1,136 +1,69 @@
-# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-
import json
+from typing import TYPE_CHECKING, Dict, Literal, Optional, Union
-import click
+if TYPE_CHECKING:
+ from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import BOMUpdateLog
+
import frappe
-from frappe import _
from frappe.model.document import Document
-from frappe.utils import cstr, flt
from erpnext.manufacturing.doctype.bom.bom import get_boms_in_bottom_up_order
class BOMUpdateTool(Document):
- def replace_bom(self):
- self.validate_bom()
-
- unit_cost = get_new_bom_unit_cost(self.new_bom)
- self.update_new_bom(unit_cost)
-
- frappe.cache().delete_key("bom_children")
- bom_list = self.get_parent_boms(self.new_bom)
-
- with click.progressbar(bom_list) as bom_list:
- pass
- for bom in bom_list:
- try:
- bom_obj = frappe.get_cached_doc("BOM", bom)
- # this is only used for versioning and we do not want
- # to make separate db calls by using load_doc_before_save
- # which proves to be expensive while doing bulk replace
- bom_obj._doc_before_save = bom_obj
- bom_obj.update_new_bom(self.current_bom, self.new_bom, unit_cost)
- bom_obj.update_exploded_items()
- bom_obj.calculate_cost()
- bom_obj.update_parent_cost()
- bom_obj.db_update()
- if bom_obj.meta.get("track_changes") and not bom_obj.flags.ignore_version:
- bom_obj.save_version()
- except Exception:
- frappe.log_error(frappe.get_traceback())
-
- def validate_bom(self):
- if cstr(self.current_bom) == cstr(self.new_bom):
- frappe.throw(_("Current BOM and New BOM can not be same"))
-
- if frappe.db.get_value("BOM", self.current_bom, "item") != frappe.db.get_value(
- "BOM", self.new_bom, "item"
- ):
- frappe.throw(_("The selected BOMs are not for the same item"))
-
- def update_new_bom(self, unit_cost):
- frappe.db.sql(
- """update `tabBOM Item` set bom_no=%s,
- rate=%s, amount=stock_qty*%s where bom_no = %s and docstatus < 2 and parenttype='BOM'""",
- (self.new_bom, unit_cost, unit_cost, self.current_bom),
- )
-
- def get_parent_boms(self, bom, bom_list=None):
- if bom_list is None:
- bom_list = []
- data = frappe.db.sql(
- """SELECT DISTINCT parent FROM `tabBOM Item`
- WHERE bom_no = %s AND docstatus < 2 AND parenttype='BOM'""",
- bom,
- )
-
- for d in data:
- if self.new_bom == d[0]:
- frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(bom, self.new_bom))
-
- bom_list.append(d[0])
- self.get_parent_boms(d[0], bom_list)
-
- return list(set(bom_list))
-
-
-def get_new_bom_unit_cost(bom):
- new_bom_unitcost = frappe.db.sql(
- """SELECT `total_cost`/`quantity`
- FROM `tabBOM` WHERE name = %s""",
- bom,
- )
-
- return flt(new_bom_unitcost[0][0]) if new_bom_unitcost else 0
+ pass
@frappe.whitelist()
-def enqueue_replace_bom(args):
- if isinstance(args, str):
- args = json.loads(args)
+def enqueue_replace_bom(
+ boms: Optional[Union[Dict, str]] = None, args: Optional[Union[Dict, str]] = None
+) -> "BOMUpdateLog":
+ """Returns a BOM Update Log (that queues a job) for BOM Replacement."""
+ boms = boms or args
+ if isinstance(boms, str):
+ boms = json.loads(boms)
- frappe.enqueue(
- "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.replace_bom",
- args=args,
- timeout=40000,
- )
- frappe.msgprint(_("Queued for replacing the BOM. It may take a few minutes."))
+ update_log = create_bom_update_log(boms=boms)
+ return update_log
@frappe.whitelist()
-def enqueue_update_cost():
- frappe.enqueue(
- "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.update_cost", timeout=40000
- )
- frappe.msgprint(
- _("Queued for updating latest price in all Bill of Materials. It may take a few minutes.")
- )
+def enqueue_update_cost() -> "BOMUpdateLog":
+ """Returns a BOM Update Log (that queues a job) for BOM Cost Updation."""
+ update_log = create_bom_update_log(update_type="Update Cost")
+ return update_log
-def update_latest_price_in_all_boms():
+def auto_update_latest_price_in_all_boms() -> None:
+ """Called via hooks.py."""
if frappe.db.get_single_value("Manufacturing Settings", "update_bom_costs_automatically"):
update_cost()
-def replace_bom(args):
- frappe.db.auto_commit_on_many_writes = 1
- args = frappe._dict(args)
-
- doc = frappe.get_doc("BOM Update Tool")
- doc.current_bom = args.current_bom
- doc.new_bom = args.new_bom
- doc.replace_bom()
-
- frappe.db.auto_commit_on_many_writes = 0
-
-
-def update_cost():
- frappe.db.auto_commit_on_many_writes = 1
+def update_cost() -> None:
+ """Updates Cost for all BOMs from bottom to top."""
bom_list = get_boms_in_bottom_up_order()
for bom in bom_list:
frappe.get_doc("BOM", bom).update_cost(update_parent=False, from_child_bom=True)
- frappe.db.auto_commit_on_many_writes = 0
+
+def create_bom_update_log(
+ boms: Optional[Dict[str, str]] = None,
+ update_type: Literal["Replace BOM", "Update Cost"] = "Replace BOM",
+) -> "BOMUpdateLog":
+ """Creates a BOM Update Log that handles the background job."""
+
+ boms = boms or {}
+ current_bom = boms.get("current_bom")
+ new_bom = boms.get("new_bom")
+ return frappe.get_doc(
+ {
+ "doctype": "BOM Update Log",
+ "current_bom": current_bom,
+ "new_bom": new_bom,
+ "update_type": update_type,
+ }
+ ).submit()
diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
index 57785e5..fae72a0 100644
--- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
+++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py
@@ -4,6 +4,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase
+from erpnext.manufacturing.doctype.bom_update_log.bom_update_log import replace_bom
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.item.test_item import create_item
@@ -12,6 +13,8 @@
class TestBOMUpdateTool(FrappeTestCase):
+ "Test major functions run via BOM Update Tool."
+
def test_replace_bom(self):
current_bom = "BOM-_Test Item Home Desktop Manufactured-001"
@@ -19,18 +22,16 @@
bom_doc.items[1].item_code = "_Test Item"
bom_doc.insert()
- update_tool = frappe.get_doc("BOM Update Tool")
- update_tool.current_bom = current_bom
- update_tool.new_bom = bom_doc.name
- update_tool.replace_bom()
+ boms = frappe._dict(current_bom=current_bom, new_bom=bom_doc.name)
+ replace_bom(boms)
self.assertFalse(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", current_bom))
self.assertTrue(frappe.db.sql("select name from `tabBOM Item` where bom_no=%s", bom_doc.name))
# reverse, as it affects other testcases
- update_tool.current_bom = bom_doc.name
- update_tool.new_bom = current_bom
- update_tool.replace_bom()
+ boms.current_bom = bom_doc.name
+ boms.new_bom = current_bom
+ replace_bom(boms)
def test_bom_cost(self):
for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]:
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js
index d85b8a6..b2824e1 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card.js
@@ -28,12 +28,12 @@
frappe.flags.resume_job = 0;
let has_items = frm.doc.items && frm.doc.items.length;
- if (frm.doc.__onload.work_order_closed) {
+ if (!frm.is_new() && frm.doc.__onload.work_order_closed) {
frm.disable_save();
return;
}
- if (!frm.doc.__islocal && has_items && frm.doc.docstatus < 2) {
+ if (!frm.is_new() && 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;
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 89f9ca6..9ca05b9 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -462,6 +462,7 @@
work_order_data = {
"wip_warehouse": default_warehouses.get("wip_warehouse"),
"fg_warehouse": default_warehouses.get("fg_warehouse"),
+ "company": self.get("company"),
}
self.prepare_data_for_sub_assembly_items(row, work_order_data)
@@ -499,9 +500,10 @@
for supplier, po_list in subcontracted_po.items():
po = frappe.new_doc("Purchase Order")
+ po.company = self.company
po.supplier = supplier
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
- po.is_subcontracted = "Yes"
+ po.is_subcontracted = 1
for row in po_list:
po_data = {
"item_code": row.production_item,
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index 3721704..2aba482 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -1114,6 +1114,86 @@
except frappe.MandatoryError:
self.fail("Batch generation causing failing in Work Order")
+ @change_settings(
+ "Manufacturing Settings",
+ {"backflush_raw_materials_based_on": "Material Transferred for Manufacture"},
+ )
+ def test_manufacture_entry_mapped_idx_with_exploded_bom(self):
+ """Test if WO containing BOM with partial exploded items and scrap items, maps idx correctly."""
+ test_stock_entry.make_stock_entry(
+ item_code="_Test Item",
+ target="_Test Warehouse - _TC",
+ basic_rate=5000.0,
+ qty=2,
+ )
+ test_stock_entry.make_stock_entry(
+ item_code="_Test Item Home Desktop 100",
+ target="_Test Warehouse - _TC",
+ basic_rate=1000.0,
+ qty=2,
+ )
+
+ wo_order = make_wo_order_test_record(
+ qty=1,
+ use_multi_level_bom=1,
+ skip_transfer=1,
+ )
+
+ ste_manu = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 1))
+
+ for index, row in enumerate(ste_manu.get("items"), start=1):
+ self.assertEqual(index, row.idx)
+
+ @change_settings(
+ "Manufacturing Settings",
+ {"backflush_raw_materials_based_on": "Material Transferred for Manufacture"},
+ )
+ def test_work_order_multiple_material_transfer(self):
+ """
+ Test transferring multiple RMs in separate Stock Entries.
+ """
+ work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
+ test_stock_entry.make_stock_entry( # stock up RM
+ item_code="_Test Item",
+ target="_Test Warehouse - _TC",
+ qty=1,
+ basic_rate=5000.0,
+ )
+ test_stock_entry.make_stock_entry( # stock up RM
+ item_code="_Test Item Home Desktop 100",
+ target="_Test Warehouse - _TC",
+ qty=2,
+ basic_rate=1000.0,
+ )
+
+ transfer_entry = frappe.get_doc(
+ make_stock_entry(work_order.name, "Material Transfer for Manufacture", 1)
+ )
+ del transfer_entry.get("items")[0] # transfer only one RM
+ transfer_entry.submit()
+
+ # WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending
+ work_order.reload()
+ self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
+ self.assertEqual(work_order.required_items[0].transferred_qty, 0)
+ self.assertEqual(work_order.required_items[1].transferred_qty, 2)
+
+ final_transfer_entry = frappe.get_doc( # transfer last RM with For Quantity = 0
+ make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
+ )
+ final_transfer_entry.save()
+
+ self.assertEqual(final_transfer_entry.fg_completed_qty, 0.0)
+ self.assertEqual(final_transfer_entry.items[0].qty, 1)
+
+ final_transfer_entry.submit()
+ work_order.reload()
+
+ # WO's "Material Transferred for Mfg" shows all is transferred, no RM is pending
+ self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
+ self.assertEqual(work_order.required_items[0].transferred_qty, 1)
+ self.assertEqual(work_order.required_items[1].transferred_qty, 2)
+
def update_job_card(job_card, jc_qty=None):
employee = frappe.db.get_value("Employee", {"status": "Active"}, "name")
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js
index 6433a99..20f1503 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.js
+++ b/erpnext/manufacturing/doctype/work_order/work_order.js
@@ -540,8 +540,10 @@
|| frm.doc.transfer_material_against == 'Job Card') ? 0 : 1;
if (show_start_btn) {
- if ((flt(doc.material_transferred_for_manufacturing) < flt(doc.qty))
- && frm.doc.status != 'Stopped') {
+ let pending_to_transfer = frm.doc.required_items.some(
+ item => flt(item.transferred_qty) < flt(item.required_qty)
+ );
+ if (pending_to_transfer && frm.doc.status != 'Stopped') {
frm.has_start_btn = true;
frm.add_custom_button(__('Create Pick List'), function() {
erpnext.work_order.create_pick_list(frm);
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index c8c2f9a..2802310 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -1186,7 +1186,11 @@
stock_entry.from_bom = 1
stock_entry.bom_no = work_order.bom_no
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
- stock_entry.fg_completed_qty = qty or (flt(work_order.qty) - flt(work_order.produced_qty))
+ # accept 0 qty as well
+ stock_entry.fg_completed_qty = (
+ qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty))
+ )
+
if work_order.bom_no:
stock_entry.inspection_required = frappe.db.get_value(
"BOM", work_order.bom_no, "inspection_required"
@@ -1327,7 +1331,7 @@
used_serial_nos.extend(get_serial_nos(d.serial_no))
serial_nos = sorted(list(set(serial_nos) - set(used_serial_nos)))
- row.serial_no = "\n".join(serial_nos[0 : row.job_card_qty])
+ row.serial_no = "\n".join(serial_nos[0 : cint(row.job_card_qty)])
def validate_operation_data(row):
diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py
index c0affd9..ac2f61c 100644
--- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py
+++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py
@@ -3,6 +3,7 @@
import frappe
+from frappe import _
def execute(filters=None):
@@ -46,17 +47,22 @@
def get_columns():
return [
{
- "label": "Item Code",
+ "label": _("Item Code"),
"fieldtype": "Link",
"fieldname": "item_code",
"width": 300,
"options": "Item",
},
- {"label": "Item Name", "fieldtype": "data", "fieldname": "item_name", "width": 100},
- {"label": "BOM", "fieldtype": "Link", "fieldname": "bom", "width": 150, "options": "BOM"},
- {"label": "Qty", "fieldtype": "data", "fieldname": "qty", "width": 100},
- {"label": "UOM", "fieldtype": "data", "fieldname": "uom", "width": 100},
- {"label": "BOM Level", "fieldtype": "Int", "fieldname": "bom_level", "width": 100},
- {"label": "Standard Description", "fieldtype": "data", "fieldname": "description", "width": 150},
- {"label": "Scrap", "fieldtype": "data", "fieldname": "scrap", "width": 100},
+ {"label": _("Item Name"), "fieldtype": "data", "fieldname": "item_name", "width": 100},
+ {"label": _("BOM"), "fieldtype": "Link", "fieldname": "bom", "width": 150, "options": "BOM"},
+ {"label": _("Qty"), "fieldtype": "data", "fieldname": "qty", "width": 100},
+ {"label": _("UOM"), "fieldtype": "data", "fieldname": "uom", "width": 100},
+ {"label": _("BOM Level"), "fieldtype": "Int", "fieldname": "bom_level", "width": 100},
+ {
+ "label": _("Standard Description"),
+ "fieldtype": "data",
+ "fieldname": "description",
+ "width": 150,
+ },
+ {"label": _("Scrap"), "fieldtype": "data", "fieldname": "scrap", "width": 100},
]
diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py
index 17f7f5e..2c8f82f 100644
--- a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py
+++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py
@@ -3,6 +3,7 @@
import frappe
+from frappe import _
from frappe.utils import flt
@@ -114,28 +115,28 @@
def get_column(filters):
return [
{
- "label": "Finished Good",
+ "label": _("Finished Good"),
"fieldtype": "Link",
"fieldname": "item_code",
"width": 300,
"options": "Item",
},
- {"label": "Item Name", "fieldtype": "data", "fieldname": "item_name", "width": 100},
+ {"label": _("Item Name"), "fieldtype": "data", "fieldname": "item_name", "width": 100},
{
- "label": "Document Type",
+ "label": _("Document Type"),
"fieldtype": "Link",
"fieldname": "document_type",
"width": 150,
"options": "DocType",
},
{
- "label": "Document Name",
+ "label": _("Document Name"),
"fieldtype": "Dynamic Link",
"fieldname": "document_name",
"width": 150,
},
- {"label": "BOM Level", "fieldtype": "Int", "fieldname": "bom_level", "width": 100},
- {"label": "Order Qty", "fieldtype": "Float", "fieldname": "qty", "width": 120},
- {"label": "Received Qty", "fieldtype": "Float", "fieldname": "produced_qty", "width": 160},
- {"label": "Pending Qty", "fieldtype": "Float", "fieldname": "pending_qty", "width": 110},
+ {"label": _("BOM Level"), "fieldtype": "Int", "fieldname": "bom_level", "width": 100},
+ {"label": _("Order Qty"), "fieldtype": "Float", "fieldname": "qty", "width": 120},
+ {"label": _("Received Qty"), "fieldtype": "Float", "fieldname": "produced_qty", "width": 160},
+ {"label": _("Pending Qty"), "fieldtype": "Float", "fieldname": "pending_qty", "width": 110},
]
diff --git a/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py b/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py
index c6b7e58..063ebba 100644
--- a/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py
+++ b/erpnext/manufacturing/report/work_order_stock_report/work_order_stock_report.py
@@ -3,6 +3,7 @@
import frappe
+from frappe import _
from frappe.utils import cint
@@ -99,59 +100,65 @@
columns = [
{
"fieldname": "work_order",
- "label": "Work Order",
+ "label": _("Work Order"),
"fieldtype": "Link",
"options": "Work Order",
"width": 110,
},
- {"fieldname": "bom_no", "label": "BOM", "fieldtype": "Link", "options": "BOM", "width": 120},
+ {"fieldname": "bom_no", "label": _("BOM"), "fieldtype": "Link", "options": "BOM", "width": 120},
{
"fieldname": "description",
- "label": "Description",
+ "label": _("Description"),
"fieldtype": "Data",
"options": "",
"width": 230,
},
{
"fieldname": "item_code",
- "label": "Item Code",
+ "label": _("Item Code"),
"fieldtype": "Link",
"options": "Item",
"width": 110,
},
{
"fieldname": "source_warehouse",
- "label": "Source Warehouse",
+ "label": _("Source Warehouse"),
"fieldtype": "Link",
"options": "Warehouse",
"width": 110,
},
- {"fieldname": "qty", "label": "Qty to Build", "fieldtype": "Data", "options": "", "width": 110},
- {"fieldname": "status", "label": "Status", "fieldtype": "Data", "options": "", "width": 100},
+ {
+ "fieldname": "qty",
+ "label": _("Qty to Build"),
+ "fieldtype": "Data",
+ "options": "",
+ "width": 110,
+ },
+ {"fieldname": "status", "label": _("Status"), "fieldtype": "Data", "options": "", "width": 100},
{
"fieldname": "req_items",
- "label": "# Req'd Items",
+ "label": _("# Req'd Items"),
"fieldtype": "Data",
"options": "",
"width": 105,
},
{
"fieldname": "instock",
- "label": "# In Stock",
+ "label": _("# In Stock"),
"fieldtype": "Data",
"options": "",
"width": 105,
},
{
"fieldname": "buildable_qty",
- "label": "Buildable Qty",
+ "label": _("Buildable Qty"),
"fieldtype": "Data",
"options": "",
"width": 100,
},
{
"fieldname": "ready_to_build",
- "label": "Build All?",
+ "label": _("Build All?"),
"fieldtype": "Data",
"options": "",
"width": 90,
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 028834a..63b6bb7 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -4,6 +4,7 @@
erpnext.patches.v13_0.add_bin_unique_constraint
erpnext.patches.v11_0.refactor_naming_series
erpnext.patches.v11_0.refactor_autoname_naming
+erpnext.patches.v14_0.change_is_subcontracted_fieldtype
execute:frappe.reload_doc("accounts", "doctype", "POS Payment Method") #2020-05-28
execute:frappe.reload_doc("HR", "doctype", "HR Settings") #2020-01-16 #2020-07-24
erpnext.patches.v4_2.update_requested_and_ordered_qty #2021-03-31
@@ -261,8 +262,6 @@
erpnext.patches.v13_0.update_shipment_status
erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting
erpnext.patches.v12_0.add_ewaybill_validity_field
-erpnext.patches.v13_0.germany_make_custom_fields
-erpnext.patches.v13_0.germany_fill_debtor_creditor_number
erpnext.patches.v13_0.set_pos_closing_as_failed
erpnext.patches.v13_0.rename_stop_to_send_birthday_reminders
execute:frappe.rename_doc("Workspace", "Loan Management", "Loans", force=True)
@@ -342,6 +341,7 @@
erpnext.patches.v14_0.delete_hub_doctypes
erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022
erpnext.patches.v14_0.delete_agriculture_doctypes
+erpnext.patches.v14_0.delete_datev_doctypes
erpnext.patches.v14_0.rearrange_company_fields
erpnext.patches.v14_0.update_leave_notification_template
erpnext.patches.v14_0.restore_einvoice_fields
@@ -363,3 +363,4 @@
erpnext.patches.v13_0.set_return_against_in_pos_invoice_references
erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items # 24-03-2022
erpnext.patches.v13_0.update_expense_claim_status_for_paid_advances
+erpnext.patches.v13_0.create_gst_custom_fields_in_quotation
diff --git a/erpnext/patches/v12_0/update_is_cancelled_field.py b/erpnext/patches/v12_0/update_is_cancelled_field.py
index b567823..398dd70 100644
--- a/erpnext/patches/v12_0/update_is_cancelled_field.py
+++ b/erpnext/patches/v12_0/update_is_cancelled_field.py
@@ -20,7 +20,7 @@
"""
UPDATE `tab{doctype}`
SET is_cancelled = 0
- where is_cancelled in ('', NULL, 'No')""".format(
+ where is_cancelled in ('', 'No') or is_cancelled is NULL""".format(
doctype=doctype
)
)
diff --git a/erpnext/patches/v13_0/create_gst_custom_fields_in_quotation.py b/erpnext/patches/v13_0/create_gst_custom_fields_in_quotation.py
new file mode 100644
index 0000000..3217eab
--- /dev/null
+++ b/erpnext/patches/v13_0/create_gst_custom_fields_in_quotation.py
@@ -0,0 +1,53 @@
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+
+def execute():
+ company = frappe.get_all("Company", filters={"country": "India"}, fields=["name"])
+ if not company:
+ return
+
+ 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,
+ 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,
+ length=15,
+ ),
+ dict(
+ fieldname="place_of_supply",
+ label="Place of Supply",
+ fieldtype="Data",
+ insert_after="customer_gstin",
+ 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,
+ length=15,
+ ),
+ ]
+
+ custom_fields = {"Quotation": sales_invoice_gst_fields}
+
+ create_custom_fields(custom_fields, update=True)
diff --git a/erpnext/patches/v13_0/germany_fill_debtor_creditor_number.py b/erpnext/patches/v13_0/germany_fill_debtor_creditor_number.py
deleted file mode 100644
index fc3e68a..0000000
--- a/erpnext/patches/v13_0/germany_fill_debtor_creditor_number.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# Copyright (c) 2019, Frappe and Contributors
-# License: GNU General Public License v3. See license.txt
-
-
-import frappe
-
-
-def execute():
- """Move account number into the new custom field debtor_creditor_number.
-
- German companies used to use a dedicated payable/receivable account for
- every party to mimick party accounts in the external accounting software
- "DATEV". This is no longer necessary. The reference ID for DATEV will be
- stored in a new custom field "debtor_creditor_number".
- """
- company_list = frappe.get_all("Company", filters={"country": "Germany"})
-
- for company in company_list:
- party_account_list = frappe.get_all(
- "Party Account",
- filters={"company": company.name},
- fields=["name", "account", "debtor_creditor_number"],
- )
- for party_account in party_account_list:
- if (not party_account.account) or party_account.debtor_creditor_number:
- # account empty or debtor_creditor_number already filled
- continue
-
- account_number = frappe.db.get_value("Account", party_account.account, "account_number")
- if not account_number:
- continue
-
- frappe.db.set_value(
- "Party Account", party_account.name, "debtor_creditor_number", account_number
- )
- frappe.db.set_value("Party Account", party_account.name, "account", "")
diff --git a/erpnext/patches/v13_0/germany_make_custom_fields.py b/erpnext/patches/v13_0/germany_make_custom_fields.py
deleted file mode 100644
index cc35813..0000000
--- a/erpnext/patches/v13_0/germany_make_custom_fields.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# Copyright (c) 2019, Frappe and Contributors
-# License: GNU General Public License v3. See license.txt
-
-
-import frappe
-
-from erpnext.regional.germany.setup import make_custom_fields
-
-
-def execute():
- """Execute the make_custom_fields method for german companies.
-
- It is usually run once at setup of a new company. Since it's new, run it
- once for existing companies as well.
- """
- company_list = frappe.get_all("Company", filters={"country": "Germany"})
- if not company_list:
- return
-
- make_custom_fields()
diff --git a/erpnext/patches/v14_0/change_is_subcontracted_fieldtype.py b/erpnext/patches/v14_0/change_is_subcontracted_fieldtype.py
new file mode 100644
index 0000000..9b07ba8
--- /dev/null
+++ b/erpnext/patches/v14_0/change_is_subcontracted_fieldtype.py
@@ -0,0 +1,26 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+
+
+def execute():
+ for doctype in ["Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation"]:
+ frappe.db.sql(
+ """
+ UPDATE `tab{doctype}`
+ SET is_subcontracted = 0
+ where is_subcontracted in ('', 'No') or is_subcontracted is null""".format(
+ doctype=doctype
+ )
+ )
+ frappe.db.sql(
+ """
+ UPDATE `tab{doctype}`
+ SET is_subcontracted = 1
+ where is_subcontracted = 'Yes'""".format(
+ doctype=doctype
+ )
+ )
+
+ frappe.reload_doc(frappe.get_meta(doctype).module, "doctype", frappe.scrub(doctype))
diff --git a/erpnext/patches/v14_0/delete_datev_doctypes.py b/erpnext/patches/v14_0/delete_datev_doctypes.py
new file mode 100644
index 0000000..a5de91f
--- /dev/null
+++ b/erpnext/patches/v14_0/delete_datev_doctypes.py
@@ -0,0 +1,13 @@
+import frappe
+
+
+def execute():
+ install_apps = frappe.get_installed_apps()
+ if "erpnext_datev_uo" in install_apps or "erpnext_datev" in install_apps:
+ return
+
+ # doctypes
+ frappe.delete_doc("DocType", "DATEV Settings", ignore_missing=True, force=True)
+
+ # reports
+ frappe.delete_doc("Report", "DATEV", ignore_missing=True, force=True)
diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.py b/erpnext/payroll/doctype/additional_salary/additional_salary.py
index 256a3b5..18bd3b7 100644
--- a/erpnext/payroll/doctype/additional_salary/additional_salary.py
+++ b/erpnext/payroll/doctype/additional_salary/additional_salary.py
@@ -147,6 +147,8 @@
@frappe.whitelist()
def get_additional_salaries(employee, start_date, end_date, component_type):
+ from frappe.query_builder import Criterion
+
comp_type = "Earning" if component_type == "earnings" else "Deduction"
additional_sal = frappe.qb.DocType("Additional Salary")
@@ -170,8 +172,23 @@
& (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))
+ Criterion.any(
+ [
+ Criterion.all(
+ [ # is recurring and additional salary dates fall within the payroll period
+ additional_sal.is_recurring == 1,
+ additional_sal.from_date <= end_date,
+ additional_sal.to_date >= end_date,
+ ]
+ ),
+ Criterion.all(
+ [ # is not recurring and additional salary's payroll date falls within the payroll period
+ additional_sal.is_recurring == 0,
+ additional_sal.payroll_date[start_date:end_date],
+ ]
+ ),
+ ]
+ )
)
.run(as_dict=True)
)
diff --git a/erpnext/payroll/doctype/additional_salary/test_additional_salary.py b/erpnext/payroll/doctype/additional_salary/test_additional_salary.py
index 7d5d9e0..bd73936 100644
--- a/erpnext/payroll/doctype/additional_salary/test_additional_salary.py
+++ b/erpnext/payroll/doctype/additional_salary/test_additional_salary.py
@@ -4,7 +4,8 @@
import unittest
import frappe
-from frappe.utils import add_days, nowdate
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, add_months, nowdate
import erpnext
from erpnext.hr.doctype.employee.test_employee import make_employee
@@ -16,19 +17,10 @@
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
-class TestAdditionalSalary(unittest.TestCase):
+class TestAdditionalSalary(FrappeTestCase):
def setUp(self):
setup_test()
- def tearDown(self):
- for dt in [
- "Salary Slip",
- "Additional Salary",
- "Salary Structure Assignment",
- "Salary Structure",
- ]:
- frappe.db.sql("delete from `tab%s`" % dt)
-
def test_recurring_additional_salary(self):
amount = 0
salary_component = None
@@ -46,19 +38,66 @@
if earning.salary_component == "Recurring Salary Component":
amount = earning.amount
salary_component = earning.salary_component
+ break
self.assertEqual(amount, add_sal.amount)
self.assertEqual(salary_component, add_sal.salary_component)
+ def test_non_recurring_additional_salary(self):
+ amount = 0
+ salary_component = None
+ date = nowdate()
-def get_additional_salary(emp_id):
+ emp_id = make_employee("test_additional@salary.com")
+ frappe.db.set_value("Employee", emp_id, "relieving_date", add_days(date, 1800))
+ salary_structure = make_salary_structure(
+ "Test Salary Structure Additional Salary", "Monthly", employee=emp_id
+ )
+ add_sal = get_additional_salary(emp_id, recurring=False, payroll_date=date)
+
+ ss = make_employee_salary_slip(
+ "test_additional@salary.com", "Monthly", salary_structure=salary_structure.name
+ )
+
+ amount, salary_component = None, None
+ for earning in ss.earnings:
+ if earning.salary_component == "Recurring Salary Component":
+ amount = earning.amount
+ salary_component = earning.salary_component
+ break
+
+ self.assertEqual(amount, add_sal.amount)
+ self.assertEqual(salary_component, add_sal.salary_component)
+
+ # should not show up in next months
+ ss.posting_date = add_months(date, 1)
+ ss.start_date = ss.end_date = None
+ ss.earnings = []
+ ss.deductions = []
+ ss.save()
+
+ amount, salary_component = None, None
+ for earning in ss.earnings:
+ if earning.salary_component == "Recurring Salary Component":
+ amount = earning.amount
+ salary_component = earning.salary_component
+ break
+
+ self.assertIsNone(amount)
+ self.assertIsNone(salary_component)
+
+
+def get_additional_salary(emp_id, recurring=True, payroll_date=None):
create_salary_component("Recurring Salary Component")
add_sal = frappe.new_doc("Additional Salary")
add_sal.employee = emp_id
add_sal.salary_component = "Recurring Salary Component"
- add_sal.is_recurring = 1
+
+ add_sal.is_recurring = 1 if recurring else 0
add_sal.from_date = add_days(nowdate(), -50)
add_sal.to_date = add_days(nowdate(), 180)
+ add_sal.payroll_date = payroll_date
+
add_sal.amount = 5000
add_sal.currency = erpnext.get_default_currency()
add_sal.save()
diff --git a/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.py b/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.py
index 31f26b2..6ec34b9 100644
--- a/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.py
+++ b/erpnext/payroll/doctype/employee_benefit_claim/employee_benefit_claim.py
@@ -44,8 +44,7 @@
if max_benefits < claimed_amount:
frappe.throw(
_(
- "Maximum benefit of employee {0} exceeds {1} by the sum {2} of previous claimed\
- amount"
+ "Maximum benefit of employee {0} exceeds {1} by the sum {2} of previous claimed amount"
).format(self.employee, max_benefits, claimed_amount - max_benefits)
)
@@ -84,8 +83,7 @@
if max_benefits < pro_rata_amount + claimed_amount:
frappe.throw(
_(
- "Maximum benefit of employee {0} exceeds {1} by the sum {2} of benefit application pro-rata component\
- amount and previous claimed amount"
+ "Maximum benefit of employee {0} exceeds {1} by the sum {2} of benefit application pro-rata component amount and previous claimed amount"
).format(
self.employee, max_benefits, pro_rata_amount + claimed_amount - max_benefits
)
diff --git a/erpnext/payroll/doctype/gratuity/test_gratuity.py b/erpnext/payroll/doctype/gratuity/test_gratuity.py
index 67bb447..aa03d80 100644
--- a/erpnext/payroll/doctype/gratuity/test_gratuity.py
+++ b/erpnext/payroll/doctype/gratuity/test_gratuity.py
@@ -24,7 +24,9 @@
frappe.db.delete("Gratuity")
frappe.db.delete("Additional Salary", {"ref_doctype": "Gratuity"})
- make_earning_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
+ make_earning_salary_component(
+ setup=True, test_tax=True, company_list=["_Test Company"], include_flexi_benefits=True
+ )
make_deduction_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
def test_get_last_salary_slip_should_return_none_for_new_employee(self):
diff --git a/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.js b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.js
index 014a121..7290a9e 100644
--- a/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.js
+++ b/erpnext/payroll/doctype/gratuity_rule/gratuity_rule.js
@@ -34,7 +34,7 @@
to_year(frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.to_year <= row.from_year && row.to_year === 0) {
- frappe.throw(__("To(Year) year can not be less than From(year) "));
+ frappe.throw(__("To(Year) year can not be less than From(year)"));
}
}
});
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js
index 496c37b..62e183e 100644
--- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js
@@ -112,7 +112,7 @@
},
callback: function (r) {
if (r.message && !r.message.submitted) {
- frm.add_custom_button("Make Bank Entry", function () {
+ frm.add_custom_button(__("Make Bank Entry"), function () {
make_bank_entry(frm);
}).addClass("btn-primary");
}
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index 38fecac..1922329 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -952,8 +952,12 @@
)
# Structured tax amount
- total_structured_tax_amount = self.calculate_tax_by_tax_slab(
- total_taxable_earnings_without_full_tax_addl_components, tax_slab
+ eval_locals = self.get_data_for_eval()
+ total_structured_tax_amount = calculate_tax_by_tax_slab(
+ total_taxable_earnings_without_full_tax_addl_components,
+ tax_slab,
+ self.whitelisted_globals,
+ eval_locals,
)
current_structured_tax_amount = (
total_structured_tax_amount - previous_total_paid_taxes
@@ -962,7 +966,9 @@
# Total taxable earnings with additional earnings with full tax
full_tax_on_additional_earnings = 0.0
if current_additional_earnings_with_full_tax:
- total_tax_amount = self.calculate_tax_by_tax_slab(total_taxable_earnings, tax_slab)
+ total_tax_amount = calculate_tax_by_tax_slab(
+ total_taxable_earnings, tax_slab, self.whitelisted_globals, eval_locals
+ )
full_tax_on_additional_earnings = total_tax_amount - total_structured_tax_amount
current_tax_amount = current_structured_tax_amount + full_tax_on_additional_earnings
@@ -1278,50 +1284,6 @@
fields="SUM(amount) as total_amount",
)[0].total_amount
- def calculate_tax_by_tax_slab(self, annual_taxable_earning, tax_slab):
- data = self.get_data_for_eval()
- data.update({"annual_taxable_earning": annual_taxable_earning})
- tax_amount = 0
- for slab in tax_slab.slabs:
- cond = cstr(slab.condition).strip()
- if cond and not self.eval_tax_slab_condition(cond, data):
- continue
- if not slab.to_amount and annual_taxable_earning >= slab.from_amount:
- tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
- continue
- if annual_taxable_earning >= slab.from_amount and annual_taxable_earning < slab.to_amount:
- tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
- elif annual_taxable_earning >= slab.from_amount and annual_taxable_earning >= slab.to_amount:
- tax_amount += (slab.to_amount - slab.from_amount + 1) * slab.percent_deduction * 0.01
-
- # other taxes and charges on income tax
- for d in tax_slab.other_taxes_and_charges:
- if flt(d.min_taxable_income) and flt(d.min_taxable_income) > annual_taxable_earning:
- continue
-
- if flt(d.max_taxable_income) and flt(d.max_taxable_income) < annual_taxable_earning:
- continue
-
- tax_amount += tax_amount * flt(d.percent) / 100
-
- return tax_amount
-
- def eval_tax_slab_condition(self, condition, data):
- try:
- condition = condition.strip()
- if condition:
- return frappe.safe_eval(condition, self.whitelisted_globals, data)
- except NameError as err:
- frappe.throw(
- _("{0} <br> This error can be due to missing or deleted field.").format(err),
- title=_("Name error"),
- )
- except SyntaxError as err:
- frappe.throw(_("Syntax error in condition: {0}").format(err))
- except Exception as e:
- frappe.throw(_("Error in formula or condition: {0}").format(e))
- raise
-
def get_component_totals(self, component_type, depends_on_payment_days=0):
joining_date, relieving_date = frappe.get_cached_value(
"Employee", self.employee, ["date_of_joining", "relieving_date"]
@@ -1705,3 +1667,60 @@
)
return payroll_payable_account
+
+
+def calculate_tax_by_tax_slab(
+ annual_taxable_earning, tax_slab, eval_globals=None, eval_locals=None
+):
+ eval_locals.update({"annual_taxable_earning": annual_taxable_earning})
+ tax_amount = 0
+ for slab in tax_slab.slabs:
+ cond = cstr(slab.condition).strip()
+ if cond and not eval_tax_slab_condition(cond, eval_globals, eval_locals):
+ continue
+ if not slab.to_amount and annual_taxable_earning >= slab.from_amount:
+ tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
+ continue
+ if annual_taxable_earning >= slab.from_amount and annual_taxable_earning < slab.to_amount:
+ tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction * 0.01
+ elif annual_taxable_earning >= slab.from_amount and annual_taxable_earning >= slab.to_amount:
+ tax_amount += (slab.to_amount - slab.from_amount + 1) * slab.percent_deduction * 0.01
+
+ # other taxes and charges on income tax
+ for d in tax_slab.other_taxes_and_charges:
+ if flt(d.min_taxable_income) and flt(d.min_taxable_income) > annual_taxable_earning:
+ continue
+
+ if flt(d.max_taxable_income) and flt(d.max_taxable_income) < annual_taxable_earning:
+ continue
+
+ tax_amount += tax_amount * flt(d.percent) / 100
+
+ return tax_amount
+
+
+def eval_tax_slab_condition(condition, eval_globals=None, eval_locals=None):
+ if not eval_globals:
+ eval_globals = {
+ "int": int,
+ "float": float,
+ "long": int,
+ "round": round,
+ "date": datetime.date,
+ "getdate": getdate,
+ }
+
+ try:
+ condition = condition.strip()
+ if condition:
+ return frappe.safe_eval(condition, eval_globals, eval_locals)
+ except NameError as err:
+ frappe.throw(
+ _("{0} <br> This error can be due to missing or deleted field.").format(err),
+ title=_("Name error"),
+ )
+ except SyntaxError as err:
+ frappe.throw(_("Syntax error in condition: {0} in Income Tax Slab").format(err))
+ except Exception as e:
+ frappe.throw(_("Error in formula or condition: {0} in Income Tax Slab").format(e))
+ raise
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index e1d1fa1..869ea83 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -772,6 +772,7 @@
"Monthly",
other_details={"max_benefits": 100000},
test_tax=True,
+ include_flexi_benefits=True,
employee=employee,
payroll_period=payroll_period,
)
@@ -875,6 +876,7 @@
"Monthly",
other_details={"max_benefits": 100000},
test_tax=True,
+ include_flexi_benefits=True,
employee=employee,
payroll_period=payroll_period,
)
@@ -1022,7 +1024,9 @@
return account
-def make_earning_salary_component(setup=False, test_tax=False, company_list=None):
+def make_earning_salary_component(
+ setup=False, test_tax=False, company_list=None, include_flexi_benefits=False
+):
data = [
{
"salary_component": "Basic Salary",
@@ -1043,7 +1047,7 @@
},
{"salary_component": "Leave Encashment", "abbr": "LE", "type": "Earning"},
]
- if test_tax:
+ if include_flexi_benefits:
data.extend(
[
{
@@ -1063,11 +1067,18 @@
"type": "Earning",
"max_benefit_amount": 15000,
},
+ ]
+ )
+ if test_tax:
+ data.extend(
+ [
{"salary_component": "Performance Bonus", "abbr": "B", "type": "Earning"},
]
)
+
if setup or test_tax:
make_salary_component(data, test_tax, company_list)
+
data.append(
{
"salary_component": "Basic Salary",
@@ -1290,7 +1301,16 @@
return salary_date
-def make_leave_application(employee, from_date, to_date, leave_type, company=None, submit=True):
+def make_leave_application(
+ employee,
+ from_date,
+ to_date,
+ leave_type,
+ company=None,
+ half_day=False,
+ half_day_date=None,
+ submit=True,
+):
leave_application = frappe.get_doc(
dict(
doctype="Leave Application",
@@ -1298,6 +1318,8 @@
leave_type=leave_type,
from_date=from_date,
to_date=to_date,
+ half_day=half_day,
+ half_day_date=half_day_date,
company=company or erpnext.get_default_company() or "_Test Company",
status="Approved",
leave_approver="test@example.com",
diff --git a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
index def622b..2eb1671 100644
--- a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
+++ b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
@@ -149,6 +149,7 @@
company=None,
currency=erpnext.get_default_currency(),
payroll_period=None,
+ include_flexi_benefits=False,
):
if test_tax:
frappe.db.sql("""delete from `tabSalary Structure` where name=%s""", (salary_structure))
@@ -161,7 +162,10 @@
"name": salary_structure,
"company": company or erpnext.get_default_company(),
"earnings": make_earning_salary_component(
- setup=True, test_tax=test_tax, company_list=["_Test Company"]
+ setup=True,
+ test_tax=test_tax,
+ company_list=["_Test Company"],
+ include_flexi_benefits=include_flexi_benefits,
),
"deductions": make_deduction_salary_component(
setup=True, test_tax=test_tax, company_list=["_Test Company"]
diff --git a/erpnext/regional/doctype/datev_settings/__init__.py b/erpnext/payroll/report/income_tax_computation/__init__.py
similarity index 100%
copy from erpnext/regional/doctype/datev_settings/__init__.py
copy to erpnext/payroll/report/income_tax_computation/__init__.py
diff --git a/erpnext/payroll/report/income_tax_computation/income_tax_computation.js b/erpnext/payroll/report/income_tax_computation/income_tax_computation.js
new file mode 100644
index 0000000..26e463f
--- /dev/null
+++ b/erpnext/payroll/report/income_tax_computation/income_tax_computation.js
@@ -0,0 +1,47 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+frappe.query_reports["Income Tax Computation"] = {
+ "filters": [
+ {
+ "fieldname":"company",
+ "label": __("Company"),
+ "fieldtype": "Link",
+ "options": "Company",
+ "default": frappe.defaults.get_user_default("Company"),
+ "width": "100px",
+ "reqd": 1
+ },
+ {
+ "fieldname":"payroll_period",
+ "label": __("Payroll Period"),
+ "fieldtype": "Link",
+ "options": "Payroll Period",
+ "width": "100px",
+ "reqd": 1
+ },
+ {
+ "fieldname":"employee",
+ "label": __("Employee"),
+ "fieldtype": "Link",
+ "options": "Employee",
+ "width": "100px"
+ },
+ {
+ "fieldname":"department",
+ "label": __("Department"),
+ "fieldtype": "Link",
+ "options": "Department",
+ "width": "100px",
+ },
+ {
+ "fieldname":"consider_tax_exemption_declaration",
+ "label": __("Consider Tax Exemption Declaration"),
+ "fieldtype": "Check",
+ "width": "180px"
+ }
+ ]
+};
+
+
diff --git a/erpnext/payroll/report/income_tax_computation/income_tax_computation.json b/erpnext/payroll/report/income_tax_computation/income_tax_computation.json
new file mode 100644
index 0000000..7cb5b22
--- /dev/null
+++ b/erpnext/payroll/report/income_tax_computation/income_tax_computation.json
@@ -0,0 +1,36 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2022-02-17 17:19:30.921422",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letter_head": "",
+ "modified": "2022-02-23 13:07:30.347861",
+ "modified_by": "Administrator",
+ "module": "Payroll",
+ "name": "Income Tax Computation",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Salary Slip",
+ "report_name": "Income Tax Computation",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Employee"
+ },
+ {
+ "role": "HR User"
+ },
+ {
+ "role": "HR Manager"
+ },
+ {
+ "role": "Employee Self Service"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/payroll/report/income_tax_computation/income_tax_computation.py b/erpnext/payroll/report/income_tax_computation/income_tax_computation.py
new file mode 100644
index 0000000..739ed8e
--- /dev/null
+++ b/erpnext/payroll/report/income_tax_computation/income_tax_computation.py
@@ -0,0 +1,513 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _, scrub
+from frappe.query_builder.functions import Sum
+from frappe.utils import add_days, flt, getdate, rounded
+
+from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates
+from erpnext.payroll.doctype.salary_slip.salary_slip import calculate_tax_by_tax_slab
+
+
+def execute(filters=None):
+ return IncomeTaxComputationReport(filters).run()
+
+
+class IncomeTaxComputationReport(object):
+ def __init__(self, filters=None):
+ self.filters = frappe._dict(filters or {})
+ self.columns = []
+ self.data = []
+ self.employees = frappe._dict()
+ self.payroll_period_start_date = None
+ self.payroll_period_end_date = None
+ if self.filters.payroll_period:
+ self.payroll_period_start_date, self.payroll_period_end_date = frappe.db.get_value(
+ "Payroll Period", self.filters.payroll_period, ["start_date", "end_date"]
+ )
+
+ def run(self):
+ self.get_fixed_columns()
+ self.get_data()
+ return self.columns, self.data
+
+ def get_data(self):
+ self.get_employee_details()
+ self.get_future_salary_slips()
+ self.get_ctc()
+ self.get_tax_exempted_earnings_and_deductions()
+ self.get_employee_tax_exemptions()
+ self.get_hra()
+ self.get_standard_tax_exemption()
+ self.get_total_taxable_amount()
+ self.get_applicable_tax()
+ self.get_total_deducted_tax()
+ self.get_payable_tax()
+
+ self.data = list(self.employees.values())
+
+ def get_employee_details(self):
+ filters, or_filters = self.get_employee_filters()
+ fields = [
+ "name as employee",
+ "employee_name",
+ "department",
+ "designation",
+ "date_of_joining",
+ "relieving_date",
+ ]
+
+ employees = frappe.get_all("Employee", filters=filters, or_filters=or_filters, fields=fields)
+ ss_assignments = self.get_ss_assignments([d.employee for d in employees])
+
+ for d in employees:
+ if d.employee in list(ss_assignments.keys()):
+ d.update(ss_assignments[d.employee])
+ self.employees.setdefault(d.employee, d)
+
+ if not self.employees:
+ frappe.throw(_("No employees found with selected filters and active salary structure"))
+
+ def get_employee_filters(self):
+ filters = {"company": self.filters.company}
+ or_filters = {
+ "status": "Active",
+ "relieving_date": ["between", [self.payroll_period_start_date, self.payroll_period_end_date]],
+ }
+ if self.filters.employee:
+ filters = {"name": self.filters.employee}
+ elif self.filters.department:
+ filters.update({"department": self.filters.department})
+
+ return filters, or_filters
+
+ def get_ss_assignments(self, employees):
+ ss_assignments = frappe.get_all(
+ "Salary Structure Assignment",
+ filters={
+ "employee": ["in", employees],
+ "docstatus": 1,
+ "salary_structure": ["is", "set"],
+ "income_tax_slab": ["is", "set"],
+ },
+ fields=["employee", "income_tax_slab", "salary_structure"],
+ order_by="from_date desc",
+ )
+
+ employee_ss_assignments = frappe._dict()
+ for d in ss_assignments:
+ if d.employee not in list(employee_ss_assignments.keys()):
+ tax_slab = frappe.get_cached_value(
+ "Income Tax Slab", d.income_tax_slab, ["allow_tax_exemption", "disabled"], as_dict=1
+ )
+
+ if tax_slab and not tax_slab.disabled:
+ employee_ss_assignments.setdefault(
+ d.employee,
+ {
+ "salary_structure": d.salary_structure,
+ "income_tax_slab": d.income_tax_slab,
+ "allow_tax_exemption": tax_slab.allow_tax_exemption,
+ },
+ )
+ return employee_ss_assignments
+
+ def get_future_salary_slips(self):
+ self.future_salary_slips = frappe._dict()
+ for employee in list(self.employees.keys()):
+ last_ss = self.get_last_salary_slip(employee)
+ if last_ss and last_ss.end_date == self.payroll_period_end_date:
+ continue
+
+ relieving_date = self.employees[employee].get("relieving_date", "")
+ if last_ss:
+ ss_start_date = add_days(last_ss.end_date, 1)
+ else:
+ ss_start_date = self.payroll_period_start_date
+ last_ss = frappe._dict(
+ {
+ "payroll_frequency": "Monthly",
+ "salary_structure": self.employees[employee].get("salary_structure"),
+ }
+ )
+
+ while getdate(ss_start_date) < getdate(self.payroll_period_end_date) and (
+ not relieving_date or getdate(ss_start_date) < relieving_date
+ ):
+ ss_end_date = get_start_end_dates(last_ss.payroll_frequency, ss_start_date).end_date
+
+ ss = frappe.new_doc("Salary Slip")
+ ss.employee = employee
+ ss.start_date = ss_start_date
+ ss.end_date = ss_end_date
+ ss.salary_structure = last_ss.salary_structure
+ ss.payroll_frequency = last_ss.payroll_frequency
+ ss.company = self.filters.company
+ try:
+ ss.process_salary_structure(for_preview=1)
+ self.future_salary_slips.setdefault(employee, []).append(ss.as_dict())
+ except Exception:
+ break
+
+ ss_start_date = add_days(ss_end_date, 1)
+
+ def get_last_salary_slip(self, employee):
+ last_salary_slip = frappe.db.get_value(
+ "Salary Slip",
+ {
+ "employee": employee,
+ "docstatus": 1,
+ "start_date": ["between", [self.payroll_period_start_date, self.payroll_period_end_date]],
+ },
+ ["start_date", "end_date", "salary_structure", "payroll_frequency"],
+ order_by="start_date desc",
+ as_dict=1,
+ )
+
+ return last_salary_slip
+
+ def get_ctc(self):
+ # Get total earnings from existing salary slip
+ ss = frappe.qb.DocType("Salary Slip")
+ existing_ss = frappe._dict(
+ (
+ frappe.qb.from_(ss)
+ .select(ss.employee, Sum(ss.base_gross_pay).as_("amount"))
+ .where(ss.docstatus == 1)
+ .where(ss.employee.isin(list(self.employees.keys())))
+ .where(ss.start_date >= self.payroll_period_start_date)
+ .where(ss.end_date <= self.payroll_period_end_date)
+ .groupby(ss.employee)
+ ).run()
+ )
+
+ for employee in list(self.employees.keys()):
+ future_ss_earnings = self.get_future_earnings(employee)
+ ctc = flt(existing_ss.get(employee)) + future_ss_earnings
+
+ self.employees[employee].setdefault("ctc", ctc)
+
+ def get_future_earnings(self, employee):
+ future_earnings = 0.0
+ for ss in self.future_salary_slips.get(employee, []):
+ future_earnings += flt(ss.base_gross_pay)
+
+ return future_earnings
+
+ def get_tax_exempted_earnings_and_deductions(self):
+ tax_exempted_components = self.get_tax_exempted_components()
+
+ # Get component totals from existing salary slips
+ ss = frappe.qb.DocType("Salary Slip")
+ ss_comps = frappe.qb.DocType("Salary Detail")
+
+ records = (
+ frappe.qb.from_(ss)
+ .inner_join(ss_comps)
+ .on(ss.name == ss_comps.parent)
+ .select(ss.name, ss.employee, ss_comps.salary_component, Sum(ss_comps.amount).as_("amount"))
+ .where(ss.docstatus == 1)
+ .where(ss.employee.isin(list(self.employees.keys())))
+ .where(ss_comps.salary_component.isin(tax_exempted_components))
+ .where(ss.start_date >= self.payroll_period_start_date)
+ .where(ss.end_date <= self.payroll_period_end_date)
+ .groupby(ss.employee, ss_comps.salary_component)
+ ).run(as_dict=True)
+
+ existing_ss_exemptions = frappe._dict()
+ for d in records:
+ existing_ss_exemptions.setdefault(d.employee, {}).setdefault(
+ scrub(d.salary_component), d.amount
+ )
+
+ for employee in list(self.employees.keys()):
+ if not self.employees[employee]["allow_tax_exemption"]:
+ continue
+
+ exemptions = existing_ss_exemptions.get(employee, {})
+ self.add_exemptions_from_future_salary_slips(employee, exemptions)
+ self.employees[employee].update(exemptions)
+
+ total_exemptions = sum(list(exemptions.values()))
+ self.add_to_total_exemption(employee, total_exemptions)
+
+ def add_exemptions_from_future_salary_slips(self, employee, exemptions):
+ for ss in self.future_salary_slips.get(employee, []):
+ for e in ss.earnings:
+ if not e.is_tax_applicable:
+ exemptions.setdefault(scrub(e.salary_component), 0)
+ exemptions[scrub(e.salary_component)] += flt(e.amount)
+
+ for d in ss.deductions:
+ if d.exempted_from_income_tax:
+ exemptions.setdefault(scrub(d.salary_component), 0)
+ exemptions[scrub(d.salary_component)] += flt(d.amount)
+
+ return exemptions
+
+ def get_tax_exempted_components(self):
+ # nontaxable earning components
+ nontaxable_earning_components = [
+ d.name
+ for d in frappe.get_all(
+ "Salary Component", {"type": "Earning", "is_tax_applicable": 0, "disabled": 0}
+ )
+ ]
+
+ # tax exempted deduction components
+ tax_exempted_deduction_components = [
+ d.name
+ for d in frappe.get_all(
+ "Salary Component", {"type": "Deduction", "exempted_from_income_tax": 1, "disabled": 0}
+ )
+ ]
+
+ tax_exempted_components = nontaxable_earning_components + tax_exempted_deduction_components
+
+ # Add columns
+ for d in tax_exempted_components:
+ self.add_column(d)
+
+ return tax_exempted_components
+
+ def add_to_total_exemption(self, employee, amount):
+ self.employees[employee].setdefault("total_exemption", 0)
+ self.employees[employee]["total_exemption"] += amount
+
+ def get_employee_tax_exemptions(self):
+ # add columns
+ exemption_categories = frappe.get_all("Employee Tax Exemption Category", {"is_active": 1})
+ for d in exemption_categories:
+ self.add_column(d.name)
+
+ self.employees_with_proofs = []
+ self.get_tax_exemptions("Employee Tax Exemption Proof Submission")
+ if self.filters.consider_tax_exemption_declaration:
+ self.get_tax_exemptions("Employee Tax Exemption Declaration")
+
+ def get_tax_exemptions(self, source):
+ # Get category-wise exmeptions based on submitted proofs or declarations
+ if source == "Employee Tax Exemption Proof Submission":
+ child_doctype = "Employee Tax Exemption Proof Submission Detail"
+ else:
+ child_doctype = "Employee Tax Exemption Declaration Category"
+
+ max_exemptions = self.get_max_exemptions_based_on_category()
+
+ par = frappe.qb.DocType(source)
+ child = frappe.qb.DocType(child_doctype)
+
+ records = (
+ frappe.qb.from_(par)
+ .inner_join(child)
+ .on(par.name == child.parent)
+ .select(par.employee, child.exemption_category, Sum(child.amount).as_("amount"))
+ .where(par.docstatus == 1)
+ .where(par.employee.isin(list(self.employees.keys())))
+ .where(par.payroll_period == self.filters.payroll_period)
+ .groupby(par.employee, child.exemption_category)
+ ).run(as_dict=True)
+
+ for d in records:
+ if not self.employees[d.employee]["allow_tax_exemption"]:
+ continue
+
+ if source == "Employee Tax Exemption Declaration" and d.employee in self.employees_with_proofs:
+ continue
+
+ amount = flt(d.amount)
+ max_eligible_amount = flt(max_exemptions.get(d.exemption_category))
+ if max_eligible_amount and amount > max_eligible_amount:
+ amount = max_eligible_amount
+
+ self.employees[d.employee].setdefault(scrub(d.exemption_category), amount)
+ self.add_to_total_exemption(d.employee, amount)
+
+ if (
+ source == "Employee Tax Exemption Proof Submission"
+ and d.employee not in self.employees_with_proofs
+ ):
+ self.employees_with_proofs.append(d.employee)
+
+ def get_max_exemptions_based_on_category(self):
+ return dict(
+ frappe.get_all(
+ "Employee Tax Exemption Category",
+ filters={"is_active": 1},
+ fields=["name", "max_amount"],
+ as_list=1,
+ )
+ )
+
+ def get_hra(self):
+ if not frappe.get_meta("Employee Tax Exemption Declaration").has_field("monthly_house_rent"):
+ return
+
+ self.add_column("HRA")
+
+ self.employees_with_proofs = []
+ self.get_eligible_hra("Employee Tax Exemption Proof Submission")
+ if self.filters.consider_tax_exemption_declaration:
+ self.get_eligible_hra("Employee Tax Exemption Declaration")
+
+ def get_eligible_hra(self, source):
+ if source == "Employee Tax Exemption Proof Submission":
+ hra_amount_field = "total_eligible_hra_exemption"
+ else:
+ hra_amount_field = "annual_hra_exemption"
+
+ records = frappe.get_all(
+ source,
+ filters={
+ "docstatus": 1,
+ "employee": ["in", list(self.employees.keys())],
+ "payroll_period": self.filters.payroll_period,
+ },
+ fields=["employee", hra_amount_field],
+ as_list=1,
+ )
+
+ for d in records:
+ if not self.employees[d[0]]["allow_tax_exemption"]:
+ continue
+
+ if d[0] not in self.employees_with_proofs:
+ self.employees[d[0]].setdefault("hra", d[1])
+ self.add_to_total_exemption(d[0], d[1])
+ self.employees_with_proofs.append(d[0])
+
+ def get_standard_tax_exemption(self):
+ self.add_column("Standard Tax Exemption")
+
+ standard_exemptions_per_slab = dict(
+ frappe.get_all(
+ "Income Tax Slab",
+ filters={"company": self.filters.company, "docstatus": 1, "disabled": 0},
+ fields=["name", "standard_tax_exemption_amount"],
+ as_list=1,
+ )
+ )
+
+ for emp, emp_details in self.employees.items():
+ if not self.employees[emp]["allow_tax_exemption"]:
+ continue
+
+ income_tax_slab = emp_details.get("income_tax_slab")
+ standard_exemption = standard_exemptions_per_slab.get(income_tax_slab, 0)
+ emp_details["standard_tax_exemption"] = standard_exemption
+ self.add_to_total_exemption(emp, standard_exemption)
+
+ self.add_column("Total Exemption")
+
+ def get_total_taxable_amount(self):
+ self.add_column("Total Taxable Amount")
+ for emp, emp_details in self.employees.items():
+ emp_details["total_taxable_amount"] = flt(emp_details.get("ctc")) - flt(
+ emp_details.get("total_exemption")
+ )
+
+ def get_applicable_tax(self):
+ self.add_column("Applicable Tax")
+
+ is_tax_rounded = frappe.db.get_value(
+ "Salary Component",
+ {"variable_based_on_taxable_salary": 1, "disabled": 0},
+ "round_to_the_nearest_integer",
+ )
+
+ for emp, emp_details in self.employees.items():
+ tax_slab = emp_details.get("income_tax_slab")
+ if tax_slab:
+ tax_slab = frappe.get_cached_doc("Income Tax Slab", tax_slab)
+ employee_dict = frappe.get_doc("Employee", emp).as_dict()
+ tax_amount = calculate_tax_by_tax_slab(
+ emp_details["total_taxable_amount"], tax_slab, eval_globals=None, eval_locals=employee_dict
+ )
+ else:
+ tax_amount = 0.0
+
+ if is_tax_rounded:
+ tax_amount = rounded(tax_amount)
+ emp_details["applicable_tax"] = tax_amount
+
+ def get_total_deducted_tax(self):
+ self.add_column("Total Tax Deducted")
+
+ ss = frappe.qb.DocType("Salary Slip")
+ ss_ded = frappe.qb.DocType("Salary Detail")
+
+ records = (
+ frappe.qb.from_(ss)
+ .inner_join(ss_ded)
+ .on(ss.name == ss_ded.parent)
+ .select(ss.employee, Sum(ss_ded.amount).as_("amount"))
+ .where(ss.docstatus == 1)
+ .where(ss.employee.isin(list(self.employees.keys())))
+ .where(ss_ded.parentfield == "deductions")
+ .where(ss_ded.variable_based_on_taxable_salary == 1)
+ .where(ss.start_date >= self.payroll_period_start_date)
+ .where(ss.end_date <= self.payroll_period_end_date)
+ .groupby(ss.employee)
+ ).run(as_dict=True)
+
+ for d in records:
+ self.employees[d.employee].setdefault("total_tax_deducted", d.amount)
+
+ def get_payable_tax(self):
+ self.add_column("Payable Tax")
+
+ for emp, emp_details in self.employees.items():
+ emp_details["payable_tax"] = flt(emp_details.get("applicable_tax")) - flt(
+ emp_details.get("total_tax_deducted")
+ )
+
+ def add_column(self, label, fieldname=None, fieldtype=None, options=None, width=None):
+ col = {
+ "label": _(label),
+ "fieldname": fieldname or scrub(label),
+ "fieldtype": fieldtype or "Currency",
+ "options": options,
+ "width": width or "140px",
+ }
+ self.columns.append(col)
+
+ def get_fixed_columns(self):
+ self.columns = [
+ {
+ "label": _("Employee"),
+ "fieldname": "employee",
+ "fieldtype": "Link",
+ "options": "Employee",
+ "width": "140px",
+ },
+ {
+ "label": _("Employee Name"),
+ "fieldname": "employee_name",
+ "fieldtype": "Data",
+ "width": "160px",
+ },
+ {
+ "label": _("Department"),
+ "fieldname": "department",
+ "fieldtype": "Link",
+ "options": "Department",
+ "width": "140px",
+ },
+ {
+ "label": _("Designation"),
+ "fieldname": "designation",
+ "fieldtype": "Link",
+ "options": "Designation",
+ "width": "140px",
+ },
+ {"label": _("Date of Joining"), "fieldname": "date_of_joining", "fieldtype": "Date"},
+ {
+ "label": _("Income Tax Slab"),
+ "fieldname": "income_tax_slab",
+ "fieldtype": "Link",
+ "options": "Income Tax Slab",
+ "width": "140px",
+ },
+ {"label": _("CTC"), "fieldname": "ctc", "fieldtype": "Currency", "width": "140px"},
+ ]
diff --git a/erpnext/payroll/report/income_tax_computation/test_income_tax_computation.py b/erpnext/payroll/report/income_tax_computation/test_income_tax_computation.py
new file mode 100644
index 0000000..57ca317
--- /dev/null
+++ b/erpnext/payroll/report/income_tax_computation/test_income_tax_computation.py
@@ -0,0 +1,115 @@
+import unittest
+
+import frappe
+from frappe.utils import getdate
+
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
+ create_payroll_period,
+)
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
+ create_exemption_declaration,
+ create_salary_slips_for_payroll_period,
+ create_tax_slab,
+)
+from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
+from erpnext.payroll.report.income_tax_computation.income_tax_computation import execute
+
+
+class TestIncomeTaxComputation(unittest.TestCase):
+ def setUp(self):
+ self.cleanup_records()
+ self.create_records()
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def cleanup_records(self):
+ frappe.db.sql("delete from `tabEmployee Tax Exemption Declaration`")
+ frappe.db.sql("delete from `tabPayroll Period`")
+ frappe.db.sql("delete from `tabIncome Tax Slab`")
+ frappe.db.sql("delete from `tabSalary Component`")
+ frappe.db.sql("delete from `tabEmployee Benefit Application`")
+ frappe.db.sql("delete from `tabEmployee Benefit Claim`")
+ frappe.db.sql("delete from `tabEmployee` where company='_Test Company'")
+ frappe.db.sql("delete from `tabSalary Slip`")
+
+ def create_records(self):
+ self.employee = make_employee(
+ "employee_tax_computation@example.com",
+ company="_Test Company",
+ date_of_joining=getdate("01-10-2021"),
+ )
+
+ self.payroll_period = create_payroll_period(
+ name="_Test Payroll Period 1", company="_Test Company"
+ )
+
+ self.income_tax_slab = create_tax_slab(
+ self.payroll_period,
+ allow_tax_exemption=True,
+ effective_date=getdate("2019-04-01"),
+ company="_Test Company",
+ )
+ salary_structure = make_salary_structure(
+ "Monthly Salary Structure Test Income Tax Computation",
+ "Monthly",
+ employee=self.employee,
+ company="_Test Company",
+ currency="INR",
+ payroll_period=self.payroll_period,
+ test_tax=True,
+ )
+
+ create_exemption_declaration(self.employee, self.payroll_period.name)
+
+ create_salary_slips_for_payroll_period(
+ self.employee, salary_structure.name, self.payroll_period, deduct_random=False, num=3
+ )
+
+ def test_report(self):
+ filters = frappe._dict(
+ {
+ "company": "_Test Company",
+ "payroll_period": self.payroll_period.name,
+ "employee": self.employee,
+ }
+ )
+
+ result = execute(filters)
+
+ expected_data = {
+ "employee": self.employee,
+ "employee_name": "employee_tax_computation@example.com",
+ "department": "All Departments",
+ "income_tax_slab": self.income_tax_slab,
+ "ctc": 936000.0,
+ "professional_tax": 2400.0,
+ "standard_tax_exemption": 50000,
+ "total_exemption": 52400.0,
+ "total_taxable_amount": 883600.0,
+ "applicable_tax": 92789.0,
+ "total_tax_deducted": 17997.0,
+ "payable_tax": 74792,
+ }
+
+ for key, val in expected_data.items():
+ self.assertEqual(result[1][0].get(key), val)
+
+ # Run report considering tax exemption declaration
+ filters.consider_tax_exemption_declaration = 1
+
+ result = execute(filters)
+
+ expected_data.update(
+ {
+ "_test_category": 100000.0,
+ "total_exemption": 152400.0,
+ "total_taxable_amount": 783600.0,
+ "applicable_tax": 71989.0,
+ "payable_tax": 53992.0,
+ }
+ )
+
+ for key, val in expected_data.items():
+ self.assertEqual(result[1][0].get(key), val)
diff --git a/erpnext/payroll/report/salary_payments_based_on_payment_mode/salary_payments_based_on_payment_mode.py b/erpnext/payroll/report/salary_payments_based_on_payment_mode/salary_payments_based_on_payment_mode.py
index e5348df..4223f9d 100644
--- a/erpnext/payroll/report/salary_payments_based_on_payment_mode/salary_payments_based_on_payment_mode.py
+++ b/erpnext/payroll/report/salary_payments_based_on_payment_mode/salary_payments_based_on_payment_mode.py
@@ -142,21 +142,21 @@
return [
{
"value": gross_pay,
- "label": "Total Gross Pay",
+ "label": _("Total Gross Pay"),
"indicator": "Green",
"datatype": "Currency",
"currency": currency,
},
{
"value": total_deductions,
- "label": "Total Deduction",
+ "label": _("Total Deduction"),
"datatype": "Currency",
"indicator": "Red",
"currency": currency,
},
{
"value": net_pay,
- "label": "Total Net Pay",
+ "label": _("Total Net Pay"),
"datatype": "Currency",
"indicator": "Blue",
"currency": currency,
diff --git a/erpnext/payroll/workspace/payroll/payroll.json b/erpnext/payroll/workspace/payroll/payroll.json
index 762bea0..5629e63 100644
--- a/erpnext/payroll/workspace/payroll/payroll.json
+++ b/erpnext/payroll/workspace/payroll/payroll.json
@@ -246,6 +246,17 @@
"type": "Link"
},
{
+ "dependencies": "Salary Structure",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Income Tax Computation",
+ "link_count": 0,
+ "link_to": "Income Tax Computation",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
"dependencies": "Salary Slip",
"hidden": 0,
"is_query_report": 1,
@@ -312,7 +323,7 @@
"type": "Link"
}
],
- "modified": "2022-01-13 17:41:19.098813",
+ "modified": "2022-02-23 17:41:19.098813",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Payroll",
diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py
index 7031fcb..29f1ce4 100644
--- a/erpnext/projects/doctype/project/project.py
+++ b/erpnext/projects/doctype/project/project.py
@@ -326,21 +326,39 @@
def get_project_list(
doctype, txt, filters, limit_start, limit_page_length=20, order_by="modified"
):
- return frappe.db.sql(
- """select distinct project.*
- from tabProject project, `tabProject User` project_user
- where
- (project_user.user = %(user)s
- and project_user.parent = project.name)
- or project.owner = %(user)s
- order by project.modified desc
- limit {0}, {1}
- """.format(
- limit_start, limit_page_length
- ),
- {"user": frappe.session.user},
- as_dict=True,
- update={"doctype": "Project"},
+ meta = frappe.get_meta(doctype)
+ if not filters:
+ filters = []
+
+ fields = "distinct *"
+
+ or_filters = []
+
+ if txt:
+ if meta.search_fields:
+ for f in meta.get_search_fields():
+ if f == "name" or meta.get_field(f).fieldtype in (
+ "Data",
+ "Text",
+ "Small Text",
+ "Text Editor",
+ "select",
+ ):
+ or_filters.append([doctype, f, "like", "%" + txt + "%"])
+ else:
+ if isinstance(filters, dict):
+ filters["name"] = ("like", "%" + txt + "%")
+ else:
+ filters.append([doctype, "name", "like", "%" + txt + "%"])
+
+ return frappe.get_list(
+ doctype,
+ fields=fields,
+ filters=filters,
+ or_filters=or_filters,
+ limit_start=limit_start,
+ limit_page_length=limit_page_length,
+ order_by=order_by,
)
diff --git a/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py
index 5c3dc2d..17e3155 100644
--- a/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py
+++ b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py
@@ -3,6 +3,7 @@
import frappe
+from frappe import _
from frappe.utils import date_diff, nowdate
@@ -83,19 +84,24 @@
def get_columns():
columns = [
- {"fieldname": "name", "fieldtype": "Link", "label": "Task", "options": "Task", "width": 150},
- {"fieldname": "subject", "fieldtype": "Data", "label": "Subject", "width": 200},
- {"fieldname": "status", "fieldtype": "Data", "label": "Status", "width": 100},
- {"fieldname": "priority", "fieldtype": "Data", "label": "Priority", "width": 80},
- {"fieldname": "progress", "fieldtype": "Data", "label": "Progress (%)", "width": 120},
+ {"fieldname": "name", "fieldtype": "Link", "label": _("Task"), "options": "Task", "width": 150},
+ {"fieldname": "subject", "fieldtype": "Data", "label": _("Subject"), "width": 200},
+ {"fieldname": "status", "fieldtype": "Data", "label": _("Status"), "width": 100},
+ {"fieldname": "priority", "fieldtype": "Data", "label": _("Priority"), "width": 80},
+ {"fieldname": "progress", "fieldtype": "Data", "label": _("Progress (%)"), "width": 120},
{
"fieldname": "exp_start_date",
"fieldtype": "Date",
- "label": "Expected Start Date",
+ "label": _("Expected Start Date"),
"width": 150,
},
- {"fieldname": "exp_end_date", "fieldtype": "Date", "label": "Expected End Date", "width": 150},
- {"fieldname": "completed_on", "fieldtype": "Date", "label": "Actual End Date", "width": 130},
- {"fieldname": "delay", "fieldtype": "Data", "label": "Delay (In Days)", "width": 120},
+ {
+ "fieldname": "exp_end_date",
+ "fieldtype": "Date",
+ "label": _("Expected End Date"),
+ "width": 150,
+ },
+ {"fieldname": "completed_on", "fieldtype": "Date", "label": _("Actual End Date"), "width": 130},
+ {"fieldname": "delay", "fieldtype": "Data", "label": _("Delay (In Days)"), "width": 120},
]
return columns
diff --git a/erpnext/public/js/call_popup/call_popup.js b/erpnext/public/js/call_popup/call_popup.js
index c954f12..2dbe999 100644
--- a/erpnext/public/js/call_popup/call_popup.js
+++ b/erpnext/public/js/call_popup/call_popup.js
@@ -141,6 +141,14 @@
'fieldtype': 'Section Break',
'hide_border': 1,
}, {
+ 'fieldname': 'call_type',
+ 'label': 'Call Type',
+ 'fieldtype': 'Link',
+ 'options': 'Telephony Call Type',
+ }, {
+ 'fieldtype': 'Section Break',
+ 'hide_border': 1,
+ }, {
'fieldtype': 'Small Text',
'label': __('Call Summary'),
'fieldname': 'call_summary',
@@ -149,10 +157,12 @@
'label': __('Save'),
'click': () => {
const call_summary = this.call_details.get_value('call_summary');
+ const call_type = this.call_details.get_value('call_type');
if (!call_summary) return;
- frappe.xcall('erpnext.telephony.doctype.call_log.call_log.add_call_summary', {
+ frappe.xcall('erpnext.telephony.doctype.call_log.call_log.add_call_summary_and_call_type', {
'call_log': this.call_log.name,
'summary': call_summary,
+ 'call_type': call_type,
}).then(() => {
this.close_modal();
frappe.show_alert({
diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js
index 54e5daa..bbf1ff6 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -81,7 +81,7 @@
}
this.frm.set_query("item_code", "items", function() {
- if (me.frm.doc.is_subcontracted == "Yes") {
+ if (me.frm.doc.is_subcontracted) {
return{
query: "erpnext.controllers.queries.item_query",
filters:{ 'supplier': me.frm.doc.supplier, 'is_sub_contracted_item': 1 }
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index 047ec81..3dd11f6 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -34,12 +34,12 @@
frappe.model.set_value(item.doctype, item.name, "rate", item_rate);
}
- calculate_taxes_and_totals(update_paid_amount) {
+ async calculate_taxes_and_totals(update_paid_amount) {
this.discount_amount_applied = false;
this._calculate_taxes_and_totals();
this.calculate_discount_amount();
- this.calculate_shipping_charges();
+ await this.calculate_shipping_charges();
// Advance calculation applicable to Sales /Purchase Invoice
if(in_list(["Sales Invoice", "POS Invoice", "Purchase Invoice"], this.frm.doc.doctype)
@@ -273,10 +273,14 @@
}
calculate_shipping_charges() {
+ // Do not apply shipping rule for POS
+ if (this.frm.doc.is_pos) {
+ return;
+ }
+
frappe.model.round_floats_in(this.frm.doc, ["total", "base_total", "net_total", "base_net_total"]);
if (frappe.meta.get_docfield(this.frm.doc.doctype, "shipping_rule", this.frm.doc.name)) {
- this.shipping_rule();
- this._calculate_taxes_and_totals();
+ return this.shipping_rule();
}
}
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 23c2bd4..767221e 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -239,7 +239,7 @@
() => set_value('currency', currency),
() => set_value('price_list_currency', currency),
() => set_value('status', 'Draft'),
- () => set_value('is_subcontracted', 'No'),
+ () => set_value('is_subcontracted', 0),
() => {
if(this.frm.doc.company && !this.frm.doc.amended_from) {
this.frm.trigger("company");
@@ -403,17 +403,6 @@
var sms_man = new erpnext.SMSManager(this.frm.doc);
}
- barcode(doc, cdt, cdn) {
- const d = locals[cdt][cdn];
- if (!d.barcode) {
- // barcode cleared, remove item
- d.item_code = "";
- }
- // flag required for circular triggers
- d._triggerd_from_barcode = true;
- this.item_code(doc, cdt, cdn);
- }
-
item_code(doc, cdt, cdn) {
var me = this;
var item = frappe.get_doc(cdt, cdn);
@@ -431,9 +420,7 @@
this.frm.doc.doctype === 'Delivery Note') {
show_batch_dialog = 1;
}
- if (!item._triggerd_from_barcode) {
- item.barcode = null;
- }
+ item.barcode = null;
if(item.item_code || item.barcode || item.serial_no) {
@@ -539,6 +526,12 @@
if(!d[k]) d[k] = v;
});
+ if (d.__disable_batch_serial_selector) {
+ // reset for future use.
+ d.__disable_batch_serial_selector = false;
+ return;
+ }
+
if (d.has_batch_no && d.has_serial_no) {
d.batch_no = undefined;
}
@@ -981,6 +974,9 @@
return this.frm.call({
doc: this.frm.doc,
method: "apply_shipping_rule",
+ callback: function(r) {
+ me._calculate_taxes_and_totals();
+ }
}).fail(() => this.frm.set_value('shipping_rule', ''));
}
}
@@ -1392,6 +1388,11 @@
return;
}
+ // Target doc created from a mapped doc
+ if (this.frm.doc.__onload && this.frm.doc.__onload.ignore_price_list) {
+ return;
+ }
+
return this.frm.call({
method: "erpnext.accounts.doctype.pricing_rule.pricing_rule.apply_pricing_rule",
args: { args: args, doc: me.frm.doc },
@@ -1508,7 +1509,7 @@
me.remove_pricing_rule(frappe.get_doc(d.doctype, d.name));
}
- if (d.free_item_data) {
+ if (d.free_item_data.length > 0) {
me.apply_product_discount(d);
}
diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js
index 9339c5d..eded165 100755
--- a/erpnext/public/js/utils.js
+++ b/erpnext/public/js/utils.js
@@ -483,7 +483,7 @@
if (frm.doc.doctype == 'Sales Order') {
filters = {"is_sales_item": 1};
} else if (frm.doc.doctype == 'Purchase Order') {
- if (frm.doc.is_subcontracted == "Yes") {
+ if (frm.doc.is_subcontracted) {
filters = {"is_sub_contracted_item": 1};
} else {
filters = {"is_purchase_item": 1};
diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js
index abea5fc..f72b85c 100644
--- a/erpnext/public/js/utils/barcode_scanner.js
+++ b/erpnext/public/js/utils/barcode_scanner.js
@@ -21,9 +21,7 @@
// batch_no: "LOT12", // present if batch was scanned
// serial_no: "987XYZ", // present if serial no was scanned
// }
- this.scan_api =
- opts.scan_api ||
- "erpnext.selling.page.point_of_sale.point_of_sale.search_for_serial_or_batch_or_barcode_number";
+ this.scan_api = opts.scan_api || "erpnext.stock.utils.scan_barcode";
}
process_scan() {
@@ -52,14 +50,16 @@
return;
}
- me.update_table(data.item_code, data.barcode, data.batch_no, data.serial_no);
+ me.update_table(data);
});
}
- update_table(item_code, barcode, batch_no, serial_no) {
+ update_table(data) {
let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
let row = null;
+ const {item_code, barcode, batch_no, serial_no} = data;
+
// Check if batch is scanned and table has batch no field
let batch_no_scan =
Boolean(batch_no) && frappe.meta.has_field(cur_grid.doctype, this.batch_no_field);
@@ -68,7 +68,7 @@
row = this.get_batch_row_to_modify(batch_no);
} else {
// serial or barcode scan
- row = this.get_row_to_modify_on_scan(row, item_code);
+ row = this.get_row_to_modify_on_scan(item_code);
}
if (!row) {
@@ -84,6 +84,7 @@
}
this.show_scan_message(row.idx, row.item_code);
+ this.set_selector_trigger_flag(row, data);
this.set_item(row, item_code);
this.set_serial_no(row, serial_no);
this.set_batch_no(row, batch_no);
@@ -91,6 +92,19 @@
this.clean_up();
}
+ // batch and serial selector is reduandant when all info can be added by scan
+ // this flag on item row is used by transaction.js to avoid triggering selector
+ set_selector_trigger_flag(row, data) {
+ const {batch_no, serial_no, has_batch_no, has_serial_no} = data;
+
+ const require_selecting_batch = has_batch_no && !batch_no;
+ const require_selecting_serial = has_serial_no && !serial_no;
+
+ if (!(require_selecting_batch || require_selecting_serial)) {
+ row.__disable_batch_serial_selector = true;
+ }
+ }
+
set_item(row, item_code) {
const item_data = { item_code: item_code };
item_data[this.qty_field] = (row[this.qty_field] || 0) + 1;
@@ -163,21 +177,17 @@
get_batch_row_to_modify(batch_no) {
// get row if batch already exists in table
const existing_batch_row = this.items_table.find((d) => d.batch_no === batch_no);
- return existing_batch_row || null;
+ return existing_batch_row || this.get_existing_blank_row();
}
- get_row_to_modify_on_scan(row_to_modify, item_code) {
+ get_row_to_modify_on_scan(item_code) {
// get an existing item row to increment or blank row to modify
const existing_item_row = this.items_table.find((d) => d.item_code === item_code);
- const blank_item_row = this.items_table.find((d) => !d.item_code);
+ return existing_item_row || this.get_existing_blank_row();
+ }
- if (existing_item_row) {
- row_to_modify = existing_item_row;
- } else if (blank_item_row) {
- row_to_modify = blank_item_row;
- }
-
- return row_to_modify;
+ get_existing_blank_row() {
+ return this.items_table.find((d) => !d.item_code);
}
clean_up() {
diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js
index f484545..64c5ee5 100644
--- a/erpnext/public/js/utils/serial_no_batch_selector.js
+++ b/erpnext/public/js/utils/serial_no_batch_selector.js
@@ -609,8 +609,8 @@
&& erpnext.stock.bom
&& erpnext.stock.bom.name === doc.bom_no;
const itemChecks = !!item
- && !item.allow_alternative_item
- && erpnext.stock.bom && erpnext.stock.items
+ && !item.original_item
+ && erpnext.stock.bom && erpnext.stock.bom.items
&& (item.item_code in erpnext.stock.bom.items);
return docChecks && itemChecks;
}
diff --git a/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.py b/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.py
index daf7a69..04e8211 100644
--- a/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.py
+++ b/erpnext/quality_management/doctype/quality_procedure/test_quality_procedure.py
@@ -19,7 +19,7 @@
)
).insert()
- frappe.form_dict = dict(
+ frappe.local.form_dict = frappe._dict(
doctype="Quality Procedure",
quality_procedure_name="Test Child 1",
parent_quality_procedure=procedure.name,
diff --git a/erpnext/regional/doctype/datev_settings/datev_settings.js b/erpnext/regional/doctype/datev_settings/datev_settings.js
deleted file mode 100644
index f047059..0000000
--- a/erpnext/regional/doctype/datev_settings/datev_settings.js
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('DATEV Settings', {
- refresh: function(frm) {
- frm.add_custom_button('Show Report', () => frappe.set_route('query-report', 'DATEV'), "fa fa-table");
- }
-});
diff --git a/erpnext/regional/doctype/datev_settings/datev_settings.json b/erpnext/regional/doctype/datev_settings/datev_settings.json
deleted file mode 100644
index f60de4c..0000000
--- a/erpnext/regional/doctype/datev_settings/datev_settings.json
+++ /dev/null
@@ -1,125 +0,0 @@
-{
- "actions": [],
- "autoname": "field:client",
- "creation": "2019-08-13 23:56:34.259906",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "client",
- "client_number",
- "column_break_2",
- "consultant_number",
- "consultant",
- "section_break_4",
- "account_number_length",
- "column_break_6",
- "temporary_against_account_number"
- ],
- "fields": [
- {
- "fieldname": "client",
- "fieldtype": "Link",
- "in_list_view": 1,
- "label": "Client",
- "options": "Company",
- "reqd": 1,
- "unique": 1
- },
- {
- "fieldname": "client_number",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Client ID",
- "length": 5,
- "reqd": 1
- },
- {
- "fieldname": "consultant",
- "fieldtype": "Link",
- "in_list_view": 1,
- "label": "Consultant",
- "options": "Supplier"
- },
- {
- "fieldname": "consultant_number",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Consultant ID",
- "length": 7,
- "reqd": 1
- },
- {
- "fieldname": "column_break_2",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "section_break_4",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "column_break_6",
- "fieldtype": "Column Break"
- },
- {
- "default": "4",
- "fieldname": "account_number_length",
- "fieldtype": "Int",
- "label": "Account Number Length",
- "reqd": 1
- },
- {
- "allow_in_quick_entry": 1,
- "fieldname": "temporary_against_account_number",
- "fieldtype": "Data",
- "label": "Temporary Against Account Number",
- "reqd": 1
- }
- ],
- "links": [],
- "modified": "2020-11-19 19:00:09.088816",
- "modified_by": "Administrator",
- "module": "Regional",
- "name": "DATEV Settings",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- },
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Accounts Manager",
- "share": 1,
- "write": 1
- },
- {
- "create": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Accounts User",
- "share": 1
- }
- ],
- "quick_entry": 1,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/datev_settings/datev_settings.py b/erpnext/regional/doctype/datev_settings/datev_settings.py
deleted file mode 100644
index 686a93e..0000000
--- a/erpnext/regional/doctype/datev_settings/datev_settings.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
-
-
-# import frappe
-from frappe.model.document import Document
-
-
-class DATEVSettings(Document):
- pass
diff --git a/erpnext/regional/doctype/datev_settings/test_datev_settings.py b/erpnext/regional/doctype/datev_settings/test_datev_settings.py
deleted file mode 100644
index ba70eb4..0000000
--- a/erpnext/regional/doctype/datev_settings/test_datev_settings.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-
-# import frappe
-import unittest
-
-
-class TestDATEVSettings(unittest.TestCase):
- pass
diff --git a/erpnext/regional/germany/__init__.py b/erpnext/regional/germany/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/erpnext/regional/germany/__init__.py
+++ /dev/null
diff --git a/erpnext/regional/germany/setup.py b/erpnext/regional/germany/setup.py
deleted file mode 100644
index b8e66c3..0000000
--- a/erpnext/regional/germany/setup.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import frappe
-from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
-
-
-def setup(company=None, patch=True):
- make_custom_fields()
- add_custom_roles_for_reports()
-
-
-def make_custom_fields():
- custom_fields = {
- "Party Account": [
- dict(
- fieldname="debtor_creditor_number",
- label="Debtor/Creditor Number",
- fieldtype="Data",
- insert_after="account",
- translatable=0,
- )
- ]
- }
-
- create_custom_fields(custom_fields)
-
-
-def add_custom_roles_for_reports():
- """Add Access Control to UAE VAT 201."""
- if not frappe.db.get_value("Custom Role", dict(report="DATEV")):
- frappe.get_doc(
- dict(
- doctype="Custom Role",
- report="DATEV",
- roles=[dict(role="Accounts User"), dict(role="Accounts Manager")],
- )
- ).insert()
diff --git a/erpnext/regional/germany/utils/__init__.py b/erpnext/regional/germany/utils/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/erpnext/regional/germany/utils/__init__.py
+++ /dev/null
diff --git a/erpnext/regional/germany/utils/datev/__init__.py b/erpnext/regional/germany/utils/datev/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/erpnext/regional/germany/utils/datev/__init__.py
+++ /dev/null
diff --git a/erpnext/regional/germany/utils/datev/datev_constants.py b/erpnext/regional/germany/utils/datev/datev_constants.py
deleted file mode 100644
index 9524481..0000000
--- a/erpnext/regional/germany/utils/datev/datev_constants.py
+++ /dev/null
@@ -1,501 +0,0 @@
-"""Constants used in datev.py."""
-
-TRANSACTION_COLUMNS = [
- # All possible columns must tbe listed here, because DATEV requires them to
- # be present in the CSV.
- # ---
- # Umsatz
- "Umsatz (ohne Soll/Haben-Kz)",
- "Soll/Haben-Kennzeichen",
- "WKZ Umsatz",
- "Kurs",
- "Basis-Umsatz",
- "WKZ Basis-Umsatz",
- # Konto/Gegenkonto
- "Konto",
- "Gegenkonto (ohne BU-Schlüssel)",
- "BU-Schlüssel",
- # Datum
- "Belegdatum",
- # Rechnungs- / Belegnummer
- "Belegfeld 1",
- # z.B. Fälligkeitsdatum Format: TTMMJJ
- "Belegfeld 2",
- # Skonto-Betrag / -Abzug (Der Wert 0 ist unzulässig)
- "Skonto",
- # Beschreibung des Buchungssatzes
- "Buchungstext",
- # Mahn- / Zahl-Sperre (1 = Postensperre)
- "Postensperre",
- "Diverse Adressnummer",
- "Geschäftspartnerbank",
- "Sachverhalt",
- # Keine Mahnzinsen
- "Zinssperre",
- # Link auf den Buchungsbeleg (Programmkürzel + GUID)
- "Beleglink",
- # Beleginfo
- "Beleginfo - Art 1",
- "Beleginfo - Inhalt 1",
- "Beleginfo - Art 2",
- "Beleginfo - Inhalt 2",
- "Beleginfo - Art 3",
- "Beleginfo - Inhalt 3",
- "Beleginfo - Art 4",
- "Beleginfo - Inhalt 4",
- "Beleginfo - Art 5",
- "Beleginfo - Inhalt 5",
- "Beleginfo - Art 6",
- "Beleginfo - Inhalt 6",
- "Beleginfo - Art 7",
- "Beleginfo - Inhalt 7",
- "Beleginfo - Art 8",
- "Beleginfo - Inhalt 8",
- # Zuordnung des Geschäftsvorfalls für die Kostenrechnung
- "KOST1 - Kostenstelle",
- "KOST2 - Kostenstelle",
- "KOST-Menge",
- # USt-ID-Nummer (Beispiel: DE133546770)
- "EU-Mitgliedstaat u. USt-IdNr.",
- # Der im EU-Bestimmungsland gültige Steuersatz
- "EU-Steuersatz",
- # I = Ist-Versteuerung,
- # K = keine Umsatzsteuerrechnung
- # P = Pauschalierung (z. B. für Land- und Forstwirtschaft),
- # S = Soll-Versteuerung
- "Abw. Versteuerungsart",
- # Sachverhalte gem. § 13b Abs. 1 Satz 1 Nrn. 1.-5. UStG
- "Sachverhalt L+L",
- # Steuersatz / Funktion zum L+L-Sachverhalt (Beispiel: Wert 190 für 19%)
- "Funktionsergänzung L+L",
- # Bei Verwendung des BU-Schlüssels 49 für „andere Steuersätze“ muss der
- # steuerliche Sachverhalt mitgegeben werden
- "BU 49 Hauptfunktionstyp",
- "BU 49 Hauptfunktionsnummer",
- "BU 49 Funktionsergänzung",
- # Zusatzinformationen, besitzen den Charakter eines Notizzettels und können
- # frei erfasst werden.
- "Zusatzinformation - Art 1",
- "Zusatzinformation - Inhalt 1",
- "Zusatzinformation - Art 2",
- "Zusatzinformation - Inhalt 2",
- "Zusatzinformation - Art 3",
- "Zusatzinformation - Inhalt 3",
- "Zusatzinformation - Art 4",
- "Zusatzinformation - Inhalt 4",
- "Zusatzinformation - Art 5",
- "Zusatzinformation - Inhalt 5",
- "Zusatzinformation - Art 6",
- "Zusatzinformation - Inhalt 6",
- "Zusatzinformation - Art 7",
- "Zusatzinformation - Inhalt 7",
- "Zusatzinformation - Art 8",
- "Zusatzinformation - Inhalt 8",
- "Zusatzinformation - Art 9",
- "Zusatzinformation - Inhalt 9",
- "Zusatzinformation - Art 10",
- "Zusatzinformation - Inhalt 10",
- "Zusatzinformation - Art 11",
- "Zusatzinformation - Inhalt 11",
- "Zusatzinformation - Art 12",
- "Zusatzinformation - Inhalt 12",
- "Zusatzinformation - Art 13",
- "Zusatzinformation - Inhalt 13",
- "Zusatzinformation - Art 14",
- "Zusatzinformation - Inhalt 14",
- "Zusatzinformation - Art 15",
- "Zusatzinformation - Inhalt 15",
- "Zusatzinformation - Art 16",
- "Zusatzinformation - Inhalt 16",
- "Zusatzinformation - Art 17",
- "Zusatzinformation - Inhalt 17",
- "Zusatzinformation - Art 18",
- "Zusatzinformation - Inhalt 18",
- "Zusatzinformation - Art 19",
- "Zusatzinformation - Inhalt 19",
- "Zusatzinformation - Art 20",
- "Zusatzinformation - Inhalt 20",
- # Wirkt sich nur bei Sachverhalt mit SKR 14 Land- und Forstwirtschaft aus,
- # für andere SKR werden die Felder beim Import / Export überlesen bzw.
- # leer exportiert.
- "Stück",
- "Gewicht",
- # 1 = Lastschrift
- # 2 = Mahnung
- # 3 = Zahlung
- "Zahlweise",
- "Forderungsart",
- # JJJJ
- "Veranlagungsjahr",
- # TTMMJJJJ
- "Zugeordnete Fälligkeit",
- # 1 = Einkauf von Waren
- # 2 = Erwerb von Roh-Hilfs- und Betriebsstoffen
- "Skontotyp",
- # Allgemeine Bezeichnung, des Auftrags / Projekts.
- "Auftragsnummer",
- # AA = Angeforderte Anzahlung / Abschlagsrechnung
- # AG = Erhaltene Anzahlung (Geldeingang)
- # AV = Erhaltene Anzahlung (Verbindlichkeit)
- # SR = Schlussrechnung
- # SU = Schlussrechnung (Umbuchung)
- # SG = Schlussrechnung (Geldeingang)
- # SO = Sonstige
- "Buchungstyp",
- "USt-Schlüssel (Anzahlungen)",
- "EU-Mitgliedstaat (Anzahlungen)",
- "Sachverhalt L+L (Anzahlungen)",
- "EU-Steuersatz (Anzahlungen)",
- "Erlöskonto (Anzahlungen)",
- # Wird beim Import durch SV (Stapelverarbeitung) ersetzt.
- "Herkunft-Kz",
- # Wird von DATEV verwendet.
- "Leerfeld",
- # Format TTMMJJJJ
- "KOST-Datum",
- # Vom Zahlungsempfänger individuell vergebenes Kennzeichen eines Mandats
- # (z.B. Rechnungs- oder Kundennummer).
- "SEPA-Mandatsreferenz",
- # 1 = Skontosperre
- # 0 = Keine Skontosperre
- "Skontosperre",
- # Gesellschafter und Sonderbilanzsachverhalt
- "Gesellschaftername",
- # Amtliche Nummer aus der Feststellungserklärung
- "Beteiligtennummer",
- "Identifikationsnummer",
- "Zeichnernummer",
- # Format TTMMJJJJ
- "Postensperre bis",
- # Gesellschafter und Sonderbilanzsachverhalt
- "Bezeichnung SoBil-Sachverhalt",
- "Kennzeichen SoBil-Buchung",
- # 0 = keine Festschreibung
- # 1 = Festschreibung
- "Festschreibung",
- # Format TTMMJJJJ
- "Leistungsdatum",
- # Format TTMMJJJJ
- "Datum Zuord. Steuerperiode",
- # OPOS-Informationen, Format TTMMJJJJ
- "Fälligkeit",
- # G oder 1 = Generalumkehr
- # 0 = keine Generalumkehr
- "Generalumkehr (GU)",
- # Steuersatz für Steuerschlüssel
- "Steuersatz",
- # Beispiel: DE für Deutschland
- "Land",
-]
-
-DEBTOR_CREDITOR_COLUMNS = [
- # All possible columns must tbe listed here, because DATEV requires them to
- # be present in the CSV.
- # Columns "Leerfeld" have been replaced with "Leerfeld #" to not confuse pandas
- # ---
- "Konto",
- "Name (Adressatentyp Unternehmen)",
- "Unternehmensgegenstand",
- "Name (Adressatentyp natürl. Person)",
- "Vorname (Adressatentyp natürl. Person)",
- "Name (Adressatentyp keine Angabe)",
- "Adressatentyp",
- "Kurzbezeichnung",
- "EU-Land",
- "EU-USt-IdNr.",
- "Anrede",
- "Titel/Akad. Grad",
- "Adelstitel",
- "Namensvorsatz",
- "Adressart",
- "Straße",
- "Postfach",
- "Postleitzahl",
- "Ort",
- "Land",
- "Versandzusatz",
- "Adresszusatz",
- "Abweichende Anrede",
- "Abw. Zustellbezeichnung 1",
- "Abw. Zustellbezeichnung 2",
- "Kennz. Korrespondenzadresse",
- "Adresse gültig von",
- "Adresse gültig bis",
- "Telefon",
- "Bemerkung (Telefon)",
- "Telefon Geschäftsleitung",
- "Bemerkung (Telefon GL)",
- "E-Mail",
- "Bemerkung (E-Mail)",
- "Internet",
- "Bemerkung (Internet)",
- "Fax",
- "Bemerkung (Fax)",
- "Sonstige",
- "Bemerkung (Sonstige)",
- "Bankleitzahl 1",
- "Bankbezeichnung 1",
- "Bankkonto-Nummer 1",
- "Länderkennzeichen 1",
- "IBAN 1",
- "Leerfeld 1",
- "SWIFT-Code 1",
- "Abw. Kontoinhaber 1",
- "Kennz. Haupt-Bankverb. 1",
- "Bankverb. 1 Gültig von",
- "Bankverb. 1 Gültig bis",
- "Bankleitzahl 2",
- "Bankbezeichnung 2",
- "Bankkonto-Nummer 2",
- "Länderkennzeichen 2",
- "IBAN 2",
- "Leerfeld 2",
- "SWIFT-Code 2",
- "Abw. Kontoinhaber 2",
- "Kennz. Haupt-Bankverb. 2",
- "Bankverb. 2 gültig von",
- "Bankverb. 2 gültig bis",
- "Bankleitzahl 3",
- "Bankbezeichnung 3",
- "Bankkonto-Nummer 3",
- "Länderkennzeichen 3",
- "IBAN 3",
- "Leerfeld 3",
- "SWIFT-Code 3",
- "Abw. Kontoinhaber 3",
- "Kennz. Haupt-Bankverb. 3",
- "Bankverb. 3 gültig von",
- "Bankverb. 3 gültig bis",
- "Bankleitzahl 4",
- "Bankbezeichnung 4",
- "Bankkonto-Nummer 4",
- "Länderkennzeichen 4",
- "IBAN 4",
- "Leerfeld 4",
- "SWIFT-Code 4",
- "Abw. Kontoinhaber 4",
- "Kennz. Haupt-Bankverb. 4",
- "Bankverb. 4 Gültig von",
- "Bankverb. 4 Gültig bis",
- "Bankleitzahl 5",
- "Bankbezeichnung 5",
- "Bankkonto-Nummer 5",
- "Länderkennzeichen 5",
- "IBAN 5",
- "Leerfeld 5",
- "SWIFT-Code 5",
- "Abw. Kontoinhaber 5",
- "Kennz. Haupt-Bankverb. 5",
- "Bankverb. 5 gültig von",
- "Bankverb. 5 gültig bis",
- "Leerfeld 6",
- "Briefanrede",
- "Grußformel",
- "Kundennummer",
- "Steuernummer",
- "Sprache",
- "Ansprechpartner",
- "Vertreter",
- "Sachbearbeiter",
- "Diverse-Konto",
- "Ausgabeziel",
- "Währungssteuerung",
- "Kreditlimit (Debitor)",
- "Zahlungsbedingung",
- "Fälligkeit in Tagen (Debitor)",
- "Skonto in Prozent (Debitor)",
- "Kreditoren-Ziel 1 (Tage)",
- "Kreditoren-Skonto 1 (%)",
- "Kreditoren-Ziel 2 (Tage)",
- "Kreditoren-Skonto 2 (%)",
- "Kreditoren-Ziel 3 Brutto (Tage)",
- "Kreditoren-Ziel 4 (Tage)",
- "Kreditoren-Skonto 4 (%)",
- "Kreditoren-Ziel 5 (Tage)",
- "Kreditoren-Skonto 5 (%)",
- "Mahnung",
- "Kontoauszug",
- "Mahntext 1",
- "Mahntext 2",
- "Mahntext 3",
- "Kontoauszugstext",
- "Mahnlimit Betrag",
- "Mahnlimit %",
- "Zinsberechnung",
- "Mahnzinssatz 1",
- "Mahnzinssatz 2",
- "Mahnzinssatz 3",
- "Lastschrift",
- "Verfahren",
- "Mandantenbank",
- "Zahlungsträger",
- "Indiv. Feld 1",
- "Indiv. Feld 2",
- "Indiv. Feld 3",
- "Indiv. Feld 4",
- "Indiv. Feld 5",
- "Indiv. Feld 6",
- "Indiv. Feld 7",
- "Indiv. Feld 8",
- "Indiv. Feld 9",
- "Indiv. Feld 10",
- "Indiv. Feld 11",
- "Indiv. Feld 12",
- "Indiv. Feld 13",
- "Indiv. Feld 14",
- "Indiv. Feld 15",
- "Abweichende Anrede (Rechnungsadresse)",
- "Adressart (Rechnungsadresse)",
- "Straße (Rechnungsadresse)",
- "Postfach (Rechnungsadresse)",
- "Postleitzahl (Rechnungsadresse)",
- "Ort (Rechnungsadresse)",
- "Land (Rechnungsadresse)",
- "Versandzusatz (Rechnungsadresse)",
- "Adresszusatz (Rechnungsadresse)",
- "Abw. Zustellbezeichnung 1 (Rechnungsadresse)",
- "Abw. Zustellbezeichnung 2 (Rechnungsadresse)",
- "Adresse Gültig von (Rechnungsadresse)",
- "Adresse Gültig bis (Rechnungsadresse)",
- "Bankleitzahl 6",
- "Bankbezeichnung 6",
- "Bankkonto-Nummer 6",
- "Länderkennzeichen 6",
- "IBAN 6",
- "Leerfeld 7",
- "SWIFT-Code 6",
- "Abw. Kontoinhaber 6",
- "Kennz. Haupt-Bankverb. 6",
- "Bankverb 6 gültig von",
- "Bankverb 6 gültig bis",
- "Bankleitzahl 7",
- "Bankbezeichnung 7",
- "Bankkonto-Nummer 7",
- "Länderkennzeichen 7",
- "IBAN 7",
- "Leerfeld 8",
- "SWIFT-Code 7",
- "Abw. Kontoinhaber 7",
- "Kennz. Haupt-Bankverb. 7",
- "Bankverb 7 gültig von",
- "Bankverb 7 gültig bis",
- "Bankleitzahl 8",
- "Bankbezeichnung 8",
- "Bankkonto-Nummer 8",
- "Länderkennzeichen 8",
- "IBAN 8",
- "Leerfeld 9",
- "SWIFT-Code 8",
- "Abw. Kontoinhaber 8",
- "Kennz. Haupt-Bankverb. 8",
- "Bankverb 8 gültig von",
- "Bankverb 8 gültig bis",
- "Bankleitzahl 9",
- "Bankbezeichnung 9",
- "Bankkonto-Nummer 9",
- "Länderkennzeichen 9",
- "IBAN 9",
- "Leerfeld 10",
- "SWIFT-Code 9",
- "Abw. Kontoinhaber 9",
- "Kennz. Haupt-Bankverb. 9",
- "Bankverb 9 gültig von",
- "Bankverb 9 gültig bis",
- "Bankleitzahl 10",
- "Bankbezeichnung 10",
- "Bankkonto-Nummer 10",
- "Länderkennzeichen 10",
- "IBAN 10",
- "Leerfeld 11",
- "SWIFT-Code 10",
- "Abw. Kontoinhaber 10",
- "Kennz. Haupt-Bankverb. 10",
- "Bankverb 10 gültig von",
- "Bankverb 10 gültig bis",
- "Nummer Fremdsystem",
- "Insolvent",
- "SEPA-Mandatsreferenz 1",
- "SEPA-Mandatsreferenz 2",
- "SEPA-Mandatsreferenz 3",
- "SEPA-Mandatsreferenz 4",
- "SEPA-Mandatsreferenz 5",
- "SEPA-Mandatsreferenz 6",
- "SEPA-Mandatsreferenz 7",
- "SEPA-Mandatsreferenz 8",
- "SEPA-Mandatsreferenz 9",
- "SEPA-Mandatsreferenz 10",
- "Verknüpftes OPOS-Konto",
- "Mahnsperre bis",
- "Lastschriftsperre bis",
- "Zahlungssperre bis",
- "Gebührenberechnung",
- "Mahngebühr 1",
- "Mahngebühr 2",
- "Mahngebühr 3",
- "Pauschalberechnung",
- "Verzugspauschale 1",
- "Verzugspauschale 2",
- "Verzugspauschale 3",
- "Alternativer Suchname",
- "Status",
- "Anschrift manuell geändert (Korrespondenzadresse)",
- "Anschrift individuell (Korrespondenzadresse)",
- "Anschrift manuell geändert (Rechnungsadresse)",
- "Anschrift individuell (Rechnungsadresse)",
- "Fristberechnung bei Debitor",
- "Mahnfrist 1",
- "Mahnfrist 2",
- "Mahnfrist 3",
- "Letzte Frist",
-]
-
-ACCOUNT_NAME_COLUMNS = [
- # Account number
- "Konto",
- # Account name
- "Kontenbeschriftung",
- # Language of the account name
- # "de-DE" or "en-GB"
- "Sprach-ID",
-]
-
-
-class DataCategory:
-
- """Field of the CSV Header."""
-
- DEBTORS_CREDITORS = "16"
- ACCOUNT_NAMES = "20"
- TRANSACTIONS = "21"
- POSTING_TEXT_CONSTANTS = "67"
-
-
-class FormatName:
-
- """Field of the CSV Header, corresponds to DataCategory."""
-
- DEBTORS_CREDITORS = "Debitoren/Kreditoren"
- ACCOUNT_NAMES = "Kontenbeschriftungen"
- TRANSACTIONS = "Buchungsstapel"
- POSTING_TEXT_CONSTANTS = "Buchungstextkonstanten"
-
-
-class Transactions:
- DATA_CATEGORY = DataCategory.TRANSACTIONS
- FORMAT_NAME = FormatName.TRANSACTIONS
- FORMAT_VERSION = "9"
- COLUMNS = TRANSACTION_COLUMNS
-
-
-class DebtorsCreditors:
- DATA_CATEGORY = DataCategory.DEBTORS_CREDITORS
- FORMAT_NAME = FormatName.DEBTORS_CREDITORS
- FORMAT_VERSION = "5"
- COLUMNS = DEBTOR_CREDITOR_COLUMNS
-
-
-class AccountNames:
- DATA_CATEGORY = DataCategory.ACCOUNT_NAMES
- FORMAT_NAME = FormatName.ACCOUNT_NAMES
- FORMAT_VERSION = "2"
- COLUMNS = ACCOUNT_NAME_COLUMNS
diff --git a/erpnext/regional/germany/utils/datev/datev_csv.py b/erpnext/regional/germany/utils/datev/datev_csv.py
deleted file mode 100644
index d4e9c27..0000000
--- a/erpnext/regional/germany/utils/datev/datev_csv.py
+++ /dev/null
@@ -1,184 +0,0 @@
-import datetime
-import zipfile
-from csv import QUOTE_NONNUMERIC
-from io import BytesIO
-
-import frappe
-import pandas as pd
-from frappe import _
-
-from .datev_constants import DataCategory
-
-
-def get_datev_csv(data, filters, csv_class):
- """
- Fill in missing columns and return a CSV in DATEV Format.
-
- For automatic processing, DATEV requires the first line of the CSV file to
- hold meta data such as the length of account numbers oder the category of
- the data.
-
- Arguments:
- data -- array of dictionaries
- filters -- dict
- csv_class -- defines DATA_CATEGORY, FORMAT_NAME and COLUMNS
- """
- empty_df = pd.DataFrame(columns=csv_class.COLUMNS)
- data_df = pd.DataFrame.from_records(data)
- result = empty_df.append(data_df, sort=True)
-
- if csv_class.DATA_CATEGORY == DataCategory.TRANSACTIONS:
- result["Belegdatum"] = pd.to_datetime(result["Belegdatum"])
-
- result["Beleginfo - Inhalt 6"] = pd.to_datetime(result["Beleginfo - Inhalt 6"])
- result["Beleginfo - Inhalt 6"] = result["Beleginfo - Inhalt 6"].dt.strftime("%d%m%Y")
-
- result["Fälligkeit"] = pd.to_datetime(result["Fälligkeit"])
- result["Fälligkeit"] = result["Fälligkeit"].dt.strftime("%d%m%y")
-
- result.sort_values(by="Belegdatum", inplace=True, kind="stable", ignore_index=True)
-
- if csv_class.DATA_CATEGORY == DataCategory.ACCOUNT_NAMES:
- result["Sprach-ID"] = "de-DE"
-
- data = result.to_csv(
- # Reason for str(';'): https://github.com/pandas-dev/pandas/issues/6035
- sep=";",
- # European decimal seperator
- decimal=",",
- # Windows "ANSI" encoding
- encoding="latin_1",
- # format date as DDMM
- date_format="%d%m",
- # Windows line terminator
- line_terminator="\r\n",
- # Do not number rows
- index=False,
- # Use all columns defined above
- columns=csv_class.COLUMNS,
- # Quote most fields, even currency values with "," separator
- quoting=QUOTE_NONNUMERIC,
- )
-
- data = data.encode("latin_1", errors="replace")
-
- header = get_header(filters, csv_class)
- header = ";".join(header).encode("latin_1", errors="replace")
-
- # 1st Row: Header with meta data
- # 2nd Row: Data heading (Überschrift der Nutzdaten), included in `data` here.
- # 3rd - nth Row: Data (Nutzdaten)
- return header + b"\r\n" + data
-
-
-def get_header(filters, csv_class):
- description = filters.get("voucher_type", csv_class.FORMAT_NAME)
- company = filters.get("company")
- datev_settings = frappe.get_doc("DATEV Settings", {"client": company})
- default_currency = frappe.get_value("Company", company, "default_currency")
- coa = frappe.get_value("Company", company, "chart_of_accounts")
- coa_short_code = "04" if "SKR04" in coa else ("03" if "SKR03" in coa else "")
-
- header = [
- # DATEV format
- # "DTVF" = created by DATEV software,
- # "EXTF" = created by other software
- '"EXTF"',
- # version of the DATEV format
- # 141 = 1.41,
- # 510 = 5.10,
- # 720 = 7.20
- "700",
- csv_class.DATA_CATEGORY,
- '"%s"' % csv_class.FORMAT_NAME,
- # Format version (regarding format name)
- csv_class.FORMAT_VERSION,
- # Generated on
- datetime.datetime.now().strftime("%Y%m%d%H%M%S") + "000",
- # Imported on -- stays empty
- "",
- # Origin. Any two symbols, will be replaced by "SV" on import.
- '"EN"',
- # I = Exported by
- '"%s"' % frappe.session.user,
- # J = Imported by -- stays empty
- "",
- # K = Tax consultant number (Beraternummer)
- datev_settings.get("consultant_number", "0000000"),
- # L = Tax client number (Mandantennummer)
- datev_settings.get("client_number", "00000"),
- # M = Start of the fiscal year (Wirtschaftsjahresbeginn)
- frappe.utils.formatdate(filters.get("fiscal_year_start"), "yyyyMMdd"),
- # N = Length of account numbers (Sachkontenlänge)
- str(filters.get("account_number_length", 4)),
- # O = Transaction batch start date (YYYYMMDD)
- frappe.utils.formatdate(filters.get("from_date"), "yyyyMMdd")
- if csv_class.DATA_CATEGORY == DataCategory.TRANSACTIONS
- else "",
- # P = Transaction batch end date (YYYYMMDD)
- frappe.utils.formatdate(filters.get("to_date"), "yyyyMMdd")
- if csv_class.DATA_CATEGORY == DataCategory.TRANSACTIONS
- else "",
- # Q = Description (for example, "Sales Invoice") Max. 30 chars
- '"{}"'.format(_(description)) if csv_class.DATA_CATEGORY == DataCategory.TRANSACTIONS else "",
- # R = Diktatkürzel
- "",
- # S = Buchungstyp
- # 1 = Transaction batch (Finanzbuchführung),
- # 2 = Annual financial statement (Jahresabschluss)
- "1" if csv_class.DATA_CATEGORY == DataCategory.TRANSACTIONS else "",
- # T = Rechnungslegungszweck
- # 0 oder leer = vom Rechnungslegungszweck unabhängig
- # 50 = Handelsrecht
- # 30 = Steuerrecht
- # 64 = IFRS
- # 40 = Kalkulatorik
- # 11 = Reserviert
- # 12 = Reserviert
- "0" if csv_class.DATA_CATEGORY == DataCategory.TRANSACTIONS else "",
- # U = Festschreibung
- # TODO: Filter by Accounting Period. In export for closed Accounting Period, this will be "1"
- "0",
- # V = Default currency, for example, "EUR"
- '"%s"' % default_currency if csv_class.DATA_CATEGORY == DataCategory.TRANSACTIONS else "",
- # reserviert
- "",
- # Derivatskennzeichen
- "",
- # reserviert
- "",
- # reserviert
- "",
- # SKR
- '"%s"' % coa_short_code,
- # Branchen-Lösungs-ID
- "",
- # reserviert
- "",
- # reserviert
- "",
- # Anwendungsinformation (Verarbeitungskennzeichen der abgebenden Anwendung)
- "",
- ]
- return header
-
-
-def zip_and_download(zip_filename, csv_files):
- """
- Put CSV files in a zip archive and send that to the client.
-
- Params:
- zip_filename Name of the zip file
- csv_files list of dicts [{'file_name': 'my_file.csv', 'csv_data': 'comma,separated,values'}]
- """
- zip_buffer = BytesIO()
-
- zip_file = zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED)
- for csv_file in csv_files:
- zip_file.writestr(csv_file.get("file_name"), csv_file.get("csv_data"))
-
- zip_file.close()
-
- frappe.response["filecontent"] = zip_buffer.getvalue()
- frappe.response["filename"] = zip_filename
- frappe.response["type"] = "binary"
diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js
index 348f0c6..17b018c 100644
--- a/erpnext/regional/india/e_invoice/einvoice.js
+++ b/erpnext/regional/india/e_invoice/einvoice.js
@@ -105,6 +105,30 @@
},
primary_action_label: __('Submit')
});
+ d.fields_dict.transporter.df.onchange = function () {
+ const transporter = d.fields_dict.transporter.value;
+ if (transporter) {
+ frappe.db.get_value('Supplier', transporter, ['gst_transporter_id', 'supplier_name'])
+ .then(({ message }) => {
+ d.set_value('gst_transporter_id', message.gst_transporter_id);
+ d.set_value('transporter_name', message.supplier_name);
+ });
+ } else {
+ d.set_value('gst_transporter_id', '');
+ d.set_value('transporter_name', '');
+ }
+ };
+ d.fields_dict.driver.df.onchange = function () {
+ const driver = d.fields_dict.driver.value;
+ if (driver) {
+ frappe.db.get_value('Driver', driver, ['full_name'])
+ .then(({ message }) => {
+ d.set_value('driver_name', message.full_name);
+ });
+ } else {
+ d.set_value('driver_name', '');
+ }
+ };
d.show();
};
@@ -153,7 +177,6 @@
'fieldname': 'gst_transporter_id',
'label': 'GST Transporter ID',
'fieldtype': 'Data',
- 'fetch_from': 'transporter.gst_transporter_id',
'default': frm.doc.gst_transporter_id
},
{
@@ -189,9 +212,9 @@
'fieldname': 'transporter_name',
'label': 'Transporter Name',
'fieldtype': 'Data',
- 'fetch_from': 'transporter.name',
'read_only': 1,
- 'default': frm.doc.transporter_name
+ 'default': frm.doc.transporter_name,
+ 'depends_on': 'transporter'
},
{
'fieldname': 'mode_of_transport',
@@ -206,7 +229,8 @@
'fieldtype': 'Data',
'fetch_from': 'driver.full_name',
'read_only': 1,
- 'default': frm.doc.driver_name
+ 'default': frm.doc.driver_name,
+ 'depends_on': 'driver'
},
{
'fieldname': 'lr_date',
diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py
index cbdec56..f317569 100644
--- a/erpnext/regional/india/e_invoice/utils.py
+++ b/erpnext/regional/india/e_invoice/utils.py
@@ -314,10 +314,14 @@
item.cess_rate += item_tax_rate
item.cess_amount += abs(item_tax_amount_after_discount)
- for tax_type in ["igst", "cgst", "sgst"]:
+ for tax_type in ["igst", "cgst", "sgst", "utgst"]:
if t.account_head in gst_accounts[f"{tax_type}_account"]:
item.tax_rate += item_tax_rate
- item[f"{tax_type}_amount"] += abs(item_tax_amount)
+ if tax_type == "utgst":
+ # utgst taxes are reported same as sgst tax
+ item["sgst_amount"] += abs(item_tax_amount)
+ else:
+ item[f"{tax_type}_amount"] += abs(item_tax_amount)
else:
# TODO: other charges per item
pass
@@ -359,11 +363,15 @@
# using after discount amt since item also uses after discount amt for cess calc
invoice_value_details.total_cess_amt += abs(t.base_tax_amount_after_discount_amount)
- for tax_type in ["igst", "cgst", "sgst"]:
+ for tax_type in ["igst", "cgst", "sgst", "utgst"]:
if t.account_head in gst_accounts[f"{tax_type}_account"]:
+ if tax_type == "utgst":
+ invoice_value_details["total_sgst_amt"] += abs(tax_amount)
+ else:
+ invoice_value_details[f"total_{tax_type}_amt"] += abs(tax_amount)
- invoice_value_details[f"total_{tax_type}_amt"] += abs(tax_amount)
update_other_charges(t, invoice_value_details, gst_accounts_list, invoice, considered_rows)
+
else:
invoice_value_details.total_other_charges += abs(tax_amount)
@@ -387,7 +395,7 @@
def get_payment_details(invoice):
payee_name = invoice.company
- mode_of_payment = ", ".join([d.mode_of_payment for d in invoice.payments])
+ mode_of_payment = ""
paid_amount = invoice.base_paid_amount
outstanding_amount = invoice.outstanding_amount
diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py
index 40fa6cd..446faaa 100644
--- a/erpnext/regional/india/setup.py
+++ b/erpnext/regional/india/setup.py
@@ -930,6 +930,7 @@
"Journal Entry": journal_entry_fields,
"Sales Order": sales_invoice_gst_fields,
"Tax Category": inter_state_gst_field,
+ "Quotation": sales_invoice_gst_fields,
"Item": [
dict(
fieldname="gst_hsn_code",
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index 47e6ae6..0b6fcc6 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -225,7 +225,7 @@
if not frappe.get_meta("Address").has_field("gst_state"):
return
- if doctype in ("Sales Invoice", "Delivery Note", "Sales Order"):
+ if doctype in ("Sales Invoice", "Delivery Note", "Sales Order", "Quotation"):
address_name = party_details.customer_address or party_details.shipping_address_name
elif doctype in ("Purchase Invoice", "Purchase Order", "Purchase Receipt"):
address_name = party_details.shipping_address or party_details.supplier_address
@@ -254,7 +254,7 @@
party_details.taxes = []
return party_details
- if doctype in ("Sales Invoice", "Delivery Note", "Sales Order"):
+ if doctype in ("Sales Invoice", "Delivery Note", "Sales Order", "Quotation"):
master_doctype = "Sales Taxes and Charges Template"
tax_template_by_category = get_tax_template_based_on_category(
master_doctype, company, party_details
@@ -268,6 +268,7 @@
if tax_template_by_category:
party_details["taxes_and_charges"] = tax_template_by_category
+ party_details["taxes"] = get_taxes_and_charges(master_doctype, tax_template_by_category)
return party_details
if not party_details.place_of_supply:
@@ -292,7 +293,7 @@
return party_details
party_details["taxes_and_charges"] = default_tax
- party_details.taxes = get_taxes_and_charges(master_doctype, default_tax)
+ party_details["taxes"] = get_taxes_and_charges(master_doctype, default_tax)
return party_details
@@ -310,7 +311,7 @@
def is_internal_transfer(party_details, doctype):
- if doctype in ("Sales Invoice", "Delivery Note", "Sales Order"):
+ if doctype in ("Sales Invoice", "Delivery Note", "Sales Order", "Quotation"):
destination_gstin = party_details.company_gstin
elif doctype in ("Purchase Invoice", "Purchase Order", "Purchase Receipt"):
destination_gstin = party_details.supplier_gstin
@@ -339,7 +340,7 @@
tax_categories = frappe.get_all(
"Tax Category",
fields=["name", "is_inter_state", "gst_state"],
- filters={"is_inter_state": is_inter_state, "is_reverse_charge": 0},
+ filters={"is_inter_state": is_inter_state, "is_reverse_charge": 0, "disabled": 0},
)
default_tax = ""
@@ -823,7 +824,7 @@
gst_settings_accounts = frappe.get_all(
"GST Account",
filters=filters,
- fields=["cgst_account", "sgst_account", "igst_account", "cess_account"],
+ fields=["cgst_account", "sgst_account", "igst_account", "cess_account", "utgst_account"],
)
if not gst_settings_accounts and not frappe.flags.in_test and not frappe.flags.in_migrate:
diff --git a/erpnext/regional/report/datev/__init__.py b/erpnext/regional/report/datev/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/erpnext/regional/report/datev/__init__.py
+++ /dev/null
diff --git a/erpnext/regional/report/datev/datev.js b/erpnext/regional/report/datev/datev.js
deleted file mode 100644
index 03c729e..0000000
--- a/erpnext/regional/report/datev/datev.js
+++ /dev/null
@@ -1,56 +0,0 @@
-frappe.query_reports["DATEV"] = {
- "filters": [
- {
- "fieldname": "company",
- "label": __("Company"),
- "fieldtype": "Link",
- "options": "Company",
- "default": frappe.defaults.get_user_default("Company") || frappe.defaults.get_global_default("Company"),
- "reqd": 1
- },
- {
- "fieldname": "from_date",
- "label": __("From Date"),
- "default": moment().subtract(1, 'month').startOf('month').format(),
- "fieldtype": "Date",
- "reqd": 1
- },
- {
- "fieldname": "to_date",
- "label": __("To Date"),
- "default": moment().subtract(1, 'month').endOf('month').format(),
- "fieldtype": "Date",
- "reqd": 1
- },
- {
- "fieldname": "voucher_type",
- "label": __("Voucher Type"),
- "fieldtype": "Select",
- "options": "\nSales Invoice\nPurchase Invoice\nPayment Entry\nExpense Claim\nPayroll Entry\nBank Reconciliation\nAsset\nStock Entry"
- }
- ],
- onload: function(query_report) {
- let company = frappe.query_report.get_filter_value('company');
- frappe.db.exists('DATEV Settings', company).then((settings_exist) => {
- if (!settings_exist) {
- frappe.confirm(__('DATEV Settings for your Company are missing. Would you like to create them now?'),
- () => frappe.new_doc('DATEV Settings', {'company': company})
- );
- }
- });
-
- query_report.page.add_menu_item(__("Download DATEV File"), () => {
- const filters = encodeURIComponent(
- JSON.stringify(
- query_report.get_values()
- )
- );
- window.open(`/api/method/erpnext.regional.report.datev.datev.download_datev_csv?filters=${filters}`);
- });
-
- query_report.page.add_menu_item(__("Change DATEV Settings"), () => {
- let company = frappe.query_report.get_filter_value('company'); // read company from filters again – it might have changed by now.
- frappe.set_route('Form', 'DATEV Settings', company);
- });
- }
-};
diff --git a/erpnext/regional/report/datev/datev.json b/erpnext/regional/report/datev/datev.json
deleted file mode 100644
index 94e3960..0000000
--- a/erpnext/regional/report/datev/datev.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "add_total_row": 0,
- "columns": [],
- "creation": "2019-04-24 08:45:16.650129",
- "disable_prepared_report": 0,
- "disabled": 0,
- "docstatus": 0,
- "doctype": "Report",
- "filters": [],
- "idx": 0,
- "is_standard": "Yes",
- "modified": "2021-04-06 12:23:00.379517",
- "modified_by": "Administrator",
- "module": "Regional",
- "name": "DATEV",
- "owner": "Administrator",
- "prepared_report": 0,
- "ref_doctype": "GL Entry",
- "report_name": "DATEV",
- "report_type": "Script Report",
- "roles": []
-}
\ No newline at end of file
diff --git a/erpnext/regional/report/datev/datev.py b/erpnext/regional/report/datev/datev.py
deleted file mode 100644
index 2d888a8..0000000
--- a/erpnext/regional/report/datev/datev.py
+++ /dev/null
@@ -1,570 +0,0 @@
-"""
-Provide a report and downloadable CSV according to the German DATEV format.
-
-- Query report showing only the columns that contain data, formatted nicely for
- dispay to the user.
-- CSV download functionality `download_datev_csv` that provides a CSV file with
- all required columns. Used to import the data into the DATEV Software.
-"""
-
-import json
-
-import frappe
-from frappe import _
-
-from erpnext.accounts.utils import get_fiscal_year
-from erpnext.regional.germany.utils.datev.datev_constants import (
- AccountNames,
- DebtorsCreditors,
- Transactions,
-)
-from erpnext.regional.germany.utils.datev.datev_csv import get_datev_csv, zip_and_download
-
-COLUMNS = [
- {
- "label": "Umsatz (ohne Soll/Haben-Kz)",
- "fieldname": "Umsatz (ohne Soll/Haben-Kz)",
- "fieldtype": "Currency",
- "width": 100,
- },
- {
- "label": "Soll/Haben-Kennzeichen",
- "fieldname": "Soll/Haben-Kennzeichen",
- "fieldtype": "Data",
- "width": 100,
- },
- {"label": "Konto", "fieldname": "Konto", "fieldtype": "Data", "width": 100},
- {
- "label": "Gegenkonto (ohne BU-Schlüssel)",
- "fieldname": "Gegenkonto (ohne BU-Schlüssel)",
- "fieldtype": "Data",
- "width": 100,
- },
- {"label": "BU-Schlüssel", "fieldname": "BU-Schlüssel", "fieldtype": "Data", "width": 100},
- {"label": "Belegdatum", "fieldname": "Belegdatum", "fieldtype": "Date", "width": 100},
- {"label": "Belegfeld 1", "fieldname": "Belegfeld 1", "fieldtype": "Data", "width": 150},
- {"label": "Buchungstext", "fieldname": "Buchungstext", "fieldtype": "Text", "width": 300},
- {
- "label": "Beleginfo - Art 1",
- "fieldname": "Beleginfo - Art 1",
- "fieldtype": "Link",
- "options": "DocType",
- "width": 100,
- },
- {
- "label": "Beleginfo - Inhalt 1",
- "fieldname": "Beleginfo - Inhalt 1",
- "fieldtype": "Dynamic Link",
- "options": "Beleginfo - Art 1",
- "width": 150,
- },
- {
- "label": "Beleginfo - Art 2",
- "fieldname": "Beleginfo - Art 2",
- "fieldtype": "Link",
- "options": "DocType",
- "width": 100,
- },
- {
- "label": "Beleginfo - Inhalt 2",
- "fieldname": "Beleginfo - Inhalt 2",
- "fieldtype": "Dynamic Link",
- "options": "Beleginfo - Art 2",
- "width": 150,
- },
- {
- "label": "Beleginfo - Art 3",
- "fieldname": "Beleginfo - Art 3",
- "fieldtype": "Link",
- "options": "DocType",
- "width": 100,
- },
- {
- "label": "Beleginfo - Inhalt 3",
- "fieldname": "Beleginfo - Inhalt 3",
- "fieldtype": "Dynamic Link",
- "options": "Beleginfo - Art 3",
- "width": 150,
- },
- {
- "label": "Beleginfo - Art 4",
- "fieldname": "Beleginfo - Art 4",
- "fieldtype": "Data",
- "width": 100,
- },
- {
- "label": "Beleginfo - Inhalt 4",
- "fieldname": "Beleginfo - Inhalt 4",
- "fieldtype": "Data",
- "width": 150,
- },
- {
- "label": "Beleginfo - Art 5",
- "fieldname": "Beleginfo - Art 5",
- "fieldtype": "Data",
- "width": 150,
- },
- {
- "label": "Beleginfo - Inhalt 5",
- "fieldname": "Beleginfo - Inhalt 5",
- "fieldtype": "Data",
- "width": 100,
- },
- {
- "label": "Beleginfo - Art 6",
- "fieldname": "Beleginfo - Art 6",
- "fieldtype": "Data",
- "width": 150,
- },
- {
- "label": "Beleginfo - Inhalt 6",
- "fieldname": "Beleginfo - Inhalt 6",
- "fieldtype": "Date",
- "width": 100,
- },
- {"label": "Fälligkeit", "fieldname": "Fälligkeit", "fieldtype": "Date", "width": 100},
-]
-
-
-def execute(filters=None):
- """Entry point for frappe."""
- data = []
- if filters and validate(filters):
- fn = "temporary_against_account_number"
- filters[fn] = frappe.get_value("DATEV Settings", filters.get("company"), fn)
- data = get_transactions(filters, as_dict=0)
-
- return COLUMNS, data
-
-
-def validate(filters):
- """Make sure all mandatory filters and settings are present."""
- company = filters.get("company")
- if not company:
- frappe.throw(_("<b>Company</b> is a mandatory filter."))
-
- from_date = filters.get("from_date")
- if not from_date:
- frappe.throw(_("<b>From Date</b> is a mandatory filter."))
-
- to_date = filters.get("to_date")
- if not to_date:
- frappe.throw(_("<b>To Date</b> is a mandatory filter."))
-
- validate_fiscal_year(from_date, to_date, company)
-
- if not frappe.db.exists("DATEV Settings", filters.get("company")):
- msg = "Please create DATEV Settings for Company {}".format(filters.get("company"))
- frappe.log_error(msg, title="DATEV Settings missing")
- return False
-
- return True
-
-
-def validate_fiscal_year(from_date, to_date, company):
- from_fiscal_year = get_fiscal_year(date=from_date, company=company)
- to_fiscal_year = get_fiscal_year(date=to_date, company=company)
- if from_fiscal_year != to_fiscal_year:
- frappe.throw(_("Dates {} and {} are not in the same fiscal year.").format(from_date, to_date))
-
-
-def get_transactions(filters, as_dict=1):
- def run(params_method, filters):
- extra_fields, extra_joins, extra_filters = params_method(filters)
- return run_query(filters, extra_fields, extra_joins, extra_filters, as_dict=as_dict)
-
- def sort_by(row):
- # "Belegdatum" is in the fifth column when list format is used
- return row["Belegdatum" if as_dict else 5]
-
- type_map = {
- # specific query methods for some voucher types
- "Payment Entry": get_payment_entry_params,
- "Sales Invoice": get_sales_invoice_params,
- "Purchase Invoice": get_purchase_invoice_params,
- }
-
- only_voucher_type = filters.get("voucher_type")
- transactions = []
-
- for voucher_type, get_voucher_params in type_map.items():
- if only_voucher_type and only_voucher_type != voucher_type:
- continue
-
- transactions.extend(run(params_method=get_voucher_params, filters=filters))
-
- if not only_voucher_type or only_voucher_type not in type_map:
- # generic query method for all other voucher types
- filters["exclude_voucher_types"] = type_map.keys()
- transactions.extend(run(params_method=get_generic_params, filters=filters))
-
- return sorted(transactions, key=sort_by)
-
-
-def get_payment_entry_params(filters):
- extra_fields = """
- , 'Zahlungsreferenz' as 'Beleginfo - Art 5'
- , pe.reference_no as 'Beleginfo - Inhalt 5'
- , 'Buchungstag' as 'Beleginfo - Art 6'
- , pe.reference_date as 'Beleginfo - Inhalt 6'
- , '' as 'Fälligkeit'
- """
-
- extra_joins = """
- LEFT JOIN `tabPayment Entry` pe
- ON gl.voucher_no = pe.name
- """
-
- extra_filters = """
- AND gl.voucher_type = 'Payment Entry'
- """
-
- return extra_fields, extra_joins, extra_filters
-
-
-def get_sales_invoice_params(filters):
- extra_fields = """
- , '' as 'Beleginfo - Art 5'
- , '' as 'Beleginfo - Inhalt 5'
- , '' as 'Beleginfo - Art 6'
- , '' as 'Beleginfo - Inhalt 6'
- , si.due_date as 'Fälligkeit'
- """
-
- extra_joins = """
- LEFT JOIN `tabSales Invoice` si
- ON gl.voucher_no = si.name
- """
-
- extra_filters = """
- AND gl.voucher_type = 'Sales Invoice'
- """
-
- return extra_fields, extra_joins, extra_filters
-
-
-def get_purchase_invoice_params(filters):
- extra_fields = """
- , 'Lieferanten-Rechnungsnummer' as 'Beleginfo - Art 5'
- , pi.bill_no as 'Beleginfo - Inhalt 5'
- , 'Lieferanten-Rechnungsdatum' as 'Beleginfo - Art 6'
- , pi.bill_date as 'Beleginfo - Inhalt 6'
- , pi.due_date as 'Fälligkeit'
- """
-
- extra_joins = """
- LEFT JOIN `tabPurchase Invoice` pi
- ON gl.voucher_no = pi.name
- """
-
- extra_filters = """
- AND gl.voucher_type = 'Purchase Invoice'
- """
-
- return extra_fields, extra_joins, extra_filters
-
-
-def get_generic_params(filters):
- # produce empty fields so all rows will have the same length
- extra_fields = """
- , '' as 'Beleginfo - Art 5'
- , '' as 'Beleginfo - Inhalt 5'
- , '' as 'Beleginfo - Art 6'
- , '' as 'Beleginfo - Inhalt 6'
- , '' as 'Fälligkeit'
- """
- extra_joins = ""
-
- if filters.get("exclude_voucher_types"):
- # exclude voucher types that are queried by a dedicated method
- exclude = "({})".format(
- ", ".join("'{}'".format(key) for key in filters.get("exclude_voucher_types"))
- )
- extra_filters = "AND gl.voucher_type NOT IN {}".format(exclude)
-
- # if voucher type filter is set, allow only this type
- if filters.get("voucher_type"):
- extra_filters += " AND gl.voucher_type = %(voucher_type)s"
-
- return extra_fields, extra_joins, extra_filters
-
-
-def run_query(filters, extra_fields, extra_joins, extra_filters, as_dict=1):
- """
- Get a list of accounting entries.
-
- Select GL Entries joined with Account and Party Account in order to get the
- account numbers. Returns a list of accounting entries.
-
- Arguments:
- filters -- dict of filters to be passed to the sql query
- as_dict -- return as list of dicts [0,1]
- """
- query = """
- SELECT
-
- /* either debit or credit amount; always positive */
- case gl.debit when 0 then gl.credit else gl.debit end as 'Umsatz (ohne Soll/Haben-Kz)',
-
- /* 'H' when credit, 'S' when debit */
- case gl.debit when 0 then 'H' else 'S' end as 'Soll/Haben-Kennzeichen',
-
- /* account number or, if empty, party account number */
- acc.account_number as 'Konto',
-
- /* against number or, if empty, party against number */
- %(temporary_against_account_number)s as 'Gegenkonto (ohne BU-Schlüssel)',
-
- '' as 'BU-Schlüssel',
-
- gl.posting_date as 'Belegdatum',
- gl.voucher_no as 'Belegfeld 1',
- 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',
- gl.against_voucher as 'Beleginfo - Inhalt 2',
- gl.party_type as 'Beleginfo - Art 3',
- gl.party as 'Beleginfo - Inhalt 3',
- case gl.party_type when 'Customer' then 'Debitorennummer' when 'Supplier' then 'Kreditorennummer' else NULL end as 'Beleginfo - Art 4',
- par.debtor_creditor_number as 'Beleginfo - Inhalt 4'
-
- {extra_fields}
-
- FROM `tabGL Entry` gl
-
- /* Kontonummer */
- LEFT JOIN `tabAccount` acc
- ON gl.account = acc.name
-
- LEFT JOIN `tabParty Account` par
- ON par.parent = gl.party
- AND par.parenttype = gl.party_type
- AND par.company = %(company)s
-
- {extra_joins}
-
- WHERE gl.company = %(company)s
- AND DATE(gl.posting_date) >= %(from_date)s
- AND DATE(gl.posting_date) <= %(to_date)s
-
- {extra_filters}
-
- ORDER BY 'Belegdatum', gl.voucher_no""".format(
- extra_fields=extra_fields, extra_joins=extra_joins, extra_filters=extra_filters
- )
-
- gl_entries = frappe.db.sql(query, filters, as_dict=as_dict)
-
- return gl_entries
-
-
-def get_customers(filters):
- """
- Get a list of Customers.
-
- Arguments:
- filters -- dict of filters to be passed to the sql query
- """
- return frappe.db.sql(
- """
- SELECT
-
- par.debtor_creditor_number as 'Konto',
- CASE cus.customer_type
- WHEN 'Company' THEN cus.customer_name
- ELSE null
- END as 'Name (Adressatentyp Unternehmen)',
- CASE cus.customer_type
- WHEN 'Individual' THEN TRIM(SUBSTR(cus.customer_name, LOCATE(' ', cus.customer_name)))
- ELSE null
- END as 'Name (Adressatentyp natürl. Person)',
- CASE cus.customer_type
- WHEN 'Individual' THEN SUBSTRING_INDEX(SUBSTRING_INDEX(cus.customer_name, ' ', 1), ' ', -1)
- ELSE null
- END as 'Vorname (Adressatentyp natürl. Person)',
- CASE cus.customer_type
- WHEN 'Individual' THEN '1'
- WHEN 'Company' THEN '2'
- ELSE '0'
- END as 'Adressatentyp',
- adr.address_line1 as 'Straße',
- adr.pincode as 'Postleitzahl',
- adr.city as 'Ort',
- UPPER(country.code) as 'Land',
- adr.address_line2 as 'Adresszusatz',
- adr.email_id as 'E-Mail',
- adr.phone as 'Telefon',
- adr.fax as 'Fax',
- cus.website as 'Internet',
- cus.tax_id as 'Steuernummer'
-
- FROM `tabCustomer` cus
-
- left join `tabParty Account` par
- on par.parent = cus.name
- and par.parenttype = 'Customer'
- and par.company = %(company)s
-
- left join `tabDynamic Link` dyn_adr
- on dyn_adr.link_name = cus.name
- and dyn_adr.link_doctype = 'Customer'
- and dyn_adr.parenttype = 'Address'
-
- left join `tabAddress` adr
- on adr.name = dyn_adr.parent
- and adr.is_primary_address = '1'
-
- left join `tabCountry` country
- on country.name = adr.country
-
- WHERE adr.is_primary_address = '1'
- """,
- filters,
- as_dict=1,
- )
-
-
-def get_suppliers(filters):
- """
- Get a list of Suppliers.
-
- Arguments:
- filters -- dict of filters to be passed to the sql query
- """
- return frappe.db.sql(
- """
- SELECT
-
- par.debtor_creditor_number as 'Konto',
- CASE sup.supplier_type
- WHEN 'Company' THEN sup.supplier_name
- ELSE null
- END as 'Name (Adressatentyp Unternehmen)',
- CASE sup.supplier_type
- WHEN 'Individual' THEN TRIM(SUBSTR(sup.supplier_name, LOCATE(' ', sup.supplier_name)))
- ELSE null
- END as 'Name (Adressatentyp natürl. Person)',
- CASE sup.supplier_type
- WHEN 'Individual' THEN SUBSTRING_INDEX(SUBSTRING_INDEX(sup.supplier_name, ' ', 1), ' ', -1)
- ELSE null
- END as 'Vorname (Adressatentyp natürl. Person)',
- CASE sup.supplier_type
- WHEN 'Individual' THEN '1'
- WHEN 'Company' THEN '2'
- ELSE '0'
- END as 'Adressatentyp',
- adr.address_line1 as 'Straße',
- adr.pincode as 'Postleitzahl',
- adr.city as 'Ort',
- UPPER(country.code) as 'Land',
- adr.address_line2 as 'Adresszusatz',
- adr.email_id as 'E-Mail',
- adr.phone as 'Telefon',
- adr.fax as 'Fax',
- sup.website as 'Internet',
- sup.tax_id as 'Steuernummer',
- case sup.on_hold when 1 then sup.release_date else null end as 'Zahlungssperre bis'
-
- FROM `tabSupplier` sup
-
- left join `tabParty Account` par
- on par.parent = sup.name
- and par.parenttype = 'Supplier'
- and par.company = %(company)s
-
- left join `tabDynamic Link` dyn_adr
- on dyn_adr.link_name = sup.name
- and dyn_adr.link_doctype = 'Supplier'
- and dyn_adr.parenttype = 'Address'
-
- left join `tabAddress` adr
- on adr.name = dyn_adr.parent
- and adr.is_primary_address = '1'
-
- left join `tabCountry` country
- on country.name = adr.country
-
- WHERE adr.is_primary_address = '1'
- """,
- filters,
- as_dict=1,
- )
-
-
-def get_account_names(filters):
- return frappe.db.sql(
- """
- SELECT
-
- account_number as 'Konto',
- LEFT(account_name, 40) as 'Kontenbeschriftung',
- 'de-DE' as 'Sprach-ID'
-
- FROM `tabAccount`
- WHERE company = %(company)s
- AND is_group = 0
- AND account_number != ''
- """,
- filters,
- as_dict=1,
- )
-
-
-@frappe.whitelist()
-def download_datev_csv(filters):
- """
- Provide accounting entries for download in DATEV format.
-
- Validate the filters, get the data, produce the CSV file and provide it for
- download. Can be called like this:
-
- GET /api/method/erpnext.regional.report.datev.datev.download_datev_csv
-
- Arguments / Params:
- filters -- dict of filters to be passed to the sql query
- """
- if isinstance(filters, str):
- filters = json.loads(filters)
-
- validate(filters)
- company = filters.get("company")
-
- fiscal_year = get_fiscal_year(date=filters.get("from_date"), company=company)
- filters["fiscal_year_start"] = fiscal_year[1]
-
- # set chart of accounts used
- coa = frappe.get_value("Company", company, "chart_of_accounts")
- filters["skr"] = "04" if "SKR04" in coa else ("03" if "SKR03" in coa else "")
-
- datev_settings = frappe.get_doc("DATEV Settings", company)
- filters["account_number_length"] = datev_settings.account_number_length
- filters["temporary_against_account_number"] = datev_settings.temporary_against_account_number
-
- transactions = get_transactions(filters)
- account_names = get_account_names(filters)
- customers = get_customers(filters)
- suppliers = get_suppliers(filters)
-
- zip_name = "{} DATEV.zip".format(frappe.utils.datetime.date.today())
- zip_and_download(
- zip_name,
- [
- {
- "file_name": "EXTF_Buchungsstapel.csv",
- "csv_data": get_datev_csv(transactions, filters, csv_class=Transactions),
- },
- {
- "file_name": "EXTF_Kontenbeschriftungen.csv",
- "csv_data": get_datev_csv(account_names, filters, csv_class=AccountNames),
- },
- {
- "file_name": "EXTF_Kunden.csv",
- "csv_data": get_datev_csv(customers, filters, csv_class=DebtorsCreditors),
- },
- {
- "file_name": "EXTF_Lieferanten.csv",
- "csv_data": get_datev_csv(suppliers, filters, csv_class=DebtorsCreditors),
- },
- ],
- )
diff --git a/erpnext/regional/report/datev/test_datev.py b/erpnext/regional/report/datev/test_datev.py
deleted file mode 100644
index 0df8c06..0000000
--- a/erpnext/regional/report/datev/test_datev.py
+++ /dev/null
@@ -1,252 +0,0 @@
-import zipfile
-from io import BytesIO
-from unittest import TestCase
-
-import frappe
-from frappe.utils import cstr, now_datetime, today
-
-from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
-from erpnext.regional.germany.utils.datev.datev_constants import (
- AccountNames,
- DebtorsCreditors,
- Transactions,
-)
-from erpnext.regional.germany.utils.datev.datev_csv import get_datev_csv, get_header
-from erpnext.regional.report.datev.datev import (
- download_datev_csv,
- get_account_names,
- get_customers,
- get_suppliers,
- get_transactions,
-)
-
-
-def make_company(company_name, abbr):
- if not frappe.db.exists("Company", company_name):
- company = frappe.get_doc(
- {
- "doctype": "Company",
- "company_name": company_name,
- "abbr": abbr,
- "default_currency": "EUR",
- "country": "Germany",
- "create_chart_of_accounts_based_on": "Standard Template",
- "chart_of_accounts": "SKR04 mit Kontonummern",
- }
- )
- company.insert()
- else:
- company = frappe.get_doc("Company", company_name)
-
- # indempotent
- company.create_default_warehouses()
-
- if not frappe.db.get_value("Cost Center", {"is_group": 0, "company": company.name}):
- company.create_default_cost_center()
-
- company.save()
- return company
-
-
-def setup_fiscal_year():
- fiscal_year = None
- year = cstr(now_datetime().year)
- if not frappe.db.get_value("Fiscal Year", {"year": year}, "name"):
- try:
- fiscal_year = frappe.get_doc(
- {
- "doctype": "Fiscal Year",
- "year": year,
- "year_start_date": "{0}-01-01".format(year),
- "year_end_date": "{0}-12-31".format(year),
- }
- )
- fiscal_year.insert()
- except frappe.NameError:
- pass
-
- if fiscal_year:
- fiscal_year.set_as_default()
-
-
-def make_customer_with_account(customer_name, company):
- acc_name = frappe.db.get_value(
- "Account", {"account_name": customer_name, "company": company.name}, "name"
- )
-
- if not acc_name:
- acc = frappe.get_doc(
- {
- "doctype": "Account",
- "parent_account": "1 - Forderungen aus Lieferungen und Leistungen - _TG",
- "account_name": customer_name,
- "company": company.name,
- "account_type": "Receivable",
- "account_number": "10001",
- }
- )
- acc.insert()
- acc_name = acc.name
-
- if not frappe.db.exists("Customer", customer_name):
- customer = frappe.get_doc(
- {
- "doctype": "Customer",
- "customer_name": customer_name,
- "customer_type": "Company",
- "accounts": [{"company": company.name, "account": acc_name}],
- }
- )
- customer.insert()
- else:
- customer = frappe.get_doc("Customer", customer_name)
-
- return customer
-
-
-def make_item(item_code, company):
- warehouse_name = frappe.db.get_value(
- "Warehouse", {"warehouse_name": "Stores", "company": company.name}, "name"
- )
-
- if not frappe.db.exists("Item", item_code):
- item = frappe.get_doc(
- {
- "doctype": "Item",
- "item_code": item_code,
- "item_name": item_code,
- "description": item_code,
- "item_group": "All Item Groups",
- "is_stock_item": 0,
- "is_purchase_item": 0,
- "is_customer_provided_item": 0,
- "item_defaults": [{"default_warehouse": warehouse_name, "company": company.name}],
- }
- )
- item.insert()
- else:
- item = frappe.get_doc("Item", item_code)
- return item
-
-
-def make_datev_settings(company):
- if not frappe.db.exists("DATEV Settings", company.name):
- frappe.get_doc(
- {
- "doctype": "DATEV Settings",
- "client": company.name,
- "client_number": "12345",
- "consultant_number": "67890",
- "temporary_against_account_number": "9999",
- }
- ).insert()
-
-
-class TestDatev(TestCase):
- def setUp(self):
- self.company = make_company("_Test GmbH", "_TG")
- self.customer = make_customer_with_account("_Test Kunde GmbH", self.company)
- self.filters = {
- "company": self.company.name,
- "from_date": today(),
- "to_date": today(),
- "temporary_against_account_number": "9999",
- }
-
- make_datev_settings(self.company)
- item = make_item("_Test Item", self.company)
- setup_fiscal_year()
-
- warehouse = frappe.db.get_value(
- "Item Default", {"parent": item.name, "company": self.company.name}, "default_warehouse"
- )
-
- income_account = frappe.db.get_value(
- "Account", {"account_number": "4200", "company": self.company.name}, "name"
- )
-
- tax_account = frappe.db.get_value(
- "Account", {"account_number": "3806", "company": self.company.name}, "name"
- )
-
- si = create_sales_invoice(
- company=self.company.name,
- customer=self.customer.name,
- currency=self.company.default_currency,
- debit_to=self.customer.accounts[0].account,
- income_account="4200 - Erlöse - _TG",
- expense_account="6990 - Herstellungskosten - _TG",
- cost_center=self.company.cost_center,
- warehouse=warehouse,
- item=item.name,
- do_not_save=1,
- )
-
- si.append(
- "taxes",
- {
- "charge_type": "On Net Total",
- "account_head": tax_account,
- "description": "Umsatzsteuer 19 %",
- "rate": 19,
- "cost_center": self.company.cost_center,
- },
- )
-
- si.cost_center = self.company.cost_center
-
- si.save()
- si.submit()
-
- def test_columns(self):
- def is_subset(get_data, allowed_keys):
- """
- Validate that the dict contains only allowed keys.
-
- Params:
- get_data -- Function that returns a list of dicts.
- allowed_keys -- List of allowed keys
- """
- data = get_data(self.filters)
- if data == []:
- # No data and, therefore, no columns is okay
- return True
- actual_set = set(data[0].keys())
- # allowed set must be interpreted as unicode to match the actual set
- allowed_set = set({frappe.as_unicode(key) for key in allowed_keys})
- return actual_set.issubset(allowed_set)
-
- self.assertTrue(is_subset(get_transactions, Transactions.COLUMNS))
- self.assertTrue(is_subset(get_customers, DebtorsCreditors.COLUMNS))
- self.assertTrue(is_subset(get_suppliers, DebtorsCreditors.COLUMNS))
- self.assertTrue(is_subset(get_account_names, AccountNames.COLUMNS))
-
- def test_header(self):
- self.assertTrue(Transactions.DATA_CATEGORY in get_header(self.filters, Transactions))
- self.assertTrue(AccountNames.DATA_CATEGORY in get_header(self.filters, AccountNames))
- self.assertTrue(DebtorsCreditors.DATA_CATEGORY in get_header(self.filters, DebtorsCreditors))
-
- def test_csv(self):
- test_data = [
- {
- "Umsatz (ohne Soll/Haben-Kz)": 100,
- "Soll/Haben-Kennzeichen": "H",
- "Kontonummer": "4200",
- "Gegenkonto (ohne BU-Schlüssel)": "10000",
- "Belegdatum": today(),
- "Buchungstext": "No remark",
- "Beleginfo - Art 1": "Sales Invoice",
- "Beleginfo - Inhalt 1": "SINV-0001",
- }
- ]
- get_datev_csv(data=test_data, filters=self.filters, csv_class=Transactions)
-
- def test_download(self):
- """Assert that the returned file is a ZIP file."""
- download_datev_csv(self.filters)
-
- # zipfile.is_zipfile() expects a file-like object
- zip_buffer = BytesIO()
- zip_buffer.write(frappe.response["filecontent"])
-
- self.assertTrue(zipfile.is_zipfile(zip_buffer))
diff --git a/erpnext/regional/report/gstr_1/gstr_1.js b/erpnext/regional/report/gstr_1/gstr_1.js
index 9999a6d..943bd2c 100644
--- a/erpnext/regional/report/gstr_1/gstr_1.js
+++ b/erpnext/regional/report/gstr_1/gstr_1.js
@@ -78,8 +78,9 @@
}
});
-
report.page.add_inner_button(__("Download as JSON"), function () {
+ let filters = report.get_values();
+
frappe.call({
method: 'erpnext.regional.report.gstr_1.gstr_1.get_json',
args: {
diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json
index ae40630..7cba141 100644
--- a/erpnext/selling/doctype/customer/customer.json
+++ b/erpnext/selling/doctype/customer/customer.json
@@ -15,23 +15,23 @@
"salutation",
"customer_name",
"gender",
- "customer_type",
- "tax_withholding_category",
"default_bank_account",
+ "tax_id",
+ "tax_category",
+ "tax_withholding_category",
"lead_name",
"opportunity_name",
"image",
"column_break0",
- "account_manager",
"customer_group",
+ "customer_type",
"territory",
- "tax_id",
- "tax_category",
+ "account_manager",
"so_required",
"dn_required",
- "disabled",
"is_internal_customer",
"represents_company",
+ "disabled",
"allowed_to_transact_section",
"companies",
"currency_and_price_list",
@@ -40,7 +40,6 @@
"default_price_list",
"address_contacts",
"address_html",
- "website",
"column_break1",
"contact_html",
"primary_address_and_contact_detail",
@@ -60,6 +59,7 @@
"column_break_45",
"market_segment",
"industry",
+ "website",
"language",
"is_frozen",
"column_break_38",
@@ -100,7 +100,7 @@
"fieldname": "customer_name",
"fieldtype": "Data",
"in_global_search": 1,
- "label": "Full Name",
+ "label": "Customer Name",
"no_copy": 1,
"oldfieldname": "customer_name",
"oldfieldtype": "Data",
@@ -118,7 +118,7 @@
"default": "Company",
"fieldname": "customer_type",
"fieldtype": "Select",
- "label": "Type",
+ "label": "Customer Type",
"oldfieldname": "customer_type",
"oldfieldtype": "Select",
"options": "Company\nIndividual",
@@ -337,7 +337,7 @@
"collapsible": 1,
"fieldname": "default_receivable_accounts",
"fieldtype": "Section Break",
- "label": "Accounting"
+ "label": "Default Receivable Accounts"
},
{
"description": "Mention if non-standard receivable account",
@@ -511,7 +511,7 @@
"link_fieldname": "party"
}
],
- "modified": "2021-10-20 22:07:52.485809",
+ "modified": "2022-04-16 20:32:34.000304",
"modified_by": "Administrator",
"module": "Selling",
"name": "Customer",
@@ -595,6 +595,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"title_field": "customer_name",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index 2e5cbb8..8889a5f 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -100,7 +100,8 @@
@frappe.whitelist()
def get_customer_group_details(self):
doc = frappe.get_doc("Customer Group", self.customer_group)
- self.accounts = self.credit_limits = []
+ self.accounts = []
+ self.credit_limits = []
self.payment_terms = self.default_price_list = ""
tables = [["accounts", "account"], ["credit_limits", "credit_limit"]]
diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py
index 4027d2e..36ca2b2 100644
--- a/erpnext/selling/doctype/customer/test_customer.py
+++ b/erpnext/selling/doctype/customer/test_customer.py
@@ -45,7 +45,8 @@
c_doc.customer_name = "Testing Customer"
c_doc.customer_group = "_Testing Customer Group"
c_doc.payment_terms = c_doc.default_price_list = ""
- c_doc.accounts = c_doc.credit_limits = []
+ c_doc.accounts = []
+ c_doc.credit_limits = []
c_doc.insert()
c_doc.get_customer_group_details()
self.assertEqual(c_doc.payment_terms, "_Test Payment Term Template 3")
diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json
index ee5b0ea..75443ab 100644
--- a/erpnext/selling/doctype/quotation/quotation.json
+++ b/erpnext/selling/doctype/quotation/quotation.json
@@ -31,6 +31,8 @@
"col_break98",
"shipping_address_name",
"shipping_address",
+ "company_address",
+ "company_address_display",
"customer_group",
"territory",
"currency_and_price_list",
@@ -41,6 +43,8 @@
"price_list_currency",
"plc_conversion_rate",
"ignore_pricing_rule",
+ "section_break_33",
+ "scan_barcode",
"items_section",
"items",
"bundle_items_section",
@@ -953,15 +957,36 @@
"fieldname": "competitors",
"fieldtype": "Table MultiSelect",
"label": "Competitors",
- "options": "Competitor Detail",
+ "options": "Competitor Detail"
+ },
+ {
+ "fieldname": "company_address",
+ "fieldtype": "Link",
+ "label": "Company Address Name",
+ "options": "Address"
+ },
+ {
+ "fieldname": "company_address_display",
+ "fieldtype": "Small Text",
+ "label": "Company Address",
"read_only": 1
+ },
+ {
+ "fieldname": "section_break_33",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "scan_barcode",
+ "fieldtype": "Data",
+ "label": "Scan Barcode",
+ "options": "Barcode"
}
],
"icon": "fa fa-shopping-cart",
"idx": 82,
"is_submittable": 1,
"links": [],
- "modified": "2021-11-30 01:33:21.106073",
+ "modified": "2022-04-07 11:01:31.157084",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",
@@ -1056,6 +1081,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "party_name",
"title_field": "title"
}
\ No newline at end of file
diff --git a/erpnext/selling/doctype/quotation/regional/india.js b/erpnext/selling/doctype/quotation/regional/india.js
new file mode 100644
index 0000000..9550835
--- /dev/null
+++ b/erpnext/selling/doctype/quotation/regional/india.js
@@ -0,0 +1,3 @@
+{% include "erpnext/regional/india/taxes.js" %}
+
+erpnext.setup_auto_gst_taxation('Quotation');
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 87f277f..0b48f70 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -727,7 +727,7 @@
args: {
reference_doctype: me.frm.doctype,
reference_name: me.frm.docname,
- content: __('Reason for hold: ')+data.reason_for_hold,
+ content: __('Reason for hold:') + ' ' + data.reason_for_hold,
comment_email: frappe.session.user,
comment_by: frappe.session.user_fullname
},
diff --git a/erpnext/selling/doctype/selling_settings/test_selling_settings.py b/erpnext/selling/doctype/selling_settings/test_selling_settings.py
index fc6754a..7290e68 100644
--- a/erpnext/selling/doctype/selling_settings/test_selling_settings.py
+++ b/erpnext/selling/doctype/selling_settings/test_selling_settings.py
@@ -1,9 +1,14 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-# import frappe
import unittest
+import frappe
+
class TestSellingSettings(unittest.TestCase):
- pass
+ def test_defaults_populated(self):
+ # Setup default values are not populated on migrate, this test checks
+ # if setup was completed correctly
+ default = frappe.db.get_single_value("Selling Settings", "maintain_same_rate_action")
+ self.assertEqual("Stop", default)
diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py
index bf62982..99afe81 100644
--- a/erpnext/selling/page/point_of_sale/point_of_sale.py
+++ b/erpnext/selling/page/point_of_sale/point_of_sale.py
@@ -3,12 +3,14 @@
import json
+from typing import Dict, Optional
import frappe
from frappe.utils.nestedset import get_root_of
from erpnext.accounts.doctype.pos_invoice.pos_invoice import get_stock_availability
from erpnext.accounts.doctype.pos_profile.pos_profile import get_child_nodes, get_item_groups
+from erpnext.stock.utils import scan_barcode
def search_by_term(search_term, warehouse, price_list):
@@ -150,29 +152,8 @@
@frappe.whitelist()
-def search_for_serial_or_batch_or_barcode_number(search_value):
- # search barcode no
- barcode_data = frappe.db.get_value(
- "Item Barcode", {"barcode": search_value}, ["barcode", "parent as item_code"], as_dict=True
- )
- if barcode_data:
- return barcode_data
-
- # search serial no
- serial_no_data = frappe.db.get_value(
- "Serial No", search_value, ["name as serial_no", "item_code"], as_dict=True
- )
- if serial_no_data:
- return serial_no_data
-
- # search batch no
- batch_no_data = frappe.db.get_value(
- "Batch", search_value, ["name as batch_no", "item as item_code"], as_dict=True
- )
- if batch_no_data:
- return batch_no_data
-
- return {}
+def search_for_serial_or_batch_or_barcode_number(search_value: str) -> Dict[str, Optional[str]]:
+ return scan_barcode(search_value)
def get_conditions(search_term):
diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js
index 6974bed..65e0cbb 100644
--- a/erpnext/selling/page/point_of_sale/pos_controller.js
+++ b/erpnext/selling/page/point_of_sale/pos_controller.js
@@ -721,11 +721,14 @@
async save_and_checkout() {
if (this.frm.is_dirty()) {
+ let save_error = false;
+ await this.frm.save(null, null, null, () => save_error = true);
// only move to payment section if save is successful
- frappe.route_hooks.after_save = () => this.payment.checkout();
- return this.frm.save(
- null, null, null, () => this.cart.toggle_checkout_btn(true) // show checkout button on error
- );
+ !save_error && this.payment.checkout();
+ // show checkout button on error
+ save_error && setTimeout(() => {
+ this.cart.toggle_checkout_btn(true);
+ }, 300); // wait for save to finish
} else {
this.payment.checkout();
}
diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js
index 1177615..b62b27b 100644
--- a/erpnext/selling/page/point_of_sale/pos_item_selector.js
+++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js
@@ -243,7 +243,7 @@
value: "+1",
item: { item_code, batch_no, serial_no, uom, rate }
});
- me.set_search_value('');
+ me.search_field.set_focus();
});
this.search_field.$input.on('input', (e) => {
@@ -328,6 +328,7 @@
add_filtered_item_to_cart() {
this.$items_container.find(".item-wrapper").click();
+ this.set_search_value('');
}
resize_selector(minimize) {
diff --git a/erpnext/selling/page/sales_funnel/sales_funnel.py b/erpnext/selling/page/sales_funnel/sales_funnel.py
index c626f5b..6b33a71 100644
--- a/erpnext/selling/page/sales_funnel/sales_funnel.py
+++ b/erpnext/selling/page/sales_funnel/sales_funnel.py
@@ -1,10 +1,11 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
+from itertools import groupby
import frappe
-import pandas as pd
from frappe import _
+from frappe.utils import flt
from erpnext.accounts.report.utils import convert
@@ -89,28 +90,21 @@
for x in opportunities
]
- df = (
- pd.DataFrame(cp_opportunities)
- .groupby(["source", "sales_stage"], as_index=False)
- .agg({"compound_amount": "sum"})
- )
+ summary = {}
+ sales_stages = set()
+ group_key = lambda o: (o["source"], o["sales_stage"]) # noqa
+ for (source, sales_stage), rows in groupby(cp_opportunities, group_key):
+ summary.setdefault(source, {})[sales_stage] = sum(r["compound_amount"] for r in rows)
+ sales_stages.add(sales_stage)
- result = {}
- result["labels"] = list(set(df.source.values))
- result["datasets"] = []
+ pivot_table = []
+ for sales_stage in sales_stages:
+ row = []
+ for source, sales_stage_values in summary.items():
+ row.append(flt(sales_stage_values.get(sales_stage)))
+ pivot_table.append({"chartType": "bar", "name": sales_stage, "values": row})
- for s in set(df.sales_stage.values):
- result["datasets"].append(
- {"name": s, "values": [0] * len(result["labels"]), "chartType": "bar"}
- )
-
- for row in df.itertuples():
- source_index = result["labels"].index(row.source)
-
- for dataset in result["datasets"]:
- if dataset["name"] == row.sales_stage:
- dataset["values"][source_index] = row.compound_amount
-
+ result = {"datasets": pivot_table, "labels": list(summary.keys())}
return result
else:
@@ -148,20 +142,14 @@
for x in opportunities
]
- df = (
- pd.DataFrame(cp_opportunities)
- .groupby(["sales_stage"], as_index=True)
- .agg({"compound_amount": "sum"})
- .to_dict()
- )
+ summary = {}
+ for sales_stage, rows in groupby(cp_opportunities, lambda o: o["sales_stage"]):
+ summary[sales_stage] = sum(flt(r["compound_amount"]) for r in rows)
- result = {}
- result["labels"] = df["compound_amount"].keys()
- result["datasets"] = []
- result["datasets"].append(
- {"name": _("Total Amount"), "values": df["compound_amount"].values(), "chartType": "bar"}
- )
-
+ result = {
+ "labels": list(summary.keys()),
+ "datasets": [{"name": _("Total Amount"), "values": list(summary.values()), "chartType": "bar"}],
+ }
return result
else:
diff --git a/erpnext/selling/report/customer_acquisition_and_loyalty/customer_acquisition_and_loyalty.py b/erpnext/selling/report/customer_acquisition_and_loyalty/customer_acquisition_and_loyalty.py
index 33badc3..3e4bfb2 100644
--- a/erpnext/selling/report/customer_acquisition_and_loyalty/customer_acquisition_and_loyalty.py
+++ b/erpnext/selling/report/customer_acquisition_and_loyalty/customer_acquisition_and_loyalty.py
@@ -102,7 +102,7 @@
def get_data_by_territory(filters, common_columns):
columns = [
{
- "label": "Territory",
+ "label": _("Territory"),
"fieldname": "territory",
"fieldtype": "Link",
"options": "Territory",
diff --git a/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py b/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py
index 1c10a37..98633cb 100644
--- a/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py
+++ b/erpnext/selling/report/customer_credit_balance/customer_credit_balance.py
@@ -65,7 +65,7 @@
_("Credit Limit") + ":Currency:120",
_("Outstanding Amt") + ":Currency:100",
_("Credit Balance") + ":Currency:120",
- _("Bypass credit check at Sales Order ") + ":Check:80",
+ _("Bypass credit check at Sales Order") + ":Check:80",
_("Is Frozen") + ":Check:80",
_("Disabled") + ":Check:80",
]
diff --git a/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py b/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py
index 12ca7b3..091c20c 100644
--- a/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py
+++ b/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py
@@ -235,7 +235,7 @@
return {
"data": {
"labels": labels[:30], # show max of 30 items in chart
- "datasets": [{"name": _(" Total Sales Amount"), "values": datapoints[:30]}],
+ "datasets": [{"name": _("Total Sales Amount"), "values": datapoints[:30]}],
},
"type": "bar",
}
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
index 0e36b3f..c068ae3 100644
--- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
@@ -27,28 +27,55 @@
"default": frappe.datetime.get_today()
},
{
- "fieldname":"sales_order",
- "label": __("Sales Order"),
- "fieldtype": "MultiSelectList",
+ "fieldname":"customer_group",
+ "label": __("Customer Group"),
+ "fieldtype": "Link",
"width": 100,
- "options": "Sales Order",
- "get_data": function(txt) {
- return frappe.db.get_link_options("Sales Order", txt, this.filters());
- },
- "filters": () => {
- return {
- docstatus: 1,
- payment_terms_template: ['not in', ['']],
- company: frappe.query_report.get_filter_value("company"),
- transaction_date: ['between', [frappe.query_report.get_filter_value("period_start_date"), frappe.query_report.get_filter_value("period_end_date")]]
+ "options": "Customer Group",
+ },
+ {
+ "fieldname":"customer",
+ "label": __("Customer"),
+ "fieldtype": "Link",
+ "width": 100,
+ "options": "Customer",
+ "get_query": () => {
+ var customer_group = frappe.query_report.get_filter_value('customer_group');
+ return{
+ "query": "erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order.get_customers_or_items",
+ "filters": [
+ ['Customer', 'disabled', '=', '0'],
+ ['Customer Group','name', '=', customer_group]
+ ]
}
- },
- on_change: function(){
- frappe.query_report.refresh();
+ }
+ },
+ {
+ "fieldname":"item_group",
+ "label": __("Item Group"),
+ "fieldtype": "Link",
+ "width": 100,
+ "options": "Item Group",
+
+ },
+ {
+ "fieldname":"item",
+ "label": __("Item"),
+ "fieldtype": "Link",
+ "width": 100,
+ "options": "Item",
+ "get_query": () => {
+ var item_group = frappe.query_report.get_filter_value('item_group');
+ return{
+ "query": "erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order.get_customers_or_items",
+ "filters": [
+ ['Item', 'disabled', '=', '0'],
+ ['Item Group','name', '=', item_group]
+ ]
+ }
}
}
]
-
return filters;
}
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
index 7f797f6..cb22fb6 100644
--- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
@@ -3,7 +3,7 @@
import frappe
from frappe import _, qb, query_builder
-from frappe.query_builder import functions
+from frappe.query_builder import Criterion, functions
def get_columns():
@@ -15,6 +15,12 @@
"options": "Sales Order",
},
{
+ "label": _("Customer"),
+ "fieldname": "customer",
+ "fieldtype": "Link",
+ "options": "Customer",
+ },
+ {
"label": _("Posting Date"),
"fieldname": "submitted",
"fieldtype": "Date",
@@ -67,6 +73,55 @@
return columns
+def get_descendants_of(doctype, group_name):
+ group_doc = qb.DocType(doctype)
+ # get lft and rgt of group node
+ lft, rgt = (
+ qb.from_(group_doc).select(group_doc.lft, group_doc.rgt).where(group_doc.name == group_name)
+ ).run()[0]
+
+ # get all children of group node
+ query = (
+ qb.from_(group_doc).select(group_doc.name).where((group_doc.lft >= lft) & (group_doc.rgt <= rgt))
+ )
+
+ child_nodes = []
+ for x in query.run():
+ child_nodes.append(x[0])
+
+ return child_nodes
+
+
+@frappe.whitelist()
+@frappe.validate_and_sanitize_search_inputs
+def get_customers_or_items(doctype, txt, searchfield, start, page_len, filters):
+ filter_list = []
+ if isinstance(filters, list):
+ for item in filters:
+ if item[0] == doctype:
+ filter_list.append(item)
+ elif item[0] == "Customer Group":
+ if item[3] != "":
+ filter_list.append(
+ [doctype, "customer_group", "in", get_descendants_of("Customer Group", item[3])]
+ )
+ elif item[0] == "Item Group":
+ if item[3] != "":
+ filter_list.append([doctype, "item_group", "in", get_descendants_of("Item Group", item[3])])
+
+ if searchfield and txt:
+ filter_list.append([doctype, searchfield, "like", "%%%s%%" % txt])
+
+ return frappe.desk.reportview.execute(
+ doctype,
+ filters=filter_list,
+ fields=["name", "customer_group"] if doctype == "Customer" else ["name", "item_group"],
+ limit_start=start,
+ limit_page_length=page_len,
+ as_list=True,
+ )
+
+
def get_conditions(filters):
"""
Convert filter options to conditions used in query
@@ -79,11 +134,37 @@
conditions.start_date = filters.period_start_date or frappe.utils.add_months(
conditions.end_date, -1
)
- conditions.sales_order = filters.sales_order or []
return conditions
+def build_filter_criterions(filters):
+ filters = frappe._dict(filters) if filters else frappe._dict({})
+ qb_criterions = []
+
+ if filters.customer_group:
+ qb_criterions.append(
+ qb.DocType("Sales Order").customer_group.isin(
+ get_descendants_of("Customer Group", filters.customer_group)
+ )
+ )
+
+ if filters.customer:
+ qb_criterions.append(qb.DocType("Sales Order").customer == filters.customer)
+
+ if filters.item_group:
+ qb_criterions.append(
+ qb.DocType("Sales Order Item").item_group.isin(
+ get_descendants_of("Item Group", filters.item_group)
+ )
+ )
+
+ if filters.item:
+ qb_criterions.append(qb.DocType("Sales Order Item").item_code == filters.item)
+
+ return qb_criterions
+
+
def get_so_with_invoices(filters):
"""
Get Sales Order with payment terms template with their associated Invoices
@@ -92,16 +173,23 @@
so = qb.DocType("Sales Order")
ps = qb.DocType("Payment Schedule")
+ soi = qb.DocType("Sales Order Item")
+
+ conditions = get_conditions(filters)
+ filter_criterions = build_filter_criterions(filters)
+
datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"])
ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
- conditions = get_conditions(filters)
query_so = (
qb.from_(so)
+ .join(soi)
+ .on(soi.parent == so.name)
.join(ps)
.on(ps.parent == so.name)
.select(
so.name,
+ so.customer,
so.transaction_date.as_("submitted"),
ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"),
ps.payment_term,
@@ -117,12 +205,10 @@
& (so.company == conditions.company)
& (so.transaction_date[conditions.start_date : conditions.end_date])
)
+ .where(Criterion.all(filter_criterions))
.orderby(so.name, so.transaction_date, ps.due_date)
)
- if conditions.sales_order != []:
- query_so = query_so.where(so.name.isin(conditions.sales_order))
-
sorders = query_so.run(as_dict=True)
invoices = []
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
index 89940a6..9d542f5 100644
--- a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
@@ -11,10 +11,13 @@
)
from erpnext.stock.doctype.item.test_item import create_item
-test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template"]
+test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template", "Customer"]
class TestPaymentTermsStatusForSalesOrder(FrappeTestCase):
+ def tearDown(self):
+ frappe.db.rollback()
+
def create_payment_terms_template(self):
# create template for 50-50 payments
template = None
@@ -48,9 +51,9 @@
template.insert()
self.template = template
- def test_payment_terms_status(self):
+ def test_01_payment_terms_status(self):
self.create_payment_terms_template()
- item = create_item(item_code="_Test Excavator", is_stock_item=0)
+ item = create_item(item_code="_Test Excavator 1", is_stock_item=0)
so = make_sales_order(
transaction_date="2021-06-15",
delivery_date=add_days("2021-06-15", -30),
@@ -78,13 +81,14 @@
"company": "_Test Company",
"period_start_date": "2021-06-01",
"period_end_date": "2021-06-30",
- "sales_order": [so.name],
+ "item": item.item_code,
}
)
expected_value = [
{
"name": so.name,
+ "customer": so.customer,
"submitted": datetime.date(2021, 6, 15),
"status": "Completed",
"payment_term": None,
@@ -98,6 +102,7 @@
},
{
"name": so.name,
+ "customer": so.customer,
"submitted": datetime.date(2021, 6, 15),
"status": "Partly Paid",
"payment_term": None,
@@ -132,11 +137,11 @@
)
doc.insert()
- def test_alternate_currency(self):
+ def test_02_alternate_currency(self):
transaction_date = "2021-06-15"
self.create_payment_terms_template()
self.create_exchange_rate(transaction_date)
- item = create_item(item_code="_Test Excavator", is_stock_item=0)
+ item = create_item(item_code="_Test Excavator 2", is_stock_item=0)
so = make_sales_order(
transaction_date=transaction_date,
currency="USD",
@@ -166,7 +171,7 @@
"company": "_Test Company",
"period_start_date": "2021-06-01",
"period_end_date": "2021-06-30",
- "sales_order": [so.name],
+ "item": item.item_code,
}
)
@@ -174,6 +179,7 @@
expected_value = [
{
"name": so.name,
+ "customer": so.customer,
"submitted": datetime.date(2021, 6, 15),
"status": "Completed",
"payment_term": None,
@@ -187,6 +193,7 @@
},
{
"name": so.name,
+ "customer": so.customer,
"submitted": datetime.date(2021, 6, 15),
"status": "Partly Paid",
"payment_term": None,
@@ -200,3 +207,134 @@
},
]
self.assertEqual(data, expected_value)
+
+ def test_03_group_filters(self):
+ transaction_date = "2021-06-15"
+ self.create_payment_terms_template()
+ item1 = create_item(item_code="_Test Excavator 1", is_stock_item=0)
+ item1.item_group = "Products"
+ item1.save()
+
+ so1 = make_sales_order(
+ transaction_date=transaction_date,
+ delivery_date=add_days(transaction_date, -30),
+ item=item1.item_code,
+ qty=1,
+ rate=1000000,
+ do_not_save=True,
+ )
+ so1.po_no = ""
+ so1.taxes_and_charges = ""
+ so1.taxes = ""
+ so1.payment_terms_template = self.template.name
+ so1.save()
+ so1.submit()
+
+ item2 = create_item(item_code="_Test Steel", is_stock_item=0)
+ item2.item_group = "Raw Material"
+ item2.save()
+
+ so2 = make_sales_order(
+ customer="_Test Customer 1",
+ transaction_date=transaction_date,
+ delivery_date=add_days(transaction_date, -30),
+ item=item2.item_code,
+ qty=100,
+ rate=1000,
+ do_not_save=True,
+ )
+ so2.po_no = ""
+ so2.taxes_and_charges = ""
+ so2.taxes = ""
+ so2.payment_terms_template = self.template.name
+ so2.save()
+ so2.submit()
+
+ base_filters = {
+ "company": "_Test Company",
+ "period_start_date": "2021-06-01",
+ "period_end_date": "2021-06-30",
+ }
+
+ expected_value_so1 = [
+ {
+ "name": so1.name,
+ "customer": so1.customer,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Overdue",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 6, 30),
+ "invoice_portion": 50.0,
+ "currency": "INR",
+ "base_payment_amount": 500000.0,
+ "paid_amount": 0.0,
+ "invoices": "",
+ },
+ {
+ "name": so1.name,
+ "customer": so1.customer,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Overdue",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 7, 15),
+ "invoice_portion": 50.0,
+ "currency": "INR",
+ "base_payment_amount": 500000.0,
+ "paid_amount": 0.0,
+ "invoices": "",
+ },
+ ]
+
+ expected_value_so2 = [
+ {
+ "name": so2.name,
+ "customer": so2.customer,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Overdue",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 6, 30),
+ "invoice_portion": 50.0,
+ "currency": "INR",
+ "base_payment_amount": 50000.0,
+ "paid_amount": 0.0,
+ "invoices": "",
+ },
+ {
+ "name": so2.name,
+ "customer": so2.customer,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Overdue",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 7, 15),
+ "invoice_portion": 50.0,
+ "currency": "INR",
+ "base_payment_amount": 50000.0,
+ "paid_amount": 0.0,
+ "invoices": "",
+ },
+ ]
+
+ group_filters = [
+ {"customer_group": "All Customer Groups"},
+ {"item_group": "All Item Groups"},
+ {"item_group": "Products"},
+ {"item_group": "Raw Material"},
+ ]
+
+ expected_values_for_group_filters = [
+ expected_value_so1 + expected_value_so2,
+ expected_value_so1 + expected_value_so2,
+ expected_value_so1,
+ expected_value_so2,
+ ]
+
+ for idx, g in enumerate(group_filters, 0):
+ # build filter
+ filters = frappe._dict({}).update(base_filters).update(g)
+ with self.subTest(filters=filters):
+ columns, data, message, chart = execute(filters)
+ self.assertEqual(data, expected_values_for_group_filters[idx])
diff --git a/erpnext/selling/report/quotation_trends/quotation_trends.py b/erpnext/selling/report/quotation_trends/quotation_trends.py
index dfcec22..4e0758d 100644
--- a/erpnext/selling/report/quotation_trends/quotation_trends.py
+++ b/erpnext/selling/report/quotation_trends/quotation_trends.py
@@ -49,7 +49,7 @@
"data": {
"labels": labels,
"datasets": [
- {"name": _("{0}").format(filters.get("period")) + _(" Quoted Amount"), "values": datapoints}
+ {"name": _(filters.get("period")) + " " + _("Quoted Amount"), "values": datapoints}
],
},
"type": "line",
diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.py b/erpnext/selling/report/sales_order_trends/sales_order_trends.py
index 93707bd..719f1c5 100644
--- a/erpnext/selling/report/sales_order_trends/sales_order_trends.py
+++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.py
@@ -47,9 +47,7 @@
return {
"data": {
"labels": labels,
- "datasets": [
- {"name": _("{0}").format(filters.get("period")) + _(" Sales Value"), "values": datapoints}
- ],
+ "datasets": [{"name": _(filters.get("period")) + " " + _("Sales Value"), "values": datapoints}],
},
"type": "line",
"lineOptions": {"regionFill": 1},
diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js
index dd185fc..0de5b2d 100644
--- a/erpnext/setup/doctype/company/company.js
+++ b/erpnext/setup/doctype/company/company.js
@@ -233,7 +233,8 @@
["expenses_included_in_asset_valuation", {"account_type": "Expenses Included In Asset Valuation"}],
["capital_work_in_progress_account", {"account_type": "Capital Work in Progress"}],
["asset_received_but_not_billed", {"account_type": "Asset Received But Not Billed"}],
- ["unrealized_profit_loss_account", {"root_type": ["in", ["Liability", "Asset"]]}]
+ ["unrealized_profit_loss_account", {"root_type": ["in", ["Liability", "Asset"]]}],
+ ["default_provisional_account", {"root_type": ["in", ["Liability", "Asset"]]}]
], function(i, v) {
erpnext.company.set_custom_query(frm, v);
});
diff --git a/erpnext/setup/doctype/company/company_dashboard.py b/erpnext/setup/doctype/company/company_dashboard.py
index ff1e7f1..2d073c1 100644
--- a/erpnext/setup/doctype/company/company_dashboard.py
+++ b/erpnext/setup/doctype/company/company_dashboard.py
@@ -14,7 +14,7 @@
"goal_doctype_link": "company",
"goal_field": "base_grand_total",
"date_field": "posting_date",
- "filter_str": "docstatus = 1 and is_opening != 'Yes'",
+ "filters": {"docstatus": 1, "is_opening": ("!=", "Yes")},
"aggregation": "sum",
},
"fieldname": "company",
diff --git a/erpnext/setup/doctype/company/test_records.json b/erpnext/setup/doctype/company/test_records.json
index 89be607..19b6ef2 100644
--- a/erpnext/setup/doctype/company/test_records.json
+++ b/erpnext/setup/doctype/company/test_records.json
@@ -8,7 +8,8 @@
"domain": "Manufacturing",
"chart_of_accounts": "Standard",
"default_holiday_list": "_Test Holiday List",
- "enable_perpetual_inventory": 0
+ "enable_perpetual_inventory": 0,
+ "allow_account_creation_against_child_company": 1
},
{
"abbr": "_TC1",
diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py
index 2b055d2..8dae23d 100644
--- a/erpnext/setup/install.py
+++ b/erpnext/setup/install.py
@@ -56,12 +56,11 @@
)
if default_values:
try:
- b = frappe.get_doc(dt, dt)
+ doc = frappe.get_doc(dt, dt)
for fieldname, value in default_values:
- b.set(fieldname, value)
- b.save()
- except frappe.MandatoryError:
- pass
+ doc.set(fieldname, value)
+ doc.flags.ignore_mandatory = True
+ doc.save()
except frappe.ValidationError:
pass
diff --git a/erpnext/stock/dashboard/item_dashboard.html b/erpnext/stock/dashboard/item_dashboard.html
index 99698ba..b7a786e 100644
--- a/erpnext/stock/dashboard/item_dashboard.html
+++ b/erpnext/stock/dashboard/item_dashboard.html
@@ -1,4 +1,4 @@
-<div>
+<div class="stock-levels">
<div class="result">
</div>
<div class="more hidden" style="padding: 15px;">
diff --git a/erpnext/stock/doctype/bin/bin.json b/erpnext/stock/doctype/bin/bin.json
index 56dc71c..d822f4a 100644
--- a/erpnext/stock/doctype/bin/bin.json
+++ b/erpnext/stock/doctype/bin/bin.json
@@ -1,6 +1,6 @@
{
"actions": [],
- "autoname": "MAT-BIN-.YYYY.-.#####",
+ "autoname": "hash",
"creation": "2013-01-10 16:34:25",
"doctype": "DocType",
"engine": "InnoDB",
@@ -171,11 +171,11 @@
"idx": 1,
"in_create": 1,
"links": [],
- "modified": "2022-01-30 17:04:54.715288",
+ "modified": "2022-03-30 07:22:23.868602",
"modified_by": "Administrator",
"module": "Stock",
"name": "Bin",
- "naming_rule": "Expression (old style)",
+ "naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py
index 6cb9f7e..6ea4525 100644
--- a/erpnext/stock/doctype/bin/bin.py
+++ b/erpnext/stock/doctype/bin/bin.py
@@ -54,7 +54,7 @@
(supplied_item.rm_item_code == self.item_code)
& (po.name == supplied_item.parent)
& (po.docstatus == 1)
- & (po.is_subcontracted == "Yes")
+ & (po.is_subcontracted)
& (po.status != "Closed")
& (po.per_received < 100)
& (supplied_item.reserve_warehouse == self.warehouse)
@@ -79,7 +79,7 @@
& (se.name == se_item.parent)
& (po.name == se.purchase_order)
& (po.docstatus == 1)
- & (po.is_subcontracted == "Yes")
+ & (po.is_subcontracted == 1)
& (po.status != "Closed")
& (po.per_received < 100)
)
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 69e052b..0e68e85 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -280,8 +280,11 @@
)
if bypass_credit_limit_check_at_sales_order:
- validate_against_credit_limit = True
- extra_amount = self.base_grand_total
+ for d in self.get("items"):
+ if not d.against_sales_invoice:
+ validate_against_credit_limit = True
+ extra_amount = self.base_grand_total
+ break
else:
for d in self.get("items"):
if not (d.against_sales_order or d.against_sales_invoice):
diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
index f1f5d96..e2eb2a4 100644
--- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
+++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json
@@ -74,6 +74,7 @@
"against_sales_invoice",
"si_detail",
"dn_detail",
+ "pick_list_item",
"section_break_40",
"batch_no",
"serial_no",
@@ -762,13 +763,22 @@
"fieldtype": "Check",
"label": "Grant Commission",
"read_only": 1
+ },
+ {
+ "fieldname": "pick_list_item",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Pick List Item",
+ "no_copy": 1,
+ "print_hide": 1,
+ "read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-02-24 14:42:20.211085",
+ "modified": "2022-03-31 18:36:24.671913",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",
diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js
index 9e8b3bd..ae8b488 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -55,10 +55,15 @@
if (frm.doc.has_variants) {
frm.set_intro(__("This Item is a Template and cannot be used in transactions. Item attributes will be copied over into the variants unless 'No Copy' is set"), true);
+
frm.add_custom_button(__("Show Variants"), function() {
frappe.set_route("List", "Item", {"variant_of": frm.doc.name});
}, __("View"));
+ frm.add_custom_button(__("Item Variant Settings"), function() {
+ frappe.set_route("Form", "Item Variant Settings");
+ }, __("View"));
+
frm.add_custom_button(__("Variant Details Report"), function() {
frappe.set_route("query-report", "Item Variant Details", {"item": frm.doc.name});
}, __("View"));
@@ -110,6 +115,13 @@
}
});
}, __('Actions'));
+ } else {
+ frm.add_custom_button(__("Website Item"), function() {
+ frappe.db.get_value("Website Item", {item_code: frm.doc.name}, "name", (d) => {
+ if (!d.name) frappe.throw(__("Website Item not found"));
+ frappe.set_route("Form", "Website Item", d.name);
+ });
+ }, __("View"));
}
erpnext.item.edit_prices_button(frm);
@@ -131,12 +143,6 @@
frappe.set_route('Form', 'Item', new_item.name);
});
- if(frm.doc.has_variants) {
- frm.add_custom_button(__("Item Variant Settings"), function() {
- frappe.set_route("Form", "Item Variant Settings");
- }, __("View"));
- }
-
const stock_exists = (frm.doc.__onload
&& frm.doc.__onload.stock_exists) ? 1 : 0;
@@ -371,6 +377,17 @@
}
}
+ frm.set_query('default_provisional_account', 'item_defaults', (doc, cdt, cdn) => {
+ let row = locals[cdt][cdn];
+ return {
+ filters: {
+ "company": row.company,
+ "root_type": ["in", ["Liability", "Asset"]],
+ "is_group": 0
+ }
+ };
+ });
+
},
make_dashboard: function(frm) {
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index 524c3d1..06da8ee 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -645,7 +645,6 @@
},
{
"collapsible": 1,
- "default": "eval:!doc.is_fixed_asset",
"fieldname": "sales_details",
"fieldtype": "Section Break",
"label": "Sales Details",
@@ -992,4 +991,4 @@
"states": [],
"title_field": "item_name",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index 9d7c22f..b2f5fb7 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -3,7 +3,7 @@
import copy
import json
-from typing import List
+from typing import Dict, List, Optional
import frappe
from frappe import _
@@ -18,6 +18,7 @@
now_datetime,
nowtime,
strip,
+ strip_html,
)
from frappe.utils.html_utils import clean_html
@@ -69,10 +70,6 @@
self.item_code = strip(self.item_code)
self.name = self.item_code
- def before_insert(self):
- if not self.description:
- self.description = self.item_name
-
def after_insert(self):
"""set opening stock and item price"""
if self.standard_rate:
@@ -86,7 +83,7 @@
if not self.item_name:
self.item_name = self.item_code
- if not self.description:
+ if not strip_html(cstr(self.description)).strip():
self.description = self.item_name
self.validate_uom()
@@ -464,7 +461,7 @@
frappe.msgprint(
_("It can take upto few hours for accurate stock values to be visible after merging items."),
indicator="orange",
- title="Note",
+ title=_("Note"),
)
if self.published_in_website:
@@ -890,25 +887,38 @@
if self.is_new():
return
- fields = ("has_serial_no", "is_stock_item", "valuation_method", "has_batch_no")
+ restricted_fields = ("has_serial_no", "is_stock_item", "valuation_method", "has_batch_no")
- values = frappe.db.get_value("Item", self.name, fields, as_dict=True)
+ values = frappe.db.get_value("Item", self.name, restricted_fields, as_dict=True)
+ if not values:
+ return
+
if not values.get("valuation_method") and self.get("valuation_method"):
values["valuation_method"] = (
frappe.db.get_single_value("Stock Settings", "valuation_method") or "FIFO"
)
- if values:
- for field in fields:
- if cstr(self.get(field)) != cstr(values.get(field)):
- if self.check_if_linked_document_exists(field):
- frappe.throw(
- _(
- "As there are existing transactions against item {0}, you can not change the value of {1}"
- ).format(self.name, frappe.bold(self.meta.get_label(field)))
- )
+ changed_fields = [
+ field for field in restricted_fields if cstr(self.get(field)) != cstr(values.get(field))
+ ]
+ if not changed_fields:
+ return
- def check_if_linked_document_exists(self, field):
+ if linked_doc := self._get_linked_submitted_documents(changed_fields):
+ changed_field_labels = [frappe.bold(self.meta.get_label(f)) for f in changed_fields]
+ msg = _(
+ "As there are existing submitted transactions against item {0}, you can not change the value of {1}."
+ ).format(self.name, ", ".join(changed_field_labels))
+
+ if linked_doc and isinstance(linked_doc, dict):
+ msg += "<br>"
+ msg += _("Example of a linked document: {0}").format(
+ frappe.get_desk_link(linked_doc.doctype, linked_doc.docname)
+ )
+
+ frappe.throw(msg, title=_("Linked with submitted documents"))
+
+ def _get_linked_submitted_documents(self, changed_fields: List[str]) -> Optional[Dict[str, str]]:
linked_doctypes = [
"Delivery Note Item",
"Sales Invoice Item",
@@ -921,7 +931,7 @@
# For "Is Stock Item", following doctypes is important
# because reserved_qty, ordered_qty and requested_qty updated from these doctypes
- if field == "is_stock_item":
+ if "is_stock_item" in changed_fields:
linked_doctypes += [
"Sales Order Item",
"Purchase Order Item",
@@ -940,11 +950,21 @@
"Sales Invoice Item",
):
# If Invoice has Stock impact, only then consider it.
- if self.stock_ledger_created():
- return True
+ if linked_doc := frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"item_code": self.name, "is_cancelled": 0},
+ ["voucher_no as docname", "voucher_type as doctype"],
+ as_dict=True,
+ ):
+ return linked_doc
- elif frappe.db.get_value(doctype, filters):
- return True
+ elif linked_doc := frappe.db.get_value(
+ doctype,
+ filters,
+ ["parent as docname", "parenttype as doctype"],
+ as_dict=True,
+ ):
+ return linked_doc
def validate_auto_reorder_enabled_in_stock_settings(self):
if self.reorder_levels:
diff --git a/erpnext/stock/doctype/item/item_dashboard.py b/erpnext/stock/doctype/item/item_dashboard.py
index 33acf4b..897acb7 100644
--- a/erpnext/stock/doctype/item/item_dashboard.py
+++ b/erpnext/stock/doctype/item/item_dashboard.py
@@ -31,6 +31,7 @@
},
{"label": _("Manufacture"), "items": ["Production Plan", "Work Order", "Item Manufacturer"]},
{"label": _("Traceability"), "items": ["Serial No", "Batch"]},
- {"label": _("Move"), "items": ["Stock Entry"]},
+ {"label": _("Stock Movement"), "items": ["Stock Entry", "Stock Reconciliation"]},
+ {"label": _("E-commerce"), "items": ["Website Item"]},
],
}
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index 328d937..aa0a549 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -744,6 +744,40 @@
self.assertTrue(get_data(warehouse="_Test Warehouse - _TC"))
self.assertTrue(get_data(item_group="All Item Groups"))
+ def test_empty_description(self):
+ item = make_item(properties={"description": "<p></p>"})
+ self.assertEqual(item.description, item.item_name)
+ item.description = ""
+ item.save()
+ self.assertEqual(item.description, item.item_name)
+
+ def test_item_type_field_change(self):
+ """Check if critical fields like `is_stock_item`, `has_batch_no` are not changed if transactions exist."""
+ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
+ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+ transaction_creators = [
+ lambda i: make_purchase_receipt(item_code=i),
+ lambda i: make_purchase_invoice(item_code=i, update_stock=1),
+ lambda i: make_stock_entry(item_code=i, qty=1, target="_Test Warehouse - _TC"),
+ lambda i: create_delivery_note(item_code=i),
+ ]
+
+ properties = {"has_batch_no": 0, "allow_negative_stock": 1, "valuation_rate": 10}
+ for transaction_creator in transaction_creators:
+ item = make_item(properties=properties)
+ transaction = transaction_creator(item.name)
+ item.has_batch_no = 1
+ self.assertRaises(frappe.ValidationError, item.save)
+
+ transaction.cancel()
+ # should be allowed now
+ item.reload()
+ item.has_batch_no = 1
+ item.save()
+
def set_item_variant_settings(fields):
doc = frappe.get_doc("Item Variant Settings")
diff --git a/erpnext/stock/doctype/item_alternative/test_item_alternative.py b/erpnext/stock/doctype/item_alternative/test_item_alternative.py
index d829b2c..32c58c5 100644
--- a/erpnext/stock/doctype/item_alternative/test_item_alternative.py
+++ b/erpnext/stock/doctype/item_alternative/test_item_alternative.py
@@ -41,7 +41,7 @@
supplier_warehouse = "Test Supplier Warehouse - _TC"
po = create_purchase_order(
item="Test Finished Goods - A",
- is_subcontracted="Yes",
+ is_subcontracted=1,
qty=5,
rate=3000,
supplier_warehouse=supplier_warehouse,
diff --git a/erpnext/stock/doctype/item_barcode/item_barcode.json b/erpnext/stock/doctype/item_barcode/item_barcode.json
index d89ca55..eef70c9 100644
--- a/erpnext/stock/doctype/item_barcode/item_barcode.json
+++ b/erpnext/stock/doctype/item_barcode/item_barcode.json
@@ -1,109 +1,42 @@
{
- "allow_copy": 0,
- "allow_events_in_timeline": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "autoname": "field:barcode",
- "beta": 0,
- "creation": "2017-12-09 18:54:50.562438",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
+ "actions": [],
+ "autoname": "hash",
+ "creation": "2022-02-11 11:26:22.155183",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "barcode",
+ "barcode_type"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "barcode",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 1,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Barcode",
- "length": 0,
- "no_copy": 1,
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
+ "fieldname": "barcode",
+ "fieldtype": "Data",
+ "in_global_search": 1,
+ "in_list_view": 1,
+ "label": "Barcode",
+ "no_copy": 1,
"unique": 1
- },
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "barcode_type",
- "fieldtype": "Select",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Barcode Type",
- "length": 0,
- "no_copy": 0,
- "options": "\nEAN\nUPC-A",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "fieldname": "barcode_type",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Barcode Type",
+ "options": "\nEAN\nUPC-A"
}
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 1,
- "max_attachments": 0,
- "modified": "2018-11-13 06:03:09.814357",
- "modified_by": "Administrator",
- "module": "Stock",
- "name": "Item Barcode",
- "name_case": "",
- "owner": "Administrator",
- "permissions": [],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1,
- "track_seen": 0,
- "track_views": 0
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2022-04-01 05:54:27.314030",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "Item Barcode",
+ "naming_rule": "Random",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/item_default/item_default.json b/erpnext/stock/doctype/item_default/item_default.json
index bc17160..042d398 100644
--- a/erpnext/stock/doctype/item_default/item_default.json
+++ b/erpnext/stock/doctype/item_default/item_default.json
@@ -15,6 +15,7 @@
"default_supplier",
"column_break_8",
"expense_account",
+ "default_provisional_account",
"selling_defaults",
"selling_cost_center",
"column_break_12",
@@ -101,11 +102,17 @@
"fieldtype": "Link",
"label": "Default Discount Account",
"options": "Account"
+ },
+ {
+ "fieldname": "default_provisional_account",
+ "fieldtype": "Link",
+ "label": "Default Provisional Account",
+ "options": "Account"
}
],
"istable": 1,
"links": [],
- "modified": "2021-07-13 01:26:03.860065",
+ "modified": "2022-04-10 20:18:54.148195",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item Default",
@@ -114,5 +121,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py
index 4524914..a70ff17 100644
--- a/erpnext/stock/doctype/material_request/material_request.py
+++ b/erpnext/stock/doctype/material_request/material_request.py
@@ -209,16 +209,14 @@
if d.ordered_qty and d.ordered_qty > allowed_qty:
frappe.throw(
_(
- "The total Issue / Transfer quantity {0} in Material Request {1} \
- cannot be greater than allowed requested quantity {2} for Item {3}"
+ "The total Issue / Transfer quantity {0} in Material Request {1} cannot be greater than allowed requested quantity {2} for Item {3}"
).format(d.ordered_qty, d.parent, allowed_qty, d.item_code)
)
elif d.ordered_qty and d.ordered_qty > d.stock_qty:
frappe.throw(
_(
- "The total Issue / Transfer quantity {0} in Material Request {1} \
- cannot be greater than requested quantity {2} for Item {3}"
+ "The total Issue / Transfer quantity {0} in Material Request {1} cannot be greater than requested quantity {2} for Item {3}"
).format(d.ordered_qty, d.parent, d.qty, d.item_code)
)
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 7061ee1..33d7745 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -33,7 +33,9 @@
location.sales_order
and frappe.db.get_value("Sales Order", location.sales_order, "per_picked") == 100
):
- frappe.throw("Row " + str(location.idx) + " has been picked already!")
+ frappe.throw(
+ _("Row #{}: item {} has been picked already.").format(location.idx, location.item_code)
+ )
def before_submit(self):
for item in self.locations:
@@ -82,10 +84,9 @@
100 + flt(frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance"))
):
frappe.throw(
- "You are picking more than required quantity for "
- + item_code
- + ". Check if there is any other pick list created for "
- + so_doc.name
+ _(
+ "You are picking more than required quantity for {}. Check if there is any other pick list created for {}"
+ ).format(item_code, so_doc.name)
)
frappe.db.set_value("Sales Order Item", so_item, "picked_qty", already_picked + picked_qty)
@@ -534,6 +535,7 @@
dn_item = map_child_doc(source_doc, delivery_note, table_mapper)
if dn_item:
+ dn_item.pick_list_item = location.name
dn_item.warehouse = location.warehouse
dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1)
dn_item.batch_no = location.batch_no
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index 7496b6b..27b06d2 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -8,7 +8,7 @@
from frappe.tests.utils import FrappeTestCase
-from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
@@ -18,6 +18,7 @@
class TestPickList(FrappeTestCase):
def test_pick_list_picks_warehouse_for_each_item(self):
+ item_code = make_item().name
try:
frappe.get_doc(
{
@@ -27,7 +28,7 @@
"expense_account": "Temporary Opening - _TC",
"items": [
{
- "item_code": "_Test Item",
+ "item_code": item_code,
"warehouse": "_Test Warehouse - _TC",
"valuation_rate": 100,
"qty": 5,
@@ -47,7 +48,7 @@
"purpose": "Delivery",
"locations": [
{
- "item_code": "_Test Item",
+ "item_code": item_code,
"qty": 5,
"stock_qty": 5,
"conversion_factor": 1,
@@ -59,7 +60,7 @@
)
pick_list.set_item_locations()
- self.assertEqual(pick_list.locations[0].item_code, "_Test Item")
+ self.assertEqual(pick_list.locations[0].item_code, item_code)
self.assertEqual(pick_list.locations[0].warehouse, "_Test Warehouse - _TC")
self.assertEqual(pick_list.locations[0].qty, 5)
@@ -270,6 +271,8 @@
pr2.cancel()
def test_pick_list_for_items_from_multiple_sales_orders(self):
+
+ item_code = make_item().name
try:
frappe.get_doc(
{
@@ -279,7 +282,7 @@
"expense_account": "Temporary Opening - _TC",
"items": [
{
- "item_code": "_Test Item",
+ "item_code": item_code,
"warehouse": "_Test Warehouse - _TC",
"valuation_rate": 100,
"qty": 10,
@@ -295,7 +298,14 @@
"doctype": "Sales Order",
"customer": "_Test Customer",
"company": "_Test Company",
- "items": [{"item_code": "_Test Item", "qty": 10, "delivery_date": frappe.utils.today()}],
+ "items": [
+ {
+ "item_code": item_code,
+ "qty": 10,
+ "delivery_date": frappe.utils.today(),
+ "warehouse": "_Test Warehouse - _TC",
+ }
+ ],
}
)
sales_order.submit()
@@ -309,7 +319,7 @@
"purpose": "Delivery",
"locations": [
{
- "item_code": "_Test Item",
+ "item_code": item_code,
"qty": 5,
"stock_qty": 5,
"conversion_factor": 1,
@@ -317,7 +327,7 @@
"sales_order_item": "_T-Sales Order-1_item",
},
{
- "item_code": "_Test Item",
+ "item_code": item_code,
"qty": 5,
"stock_qty": 5,
"conversion_factor": 1,
@@ -329,18 +339,19 @@
)
pick_list.set_item_locations()
- self.assertEqual(pick_list.locations[0].item_code, "_Test Item")
+ self.assertEqual(pick_list.locations[0].item_code, item_code)
self.assertEqual(pick_list.locations[0].warehouse, "_Test Warehouse - _TC")
self.assertEqual(pick_list.locations[0].qty, 5)
self.assertEqual(pick_list.locations[0].sales_order_item, "_T-Sales Order-1_item")
- self.assertEqual(pick_list.locations[1].item_code, "_Test Item")
+ self.assertEqual(pick_list.locations[1].item_code, item_code)
self.assertEqual(pick_list.locations[1].warehouse, "_Test Warehouse - _TC")
self.assertEqual(pick_list.locations[1].qty, 5)
self.assertEqual(pick_list.locations[1].sales_order_item, sales_order.items[0].name)
def test_pick_list_for_items_with_multiple_UOM(self):
- purchase_receipt = make_purchase_receipt(item_code="_Test Item", qty=10)
+ item_code = make_item().name
+ purchase_receipt = make_purchase_receipt(item_code=item_code, qty=10)
purchase_receipt.submit()
sales_order = frappe.get_doc(
@@ -350,17 +361,19 @@
"company": "_Test Company",
"items": [
{
- "item_code": "_Test Item",
+ "item_code": item_code,
"qty": 1,
"conversion_factor": 5,
"stock_qty": 5,
"delivery_date": frappe.utils.today(),
+ "warehouse": "_Test Warehouse - _TC",
},
{
- "item_code": "_Test Item",
+ "item_code": item_code,
"qty": 1,
"conversion_factor": 1,
"delivery_date": frappe.utils.today(),
+ "warehouse": "_Test Warehouse - _TC",
},
],
}
@@ -376,7 +389,7 @@
"purpose": "Delivery",
"locations": [
{
- "item_code": "_Test Item",
+ "item_code": item_code,
"qty": 2,
"stock_qty": 1,
"conversion_factor": 0.5,
@@ -384,7 +397,7 @@
"sales_order_item": sales_order.items[0].name,
},
{
- "item_code": "_Test Item",
+ "item_code": item_code,
"qty": 1,
"stock_qty": 1,
"conversion_factor": 1,
@@ -521,6 +534,8 @@
for dn_item in frappe.get_doc("Delivery Note", dn.name).get("items"):
self.assertEqual(dn_item.item_code, "_Test Item")
self.assertEqual(dn_item.against_sales_order, sales_order_1.name)
+ self.assertEqual(dn_item.pick_list_item, pick_list.locations[dn_item.idx - 1].name)
+
for dn in frappe.get_all(
"Delivery Note",
filters={"pick_list": pick_list.name, "customer": "_Test Customer 1"},
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
index 0182ed5..51ec598 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
@@ -200,7 +200,7 @@
cur_frm.add_custom_button(__('Reopen'), this.reopen_purchase_receipt, __("Status"))
}
- this.frm.toggle_reqd("supplier_warehouse", this.frm.doc.is_subcontracted==="Yes");
+ this.frm.toggle_reqd("supplier_warehouse", this.frm.doc.is_subcontracted);
}
make_purchase_invoice() {
@@ -298,10 +298,10 @@
frappe.provide("erpnext.buying");
frappe.ui.form.on("Purchase Receipt", "is_subcontracted", function(frm) {
- if (frm.doc.is_subcontracted === "Yes") {
+ if (frm.doc.is_subcontracted) {
erpnext.buying.get_default_bom(frm);
}
- frm.toggle_reqd("supplier_warehouse", frm.doc.is_subcontracted==="Yes");
+ frm.toggle_reqd("supplier_warehouse", frm.doc.is_subcontracted);
});
frappe.ui.form.on('Purchase Receipt Item', {
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
index 6d4b4a1..19c490d 100755
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
@@ -106,8 +106,6 @@
"terms",
"bill_no",
"bill_date",
- "accounting_details_section",
- "provisional_expense_account",
"more_info",
"project",
"status",
@@ -437,17 +435,16 @@
"fieldtype": "Column Break"
},
{
- "default": "No",
+ "default": "0",
"fieldname": "is_subcontracted",
- "fieldtype": "Select",
- "label": "Raw Materials Consumed",
+ "fieldtype": "Check",
+ "label": "Is Subcontracted",
"oldfieldname": "is_subcontracted",
"oldfieldtype": "Select",
- "options": "No\nYes",
"print_hide": 1
},
{
- "depends_on": "eval:doc.is_subcontracted==\"Yes\"",
+ "depends_on": "eval:doc.is_subcontracted",
"fieldname": "supplier_warehouse",
"fieldtype": "Link",
"label": "Supplier Warehouse",
@@ -1146,26 +1143,13 @@
"label": "Represents Company",
"options": "Company",
"read_only": 1
- },
- {
- "collapsible": 1,
- "fieldname": "accounting_details_section",
- "fieldtype": "Section Break",
- "label": "Accounting Details"
- },
- {
- "fieldname": "provisional_expense_account",
- "fieldtype": "Link",
- "hidden": 1,
- "label": "Provisional Expense Account",
- "options": "Account"
}
],
"icon": "fa fa-truck",
"idx": 261,
"is_submittable": 1,
"links": [],
- "modified": "2022-03-10 11:40:52.690984",
+ "modified": "2022-04-10 22:50:37.761362",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 1e1c0b9..ec0e809 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -145,10 +145,13 @@
)
)
- if provisional_accounting_for_non_stock_items:
- default_provisional_account = self.get_company_default("default_provisional_account")
- if not self.provisional_expense_account:
- self.provisional_expense_account = default_provisional_account
+ if not provisional_accounting_for_non_stock_items:
+ return
+
+ default_provisional_account = self.get_company_default("default_provisional_account")
+ for item in self.get("items"):
+ if not item.get("provisional_expense_account"):
+ item.provisional_expense_account = default_provisional_account
def validate_with_previous_doc(self):
super(PurchaseReceipt, self).validate_with_previous_doc(
@@ -509,7 +512,9 @@
and flt(d.qty)
and provisional_accounting_for_non_stock_items
):
- self.add_provisional_gl_entry(d, gl_entries, self.posting_date)
+ self.add_provisional_gl_entry(
+ d, gl_entries, self.posting_date, d.get("provisional_expense_account")
+ )
if warehouse_with_no_account:
frappe.msgprint(
@@ -518,9 +523,10 @@
+ "\n".join(warehouse_with_no_account)
)
- def add_provisional_gl_entry(self, item, gl_entries, posting_date, reverse=0):
- provisional_expense_account = self.get("provisional_expense_account")
- credit_currency = get_account_currency(provisional_expense_account)
+ def add_provisional_gl_entry(
+ self, item, gl_entries, posting_date, provisional_account, reverse=0
+ ):
+ credit_currency = get_account_currency(provisional_account)
debit_currency = get_account_currency(item.expense_account)
expense_account = item.expense_account
remarks = self.get("remarks") or _("Accounting Entry for Service")
@@ -534,7 +540,7 @@
self.add_gl_entry(
gl_entries=gl_entries,
- account=provisional_expense_account,
+ account=provisional_account,
cost_center=item.cost_center,
debit=0.0,
credit=multiplication_factor * item.amount,
@@ -554,7 +560,7 @@
debit=multiplication_factor * item.amount,
credit=0.0,
remarks=remarks,
- against_account=provisional_expense_account,
+ against_account=provisional_account,
account_currency=debit_currency,
project=item.project,
voucher_detail_no=item.name,
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index a6f82b0..ce3bd56 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -327,7 +327,7 @@
target="_Test Warehouse 1 - _TC",
basic_rate=100,
)
- pr = make_purchase_receipt(item_code="_Test FG Item", qty=10, rate=500, is_subcontracted="Yes")
+ pr = make_purchase_receipt(item_code="_Test FG Item", qty=10, rate=500, is_subcontracted=1)
self.assertEqual(len(pr.get("supplied_items")), 2)
rm_supp_cost = sum(d.amount for d in pr.get("supplied_items"))
@@ -362,7 +362,7 @@
item_code="_Test FG Item",
qty=10,
rate=0,
- is_subcontracted="Yes",
+ is_subcontracted=1,
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
supplier_warehouse="Work In Progress - TCP1",
@@ -401,7 +401,7 @@
item_code=item_code,
qty=1,
include_exploded_items=0,
- is_subcontracted="Yes",
+ is_subcontracted=1,
supplier_warehouse="_Test Warehouse 1 - _TC",
)
@@ -647,6 +647,45 @@
return_pr.cancel()
pr.cancel()
+ def test_purchase_receipt_for_rejected_gle_without_accepted_warehouse(self):
+ from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse
+
+ rejected_warehouse = "_Test Rejected Warehouse - TCP1"
+ if not frappe.db.exists("Warehouse", rejected_warehouse):
+ get_warehouse(
+ company="_Test Company with perpetual inventory",
+ abbr=" - TCP1",
+ warehouse_name="_Test Rejected Warehouse",
+ ).name
+
+ pr = make_purchase_receipt(
+ company="_Test Company with perpetual inventory",
+ warehouse="Stores - TCP1",
+ received_qty=2,
+ rejected_qty=2,
+ rejected_warehouse=rejected_warehouse,
+ do_not_save=True,
+ )
+
+ pr.items[0].qty = 0.0
+ pr.items[0].warehouse = ""
+ pr.submit()
+
+ actual_qty = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {
+ "voucher_type": "Purchase Receipt",
+ "voucher_no": pr.name,
+ "warehouse": pr.items[0].rejected_warehouse,
+ "is_cancelled": 0,
+ },
+ "actual_qty",
+ )
+
+ self.assertEqual(actual_qty, 2)
+ self.assertFalse(pr.items[0].warehouse)
+ pr.cancel()
+
def test_purchase_return_for_serialized_items(self):
def _check_serial_no_values(serial_no, field_values):
serial_no = frappe.get_doc("Serial No", serial_no)
@@ -708,14 +747,13 @@
update_purchase_receipt_status,
)
- pr = make_purchase_receipt()
+ item = make_item()
+
+ pr = make_purchase_receipt(item_code=item.name)
update_purchase_receipt_status(pr.name, "Closed")
self.assertEqual(frappe.db.get_value("Purchase Receipt", pr.name, "status"), "Closed")
- pr.reload()
- pr.cancel()
-
def test_pr_billing_status(self):
"""Flow:
1. PO -> PR1 -> PI
@@ -1122,7 +1160,7 @@
po = create_purchase_order(
item_code=item_code,
qty=order_qty,
- is_subcontracted="Yes",
+ is_subcontracted=1,
supplier_warehouse="_Test Warehouse 1 - _TC",
)
@@ -1465,7 +1503,7 @@
pr.set_posting_time = 1
pr.company = args.company or "_Test Company"
pr.supplier = args.supplier or "_Test Supplier"
- pr.is_subcontracted = args.is_subcontracted or "No"
+ pr.is_subcontracted = args.is_subcontracted or 0
pr.supplier_warehouse = args.supplier_warehouse or "_Test Warehouse 1 - _TC"
pr.currency = args.currency or "INR"
pr.is_return = args.is_return
diff --git a/erpnext/stock/doctype/purchase_receipt/test_records.json b/erpnext/stock/doctype/purchase_receipt/test_records.json
index 724e3d7..990ad12 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_records.json
+++ b/erpnext/stock/doctype/purchase_receipt/test_records.json
@@ -92,7 +92,7 @@
"currency": "INR",
"doctype": "Purchase Receipt",
"base_grand_total": 5000.0,
- "is_subcontracted": "Yes",
+ "is_subcontracted": 1,
"base_net_total": 5000.0,
"items": [
{
diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
index e5994b2..1c65ac8 100644
--- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
+++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
@@ -96,7 +96,6 @@
"include_exploded_items",
"batch_no",
"rejected_serial_no",
- "expense_account",
"item_tax_rate",
"item_weight_details",
"weight_per_unit",
@@ -107,6 +106,10 @@
"manufacturer",
"column_break_16",
"manufacturer_part_no",
+ "accounting_details_section",
+ "expense_account",
+ "column_break_102",
+ "provisional_expense_account",
"accounting_dimensions_section",
"project",
"dimension_col_break",
@@ -648,7 +651,7 @@
},
{
"default": "0",
- "depends_on": "eval:parent.is_subcontracted == 'Yes'",
+ "depends_on": "eval:parent.is_subcontracted",
"fieldname": "include_exploded_items",
"fieldtype": "Check",
"label": "Include Exploded Items",
@@ -971,12 +974,27 @@
"label": "Product Bundle",
"options": "Product Bundle",
"read_only": 1
+ },
+ {
+ "fieldname": "provisional_expense_account",
+ "fieldtype": "Link",
+ "label": "Provisional Expense Account",
+ "options": "Account"
+ },
+ {
+ "fieldname": "accounting_details_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Details"
+ },
+ {
+ "fieldname": "column_break_102",
+ "fieldtype": "Column Break"
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-02-01 11:32:27.980524",
+ "modified": "2022-04-11 13:07:32.061402",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
index 0ba97d5..6148e16 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
@@ -1,6 +1,6 @@
{
"actions": [],
- "autoname": "REPOST-ITEM-VAL-.######",
+ "autoname": "hash",
"creation": "2022-01-11 15:03:38.273179",
"doctype": "DocType",
"editable_grid": 1,
@@ -177,11 +177,11 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-01-18 10:57:33.450907",
+ "modified": "2022-03-30 07:22:48.520266",
"modified_by": "Administrator",
"module": "Stock",
"name": "Repost Item Valuation",
- "naming_rule": "Expression (old style)",
+ "naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index ec1d140..c7d65c6 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -4,15 +4,12 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cint, get_link_to_form, get_weekday, now, nowtime, today
+from frappe.utils import cint, get_link_to_form, get_weekday, now, nowtime
from frappe.utils.user import get_users_with_role
from rq.timeouts import JobTimeoutException
import erpnext
-from erpnext.accounts.utils import (
- check_if_stock_and_account_balance_synced,
- update_gl_entries_after,
-)
+from erpnext.accounts.utils import update_gl_entries_after
from erpnext.stock.stock_ledger import get_items_to_be_repost, repost_future_sle
@@ -61,6 +58,22 @@
repost(self)
+ def before_cancel(self):
+ self.check_pending_repost_against_cancelled_transaction()
+
+ def check_pending_repost_against_cancelled_transaction(self):
+ if self.status not in ("Queued", "In Progress"):
+ return
+
+ if not (self.voucher_no and self.voucher_no):
+ return
+
+ transaction_status = frappe.db.get_value(self.voucher_type, self.voucher_no, "docstatus")
+ if transaction_status == 2:
+ msg = _("Cannot cancel as processing of cancelled documents is pending.")
+ msg += "<br>" + _("Please try again in an hour.")
+ frappe.throw(msg, title=_("Pending processing"))
+
@frappe.whitelist()
def restart_reposting(self):
self.set_status("Queued", write=False)
@@ -208,6 +221,10 @@
def repost_entries():
+ """
+ Reposts 'Repost Item Valuation' entries in queue.
+ Called hourly via hooks.py.
+ """
if not in_configured_timeslot():
return
@@ -223,9 +240,6 @@
if riv_entries:
return
- for d in frappe.get_all("Company", filters={"enable_perpetual_inventory": 1}):
- check_if_stock_and_account_balance_synced(today(), d.name)
-
def get_repost_item_valuation_entries():
return frappe.db.sql(
diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
index f3bebad..55117ce 100644
--- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py
@@ -1,20 +1,25 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import nowdate
from erpnext.controllers.stock_controller import create_item_wise_repost_entries
+from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import (
in_configured_timeslot,
)
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.utils import PendingRepostingError
-class TestRepostItemValuation(unittest.TestCase):
+class TestRepostItemValuation(FrappeTestCase):
+ def tearDown(self):
+ frappe.flags.dont_execute_stock_reposts = False
+
def test_repost_time_slot(self):
repost_settings = frappe.get_doc("Stock Reposting Settings")
@@ -162,3 +167,22 @@
self.assertRaises(PendingRepostingError, stock_settings.save)
riv.set_status("Skipped")
+
+ def test_prevention_of_cancelled_transaction_riv(self):
+ frappe.flags.dont_execute_stock_reposts = True
+
+ item = make_item()
+ warehouse = "_Test Warehouse - _TC"
+ old = make_stock_entry(item_code=item.name, to_warehouse=warehouse, qty=2, rate=5)
+ _new = make_stock_entry(item_code=item.name, to_warehouse=warehouse, qty=5, rate=10)
+
+ old.cancel()
+
+ riv = frappe.get_last_doc(
+ "Repost Item Valuation", {"voucher_type": old.doctype, "voucher_no": old.name}
+ )
+ self.assertRaises(frappe.ValidationError, riv.cancel)
+
+ riv.db_set("status", "Skipped")
+ riv.reload()
+ riv.cancel() # it should cancel now
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index 1aafcee..1df56ef 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -214,7 +214,7 @@
if (frm.doc.docstatus === 1) {
if (frm.doc.add_to_transit && frm.doc.purpose=='Material Transfer' && frm.doc.per_transferred < 100) {
- frm.add_custom_button('End Transit', function() {
+ frm.add_custom_button(__('End Transit'), function() {
frappe.model.open_mapped_doc({
method: "erpnext.stock.doctype.stock_entry.stock_entry.make_stock_in_entry",
frm: frm
@@ -633,7 +633,7 @@
// set allow_zero_valuation_rate to 0 if s_warehouse is selected.
let item = frappe.get_doc(cdt, cdn);
if (item.s_warehouse) {
- item.allow_zero_valuation_rate = 0;
+ frappe.model.set_value(cdt, cdn, "allow_zero_valuation_rate", 0);
}
},
@@ -646,21 +646,6 @@
frm.events.calculate_basic_amount(frm, item);
},
- barcode: function(doc, cdt, cdn) {
- var d = locals[cdt][cdn];
- if (d.barcode) {
- frappe.call({
- method: "erpnext.stock.get_item_details.get_item_code",
- args: {"barcode": d.barcode },
- callback: function(r) {
- if (!r.exe){
- frappe.model.set_value(cdt, cdn, "item_code", r.message);
- }
- }
- });
- }
- },
-
uom: function(doc, cdt, cdn) {
var d = locals[cdt][cdn];
if(d.uom && d.item_code){
@@ -793,7 +778,7 @@
return {
"filters": {
"docstatus": 1,
- "is_subcontracted": "Yes",
+ "is_subcontracted": 1,
"company": me.frm.doc.company
}
};
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index bc54f7f..c4aa8a4 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -225,12 +225,16 @@
def set_transfer_qty(self):
for item in self.get("items"):
if not flt(item.qty):
- frappe.throw(_("Row {0}: Qty is mandatory").format(item.idx))
+ frappe.throw(_("Row {0}: Qty is mandatory").format(item.idx), title=_("Zero quantity"))
if not flt(item.conversion_factor):
frappe.throw(_("Row {0}: UOM Conversion Factor is mandatory").format(item.idx))
item.transfer_qty = flt(
flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item)
)
+ if not flt(item.transfer_qty):
+ frappe.throw(
+ _("Row {0}: Qty in Stock UOM can not be zero.").format(item.idx), title=_("Zero quantity")
+ )
def update_cost_in_project(self):
if self.work_order and not frappe.db.get_value(
@@ -1382,7 +1386,6 @@
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]:
scrap_item_dict = self.get_bom_scrap_material(self.fg_completed_qty)
for item in scrap_item_dict.values():
- item.idx = ""
if self.pro_doc and self.pro_doc.scrap_warehouse:
item["to_warehouse"] = self.pro_doc.scrap_warehouse
@@ -1800,7 +1803,9 @@
or (desire_to_transfer > 0 and backflush_based_on == "Material Transferred for Manufacture")
or allow_overproduction
):
- item_dict[item]["qty"] = desire_to_transfer
+ # "No need for transfer but qty still pending to transfer" case can occur
+ # when transferring multiple RM in different Stock Entries
+ item_dict[item]["qty"] = desire_to_transfer if (desire_to_transfer > 0) else pending_to_issue
elif pending_to_issue > 0:
item_dict[item]["qty"] = pending_to_issue
else:
@@ -1898,7 +1903,6 @@
se_child.is_process_loss = item_row.get("is_process_loss", 0)
for field in [
- "idx",
"po_detail",
"original_item",
"expense_account",
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index aeedcd1..3ccd342 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -51,7 +51,6 @@
def tearDown(self):
frappe.db.rollback()
frappe.set_user("Administrator")
- frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0")
def test_fifo(self):
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
@@ -767,13 +766,12 @@
fg_cost, flt(rm_cost + bom_operation_cost + work_order.additional_operating_cost, 2)
)
+ @change_settings("Manufacturing Settings", {"material_consumption": 1})
def test_work_order_manufacture_with_material_consumption(self):
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as _make_stock_entry,
)
- frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "1")
-
bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item", "is_default": 1, "docstatus": 1})
work_order = frappe.new_doc("Work Order")
@@ -983,43 +981,6 @@
repack.insert()
self.assertRaises(frappe.ValidationError, repack.submit)
- # def test_material_consumption(self):
- # frappe.db.set_value("Manufacturing Settings", None, "backflush_raw_materials_based_on", "BOM")
- # frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0")
-
- # from erpnext.manufacturing.doctype.work_order.work_order \
- # import make_stock_entry as _make_stock_entry
- # bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2",
- # "is_default": 1, "docstatus": 1})
-
- # work_order = frappe.new_doc("Work Order")
- # work_order.update({
- # "company": "_Test Company",
- # "fg_warehouse": "_Test Warehouse 1 - _TC",
- # "production_item": "_Test FG Item 2",
- # "bom_no": bom_no,
- # "qty": 4.0,
- # "stock_uom": "_Test UOM",
- # "wip_warehouse": "_Test Warehouse - _TC",
- # "additional_operating_cost": 1000,
- # "use_multi_level_bom": 1
- # })
- # work_order.insert()
- # work_order.submit()
-
- # make_stock_entry(item_code="_Test Serialized Item With Series", target="_Test Warehouse - _TC", qty=50, basic_rate=100)
- # make_stock_entry(item_code="_Test Item 2", target="_Test Warehouse - _TC", qty=50, basic_rate=20)
-
- # item_quantity = {
- # '_Test Item': 2.0,
- # '_Test Item 2': 12.0,
- # '_Test Serialized Item With Series': 6.0
- # }
-
- # stock_entry = frappe.get_doc(_make_stock_entry(work_order.name, "Material Consumption for Manufacture", 2))
- # for d in stock_entry.get('items'):
- # self.assertEqual(item_quantity.get(d.item_code), d.qty)
-
def test_customer_provided_parts_se(self):
create_item(
"CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0
@@ -1358,6 +1319,13 @@
issue.reload() # reload because reposting current voucher updates rate
self.assertEqual(issue.value_difference, -30)
+ def test_transfer_qty_validation(self):
+ se = make_stock_entry(item_code="_Test Item", do_not_save=True, qty=0.001, rate=100)
+ se.items[0].uom = "Kg"
+ se.items[0].conversion_factor = 0.002
+
+ self.assertRaises(frappe.ValidationError, se.save)
+
def make_serialized_item(**args):
args = frappe._dict(args)
diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.js b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.js
index 42cc7e6..23018aa 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.js
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.js
@@ -3,6 +3,6 @@
frappe.ui.form.on('Stock Ledger Entry', {
refresh: function(frm) {
-
+ frm.page.btn_secondary.hide()
}
});
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 5c1da42..329cd7d 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
@@ -209,6 +209,11 @@
msg += "<br>" + "<br>".join(authorized_users)
frappe.throw(msg, BackDatedStockTransaction, title=_("Backdated Stock Entry"))
+ def on_cancel(self):
+ msg = _("Individual Stock Ledger Entry cannot be cancelled.")
+ msg += "<br>" + _("Please cancel related transaction.")
+ frappe.throw(msg)
+
def on_doctype_update():
if not frappe.db.has_index("tabStock Ledger Entry", "posting_sort_index"):
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 42956a1..6561362 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
@@ -436,7 +436,7 @@
item_code=subcontracted_item,
qty=10,
rate=20,
- is_subcontracted="Yes",
+ is_subcontracted=1,
)
self.assertEqual(pr1.items[0].valuation_rate, 120)
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js
index 84f65a0..05dd105 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js
@@ -55,6 +55,25 @@
}
},
+ scan_barcode: function(frm) {
+ const barcode_scanner = new erpnext.utils.BarcodeScanner({frm:frm});
+ barcode_scanner.process_scan();
+ },
+
+ scan_mode: function(frm) {
+ if (frm.doc.scan_mode) {
+ frappe.show_alert({
+ message: __("Scan mode enabled, existing quantity will not be fetched."),
+ indicator: "green"
+ });
+ }
+ },
+
+ set_warehouse: function(frm) {
+ let transaction_controller = new erpnext.TransactionController({frm:frm});
+ transaction_controller.autofill_warehouse(frm.doc.items, "warehouse", frm.doc.set_warehouse);
+ },
+
get_items: function(frm) {
let fields = [
{
@@ -148,35 +167,25 @@
batch_no: d.batch_no
},
callback: function(r) {
- frappe.model.set_value(cdt, cdn, "qty", r.message.qty);
+ const row = frappe.model.get_doc(cdt, cdn);
+ if (!frm.doc.scan_mode) {
+ frappe.model.set_value(cdt, cdn, "qty", r.message.qty);
+ }
frappe.model.set_value(cdt, cdn, "valuation_rate", r.message.rate);
frappe.model.set_value(cdt, cdn, "current_qty", r.message.qty);
frappe.model.set_value(cdt, cdn, "current_valuation_rate", r.message.rate);
frappe.model.set_value(cdt, cdn, "current_amount", r.message.rate * r.message.qty);
- frappe.model.set_value(cdt, cdn, "amount", r.message.rate * r.message.qty);
+ frappe.model.set_value(cdt, cdn, "amount", row.qty * row.valuation_rate);
frappe.model.set_value(cdt, cdn, "current_serial_no", r.message.serial_nos);
- if (frm.doc.purpose == "Stock Reconciliation") {
+ if (frm.doc.purpose == "Stock Reconciliation" && !frm.doc.scan_mode) {
frappe.model.set_value(cdt, cdn, "serial_no", r.message.serial_nos);
}
}
});
}
},
- set_item_code: function(doc, cdt, cdn) {
- var d = frappe.model.get_doc(cdt, cdn);
- if (d.barcode) {
- frappe.call({
- method: "erpnext.stock.get_item_details.get_item_code",
- args: {"barcode": d.barcode },
- callback: function(r) {
- if (!r.exe){
- frappe.model.set_value(cdt, cdn, "item_code", r.message);
- }
- }
- });
- }
- },
+
set_amount_quantity: function(doc, cdt, cdn) {
var d = frappe.model.get_doc(cdt, cdn);
if (d.qty & d.valuation_rate) {
@@ -214,13 +223,10 @@
});
frappe.ui.form.on("Stock Reconciliation Item", {
- barcode: function(frm, cdt, cdn) {
- frm.events.set_item_code(frm, cdt, cdn);
- },
warehouse: function(frm, cdt, cdn) {
var child = locals[cdt][cdn];
- if (child.batch_no) {
+ if (child.batch_no && !frm.doc.scan_mode) {
frappe.model.set_value(child.cdt, child.cdn, "batch_no", "");
}
@@ -229,7 +235,7 @@
item_code: function(frm, cdt, cdn) {
var child = locals[cdt][cdn];
- if (child.batch_no) {
+ if (child.batch_no && !frm.doc.scan_mode) {
frappe.model.set_value(cdt, cdn, "batch_no", "");
}
@@ -255,7 +261,14 @@
const serial_nos = child.serial_no.trim().split('\n');
frappe.model.set_value(cdt, cdn, "qty", serial_nos.length);
}
- }
+ },
+
+ items_add: function(frm, cdt, cdn) {
+ var item = frappe.get_doc(cdt, cdn);
+ if (!item.warehouse && frm.doc.set_warehouse) {
+ frappe.model.set_value(cdt, cdn, "warehouse", frm.doc.set_warehouse);
+ }
+ },
});
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
index a882a61..e545b8e 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
@@ -14,6 +14,12 @@
"posting_date",
"posting_time",
"set_posting_time",
+ "section_break_8",
+ "set_warehouse",
+ "section_break_22",
+ "scan_barcode",
+ "column_break_12",
+ "scan_mode",
"sb9",
"items",
"section_break_9",
@@ -139,13 +145,44 @@
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_8",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "scan_barcode",
+ "fieldtype": "Data",
+ "label": "Scan Barcode",
+ "options": "Barcode"
+ },
+ {
+ "default": "0",
+ "description": "Disables auto-fetching of existing quantity",
+ "fieldname": "scan_mode",
+ "fieldtype": "Check",
+ "label": "Scan Mode"
+ },
+ {
+ "fieldname": "set_warehouse",
+ "fieldtype": "Link",
+ "label": "Default Warehouse",
+ "options": "Warehouse"
+ },
+ {
+ "fieldname": "section_break_22",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_12",
+ "fieldtype": "Column Break"
}
],
"icon": "fa fa-upload-alt",
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-02-06 14:28:19.043905",
+ "modified": "2022-03-27 08:57:47.161959",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation",
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index 07a8566..5d5a27f 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
+from typing import Optional
import frappe
from frappe import _, msgprint
@@ -706,29 +707,43 @@
@frappe.whitelist()
def get_stock_balance_for(
- item_code, warehouse, posting_date, posting_time, batch_no=None, with_valuation_rate=True
+ item_code: str,
+ warehouse: str,
+ posting_date: str,
+ posting_time: str,
+ batch_no: Optional[str] = None,
+ with_valuation_rate: bool = True,
):
frappe.has_permission("Stock Reconciliation", "write", throw=True)
- item_dict = frappe.db.get_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1)
+ item_dict = frappe.get_cached_value(
+ "Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1
+ )
if not item_dict:
# In cases of data upload to Items table
msg = _("Item {} does not exist.").format(item_code)
frappe.throw(msg, title=_("Missing"))
- serial_nos = ""
- with_serial_no = True if item_dict.get("has_serial_no") else False
+ serial_nos = None
+ has_serial_no = bool(item_dict.get("has_serial_no"))
+ has_batch_no = bool(item_dict.get("has_batch_no"))
+
+ if not batch_no and has_batch_no:
+ # Not enough information to fetch data
+ return {"qty": 0, "rate": 0, "serial_nos": None}
+
+ # TODO: fetch only selected batch's values
data = get_stock_balance(
item_code,
warehouse,
posting_date,
posting_time,
with_valuation_rate=with_valuation_rate,
- with_serial_no=with_serial_no,
+ with_serial_no=has_serial_no,
)
- if with_serial_no:
+ if has_serial_no:
qty, rate, serial_nos = data
else:
qty, rate = data
diff --git a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
index 6bbba05..79c2fcc 100644
--- a/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
+++ b/erpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json
@@ -16,15 +16,15 @@
"amount",
"allow_zero_valuation_rate",
"serial_no_and_batch_section",
- "serial_no",
- "column_break_11",
"batch_no",
+ "column_break_11",
+ "serial_no",
"section_break_3",
"current_qty",
- "current_serial_no",
+ "current_amount",
"column_break_9",
"current_valuation_rate",
- "current_amount",
+ "current_serial_no",
"section_break_14",
"quantity_difference",
"column_break_16",
@@ -181,7 +181,7 @@
],
"istable": 1,
"links": [],
- "modified": "2021-05-21 12:13:33.041266",
+ "modified": "2022-04-02 04:19:40.380587",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation Item",
@@ -190,5 +190,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py
index 1e9d01a..5a7228a 100644
--- a/erpnext/stock/doctype/warehouse/test_warehouse.py
+++ b/erpnext/stock/doctype/warehouse/test_warehouse.py
@@ -38,6 +38,16 @@
self.assertEqual(p_warehouse.name, child_warehouse.parent_warehouse)
self.assertEqual(child_warehouse.is_group, 0)
+ def test_naming(self):
+ company = "Wind Power LLC"
+ warehouse_name = "Named Warehouse - WP"
+ wh = frappe.get_doc(doctype="Warehouse", warehouse_name=warehouse_name, company=company).insert()
+ self.assertEqual(wh.name, warehouse_name)
+
+ warehouse_name = "Unnamed Warehouse"
+ wh = frappe.get_doc(doctype="Warehouse", warehouse_name=warehouse_name, company=company).insert()
+ self.assertIn(warehouse_name, wh.name)
+
def test_unlinking_warehouse_from_item_defaults(self):
company = "_Test Company"
diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py
index c892ba3..3b18a9a 100644
--- a/erpnext/stock/doctype/warehouse/warehouse.py
+++ b/erpnext/stock/doctype/warehouse/warehouse.py
@@ -21,8 +21,9 @@
suffix = " - " + frappe.get_cached_value("Company", self.company, "abbr")
if not self.warehouse_name.endswith(suffix):
self.name = self.warehouse_name + suffix
- else:
- self.name = self.warehouse_name
+ return
+
+ self.name = self.warehouse_name
def onload(self):
"""load account name for General Ledger Report"""
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index f72588e..324ff4f 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -50,7 +50,7 @@
"transaction_date": None,
"conversion_rate": 1.0,
"buying_price_list": None,
- "is_subcontracted": "Yes" / "No",
+ "is_subcontracted": 0/1,
"ignore_pricing_rule": 0/1
"project": ""
"set_warehouse": ""
@@ -124,7 +124,7 @@
if args.transaction_date and item.lead_time_days:
out.schedule_date = out.lead_time_date = add_days(args.transaction_date, item.lead_time_days)
- if args.get("is_subcontracted") == "Yes":
+ if args.get("is_subcontracted"):
out.bom = args.get("bom") or get_default_bom(args.item_code)
get_gross_profit(out)
@@ -167,6 +167,9 @@
reserved_so = get_so_reservation_for_item(args)
out.serial_no = get_serial_no(out, args.serial_no, sales_order=reserved_so)
+ if not out.serial_no:
+ out.pop("serial_no", None)
+
def set_valuation_rate(out, args):
if frappe.db.exists("Product Bundle", args.item_code, cache=True):
@@ -237,7 +240,7 @@
throw(_("Item {0} is a template, please select one of its variants").format(item.name))
elif args.transaction_type == "buying" and args.doctype != "Material Request":
- if args.get("is_subcontracted") == "Yes" and item.is_sub_contracted_item != 1:
+ if args.get("is_subcontracted") and item.is_sub_contracted_item != 1:
throw(_("Item {0} must be a Sub-contracted Item").format(item.name))
@@ -258,7 +261,7 @@
"transaction_date": None,
"conversion_rate": 1.0,
"buying_price_list": None,
- "is_subcontracted": "Yes" / "No",
+ "is_subcontracted": 0/1,
"ignore_pricing_rule": 0/1
"project": "",
barcode: "",
@@ -342,6 +345,7 @@
"expense_account": expense_account
or get_default_expense_account(args, item_defaults, item_group_defaults, brand_defaults),
"discount_account": get_default_discount_account(args, item_defaults),
+ "provisional_expense_account": get_provisional_account(args, item_defaults),
"cost_center": get_default_cost_center(
args, item_defaults, item_group_defaults, brand_defaults
),
@@ -696,6 +700,10 @@
)
+def get_provisional_account(args, item):
+ return item.get("default_provisional_account") or args.default_provisional_account
+
+
def get_default_discount_account(args, item):
return item.get("default_discount_account") or args.discount_account
diff --git a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.js b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.js
index ea27dd2..61927f5 100644
--- a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.js
+++ b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.js
@@ -68,7 +68,7 @@
options: [
{fieldname: 'stock_capacity', label: __('Capacity (Stock UOM)')},
{fieldname: 'percent_occupied', label: __('% Occupied')},
- {fieldname: 'actual_qty', label: __('Balance Qty (Stock ')}
+ {fieldname: 'actual_qty', label: __('Balance Qty (Stock)')}
]
},
change: function(sort_by, sort_order) {
diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py
index a96ffef..ee151b7 100644
--- a/erpnext/stock/reorder_item.py
+++ b/erpnext/stock/reorder_item.py
@@ -246,8 +246,7 @@
_("Dear System Manager,")
+ "<br>"
+ _(
- "An error occured for certain Items while creating Material Requests based on Re-order level. \
- Please rectify these issues :"
+ "An error occured for certain Items while creating Material Requests based on Re-order level. Please rectify these issues :"
)
+ "<br>"
)
diff --git a/erpnext/stock/report/bom_search/bom_search.py b/erpnext/stock/report/bom_search/bom_search.py
index 3be87ab..56a65c3 100644
--- a/erpnext/stock/report/bom_search/bom_search.py
+++ b/erpnext/stock/report/bom_search/bom_search.py
@@ -3,6 +3,7 @@
import frappe
+from frappe import _
def execute(filters=None):
@@ -34,10 +35,10 @@
return [
{
"fieldname": "parent",
- "label": "BOM",
+ "label": _("BOM"),
"width": 200,
"fieldtype": "Dynamic Link",
"options": "doctype",
},
- {"fieldname": "doctype", "label": "Type", "width": 200, "fieldtype": "Data"},
+ {"fieldname": "doctype", "label": _("Type"), "width": 200, "fieldtype": "Data"},
], data
diff --git a/erpnext/stock/report/item_variant_details/item_variant_details.py b/erpnext/stock/report/item_variant_details/item_variant_details.py
index d1bf220..e3a2a65 100644
--- a/erpnext/stock/report/item_variant_details/item_variant_details.py
+++ b/erpnext/stock/report/item_variant_details/item_variant_details.py
@@ -71,7 +71,7 @@
columns = [
{
"fieldname": "variant_name",
- "label": "Variant",
+ "label": _("Variant"),
"fieldtype": "Link",
"options": "Item",
"width": 200,
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index afbc6fe..6369f91 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -3,24 +3,41 @@
from operator import itemgetter
+from typing import Any, Dict, List, Optional, TypedDict
import frappe
from frappe import _
+from frappe.query_builder.functions import CombineDatetime
from frappe.utils import cint, date_diff, flt, getdate
+from frappe.utils.nestedset import get_descendants_of
+from pypika.terms import ExistsCriterion
import erpnext
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
-from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
from erpnext.stock.utils import add_additional_uom_columns, is_reposting_item_valuation_in_progress
-def execute(filters=None):
+class StockBalanceFilter(TypedDict):
+ company: Optional[str]
+ from_date: str
+ to_date: str
+ item_group: Optional[str]
+ item: Optional[str]
+ warehouse: Optional[str]
+ warehouse_type: Optional[str]
+ include_uom: Optional[str] # include extra info in converted UOM
+ show_stock_ageing_data: bool
+ show_variant_attributes: bool
+
+
+SLEntry = Dict[str, Any]
+
+
+def execute(filters: Optional[StockBalanceFilter] = None):
is_reposting_item_valuation_in_progress()
if not filters:
filters = {}
- to_date = filters.get("to_date")
-
if filters.get("company"):
company_currency = erpnext.get_company_currency(filters.get("company"))
else:
@@ -48,6 +65,7 @@
_func = itemgetter(1)
+ to_date = filters.get("to_date")
for (company, item, warehouse) in sorted(iwb_map):
if item_map.get(item):
qty_dict = iwb_map[(company, item, warehouse)]
@@ -92,7 +110,7 @@
return columns, data
-def get_columns(filters):
+def get_columns(filters: StockBalanceFilter):
"""return columns"""
columns = [
{
@@ -215,66 +233,77 @@
return columns
-def get_conditions(filters):
- conditions = ""
+def apply_conditions(query, filters):
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ warehouse_table = frappe.qb.DocType("Warehouse")
+
if not filters.get("from_date"):
frappe.throw(_("'From Date' is required"))
- if filters.get("to_date"):
- conditions += " and sle.posting_date <= %s" % frappe.db.escape(filters.get("to_date"))
+ if to_date := filters.get("to_date"):
+ query = query.where(sle.posting_date <= to_date)
else:
frappe.throw(_("'To Date' is required"))
- if filters.get("company"):
- conditions += " and sle.company = %s" % frappe.db.escape(filters.get("company"))
+ if company := filters.get("company"):
+ query = query.where(sle.company == company)
- if filters.get("warehouse"):
- warehouse_details = frappe.db.get_value(
- "Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1
- )
- if warehouse_details:
- conditions += (
- " and exists (select name from `tabWarehouse` wh \
- where wh.lft >= %s and wh.rgt <= %s and sle.warehouse = wh.name)"
- % (warehouse_details.lft, warehouse_details.rgt)
+ if warehouse := filters.get("warehouse"):
+ lft, rgt = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"])
+ chilren_subquery = (
+ frappe.qb.from_(warehouse_table)
+ .select(warehouse_table.name)
+ .where(
+ (warehouse_table.lft >= lft)
+ & (warehouse_table.rgt <= rgt)
+ & (warehouse_table.name == sle.warehouse)
)
-
- if filters.get("warehouse_type") and not filters.get("warehouse"):
- conditions += (
- " and exists (select name from `tabWarehouse` wh \
- where wh.warehouse_type = '%s' and sle.warehouse = wh.name)"
- % (filters.get("warehouse_type"))
+ )
+ query = query.where(ExistsCriterion(chilren_subquery))
+ elif warehouse_type := filters.get("warehouse_type"):
+ query = (
+ query.join(warehouse_table)
+ .on(warehouse_table.name == sle.warehouse)
+ .where(warehouse_table.warehouse_type == warehouse_type)
)
- return conditions
+ return query
-def get_stock_ledger_entries(filters, items):
- item_conditions_sql = ""
- if items:
- item_conditions_sql = " and sle.item_code in ({})".format(
- ", ".join(frappe.db.escape(i, percent=False) for i in items)
+def get_stock_ledger_entries(filters: StockBalanceFilter, items: List[str]) -> List[SLEntry]:
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+
+ query = (
+ frappe.qb.from_(sle)
+ .select(
+ sle.item_code,
+ sle.warehouse,
+ sle.posting_date,
+ sle.actual_qty,
+ sle.valuation_rate,
+ sle.company,
+ sle.voucher_type,
+ sle.qty_after_transaction,
+ sle.stock_value_difference,
+ sle.item_code.as_("name"),
+ sle.voucher_no,
+ sle.stock_value,
+ sle.batch_no,
)
-
- conditions = get_conditions(filters)
-
- return frappe.db.sql(
- """
- select
- sle.item_code, warehouse, sle.posting_date, sle.actual_qty, sle.valuation_rate,
- sle.company, sle.voucher_type, sle.qty_after_transaction, sle.stock_value_difference,
- sle.item_code as name, sle.voucher_no, sle.stock_value, sle.batch_no
- from
- `tabStock Ledger Entry` sle
- where sle.docstatus < 2 %s %s
- and is_cancelled = 0
- order by sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty"""
- % (item_conditions_sql, conditions), # nosec
- as_dict=1,
+ .where((sle.docstatus < 2) & (sle.is_cancelled == 0))
+ .orderby(CombineDatetime(sle.posting_date, sle.posting_time))
+ .orderby(sle.creation)
+ .orderby(sle.actual_qty)
)
+ if items:
+ query = query.where(sle.item_code.isin(items))
-def get_item_warehouse_map(filters, sle):
+ query = apply_conditions(query, filters)
+ return query.run(as_dict=True)
+
+
+def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]):
iwb_map = {}
from_date = getdate(filters.get("from_date"))
to_date = getdate(filters.get("to_date"))
@@ -332,7 +361,7 @@
return iwb_map
-def filter_items_with_no_transactions(iwb_map, float_precision):
+def filter_items_with_no_transactions(iwb_map, float_precision: float):
for (company, item, warehouse) in sorted(iwb_map):
qty_dict = iwb_map[(company, item, warehouse)]
@@ -349,26 +378,22 @@
return iwb_map
-def get_items(filters):
+def get_items(filters: StockBalanceFilter) -> List[str]:
"Get items based on item code, item group or brand."
- conditions = []
- if filters.get("item_code"):
- conditions.append("item.name=%(item_code)s")
+ if item_code := filters.get("item_code"):
+ return [item_code]
else:
- if filters.get("item_group"):
- conditions.append(get_item_group_condition(filters.get("item_group")))
- if filters.get("brand"): # used in stock analytics report
- conditions.append("item.brand=%(brand)s")
+ item_filters = {}
+ if item_group := filters.get("item_group"):
+ children = get_descendants_of("Item Group", item_group, ignore_permissions=True)
+ item_filters["item_group"] = ("in", children + [item_group])
+ if brand := filters.get("brand"):
+ item_filters["brand"] = brand
- items = []
- if conditions:
- items = frappe.db.sql_list(
- """select name from `tabItem` item where {}""".format(" and ".join(conditions)), filters
- )
- return items
+ return frappe.get_all("Item", filters=item_filters, pluck="name", order_by=None)
-def get_item_details(items, sle, filters):
+def get_item_details(items: List[str], sle: List[SLEntry], filters: StockBalanceFilter):
item_details = {}
if not items:
items = list(set(d.item_code for d in sle))
@@ -376,33 +401,35 @@
if not items:
return item_details
- cf_field = cf_join = ""
- if filters.get("include_uom"):
- cf_field = ", ucd.conversion_factor"
- cf_join = (
- "left join `tabUOM Conversion Detail` ucd on ucd.parent=item.name and ucd.uom=%s"
- % frappe.db.escape(filters.get("include_uom"))
- )
+ item_table = frappe.qb.DocType("Item")
- res = frappe.db.sql(
- """
- select
- item.name, item.item_name, item.description, item.item_group, item.brand, item.stock_uom %s
- from
- `tabItem` item
- %s
- where
- item.name in (%s)
- """
- % (cf_field, cf_join, ",".join(["%s"] * len(items))),
- items,
- as_dict=1,
+ query = (
+ frappe.qb.from_(item_table)
+ .select(
+ item_table.name,
+ item_table.item_name,
+ item_table.description,
+ item_table.item_group,
+ item_table.brand,
+ item_table.stock_uom,
+ )
+ .where(item_table.name.isin(items))
)
- for item in res:
- item_details.setdefault(item.name, item)
+ if uom := filters.get("include_uom"):
+ uom_conv_detail = frappe.qb.DocType("UOM Conversion Detail")
+ query = (
+ query.left_join(uom_conv_detail)
+ .on((uom_conv_detail.parent == item_table.name) & (uom_conv_detail.uom == uom))
+ .select(uom_conv_detail.conversion_factor)
+ )
- if filters.get("show_variant_attributes", 0) == 1:
+ result = query.run(as_dict=1)
+
+ for item_table in result:
+ item_details.setdefault(item_table.name, item_table)
+
+ if filters.get("show_variant_attributes"):
variant_values = get_variant_values_for(list(item_details))
item_details = {k: v.update(variant_values.get(k, {})) for k, v in item_details.items()}
@@ -413,36 +440,33 @@
item_reorder_details = frappe._dict()
if items:
- item_reorder_details = frappe.db.sql(
- """
- select parent, warehouse, warehouse_reorder_qty, warehouse_reorder_level
- from `tabItem Reorder`
- where parent in ({0})
- """.format(
- ", ".join(frappe.db.escape(i, percent=False) for i in items)
- ),
- as_dict=1,
+ item_reorder_details = frappe.get_all(
+ "Item Reorder",
+ ["parent", "warehouse", "warehouse_reorder_qty", "warehouse_reorder_level"],
+ filters={"parent": ("in", items)},
)
return dict((d.parent + d.warehouse, d) for d in item_reorder_details)
-def get_variants_attributes():
+def get_variants_attributes() -> List[str]:
"""Return all item variant attributes."""
- return [i.name for i in frappe.get_all("Item Attribute")]
+ return frappe.get_all("Item Attribute", pluck="name")
def get_variant_values_for(items):
"""Returns variant values for items."""
attribute_map = {}
- for attr in frappe.db.sql(
- """select parent, attribute, attribute_value
- from `tabItem Variant Attribute` where parent in (%s)
- """
- % ", ".join(["%s"] * len(items)),
- tuple(items),
- as_dict=1,
- ):
+
+ attribute_info = frappe.get_all(
+ "Item Variant Attribute",
+ ["parent", "attribute", "attribute_value"],
+ {
+ "parent": ("in", items),
+ },
+ )
+
+ for attr in attribute_info:
attribute_map.setdefault(attr["parent"], {})
attribute_map[attr["parent"]].update({attr["attribute"]: attr["attribute_value"]})
diff --git a/erpnext/stock/report/stock_balance/test_stock_balance.py b/erpnext/stock/report/stock_balance/test_stock_balance.py
new file mode 100644
index 0000000..e963de2
--- /dev/null
+++ b/erpnext/stock/report/stock_balance/test_stock_balance.py
@@ -0,0 +1,174 @@
+from typing import Any, Dict
+
+import frappe
+from frappe import _dict
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import today
+
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.stock.report.stock_balance.stock_balance import execute
+
+
+def stock_balance(filters):
+ """Get rows from stock balance report"""
+ return [_dict(row) for row in execute(filters)[1]]
+
+
+class TestStockBalance(FrappeTestCase):
+ # ----------- utils
+
+ def setUp(self):
+ self.item = make_item()
+ self.filters = _dict(
+ {
+ "company": "_Test Company",
+ "item_code": self.item.name,
+ "from_date": "2020-01-01",
+ "to_date": str(today()),
+ }
+ )
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def assertPartialDictEq(self, expected: Dict[str, Any], actual: Dict[str, Any]):
+ for k, v in expected.items():
+ self.assertEqual(v, actual[k], msg=f"{expected=}\n{actual=}")
+
+ def generate_stock_ledger(self, item_code: str, movements):
+
+ for movement in map(_dict, movements):
+ if "to_warehouse" not in movement:
+ movement.to_warehouse = "_Test Warehouse - _TC"
+ make_stock_entry(item_code=item_code, **movement)
+
+ def assertInvariants(self, rows):
+ last_balance = frappe.db.sql(
+ """
+ WITH last_balances AS (
+ SELECT item_code, warehouse,
+ stock_value, qty_after_transaction,
+ ROW_NUMBER() OVER (PARTITION BY item_code, warehouse
+ ORDER BY timestamp(posting_date, posting_time) desc, creation desc)
+ AS rn
+ FROM `tabStock Ledger Entry`
+ where is_cancelled=0
+ )
+ SELECT * FROM last_balances WHERE rn = 1""",
+ as_dict=True,
+ )
+
+ item_wh_stock = _dict()
+
+ for line in last_balance:
+ item_wh_stock.setdefault((line.item_code, line.warehouse), line)
+
+ for row in rows:
+ msg = f"Invariants not met for {rows=}"
+ # qty invariant
+ self.assertAlmostEqual(row.bal_qty, row.opening_qty + row.in_qty - row.out_qty, msg)
+
+ # value invariant
+ self.assertAlmostEqual(row.bal_val, row.opening_val + row.in_val - row.out_val, msg)
+
+ # check against SLE
+ last_sle = item_wh_stock[(row.item_code, row.warehouse)]
+ self.assertAlmostEqual(row.bal_qty, last_sle.qty_after_transaction, 3)
+ self.assertAlmostEqual(row.bal_val, last_sle.stock_value, 3)
+
+ # valuation rate
+ if not row.bal_qty:
+ continue
+ self.assertAlmostEqual(row.val_rate, row.bal_val / row.bal_qty, 3, msg)
+
+ # ----------- tests
+
+ def test_basic_stock_balance(self):
+ """Check very basic functionality and item info"""
+ rows = stock_balance(self.filters)
+ self.assertEqual(rows, [])
+
+ self.generate_stock_ledger(self.item.name, [_dict(qty=5, rate=10)])
+
+ # check item info
+ rows = stock_balance(self.filters)
+ self.assertPartialDictEq(
+ {
+ "item_code": self.item.name,
+ "item_name": self.item.item_name,
+ "item_group": self.item.item_group,
+ "stock_uom": self.item.stock_uom,
+ "in_qty": 5,
+ "in_val": 50,
+ "val_rate": 10,
+ },
+ rows[0],
+ )
+ self.assertInvariants(rows)
+
+ def test_opening_balance(self):
+ self.generate_stock_ledger(
+ self.item.name,
+ [
+ _dict(qty=1, rate=1, posting_date="2021-01-01"),
+ _dict(qty=2, rate=2, posting_date="2021-01-02"),
+ _dict(qty=3, rate=3, posting_date="2021-01-03"),
+ ],
+ )
+ rows = stock_balance(self.filters)
+ self.assertInvariants(rows)
+
+ rows = stock_balance(self.filters.update({"from_date": "2021-01-02"}))
+ self.assertInvariants(rows)
+ self.assertPartialDictEq({"opening_qty": 1, "in_qty": 5}, rows[0])
+
+ rows = stock_balance(self.filters.update({"from_date": "2022-01-01"}))
+ self.assertInvariants(rows)
+ self.assertPartialDictEq({"opening_qty": 6, "in_qty": 0}, rows[0])
+
+ def test_uom_converted_info(self):
+
+ self.item.append("uoms", {"conversion_factor": 5, "uom": "Box"})
+ self.item.save()
+
+ self.generate_stock_ledger(self.item.name, [_dict(qty=5, rate=10)])
+
+ rows = stock_balance(self.filters.update({"include_uom": "Box"}))
+ self.assertEqual(rows[0].bal_qty_alt, 1)
+ self.assertInvariants(rows)
+
+ def test_item_group(self):
+ self.filters.pop("item_code", None)
+ rows = stock_balance(self.filters.update({"item_group": self.item.item_group}))
+ self.assertTrue(all(r.item_group == self.item.item_group for r in rows))
+
+ def test_child_warehouse_balances(self):
+ # This is default
+ self.generate_stock_ledger(self.item.name, [_dict(qty=5, rate=10, to_warehouse="Stores - _TC")])
+
+ self.filters.pop("item_code", None)
+ rows = stock_balance(self.filters.update({"warehouse": "All Warehouses - _TC"}))
+
+ self.assertTrue(
+ any(r.item_code == self.item.name and r.warehouse == "Stores - _TC" for r in rows),
+ msg=f"Expected child warehouse balances \n{rows}",
+ )
+
+ def test_show_item_attr(self):
+ from erpnext.controllers.item_variant import create_variant
+
+ self.item.has_variants = True
+ self.item.append("attributes", {"attribute": "Test Size"})
+ self.item.save()
+
+ attributes = {"Test Size": "Large"}
+ variant = create_variant(self.item.name, attributes)
+ variant.save()
+
+ self.generate_stock_ledger(variant.name, [_dict(qty=5, rate=10)])
+ rows = stock_balance(
+ self.filters.update({"show_variant_attributes": 1, "item_code": variant.name})
+ )
+ self.assertPartialDictEq(attributes, rows[0])
+ self.assertInvariants(rows)
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
index 6cc9061..837c4a6 100644
--- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
+++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
@@ -4,6 +4,7 @@
import json
import frappe
+from frappe import _
SLE_FIELDS = (
"name",
@@ -105,155 +106,155 @@
{
"fieldname": "name",
"fieldtype": "Link",
- "label": "Stock Ledger Entry",
+ "label": _("Stock Ledger Entry"),
"options": "Stock Ledger Entry",
},
{
"fieldname": "posting_date",
"fieldtype": "Date",
- "label": "Posting Date",
+ "label": _("Posting Date"),
},
{
"fieldname": "posting_time",
"fieldtype": "Time",
- "label": "Posting Time",
+ "label": _("Posting Time"),
},
{
"fieldname": "creation",
"fieldtype": "Datetime",
- "label": "Creation",
+ "label": _("Creation"),
},
{
"fieldname": "voucher_type",
"fieldtype": "Link",
- "label": "Voucher Type",
+ "label": _("Voucher Type"),
"options": "DocType",
},
{
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
- "label": "Voucher No",
+ "label": _("Voucher No"),
"options": "voucher_type",
},
{
"fieldname": "batch_no",
"fieldtype": "Link",
- "label": "Batch",
+ "label": _("Batch"),
"options": "Batch",
},
{
"fieldname": "use_batchwise_valuation",
"fieldtype": "Check",
- "label": "Batchwise Valuation",
+ "label": _("Batchwise Valuation"),
},
{
"fieldname": "actual_qty",
"fieldtype": "Float",
- "label": "Qty Change",
+ "label": _("Qty Change"),
},
{
"fieldname": "incoming_rate",
"fieldtype": "Float",
- "label": "Incoming Rate",
+ "label": _("Incoming Rate"),
},
{
"fieldname": "consumption_rate",
"fieldtype": "Float",
- "label": "Consumption Rate",
+ "label": _("Consumption Rate"),
},
{
"fieldname": "qty_after_transaction",
"fieldtype": "Float",
- "label": "(A) Qty After Transaction",
+ "label": _("(A) Qty After Transaction"),
},
{
"fieldname": "expected_qty_after_transaction",
"fieldtype": "Float",
- "label": "(B) Expected Qty After Transaction",
+ "label": _("(B) Expected Qty After Transaction"),
},
{
"fieldname": "difference_in_qty",
"fieldtype": "Float",
- "label": "A - B",
+ "label": _("A - B"),
},
{
"fieldname": "stock_queue",
"fieldtype": "Data",
- "label": "FIFO/LIFO Queue",
+ "label": _("FIFO/LIFO Queue"),
},
{
"fieldname": "fifo_queue_qty",
"fieldtype": "Float",
- "label": "(C) Total qty in queue",
+ "label": _("(C) Total qty in queue"),
},
{
"fieldname": "fifo_qty_diff",
"fieldtype": "Float",
- "label": "A - C",
+ "label": _("A - C"),
},
{
"fieldname": "stock_value",
"fieldtype": "Float",
- "label": "(D) Balance Stock Value",
+ "label": _("(D) Balance Stock Value"),
},
{
"fieldname": "fifo_stock_value",
"fieldtype": "Float",
- "label": "(E) Balance Stock Value in Queue",
+ "label": _("(E) Balance Stock Value in Queue"),
},
{
"fieldname": "fifo_value_diff",
"fieldtype": "Float",
- "label": "D - E",
+ "label": _("D - E"),
},
{
"fieldname": "stock_value_difference",
"fieldtype": "Float",
- "label": "(F) Stock Value Difference",
+ "label": _("(F) Stock Value Difference"),
},
{
"fieldname": "stock_value_from_diff",
"fieldtype": "Float",
- "label": "Balance Stock Value using (F)",
+ "label": _("Balance Stock Value using (F)"),
},
{
"fieldname": "diff_value_diff",
"fieldtype": "Float",
- "label": "K - D",
+ "label": _("K - D"),
},
{
"fieldname": "fifo_stock_diff",
"fieldtype": "Float",
- "label": "(G) Stock Value difference (FIFO queue)",
+ "label": _("(G) Stock Value difference (FIFO queue)"),
},
{
"fieldname": "fifo_difference_diff",
"fieldtype": "Float",
- "label": "F - G",
+ "label": _("F - G"),
},
{
"fieldname": "valuation_rate",
"fieldtype": "Float",
- "label": "(H) Valuation Rate",
+ "label": _("(H) Valuation Rate"),
},
{
"fieldname": "fifo_valuation_rate",
"fieldtype": "Float",
- "label": "(I) Valuation Rate as per FIFO",
+ "label": _("(I) Valuation Rate as per FIFO"),
},
{
"fieldname": "fifo_valuation_diff",
"fieldtype": "Float",
- "label": "H - I",
+ "label": _("H - I"),
},
{
"fieldname": "balance_value_by_qty",
"fieldtype": "Float",
- "label": "(J) Valuation = Value (D) ÷ Qty (A)",
+ "label": _("(J) Valuation = Value (D) ÷ Qty (A)"),
},
{
"fieldname": "valuation_diff",
"fieldtype": "Float",
- "label": "H - J",
+ "label": _("H - J"),
},
]
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 967b2b2..b7fd65b 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -178,9 +178,9 @@
)
if repost_entry.status == "Queued":
doc = frappe.get_doc("Repost Item Valuation", repost_entry.name)
+ doc.status = "Skipped"
doc.flags.ignore_permissions = True
doc.cancel()
- doc.delete()
def set_as_cancel(voucher_type, voucher_no):
@@ -715,7 +715,7 @@
)
# Recalculate subcontracted item's rate in case of subcontracted purchase receipt/invoice
- if frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_subcontracted") == "Yes":
+ if frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "is_subcontracted"):
doc = frappe.get_doc(sle.voucher_type, sle.voucher_no)
doc.update_valuation_rate(reset_outgoing_rate=False)
for d in doc.items + doc.supplied_items:
diff --git a/erpnext/stock/tests/test_utils.py b/erpnext/stock/tests/test_utils.py
new file mode 100644
index 0000000..9ee0c9f
--- /dev/null
+++ b/erpnext/stock/tests/test_utils.py
@@ -0,0 +1,31 @@
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.utils import scan_barcode
+
+
+class TestStockUtilities(FrappeTestCase):
+ def test_barcode_scanning(self):
+ simple_item = make_item(properties={"barcodes": [{"barcode": "12399"}]})
+ self.assertEqual(scan_barcode("12399")["item_code"], simple_item.name)
+
+ batch_item = make_item(properties={"has_batch_no": 1, "create_new_batch": 1})
+ batch = frappe.get_doc(doctype="Batch", item=batch_item.name).insert()
+
+ batch_scan = scan_barcode(batch.name)
+ self.assertEqual(batch_scan["item_code"], batch_item.name)
+ self.assertEqual(batch_scan["batch_no"], batch.name)
+ self.assertEqual(batch_scan["has_batch_no"], 1)
+ self.assertEqual(batch_scan["has_serial_no"], 0)
+
+ serial_item = make_item(properties={"has_serial_no": 1})
+ serial = frappe.get_doc(
+ doctype="Serial No", item_code=serial_item.name, serial_no=frappe.generate_hash()
+ ).insert()
+
+ serial_scan = scan_barcode(serial.name)
+ self.assertEqual(serial_scan["item_code"], serial_item.name)
+ self.assertEqual(serial_scan["serial_no"], serial.name)
+ self.assertEqual(serial_scan["has_batch_no"], 0)
+ self.assertEqual(serial_scan["has_serial_no"], 1)
diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py
index 506a666..e60c1ca 100644
--- a/erpnext/stock/tests/test_valuation.py
+++ b/erpnext/stock/tests/test_valuation.py
@@ -60,9 +60,9 @@
self.queue.remove_stock(1, 5)
self.assertEqual(self.queue, [[-1, 5]])
- # XXX
- self.queue.remove_stock(1, 10)
+ self.queue.remove_stock(1)
self.assertTotalQty(-2)
+ self.assertEqual(self.queue, [[-2, 5]])
self.queue.add_stock(2, 10)
self.assertTotalQty(0)
@@ -93,7 +93,7 @@
self.queue.remove_stock(3, 20)
self.assertEqual(self.queue, [[1, 10], [5, 20]])
- def test_collapsing_of_queue(self):
+ def test_queue_with_unknown_rate(self):
self.queue.add_stock(1, 1)
self.queue.add_stock(1, 2)
self.queue.add_stock(1, 3)
@@ -102,8 +102,7 @@
self.assertTotalValue(10)
self.queue.remove_stock(3, 1)
- # XXX
- self.assertEqual(self.queue, [[1, 7]])
+ self.assertEqual(self.queue, [[1, 4]])
def test_rounding_off(self):
self.queue.add_stock(1.0, 1.0)
@@ -172,6 +171,32 @@
self.assertTotalQty(total_qty)
self.assertTotalValue(total_value)
+ @given(stock_queue_generator, st.floats(min_value=0.1, max_value=1e6))
+ def test_fifo_qty_value_nonneg_hypothesis_with_outgoing_rate(self, stock_queue, outgoing_rate):
+ self.queue = FIFOValuation([])
+ total_qty = 0.0
+ total_value = 0.0
+
+ for qty, rate in stock_queue:
+ # don't allow negative stock
+ if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
+ continue
+ if qty > 0:
+ self.queue.add_stock(qty, rate)
+ total_qty += qty
+ total_value += qty * rate
+ else:
+ qty = abs(qty)
+ consumed = self.queue.remove_stock(qty, outgoing_rate)
+ self.assertAlmostEqual(
+ qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}"
+ )
+ total_qty -= qty
+ total_value -= sum(q * r for q, r in consumed)
+ self.assertTotalQty(total_qty)
+ self.assertTotalValue(total_value)
+ self.assertGreaterEqual(total_value, 0)
+
class TestLIFOValuation(unittest.TestCase):
def setUp(self):
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index 4f1891f..d40218e 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -3,6 +3,7 @@
import json
+from typing import Dict, Optional
import frappe
from frappe import _
@@ -548,3 +549,51 @@
)
return bool(reposting_pending)
+
+
+@frappe.whitelist()
+def scan_barcode(search_value: str) -> Dict[str, Optional[str]]:
+
+ # search barcode no
+ barcode_data = frappe.db.get_value(
+ "Item Barcode",
+ {"barcode": search_value},
+ ["barcode", "parent as item_code"],
+ as_dict=True,
+ )
+ if barcode_data:
+ return _update_item_info(barcode_data)
+
+ # search serial no
+ serial_no_data = frappe.db.get_value(
+ "Serial No",
+ search_value,
+ ["name as serial_no", "item_code", "batch_no"],
+ as_dict=True,
+ )
+ if serial_no_data:
+ return _update_item_info(serial_no_data)
+
+ # search batch no
+ batch_no_data = frappe.db.get_value(
+ "Batch",
+ search_value,
+ ["name as batch_no", "item as item_code"],
+ as_dict=True,
+ )
+ if batch_no_data:
+ return _update_item_info(batch_no_data)
+
+ return {}
+
+
+def _update_item_info(scan_result: Dict[str, Optional[str]]) -> Dict[str, Optional[str]]:
+ if item_code := scan_result.get("item_code"):
+ if item_info := frappe.get_cached_value(
+ "Item",
+ item_code,
+ ["has_batch_no", "has_serial_no"],
+ as_dict=True,
+ ):
+ scan_result.update(item_info)
+ return scan_result
diff --git a/erpnext/stock/valuation.py b/erpnext/stock/valuation.py
index 648b218..35f4f12 100644
--- a/erpnext/stock/valuation.py
+++ b/erpnext/stock/valuation.py
@@ -60,9 +60,7 @@
# specifying the attributes to save resources
# ref: https://docs.python.org/3/reference/datamodel.html#slots
- __slots__ = [
- "queue",
- ]
+ __slots__ = ["queue"]
def __init__(self, state: Optional[List[StockBin]]):
self.queue: List[StockBin] = state if state is not None else []
@@ -123,15 +121,9 @@
index = idx
break
- # If no entry found with outgoing rate, collapse queue
+ # If no entry found with outgoing rate, consume as per FIFO
if index is None: # nosemgrep
- new_stock_value = sum(d[QTY] * d[RATE] for d in self.queue) - qty * outgoing_rate
- new_stock_qty = sum(d[QTY] for d in self.queue) - qty
- self.queue = [
- [new_stock_qty, new_stock_value / new_stock_qty if new_stock_qty > 0 else outgoing_rate]
- ]
- consumed_bins.append([qty, outgoing_rate])
- break
+ index = 0
else:
index = 0
@@ -172,9 +164,7 @@
# specifying the attributes to save resources
# ref: https://docs.python.org/3/reference/datamodel.html#slots
- __slots__ = [
- "stack",
- ]
+ __slots__ = ["stack"]
def __init__(self, state: Optional[List[StockBin]]):
self.stack: List[StockBin] = state if state is not None else []
diff --git a/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.py b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.py
index 5b51ef8..57fa7bf 100644
--- a/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.py
+++ b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.py
@@ -3,15 +3,16 @@
import frappe
+from frappe import _
def execute(filters=None):
columns = [
- {"fieldname": "creation_date", "label": "Date", "fieldtype": "Date", "width": 300},
+ {"fieldname": "creation_date", "label": _("Date"), "fieldtype": "Date", "width": 300},
{
"fieldname": "first_response_time",
"fieldtype": "Duration",
- "label": "First Response Time",
+ "label": _("First Response Time"),
"width": 300,
},
]
diff --git a/erpnext/telephony/doctype/call_log/call_log.json b/erpnext/telephony/doctype/call_log/call_log.json
index 1d6c39e..a41ddb1 100644
--- a/erpnext/telephony/doctype/call_log/call_log.json
+++ b/erpnext/telephony/doctype/call_log/call_log.json
@@ -1,7 +1,7 @@
{
"actions": [],
"autoname": "field:id",
- "creation": "2019-06-05 12:07:02.634534",
+ "creation": "2022-02-21 11:54:58.414784",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
@@ -9,6 +9,8 @@
"id",
"from",
"to",
+ "call_received_by",
+ "employee_user_id",
"medium",
"start_time",
"end_time",
@@ -20,6 +22,7 @@
"recording_url",
"recording_html",
"section_break_11",
+ "type_of_call",
"summary",
"section_break_19",
"links"
@@ -103,7 +106,8 @@
},
{
"fieldname": "summary",
- "fieldtype": "Small Text"
+ "fieldtype": "Small Text",
+ "label": "Summary"
},
{
"fieldname": "section_break_11",
@@ -134,15 +138,37 @@
"fieldname": "call_details_section",
"fieldtype": "Section Break",
"label": "Call Details"
+ },
+ {
+ "fieldname": "employee_user_id",
+ "fieldtype": "Link",
+ "hidden": 1,
+ "label": "Employee User Id",
+ "options": "User"
+ },
+ {
+ "fieldname": "type_of_call",
+ "fieldtype": "Link",
+ "label": "Type Of Call",
+ "options": "Telephony Call Type"
+ },
+ {
+ "depends_on": "to",
+ "fieldname": "call_received_by",
+ "fieldtype": "Link",
+ "label": "Call Received By",
+ "options": "Employee",
+ "read_only": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-02-08 14:23:28.744844",
+ "modified": "2022-04-14 02:59:22.503202",
"modified_by": "Administrator",
"module": "Telephony",
"name": "Call Log",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -164,6 +190,7 @@
],
"sort_field": "creation",
"sort_order": "DESC",
+ "states": [],
"title_field": "from",
"track_changes": 1,
"track_views": 1
diff --git a/erpnext/telephony/doctype/call_log/call_log.py b/erpnext/telephony/doctype/call_log/call_log.py
index 1c88883..7725e71 100644
--- a/erpnext/telephony/doctype/call_log/call_log.py
+++ b/erpnext/telephony/doctype/call_log/call_log.py
@@ -32,6 +32,10 @@
if lead:
self.add_link(link_type="Lead", link_name=lead)
+ # Add Employee Name
+ if self.is_incoming_call():
+ self.update_received_by()
+
def after_insert(self):
self.trigger_call_popup()
@@ -49,6 +53,9 @@
if not doc_before_save:
return
+ if self.is_incoming_call() and self.has_value_changed("to"):
+ self.update_received_by()
+
if _is_call_missed(doc_before_save, self):
frappe.publish_realtime("call_{id}_missed".format(id=self.id), self)
self.trigger_call_popup()
@@ -65,7 +72,8 @@
def trigger_call_popup(self):
if self.is_incoming_call():
scheduled_employees = get_scheduled_employees_for_popup(self.medium)
- employee_emails = get_employees_with_number(self.to)
+ employees = get_employees_with_number(self.to)
+ employee_emails = [employee.get("user_id") for employee in employees]
# check if employees with matched number are scheduled to receive popup
emails = set(scheduled_employees).intersection(employee_emails)
@@ -85,10 +93,17 @@
for email in emails:
frappe.publish_realtime("show_call_popup", self, user=email)
+ def update_received_by(self):
+ if employees := get_employees_with_number(self.get("to")):
+ self.call_received_by = employees[0].get("name")
+ self.employee_user_id = employees[0].get("user_id")
+
@frappe.whitelist()
-def add_call_summary(call_log, summary):
+def add_call_summary_and_call_type(call_log, summary, call_type):
doc = frappe.get_doc("Call Log", call_log)
+ doc.type_of_call = call_type
+ doc.save()
doc.add_comment("Comment", frappe.bold(_("Call Summary")) + "<br><br>" + summary)
@@ -97,20 +112,19 @@
if not number:
return []
- employee_emails = frappe.cache().hget("employees_with_number", number)
- if employee_emails:
- return employee_emails
+ employee_doc_name_and_emails = frappe.cache().hget("employees_with_number", number)
+ if employee_doc_name_and_emails:
+ return employee_doc_name_and_emails
- employees = frappe.get_all(
+ employee_doc_name_and_emails = frappe.get_all(
"Employee",
- filters={"cell_number": ["like", "%{}%".format(number)], "user_id": ["!=", ""]},
- fields=["user_id"],
+ filters={"cell_number": ["like", f"%{number}%"], "user_id": ["!=", ""]},
+ fields=["name", "user_id"],
)
- employee_emails = [employee.user_id for employee in employees]
- frappe.cache().hset("employees_with_number", number, employee_emails)
+ frappe.cache().hset("employees_with_number", number, employee_doc_name_and_emails)
- return employee_emails
+ return employee_doc_name_and_emails
def link_existing_conversations(doc, state):
diff --git a/erpnext/regional/doctype/datev_settings/__init__.py b/erpnext/telephony/doctype/telephony_call_type/__init__.py
similarity index 100%
copy from erpnext/regional/doctype/datev_settings/__init__.py
copy to erpnext/telephony/doctype/telephony_call_type/__init__.py
diff --git a/erpnext/telephony/doctype/telephony_call_type/telephony_call_type.js b/erpnext/telephony/doctype/telephony_call_type/telephony_call_type.js
new file mode 100644
index 0000000..efba2b8
--- /dev/null
+++ b/erpnext/telephony/doctype/telephony_call_type/telephony_call_type.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Telephony Call Type', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/erpnext/telephony/doctype/telephony_call_type/telephony_call_type.json b/erpnext/telephony/doctype/telephony_call_type/telephony_call_type.json
new file mode 100644
index 0000000..603709e
--- /dev/null
+++ b/erpnext/telephony/doctype/telephony_call_type/telephony_call_type.json
@@ -0,0 +1,58 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "field:call_type",
+ "creation": "2022-02-25 16:13:37.321312",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "call_type",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "call_type",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Call Type",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Telephony Call Type",
+ "print_hide": 1,
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2022-02-25 16:14:07.087461",
+ "modified_by": "Administrator",
+ "module": "Telephony",
+ "name": "Telephony Call Type",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/telephony/doctype/telephony_call_type/telephony_call_type.py b/erpnext/telephony/doctype/telephony_call_type/telephony_call_type.py
new file mode 100644
index 0000000..944ffef
--- /dev/null
+++ b/erpnext/telephony/doctype/telephony_call_type/telephony_call_type.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class TelephonyCallType(Document):
+ pass
diff --git a/erpnext/telephony/doctype/telephony_call_type/test_telephony_call_type.py b/erpnext/telephony/doctype/telephony_call_type/test_telephony_call_type.py
new file mode 100644
index 0000000..b3c19c3
--- /dev/null
+++ b/erpnext/telephony/doctype/telephony_call_type/test_telephony_call_type.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+# import frappe
+import unittest
+
+
+class TestTelephonyCallType(unittest.TestCase):
+ pass
diff --git a/erpnext/templates/includes/projects/project_row.html b/erpnext/templates/includes/projects/project_row.html
index a256fbd..686637a 100644
--- a/erpnext/templates/includes/projects/project_row.html
+++ b/erpnext/templates/includes/projects/project_row.html
@@ -1,11 +1,11 @@
{% if doc.status == "Open" %}
<div class="web-list-item transaction-list-item">
<div class="row">
- <div class="col-xs-2">
+ <div class="col-xs-2 project-link">
<a class="transaction-item-link" href="/projects?project={{ doc.name | urlencode }}">Link</a>
{{ doc.name }}
</div>
- <div class="col-xs-2">
+ <div class="col-xs-2 project-name">
{{ doc.project_name }}
</div>
<div class="col-xs-3 text-center">
@@ -25,7 +25,7 @@
</div>
{% if doc["_assign"] %}
{% set assigned_users = json.loads(doc["_assign"])%}
- <div class="col-xs-2">
+ <div class="col-xs-2 project-users">
{% for user in assigned_users %}
{% set user_details = frappe
.db
@@ -46,7 +46,7 @@
{% endfor %}
</div>
{% endif %}
- <div class="col-xs-3 text-right small text-muted">
+ <div class="col-xs-3 text-right small text-muted project-modified-on">
{{ frappe.utils.pretty_date(doc.modified) }}
</div>
</div>
diff --git a/erpnext/templates/pages/courses.html b/erpnext/templates/pages/courses.html
deleted file mode 100644
index 6592f7a..0000000
--- a/erpnext/templates/pages/courses.html
+++ /dev/null
@@ -1,11 +0,0 @@
-{% extends "templates/web.html" %}
-
-{% block header %}
- <h1> About </h1>
-{% endblock %}
-
-{% block page_content %}
-
-<p class="post-description"> {{ intro }} </p>
-
-{% endblock %}
diff --git a/erpnext/templates/pages/courses.py b/erpnext/templates/pages/courses.py
deleted file mode 100644
index fb1af38..0000000
--- a/erpnext/templates/pages/courses.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-
-import frappe
-
-
-def get_context(context):
- course = frappe.get_doc("Course", frappe.form_dict.course)
- sidebar_title = course.name
-
- context.no_cache = 1
- context.show_sidebar = True
- course = frappe.get_doc("Course", frappe.form_dict.course)
- course.has_permission("read")
- context.doc = course
- context.sidebar_title = sidebar_title
- context.intro = course.course_intro
diff --git a/erpnext/templates/pages/home.py b/erpnext/templates/pages/home.py
index bca3e56..47fb89d 100644
--- a/erpnext/templates/pages/home.py
+++ b/erpnext/templates/pages/home.py
@@ -8,7 +8,7 @@
def get_context(context):
- homepage = frappe.get_doc("Homepage")
+ homepage = frappe.get_cached_doc("Homepage")
for item in homepage.products:
route = frappe.db.get_value("Website Item", {"item_code": item.item_code}, "route")
@@ -20,10 +20,10 @@
context.homepage = homepage
if homepage.hero_section_based_on == "Homepage Section" and homepage.hero_section:
- homepage.hero_section_doc = frappe.get_doc("Homepage Section", homepage.hero_section)
+ homepage.hero_section_doc = frappe.get_cached_doc("Homepage Section", homepage.hero_section)
if homepage.slideshow:
- doc = frappe.get_doc("Website Slideshow", homepage.slideshow)
+ doc = frappe.get_cached_doc("Website Slideshow", homepage.slideshow)
context.slideshow = homepage.slideshow
context.slideshow_header = doc.header
context.slides = doc.slideshow_items
@@ -46,7 +46,7 @@
order_by="section_order asc",
)
context.homepage_sections = [
- frappe.get_doc("Homepage Section", name) for name in homepage_sections
+ frappe.get_cached_doc("Homepage Section", name) for name in homepage_sections
]
context.metatags = context.metatags or frappe._dict({})
diff --git a/erpnext/templates/pages/product_search.py b/erpnext/templates/pages/product_search.py
index 77a749e..3ed056f 100644
--- a/erpnext/templates/pages/product_search.py
+++ b/erpnext/templates/pages/product_search.py
@@ -1,6 +1,8 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
+import json
+
import frappe
from frappe.utils import cint, cstr
from redisearch import AutoCompleter, Client, Query
@@ -9,7 +11,7 @@
WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE,
WEBSITE_ITEM_INDEX,
WEBSITE_ITEM_NAME_AUTOCOMPLETE,
- is_search_module_loaded,
+ is_redisearch_enabled,
make_key,
)
from erpnext.e_commerce.shopping_cart.product_info import set_product_info_for_website
@@ -74,8 +76,8 @@
def product_search(query, limit=10, fuzzy_search=True):
search_results = {"from_redisearch": True, "results": []}
- if not is_search_module_loaded():
- # Redisearch module not loaded
+ if not is_redisearch_enabled():
+ # Redisearch module not enabled
search_results["from_redisearch"] = False
search_results["results"] = get_product_data(query, 0, limit)
return search_results
@@ -86,6 +88,8 @@
red = frappe.cache()
query = clean_up_query(query)
+ # TODO: Check perf/correctness with Suggestions & Query vs only Query
+ # TODO: Use Levenshtein Distance in Query (max=3)
ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=red)
client = Client(make_key(WEBSITE_ITEM_INDEX), conn=red)
suggestions = ac.get_suggestions(
@@ -121,8 +125,8 @@
def get_category_suggestions(query):
search_results = {"results": []}
- if not is_search_module_loaded():
- # Redisearch module not loaded, query db
+ if not is_redisearch_enabled():
+ # Redisearch module not enabled, query db
categories = frappe.db.get_all(
"Item Group",
filters={"name": ["like", "%{0}%".format(query)], "show_in_website": 1},
@@ -135,8 +139,10 @@
return search_results
ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=frappe.cache())
- suggestions = ac.get_suggestions(query, num=10)
+ suggestions = ac.get_suggestions(query, num=10, with_payloads=True)
- search_results["results"] = [s.string for s in suggestions]
+ results = [json.loads(s.payload) for s in suggestions]
+
+ search_results["results"] = results
return search_results
diff --git a/erpnext/tests/exotel_test_data.py b/erpnext/tests/exotel_test_data.py
new file mode 100644
index 0000000..3ad2575
--- /dev/null
+++ b/erpnext/tests/exotel_test_data.py
@@ -0,0 +1,122 @@
+import frappe
+
+call_initiation_data = frappe._dict(
+ {
+ "CallSid": "23c162077629863c1a2d7f29263a162m",
+ "CallFrom": "09999999991",
+ "CallTo": "09999999980",
+ "Direction": "incoming",
+ "Created": "Wed, 23 Feb 2022 12:31:59",
+ "From": "09999999991",
+ "To": "09999999988",
+ "CurrentTime": "2022-02-23 12:32:02",
+ "DialWhomNumber": "09999999999",
+ "Status": "busy",
+ "EventType": "Dial",
+ "AgentEmail": "test_employee_exotel@company.com",
+ }
+)
+
+call_end_data = frappe._dict(
+ {
+ "CallSid": "23c162077629863c1a2d7f29263a162m",
+ "CallFrom": "09999999991",
+ "CallTo": "09999999980",
+ "Direction": "incoming",
+ "ForwardedFrom": "null",
+ "Created": "Wed, 23 Feb 2022 12:31:59",
+ "DialCallDuration": "17",
+ "RecordingUrl": "https://s3-ap-southeast-1.amazonaws.com/random.mp3",
+ "StartTime": "2022-02-23 12:31:58",
+ "EndTime": "1970-01-01 05:30:00",
+ "DialCallStatus": "completed",
+ "CallType": "completed",
+ "DialWhomNumber": "09999999999",
+ "ProcessStatus": "null",
+ "flow_id": "228040",
+ "tenant_id": "67291",
+ "From": "09999999991",
+ "To": "09999999988",
+ "RecordingAvailableBy": "Wed, 23 Feb 2022 12:37:25",
+ "CurrentTime": "2022-02-23 12:32:25",
+ "OutgoingPhoneNumber": "09999999988",
+ "Legs": [
+ {
+ "Number": "09999999999",
+ "Type": "single",
+ "OnCallDuration": "10",
+ "CallerId": "09999999980",
+ "CauseCode": "NORMAL_CLEARING",
+ "Cause": "16",
+ }
+ ],
+ }
+)
+
+call_disconnected_data = frappe._dict(
+ {
+ "CallSid": "d96421addce69e24bdc7ce5880d1162l",
+ "CallFrom": "09999999991",
+ "CallTo": "09999999980",
+ "Direction": "incoming",
+ "ForwardedFrom": "null",
+ "Created": "Mon, 21 Feb 2022 15:58:12",
+ "DialCallDuration": "0",
+ "StartTime": "2022-02-21 15:58:12",
+ "EndTime": "1970-01-01 05:30:00",
+ "DialCallStatus": "canceled",
+ "CallType": "client-hangup",
+ "DialWhomNumber": "09999999999",
+ "ProcessStatus": "null",
+ "flow_id": "228040",
+ "tenant_id": "67291",
+ "From": "09999999991",
+ "To": "09999999988",
+ "CurrentTime": "2022-02-21 15:58:47",
+ "OutgoingPhoneNumber": "09999999988",
+ "Legs": [
+ {
+ "Number": "09999999999",
+ "Type": "single",
+ "OnCallDuration": "0",
+ "CallerId": "09999999980",
+ "CauseCode": "RING_TIMEOUT",
+ "Cause": "1003",
+ }
+ ],
+ }
+)
+
+call_not_answered_data = frappe._dict(
+ {
+ "CallSid": "fdb67a2b4b2d057b610a52ef43f81622",
+ "CallFrom": "09999999991",
+ "CallTo": "09999999980",
+ "Direction": "incoming",
+ "ForwardedFrom": "null",
+ "Created": "Mon, 21 Feb 2022 15:47:02",
+ "DialCallDuration": "0",
+ "StartTime": "2022-02-21 15:47:02",
+ "EndTime": "1970-01-01 05:30:00",
+ "DialCallStatus": "no-answer",
+ "CallType": "incomplete",
+ "DialWhomNumber": "09999999999",
+ "ProcessStatus": "null",
+ "flow_id": "228040",
+ "tenant_id": "67291",
+ "From": "09999999991",
+ "To": "09999999988",
+ "CurrentTime": "2022-02-21 15:47:40",
+ "OutgoingPhoneNumber": "09999999988",
+ "Legs": [
+ {
+ "Number": "09999999999",
+ "Type": "single",
+ "OnCallDuration": "0",
+ "CallerId": "09999999980",
+ "CauseCode": "RING_TIMEOUT",
+ "Cause": "1003",
+ }
+ ],
+ }
+)
diff --git a/erpnext/tests/test_exotel.py b/erpnext/tests/test_exotel.py
new file mode 100644
index 0000000..76bbb3e
--- /dev/null
+++ b/erpnext/tests/test_exotel.py
@@ -0,0 +1,69 @@
+import frappe
+from frappe.contacts.doctype.contact.test_contact import create_contact
+from frappe.tests.test_api import FrappeAPITestCase
+
+from erpnext.hr.doctype.employee.test_employee import make_employee
+
+
+class TestExotel(FrappeAPITestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.CURRENT_DB_CONNECTION = frappe.db
+ cls.test_employee_name = make_employee(
+ user="test_employee_exotel@company.com", cell_number="9999999999"
+ )
+ frappe.db.set_value("Exotel Settings", "Exotel Settings", "enabled", 1)
+ phones = [{"phone": "+91 9999999991", "is_primary_phone": 0, "is_primary_mobile_no": 1}]
+ create_contact(name="Test Contact", salutation="Mr", phones=phones)
+ frappe.db.commit()
+
+ def test_for_successful_call(self):
+ from .exotel_test_data import call_end_data, call_initiation_data
+
+ api_method = "handle_incoming_call"
+ end_call_api_method = "handle_end_call"
+
+ self.emulate_api_call_from_exotel(api_method, call_initiation_data)
+ self.emulate_api_call_from_exotel(end_call_api_method, call_end_data)
+ call_log = frappe.get_doc("Call Log", call_initiation_data.CallSid)
+
+ self.assertEqual(call_log.get("from"), call_initiation_data.CallFrom)
+ self.assertEqual(call_log.get("to"), call_initiation_data.DialWhomNumber)
+ self.assertEqual(call_log.get("call_received_by"), self.test_employee_name)
+ self.assertEqual(call_log.get("status"), "Completed")
+
+ def test_for_disconnected_call(self):
+ from .exotel_test_data import call_disconnected_data
+
+ api_method = "handle_missed_call"
+ self.emulate_api_call_from_exotel(api_method, call_disconnected_data)
+ call_log = frappe.get_doc("Call Log", call_disconnected_data.CallSid)
+ self.assertEqual(call_log.get("from"), call_disconnected_data.CallFrom)
+ self.assertEqual(call_log.get("to"), call_disconnected_data.DialWhomNumber)
+ self.assertEqual(call_log.get("call_received_by"), self.test_employee_name)
+ self.assertEqual(call_log.get("status"), "Canceled")
+
+ def test_for_call_not_answered(self):
+ from .exotel_test_data import call_not_answered_data
+
+ api_method = "handle_missed_call"
+ self.emulate_api_call_from_exotel(api_method, call_not_answered_data)
+ call_log = frappe.get_doc("Call Log", call_not_answered_data.CallSid)
+ self.assertEqual(call_log.get("from"), call_not_answered_data.CallFrom)
+ self.assertEqual(call_log.get("to"), call_not_answered_data.DialWhomNumber)
+ self.assertEqual(call_log.get("call_received_by"), self.test_employee_name)
+ self.assertEqual(call_log.get("status"), "No Answer")
+
+ def emulate_api_call_from_exotel(self, api_method, data):
+ self.post(
+ f"/api/method/erpnext.erpnext_integrations.exotel_integration.{api_method}",
+ data=frappe.as_json(data),
+ content_type="application/json",
+ as_tuple=True,
+ )
+ # restart db connection to get latest data
+ frappe.connect()
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db = cls.CURRENT_DB_CONNECTION
diff --git a/erpnext/tests/test_subcontracting.py b/erpnext/tests/test_subcontracting.py
index 07291e8..bf12181 100644
--- a/erpnext/tests/test_subcontracting.py
+++ b/erpnext/tests/test_subcontracting.py
@@ -50,7 +50,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -112,7 +112,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -175,7 +175,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -239,7 +239,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -298,7 +298,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -363,7 +363,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -421,7 +421,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -492,7 +492,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -529,7 +529,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -609,7 +609,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -675,7 +675,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -751,7 +751,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
@@ -834,7 +834,7 @@
itemwise_details = make_stock_in_entry(rm_items=rm_items)
po = create_purchase_order(
- rm_items=items, is_subcontracted="Yes", supplier_warehouse="_Test Warehouse 1 - _TC"
+ rm_items=items, is_subcontracted=1, supplier_warehouse="_Test Warehouse 1 - _TC"
)
for d in rm_items:
diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv
index 3cdae45..8518156 100644
--- a/erpnext/translations/fr.csv
+++ b/erpnext/translations/fr.csv
@@ -271,7 +271,7 @@
Assessment Reports,Rapports d'évaluation,
Assessment Result,Résultat de l'Évaluation,
Assessment Result record {0} already exists.,Le Résultat d'Évaluation {0} existe déjà.,
-Asset,Atout,
+Asset,Actif - Immo.,
Asset Category,Catégorie d'Actif,
Asset Category is mandatory for Fixed Asset item,Catégorie d'Actif est obligatoire pour l'article Immobilisé,
Asset Maintenance,Maintenance des actifs,
@@ -285,7 +285,7 @@
"Asset {0} cannot be scrapped, as it is already {1}","L'actif {0} ne peut pas être mis au rebut, car il est déjà {1}",
Asset {0} does not belong to company {1},L'actif {0} ne fait pas partie à la société {1},
Asset {0} must be submitted,L'actif {0} doit être soumis,
-Assets,Les atouts,
+Assets,Actifs - Immo.,
Assign,Assigner,
Assign Salary Structure,Affecter la structure salariale,
Assign To,Attribuer À,
@@ -951,14 +951,14 @@
Ends On date cannot be before Next Contact Date.,La date de fin ne peut pas être avant la prochaine date de contact,
Energy,Énergie,
Engineer,Ingénieur,
-Enough Parts to Build,Pièces Suffisantes pour Construire,
+Enough Parts to Build,Pièces Suffisantes pour Construire
Enroll,Inscrire,
Enrolling student,Inscrire un étudiant,
Enrolling students,Inscription des étudiants,
Enter depreciation details,Veuillez entrer les détails de l'amortissement,
-Enter the Bank Guarantee Number before submittting.,Entrez le numéro de garantie bancaire avant de soumettre.,
-Enter the name of the Beneficiary before submittting.,Entrez le nom du bénéficiaire avant de soumettre.,
-Enter the name of the bank or lending institution before submittting.,Entrez le nom de la banque ou de l'institution de prêt avant de soumettre.,
+Enter the Bank Guarantee Number before submittting.,Entrez le numéro de garantie bancaire avant de valider.
+Enter the name of the Beneficiary before submittting.,Entrez le nom du bénéficiaire avant de valider.
+Enter the name of the bank or lending institution before submittting.,Entrez le nom de la banque ou de l'institution de prêt avant de valider.,
Enter value betweeen {0} and {1},Entrez une valeur entre {0} et {1},
Entertainment & Leisure,Divertissement et Loisir,
Entertainment Expenses,Charges de Représentation,
@@ -1068,7 +1068,7 @@
For Quantity (Manufactured Qty) is mandatory,Pour Quantité (Qté Produite) est obligatoire,
For Supplier,Pour Fournisseur,
For Warehouse,Pour l’Entrepôt,
-For Warehouse is required before Submit,Pour l’Entrepôt est requis avant de Soumettre,
+For Warehouse is required before Submit,Pour l’Entrepôt est requis avant de Valider,
"For an item {0}, quantity must be negative number","Pour l'article {0}, la quantité doit être un nombre négatif",
"For an item {0}, quantity must be positive number","Pour un article {0}, la quantité doit être un nombre positif",
"For job card {0}, you can only make the 'Material Transfer for Manufacture' type stock entry","Pour la carte de travail {0}, vous pouvez uniquement saisir une entrée de stock de type "Transfert d'article pour fabrication".",
@@ -1211,7 +1211,7 @@
Help Results for,Aide Résultats pour,
High,Haut,
High Sensitivity,Haute sensibilité,
-Hold,Tenir,
+Hold,Mettre en attente,
Hold Invoice,Facture en attente,
Holiday,Vacances,
Holiday List,Liste de vacances,
@@ -1693,7 +1693,7 @@
No Items with Bill of Materials.,Aucun article avec nomenclature.,
No Permission,Aucune autorisation,
No Remarks,Aucune Remarque,
-No Result to submit,Aucun résultat à soumettre,
+No Result to submit,Aucun résultat à valider,
No Salary Structure assigned for Employee {0} on given date {1},Aucune structure de salaire attribuée à l'employé {0} à la date donnée {1},
No Staffing Plans found for this Designation,Aucun plan de dotation trouvé pour cette désignation,
No Student Groups created.,Aucun Groupe d'Étudiants créé.,
@@ -2847,12 +2847,12 @@
Sub-contracting,Sous-traitant,
Subcontract,Sous-traiter,
Subject,Sujet,
-Submit,Soumettre,
-Submit Proof,Soumettre une preuve,
-Submit Salary Slip,Soumettre la Fiche de Paie,
-Submit this Work Order for further processing.,Soumettre cet ordre de travail pour continuer son traitement.,
-Submit this to create the Employee record,Soumettre pour créer la fiche employé,
-Submitting Salary Slips...,Soumission des bulletins de salaire ...,
+Submit,Valider,
+Submit Proof,Valider une preuve,
+Submit Salary Slip,Valider la Fiche de Paie,
+Submit this Work Order for further processing.,Valider cet ordre de travail pour continuer son traitement.,
+Submit this to create the Employee record,Valider pour créer la fiche employé,
+Submitting Salary Slips...,Validation des bulletins de salaire ...,
Subscription,Abonnement,
Subscription Management,Gestion des abonnements,
Subscriptions,Abonnements,
@@ -2954,7 +2954,7 @@
The Term End Date cannot be later than the Year End Date of the Academic Year to which the term is linked (Academic Year {}). Please correct the dates and try again.,La Date de Fin de Terme ne peut pas être postérieure à la Date de Fin de l'Année Académique à laquelle le terme est lié (Année Académique {}). Veuillez corriger les dates et essayer à nouveau.,
The Term Start Date cannot be earlier than the Year Start Date of the Academic Year to which the term is linked (Academic Year {}). Please correct the dates and try again.,La Date de Début de Terme ne peut pas être antérieure à la Date de Début de l'Année Académique à laquelle le terme est lié (Année Académique {}). Veuillez corriger les dates et essayer à nouveau.,
The Year End Date cannot be earlier than the Year Start Date. Please correct the dates and try again.,La Date de Fin d'Année ne peut pas être antérieure à la Date de Début d’Année. Veuillez corriger les dates et essayer à nouveau.,
-The amount of {0} set in this payment request is different from the calculated amount of all payment plans: {1}. Make sure this is correct before submitting the document.,Le montant {0} défini dans cette requête de paiement est différent du montant calculé de tous les plans de paiement: {1}.\nVeuillez vérifier que c'est correct avant de soumettre le document.,
+The amount of {0} set in this payment request is different from the calculated amount of all payment plans: {1}. Make sure this is correct before submitting the document.,Le montant {0} défini dans cette requête de paiement est différent du montant calculé de tous les plans de paiement: {1}.\nVeuillez vérifier que c'est correct avant de valider le document.,
The day(s) on which you are applying for leave are holidays. You need not apply for leave.,Le(s) jour(s) pour le(s)quel(s) vous demandez un congé sont des jour(s) férié(s). Vous n’avez pas besoin d’effectuer de demande.,
The field From Shareholder cannot be blank,Le champ 'De l'actionnaire' ne peut pas être vide,
The field To Shareholder cannot be blank,Le champ 'A l'actionnaire' ne peut pas être vide,
@@ -3011,7 +3011,7 @@
This is based on transactions against this Patient. See timeline below for details,Ceci est basé sur les transactions de ce patient. Voir la chronologie ci-dessous pour plus de détails,
This is based on transactions against this Sales Person. See timeline below for details,Ceci est basé sur les transactions contre ce vendeur. Voir la chronologie ci-dessous pour plus de détails,
This is based on transactions against this Supplier. See timeline below for details,Basé sur les transactions avec ce fournisseur. Voir la chronologie ci-dessous pour plus de détails,
-This will submit Salary Slips and create accrual Journal Entry. Do you want to proceed?,Cela permettra de soumettre des bulletins de salaire et de créer une écriture de journal d'accumulation. Voulez-vous poursuivre?,
+This will submit Salary Slips and create accrual Journal Entry. Do you want to proceed?,Cela permettra de valider des bulletins de salaire et de créer une écriture de journal d'accumulation. Voulez-vous poursuivre?,
This {0} conflicts with {1} for {2} {3},Ce {0} est en conflit avec {1} pour {2} {3},
Time Sheet for manufacturing.,Feuille de Temps pour la production.,
Time Tracking,Suivi du temps,
@@ -3037,6 +3037,7 @@
To Date should be within the Fiscal Year. Assuming To Date = {0},La Date Finale doit être dans l'exercice. En supposant Date Finale = {0},
To Datetime,À la Date,
To Deliver,À Livrer,
+{} To Deliver,{} à livrer
To Deliver and Bill,À Livrer et Facturer,
To Fiscal Year,À l'année fiscale,
To GSTIN,GSTIN (Destination),
@@ -3312,7 +3313,7 @@
Work Order {0} must be submitted,L'ordre de travail {0} doit être soumis,
Work Orders Created: {0},Ordres de travail créés: {0},
Work Summary for {0},Résumé de travail de {0},
-Work-in-Progress Warehouse is required before Submit,L'entrepôt des Travaux en Cours est nécessaire avant de Soumettre,
+Work-in-Progress Warehouse is required before Submit,L'entrepôt des Travaux en Cours est nécessaire avant de Valider,
Workflow,Flux de Travail,
Working,Travail en cours,
Working Hours,Heures de travail,
@@ -3331,7 +3332,7 @@
You can only redeem max {0} points in this order.,Vous pouvez uniquement échanger un maximum de {0} points dans cet commande.,
You can only renew if your membership expires within 30 days,Vous ne pouvez renouveler que si votre abonnement expire dans les 30 jours,
You can only select a maximum of one option from the list of check boxes.,Vous pouvez sélectionner au maximum une option dans la liste des cases à cocher.,
-You can only submit Leave Encashment for a valid encashment amount,Vous pouvez uniquement soumettre un encaissement de congé pour un montant d'encaissement valide,
+You can only submit Leave Encashment for a valid encashment amount,Vous pouvez uniquement valider un encaissement de congé pour un montant d'encaissement valide,
You can't redeem Loyalty Points having more value than the Grand Total.,Vous ne pouvez pas échanger des points de fidélité ayant plus de valeur que le total général.,
You cannot credit and debit same account at the same time,Vous ne pouvez pas créditer et débiter le même compte simultanément,
You cannot delete Fiscal Year {0}. Fiscal Year {0} is set as default in Global Settings,Vous ne pouvez pas supprimer l'exercice fiscal {0}. L'exercice fiscal {0} est défini par défaut dans les Paramètres Globaux,
@@ -3684,8 +3685,8 @@
Creating Accounts...,Création de comptes ...,
Creating bank entries...,Création d'entrées bancaires ...,
Credit limit is already defined for the Company {0},La limite de crédit est déjà définie pour la société {0}.,
-Ctrl + Enter to submit,Ctrl + Entrée pour soumettre,
-Ctrl+Enter to submit,Ctrl + Entrée pour soumettre,
+Ctrl + Enter to submit,Ctrl + Entrée pour valider,
+Ctrl+Enter to submit,Ctrl + Entrée pour valider,
Currency,Devise,
Current Status,Statut Actuel,
Customer PO,Bon de commande client,
@@ -3709,7 +3710,7 @@
Disabled,Desactivé,
Disbursement and Repayment,Décaissement et remboursement,
Distance cannot be greater than 4000 kms,La distance ne peut pas dépasser 4000 km,
-Do you want to submit the material request,Voulez-vous soumettre la demande de matériel,
+Do you want to submit the material request,Voulez-vous valider la demande de matériel,
Doctype,Doctype,
Document {0} successfully uncleared,Document {0} non effacé avec succès,
Download Template,Télécharger le Modèle,
@@ -4240,7 +4241,7 @@
From date cannot be greater than To date,La Date Initiale ne peut pas être postérieure à la Date Finale,
Group by,Grouper Par,
In stock,En stock,
-Item name,Nom de l'article,
+Item name,Libellé de l'article,
Loan amount is mandatory,Le montant du prêt est obligatoire,
Minimum Qty,Quantité minimum,
More details,Plus de détails,
@@ -4309,7 +4310,7 @@
Partially Paid,Partiellement payé,
Invalid Account Currency,Devise de compte non valide,
"Row {0}: The item {1}, quantity must be positive number","Ligne {0}: l'article {1}, la quantité doit être un nombre positif",
-"Please set {0} for Batched Item {1}, which is used to set {2} on Submit.","Veuillez définir {0} pour l'article par lots {1}, qui est utilisé pour définir {2} sur Soumettre.",
+"Please set {0} for Batched Item {1}, which is used to set {2} on Submit.","Veuillez définir {0} pour l'article par lots {1}, qui est utilisé pour définir {2} sur Valider.",
Expiry Date Mandatory,Date d'expiration obligatoire,
Variant Item,Élément de variante,
BOM 1 {0} and BOM 2 {1} should not be same,La nomenclature 1 {0} et la nomenclature 2 {1} ne doivent pas être identiques,
@@ -4589,7 +4590,7 @@
New Transactions,Nouvelles transactions,
Match Transaction to Invoices,Faire correspondre la transaction aux factures,
Create New Payment/Journal Entry,Créer un nouveau paiement / écriture de journal,
-Submit/Reconcile Payments,Soumettre / rapprocher les paiements,
+Submit/Reconcile Payments,Valider / rapprocher les paiements,
Matching Invoices,Factures correspondantes,
Payment Invoice Items,Articles de la facture de paiement,
Reconciled Transactions,Transactions rapprochées,
@@ -5473,7 +5474,7 @@
PUR-ORD-.YYYY.-,PUR-ORD-.YYYY.-,
Get Items from Open Material Requests,Obtenir des Articles de Demandes Matérielles Ouvertes,
Fetch items based on Default Supplier.,Récupérez les articles en fonction du fournisseur par défaut.,
-Required By,Requis Par,
+Required By,Requis pour le,
Order Confirmation No,No de confirmation de commande,
Order Confirmation Date,Date de confirmation de la commande,
Customer Mobile No,N° de Portable du Client,
@@ -6208,7 +6209,7 @@
Checking this will create new Patients with a Disabled status by default and will only be enabled after invoicing the Registration Fee.,Cochez cette case pour créer de nouveaux patients avec un statut Désactivé par défaut et ne seront activés qu'après facturation des frais d'inscription.,
Registration Fee,Frais d'Inscription,
Automate Appointment Invoicing,Automatiser la facturation des rendez-vous,
-Manage Appointment Invoice submit and cancel automatically for Patient Encounter,Gérer les factures de rendez-vous soumettre et annuler automatiquement pour la consultation des patients,
+Manage Appointment Invoice submit and cancel automatically for Patient Encounter,Gérer les factures de rendez-vous valider et annuler automatiquement pour la consultation des patients,
Enable Free Follow-ups,Activer les suivis gratuits,
Number of Patient Encounters in Valid Days,Nombre de rencontres de patients en jours valides,
The number of free follow ups (Patient Encounters in valid days) allowed,Le nombre de suivis gratuits (rencontres de patients en jours valides) autorisés,
@@ -7223,8 +7224,8 @@
Scrap %,% de Rebut,
Original Item,Article original,
BOM Operation,Opération LDM,
-Operation Time ,Moment de l'opération,
-In minutes,En quelques minutes,
+Operation Time ,Durée de l'opération,
+In minutes,En minutes,
Batch Size,Taille du lot,
Base Hour Rate(Company Currency),Taux Horaire de Base (Devise de la Société),
Operating Cost(Company Currency),Coût d'Exploitation (Devise Société),
@@ -8679,7 +8680,7 @@
Days,Journées,
Months,Mois,
Book Deferred Entries Via Journal Entry,Enregistrer les écritures différées via l'écriture au journal,
-Submit Journal Entries,Soumettre les entrées de journal,
+Submit Journal Entries,Valider les entrées de journal,
If this is unchecked Journal Entries will be saved in a Draft state and will have to be submitted manually,"Si cette case n'est pas cochée, les entrées de journal seront enregistrées dans un état Brouillon et devront être soumises manuellement",
Enable Distributed Cost Center,Activer le centre de coûts distribués,
Distributed Cost Center,Centre de coûts distribués,
@@ -9065,7 +9066,7 @@
Monthly Eligible Amount,Montant mensuel admissible,
Total Eligible HRA Exemption,Exemption HRA totale éligible,
Validating Employee Attendance...,Validation de la présence des employés ...,
-Submitting Salary Slips and creating Journal Entry...,Soumettre des fiches de salaire et créer une écriture au journal ...,
+Submitting Salary Slips and creating Journal Entry...,Validation des fiches de salaire et créer une écriture au journal ...,
Calculate Payroll Working Days Based On,Calculer les jours ouvrables de paie en fonction de,
Consider Unmarked Attendance As,Considérez la participation non marquée comme,
Fraction of Daily Salary for Half Day,Fraction du salaire journalier pour une demi-journée,
@@ -9166,8 +9167,8 @@
Customer contact updated successfully.,Contact client mis à jour avec succès.,
Item will be removed since no serial / batch no selected.,L'article sera supprimé car aucun numéro de série / lot sélectionné.,
Discount (%),Remise (%),
-You cannot submit the order without payment.,Vous ne pouvez pas soumettre la commande sans paiement.,
-You cannot submit empty order.,Vous ne pouvez pas soumettre de commande vide.,
+You cannot submit the order without payment.,Vous ne pouvez pas valider la commande sans paiement.,
+You cannot submit empty order.,Vous ne pouvez pas valider de commande vide.,
To Be Paid,Être payé,
Create POS Opening Entry,Créer une entrée d'ouverture de PDV,
Please add Mode of payments and opening balance details.,Veuillez ajouter le mode de paiement et les détails du solde d'ouverture.,
@@ -9267,7 +9268,7 @@
Amount Delivered,Montant livré,
Delay (in Days),Retard (en jours),
Group by Sales Order,Regrouper par commande client,
- Sales Value,La valeur des ventes,
+Sales Value,La valeur des ventes,
Stock Qty vs Serial No Count,Quantité de stock vs numéro de série,
Serial No Count,Numéro de série,
Work Order Summary,Résumé de l'ordre de travail,
@@ -9305,7 +9306,7 @@
{0} {1} has been added to all the selected topics successfully.,{0} {1} a bien été ajouté à tous les sujets sélectionnés.,
Topics updated,Sujets mis à jour,
Academic Term and Program,Terme académique et programme,
-Please remove this item and try to submit again or update the posting time.,Veuillez supprimer cet élément et réessayer de le soumettre ou mettre à jour l'heure de publication.,
+Please remove this item and try to submit again or update the posting time.,Veuillez supprimer cet élément et réessayer de le valider ou mettre à jour l'heure de publication.,
Failed to Authenticate the API key.,Échec de l'authentification de la clé API.,
Invalid Credentials,Les informations d'identification invalides,
URL can only be a string,L'URL ne peut être qu'une chaîne,
@@ -9416,7 +9417,7 @@
"Valuation Rate for the Item {0}, is required to do accounting entries for {1} {2}.",Le taux de valorisation de l'article {0} est requis pour effectuer des écritures comptables pour {1} {2}.,
Here are the options to proceed:,Voici les options pour continuer:,
"If the item is transacting as a Zero Valuation Rate item in this entry, please enable 'Allow Zero Valuation Rate' in the {0} Item table.","Si l'article est traité comme un article à taux de valorisation nul dans cette entrée, veuillez activer "Autoriser le taux de valorisation nul" dans le {0} tableau des articles.",
-"If not, you can Cancel / Submit this entry ","Sinon, vous pouvez annuler / soumettre cette entrée",
+"If not, you can Cancel / Submit this entry ","Sinon, vous pouvez annuler / valider cette entrée",
performing either one below:,effectuer l'un ou l'autre ci-dessous:,
Create an incoming stock transaction for the Item.,Créez une transaction de stock entrante pour l'article.,
Mention Valuation Rate in the Item master.,Mentionnez le taux de valorisation dans la fiche article.,
@@ -9573,7 +9574,7 @@
Role Allowed to Set Frozen Accounts and Edit Frozen Entries,Rôle autorisé à définir des comptes gelés et à modifier les entrées gelées,
Address used to determine Tax Category in transactions,Adresse utilisée pour déterminer la catégorie de taxe dans les transactions,
"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 ","Le pourcentage que vous êtes autorisé à facturer davantage par rapport au montant commandé. Par exemple, si la valeur de la commande est de 100 USD pour un article et que la tolérance est définie sur 10%, vous êtes autorisé à facturer jusqu'à 110 USD.",
-This role is allowed to submit transactions that exceed credit limits,Ce rôle est autorisé à soumettre des transactions qui dépassent les limites de crédit,
+This role is allowed to submit transactions that exceed credit limits,Ce rôle est autorisé à valider des transactions qui dépassent les limites de crédit,
"If ""Months"" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month","Si «Mois» est sélectionné, un montant fixe sera comptabilisé en tant que revenus ou dépenses différés pour chaque mois, quel que soit le nombre de jours dans un mois. Il sera calculé au prorata si les revenus ou les dépenses différés ne sont pas comptabilisés pour un mois entier",
"If this is unchecked, direct GL entries will be created to book deferred revenue or expense","Si cette case n'est pas cochée, des entrées GL directes seront créées pour enregistrer les revenus ou les dépenses différés",
Show Inclusive Tax in Print,Afficher la taxe incluse en version imprimée,
@@ -9647,7 +9648,7 @@
Validate Selling Price for Item Against Purchase Rate or Valuation Rate,Valider le prix de vente de l'article par rapport au taux d'achat ou au taux de valorisation,
Hide Customer's Tax ID from Sales Transactions,Masquer le numéro d'identification fiscale du client dans les transactions de vente,
"The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.","Le pourcentage que vous êtes autorisé à recevoir ou à livrer plus par rapport à la quantité commandée. Par exemple, si vous avez commandé 100 unités et que votre allocation est de 10%, vous êtes autorisé à recevoir 110 unités.",
-Action If Quality Inspection Is Not Submitted,Action si l'inspection de la qualité n'est pas soumise,
+Action If Quality Inspection Is Not Submitted,Action si l'inspection qualité n'est pas soumise,
Auto Insert Price List Rate If Missing,Taux de liste de prix d'insertion automatique s'il est manquant,
Automatically Set Serial Nos Based on FIFO,Définir automatiquement les numéros de série en fonction de FIFO,
Set Qty in Transactions Based on Serial No Input,Définir la quantité dans les transactions en fonction du numéro de série,
@@ -9744,7 +9745,7 @@
Edit Receipt,Modifier le reçu,
Focus on search input,Focus sur l'entrée de recherche,
Focus on Item Group filter,Focus sur le filtre de groupe d'articles,
-Checkout Order / Submit Order / New Order,Commander la commande / Soumettre la commande / Nouvelle commande,
+Checkout Order / Submit Order / New Order,Commander la commande / Valider la commande / Nouvelle commande,
Add Order Discount,Ajouter une remise de commande,
Item Code: {0} is not available under warehouse {1}.,Code d'article: {0} n'est pas disponible dans l'entrepôt {1}.,
Serial numbers unavailable for Item {0} under warehouse {1}. Please try changing warehouse.,Numéros de série non disponibles pour l'article {0} sous l'entrepôt {1}. Veuillez essayer de changer d’entrepôt.,
@@ -9787,11 +9788,11 @@
as no Purchase Receipt is created against Item {}. ,car aucun reçu d'achat n'est créé pour l'article {}.,
This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice,Ceci est fait pour gérer la comptabilité des cas où le reçu d'achat est créé après la facture d'achat,
Purchase Order Required for item {},Bon de commande requis pour l'article {},
-To submit the invoice without purchase order please set {} ,"Pour soumettre la facture sans bon de commande, veuillez définir {}",
+To submit the invoice without purchase order please set {} ,"Pour valider la facture sans bon de commande, veuillez définir {}",
as {} in {},un péché {},
Mandatory Purchase Order,Bon de commande obligatoire,
Purchase Receipt Required for item {},Reçu d'achat requis pour l'article {},
-To submit the invoice without purchase receipt please set {} ,"Pour soumettre la facture sans reçu d'achat, veuillez définir {}",
+To submit the invoice without purchase receipt please set {} ,"Pour valider la facture sans reçu d'achat, veuillez définir {}",
Mandatory Purchase Receipt,Reçu d'achat obligatoire,
POS Profile {} does not belongs to company {},Le profil PDV {} n'appartient pas à l'entreprise {},
User {} is disabled. Please select valid user/cashier,L'utilisateur {} est désactivé. Veuillez sélectionner un utilisateur / caissier valide,
@@ -9838,3 +9839,37 @@
Creating Purchase Order ...,Création d'une commande d'achat ...,
"Select a Supplier from the Default Suppliers of the items below. On selection, a Purchase Order will be made against items belonging to the selected Supplier only.","Sélectionnez un fournisseur parmi les fournisseurs par défaut des articles ci-dessous. Lors de la sélection, un bon de commande sera effectué contre des articles appartenant uniquement au fournisseur sélectionné.",
Row #{}: You must select {} serial numbers for item {}.,Ligne n ° {}: vous devez sélectionner {} numéros de série pour l'article {}.,
+Update Rate as per Last Purchase,Mettre à jour avec les derniers prix d'achats
+Company Shipping Address,Adresse d'expédition
+Shipping Address Details,Détail d'adresse d'expédition
+Company Billing Address,Adresse de la société de facturation
+Supplier Address Details,
+Bank Reconciliation Tool,Outil de réconcialiation d'écritures bancaires
+Supplier Contact,Contact fournisseur
+Subcontracting,Sous traitance
+Order Status,Statut de la commande
+Build,Personnalisations avancées
+Dispatch Address Name,Adresse de livraison intermédiaire
+Amount Eligible for Commission,Montant éligible à comission
+Grant Commission,Eligible aux commissions
+Stock Transactions Settings, Paramétre des transactions
+Role Allowed to Over Deliver/Receive, Rôle autorisé à dépasser cette limite
+Users with this role are allowed to over deliver/receive against orders above the allowance percentage,Rôle Utilisateur qui sont autorisé à livrée/commandé au-delà de la limite
+Over Transfer Allowance,Autorisation de limite de transfert
+Quality Inspection Settings,Paramétre de l'inspection qualité
+Action If Quality Inspection Is Rejected,Action si l'inspection qualité est rejetée
+Disable Serial No And Batch Selector,Désactiver le sélecteur de numéro de lot/série
+Is Rate Adjustment Entry (Debit Note),Est un justement du prix de la note de débit
+Issue a debit note with 0 qty against an existing Sales Invoice,Creer une note de débit avec une quatité à O pour la facture
+Control Historical Stock Transactions,Controle de l'historique des stransaction de stock
+No stock transactions can be created or modified before this date.,Aucune transaction ne peux être créée ou modifié avant cette date.
+Stock transactions that are older than the mentioned days cannot be modified.,Les transactions de stock plus ancienne que le nombre de jours ci-dessus ne peuvent être modifiées
+Role Allowed to Create/Edit Back-dated Transactions,Rôle autorisé à créer et modifier des transactions anti-datée
+"If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.","Les utilisateur de ce role pourront creer et modifier des transactions dans le passé. Si vide tout les utilisateurs pourrons le faire"
+Auto Insert Item Price If Missing,Création du prix de l'article dans les listes de prix si abscent
+Update Existing Price List Rate,Mise a jour automatique du prix dans les listes de prix
+Show Barcode Field in Stock Transactions,Afficher le champ Code Barre dans les transactions de stock
+Convert Item Description to Clean HTML in Transactions,Convertir les descriptions d'articles en HTML valide lors des transactions
+Have Default Naming Series for Batch ID?,Nom de série par défaut pour les Lots ou Séries
+"The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units","Le pourcentage de quantité que vous pourrez réceptionner en plus de la quantité commandée. Par exemple, vous avez commandé 100 unités, votre pourcentage de dépassement est de 10%, vous pourrez réceptionner 110 unités"
+Unit Of Measure (UOM),Unité de mesure (UDM),
diff --git a/erpnext/utilities/doctype/video/video.js b/erpnext/utilities/doctype/video/video.js
index 9cb5a15..e6c6efb 100644
--- a/erpnext/utilities/doctype/video/video.js
+++ b/erpnext/utilities/doctype/video/video.js
@@ -4,7 +4,7 @@
frappe.ui.form.on('Video', {
refresh: function (frm) {
frm.events.toggle_youtube_statistics_section(frm);
- frm.add_custom_button("Watch Video", () => frappe.help.show_video(frm.doc.url, frm.doc.title));
+ frm.add_custom_button(__("Watch Video"), () => frappe.help.show_video(frm.doc.url, frm.doc.title));
},
toggle_youtube_statistics_section: (frm) => {
diff --git a/erpnext/utilities/report/youtube_interactions/youtube_interactions.py b/erpnext/utilities/report/youtube_interactions/youtube_interactions.py
index a65a75f..a2cb4e8 100644
--- a/erpnext/utilities/report/youtube_interactions/youtube_interactions.py
+++ b/erpnext/utilities/report/youtube_interactions/youtube_interactions.py
@@ -67,7 +67,7 @@
{
"value": total_views,
"indicator": "Blue",
- "label": "Total Views",
+ "label": _("Total Views"),
"datatype": "Float",
}
]
diff --git a/requirements.txt b/requirements.txt
index 39591ca..85ff515 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,6 @@
# frappe # https://github.com/frappe/frappe is installed during bench-init
gocardless-pro~=1.22.0
googlemaps
-pandas~=1.1.5
plaid-python~=7.2.1
pycountry~=20.7.3
PyGithub~=1.55
@@ -10,4 +9,4 @@
taxjar~=1.9.2
tweepy~=3.10.0
Unidecode~=1.2.0
-redisearch==2.0.0
\ No newline at end of file
+redisearch~=2.1.0